Compare commits
339 Commits
Author | SHA1 | Date |
---|---|---|
Morgan Pretty | 6a9ffdd22b | |
Morgan Pretty | f532496ee4 | |
Morgan Pretty | d8dc801e5f | |
Ryan ZHAO | 6d2e0b457e | |
Ryan ZHAO | e6cf75dd3f | |
Morgan Pretty | 109a81f33f | |
Morgan Pretty | 05460ca2b3 | |
Morgan Pretty | de7d85f4cb | |
Morgan Pretty | 89b38dc2f5 | |
Morgan Pretty | 638685a8cc | |
Morgan Pretty | e427e59544 | |
Morgan Pretty | aec2aed81f | |
Morgan Pretty | 4b9e15e5c1 | |
Morgan Pretty | bd98db2612 | |
Morgan Pretty | b3eb78aaee | |
Morgan Pretty | f97170fdcd | |
Morgan Pretty | 085a1a59aa | |
Morgan Pretty | 658240e549 | |
Morgan Pretty | 819106b0f2 | |
Morgan Pretty | 3a9ada581d | |
Morgan Pretty | 6d57523ede | |
Morgan Pretty | 06f12a58b0 | |
Morgan Pretty | 187902e48a | |
Morgan Pretty | c81616c145 | |
Morgan Pretty | 8346a2e610 | |
Morgan Pretty | edfe0c35be | |
Morgan Pretty | bd64d182f8 | |
Morgan Pretty | ed33e1f2e2 | |
Morgan Pretty | 2cff251e8d | |
Morgan Pretty | 74f32e9ea3 | |
Morgan Pretty | 9dd2e896bb | |
Morgan Pretty | 4a95b4c921 | |
Morgan Pretty | 42b49e0227 | |
Morgan Pretty | f9dc85b7ab | |
Morgan Pretty | 3dd626fa28 | |
Morgan Pretty | 0c5f3f2db9 | |
Morgan Pretty | 655fcb5808 | |
Morgan Pretty | 5917cf103f | |
Morgan Pretty | bfc5375a30 | |
Morgan Pretty | cf159bdd77 | |
Morgan Pretty | 34481b7f1d | |
Morgan Pretty | 1d0733baa7 | |
Morgan Pretty | 9411d803cb | |
Morgan Pretty | a6bd2676b0 | |
Morgan Pretty | b280c0a852 | |
Morgan Pretty | 7628edbb1c | |
Morgan Pretty | e6494d3432 | |
Morgan Pretty | c4aadaff1c | |
Morgan Pretty | 20ce1deb23 | |
Morgan Pretty | 323a7a1bce | |
Morgan Pretty | 134d257faf | |
Morgan Pretty | dbfeaef006 | |
Morgan Pretty | 8fee4edf34 | |
Morgan Pretty | 2310652407 | |
Morgan Pretty | 52836cff91 | |
Morgan Pretty | cdf918194a | |
Morgan Pretty | 4c934d2fda | |
Morgan Pretty | f92579db07 | |
Morgan Pretty | 8cbd318cca | |
Morgan Pretty | 8b1a4aaba0 | |
vlzuykov | b9a5e0befb | |
Ryan Zhao | 5f25abc213 | |
ryanzhao | 5088e394f2 | |
Morgan Pretty | 3cbe749d3c | |
Morgan Pretty | 67ab1e5194 | |
Morgan Pretty | 65acd79812 | |
Morgan Pretty | a2f1f36d2c | |
Morgan Pretty | fbae340bda | |
Morgan Pretty | b79de0cf32 | |
Morgan Pretty | ab610578e6 | |
Morgan Pretty | eaceabe217 | |
Morgan Pretty | 5bdfd0e93c | |
Morgan Pretty | cbc1ab437b | |
Morgan Pretty | c98af2386c | |
Morgan Pretty | 260e9b0a43 | |
Morgan Pretty | 862a6a8898 | |
Morgan Pretty | 45bee6fdf9 | |
Morgan Pretty | dc15586dd1 | |
Morgan Pretty | 1a10049f39 | |
Morgan Pretty | 7a8941db5c | |
Morgan Pretty | 6d990559b7 | |
Morgan Pretty | d71d07c430 | |
Morgan Pretty | f3b2cc577c | |
Morgan Pretty | dfdf843f66 | |
Morgan Pretty | 252e85fef9 | |
Morgan Pretty | 8e28726fa7 | |
Morgan Pretty | e6c26e7ff4 | |
Morgan Pretty | 42853a08c9 | |
Morgan Pretty | 968f50f2fc | |
Morgan Pretty | 382b466ded | |
Morgan Pretty | ef5aa927a0 | |
Morgan Pretty | 9c9fb09254 | |
Morgan Pretty | 26c6df78ab | |
Morgan Pretty | 32527d7e83 | |
Morgan Pretty | d863004e6d | |
Morgan Pretty | 9eb7a6af6d | |
Morgan Pretty | c63a9d3994 | |
Morgan Pretty | 5285d81177 | |
Morgan Pretty | 49f2d3bfe2 | |
Morgan Pretty | 1a383ea850 | |
Morgan Pretty | 0e952b40bb | |
Morgan Pretty | 87668d86a1 | |
Morgan Pretty | 18ee9d34fa | |
Morgan Pretty | 4d098914b2 | |
Morgan Pretty | 0ac7f7b339 | |
Morgan Pretty | ae0597a50f | |
Morgan Pretty | 3d755e7125 | |
Morgan Pretty | b04867705f | |
Morgan Pretty | a2c75465c1 | |
Morgan Pretty | b9512d8c4f | |
Morgan Pretty | eb3af31f0c | |
Morgan Pretty | c76b391d68 | |
Morgan Pretty | 76b37c2ad6 | |
Morgan Pretty | bf98199800 | |
Morgan Pretty | 66b94778e0 | |
Morgan Pretty | 635a5182bc | |
Morgan Pretty | 1b0fda56ad | |
Morgan Pretty | 2f05f3f3a2 | |
Morgan Pretty | 715a5b583f | |
Morgan Pretty | 2341fbf59f | |
Morgan Pretty | 00aef6ca97 | |
Morgan Pretty | 15104da58e | |
Morgan Pretty | c29827356c | |
Morgan Pretty | b471a32209 | |
Morgan Pretty | a41f1c1366 | |
ryanzhao | 5464d9c97a | |
RyanZhao | 19beff509b | |
Morgan Pretty | a3b3d2a485 | |
Morgan Pretty | 8a47adfe79 | |
Morgan Pretty | 6ad303d450 | |
Morgan Pretty | a3adb23bad | |
Morgan Pretty | 7aa3c9ff66 | |
Morgan Pretty | 8ac44b132d | |
Morgan Pretty | b12497e305 | |
Morgan Pretty | f373a989a8 | |
Morgan Pretty | e768bebe6d | |
Morgan Pretty | b7e77ea24b | |
Morgan Pretty | 6ba9d1df89 | |
Morgan Pretty | 703b1d9788 | |
Morgan Pretty | bc5d8d0931 | |
Morgan Pretty | f13f75eedf | |
Morgan Pretty | c86cc0ed9c | |
Morgan Pretty | 970efbc3be | |
Morgan Pretty | 6e32e759c5 | |
Morgan Pretty | 69ddb782a1 | |
Morgan Pretty | 9bdae9dee8 | |
Morgan Pretty | f15f16be70 | |
Morgan Pretty | b72bf42605 | |
Morgan Pretty | 2833cef5e4 | |
Morgan Pretty | c7f6b5a94e | |
Morgan Pretty | f623db678e | |
Morgan Pretty | f8b69cd03c | |
Morgan Pretty | 5bd0d5d640 | |
Morgan Pretty | 3c81e3a487 | |
Morgan Pretty | 8de4a66865 | |
Morgan Pretty | c8c70c448e | |
Morgan Pretty | 0464439e8d | |
Morgan Pretty | 7b06329454 | |
Morgan Pretty | 43b2aaf8bb | |
Morgan Pretty | 868b4cc24e | |
Morgan Pretty | 38420997b0 | |
Arshak Aghakaryan | 57dbad7e2e | |
Arshak Aghakaryan | fbdb1ad690 | |
Morgan Pretty | a5306f85b7 | |
Morgan Pretty | 0f52d358d4 | |
Morgan Pretty | 6f4bdcdccb | |
Morgan Pretty | a7761697a9 | |
Morgan Pretty | 0225f436bd | |
Morgan Pretty | 3151aa8901 | |
Morgan Pretty | f976d85c27 | |
Morgan Pretty | f45568644e | |
Morgan Pretty | 7b70f8d535 | |
Morgan Pretty | 30779bdf5b | |
Morgan Pretty | eac17678fc | |
Morgan Pretty | 1ed86d483e | |
Morgan Pretty | ec81236615 | |
Morgan Pretty | d0be7f786c | |
Morgan Pretty | 5e2e103ee1 | |
Morgan Pretty | 6cf7cc42ab | |
Morgan Pretty | b6328f79b9 | |
Morgan Pretty | 244fe9d7ae | |
Morgan Pretty | d8ae9669c8 | |
Morgan Pretty | 53a5db0ea5 | |
Morgan Pretty | 0a638bf37b | |
Morgan Pretty | c7d090251a | |
Morgan Pretty | 5db254303a | |
Morgan Pretty | 01d77a515c | |
Morgan Pretty | b3cad3e709 | |
Morgan Pretty | 54fc75cd85 | |
Morgan Pretty | f07313c7ac | |
Morgan Pretty | d2c82cb915 | |
Morgan Pretty | 65bf7d7d82 | |
Morgan Pretty | 44f8b7f59d | |
Morgan Pretty | 1ba060b7f0 | |
Morgan Pretty | 44469d9078 | |
Kee Jefferys | 9f3d9cf7ab | |
Morgan Pretty | 613dbb4afa | |
Morgan Pretty | 6209f2b5c1 | |
Morgan Pretty | 5760c23cbc | |
Morgan Pretty | c455a13a7b | |
Morgan Pretty | 3b772b7f90 | |
Morgan Pretty | 9794877692 | |
Morgan Pretty | 0cfd87ee27 | |
Morgan Pretty | fd2794cbc2 | |
Morgan Pretty | c1553aca41 | |
Morgan Pretty | 5d88db7a8a | |
Morgan Pretty | 6fcfffafe7 | |
Morgan Pretty | 09ab977861 | |
Morgan Pretty | 8f2e09d125 | |
Morgan Pretty | 4419d31077 | |
Morgan Pretty | 41ba692a03 | |
Morgan Pretty | 22303f2458 | |
Morgan Pretty | cf2e198a64 | |
Morgan Pretty | 2d792e4e3e | |
Morgan Pretty | 2bb68ccbcf | |
Morgan Pretty | fc94d24ddf | |
Morgan Pretty | b277056a62 | |
Morgan Pretty | a532976333 | |
Morgan Pretty | 9c2ec47557 | |
Morgan Pretty | 44824c8127 | |
Morgan Pretty | 77b6faccb3 | |
Morgan Pretty | 3f362a71f3 | |
Morgan Pretty | 37894175e5 | |
Ryan Zhao | ffb3f0dd90 | |
Ryan Zhao | 66d7226d80 | |
Morgan Pretty | 61ad85b97b | |
Morgan Pretty | 4330a40f6f | |
ryanzhao | e2384e464f | |
Morgan Pretty | 19eddd79a1 | |
Morgan Pretty | 2053b6b0cd | |
Morgan Pretty | 9799297e15 | |
Morgan Pretty | 534343f8b0 | |
Morgan Pretty | db67e36acb | |
Morgan Pretty | 83dad170a6 | |
Morgan Pretty | 2035d508d9 | |
Morgan Pretty | 7ecb645454 | |
Morgan Pretty | 03d879804f | |
Morgan Pretty | 6be759d39c | |
Morgan Pretty | c5a9c92a92 | |
Morgan Pretty | cd5dd1e3fa | |
Morgan Pretty | e061bb02cd | |
Morgan Pretty | ff2a48d94d | |
Morgan Pretty | 4455af9771 | |
Ryan Zhao | c69c7fc63f | |
Morgan Pretty | d19ed8599f | |
Morgan Pretty | c134acdc90 | |
Morgan Pretty | 9c8653aa21 | |
Morgan Pretty | 5b5f4a4e88 | |
Morgan Pretty | 977c2051ed | |
Olivier Bouchet | e6d5e62693 | |
Ryan Zhao | f855d89af2 | |
Ryan Zhao | 249ccab466 | |
Morgan Pretty | 6685dc0572 | |
Morgan Pretty | a9afb2d1d1 | |
Morgan Pretty | 2983086837 | |
Morgan Pretty | 56164ab7f4 | |
Morgan Pretty | 8cf2cef050 | |
Morgan Pretty | 0e1dea8f62 | |
Morgan Pretty | 6fd574916b | |
Morgan Pretty | 50b349ab43 | |
Morgan Pretty | 4dfe243965 | |
Morgan Pretty | 11b4c82bc3 | |
Morgan Pretty | 2d487101ec | |
Morgan Pretty | 6832b788d9 | |
Morgan Pretty | 6f79d83be0 | |
Morgan Pretty | 14f78e584d | |
Morgan Pretty | 08853e7333 | |
Morgan Pretty | 4bda9691a8 | |
Morgan Pretty | 27e494dcb5 | |
Morgan Pretty | f63d7c21fa | |
Morgan Pretty | 61b30c8858 | |
Morgan Pretty | 39b005172b | |
Morgan Pretty | 55c9160f4d | |
Morgan Pretty | 1d85fa35bd | |
Morgan Pretty | 16bfbd4bad | |
Morgan Pretty | 982e25d61d | |
Morgan Pretty | ad3e53d235 | |
Morgan Pretty | 19856c7ef5 | |
Morgan Pretty | 8c8453d922 | |
Morgan Pretty | fa39b5f61c | |
Morgan Pretty | f4d6babca2 | |
Morgan Pretty | 4801ebd7c2 | |
Morgan Pretty | ffdc59b704 | |
Morgan Pretty | cd00975e56 | |
Morgan Pretty | 3c526645a0 | |
Morgan Pretty | a7af1ca768 | |
Morgan Pretty | c80b6c720e | |
Morgan Pretty | fde34a6c45 | |
Morgan Pretty | 5fdfd6df3b | |
Morgan Pretty | 1c7eaed8b6 | |
Morgan Pretty | 15a0eccaf2 | |
Morgan Pretty | 4012f91777 | |
Morgan Pretty | a8c4c3eb76 | |
Morgan Pretty | 9ddfbefd72 | |
Morgan Pretty | 1334a64031 | |
Morgan Pretty | 8f39fe6972 | |
Morgan Pretty | a6699f0c58 | |
Morgan Pretty | d463a459a2 | |
Morgan Pretty | 66fd2d4ff8 | |
Morgan Pretty | 972519d7d9 | |
Morgan Pretty | 7d47a36f9f | |
Morgan Pretty | 499b20db6d | |
Morgan Pretty | e28b4b4531 | |
Morgan Pretty | 7ee84fe0d3 | |
Morgan Pretty | 8eed08b5b4 | |
Morgan Pretty | ff36b3eeab | |
Morgan Pretty | 742c4a161f | |
Morgan Pretty | c8a199a8ba | |
Morgan Pretty | f30b383bb8 | |
Morgan Pretty | 345b693225 | |
Morgan Pretty | 07046db4b6 | |
Morgan Pretty | 4f8fb63f2c | |
Morgan Pretty | 349dc03e17 | |
Morgan Pretty | 0abb09c0cf | |
Morgan Pretty | af9a135c08 | |
Morgan Pretty | 29ba25916c | |
Morgan Pretty | 4d487f2686 | |
Morgan Pretty | e3a080dd5b | |
Morgan Pretty | 14174e3fbd | |
Morgan Pretty | 8f3dcbc6be | |
Morgan Pretty | 893967e380 | |
Morgan Pretty | 22130f734e | |
Morgan Pretty | 174725c7fd | |
Morgan Pretty | f1e9412c7a | |
Morgan Pretty | 70ff2b49f0 | |
Morgan Pretty | ca4ce52402 | |
Morgan Pretty | 5033738994 | |
Morgan Pretty | 6970ff22cc | |
Morgan Pretty | 8b37002d89 | |
Morgan Pretty | 8ac6b25ced | |
Morgan Pretty | c9fdee9f24 | |
Morgan Pretty | f5933bdf75 | |
Morgan Pretty | 1345e89809 | |
Morgan Pretty | f12191f85e | |
Morgan Pretty | ba33d2c95e | |
Morgan Pretty | f721178b49 | |
Morgan Pretty | d03d2ce8ab | |
Morgan Pretty | a1e09d830f | |
Morgan Pretty | edf3bde573 |
|
@ -0,0 +1,150 @@
|
|||
// 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 || rm -rf ./Pods && 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: [
|
||||
|||
|
||||
LOOP_BREAK=0
|
||||
while test -e /Users/drone/.cocoapods_cache.lock; do
|
||||
sleep 1
|
||||
LOOP_BREAK=$((LOOP_BREAK + 1))
|
||||
|
||||
if [[ $LOOP_BREAK -ge 600 ]]; then
|
||||
rm -f /Users/drone/.cocoapods_cache.lock
|
||||
fi
|
||||
done
|
||||
|||,
|
||||
'touch /Users/drone/.cocoapods_cache.lock',
|
||||
|||
|
||||
if [[ -d /Users/drone/.cocoapods_cache ]]; then
|
||||
cp -r /Users/drone/.cocoapods_cache ./Pods
|
||||
fi
|
||||
|||,
|
||||
'rm -f /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: [
|
||||
|||
|
||||
LOOP_BREAK=0
|
||||
while test -e /Users/drone/.cocoapods_cache.lock; do
|
||||
sleep 1
|
||||
LOOP_BREAK=$((LOOP_BREAK + 1))
|
||||
|
||||
if [[ $LOOP_BREAK -ge 600 ]]; then
|
||||
rm -f /Users/drone/.cocoapods_cache.lock
|
||||
fi
|
||||
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 -f /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 -derivedDataPath ./build/derivedData -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' },
|
||||
trigger: { event: { exclude: [ 'pull_request' ] } },
|
||||
steps: [
|
||||
clone_submodules,
|
||||
load_cocoapods_cache,
|
||||
install_cocoapods,
|
||||
{
|
||||
name: 'Build',
|
||||
commands: [
|
||||
'mkdir build',
|
||||
'xcodebuild archive -workspace Session.xcworkspace -scheme Session -derivedDataPath ./build/derivedData -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' },
|
||||
trigger: { event: { exclude: [ 'pull_request' ] } },
|
||||
steps: [
|
||||
clone_submodules,
|
||||
load_cocoapods_cache,
|
||||
install_cocoapods,
|
||||
{
|
||||
name: 'Build',
|
||||
commands: [
|
||||
'mkdir build',
|
||||
'xcodebuild archive -workspace Session.xcworkspace -scheme Session -derivedDataPath ./build/derivedData -configuration "App Store Release" -sdk iphoneos -archivePath ./build/Session.xcarchive -destination "generic/platform=iOS" -allowProvisioningUpdates CODE_SIGNING_ALLOWED=NO | ./Pods/xcbeautify/xcbeautify --is-ci'
|
||||
],
|
||||
},
|
||||
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 e3ccf29db08aaf0b9bb6bbe72ae5967cd183a78d
|
50
Podfile
50
Podfile
|
@ -1,30 +1,31 @@
|
|||
platform :ios, '13.0'
|
||||
source 'https://github.com/CocoaPods/Specs.git'
|
||||
|
||||
use_frameworks!
|
||||
inhibit_all_warnings!
|
||||
|
||||
install! 'cocoapods', :warn_for_unused_master_specs_repo => false
|
||||
|
||||
# CI Dependencies
|
||||
pod 'xcbeautify'
|
||||
|
||||
# Dependencies to be included in the app and all extensions/frameworks
|
||||
abstract_target 'GlobalDependencies' do
|
||||
pod 'PromiseKit'
|
||||
pod 'CryptoSwift'
|
||||
# FIXME: If https://github.com/jedisct1/swift-sodium/pull/249 gets resolved then revert this back to the standard pod
|
||||
pod 'Sodium', :git => 'https://github.com/oxen-io/session-ios-swift-sodium.git', branch: 'session-build'
|
||||
pod 'GRDB.swift/SQLCipher'
|
||||
|
||||
# FIXME: Would be nice to migrate from CocoaPods to SwiftPackageManager (should allow us to speed up build time), haven't gone through all of the dependencies but currently unfortunately SQLCipher doesn't support SPM (for more info see: https://github.com/sqlcipher/sqlcipher/issues/371)
|
||||
pod 'SQLCipher', '~> 4.5.3'
|
||||
|
||||
# FIXME: We want to remove this once it's been long enough since the migration to GRDB
|
||||
pod 'YapDatabase/SQLCipher', :git => 'https://github.com/oxen-io/session-ios-yap-database.git', branch: 'signal-release'
|
||||
pod 'WebRTC-lib'
|
||||
pod 'SocketRocket', '~> 0.5.1'
|
||||
|
||||
target 'Session' do
|
||||
pod 'AFNetworking'
|
||||
pod 'Reachability'
|
||||
pod 'PureLayout', '~> 3.1.8'
|
||||
pod 'NVActivityIndicatorView'
|
||||
pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
|
||||
pod 'ZXingObjC'
|
||||
pod 'DifferenceKit'
|
||||
|
||||
target 'SessionTests' do
|
||||
|
@ -45,7 +46,6 @@ abstract_target 'GlobalDependencies' do
|
|||
|
||||
# Dependencies that are shared across a number of extensions/frameworks but not all
|
||||
abstract_target 'ExtendedDependencies' do
|
||||
pod 'AFNetworking'
|
||||
pod 'PureLayout', '~> 3.1.8'
|
||||
|
||||
target 'SessionShareExtension' do
|
||||
|
@ -97,28 +97,13 @@ abstract_target 'GlobalDependencies' do
|
|||
target 'SessionUIKit' do
|
||||
pod 'GRDB.swift/SQLCipher'
|
||||
pod 'DifferenceKit'
|
||||
pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
|
||||
end
|
||||
end
|
||||
|
||||
# Actions to perform post-install
|
||||
post_install do |installer|
|
||||
enable_whole_module_optimization_for_crypto_swift(installer)
|
||||
set_minimum_deployment_target(installer)
|
||||
enable_fts5_support(installer)
|
||||
|
||||
#FIXME: Remove this workaround once an official fix is released (hopefully Cocoapods 1.12.1)
|
||||
xcode_14_3_workaround(installer)
|
||||
end
|
||||
|
||||
def enable_whole_module_optimization_for_crypto_swift(installer)
|
||||
installer.pods_project.targets.each do |target|
|
||||
if target.name.end_with? "CryptoSwift"
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['GCC_OPTIMIZATION_LEVEL'] = 'fast'
|
||||
config.build_settings['SWIFT_OPTIMIZATION_LEVEL'] = '-O'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def set_minimum_deployment_target(installer)
|
||||
|
@ -128,22 +113,3 @@ def set_minimum_deployment_target(installer)
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
# This is to ensure we enable support for FastTextSearch5 (might not be enabled by default)
|
||||
# For more info see https://github.com/groue/GRDB.swift/blob/master/Documentation/FullTextSearch.md#enabling-fts5-support
|
||||
def enable_fts5_support(installer)
|
||||
installer.pods_project.targets.select { |target| target.name == "GRDB.swift" }.each do |target|
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['OTHER_SWIFT_FLAGS'] = "$(inherited) -D SQLITE_ENABLE_FTS5"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Workaround for Xcode 14.3:
|
||||
# Sourced from https://github.com/flutter/flutter/issues/123852#issuecomment-1493232105
|
||||
def xcode_14_3_workaround(installer)
|
||||
system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests-frameworks.sh\'')
|
||||
system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests-frameworks.sh\'')
|
||||
system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session-frameworks.sh\'')
|
||||
system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-Session-SessionTests/Pods-GlobalDependencies-Session-SessionTests-frameworks.sh\'')
|
||||
end
|
||||
|
|
94
Podfile.lock
94
Podfile.lock
|
@ -1,23 +1,7 @@
|
|||
PODS:
|
||||
- AFNetworking (4.0.1):
|
||||
- AFNetworking/NSURLSession (= 4.0.1)
|
||||
- AFNetworking/Reachability (= 4.0.1)
|
||||
- AFNetworking/Security (= 4.0.1)
|
||||
- AFNetworking/Serialization (= 4.0.1)
|
||||
- AFNetworking/UIKit (= 4.0.1)
|
||||
- AFNetworking/NSURLSession (4.0.1):
|
||||
- AFNetworking/Reachability
|
||||
- AFNetworking/Security
|
||||
- AFNetworking/Serialization
|
||||
- AFNetworking/Reachability (4.0.1)
|
||||
- AFNetworking/Security (4.0.1)
|
||||
- AFNetworking/Serialization (4.0.1)
|
||||
- AFNetworking/UIKit (4.0.1):
|
||||
- AFNetworking/NSURLSession
|
||||
- CocoaLumberjack (3.8.0):
|
||||
- CocoaLumberjack/Core (= 3.8.0)
|
||||
- CocoaLumberjack/Core (3.8.0)
|
||||
- CryptoSwift (1.4.2)
|
||||
- Curve25519Kit (2.1.0):
|
||||
- CocoaLumberjack
|
||||
- SignalCoreKit
|
||||
|
@ -27,39 +11,32 @@ PODS:
|
|||
- DifferenceKit/Core (1.3.0)
|
||||
- DifferenceKit/UIKitExtension (1.3.0):
|
||||
- DifferenceKit/Core
|
||||
- GRDB.swift/SQLCipher (6.10.1):
|
||||
- GRDB.swift/SQLCipher (6.13.0):
|
||||
- SQLCipher (>= 3.4.2)
|
||||
- libwebp (1.2.1):
|
||||
- libwebp/demux (= 1.2.1)
|
||||
- libwebp/mux (= 1.2.1)
|
||||
- libwebp/webp (= 1.2.1)
|
||||
- libwebp/demux (1.2.1):
|
||||
- libwebp (1.3.2):
|
||||
- libwebp/demux (= 1.3.2)
|
||||
- libwebp/mux (= 1.3.2)
|
||||
- libwebp/sharpyuv (= 1.3.2)
|
||||
- libwebp/webp (= 1.3.2)
|
||||
- libwebp/demux (1.3.2):
|
||||
- libwebp/webp
|
||||
- libwebp/mux (1.2.1):
|
||||
- libwebp/mux (1.3.2):
|
||||
- libwebp/demux
|
||||
- libwebp/webp (1.2.1)
|
||||
- Nimble (10.0.0)
|
||||
- libwebp/sharpyuv (1.3.2)
|
||||
- libwebp/webp (1.3.2):
|
||||
- libwebp/sharpyuv
|
||||
- Nimble (12.3.0)
|
||||
- NVActivityIndicatorView (5.1.1):
|
||||
- NVActivityIndicatorView/Base (= 5.1.1)
|
||||
- NVActivityIndicatorView/Base (5.1.1)
|
||||
- OpenSSL-Universal (1.1.1300)
|
||||
- PromiseKit (6.15.3):
|
||||
- PromiseKit/CorePromise (= 6.15.3)
|
||||
- PromiseKit/Foundation (= 6.15.3)
|
||||
- PromiseKit/UIKit (= 6.15.3)
|
||||
- PromiseKit/CorePromise (6.15.3)
|
||||
- PromiseKit/Foundation (6.15.3):
|
||||
- PromiseKit/CorePromise
|
||||
- PromiseKit/UIKit (6.15.3):
|
||||
- PromiseKit/CorePromise
|
||||
- PureLayout (3.1.9)
|
||||
- Quick (5.0.1)
|
||||
- Quick (7.3.0)
|
||||
- Reachability (3.2)
|
||||
- SAMKeychain (1.5.3)
|
||||
- SignalCoreKit (1.0.0):
|
||||
- CocoaLumberjack
|
||||
- OpenSSL-Universal
|
||||
- SocketRocket (0.5.1)
|
||||
- Sodium (0.9.1)
|
||||
- SQLCipher (4.5.3):
|
||||
- SQLCipher/standard (= 4.5.3)
|
||||
|
@ -67,7 +44,8 @@ PODS:
|
|||
- SQLCipher/standard (4.5.3):
|
||||
- SQLCipher/common
|
||||
- SwiftProtobuf (1.5.0)
|
||||
- WebRTC-lib (96.0.0)
|
||||
- WebRTC-lib (114.0.0)
|
||||
- xcbeautify (0.17.0)
|
||||
- YapDatabase/SQLCipher (3.1.1):
|
||||
- YapDatabase/SQLCipher/Core (= 3.1.1)
|
||||
- YapDatabase/SQLCipher/Extensions (= 3.1.1)
|
||||
|
@ -134,54 +112,44 @@ PODS:
|
|||
- YYImage/libwebp (1.0.4):
|
||||
- libwebp
|
||||
- YYImage/Core
|
||||
- ZXingObjC (3.6.5):
|
||||
- ZXingObjC/All (= 3.6.5)
|
||||
- ZXingObjC/All (3.6.5)
|
||||
|
||||
DEPENDENCIES:
|
||||
- AFNetworking
|
||||
- CryptoSwift
|
||||
- Curve25519Kit (from `https://github.com/oxen-io/session-ios-curve-25519-kit.git`, branch `session-version`)
|
||||
- DifferenceKit
|
||||
- GRDB.swift/SQLCipher
|
||||
- Nimble
|
||||
- NVActivityIndicatorView
|
||||
- PromiseKit
|
||||
- PureLayout (~> 3.1.8)
|
||||
- Quick
|
||||
- Reachability
|
||||
- SAMKeychain
|
||||
- SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`)
|
||||
- SocketRocket (~> 0.5.1)
|
||||
- Sodium (from `https://github.com/oxen-io/session-ios-swift-sodium.git`, branch `session-build`)
|
||||
- SQLCipher (~> 4.5.3)
|
||||
- SwiftProtobuf (~> 1.5.0)
|
||||
- WebRTC-lib
|
||||
- xcbeautify
|
||||
- YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`)
|
||||
- YYImage/libwebp (from `https://github.com/signalapp/YYImage`)
|
||||
- ZXingObjC
|
||||
|
||||
SPEC REPOS:
|
||||
https://github.com/CocoaPods/Specs.git:
|
||||
- AFNetworking
|
||||
- CocoaLumberjack
|
||||
- CryptoSwift
|
||||
- DifferenceKit
|
||||
- GRDB.swift
|
||||
- libwebp
|
||||
- Nimble
|
||||
- NVActivityIndicatorView
|
||||
- OpenSSL-Universal
|
||||
- PromiseKit
|
||||
- PureLayout
|
||||
- Quick
|
||||
- Reachability
|
||||
- SAMKeychain
|
||||
- SocketRocket
|
||||
- SQLCipher
|
||||
- SwiftProtobuf
|
||||
- WebRTC-lib
|
||||
- ZXingObjC
|
||||
trunk:
|
||||
- libwebp
|
||||
- Nimble
|
||||
- Quick
|
||||
- xcbeautify
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Curve25519Kit:
|
||||
|
@ -217,31 +185,27 @@ CHECKOUT OPTIONS:
|
|||
:git: https://github.com/signalapp/YYImage
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
AFNetworking: 3bd23d814e976cd148d7d44c3ab78017b744cd58
|
||||
CocoaLumberjack: 78abfb691154e2a9df8ded4350d504ee19d90732
|
||||
CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17
|
||||
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
|
||||
DifferenceKit: ab185c4d7f9cef8af3fcf593e5b387fb81e999ca
|
||||
GRDB.swift: 1cc67278f1a9878d6eb1b849485518112b79cab7
|
||||
libwebp: 98a37e597e40bfdb4c911fc98f2c53d0b12d05fc
|
||||
Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84
|
||||
GRDB.swift: fe420b1af49ec519c7e96e07887ee44f5dfa2b78
|
||||
libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009
|
||||
Nimble: f8a8219d16f176429b951e8f7e72df5c23ceddc0
|
||||
NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667
|
||||
OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2
|
||||
PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5
|
||||
PureLayout: 5fb5e5429519627d60d079ccb1eaa7265ce7cf88
|
||||
Quick: 749aa754fd1e7d984f2000fe051e18a3a9809179
|
||||
Quick: d32871931c05547cb4e0bc9009d66a18b50d8558
|
||||
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d
|
||||
SocketRocket: d57c7159b83c3c6655745cd15302aa24b6bae531
|
||||
Sodium: a7d42cb46e789d2630fa552d35870b416ed055ae
|
||||
SQLCipher: 57fa9f863fa4a3ed9dd3c90ace52315db8c0fdca
|
||||
SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2
|
||||
WebRTC-lib: 508fe02efa0c1a3a8867082a77d24c9be5d29aeb
|
||||
WebRTC-lib: d83df8976fa608b980f1d85796b3de66d60a1953
|
||||
xcbeautify: 6e2f57af5c3a86d490376d5758030a8dcc201c1b
|
||||
YapDatabase: b418a4baa6906e8028748938f9159807fd039af4
|
||||
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
|
||||
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
||||
|
||||
PODFILE CHECKSUM: e9443a8235dbff1fc342aa9bf08bbc66923adf68
|
||||
PODFILE CHECKSUM: dd814a5a92577bb2a94dac6a1cc482f193721cdf
|
||||
|
||||
COCOAPODS: 1.11.3
|
||||
COCOAPODS: 1.12.1
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
Session integrates directly with [Oxen Service Nodes](https://docs.oxen.io/about-the-oxen-blockchain/oxen-service-nodes), which are a set of distributed, decentralized and Sybil resistant nodes. Service Nodes act as servers which store messages, and a set of nodes which allow for onion routing functionality obfuscating users' IP addresses. For a full understanding of how Session works, read the [Session Whitepaper](https://getsession.org/whitepaper).
|
||||
|
||||
<img src="https://i.imgur.com/SocRFTh.jpg" width="320" />
|
||||
<img src="https://i.imgur.com/Ioub5bx.png" width="320" />
|
||||
|
||||
## Want to contribute? Found a bug or have a feature request?
|
||||
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
//
|
||||
// stringlint:disable
|
||||
|
||||
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,5 +1,11 @@
|
|||
#!/usr/bin/env xcrun --sdk macosx swift
|
||||
|
||||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
//
|
||||
// This script is used to generate/update the set of Emoji used for reactions
|
||||
//
|
||||
// stringlint:disable
|
||||
|
||||
import Foundation
|
||||
|
||||
// OWSAssertionError but for this script
|
||||
|
@ -250,6 +256,7 @@ extension EmojiGenerator {
|
|||
// e.g. case grinning = "😀"
|
||||
writeBlock(fileName: "Emoji.swift") { fileHandle in
|
||||
fileHandle.writeLine("// swiftlint:disable all")
|
||||
fileHandle.writeLine("// stringlint:disable")
|
||||
fileHandle.writeLine("")
|
||||
fileHandle.writeLine("/// A sorted representation of all available emoji")
|
||||
fileHandle.writeLine("enum Emoji: String, CaseIterable, Equatable {")
|
||||
|
@ -263,61 +270,186 @@ extension EmojiGenerator {
|
|||
}
|
||||
}
|
||||
|
||||
static func writeStringConversionsFile(from emojiModel: EmojiModel) {
|
||||
// Inline helpers:
|
||||
var firstItem = true
|
||||
func conditionalCheckForEmojiItem(_ item: EmojiModel.EmojiDefinition.Emoji) -> String {
|
||||
let isFirst = (firstItem == true)
|
||||
firstItem = false
|
||||
|
||||
let prefix = isFirst ? "" : "} else "
|
||||
let suffix = "if rawValue == \"\(item.emojiChar)\" {"
|
||||
return prefix + suffix
|
||||
}
|
||||
func conversionForEmojiItem(_ item: EmojiModel.EmojiDefinition.Emoji, definition: EmojiModel.EmojiDefinition) -> String {
|
||||
let skinToneString: String
|
||||
if item.skintoneSequence.isEmpty {
|
||||
skinToneString = "nil"
|
||||
} else {
|
||||
skinToneString = "[\(item.skintoneSequence.map { ".\($0)" }.joined(separator: ", "))]"
|
||||
indirect enum Structure {
|
||||
enum ChunkType {
|
||||
case firstScalar
|
||||
case scalarSum
|
||||
|
||||
func chunk(_ character: Character, into size: UInt32) -> UInt32 {
|
||||
guard size > 0 else { return 0 }
|
||||
|
||||
let scalarValues: [UInt32] = character.unicodeScalars.map { $0.value }
|
||||
|
||||
switch self {
|
||||
case .firstScalar: return (scalarValues.first.map { $0 / size } ?? 0)
|
||||
case .scalarSum: return (scalarValues.reduce(0, +) / size)
|
||||
}
|
||||
}
|
||||
|
||||
func switchString(with variableName: String = "rawValue", size: UInt32) -> String {
|
||||
switch self {
|
||||
case .firstScalar: return "rawValue.unicodeScalars.map({ $0.value }).first.map({ $0 / \(size) })"
|
||||
case .scalarSum: return "(rawValue.unicodeScalars.map({ $0.value }).reduce(0, +) / \(size))"
|
||||
}
|
||||
}
|
||||
return "self.init(baseEmoji: .\(definition.enumName), skinTones: \(skinToneString))"
|
||||
}
|
||||
|
||||
case ifElse // XCode 15 taking over 10 min with M1 Pro (gave up)
|
||||
case switchStatement // XCode 15 taking over 10 min with M1 Pro (gave up)
|
||||
case directLookup // XCode 15 taking 93 sec with M1 Pro
|
||||
case chunked(UInt32, Structure, ChunkType) // XCode 15 taking <10 sec with M1 Pro (chunk by 100)
|
||||
}
|
||||
typealias ChunkedEmojiInfo = (
|
||||
variant: EmojiModel.EmojiDefinition.Emoji,
|
||||
baseName: String
|
||||
)
|
||||
|
||||
static func writeStringConversionsFile(from emojiModel: EmojiModel) {
|
||||
// This combination seems to have the smallest compile time (~2.2 sec out of all of the combinations)
|
||||
let desiredStructure: Structure = .chunked(100, .directLookup, .scalarSum)
|
||||
|
||||
// Conversion from String: Creates an initializer mapping a single character emoji string to an EmojiWithSkinTones
|
||||
// e.g.
|
||||
// if rawValue == "😀" { self.init(baseEmoji: .grinning, skinTones: nil) }
|
||||
// else if rawValue == "🦻🏻" { self.init(baseEmoji: .earWithHearingAid, skinTones: [.light])
|
||||
writeBlock(fileName: "EmojiWithSkinTones+String.swift") { fileHandle in
|
||||
fileHandle.writeLine("// swiftlint:disable all")
|
||||
fileHandle.writeLine("// stringlint:disable")
|
||||
fileHandle.writeLine("")
|
||||
fileHandle.writeLine("extension EmojiWithSkinTones {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("init?(rawValue: String) {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("guard rawValue.isSingleEmoji else { return nil }")
|
||||
|
||||
emojiModel.definitions.forEach { definition in
|
||||
definition.variants.forEach { emoji in
|
||||
fileHandle.writeLine(conditionalCheckForEmojiItem(emoji))
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine(conversionForEmojiItem(emoji, definition: definition))
|
||||
switch desiredStructure {
|
||||
case .chunked(let chunkSize, let childStructure, let chunkType):
|
||||
let chunkedEmojiInfo = emojiModel.definitions
|
||||
.reduce(into: [UInt32: [ChunkedEmojiInfo]]()) { result, next in
|
||||
next.variants.forEach { emoji in
|
||||
let chunk: UInt32 = chunkType.chunk(emoji.emojiChar, into: chunkSize)
|
||||
result[chunk] = ((result[chunk] ?? []) + [(emoji, next.enumName)])
|
||||
.sorted { lhs, rhs in lhs.variant < rhs.variant }
|
||||
}
|
||||
}
|
||||
.sorted { lhs, rhs in lhs.key < rhs.key }
|
||||
|
||||
fileHandle.writeLine("init?(rawValue: String) {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("guard rawValue.isSingleEmoji else { return nil }")
|
||||
fileHandle.writeLine("switch \(chunkType.switchString(size: chunkSize)) {")
|
||||
fileHandle.indent {
|
||||
chunkedEmojiInfo.forEach { chunk, _ in
|
||||
fileHandle.writeLine("case \(chunk): self = EmojiWithSkinTones.emojiFrom\(chunk)(rawValue)")
|
||||
}
|
||||
fileHandle.writeLine("default: self = EmojiWithSkinTones(unsupportedValue: rawValue)")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
}
|
||||
|
||||
fileHandle.writeLine("} else {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("self.init(unsupportedValue: rawValue)")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
fileHandle.writeLine("}")
|
||||
|
||||
chunkedEmojiInfo.forEach { chunk, emojiInfo in
|
||||
fileHandle.writeLine("")
|
||||
fileHandle.writeLine("private static func emojiFrom\(chunk)(_ rawValue: String) -> EmojiWithSkinTones {")
|
||||
fileHandle.indent {
|
||||
switch emojiInfo.count {
|
||||
case 0:
|
||||
fileHandle.writeLine("return EmojiWithSkinTones(unsupportedValue: rawValue)")
|
||||
|
||||
default:
|
||||
writeStructure(
|
||||
childStructure,
|
||||
for: emojiInfo,
|
||||
using: fileHandle,
|
||||
assignmentPrefix: "return "
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
|
||||
default:
|
||||
fileHandle.writeLine("init?(rawValue: String) {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("guard rawValue.isSingleEmoji else { return nil }")
|
||||
writeStructure(
|
||||
desiredStructure,
|
||||
for: emojiModel.definitions
|
||||
.flatMap { definition in
|
||||
definition.variants.map { ($0, definition.enumName) }
|
||||
},
|
||||
using: fileHandle
|
||||
)
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
fileHandle.writeLine("// swiftlint:disable all")
|
||||
}
|
||||
}
|
||||
|
||||
private static func writeStructure(
|
||||
_ structure: Structure,
|
||||
for emojiInfo: [ChunkedEmojiInfo],
|
||||
using fileHandle: WriteHandle,
|
||||
assignmentPrefix: String = "self = "
|
||||
) {
|
||||
func initItem(_ info: ChunkedEmojiInfo) -> String {
|
||||
let skinToneString: String = {
|
||||
guard !info.variant.skintoneSequence.isEmpty else { return "nil" }
|
||||
return "[\(info.variant.skintoneSequence.map { ".\($0)" }.joined(separator: ", "))]"
|
||||
}()
|
||||
|
||||
return "EmojiWithSkinTones(baseEmoji: .\(info.baseName), skinTones: \(skinToneString))"
|
||||
}
|
||||
|
||||
switch structure {
|
||||
case .ifElse:
|
||||
emojiInfo.enumerated().forEach { index, info in
|
||||
switch index {
|
||||
case 0: fileHandle.writeLine("if rawValue == \"\(info.variant.emojiChar)\" {")
|
||||
default: fileHandle.writeLine("} else if rawValue == \"\(info.variant.emojiChar)\" {")
|
||||
}
|
||||
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("\(assignmentPrefix)\(initItem(info))")
|
||||
}
|
||||
}
|
||||
|
||||
fileHandle.writeLine("} else {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("\(assignmentPrefix)EmojiWithSkinTones(unsupportedValue: rawValue)")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
|
||||
case .switchStatement:
|
||||
fileHandle.writeLine("switch rawValue {")
|
||||
fileHandle.indent {
|
||||
emojiInfo.forEach { info in
|
||||
fileHandle.writeLine("case \"\(info.variant.emojiChar)\": \(assignmentPrefix)\(initItem(info))")
|
||||
}
|
||||
fileHandle.writeLine("default: \(assignmentPrefix)EmojiWithSkinTones(unsupportedValue: rawValue)")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
|
||||
case .directLookup:
|
||||
fileHandle.writeLine("let lookup: [String: EmojiWithSkinTones] = [")
|
||||
fileHandle.indent {
|
||||
emojiInfo.enumerated().forEach { index, info in
|
||||
let isLast: Bool = (index == (emojiInfo.count - 1))
|
||||
fileHandle.writeLine("\"\(info.variant.emojiChar)\": \(initItem(info))\(isLast ? "" : ",")")
|
||||
}
|
||||
}
|
||||
fileHandle.writeLine("]")
|
||||
fileHandle.writeLine("\(assignmentPrefix)(lookup[rawValue] ?? EmojiWithSkinTones(unsupportedValue: rawValue))")
|
||||
|
||||
case .chunked: break // Provide one of the other types
|
||||
}
|
||||
}
|
||||
|
||||
static func writeSkinToneLookupFile(from emojiModel: EmojiModel) {
|
||||
writeBlock(fileName: "Emoji+SkinTones.swift") { fileHandle in
|
||||
fileHandle.writeLine("// swiftlint:disable all")
|
||||
fileHandle.writeLine("// stringlint:disable")
|
||||
fileHandle.writeLine("")
|
||||
fileHandle.writeLine("extension Emoji {")
|
||||
fileHandle.indent {
|
||||
// SkinTone enum
|
||||
|
@ -380,6 +512,7 @@ extension EmojiGenerator {
|
|||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
fileHandle.writeLine("// swiftlint:disable all")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -396,6 +529,9 @@ extension EmojiGenerator {
|
|||
]
|
||||
|
||||
writeBlock(fileName: "Emoji+Category.swift") { fileHandle in
|
||||
fileHandle.writeLine("// swiftlint:disable all")
|
||||
fileHandle.writeLine("// stringlint:disable")
|
||||
fileHandle.writeLine("")
|
||||
fileHandle.writeLine("extension Emoji {")
|
||||
fileHandle.indent {
|
||||
|
||||
|
@ -501,6 +637,7 @@ extension EmojiGenerator {
|
|||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
fileHandle.writeLine("// swiftlint:disable all")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -508,19 +645,23 @@ extension EmojiGenerator {
|
|||
// Name lookup: Create a computed property mapping an Emoji enum element to the raw Emoji name string
|
||||
// e.g. case .grinning: return "GRINNING FACE"
|
||||
writeBlock(fileName: "Emoji+Name.swift") { fileHandle in
|
||||
fileHandle.writeLine("// swiftlint:disable all")
|
||||
fileHandle.writeLine("// stringlint:disable")
|
||||
fileHandle.writeLine("")
|
||||
fileHandle.writeLine("extension Emoji {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("var name: String {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("switch self {")
|
||||
emojiModel.definitions.forEach {
|
||||
fileHandle.writeLine("case .\($0.enumName): return \"\($0.shortNames.joined(separator:", "))\"")
|
||||
fileHandle.writeLine("case .\($0.enumName): return \"\($0.shortNames.sorted().joined(separator:", "))\"")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
fileHandle.writeLine("// swiftlint:disable all")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,251 +1,573 @@
|
|||
#!/usr/bin/xcrun --sdk macosx swift
|
||||
|
||||
//
|
||||
// ListLocalizableStrings.swift
|
||||
// Archa
|
||||
//
|
||||
// Created by Morgan Pretty on 18/5/20.
|
||||
// Copyright © 2020 Archa. All rights reserved.
|
||||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
//
|
||||
// This script is based on https://github.com/ginowu7/CleanSwiftLocalizableExample the main difference
|
||||
// is canges to the localized usage regex
|
||||
//
|
||||
// stringlint:disable
|
||||
|
||||
import Foundation
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let currentPath = (
|
||||
ProcessInfo.processInfo.environment["PROJECT_DIR"] ?? fileManager.currentDirectoryPath
|
||||
extension ProjectState {
|
||||
/// Adding `// stringlint:disable` to the top of a source file (before imports) or after a string will mean that file/line gets
|
||||
/// ignored by this script (good for some things like the auto-generated emoji strings or debug strings)
|
||||
static let lintSuppression: String = "stringlint:disable"
|
||||
static let primaryLocalisationFile: String = "en"
|
||||
static let validLocalisationSuffixes: Set<String> = ["Localizable.strings"]
|
||||
static let validSourceSuffixes: Set<String> = [".swift", ".m"]
|
||||
static let excludedPaths: Set<String> = [
|
||||
"build/", // Files under the build folder (CI)
|
||||
"Pods/", // The pods folder
|
||||
"Protos/", // The protobuf files
|
||||
".xcassets/", // Asset bundles
|
||||
".app/", // App build directories
|
||||
".appex/", // Extension build directories
|
||||
"tests/", // Exclude test directories
|
||||
"_SharedTestUtilities/", // Exclude shared test directory
|
||||
"external/" // External dependencies
|
||||
]
|
||||
static let excludedPhrases: Set<String> = [ "", " ", ",", ", ", "null" ]
|
||||
static let excludedUnlocalisedStringLineMatching: Set<MatchType> = [
|
||||
.contains(ProjectState.lintSuppression),
|
||||
.prefix("#import"),
|
||||
.prefix("@available("),
|
||||
.contains("fatalError("),
|
||||
.contains("precondition("),
|
||||
.contains("preconditionFailure("),
|
||||
.contains("print("),
|
||||
.contains("NSLog("),
|
||||
.contains("SNLog("),
|
||||
.contains("SNLogNotTests("),
|
||||
.contains("owsFailDebug("),
|
||||
.contains("#imageLiteral(resourceName:"),
|
||||
.contains("UIImage(named:"),
|
||||
.contains("UIImage(systemName:"),
|
||||
.contains("[UIImage imageNamed:"),
|
||||
.contains("UIFont(name:"),
|
||||
.contains(".dateFormat ="),
|
||||
.contains(".accessibilityLabel ="),
|
||||
.contains(".accessibilityValue ="),
|
||||
.contains(".accessibilityIdentifier ="),
|
||||
.contains("accessibilityIdentifier:"),
|
||||
.contains("accessibilityLabel:"),
|
||||
.contains("Accessibility(identifier:"),
|
||||
.contains("Accessibility(label:"),
|
||||
.contains("NSAttributedString.Key("),
|
||||
.contains("Notification.Name("),
|
||||
.contains("Notification.Key("),
|
||||
.contains("DispatchQueue("),
|
||||
.containsAnd("identifier:", .previousLine(numEarlier: 1, .contains("Accessibility("))),
|
||||
.containsAnd("label:", .previousLine(numEarlier: 1, .contains("Accessibility("))),
|
||||
.containsAnd("label:", .previousLine(numEarlier: 2, .contains("Accessibility("))),
|
||||
.contains("SQL("),
|
||||
.regex(".*static var databaseTableName: String"),
|
||||
.regex("Logger\\..*\\("),
|
||||
.regex("OWSLogger\\..*\\("),
|
||||
.regex("case .* = "),
|
||||
.regex("Error.*\\(")
|
||||
]
|
||||
}
|
||||
|
||||
// Execute the desired actions
|
||||
let targetActions: Set<ScriptAction> = {
|
||||
let args = CommandLine.arguments
|
||||
|
||||
// The first argument is the file name
|
||||
guard args.count > 1 else { return [.lintStrings] }
|
||||
|
||||
return Set(args.suffix(from: 1).map { (ScriptAction(rawValue: $0) ?? .lintStrings) })
|
||||
}()
|
||||
|
||||
print("------------ Searching Through Files ------------")
|
||||
let projectState: ProjectState = ProjectState(
|
||||
path: (
|
||||
ProcessInfo.processInfo.environment["PROJECT_DIR"] ??
|
||||
FileManager.default.currentDirectoryPath
|
||||
),
|
||||
loadSourceFiles: targetActions.contains(.lintStrings)
|
||||
)
|
||||
print("------------ Processing \(projectState.localizationFiles.count) Localization File(s) ------------")
|
||||
targetActions.forEach { $0.perform(projectState: projectState) }
|
||||
|
||||
/// List of files in currentPath - recursive
|
||||
var pathFiles: [String] = {
|
||||
guard let enumerator = fileManager.enumerator(atPath: currentPath), let files = enumerator.allObjects as? [String] else {
|
||||
fatalError("Could not locate files in path directory: \(currentPath)")
|
||||
}
|
||||
// MARK: - ScriptAction
|
||||
|
||||
enum ScriptAction: String {
|
||||
case validateFilesCopied = "validate"
|
||||
case lintStrings = "lint"
|
||||
|
||||
return files
|
||||
}()
|
||||
|
||||
|
||||
/// List of localizable files - not including Localizable files in the Pods
|
||||
var localizableFiles: [String] = {
|
||||
return pathFiles
|
||||
.filter {
|
||||
$0.hasSuffix("Localizable.strings") &&
|
||||
!$0.contains(".app/") && // Exclude Built Localizable.strings files
|
||||
!$0.contains("Pods") // Exclude Pods
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
/// List of executable files
|
||||
var executableFiles: [String] = {
|
||||
return pathFiles.filter {
|
||||
!$0.localizedCaseInsensitiveContains("test") && // Exclude test files
|
||||
!$0.contains(".app/") && // Exclude Built Localizable.strings files
|
||||
!$0.contains("Pods") && // Exclude Pods
|
||||
(
|
||||
NSString(string: $0).pathExtension == "swift" ||
|
||||
NSString(string: $0).pathExtension == "m"
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
/// Reads contents in path
|
||||
///
|
||||
/// - Parameter path: path of file
|
||||
/// - Returns: content in file
|
||||
func contents(atPath path: String) -> String {
|
||||
print("Path: \(path)")
|
||||
guard let data = fileManager.contents(atPath: path), let content = String(data: data, encoding: .utf8) else {
|
||||
fatalError("Could not read from path: \(path)")
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/// Returns a list of strings that match regex pattern from content
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - pattern: regex pattern
|
||||
/// - content: content to match
|
||||
/// - Returns: list of results
|
||||
func regexFor(_ pattern: String, content: String) -> [String] {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
|
||||
fatalError("Regex not formatted correctly: \(pattern)")
|
||||
}
|
||||
|
||||
let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: content.utf16.count))
|
||||
|
||||
return matches.map {
|
||||
guard let range = Range($0.range(at: 0), in: content) else {
|
||||
fatalError("Incorrect range match")
|
||||
func perform(projectState: ProjectState) {
|
||||
// Perform the action
|
||||
switch self {
|
||||
case .validateFilesCopied:
|
||||
print("------------ Checking Copied Files ------------")
|
||||
guard
|
||||
let builtProductsPath: String = ProcessInfo.processInfo.environment["BUILT_PRODUCTS_DIR"],
|
||||
let productName: String = ProcessInfo.processInfo.environment["FULL_PRODUCT_NAME"],
|
||||
let productPathInfo = try? URL(fileURLWithPath: "\(builtProductsPath)/\(productName)")
|
||||
.resourceValues(forKeys: [.isSymbolicLinkKey, .isAliasFileKey]),
|
||||
let finalProductUrl: URL = try? { () -> URL in
|
||||
let possibleAliasUrl: URL = URL(fileURLWithPath: "\(builtProductsPath)/\(productName)")
|
||||
|
||||
guard productPathInfo.isSymbolicLink == true || productPathInfo.isAliasFile == true else {
|
||||
return possibleAliasUrl
|
||||
}
|
||||
|
||||
return try URL(resolvingAliasFileAt: possibleAliasUrl, options: URL.BookmarkResolutionOptions())
|
||||
}(),
|
||||
let enumerator: FileManager.DirectoryEnumerator = FileManager.default.enumerator(
|
||||
at: finalProductUrl,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
),
|
||||
let fileUrls: [URL] = enumerator.allObjects as? [URL]
|
||||
else { return Output.error("Could not retrieve list of files within built product") }
|
||||
|
||||
let localizationFiles: Set<String> = Set(fileUrls
|
||||
.filter { $0.path.hasSuffix(".lproj") }
|
||||
.map { $0.lastPathComponent.replacingOccurrences(of: ".lproj", with: "") })
|
||||
let missingFiles: Set<String> = Set(projectState.localizationFiles
|
||||
.map { $0.name })
|
||||
.subtracting(localizationFiles)
|
||||
|
||||
guard missingFiles.isEmpty else {
|
||||
return Output.error("Translations missing from \(productName): \(missingFiles.joined(separator: ", "))")
|
||||
}
|
||||
break
|
||||
|
||||
case .lintStrings:
|
||||
guard !projectState.localizationFiles.isEmpty else {
|
||||
return print("------------ Nothing to lint ------------")
|
||||
}
|
||||
|
||||
// Add warnings for any duplicate keys
|
||||
projectState.localizationFiles.forEach { file in
|
||||
// Show errors for any duplicates
|
||||
file.duplicates.forEach { phrase, original in Output.duplicate(phrase, original: original) }
|
||||
|
||||
// Show warnings for any phrases missing from the file
|
||||
let allKeys: Set<String> = Set(file.keyPhrase.keys)
|
||||
let missingKeysFromOtherFiles: [String: [String]] = projectState.localizationFiles.reduce(into: [:]) { result, otherFile in
|
||||
guard otherFile.path != file.path else { return }
|
||||
|
||||
let missingKeys: Set<String> = Set(otherFile.keyPhrase.keys)
|
||||
.subtracting(allKeys)
|
||||
|
||||
missingKeys.forEach { missingKey in
|
||||
result[missingKey] = ((result[missingKey] ?? []) + [otherFile.name])
|
||||
}
|
||||
}
|
||||
|
||||
missingKeysFromOtherFiles.forEach { missingKey, namesOfFilesItWasFound in
|
||||
Output.warning(file, "Phrase '\(missingKey)' is missing (found in: \(namesOfFilesItWasFound.joined(separator: ", ")))")
|
||||
}
|
||||
}
|
||||
|
||||
// Process the source code
|
||||
print("------------ Processing \(projectState.sourceFiles.count) Source File(s) ------------")
|
||||
let allKeys: Set<String> = Set(projectState.primaryLocalizationFile.keyPhrase.keys)
|
||||
|
||||
projectState.sourceFiles.forEach { file in
|
||||
// Add logs for unlocalised strings
|
||||
file.unlocalizedPhrases.forEach { phrase in
|
||||
Output.warning(phrase, "Found unlocalized string '\(phrase.key)'")
|
||||
}
|
||||
|
||||
// Add errors for missing localised strings
|
||||
let missingKeys: Set<String> = Set(file.keyPhrase.keys).subtracting(allKeys)
|
||||
missingKeys.forEach { key in
|
||||
switch file.keyPhrase[key] {
|
||||
case .some(let phrase): Output.error(phrase, "Localized phrase '\(key)' missing from strings files")
|
||||
case .none: Output.error(file, "Localized phrase '\(key)' missing from strings files")
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return String(content[range])
|
||||
print("------------ Complete ------------")
|
||||
}
|
||||
}
|
||||
|
||||
func create() -> [LocalizationStringsFile] {
|
||||
return localizableFiles.map(LocalizationStringsFile.init(path:))
|
||||
}
|
||||
// MARK: - Functionality
|
||||
|
||||
///
|
||||
///
|
||||
/// - Returns: A list of LocalizationCodeFile - contains path of file and all keys in it
|
||||
func localizedStringsInCode() -> [LocalizationCodeFile] {
|
||||
return executableFiles.compactMap {
|
||||
let content = contents(atPath: $0)
|
||||
// Note: Need to exclude escaped quotation marks from strings
|
||||
let matchesOld = regexFor("(?<=NSLocalizedString\\()\\s*\"(?!.*?%d)(.*?)\"", content: content)
|
||||
let matchesNew = regexFor("\"(?!.*?%d)([^(\\\")]*?)\"(?=\\s*)(?=\\.localized)", content: content)
|
||||
let allMatches = (matchesOld + matchesNew)
|
||||
|
||||
return allMatches.isEmpty ? nil : LocalizationCodeFile(path: $0, keys: Set(allMatches))
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws error if ALL localizable files does not have matching keys
|
||||
///
|
||||
/// - Parameter files: list of localizable files to validate
|
||||
func validateMatchKeys(_ files: [LocalizationStringsFile]) {
|
||||
print("------------ Validating keys match in all localizable files ------------")
|
||||
|
||||
guard let base = files.first, files.count > 1 else { return }
|
||||
|
||||
let files = Array(files.dropFirst())
|
||||
|
||||
files.forEach {
|
||||
guard let extraKey = Set(base.keys).symmetricDifference($0.keys).first else { return }
|
||||
let incorrectFile = $0.keys.contains(extraKey) ? $0 : base
|
||||
printPretty("error: Found extra key: \(extraKey) in file: \(incorrectFile.path)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws error if localizable files are missing keys
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - codeFiles: Array of LocalizationCodeFile
|
||||
/// - localizationFiles: Array of LocalizableStringFiles
|
||||
func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
|
||||
print("------------ Checking for missing keys -----------")
|
||||
|
||||
guard let baseFile = localizationFiles.first else {
|
||||
fatalError("Could not locate base localization file")
|
||||
}
|
||||
|
||||
let baseKeys = Set(baseFile.keys)
|
||||
|
||||
codeFiles.forEach {
|
||||
let extraKeys = $0.keys.subtracting(baseKeys)
|
||||
if !extraKeys.isEmpty {
|
||||
printPretty("error: Found keys in code missing in strings file: \(extraKeys) from \($0.path)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws warning if keys exist in localizable file but are not being used
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - codeFiles: Array of LocalizationCodeFile
|
||||
/// - localizationFiles: Array of LocalizableStringFiles
|
||||
func validateDeadKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
|
||||
print("------------ Checking for any dead keys in localizable file -----------")
|
||||
|
||||
guard let baseFile = localizationFiles.first else {
|
||||
fatalError("Could not locate base localization file")
|
||||
}
|
||||
|
||||
let baseKeys: Set<String> = Set(baseFile.keys)
|
||||
let allCodeFileKeys: [String] = codeFiles.flatMap { $0.keys }
|
||||
let deadKeys: [String] = Array(baseKeys.subtracting(allCodeFileKeys))
|
||||
.sorted()
|
||||
.map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) }
|
||||
|
||||
if !deadKeys.isEmpty {
|
||||
printPretty("warning: \(deadKeys) - Suggest cleaning dead keys")
|
||||
}
|
||||
}
|
||||
|
||||
protocol Pathable {
|
||||
var path: String { get }
|
||||
}
|
||||
|
||||
struct LocalizationStringsFile: Pathable {
|
||||
let path: String
|
||||
let kv: [String: String]
|
||||
|
||||
var keys: [String] {
|
||||
return Array(kv.keys)
|
||||
}
|
||||
|
||||
init(path: String) {
|
||||
self.path = path
|
||||
self.kv = ContentParser.parse(path)
|
||||
}
|
||||
|
||||
/// Writes back to localizable file with sorted keys and removed whitespaces and new lines
|
||||
func cleanWrite() {
|
||||
print("------------ Sort and remove whitespaces: \(path) ------------")
|
||||
let content = kv.keys.sorted().map { "\($0) = \(kv[$0]!);" }.joined(separator: "\n")
|
||||
try! content.write(toFile: path, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct LocalizationCodeFile: Pathable {
|
||||
let path: String
|
||||
let keys: Set<String>
|
||||
}
|
||||
|
||||
struct ContentParser {
|
||||
|
||||
/// Parses contents of a file to localizable keys and values - Throws error if localizable file have duplicated keys
|
||||
enum Regex {
|
||||
/// Returns a list of strings that match regex pattern from content
|
||||
///
|
||||
/// - Parameter path: Localizable file paths
|
||||
/// - Returns: localizable key and value for content at path
|
||||
static func parse(_ path: String) -> [String: String] {
|
||||
print("------------ Checking for duplicate keys: \(path) ------------")
|
||||
|
||||
let content = contents(atPath: path)
|
||||
let trimmed = content
|
||||
.replacingOccurrences(of: "\n+", with: "", options: .regularExpression, range: nil)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let keys = regexFor("\"([^\"]*?)\"(?= =)", content: trimmed)
|
||||
let values = regexFor("(?<== )\"(.*?)\"(?=;)", content: trimmed)
|
||||
|
||||
if keys.count != values.count {
|
||||
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)")
|
||||
/// - Parameters:
|
||||
/// - pattern: regex pattern
|
||||
/// - content: content to match
|
||||
/// - Returns: list of results
|
||||
static func matches(_ pattern: String, content: String) -> [String] {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
|
||||
fatalError("Regex not formatted correctly: \(pattern)")
|
||||
}
|
||||
|
||||
return zip(keys, values).reduce(into: [String: String]()) { results, keyValue in
|
||||
if results[keyValue.0] != nil {
|
||||
printPretty("error: Found duplicate key: \(keyValue.0) in file: \(path)")
|
||||
abort()
|
||||
let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: content.utf16.count))
|
||||
|
||||
return matches.map {
|
||||
guard let range = Range($0.range(at: 0), in: content) else {
|
||||
fatalError("Incorrect range match")
|
||||
}
|
||||
results[keyValue.0] = keyValue.1
|
||||
|
||||
return String(content[range])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printPretty(_ string: String) {
|
||||
print(string.replacingOccurrences(of: "\\", with: ""))
|
||||
}
|
||||
// MARK: - Output
|
||||
|
||||
let stringFiles = create()
|
||||
|
||||
if !stringFiles.isEmpty {
|
||||
print("------------ Found \(stringFiles.count) file(s) ------------")
|
||||
enum Output {
|
||||
static func error(_ error: String) {
|
||||
print("error: \(error)")
|
||||
}
|
||||
|
||||
stringFiles.forEach { print($0.path) }
|
||||
validateMatchKeys(stringFiles)
|
||||
|
||||
// Note: Uncomment the below file to clean out all comments from the localizable file (we don't want this because comments make it readable...)
|
||||
// stringFiles.forEach { $0.cleanWrite() }
|
||||
|
||||
let codeFiles = localizedStringsInCode()
|
||||
validateMissingKeys(codeFiles, localizationFiles: stringFiles)
|
||||
validateDeadKeys(codeFiles, localizationFiles: stringFiles)
|
||||
static func error(_ location: Locatable, _ error: String) {
|
||||
print("\(location.location): error: \(error)")
|
||||
}
|
||||
|
||||
static func warning(_ location: Locatable, _ warning: String) {
|
||||
print("\(location.location): warning: \(warning)")
|
||||
}
|
||||
|
||||
static func duplicate(
|
||||
_ duplicate: KeyedLocatable,
|
||||
original: KeyedLocatable
|
||||
) {
|
||||
print("\(duplicate.location): error: duplicate key '\(original.key)'")
|
||||
|
||||
// Looks like the `note:` doesn't work the same as when XCode does it unfortunately so we can't
|
||||
// currently include the reference to the original entry
|
||||
// print("\(original.location): note: previously found here")
|
||||
}
|
||||
}
|
||||
|
||||
print("------------ SUCCESS ------------")
|
||||
// MARK: - ProjectState
|
||||
|
||||
struct ProjectState {
|
||||
let primaryLocalizationFile: LocalizationStringsFile
|
||||
let localizationFiles: [LocalizationStringsFile]
|
||||
let sourceFiles: [SourceFile]
|
||||
|
||||
init(path: String, loadSourceFiles: Bool) {
|
||||
guard
|
||||
let enumerator: FileManager.DirectoryEnumerator = FileManager.default.enumerator(
|
||||
at: URL(fileURLWithPath: path),
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
),
|
||||
let fileUrls: [URL] = enumerator.allObjects as? [URL]
|
||||
else { fatalError("Could not locate files in path directory: \(path)") }
|
||||
|
||||
// Get a list of valid URLs
|
||||
let lowerCaseExcludedPaths: Set<String> = Set(ProjectState.excludedPaths.map { $0.lowercased() })
|
||||
let validFileUrls: [URL] = fileUrls.filter { fileUrl in
|
||||
((try? fileUrl.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == false) &&
|
||||
!lowerCaseExcludedPaths.contains { fileUrl.path.lowercased().contains($0) }
|
||||
}
|
||||
|
||||
// Localization files
|
||||
let targetFileSuffixes: Set<String> = Set(ProjectState.validLocalisationSuffixes.map { $0.lowercased() })
|
||||
self.localizationFiles = validFileUrls
|
||||
.filter { fileUrl in targetFileSuffixes.contains { fileUrl.path.lowercased().contains($0) } }
|
||||
.map { LocalizationStringsFile(path: $0.path) }
|
||||
|
||||
guard let primaryLocalizationFile: LocalizationStringsFile = self.localizationFiles.first(where: { $0.name == ProjectState.primaryLocalisationFile }) else {
|
||||
fatalError("Could not locate primary localization file: \(ProjectState.primaryLocalisationFile)")
|
||||
}
|
||||
self.primaryLocalizationFile = primaryLocalizationFile
|
||||
|
||||
guard loadSourceFiles else {
|
||||
self.sourceFiles = []
|
||||
return
|
||||
}
|
||||
|
||||
// Source files
|
||||
let lowerCaseSourceSuffixes: Set<String> = Set(ProjectState.validSourceSuffixes.map { $0.lowercased() })
|
||||
self.sourceFiles = validFileUrls
|
||||
.filter { fileUrl in lowerCaseSourceSuffixes.contains(".\(fileUrl.pathExtension)") }
|
||||
.compactMap { SourceFile(path: $0.path) }
|
||||
}
|
||||
}
|
||||
|
||||
protocol Locatable {
|
||||
var location: String { get }
|
||||
}
|
||||
|
||||
protocol KeyedLocatable: Locatable {
|
||||
var key: String { get }
|
||||
}
|
||||
|
||||
extension ProjectState {
|
||||
// MARK: - LocalizationStringsFile
|
||||
|
||||
struct LocalizationStringsFile: Locatable {
|
||||
struct Phrase: KeyedLocatable {
|
||||
let key: String
|
||||
let value: String
|
||||
let filePath: String
|
||||
let lineNumber: Int
|
||||
|
||||
var location: String { "\(filePath):\(lineNumber)" }
|
||||
}
|
||||
|
||||
let name: String
|
||||
let path: String
|
||||
let keyPhrase: [String: Phrase]
|
||||
let duplicates: [(Phrase, original: Phrase)]
|
||||
|
||||
var location: String { path }
|
||||
|
||||
init(path: String) {
|
||||
let result = LocalizationStringsFile.parse(path)
|
||||
|
||||
self.name = (path
|
||||
.replacingOccurrences(of: "/Localizable.strings", with: "")
|
||||
.replacingOccurrences(of: ".lproj", with: "")
|
||||
.components(separatedBy: "/")
|
||||
.last ?? "Unknown")
|
||||
self.path = path
|
||||
self.keyPhrase = result.keyPhrase
|
||||
self.duplicates = result.duplicates
|
||||
}
|
||||
|
||||
static func parse(_ path: String) -> (keyPhrase: [String: Phrase], duplicates: [(Phrase, original: Phrase)]) {
|
||||
guard
|
||||
let data: Data = FileManager.default.contents(atPath: path),
|
||||
let content: String = String(data: data, encoding: .utf8)
|
||||
else { fatalError("Could not read from path: \(path)") }
|
||||
|
||||
let lines: [String] = content.components(separatedBy: .newlines)
|
||||
var duplicates: [(Phrase, original: Phrase)] = []
|
||||
var keyPhrase: [String: Phrase] = [:]
|
||||
|
||||
lines.enumerated().forEach { lineNumber, line in
|
||||
guard
|
||||
let key: String = Regex.matches("\"([^\"]*?)\"(?= =)", content: line).first,
|
||||
let value: String = Regex.matches("(?<== )\"(.*?)\"(?=;)", content: line).first
|
||||
else { return }
|
||||
|
||||
// Remove the quotation marks around the key
|
||||
let trimmedKey: String = String(key
|
||||
.prefix(upTo: key.index(before: key.endIndex))
|
||||
.suffix(from: key.index(after: key.startIndex)))
|
||||
|
||||
// Files are 1-indexed but arrays are 0-indexed so add 1 to the lineNumber
|
||||
let result: Phrase = Phrase(
|
||||
key: trimmedKey,
|
||||
value: value,
|
||||
filePath: path,
|
||||
lineNumber: (lineNumber + 1)
|
||||
)
|
||||
|
||||
switch keyPhrase[trimmedKey] {
|
||||
case .some(let original): duplicates.append((result, original))
|
||||
case .none: keyPhrase[trimmedKey] = result
|
||||
}
|
||||
}
|
||||
|
||||
return (keyPhrase, duplicates)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SourceFile
|
||||
|
||||
struct SourceFile: Locatable {
|
||||
struct Phrase: KeyedLocatable {
|
||||
let term: String
|
||||
let filePath: String
|
||||
let lineNumber: Int
|
||||
|
||||
var key: String { term }
|
||||
var location: String { "\(filePath):\(lineNumber)" }
|
||||
}
|
||||
|
||||
let path: String
|
||||
let keyPhrase: [String: Phrase]
|
||||
let unlocalizedKeyPhrase: [String: Phrase]
|
||||
let phrases: [Phrase]
|
||||
let unlocalizedPhrases: [Phrase]
|
||||
|
||||
var location: String { path }
|
||||
|
||||
init?(path: String) {
|
||||
guard let result = SourceFile.parse(path) else { return nil }
|
||||
|
||||
self.path = path
|
||||
self.keyPhrase = result.keyPhrase
|
||||
self.unlocalizedKeyPhrase = result.unlocalizedKeyPhrase
|
||||
self.phrases = result.phrases
|
||||
self.unlocalizedPhrases = result.unlocalizedPhrases
|
||||
}
|
||||
|
||||
static func parse(_ path: String) -> (keyPhrase: [String: Phrase], phrases: [Phrase], unlocalizedKeyPhrase: [String: Phrase], unlocalizedPhrases: [Phrase])? {
|
||||
guard
|
||||
let data: Data = FileManager.default.contents(atPath: path),
|
||||
let content: String = String(data: data, encoding: .utf8)
|
||||
else { fatalError("Could not read from path: \(path)") }
|
||||
|
||||
// If the file has the lint supression before the first import then ignore the file
|
||||
let preImportContent: String = (content.components(separatedBy: "import").first ?? "")
|
||||
|
||||
guard !preImportContent.contains(ProjectState.lintSuppression) else {
|
||||
print("Explicitly ignoring \(path)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Otherwise continue and process the file
|
||||
let lines: [String] = content.components(separatedBy: .newlines)
|
||||
var keyPhrase: [String: Phrase] = [:]
|
||||
var unlocalizedKeyPhrase: [String: Phrase] = [:]
|
||||
var phrases: [Phrase] = []
|
||||
var unlocalizedPhrases: [Phrase] = []
|
||||
|
||||
lines.enumerated().forEach { lineNumber, line in
|
||||
let trimmedLine: String = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Ignore the line if it doesn't contain a quotation character (optimisation), it's
|
||||
// been suppressed or it's explicitly excluded due to the rules at the top of the file
|
||||
guard
|
||||
trimmedLine.contains("\"") &&
|
||||
!ProjectState.excludedUnlocalisedStringLineMatching
|
||||
.contains(where: { $0.matches(trimmedLine, lineNumber, lines) })
|
||||
else { return }
|
||||
|
||||
// Split line based on commented out content and exclude the comment from the linting
|
||||
let commentMatches: [String] = Regex.matches(
|
||||
"//[^\\\"]*(?:\\\"[^\\\"]*\\\"[^\\\"]*)*",
|
||||
content: line
|
||||
)
|
||||
let targetLine: String = (commentMatches.isEmpty ? line :
|
||||
line.components(separatedBy: commentMatches[0])[0]
|
||||
)
|
||||
|
||||
// Use regex to find `NSLocalizedString("", "")`, `"".localised()` and any other `""`
|
||||
// values in the source code
|
||||
//
|
||||
// Note: It's more complex because we need to exclude escaped quotation marks from
|
||||
// strings and also want to ignore any strings that have been commented out, Swift
|
||||
// also doesn't support "lookbehind" in regex so we can use that approach
|
||||
var isUnlocalized: Bool = false
|
||||
var allMatches: Set<String> = Set(
|
||||
Regex
|
||||
.matches(
|
||||
"NSLocalizedString\\(@{0,1}\\\"[^\\\"\\\\]*(?:\\\\.[^\\\"\\\\]*)*(?:\\\")",
|
||||
content: targetLine
|
||||
)
|
||||
.map { match in
|
||||
match
|
||||
.removingPrefixIfPresent("NSLocalizedString(@\"")
|
||||
.removingPrefixIfPresent("NSLocalizedString(\"")
|
||||
.removingSuffixIfPresent("\")")
|
||||
.removingSuffixIfPresent("\"")
|
||||
}
|
||||
)
|
||||
|
||||
// If we didn't get any matches for the standard `NSLocalizedString` then try our
|
||||
// custom extension `"".localized()`
|
||||
if allMatches.isEmpty {
|
||||
allMatches = allMatches.union(Set(
|
||||
Regex
|
||||
.matches(
|
||||
"\\\"[^\\\"\\\\]*(?:\\\\.[^\\\"\\\\]*)*\\\"\\.localized",
|
||||
content: targetLine
|
||||
)
|
||||
.map { match in
|
||||
match
|
||||
.removingPrefixIfPresent("\"")
|
||||
.removingSuffixIfPresent("\".localized")
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
/// If we still don't have any matches then try to match any strings as unlocalized strings (handling
|
||||
/// nested `"Test\"string\" value"`, empty strings and strings only composed of quotes `"""""""`)
|
||||
///
|
||||
/// **Note:** While it'd be nice to have the regex automatically exclude the quotes doing so makes it _far_ less
|
||||
/// efficient (approx. by a factor of 8 times) so we remove those ourselves)
|
||||
if allMatches.isEmpty {
|
||||
// Find strings which are just not localised
|
||||
let potentialUnlocalizedStrings: [String] = Regex
|
||||
.matches("\\\"[^\\\"\\\\]*(?:\\\\.[^\\\"\\\\]*)*(?:\\\")", content: targetLine)
|
||||
// Remove the leading and trailing quotation marks
|
||||
.map { $0.removingPrefixIfPresent("\"").removingSuffixIfPresent("\"") }
|
||||
// Remove any empty strings
|
||||
.filter { !$0.isEmpty }
|
||||
// Remove any string conversations (ie. `.map { "\($0)" }`
|
||||
.filter { value in !value.hasPrefix("\\(") || !value.hasSuffix(")") }
|
||||
|
||||
allMatches = allMatches.union(Set(potentialUnlocalizedStrings))
|
||||
isUnlocalized = true
|
||||
}
|
||||
|
||||
// Remove any excluded phrases from the matches
|
||||
allMatches = allMatches.subtracting(ProjectState.excludedPhrases.map { "\($0)" })
|
||||
|
||||
allMatches.forEach { match in
|
||||
// Files are 1-indexed but arrays are 0-indexed so add 1 to the lineNumber
|
||||
let result: Phrase = Phrase(
|
||||
term: match,
|
||||
filePath: path,
|
||||
lineNumber: (lineNumber + 1)
|
||||
)
|
||||
|
||||
if !isUnlocalized {
|
||||
keyPhrase[match] = result
|
||||
phrases.append(result)
|
||||
}
|
||||
else {
|
||||
unlocalizedKeyPhrase[match] = result
|
||||
unlocalizedPhrases.append(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (keyPhrase, phrases, unlocalizedKeyPhrase, unlocalizedPhrases)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indirect enum MatchType: Hashable {
|
||||
case prefix(String)
|
||||
case contains(String)
|
||||
case containsAnd(String, MatchType)
|
||||
case regex(String)
|
||||
case previousLine(numEarlier: Int, MatchType)
|
||||
|
||||
func matches(_ value: String, _ index: Int, _ lines: [String]) -> Bool {
|
||||
switch self {
|
||||
case .prefix(let prefix):
|
||||
return value
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.hasPrefix(prefix)
|
||||
|
||||
case .contains(let other): return value.contains(other)
|
||||
case .containsAnd(let other, let otherMatch):
|
||||
guard value.contains(other) else { return false }
|
||||
|
||||
return otherMatch.matches(value, index, lines)
|
||||
|
||||
case .regex(let regex): return !Regex.matches(regex, content: value).isEmpty
|
||||
|
||||
case .previousLine(let numEarlier, let type):
|
||||
guard index >= numEarlier else { return false }
|
||||
|
||||
let targetIndex: Int = (index - numEarlier)
|
||||
return type.matches(lines[targetIndex], targetIndex, lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
func removingPrefixIfPresent(_ value: String) -> String {
|
||||
guard hasPrefix(value) else { return self }
|
||||
|
||||
return String(self.suffix(from: self.index(self.startIndex, offsetBy: value.count)))
|
||||
}
|
||||
|
||||
func removingSuffixIfPresent(_ value: String) -> String {
|
||||
guard hasSuffix(value) else { return self }
|
||||
|
||||
return String(self.prefix(upTo: self.index(self.endIndex, offsetBy: -value.count)))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
#!/usr/bin/env python
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
|
@ -202,10 +202,18 @@ class BaseContext(object):
|
|||
return 'UInt32'
|
||||
elif field.proto_type == 'fixed64':
|
||||
return 'UInt64'
|
||||
elif field.proto_type == 'int64':
|
||||
return 'Int64'
|
||||
elif field.proto_type == 'int32':
|
||||
return 'Int32'
|
||||
elif field.proto_type == 'bool':
|
||||
return 'Bool'
|
||||
elif field.proto_type == 'bytes':
|
||||
return 'Data'
|
||||
elif field.proto_type == 'double':
|
||||
return 'Double'
|
||||
elif field.proto_type == 'float':
|
||||
return 'Float'
|
||||
else:
|
||||
matching_context = self.context_for_proto_type(field)
|
||||
if matching_context is not None:
|
||||
|
@ -236,7 +244,11 @@ class BaseContext(object):
|
|||
return field.proto_type in ('uint64',
|
||||
'uint32',
|
||||
'fixed64',
|
||||
'bool', )
|
||||
'int64',
|
||||
'int32',
|
||||
'bool',
|
||||
'double',
|
||||
'float', )
|
||||
|
||||
def can_field_be_optional(self, field):
|
||||
if self.is_field_primitive(field):
|
||||
|
@ -288,8 +300,16 @@ class BaseContext(object):
|
|||
return '0'
|
||||
elif field.proto_type == 'fixed64':
|
||||
return '0'
|
||||
elif field.proto_type == 'int64':
|
||||
return '0'
|
||||
elif field.proto_type == 'int32':
|
||||
return '0'
|
||||
elif field.proto_type == 'bool':
|
||||
return 'false'
|
||||
elif field.proto_type == 'double':
|
||||
return '0'
|
||||
elif field.proto_type == 'float':
|
||||
return '0'
|
||||
elif self.is_field_an_enum(field):
|
||||
# TODO: Assert that rules is empty.
|
||||
enum_context = self.context_for_proto_type(field)
|
||||
|
@ -552,7 +572,7 @@ public func serializedData() throws -> Data {
|
|||
# if self.can_field_be_optional(field):
|
||||
writer.add('guard proto.%s else {' % field.has_accessor_name() )
|
||||
writer.push_indent()
|
||||
writer.add('throw %s.invalidProtobuf(description: "\(logTag) missing required field: %s")' % ( writer.invalid_protobuf_error_name, field.name_swift, ) )
|
||||
writer.add('throw %s.invalidProtobuf(description: "\(String(describing: logTag)) missing required field: %s")' % ( writer.invalid_protobuf_error_name, field.name_swift, ) )
|
||||
writer.pop_indent()
|
||||
writer.add('}')
|
||||
|
||||
|
|
|
@ -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,77 @@
|
|||
#!/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
|
||||
|
||||
# Define the output paths
|
||||
prod_path="build/Session.xcarchive"
|
||||
sim_path="build/Session_sim.xcarchive/Products/Applications/Session.app"
|
||||
|
||||
# Validate the paths exist
|
||||
if [ -d $prod_path ]; then
|
||||
suffix="store"
|
||||
target_path=$prod_path
|
||||
elif [ -d $sim_path ]; then
|
||||
suffix="sim"
|
||||
target_path=$sim_path
|
||||
else
|
||||
echo -e "\n\n\n\e[31;1mExpected a file to upload, found none\e[0m" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$DRONE_TAG" ]; then
|
||||
# For a tag build use something like `session-ios-v1.2.3`
|
||||
base="session-ios-$DRONE_TAG-$suffix"
|
||||
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}-$suffix"
|
||||
fi
|
||||
|
||||
# Copy over the build products
|
||||
mkdir -vp "$base"
|
||||
mkdir -p build
|
||||
cp -av $target_path "$base"
|
||||
|
||||
# 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,213 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "OWSBackupSettingsViewController.h"
|
||||
#import "OWSBackup.h"
|
||||
#import "Session-Swift.h"
|
||||
|
||||
#import <PromiseKit/AnyPromise.h>
|
||||
#import <SessionMessagingKit/Environment.h>
|
||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||
#import <SignalUtilitiesKit/UIColor+OWS.h>
|
||||
#import <SignalUtilitiesKit/UIFont+OWS.h>
|
||||
#import <SessionUtilitiesKit/UIView+OWS.h>
|
||||
#import <SessionUtilitiesKit/MIMETypeUtil.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface OWSBackupSettingsViewController ()
|
||||
|
||||
@property (nonatomic, nullable) NSError *iCloudError;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation OWSBackupSettingsViewController
|
||||
|
||||
#pragma mark - Dependencies
|
||||
|
||||
- (OWSBackup *)backup
|
||||
{
|
||||
OWSAssertDebug(AppEnvironment.shared.backup);
|
||||
|
||||
return AppEnvironment.shared.backup;
|
||||
}
|
||||
|
||||
#pragma mark -
|
||||
|
||||
- (void)viewDidLoad
|
||||
{
|
||||
[super viewDidLoad];
|
||||
|
||||
self.title = NSLocalizedString(@"SETTINGS_BACKUP", @"Label for the backup view in app settings.");
|
||||
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(backupStateDidChange:)
|
||||
name:NSNotificationNameBackupStateDidChange
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(applicationDidBecomeActive:)
|
||||
name:OWSApplicationDidBecomeActiveNotification
|
||||
object:nil];
|
||||
|
||||
[self updateTableContents];
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
- (void)viewDidAppear:(BOOL)animated
|
||||
{
|
||||
[super viewDidAppear:animated];
|
||||
|
||||
[self updateTableContents];
|
||||
[self updateICloudStatus];
|
||||
}
|
||||
|
||||
- (void)updateICloudStatus
|
||||
{
|
||||
__weak OWSBackupSettingsViewController *weakSelf = self;
|
||||
[[self.backup ensureCloudKitAccess]
|
||||
.then(^{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
weakSelf.iCloudError = nil;
|
||||
[weakSelf updateTableContents];
|
||||
})
|
||||
.catch(^(NSError *error) {
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
weakSelf.iCloudError = error;
|
||||
[weakSelf updateTableContents];
|
||||
}) retainUntilComplete];
|
||||
}
|
||||
|
||||
#pragma mark - Table Contents
|
||||
|
||||
- (void)updateTableContents
|
||||
{
|
||||
OWSTableContents *contents = [OWSTableContents new];
|
||||
|
||||
BOOL isBackupEnabled = [OWSBackup.sharedManager isBackupEnabled];
|
||||
|
||||
if (self.iCloudError) {
|
||||
OWSTableSection *iCloudSection = [OWSTableSection new];
|
||||
iCloudSection.headerTitle = NSLocalizedString(
|
||||
@"SETTINGS_BACKUP_ICLOUD_STATUS", @"Label for iCloud status row in the in the backup settings view.");
|
||||
[iCloudSection
|
||||
addItem:[OWSTableItem
|
||||
longDisclosureItemWithText:[OWSBackupAPI errorMessageForCloudKitAccessError:self.iCloudError]
|
||||
actionBlock:^{
|
||||
[[UIApplication sharedApplication]
|
||||
openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString]];
|
||||
}]];
|
||||
[contents addSection:iCloudSection];
|
||||
}
|
||||
|
||||
// TODO: This UI is temporary.
|
||||
// Enabling backup will involve entering and registering a PIN.
|
||||
OWSTableSection *enableSection = [OWSTableSection new];
|
||||
enableSection.headerTitle = NSLocalizedString(@"SETTINGS_BACKUP", @"Label for the backup view in app settings.");
|
||||
[enableSection
|
||||
addItem:[OWSTableItem switchItemWithText:
|
||||
NSLocalizedString(@"SETTINGS_BACKUP_ENABLING_SWITCH",
|
||||
@"Label for switch in settings that controls whether or not backup is enabled.")
|
||||
isOnBlock:^{
|
||||
return [OWSBackup.sharedManager isBackupEnabled];
|
||||
}
|
||||
target:self
|
||||
selector:@selector(isBackupEnabledDidChange:)]];
|
||||
[contents addSection:enableSection];
|
||||
|
||||
if (isBackupEnabled) {
|
||||
// TODO: This UI is temporary.
|
||||
// Enabling backup will involve entering and registering a PIN.
|
||||
OWSTableSection *progressSection = [OWSTableSection new];
|
||||
[progressSection
|
||||
addItem:[OWSTableItem
|
||||
labelItemWithText:NSLocalizedString(@"SETTINGS_BACKUP_STATUS",
|
||||
@"Label for backup status row in the in the backup settings view.")
|
||||
accessoryText:NSStringForBackupExportState(OWSBackup.sharedManager.backupExportState)]];
|
||||
if (OWSBackup.sharedManager.backupExportState == OWSBackupState_InProgress) {
|
||||
if (OWSBackup.sharedManager.backupExportDescription) {
|
||||
[progressSection
|
||||
addItem:[OWSTableItem
|
||||
labelItemWithText:NSLocalizedString(@"SETTINGS_BACKUP_PHASE",
|
||||
@"Label for phase row in the in the backup settings view.")
|
||||
accessoryText:OWSBackup.sharedManager.backupExportDescription]];
|
||||
if (OWSBackup.sharedManager.backupExportProgress) {
|
||||
NSUInteger progressPercent
|
||||
= (NSUInteger)round(OWSBackup.sharedManager.backupExportProgress.floatValue * 100);
|
||||
NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init];
|
||||
[numberFormatter setNumberStyle:NSNumberFormatterPercentStyle];
|
||||
[numberFormatter setMaximumFractionDigits:0];
|
||||
[numberFormatter setMultiplier:@1];
|
||||
NSString *progressString = [numberFormatter stringFromNumber:@(progressPercent)];
|
||||
[progressSection
|
||||
addItem:[OWSTableItem
|
||||
labelItemWithText:NSLocalizedString(@"SETTINGS_BACKUP_PROGRESS",
|
||||
@"Label for phase row in the in the backup settings view.")
|
||||
accessoryText:progressString]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (OWSBackup.sharedManager.backupExportState) {
|
||||
case OWSBackupState_Idle:
|
||||
case OWSBackupState_Failed:
|
||||
case OWSBackupState_Succeeded:
|
||||
[progressSection
|
||||
addItem:[OWSTableItem disclosureItemWithText:
|
||||
NSLocalizedString(@"SETTINGS_BACKUP_BACKUP_NOW",
|
||||
@"Label for 'backup now' button in the backup settings view.")
|
||||
actionBlock:^{
|
||||
[OWSBackup.sharedManager tryToExportBackup];
|
||||
}]];
|
||||
break;
|
||||
case OWSBackupState_InProgress:
|
||||
[progressSection
|
||||
addItem:[OWSTableItem disclosureItemWithText:
|
||||
NSLocalizedString(@"SETTINGS_BACKUP_CANCEL_BACKUP",
|
||||
@"Label for 'cancel backup' button in the backup settings view.")
|
||||
actionBlock:^{
|
||||
[OWSBackup.sharedManager cancelExportBackup];
|
||||
}]];
|
||||
break;
|
||||
}
|
||||
|
||||
[contents addSection:progressSection];
|
||||
}
|
||||
|
||||
self.contents = contents;
|
||||
}
|
||||
|
||||
- (void)isBackupEnabledDidChange:(UISwitch *)sender
|
||||
{
|
||||
[OWSBackup.sharedManager setIsBackupEnabled:sender.isOn];
|
||||
|
||||
[self updateTableContents];
|
||||
}
|
||||
|
||||
#pragma mark - Events
|
||||
|
||||
- (void)backupStateDidChange:(NSNotification *)notification
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
[self updateTableContents];
|
||||
}
|
||||
|
||||
- (void)applicationDidBecomeActive:(NSNotification *)notification
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
[self updateICloudStatus];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -1,12 +1,16 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import YYImage
|
||||
import Combine
|
||||
import CallKit
|
||||
import GRDB
|
||||
import WebRTC
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionSnodeKit
|
||||
|
||||
public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
||||
@objc static let isEnabled = true
|
||||
|
@ -25,6 +29,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
|||
|
||||
let contactName: String
|
||||
let profilePicture: UIImage
|
||||
let animatedProfilePicture: YYImage?
|
||||
|
||||
// MARK: - Control
|
||||
|
||||
|
@ -151,10 +156,18 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
|||
self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionId, with: uuid)
|
||||
self.isOutgoing = outgoing
|
||||
|
||||
let avatarData: Data? = ProfileManager.profileAvatar(db, id: sessionId)
|
||||
self.contactName = Profile.displayName(db, id: sessionId, threadVariant: .contact)
|
||||
self.profilePicture = ProfileManager.profileAvatar(db, id: sessionId)
|
||||
self.profilePicture = avatarData
|
||||
.map { UIImage(data: $0) }
|
||||
.defaulting(to: Identicon.generatePlaceholderIcon(seed: sessionId, text: self.contactName, size: 300))
|
||||
.defaulting(to: PlaceholderIcon.generate(seed: sessionId, text: self.contactName, size: 300))
|
||||
self.animatedProfilePicture = avatarData
|
||||
.map { data in
|
||||
switch data.guessedImageFormat {
|
||||
case .gif, .webp: return YYImage(data: data)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
WebRTCSession.current = self.webRTCSession
|
||||
self.webRTCSession.delegate = self
|
||||
|
@ -206,6 +219,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
|||
let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId)
|
||||
else { return }
|
||||
|
||||
let webRTCSession: WebRTCSession = self.webRTCSession
|
||||
let timestampMs: Int64 = SnodeAPI.currentOffsetTimestampMs()
|
||||
let message: CallMessage = CallMessage(
|
||||
uuid: self.uuid,
|
||||
|
@ -224,21 +238,18 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
|||
.inserted(db)
|
||||
|
||||
self.callInteractionId = interaction?.id
|
||||
try? self.webRTCSession
|
||||
|
||||
try? webRTCSession
|
||||
.sendPreOffer(
|
||||
db,
|
||||
message: message,
|
||||
interactionId: interaction?.id,
|
||||
in: thread
|
||||
)
|
||||
.done { [weak self] _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
self?.webRTCSession.sendOffer(db, to: sessionId)
|
||||
}
|
||||
|
||||
self?.setupTimeoutTimer()
|
||||
}
|
||||
.retainUntilComplete()
|
||||
// Start the timeout timer for the call
|
||||
.handleEvents(receiveOutput: { [weak self] _ in self?.setupTimeoutTimer() })
|
||||
.flatMap { _ in webRTCSession.sendOffer(to: thread) }
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
|
||||
func answerSessionCall() {
|
||||
|
@ -418,9 +429,14 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
|||
let sessionId: String = self.sessionId
|
||||
let webRTCSession: WebRTCSession = self.webRTCSession
|
||||
|
||||
Storage.shared
|
||||
.read { db in webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true) }
|
||||
.retainUntilComplete()
|
||||
guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: sessionId) }) else {
|
||||
return
|
||||
}
|
||||
|
||||
webRTCSession
|
||||
.sendOffer(to: thread, isRestartingICEConnection: true)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
|
||||
// MARK: - Timeout
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension SessionCallManager {
|
||||
@discardableResult
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
import Foundation
|
||||
import CallKit
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalCoreKit
|
||||
|
||||
extension SessionCallManager: CXProviderDelegate {
|
||||
public func providerDidReset(_ provider: CXProvider) {
|
||||
|
|
|
@ -4,6 +4,9 @@ import UIKit
|
|||
import CallKit
|
||||
import GRDB
|
||||
import SessionMessagingKit
|
||||
import SignalCoreKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public final class SessionCallManager: NSObject, CallManagerProtocol {
|
||||
let provider: CXProvider?
|
||||
|
@ -90,7 +93,8 @@ public final class SessionCallManager: NSObject, CallManagerProtocol {
|
|||
|
||||
public func reportOutgoingCall(_ call: SessionCall) {
|
||||
AssertIsOnMainThread()
|
||||
UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing")
|
||||
UserDefaults.sharedLokiProject?[.isCallOngoing] = true
|
||||
UserDefaults.sharedLokiProject?[.lastCallPreOffer] = Date()
|
||||
|
||||
call.stateDidChange = {
|
||||
if call.hasStartedConnecting {
|
||||
|
@ -120,7 +124,8 @@ public final class SessionCallManager: NSObject, CallManagerProtocol {
|
|||
completion(error)
|
||||
return
|
||||
}
|
||||
UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing")
|
||||
UserDefaults.sharedLokiProject?[.isCallOngoing] = true
|
||||
UserDefaults.sharedLokiProject?[.lastCallPreOffer] = Date()
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
|
@ -135,7 +140,9 @@ public final class SessionCallManager: NSObject, CallManagerProtocol {
|
|||
|
||||
func handleCallEnded() {
|
||||
WebRTCSession.current = nil
|
||||
UserDefaults.sharedLokiProject?.set(false, forKey: "isCallOngoing")
|
||||
UserDefaults.sharedLokiProject?[.isCallOngoing] = false
|
||||
UserDefaults.sharedLokiProject?[.lastCallPreOffer] = nil
|
||||
|
||||
if CurrentAppContext().isInBackground() {
|
||||
(UIApplication.shared.delegate as? AppDelegate)?.stopPollers()
|
||||
DDLog.flushLog()
|
||||
|
@ -186,7 +193,7 @@ public final class SessionCallManager: NSObject, CallManagerProtocol {
|
|||
if CurrentAppContext().isInBackground() {
|
||||
// Stop all jobs except for message sending and when completed suspend the database
|
||||
JobRunner.stopAndClearPendingJobs(exceptForVariant: .messageSend) {
|
||||
NotificationCenter.default.post(name: Database.suspendNotification, object: self)
|
||||
Storage.suspendDatabaseAccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -205,9 +212,9 @@ public final class SessionCallManager: NSObject, CallManagerProtocol {
|
|||
return
|
||||
}
|
||||
|
||||
guard CurrentAppContext().isMainAppAndActive else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard CurrentAppContext().isMainAppAndActive else { return }
|
||||
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else {
|
||||
preconditionFailure() // FIXME: Handle more gracefully
|
||||
}
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import YYImage
|
||||
import MediaPlayer
|
||||
import WebRTC
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class CallVC: UIViewController, VideoPreviewDelegate {
|
||||
static let floatingVideoViewWidth: CGFloat = UIDevice.current.isIPad ? 160 : 80
|
||||
static let floatingVideoViewHeight: CGFloat = UIDevice.current.isIPad ? 346: 173
|
||||
private static let avatarRadius: CGFloat = (isIPhone6OrSmaller ? 100 : 120)
|
||||
private static let floatingVideoViewWidth: CGFloat = (UIDevice.current.isIPad ? 160 : 80)
|
||||
private static let floatingVideoViewHeight: CGFloat = (UIDevice.current.isIPad ? 346: 173)
|
||||
|
||||
let call: SessionCall
|
||||
var latestKnownAudioOutputDeviceName: String?
|
||||
|
@ -129,17 +130,29 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
|
||||
private lazy var profilePictureView: UIImageView = {
|
||||
let result = UIImageView()
|
||||
let radius: CGFloat = isIPhone6OrSmaller ? 100 : 120
|
||||
result.image = self.call.profilePicture
|
||||
result.set(.width, to: radius * 2)
|
||||
result.set(.height, to: radius * 2)
|
||||
result.layer.cornerRadius = radius
|
||||
result.set(.width, to: CallVC.avatarRadius * 2)
|
||||
result.set(.height, to: CallVC.avatarRadius * 2)
|
||||
result.layer.cornerRadius = CallVC.avatarRadius
|
||||
result.layer.masksToBounds = true
|
||||
result.contentMode = .scaleAspectFill
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var animatedImageView: YYAnimatedImageView = {
|
||||
let result: YYAnimatedImageView = YYAnimatedImageView()
|
||||
result.image = self.call.animatedProfilePicture
|
||||
result.set(.width, to: CallVC.avatarRadius * 2)
|
||||
result.set(.height, to: CallVC.avatarRadius * 2)
|
||||
result.layer.cornerRadius = CallVC.avatarRadius
|
||||
result.layer.masksToBounds = true
|
||||
result.contentMode = .scaleAspectFill
|
||||
result.isHidden = (self.call.animatedProfilePicture == nil)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var minimizeButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
result.setImage(
|
||||
|
@ -486,7 +499,9 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
|||
profilePictureContainer.pin(.bottom, to: .top, of: operationPanel)
|
||||
profilePictureContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: view)
|
||||
profilePictureContainer.addSubview(profilePictureView)
|
||||
profilePictureContainer.addSubview(animatedImageView)
|
||||
profilePictureView.center(in: profilePictureContainer)
|
||||
animatedImageView.center(in: profilePictureContainer)
|
||||
|
||||
// Call info label
|
||||
let callInfoLabelContainer = UIView()
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import WebRTC
|
||||
import SessionUIKit
|
||||
|
||||
public protocol VideoPreviewDelegate: AnyObject {
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import WebRTC
|
||||
import Foundation
|
||||
import SessionUtilitiesKit
|
||||
import SignalCoreKit
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
// Note: 'RTCMTLVideoView' doesn't seem to work on the simulator so use 'RTCEAGLVideoView' instead
|
||||
|
@ -27,7 +30,7 @@ class RemoteVideoView: TargetView {
|
|||
return
|
||||
}
|
||||
|
||||
DispatchMainThreadSafe {
|
||||
Threading.dispatchMainThreadSafe {
|
||||
let frameRatio = Double(frame.height) / Double(frame.width)
|
||||
let frameRotation = frame.rotation
|
||||
let deviceRotation = UIDevice.current.orientation
|
||||
|
@ -90,7 +93,8 @@ class LocalVideoView: TargetView {
|
|||
|
||||
override func renderFrame(_ frame: RTCVideoFrame?) {
|
||||
super.renderFrame(frame)
|
||||
DispatchMainThreadSafe {
|
||||
|
||||
Threading.dispatchMainThreadSafe {
|
||||
// This is a workaround for a weird issue that
|
||||
// sometimes the rotationOverride is not working
|
||||
// if it is only set once on initialization
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import WebRTC
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
||||
private static let swipeToOperateThreshold: CGFloat = 60
|
||||
|
@ -20,14 +21,7 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
|||
return result
|
||||
}()
|
||||
|
||||
private lazy var profilePictureView: ProfilePictureView = {
|
||||
let result = ProfilePictureView()
|
||||
let size: CGFloat = 60
|
||||
result.size = size
|
||||
result.set(.width, to: size)
|
||||
result.set(.height, to: size)
|
||||
return result
|
||||
}()
|
||||
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .list)
|
||||
|
||||
private lazy var displayNameLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
|
@ -118,8 +112,10 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
|||
|
||||
profilePictureView.update(
|
||||
publicKey: call.sessionId,
|
||||
profile: Profile.fetchOrCreate(id: call.sessionId),
|
||||
threadVariant: .contact
|
||||
threadVariant: .contact,
|
||||
customImageData: nil,
|
||||
profile: Storage.shared.read { [sessionId = call.sessionId] db in Profile.fetchOrCreate(db, id: sessionId) },
|
||||
additionalProfile: nil
|
||||
)
|
||||
displayNameLabel.text = call.contactName
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import UIKit
|
||||
import WebRTC
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class MiniCallView: UIView, RTCVideoViewDelegate {
|
||||
var callVC: CallVC
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate {
|
||||
private struct GroupMemberDisplayInfo: FetchableRecord, Equatable, Hashable, Decodable, Differentiable {
|
||||
|
@ -18,6 +19,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
}
|
||||
|
||||
private let threadId: String
|
||||
private let threadVariant: SessionThread.Variant
|
||||
private var originalName: String = ""
|
||||
private var originalMembersAndZombieIds: Set<String> = []
|
||||
private var name: String = ""
|
||||
|
@ -82,8 +84,9 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(threadId: String) {
|
||||
init(threadId: String, threadVariant: SessionThread.Variant) {
|
||||
self.threadId = threadId
|
||||
self.threadVariant = threadVariant
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
@ -220,7 +223,8 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
cell.update(
|
||||
with: SessionCell.Info(
|
||||
id: displayInfo,
|
||||
leftAccessory: .profile(displayInfo.profileId, displayInfo.profile),
|
||||
position: Position.with(indexPath.row, count: membersAndZombies.count),
|
||||
leftAccessory: .profile(id: displayInfo.profileId, profile: displayInfo.profile),
|
||||
title: (
|
||||
displayInfo.profile?.displayName() ??
|
||||
Profile.truncated(id: displayInfo.profileId, threadVariant: .contact)
|
||||
|
@ -231,10 +235,9 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
.withRenderingMode(.alwaysTemplate),
|
||||
customTint: .textSecondary
|
||||
)
|
||||
)
|
||||
),
|
||||
style: .edgeToEdge,
|
||||
position: Position.with(indexPath.row, count: membersAndZombies.count)
|
||||
),
|
||||
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge)
|
||||
)
|
||||
)
|
||||
|
||||
return cell
|
||||
|
@ -244,12 +247,26 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
return adminIds.contains(userPublicKey)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
|
||||
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
|
||||
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
let profileId: String = self.membersAndZombies[indexPath.row].profileId
|
||||
|
||||
let delete: UIContextualAction = UIContextualAction(
|
||||
style: .destructive,
|
||||
title: "GROUP_ACTION_REMOVE".localized()
|
||||
title: "GROUP_ACTION_REMOVE".localized(),
|
||||
icon: UIImage(named: "icon_bin"),
|
||||
themeTintColor: .white,
|
||||
themeBackgroundColor: .conversationButton_swipeDestructive,
|
||||
side: .trailing,
|
||||
actionIndex: 0,
|
||||
indexPath: indexPath,
|
||||
tableView: tableView
|
||||
) { [weak self] _, _, completionHandler in
|
||||
self?.adminIds.remove(profileId)
|
||||
self?.membersAndZombies.remove(at: indexPath.row)
|
||||
|
@ -257,7 +274,6 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
|
||||
completionHandler(true)
|
||||
}
|
||||
delete.themeBackgroundColor = .conversationButton_swipeDestructive
|
||||
|
||||
return UISwipeActionsConfiguration(actions: [ delete ])
|
||||
}
|
||||
|
@ -286,7 +302,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
}
|
||||
|
||||
private func handleMembersChanged() {
|
||||
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 67
|
||||
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 78
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
|
@ -333,7 +349,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
guard !updatedName.isEmpty else {
|
||||
return showError(title: "vc_create_closed_group_group_name_missing_error".localized())
|
||||
}
|
||||
guard updatedName.count < 64 else {
|
||||
guard updatedName.utf8CString.count < SessionUtil.libSessionMaxGroupNameByteLength else {
|
||||
return showError(title: "vc_create_closed_group_group_name_too_long_error".localized())
|
||||
}
|
||||
|
||||
|
@ -449,32 +465,40 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
|
||||
Storage.shared
|
||||
.writeAsync { db in
|
||||
if !updatedMemberIds.contains(userPublicKey) {
|
||||
try MessageSender.leave(
|
||||
db,
|
||||
groupPublicKey: threadId,
|
||||
deleteThread: false
|
||||
)
|
||||
return Promise.value(())
|
||||
}
|
||||
|
||||
return try MessageSender.update(
|
||||
.writePublisher { db in
|
||||
// If the user is no longer a member then leave the group
|
||||
guard !updatedMemberIds.contains(userPublicKey) else { return }
|
||||
|
||||
try MessageSender.leave(
|
||||
db,
|
||||
groupPublicKey: threadId,
|
||||
deleteThread: true
|
||||
)
|
||||
|
||||
}
|
||||
.flatMap {
|
||||
MessageSender.update(
|
||||
groupPublicKey: threadId,
|
||||
with: updatedMemberIds,
|
||||
name: updatedName
|
||||
)
|
||||
}
|
||||
.done(on: DispatchQueue.main) { [weak self] in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
popToConversationVC(self)
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { [weak self] error in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
self?.showError(title: "GROUP_UPDATE_ERROR_TITLE".localized(), message: error.localizedDescription)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { [weak self] result in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
|
||||
switch result {
|
||||
case .finished: popToConversationVC(self)
|
||||
case .failure(let error):
|
||||
self?.showError(
|
||||
title: "GROUP_UPDATE_ERROR_TITLE".localized(),
|
||||
message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -485,7 +509,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
targetView: self.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: title,
|
||||
explanation: message,
|
||||
body: .text(message),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
import UIKit
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
@ -208,15 +207,17 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
cell.update(
|
||||
with: SessionCell.Info(
|
||||
id: profile,
|
||||
leftAccessory: .profile(profile.id, profile),
|
||||
position: Position.with(indexPath.row, count: data[indexPath.section].elements.count),
|
||||
leftAccessory: .profile(id: profile.id, profile: profile),
|
||||
title: profile.displayName(),
|
||||
rightAccessory: .radio(isSelected: { [weak self] in
|
||||
self?.selectedContacts.contains(profile.id) == true
|
||||
}),
|
||||
accessibilityIdentifier: "Contact"
|
||||
),
|
||||
style: .edgeToEdge,
|
||||
position: Position.with(indexPath.row, count: data[indexPath.section].elements.count)
|
||||
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
|
||||
accessibility: Accessibility(
|
||||
identifier: "Contact"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return cell
|
||||
|
@ -305,7 +306,7 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: title,
|
||||
explanation: message,
|
||||
body: .text(message),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
|
||||
|
@ -319,7 +320,7 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
else {
|
||||
return showError(title: "vc_create_closed_group_group_name_missing_error".localized())
|
||||
}
|
||||
guard name.count < 30 else {
|
||||
guard name.utf8CString.count < SessionUtil.libSessionMaxGroupNameByteLength else {
|
||||
return showError(title: "vc_create_closed_group_group_name_too_long_error".localized())
|
||||
}
|
||||
guard selectedContacts.count >= 1 else {
|
||||
|
@ -331,33 +332,38 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
let selectedContacts = self.selectedContacts
|
||||
let message: String? = (selectedContacts.count > 20 ? "GROUP_CREATION_PLEASE_WAIT".localized() : nil)
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in
|
||||
Storage.shared
|
||||
.writeAsync { db in
|
||||
try MessageSender.createClosedGroup(db, name: name, members: selectedContacts)
|
||||
}
|
||||
.done(on: DispatchQueue.main) { thread in
|
||||
Storage.shared.writeAsync { db in
|
||||
try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
|
||||
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
SessionApp.presentConversation(for: thread.id, action: .compose, animated: false)
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { [weak self] _ in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "GROUP_CREATION_ERROR_TITLE".localized(),
|
||||
explanation: "GROUP_CREATION_ERROR_MESSAGE".localized(),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
MessageSender
|
||||
.createClosedGroup(name: name, members: selectedContacts)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure:
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "GROUP_CREATION_ERROR_TITLE".localized(),
|
||||
body: .text("GROUP_CREATION_ERROR_MESSAGE".localized()),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
},
|
||||
receiveValue: { thread in
|
||||
SessionApp.presentConversationCreatingIfNeeded(
|
||||
for: thread.id,
|
||||
variant: thread.variant,
|
||||
dismissing: self?.presentingViewController,
|
||||
animated: false
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension ContextMenuVC {
|
||||
struct Action {
|
||||
|
@ -35,15 +36,15 @@ extension ContextMenuVC {
|
|||
|
||||
// MARK: - Actions
|
||||
|
||||
static func info(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func info(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_info"),
|
||||
title: "context_menu_info".localized(),
|
||||
accessibilityLabel: "Message info"
|
||||
) { delegate?.info(cellViewModel) }
|
||||
) { delegate?.info(cellViewModel, using: dependencies) }
|
||||
}
|
||||
|
||||
static func retry(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func retry(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(systemName: "arrow.triangle.2.circlepath"),
|
||||
title: (cellViewModel.state == .failedToSync ?
|
||||
|
@ -51,23 +52,23 @@ extension ContextMenuVC {
|
|||
"context_menu_resend".localized()
|
||||
),
|
||||
accessibilityLabel: (cellViewModel.state == .failedToSync ? "Resync message" : "Resend message")
|
||||
) { delegate?.retry(cellViewModel) }
|
||||
) { delegate?.retry(cellViewModel, using: dependencies) }
|
||||
}
|
||||
|
||||
static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_reply"),
|
||||
title: "context_menu_reply".localized(),
|
||||
accessibilityLabel: "Reply to message"
|
||||
) { delegate?.reply(cellViewModel) }
|
||||
) { delegate?.reply(cellViewModel, using: dependencies) }
|
||||
}
|
||||
|
||||
static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_copy"),
|
||||
title: "copy".localized(),
|
||||
accessibilityLabel: "Copy text"
|
||||
) { delegate?.copy(cellViewModel) }
|
||||
) { delegate?.copy(cellViewModel, using: dependencies) }
|
||||
}
|
||||
|
||||
static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
|
@ -79,50 +80,50 @@ extension ContextMenuVC {
|
|||
) { delegate?.copySessionID(cellViewModel) }
|
||||
}
|
||||
|
||||
static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_trash"),
|
||||
title: "TXT_DELETE_TITLE".localized(),
|
||||
accessibilityLabel: "Delete message"
|
||||
) { delegate?.delete(cellViewModel) }
|
||||
) { delegate?.delete(cellViewModel, using: dependencies) }
|
||||
}
|
||||
|
||||
static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_download"),
|
||||
title: "context_menu_save".localized(),
|
||||
accessibilityLabel: "Save attachment"
|
||||
) { delegate?.save(cellViewModel) }
|
||||
) { delegate?.save(cellViewModel, using: dependencies) }
|
||||
}
|
||||
|
||||
static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_block"),
|
||||
title: "context_menu_ban_user".localized(),
|
||||
accessibilityLabel: "Ban user"
|
||||
) { delegate?.ban(cellViewModel) }
|
||||
) { delegate?.ban(cellViewModel, using: dependencies) }
|
||||
}
|
||||
|
||||
static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_block"),
|
||||
title: "context_menu_ban_and_delete_all".localized(),
|
||||
accessibilityLabel: "Ban user and delete"
|
||||
) { delegate?.banAndDeleteAllMessages(cellViewModel) }
|
||||
) { delegate?.banAndDeleteAllMessages(cellViewModel, using: dependencies) }
|
||||
}
|
||||
|
||||
static func react(_ cellViewModel: MessageViewModel, _ emoji: EmojiWithSkinTones, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func react(_ cellViewModel: MessageViewModel, _ emoji: EmojiWithSkinTones, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action {
|
||||
return Action(
|
||||
title: emoji.rawValue,
|
||||
isEmojiAction: true
|
||||
) { delegate?.react(cellViewModel, with: emoji) }
|
||||
) { delegate?.react(cellViewModel, with: emoji, using: dependencies) }
|
||||
}
|
||||
|
||||
static func emojiPlusButton(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
static func emojiPlusButton(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, using dependencies: Dependencies) -> Action {
|
||||
return Action(
|
||||
isEmojiPlus: true,
|
||||
accessibilityLabel: "Add emoji"
|
||||
) { delegate?.showFullEmojiKeyboard(cellViewModel) }
|
||||
) { delegate?.showFullEmojiKeyboard(cellViewModel, using: dependencies) }
|
||||
}
|
||||
|
||||
static func dismiss(_ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
|
@ -131,15 +132,27 @@ extension ContextMenuVC {
|
|||
) { delegate?.contextMenuDismissed() }
|
||||
}
|
||||
}
|
||||
|
||||
static func viewModelCanReply(_ cellViewModel: MessageViewModel) -> Bool {
|
||||
return (
|
||||
cellViewModel.variant == .standardIncoming || (
|
||||
cellViewModel.variant == .standardOutgoing &&
|
||||
cellViewModel.state != .failed &&
|
||||
cellViewModel.state != .sending
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
static func actions(
|
||||
for cellViewModel: MessageViewModel,
|
||||
recentEmojis: [EmojiWithSkinTones],
|
||||
currentUserPublicKey: String,
|
||||
currentUserBlindedPublicKey: String?,
|
||||
currentUserBlinded15PublicKey: String?,
|
||||
currentUserBlinded25PublicKey: String?,
|
||||
currentUserIsOpenGroupModerator: Bool,
|
||||
currentThreadIsMessageRequest: Bool,
|
||||
delegate: ContextMenuActionDelegate?
|
||||
delegate: ContextMenuActionDelegate?,
|
||||
using dependencies: Dependencies = Dependencies()
|
||||
) -> [Action]? {
|
||||
switch cellViewModel.variant {
|
||||
case .standardIncomingDeleted, .infoCall,
|
||||
|
@ -148,7 +161,7 @@ extension ContextMenuVC {
|
|||
.infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving,
|
||||
.infoMessageRequestAccepted, .infoDisappearingMessagesUpdate:
|
||||
// Let the user delete info messages and unsent messages
|
||||
return [ Action.delete(cellViewModel, delegate) ]
|
||||
return [ Action.delete(cellViewModel, delegate, using: dependencies) ]
|
||||
|
||||
case .standardOutgoing, .standardIncoming: break
|
||||
}
|
||||
|
@ -161,12 +174,6 @@ extension ContextMenuVC {
|
|||
)
|
||||
)
|
||||
)
|
||||
let canReply: Bool = (
|
||||
cellViewModel.variant != .standardOutgoing || (
|
||||
cellViewModel.state != .failed &&
|
||||
cellViewModel.state != .sending
|
||||
)
|
||||
)
|
||||
let canCopy: Bool = (
|
||||
cellViewModel.cellType == .textOnlyMessage || (
|
||||
(
|
||||
|
@ -194,23 +201,27 @@ extension ContextMenuVC {
|
|||
)
|
||||
let canCopySessionId: Bool = (
|
||||
cellViewModel.variant == .standardIncoming &&
|
||||
cellViewModel.threadVariant != .openGroup
|
||||
cellViewModel.threadVariant != .community
|
||||
)
|
||||
let canDelete: Bool = (
|
||||
cellViewModel.threadVariant != .openGroup ||
|
||||
cellViewModel.threadVariant != .community ||
|
||||
currentUserIsOpenGroupModerator ||
|
||||
cellViewModel.authorId == currentUserPublicKey ||
|
||||
cellViewModel.authorId == currentUserBlindedPublicKey ||
|
||||
cellViewModel.authorId == currentUserBlinded15PublicKey ||
|
||||
cellViewModel.authorId == currentUserBlinded25PublicKey ||
|
||||
cellViewModel.state == .failed
|
||||
)
|
||||
let canBan: Bool = (
|
||||
cellViewModel.threadVariant == .openGroup &&
|
||||
cellViewModel.threadVariant == .community &&
|
||||
currentUserIsOpenGroupModerator
|
||||
)
|
||||
|
||||
let shouldShowEmojiActions: Bool = {
|
||||
if cellViewModel.threadVariant == .openGroup {
|
||||
return OpenGroupManager.isOpenGroupSupport(.reactions, on: cellViewModel.threadOpenGroupServer)
|
||||
if cellViewModel.threadVariant == .community {
|
||||
return OpenGroupManager.doesOpenGroupSupport(
|
||||
capability: .reactions,
|
||||
on: cellViewModel.threadOpenGroupServer
|
||||
)
|
||||
}
|
||||
return !currentThreadIsMessageRequest
|
||||
}()
|
||||
|
@ -218,18 +229,21 @@ extension ContextMenuVC {
|
|||
let shouldShowInfo: Bool = (cellViewModel.attachments?.isEmpty == false)
|
||||
|
||||
let generatedActions: [Action] = [
|
||||
(canRetry ? Action.retry(cellViewModel, delegate) : nil),
|
||||
(canReply ? Action.reply(cellViewModel, delegate) : nil),
|
||||
(canCopy ? Action.copy(cellViewModel, delegate) : nil),
|
||||
(canSave ? Action.save(cellViewModel, delegate) : nil),
|
||||
(canRetry ? Action.retry(cellViewModel, delegate, using: dependencies) : nil),
|
||||
(viewModelCanReply(cellViewModel) ? Action.reply(cellViewModel, delegate, using: dependencies) : nil),
|
||||
(canCopy ? Action.copy(cellViewModel, delegate, using: dependencies) : nil),
|
||||
(canSave ? Action.save(cellViewModel, delegate, using: dependencies) : nil),
|
||||
(canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil),
|
||||
(canDelete ? Action.delete(cellViewModel, delegate) : nil),
|
||||
(canBan ? Action.ban(cellViewModel, delegate) : nil),
|
||||
(canBan ? Action.banAndDeleteAllMessages(cellViewModel, delegate) : nil),
|
||||
(shouldShowInfo ? Action.info(cellViewModel, delegate) : nil),
|
||||
(canDelete ? Action.delete(cellViewModel, delegate, using: dependencies) : nil),
|
||||
(canBan ? Action.ban(cellViewModel, delegate, using: dependencies) : nil),
|
||||
(canBan ? Action.banAndDeleteAllMessages(cellViewModel, delegate, using: dependencies) : nil),
|
||||
(shouldShowInfo ? Action.info(cellViewModel, delegate, using: dependencies) : nil),
|
||||
]
|
||||
.appending(contentsOf: (shouldShowEmojiActions ? recentEmojis : []).map { Action.react(cellViewModel, $0, delegate) })
|
||||
.appending(Action.emojiPlusButton(cellViewModel, delegate))
|
||||
.appending(
|
||||
contentsOf: (shouldShowEmojiActions ? recentEmojis : [])
|
||||
.map { Action.react(cellViewModel, $0, delegate, using: dependencies) }
|
||||
)
|
||||
.appending(Action.emojiPlusButton(cellViewModel, delegate, using: dependencies))
|
||||
.compactMap { $0 }
|
||||
|
||||
guard !generatedActions.isEmpty else { return [] }
|
||||
|
@ -241,16 +255,16 @@ extension ContextMenuVC {
|
|||
// MARK: - Delegate
|
||||
|
||||
protocol ContextMenuActionDelegate {
|
||||
func info(_ cellViewModel: MessageViewModel)
|
||||
func retry(_ cellViewModel: MessageViewModel)
|
||||
func reply(_ cellViewModel: MessageViewModel)
|
||||
func copy(_ cellViewModel: MessageViewModel)
|
||||
func info(_ cellViewModel: MessageViewModel, using dependencies: Dependencies)
|
||||
func retry(_ cellViewModel: MessageViewModel, using dependencies: Dependencies)
|
||||
func reply(_ cellViewModel: MessageViewModel, using dependencies: Dependencies)
|
||||
func copy(_ cellViewModel: MessageViewModel, using dependencies: Dependencies)
|
||||
func copySessionID(_ cellViewModel: MessageViewModel)
|
||||
func delete(_ cellViewModel: MessageViewModel)
|
||||
func save(_ cellViewModel: MessageViewModel)
|
||||
func ban(_ cellViewModel: MessageViewModel)
|
||||
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel)
|
||||
func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones)
|
||||
func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel)
|
||||
func delete(_ cellViewModel: MessageViewModel, using dependencies: Dependencies)
|
||||
func save(_ cellViewModel: MessageViewModel, using dependencies: Dependencies)
|
||||
func ban(_ cellViewModel: MessageViewModel, using dependencies: Dependencies)
|
||||
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, using dependencies: Dependencies)
|
||||
func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones, using dependencies: Dependencies)
|
||||
func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel, using dependencies: Dependencies)
|
||||
func contextMenuDismissed()
|
||||
}
|
||||
|
|
|
@ -71,10 +71,12 @@ final class ContextMenuVC: UIViewController {
|
|||
|
||||
private lazy var fallbackTimestampLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
result.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
result.text = cellViewModel.dateForUI.formattedForDisplay
|
||||
result.themeTextColor = .textPrimary
|
||||
result.alpha = 0
|
||||
result.numberOfLines = 2
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -189,10 +191,14 @@ final class ContextMenuVC: UIViewController {
|
|||
fallbackTimestampLabel.set(.height, to: ContextMenuVC.actionViewHeight)
|
||||
|
||||
if cellViewModel.variant == .standardOutgoing {
|
||||
fallbackTimestampLabel.textAlignment = .right
|
||||
fallbackTimestampLabel.pin(.right, to: .left, of: menuView, withInset: -Values.mediumSpacing)
|
||||
fallbackTimestampLabel.pin(.left, to: .left, of: view, withInset: Values.mediumSpacing)
|
||||
}
|
||||
else {
|
||||
fallbackTimestampLabel.textAlignment = .left
|
||||
fallbackTimestampLabel.pin(.left, to: .right, of: menuView, withInset: Values.mediumSpacing)
|
||||
fallbackTimestampLabel.pin(.right, to: .right, of: view, withInset: -Values.mediumSpacing)
|
||||
}
|
||||
|
||||
// Constrains
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
final class ContextMenuWindow : UIWindow {
|
||||
|
||||
|
|
|
@ -3,6 +3,9 @@
|
|||
import UIKit
|
||||
import GRDB
|
||||
import SignalUtilitiesKit
|
||||
import SignalCoreKit
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public class StyledSearchController: UISearchController {
|
||||
public override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
|
@ -83,7 +86,7 @@ extension ConversationSearchController: UISearchResultsUpdating {
|
|||
let threadId: String = self.threadId
|
||||
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
let results: [Int64]? = Storage.shared.read { db -> [Int64] in
|
||||
let results: [Interaction.TimestampInfo]? = Storage.shared.read { db -> [Interaction.TimestampInfo] in
|
||||
self?.resultsBar.willStartSearching(readConnection: db)
|
||||
|
||||
return try Interaction.idsForTermWithin(
|
||||
|
@ -96,7 +99,7 @@ extension ConversationSearchController: UISearchResultsUpdating {
|
|||
// If we didn't get results back then we most likely interrupted the query so
|
||||
// should ignore the results (if there are no results we would succeed and get
|
||||
// an empty array back)
|
||||
guard let results: [Int64] = results else { return }
|
||||
guard let results: [Interaction.TimestampInfo] = results else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard let strongSelf = self else { return }
|
||||
|
@ -115,11 +118,11 @@ extension ConversationSearchController: SearchResultsBarDelegate {
|
|||
func searchResultsBar(
|
||||
_ searchResultsBar: SearchResultsBar,
|
||||
setCurrentIndex currentIndex: Int,
|
||||
results: [Int64]
|
||||
results: [Interaction.TimestampInfo]
|
||||
) {
|
||||
guard let interactionId: Int64 = results[safe: currentIndex] else { return }
|
||||
guard let interactionInfo: Interaction.TimestampInfo = results[safe: currentIndex] else { return }
|
||||
|
||||
self.delegate?.conversationSearchController(self, didSelectInteractionId: interactionId)
|
||||
self.delegate?.conversationSearchController(self, didSelectInteractionInfo: interactionInfo)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -127,13 +130,13 @@ protocol SearchResultsBarDelegate: AnyObject {
|
|||
func searchResultsBar(
|
||||
_ searchResultsBar: SearchResultsBar,
|
||||
setCurrentIndex currentIndex: Int,
|
||||
results: [Int64]
|
||||
results: [Interaction.TimestampInfo]
|
||||
)
|
||||
}
|
||||
|
||||
public final class SearchResultsBar: UIView {
|
||||
private var readConnection: Atomic<Database?> = Atomic(nil)
|
||||
private var results: Atomic<[Int64]?> = Atomic(nil)
|
||||
private var results: Atomic<[Interaction.TimestampInfo]?> = Atomic(nil)
|
||||
|
||||
var currentIndex: Int?
|
||||
weak var resultsBarDelegate: SearchResultsBarDelegate?
|
||||
|
@ -248,7 +251,7 @@ public final class SearchResultsBar: UIView {
|
|||
// MARK: - Actions
|
||||
|
||||
@objc public func handleUpButtonTapped() {
|
||||
guard let results: [Int64] = results.wrappedValue else { return }
|
||||
guard let results: [Interaction.TimestampInfo] = results.wrappedValue else { return }
|
||||
guard let currentIndex: Int = currentIndex else { return }
|
||||
guard currentIndex + 1 < results.count else { return }
|
||||
|
||||
|
@ -260,7 +263,7 @@ public final class SearchResultsBar: UIView {
|
|||
|
||||
@objc public func handleDownButtonTapped() {
|
||||
Logger.debug("")
|
||||
guard let results: [Int64] = results.wrappedValue else { return }
|
||||
guard let results: [Interaction.TimestampInfo] = results.wrappedValue else { return }
|
||||
guard let currentIndex: Int = currentIndex, currentIndex > 0 else { return }
|
||||
|
||||
let newIndex = currentIndex - 1
|
||||
|
@ -287,12 +290,12 @@ public final class SearchResultsBar: UIView {
|
|||
self.readConnection.mutate { $0 = readConnection }
|
||||
}
|
||||
|
||||
func updateResults(results: [Int64]?) {
|
||||
func updateResults(results: [Interaction.TimestampInfo]?) {
|
||||
// We want to ignore search results that don't match the current searchId (this
|
||||
// will happen when searching large threads with short terms as the shorter terms
|
||||
// will take much longer to resolve than the longer terms)
|
||||
currentIndex = {
|
||||
guard let results: [Int64] = results, !results.isEmpty else { return nil }
|
||||
guard let results: [Interaction.TimestampInfo] = results, !results.isEmpty else { return nil }
|
||||
|
||||
if let currentIndex: Int = currentIndex {
|
||||
return max(0, min(currentIndex, results.count - 1))
|
||||
|
@ -312,10 +315,11 @@ public final class SearchResultsBar: UIView {
|
|||
}
|
||||
|
||||
func updateBarItems() {
|
||||
guard let results: [Int64] = results.wrappedValue else {
|
||||
guard let results: [Interaction.TimestampInfo] = results.wrappedValue else {
|
||||
label.text = ""
|
||||
downButton.isEnabled = false
|
||||
upButton.isEnabled = false
|
||||
stopLoading()
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -362,6 +366,6 @@ public final class SearchResultsBar: UIView {
|
|||
// MARK: - ConversationSearchControllerDelegate
|
||||
|
||||
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate {
|
||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?)
|
||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId: Int64)
|
||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Interaction.TimestampInfo]?, searchText: String?)
|
||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionInfo: Interaction.TimestampInfo)
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionMessagingKit
|
||||
|
@ -9,6 +10,13 @@ import SessionUtilitiesKit
|
|||
public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||
public typealias SectionModel = ArraySection<Section, MessageViewModel>
|
||||
|
||||
// MARK: - FocusBehaviour
|
||||
|
||||
public enum FocusBehaviour {
|
||||
case none
|
||||
case highlight
|
||||
}
|
||||
|
||||
// MARK: - Action
|
||||
|
||||
public enum Action {
|
||||
|
@ -34,14 +42,20 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
public let initialThreadVariant: SessionThread.Variant
|
||||
public var sentMessageBeforeUpdate: Bool = false
|
||||
public var lastSearchedText: String?
|
||||
public let focusedInteractionId: Int64? // Note: This is used for global search
|
||||
public let focusedInteractionInfo: Interaction.TimestampInfo? // Note: This is used for global search
|
||||
public let focusBehaviour: FocusBehaviour
|
||||
private let initialUnreadInteractionId: Int64?
|
||||
private let markAsReadTrigger: PassthroughSubject<(SessionThreadViewModel.ReadTarget, Int64?), Never> = PassthroughSubject()
|
||||
private var markAsReadPublisher: AnyPublisher<Void, Never>?
|
||||
|
||||
public lazy var blockedBannerMessage: String = {
|
||||
switch self.threadData.threadVariant {
|
||||
let threadData: SessionThreadViewModel = self._threadData.wrappedValue
|
||||
|
||||
switch threadData.threadVariant {
|
||||
case .contact:
|
||||
let name: String = Profile.displayName(
|
||||
id: self.threadData.threadId,
|
||||
threadVariant: self.threadData.threadVariant
|
||||
id: threadData.threadId,
|
||||
threadVariant: threadData.threadVariant
|
||||
)
|
||||
|
||||
return "\(name) is blocked. Unblock them?"
|
||||
|
@ -52,28 +66,95 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64?) {
|
||||
// If we have a specified 'focusedInteractionId' then use that, otherwise retrieve the oldest
|
||||
// unread interaction and start focused around that one
|
||||
let targetInteractionId: Int64? = {
|
||||
if let focusedInteractionId: Int64 = focusedInteractionId { return focusedInteractionId }
|
||||
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo?) {
|
||||
typealias InitialData = (
|
||||
currentUserPublicKey: String,
|
||||
initialUnreadInteractionInfo: Interaction.TimestampInfo?,
|
||||
threadIsBlocked: Bool,
|
||||
currentUserIsClosedGroupMember: Bool?,
|
||||
openGroupPermissions: OpenGroup.Permissions?,
|
||||
blinded15Key: String?,
|
||||
blinded25Key: String?
|
||||
)
|
||||
|
||||
let initialData: InitialData? = Storage.shared.read { db -> InitialData in
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
|
||||
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
return Storage.shared.read { db in
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
|
||||
return try Interaction
|
||||
.select(.id)
|
||||
.filter(interaction[.wasRead] == false)
|
||||
.filter(interaction[.threadId] == threadId)
|
||||
.order(interaction[.timestampMs].asc)
|
||||
.asRequest(of: Int64.self)
|
||||
// If we have a specified 'focusedInteractionInfo' then use that, otherwise retrieve the oldest
|
||||
// unread interaction and start focused around that one
|
||||
let initialUnreadInteractionInfo: Interaction.TimestampInfo? = try Interaction
|
||||
.select(.id, .timestampMs)
|
||||
.filter(interaction[.wasRead] == false)
|
||||
.filter(interaction[.threadId] == threadId)
|
||||
.order(interaction[.timestampMs].asc)
|
||||
.asRequest(of: Interaction.TimestampInfo.self)
|
||||
.fetchOne(db)
|
||||
let threadIsBlocked: Bool = (threadVariant != .contact ? false :
|
||||
try Contact
|
||||
.filter(id: threadId)
|
||||
.select(.isBlocked)
|
||||
.asRequest(of: Bool.self)
|
||||
.fetchOne(db)
|
||||
}
|
||||
}()
|
||||
.defaulting(to: false)
|
||||
)
|
||||
let currentUserIsClosedGroupMember: Bool? = (![.legacyGroup, .group].contains(threadVariant) ? nil :
|
||||
GroupMember
|
||||
.filter(groupMember[.groupId] == threadId)
|
||||
.filter(groupMember[.profileId] == currentUserPublicKey)
|
||||
.filter(groupMember[.role] == GroupMember.Role.standard)
|
||||
.isNotEmpty(db)
|
||||
)
|
||||
let openGroupPermissions: OpenGroup.Permissions? = (threadVariant != .community ? nil :
|
||||
try OpenGroup
|
||||
.filter(id: threadId)
|
||||
.select(.permissions)
|
||||
.asRequest(of: OpenGroup.Permissions.self)
|
||||
.fetchOne(db)
|
||||
)
|
||||
let blinded15Key: String? = SessionThread.getUserHexEncodedBlindedKey(
|
||||
db,
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
blindingPrefix: .blinded15
|
||||
)
|
||||
let blinded25Key: String? = SessionThread.getUserHexEncodedBlindedKey(
|
||||
db,
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
blindingPrefix: .blinded25
|
||||
)
|
||||
|
||||
return (
|
||||
currentUserPublicKey,
|
||||
initialUnreadInteractionInfo,
|
||||
threadIsBlocked,
|
||||
currentUserIsClosedGroupMember,
|
||||
openGroupPermissions,
|
||||
blinded15Key,
|
||||
blinded25Key
|
||||
)
|
||||
}
|
||||
|
||||
self.threadId = threadId
|
||||
self.initialThreadVariant = threadVariant
|
||||
self.focusedInteractionId = targetInteractionId
|
||||
self.focusedInteractionInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo)
|
||||
self.focusBehaviour = (focusedInteractionInfo == nil ? .none : .highlight)
|
||||
self.initialUnreadInteractionId = initialData?.initialUnreadInteractionInfo?.id
|
||||
self._threadData = Atomic(
|
||||
SessionThreadViewModel(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
threadIsNoteToSelf: (initialData?.currentUserPublicKey == threadId),
|
||||
threadIsBlocked: initialData?.threadIsBlocked,
|
||||
currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember,
|
||||
openGroupPermissions: initialData?.openGroupPermissions
|
||||
).populatingCurrentUserBlindedKeys(
|
||||
currentUserBlinded15PublicKeyForThisThread: initialData?.blinded15Key,
|
||||
currentUserBlinded25PublicKeyForThisThread: initialData?.blinded25Key
|
||||
)
|
||||
)
|
||||
self.pagedDataObserver = nil
|
||||
|
||||
// Note: Since this references self we need to finish initializing before setting it, we
|
||||
|
@ -82,44 +163,30 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
// distinct stutter)
|
||||
self.pagedDataObserver = self.setupPagedObserver(
|
||||
for: threadId,
|
||||
userPublicKey: getUserHexEncodedPublicKey(),
|
||||
blindedPublicKey: SessionThread.getUserHexEncodedBlindedKey(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant
|
||||
)
|
||||
userPublicKey: (initialData?.currentUserPublicKey ?? getUserHexEncodedPublicKey()),
|
||||
blinded15PublicKey: initialData?.blinded15Key,
|
||||
blinded25PublicKey: initialData?.blinded25Key
|
||||
)
|
||||
|
||||
// Run the initial query on a background thread so we don't block the push transition
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
// If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query
|
||||
// If we don't have a `initialFocusedInfo` then default to `.pageBefore` (it'll query
|
||||
// from a `0` offset)
|
||||
guard let initialFocusedId: Int64 = targetInteractionId else {
|
||||
guard let initialFocusedInfo: Interaction.TimestampInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo) else {
|
||||
self?.pagedDataObserver?.load(.pageBefore)
|
||||
return
|
||||
}
|
||||
|
||||
self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId))
|
||||
self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedInfo.id))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Thread Data
|
||||
|
||||
private var _threadData: Atomic<SessionThreadViewModel>
|
||||
|
||||
/// This value is the current state of the view
|
||||
public private(set) lazy var threadData: SessionThreadViewModel = SessionThreadViewModel(
|
||||
threadId: self.threadId,
|
||||
threadVariant: self.initialThreadVariant,
|
||||
currentUserIsClosedGroupMember: (self.initialThreadVariant != .closedGroup ?
|
||||
nil :
|
||||
Storage.shared.read { db in
|
||||
try GroupMember
|
||||
.filter(GroupMember.Columns.groupId == self.threadId)
|
||||
.filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db))
|
||||
.filter(GroupMember.Columns.role == GroupMember.Role.standard)
|
||||
.isNotEmpty(db)
|
||||
}
|
||||
)
|
||||
)
|
||||
.populatingCurrentUserBlindedKey()
|
||||
public var threadData: SessionThreadViewModel { _threadData.wrappedValue }
|
||||
|
||||
/// 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
|
||||
|
@ -131,13 +198,15 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
||||
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||
public lazy var observableThreadData: ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> = setupObservableThreadData(for: self.threadId)
|
||||
public typealias ThreadObservation = ValueObservation<ValueReducers.Trace<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>>>
|
||||
public lazy var observableThreadData: ThreadObservation = setupObservableThreadData(for: self.threadId)
|
||||
|
||||
private func setupObservableThreadData(for threadId: String) -> ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> {
|
||||
private func setupObservableThreadData(for threadId: String) -> ThreadObservation {
|
||||
return ValueObservation
|
||||
.trackingConstantRegion { [weak self] db -> SessionThreadViewModel? in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let recentReactionEmoji: [String] = try Emoji.getRecent(db, withDefaultEmoji: true)
|
||||
let oldThreadData: SessionThreadViewModel? = self?._threadData.wrappedValue
|
||||
let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
||||
.conversationQuery(threadId: threadId, userPublicKey: userPublicKey)
|
||||
.fetchOne(db)
|
||||
|
@ -145,22 +214,25 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
return threadViewModel
|
||||
.map { $0.with(recentReactionEmoji: recentReactionEmoji) }
|
||||
.map { viewModel -> SessionThreadViewModel in
|
||||
viewModel.populatingCurrentUserBlindedKey(
|
||||
viewModel.populatingCurrentUserBlindedKeys(
|
||||
db,
|
||||
currentUserBlindedPublicKeyForThisThread: self?.threadData.currentUserBlindedPublicKey
|
||||
currentUserBlinded15PublicKeyForThisThread: oldThreadData?.currentUserBlinded15PublicKey,
|
||||
currentUserBlinded25PublicKeyForThisThread: oldThreadData?.currentUserBlinded25PublicKey
|
||||
)
|
||||
}
|
||||
}
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[ConversationViewModel] Observation failed with error: \($0)") })
|
||||
}
|
||||
|
||||
public func updateThreadData(_ updatedData: SessionThreadViewModel) {
|
||||
self.threadData = updatedData
|
||||
self._threadData.mutate { $0 = updatedData }
|
||||
}
|
||||
|
||||
// MARK: - Interaction Data
|
||||
|
||||
private var lastInteractionIdMarkedAsRead: Int64?
|
||||
private var lastInteractionIdMarkedAsRead: Int64? = nil
|
||||
private var lastInteractionTimestampMsMarkedAsRead: Int64 = 0
|
||||
public private(set) var unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
|
||||
public private(set) var interactionData: [SectionModel] = []
|
||||
public private(set) var reactionExpandedInteractionIds: Set<Int64> = []
|
||||
|
@ -170,14 +242,25 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
didSet {
|
||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||
// data was changed while we weren't observing
|
||||
if let unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedInteractionDataChanges {
|
||||
onInteractionChange?(unobservedInteractionDataChanges.0, unobservedInteractionDataChanges.1)
|
||||
if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedInteractionDataChanges {
|
||||
let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onInteractionChange
|
||||
|
||||
switch Thread.isMainThread {
|
||||
case true: performChange?(changes.0, changes.1)
|
||||
case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) }
|
||||
}
|
||||
|
||||
self.unobservedInteractionDataChanges = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupPagedObserver(for threadId: String, userPublicKey: String, blindedPublicKey: String?) -> PagedDatabaseObserver<Interaction, MessageViewModel> {
|
||||
private func setupPagedObserver(
|
||||
for threadId: String,
|
||||
userPublicKey: String,
|
||||
blinded15PublicKey: String?,
|
||||
blinded25PublicKey: String?
|
||||
) -> PagedDatabaseObserver<Interaction, MessageViewModel> {
|
||||
return PagedDatabaseObserver(
|
||||
pagedTable: Interaction.self,
|
||||
pageSize: ConversationViewModel.pageSize,
|
||||
|
@ -189,6 +272,24 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
.allCases
|
||||
.filter { $0 != .wasRead }
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: Attachment.self,
|
||||
columns: [.state],
|
||||
joinToPagedType: {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias()
|
||||
let linkPreviewAttachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||
|
||||
return SQL("""
|
||||
LEFT JOIN \(LinkPreview.self) ON (
|
||||
\(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND
|
||||
\(Interaction.linkPreviewFilterLiteral())
|
||||
)
|
||||
LEFT JOIN \(linkPreviewAttachment) ON \(linkPreviewAttachment[.id]) = \(linkPreview[.attachmentId])
|
||||
"""
|
||||
)
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: Contact.self,
|
||||
columns: [.isTrusted],
|
||||
|
@ -196,7 +297,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])")
|
||||
return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
|
@ -206,7 +307,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
|
||||
return SQL("JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
|
@ -225,7 +326,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
orderSQL: MessageViewModel.orderSQL,
|
||||
dataQuery: MessageViewModel.baseQuery(
|
||||
userPublicKey: userPublicKey,
|
||||
blindedPublicKey: blindedPublicKey,
|
||||
blinded15PublicKey: blinded15PublicKey,
|
||||
blinded25PublicKey: blinded25PublicKey,
|
||||
orderSQL: MessageViewModel.orderSQL,
|
||||
groupSQL: MessageViewModel.groupSQL
|
||||
),
|
||||
|
@ -269,22 +371,41 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
)
|
||||
],
|
||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
self?.resolveOptimisticUpdates(with: updatedData)
|
||||
|
||||
PagedData.processAndTriggerUpdates(
|
||||
updatedData: self?.process(data: updatedData, for: updatedPageInfo),
|
||||
updatedData: self?.process(
|
||||
data: updatedData,
|
||||
for: updatedPageInfo,
|
||||
optimisticMessages: (self?.optimisticallyInsertedMessages.wrappedValue.values)
|
||||
.map { $0.map { $0.messageViewModel } },
|
||||
initialUnreadInteractionId: self?.initialUnreadInteractionId
|
||||
),
|
||||
currentDataRetriever: { self?.interactionData },
|
||||
onDataChange: self?.onInteractionChange,
|
||||
onUnobservedDataChange: { updatedData, changeset in
|
||||
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 threadData: SessionThreadViewModel = self._threadData.wrappedValue
|
||||
let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true })
|
||||
let sortedData: [MessageViewModel] = data
|
||||
.filter { $0.isTypingIndicator != true }
|
||||
.filter { $0.id != MessageViewModel.optimisticUpdateId } // Remove old optimistic updates
|
||||
.appending(contentsOf: (optimisticMessages ?? [])) // Insert latest optimistic updates
|
||||
.filter { !$0.cellType.isPostProcessed } // Remove headers and other
|
||||
.sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs }
|
||||
|
||||
// We load messages from newest to oldest so having a pageOffset larger than zero means
|
||||
|
@ -314,20 +435,31 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
cellViewModel.id == sortedData
|
||||
.filter {
|
||||
$0.authorId == threadData.currentUserPublicKey ||
|
||||
$0.authorId == threadData.currentUserBlindedPublicKey
|
||||
$0.authorId == threadData.currentUserBlinded15PublicKey ||
|
||||
$0.authorId == threadData.currentUserBlinded25PublicKey
|
||||
}
|
||||
.last?
|
||||
.id
|
||||
),
|
||||
currentUserBlindedPublicKey: threadData.currentUserBlindedPublicKey
|
||||
currentUserBlinded15PublicKey: threadData.currentUserBlinded15PublicKey,
|
||||
currentUserBlinded25PublicKey: threadData.currentUserBlinded25PublicKey
|
||||
)
|
||||
}
|
||||
.reduce([]) { result, message in
|
||||
let updatedResult: [MessageViewModel] = result
|
||||
.appending(initialUnreadInteractionId == nil || message.id != initialUnreadInteractionId ?
|
||||
nil :
|
||||
MessageViewModel(
|
||||
timestampMs: message.timestampMs,
|
||||
cellType: .unreadMarker
|
||||
)
|
||||
)
|
||||
|
||||
guard message.shouldShowDateHeader else {
|
||||
return result.appending(message)
|
||||
return updatedResult.appending(message)
|
||||
}
|
||||
|
||||
return result
|
||||
return updatedResult
|
||||
.appending(
|
||||
MessageViewModel(
|
||||
timestampMs: message.timestampMs,
|
||||
|
@ -350,24 +482,198 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
self.interactionData = updatedData
|
||||
}
|
||||
|
||||
public func expandReactions(for interactionId: Int64) {
|
||||
reactionExpandedInteractionIds.insert(interactionId)
|
||||
// MARK: - Optimistic Message Handling
|
||||
|
||||
public typealias OptimisticMessageData = (
|
||||
id: UUID,
|
||||
messageViewModel: MessageViewModel,
|
||||
interaction: Interaction,
|
||||
attachmentData: Attachment.PreparedData?,
|
||||
linkPreviewDraft: LinkPreviewDraft?,
|
||||
linkPreviewAttachment: Attachment?,
|
||||
quoteModel: QuotedReplyModel?
|
||||
)
|
||||
|
||||
private var optimisticallyInsertedMessages: Atomic<[UUID: OptimisticMessageData]> = Atomic([:])
|
||||
private var optimisticMessageAssociatedInteractionIds: Atomic<[Int64: UUID]> = Atomic([:])
|
||||
|
||||
public func optimisticallyAppendOutgoingMessage(
|
||||
text: String?,
|
||||
sentTimestampMs: Int64,
|
||||
attachments: [SignalAttachment]?,
|
||||
linkPreviewDraft: LinkPreviewDraft?,
|
||||
quoteModel: QuotedReplyModel?
|
||||
) -> OptimisticMessageData {
|
||||
// Generate the optimistic data
|
||||
let optimisticMessageId: UUID = UUID()
|
||||
let threadData: SessionThreadViewModel = self._threadData.wrappedValue
|
||||
let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser()
|
||||
let interaction: Interaction = Interaction(
|
||||
threadId: threadData.threadId,
|
||||
authorId: (threadData.currentUserBlinded15PublicKey ?? threadData.currentUserPublicKey),
|
||||
variant: .standardOutgoing,
|
||||
body: text,
|
||||
timestampMs: sentTimestampMs,
|
||||
hasMention: Interaction.isUserMentioned(
|
||||
publicKeysToCheck: [
|
||||
threadData.currentUserPublicKey,
|
||||
threadData.currentUserBlinded15PublicKey,
|
||||
threadData.currentUserBlinded25PublicKey
|
||||
].compactMap { $0 },
|
||||
body: text
|
||||
),
|
||||
expiresInSeconds: threadData.disappearingMessagesConfiguration
|
||||
.map { disappearingConfig in
|
||||
guard disappearingConfig.isEnabled else { return nil }
|
||||
|
||||
return disappearingConfig.durationSeconds
|
||||
},
|
||||
linkPreviewUrl: linkPreviewDraft?.urlString
|
||||
)
|
||||
let optimisticAttachments: Attachment.PreparedData? = attachments
|
||||
.map { Attachment.prepare(attachments: $0) }
|
||||
let linkPreviewAttachment: Attachment? = linkPreviewDraft.map { draft in
|
||||
try? LinkPreview.generateAttachmentIfPossible(
|
||||
imageData: draft.jpegImageData,
|
||||
mimeType: OWSMimeTypeImageJpeg
|
||||
)
|
||||
}
|
||||
|
||||
// Generate the actual 'MessageViewModel'
|
||||
let messageViewModel: MessageViewModel = MessageViewModel(
|
||||
optimisticMessageId: optimisticMessageId,
|
||||
threadId: threadData.threadId,
|
||||
threadVariant: threadData.threadVariant,
|
||||
threadHasDisappearingMessagesEnabled: (threadData.disappearingMessagesConfiguration?.isEnabled ?? false),
|
||||
threadOpenGroupServer: threadData.openGroupServer,
|
||||
threadOpenGroupPublicKey: threadData.openGroupPublicKey,
|
||||
threadContactNameInternal: threadData.threadContactName(),
|
||||
timestampMs: interaction.timestampMs,
|
||||
receivedAtTimestampMs: interaction.receivedAtTimestampMs,
|
||||
authorId: interaction.authorId,
|
||||
authorNameInternal: currentUserProfile.displayName(),
|
||||
body: interaction.body,
|
||||
expiresStartedAtMs: interaction.expiresStartedAtMs,
|
||||
expiresInSeconds: interaction.expiresInSeconds,
|
||||
isSenderOpenGroupModerator: OpenGroupManager.isUserModeratorOrAdmin(
|
||||
threadData.currentUserPublicKey,
|
||||
for: threadData.openGroupRoomToken,
|
||||
on: threadData.openGroupServer
|
||||
),
|
||||
currentUserProfile: currentUserProfile,
|
||||
quote: quoteModel.map { model in
|
||||
// Don't care about this optimistic quote (the proper one will be generated in the database)
|
||||
Quote(
|
||||
interactionId: -1, // Can't save to db optimistically
|
||||
authorId: model.authorId,
|
||||
timestampMs: model.timestampMs,
|
||||
body: model.body,
|
||||
attachmentId: model.attachment?.id
|
||||
)
|
||||
},
|
||||
quoteAttachment: quoteModel?.attachment,
|
||||
linkPreview: linkPreviewDraft.map { draft in
|
||||
LinkPreview(
|
||||
url: draft.urlString,
|
||||
title: draft.title,
|
||||
attachmentId: nil // Can't save to db optimistically
|
||||
)
|
||||
},
|
||||
linkPreviewAttachment: linkPreviewAttachment,
|
||||
attachments: optimisticAttachments?.attachments
|
||||
)
|
||||
let optimisticData: OptimisticMessageData = (
|
||||
optimisticMessageId,
|
||||
messageViewModel,
|
||||
interaction,
|
||||
optimisticAttachments,
|
||||
linkPreviewDraft,
|
||||
linkPreviewAttachment,
|
||||
quoteModel
|
||||
)
|
||||
|
||||
optimisticallyInsertedMessages.mutate { $0[optimisticMessageId] = optimisticData }
|
||||
forceUpdateDataIfPossible()
|
||||
|
||||
return optimisticData
|
||||
}
|
||||
|
||||
public func collapseReactions(for interactionId: Int64) {
|
||||
reactionExpandedInteractionIds.remove(interactionId)
|
||||
public func failedToStoreOptimisticOutgoingMessage(id: UUID, error: Error) {
|
||||
optimisticallyInsertedMessages.mutate {
|
||||
$0[id] = $0[id].map {
|
||||
(
|
||||
$0.id,
|
||||
$0.messageViewModel.with(
|
||||
state: .failed,
|
||||
mostRecentFailureText: "FAILED_TO_STORE_OUTGOING_MESSAGE".localized()
|
||||
),
|
||||
$0.interaction,
|
||||
$0.attachmentData,
|
||||
$0.linkPreviewDraft,
|
||||
$0.linkPreviewAttachment,
|
||||
$0.quoteModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
forceUpdateDataIfPossible()
|
||||
}
|
||||
|
||||
/// Record an association between an `optimisticMessageId` and a specific `interactionId`
|
||||
public func associate(optimisticMessageId: UUID, to interactionId: Int64?) {
|
||||
guard let interactionId: Int64 = interactionId else { return }
|
||||
|
||||
optimisticMessageAssociatedInteractionIds.mutate { $0[interactionId] = optimisticMessageId }
|
||||
}
|
||||
|
||||
public func optimisticMessageData(for optimisticMessageId: UUID) -> OptimisticMessageData? {
|
||||
return optimisticallyInsertedMessages.wrappedValue[optimisticMessageId]
|
||||
}
|
||||
|
||||
/// Remove any optimisticUpdate entries which have an associated interactionId in the provided data
|
||||
private func resolveOptimisticUpdates(with data: [MessageViewModel]) {
|
||||
let interactionIds: [Int64] = data.map { $0.id }
|
||||
let idsToRemove: [UUID] = optimisticMessageAssociatedInteractionIds
|
||||
.mutate { associatedIds in interactionIds.compactMap { associatedIds.removeValue(forKey: $0) } }
|
||||
|
||||
optimisticallyInsertedMessages.mutate { messages in idsToRemove.forEach { messages.removeValue(forKey: $0) } }
|
||||
}
|
||||
|
||||
private func forceUpdateDataIfPossible() {
|
||||
// If we can't get the current page data then don't bother trying to update (it's not going to work)
|
||||
guard let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo.wrappedValue else { return }
|
||||
|
||||
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
|
||||
let currentData: [SectionModel] = (unobservedInteractionDataChanges?.0 ?? interactionData)
|
||||
|
||||
PagedData.processAndTriggerUpdates(
|
||||
updatedData: process(
|
||||
data: (currentData.first(where: { $0.model == .messages })?.elements ?? []),
|
||||
for: currentPageInfo,
|
||||
optimisticMessages: optimisticallyInsertedMessages.wrappedValue.values.map { $0.messageViewModel },
|
||||
initialUnreadInteractionId: initialUnreadInteractionId
|
||||
),
|
||||
currentDataRetriever: { [weak self] in self?.interactionData },
|
||||
onDataChange: self.onInteractionChange,
|
||||
onUnobservedDataChange: { [weak self] updatedData, changeset in
|
||||
self?.unobservedInteractionDataChanges = (changeset.isEmpty ?
|
||||
nil :
|
||||
(updatedData, changeset)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Mentions
|
||||
|
||||
public func mentions(for query: String = "") -> [MentionInfo] {
|
||||
let threadData: SessionThreadViewModel = self.threadData
|
||||
let threadData: SessionThreadViewModel = self._threadData.wrappedValue
|
||||
|
||||
return Storage.shared
|
||||
.read { db -> [MentionInfo] in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let pattern: FTS5Pattern? = try? SessionThreadViewModel.pattern(db, searchTerm: query, forTable: Profile.self)
|
||||
let capabilities: Set<Capability.Variant> = (threadData.threadVariant != .openGroup ?
|
||||
let capabilities: Set<Capability.Variant> = (threadData.threadVariant != .community ?
|
||||
nil :
|
||||
try? Capability
|
||||
.select(.variant)
|
||||
|
@ -376,9 +682,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
.fetchSet(db)
|
||||
)
|
||||
.defaulting(to: [])
|
||||
let targetPrefix: SessionId.Prefix = (capabilities.contains(.blind) ?
|
||||
.blinded :
|
||||
.standard
|
||||
let targetPrefixes: [SessionId.Prefix] = (capabilities.contains(.blind) ?
|
||||
[.blinded15, .blinded25] :
|
||||
[.standard]
|
||||
)
|
||||
|
||||
return (try MentionInfo
|
||||
|
@ -386,7 +692,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
userPublicKey: userPublicKey,
|
||||
threadId: threadData.threadId,
|
||||
threadVariant: threadData.threadVariant,
|
||||
targetPrefix: targetPrefix,
|
||||
targetPrefixes: targetPrefixes,
|
||||
pattern: pattern
|
||||
)?
|
||||
.fetchAll(db))
|
||||
|
@ -419,37 +725,55 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
/// This method will mark all interactions as read before the specified interaction id, if no id is provided then all interactions for
|
||||
/// the thread will be marked as read
|
||||
public func markAsRead(beforeInclusive interactionId: Int64?) {
|
||||
/// This method marks a thread as read and depending on the target may also update the interactions within a thread as read
|
||||
public func markAsRead(
|
||||
target: SessionThreadViewModel.ReadTarget,
|
||||
timestampMs: Int64?
|
||||
) {
|
||||
/// Since this method now gets triggered when scrolling we want to try to optimise it and avoid busying the database
|
||||
/// write queue when it isn't needed, in order to do this we:
|
||||
/// - Throttle the updates to 100ms (quick enough that users shouldn't notice, but will help the DB when the user flings the list)
|
||||
/// - Only mark interactions as read if they have newer `timestampMs` or `id` values (ie. were sent later or were more-recent
|
||||
/// entries in the database), **Note:** Old messages will be marked as read upon insertion so shouldn't be an issue
|
||||
///
|
||||
/// - Don't bother marking anything as read if there are no unread interactions (we can rely on the
|
||||
/// `threadData.threadUnreadCount` to always be accurate)
|
||||
/// - Don't bother marking anything as read if this was called with the same `interactionId` that we
|
||||
/// previously marked as read (ie. when scrolling and the last message hasn't changed)
|
||||
guard
|
||||
(self.threadData.threadUnreadCount ?? 0) > 0,
|
||||
let targetInteractionId: Int64 = (interactionId ?? self.threadData.interactionId),
|
||||
self.lastInteractionIdMarkedAsRead != targetInteractionId
|
||||
else { return }
|
||||
|
||||
let threadId: String = self.threadData.threadId
|
||||
let threadVariant: SessionThread.Variant = self.threadData.threadVariant
|
||||
let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false)
|
||||
self.lastInteractionIdMarkedAsRead = targetInteractionId
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
try Interaction.markAsRead(
|
||||
db,
|
||||
interactionId: targetInteractionId,
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
includingOlder: true,
|
||||
trySendReadReceipt: trySendReadReceipt
|
||||
)
|
||||
/// The `ThreadViewModel.markAsRead` method also tries to avoid marking as read if a conversation is already fully read
|
||||
if markAsReadPublisher == nil {
|
||||
markAsReadPublisher = markAsReadTrigger
|
||||
.throttle(for: .milliseconds(100), scheduler: DispatchQueue.global(qos: .userInitiated), latest: true)
|
||||
.handleEvents(
|
||||
receiveOutput: { [weak self] target, timestampMs in
|
||||
let threadData: SessionThreadViewModel? = self?._threadData.wrappedValue
|
||||
|
||||
switch target {
|
||||
case .thread: threadData?.markAsRead(target: target)
|
||||
case .threadAndInteractions(let interactionId):
|
||||
guard
|
||||
timestampMs == nil ||
|
||||
(self?.lastInteractionTimestampMsMarkedAsRead ?? 0) < (timestampMs ?? 0) ||
|
||||
(self?.lastInteractionIdMarkedAsRead ?? 0) < (interactionId ?? 0)
|
||||
else {
|
||||
threadData?.markAsRead(target: .thread)
|
||||
return
|
||||
}
|
||||
|
||||
// If we were given a timestamp then update the 'lastInteractionTimestampMsMarkedAsRead'
|
||||
// to avoid needless updates
|
||||
if let timestampMs: Int64 = timestampMs {
|
||||
self?.lastInteractionTimestampMsMarkedAsRead = timestampMs
|
||||
}
|
||||
|
||||
self?.lastInteractionIdMarkedAsRead = (interactionId ?? threadData?.interactionId)
|
||||
threadData?.markAsRead(target: target)
|
||||
}
|
||||
}
|
||||
)
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
markAsReadPublisher?.sinkUntilComplete()
|
||||
}
|
||||
|
||||
markAsReadTrigger.send((target, timestampMs))
|
||||
}
|
||||
|
||||
public func swapToThread(updatedThreadId: String) {
|
||||
|
@ -465,7 +789,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
self.pagedDataObserver = self.setupPagedObserver(
|
||||
for: updatedThreadId,
|
||||
userPublicKey: getUserHexEncodedPublicKey(),
|
||||
blindedPublicKey: nil
|
||||
blinded15PublicKey: nil,
|
||||
blinded25PublicKey: nil
|
||||
)
|
||||
|
||||
// Try load everything up to the initial visible message, fallback to just the initial page of messages
|
||||
|
@ -477,7 +802,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
}
|
||||
|
||||
public func trustContact() {
|
||||
guard self.threadData.threadVariant == .contact else { return }
|
||||
guard self._threadData.wrappedValue.threadVariant == .contact else { return }
|
||||
|
||||
let threadId: String = self.threadId
|
||||
|
||||
|
@ -508,21 +833,25 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
}
|
||||
|
||||
public func unblockContact() {
|
||||
guard self.threadData.threadVariant == .contact else { return }
|
||||
guard self._threadData.wrappedValue.threadVariant == .contact else { return }
|
||||
|
||||
let threadId: String = self.threadId
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
try Contact
|
||||
.filter(id: threadId)
|
||||
.updateAll(db, Contact.Columns.isBlocked.set(to: false))
|
||||
|
||||
try MessageSender
|
||||
.syncConfiguration(db, forceSyncNow: true)
|
||||
.retainUntilComplete()
|
||||
.updateAllAndConfig(db, Contact.Columns.isBlocked.set(to: false))
|
||||
}
|
||||
}
|
||||
|
||||
public func expandReactions(for interactionId: Int64) {
|
||||
reactionExpandedInteractionIds.insert(interactionId)
|
||||
}
|
||||
|
||||
public func collapseReactions(for interactionId: Int64) {
|
||||
reactionExpandedInteractionIds.remove(interactionId)
|
||||
}
|
||||
|
||||
// MARK: - Audio Playback
|
||||
|
||||
public struct PlaybackInfo {
|
||||
|
@ -624,6 +953,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
// Then setup the state for the new audio
|
||||
currentPlayingInteraction.mutate { $0 = viewModel.id }
|
||||
|
||||
let currentPlaybackTime: TimeInterval? = playbackInfo.wrappedValue[viewModel.id]?.progress
|
||||
audioPlayer.mutate { [weak self] player in
|
||||
// Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer
|
||||
// gets deallocated it triggers state changes which cause UI bugs when auto-playing
|
||||
|
@ -636,7 +966,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
delegate: self
|
||||
)
|
||||
audioPlayer.play()
|
||||
audioPlayer.setCurrentTime(playbackInfo.wrappedValue[viewModel.id]?.progress ?? 0)
|
||||
audioPlayer.setCurrentTime(currentPlaybackTime ?? 0)
|
||||
player = audioPlayer
|
||||
}
|
||||
}
|
||||
|
@ -729,7 +1059,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
let currentIndex: Int = messageSection.elements
|
||||
.firstIndex(where: { $0.id == interactionId }),
|
||||
currentIndex < (messageSection.elements.count - 1),
|
||||
messageSection.elements[currentIndex + 1].cellType == .audio,
|
||||
messageSection.elements[currentIndex + 1].cellType == .voiceMessage,
|
||||
Storage.shared[.shouldAutoPlayConsecutiveAudioMessages] == true
|
||||
else { return }
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalCoreKit
|
||||
|
||||
protocol EmojiPickerCollectionViewDelegate: AnyObject {
|
||||
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 options: UIView.AnimationOptions = UIView.AnimationOptions(rawValue: UInt(curveValue << 16))
|
||||
|
||||
let keyboardRect: CGRect = ((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect) ?? CGRect.zero)
|
||||
let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY)
|
||||
|
||||
UIView.animate(
|
||||
withDuration: duration,
|
||||
delay: 0,
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SignalCoreKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
class EmojiSkinTonePicker: UIView {
|
||||
let emoji: Emoji
|
||||
|
|
|
@ -34,6 +34,7 @@ final class ExpandingAttachmentsButton: UIView, InputViewButtonDelegate {
|
|||
lazy var documentButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_document_black"), delegate: self, hasOpaqueBackground: true)
|
||||
result.accessibilityIdentifier = "Documents folder"
|
||||
result.accessibilityLabel = "accessibility_document_button".localized()
|
||||
result.isAccessibilityElement = true
|
||||
|
||||
return result
|
||||
|
@ -42,6 +43,7 @@ final class ExpandingAttachmentsButton: UIView, InputViewButtonDelegate {
|
|||
lazy var libraryButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_roll_black"), delegate: self, hasOpaqueBackground: true)
|
||||
result.accessibilityIdentifier = "Images folder"
|
||||
result.accessibilityLabel = "accessibility_library_button".localized()
|
||||
result.isAccessibilityElement = true
|
||||
|
||||
return result
|
||||
|
@ -50,6 +52,7 @@ final class ExpandingAttachmentsButton: UIView, InputViewButtonDelegate {
|
|||
lazy var cameraButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_black"), delegate: self, hasOpaqueBackground: true)
|
||||
result.accessibilityIdentifier = "Select camera button"
|
||||
result.accessibilityLabel = "accessibility_camera_button".localized()
|
||||
result.isAccessibilityElement = true
|
||||
|
||||
return result
|
||||
|
|
|
@ -50,7 +50,7 @@ public final class InputTextView: UITextView, UITextViewDelegate {
|
|||
|
||||
public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
if action == #selector(paste(_:)) {
|
||||
if let _ = UIPasteboard.general.image {
|
||||
if UIPasteboard.general.hasImages {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate {
|
||||
// MARK: - Variables
|
||||
|
||||
private static let linkPreviewViewInset: CGFloat = 6
|
||||
|
||||
private var disposables: Set<AnyCancellable> = Set()
|
||||
private let threadVariant: SessionThread.Variant
|
||||
private weak var delegate: InputViewDelegate?
|
||||
|
||||
|
@ -37,8 +40,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
set { inputTextView.selectedRange = newValue }
|
||||
}
|
||||
|
||||
var inputTextViewIsFirstResponder: Bool { inputTextView.isFirstResponder }
|
||||
|
||||
var enabledMessageTypes: MessageInputTypes = .all {
|
||||
didSet {
|
||||
setEnabledMessageTypes(enabledMessageTypes, message: nil)
|
||||
|
@ -91,7 +92,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
let result: UIView = UIView()
|
||||
result.accessibilityLabel = "Mentions list"
|
||||
result.accessibilityIdentifier = "Mentions list"
|
||||
result.isAccessibilityElement = true
|
||||
result.alpha = 0
|
||||
|
||||
let backgroundView = UIView()
|
||||
|
@ -265,7 +265,8 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
quotedText: quoteDraftInfo.model.body,
|
||||
threadVariant: threadVariant,
|
||||
currentUserPublicKey: quoteDraftInfo.model.currentUserPublicKey,
|
||||
currentUserBlindedPublicKey: quoteDraftInfo.model.currentUserBlindedPublicKey,
|
||||
currentUserBlinded15PublicKey: quoteDraftInfo.model.currentUserBlinded15PublicKey,
|
||||
currentUserBlinded25PublicKey: quoteDraftInfo.model.currentUserBlinded25PublicKey,
|
||||
direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming),
|
||||
attachment: quoteDraftInfo.model.attachment,
|
||||
hInset: hInset,
|
||||
|
@ -332,19 +333,27 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
|
||||
// Build the link preview
|
||||
LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL)
|
||||
.done { [weak self] draft in
|
||||
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
|
||||
|
||||
self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
|
||||
self?.linkPreviewView.update(with: LinkPreview.DraftState(linkPreviewDraft: draft), isOutgoing: false)
|
||||
}
|
||||
.catch { [weak self] _ in
|
||||
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
|
||||
|
||||
self?.linkPreviewInfo = nil
|
||||
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
}
|
||||
.retainUntilComplete()
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure:
|
||||
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
|
||||
|
||||
self?.linkPreviewInfo = nil
|
||||
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
}
|
||||
},
|
||||
receiveValue: { [weak self] draft in
|
||||
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
|
||||
|
||||
self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
|
||||
self?.linkPreviewView.update(with: LinkPreview.DraftState(linkPreviewDraft: draft), isOutgoing: false)
|
||||
}
|
||||
)
|
||||
.store(in: &disposables)
|
||||
}
|
||||
|
||||
func setEnabledMessageTypes(_ messageTypes: MessageInputTypes, message: String?) {
|
||||
|
@ -407,14 +416,14 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
if inputViewButton == sendButton { delegate?.handleSendButtonTapped() }
|
||||
}
|
||||
|
||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) {
|
||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?, using dependencies: Dependencies) {
|
||||
guard inputViewButton == voiceMessageButton else { return }
|
||||
|
||||
// Note: The 'showVoiceMessageUI' call MUST come before triggering 'startVoiceMessageRecording'
|
||||
// because if something goes wrong it'll trigger `hideVoiceMessageUI` and we don't want it to
|
||||
// end up in a state with the input content hidden
|
||||
showVoiceMessageUI()
|
||||
delegate?.startVoiceMessageRecording()
|
||||
delegate?.startVoiceMessageRecording(using: dependencies)
|
||||
}
|
||||
|
||||
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?) {
|
||||
|
@ -440,10 +449,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
override func resignFirstResponder() -> Bool {
|
||||
inputTextView.resignFirstResponder()
|
||||
}
|
||||
|
||||
func inputTextViewBecomeFirstResponder() {
|
||||
inputTextView.becomeFirstResponder()
|
||||
}
|
||||
|
||||
func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
// Not relevant in this case
|
||||
|
@ -497,7 +502,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
func showMentionsUI(for candidates: [MentionInfo]) {
|
||||
mentionsView.candidates = candidates
|
||||
|
||||
let mentionCellHeight = (Values.smallProfilePictureSize + 2 * Values.smallSpacing)
|
||||
let mentionCellHeight = (ProfilePictureView.Size.message.viewSize + 2 * Values.smallSpacing)
|
||||
mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight
|
||||
layoutIfNeeded()
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class InputViewButton: UIView {
|
||||
private let icon: UIImage?
|
||||
|
@ -137,7 +138,9 @@ final class InputViewButton: UIView {
|
|||
|
||||
// We want to detect both taps and long presses
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { onTouchesBegan() }
|
||||
|
||||
private func onTouchesBegan(using dependencies: Dependencies = Dependencies()) {
|
||||
guard isUserInteractionEnabled else { return }
|
||||
|
||||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
||||
|
@ -145,7 +148,7 @@ final class InputViewButton: UIView {
|
|||
invalidateLongPressIfNeeded()
|
||||
longPressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false, block: { [weak self] _ in
|
||||
self?.isLongPress = true
|
||||
self?.delegate?.handleInputViewButtonLongPressBegan(self)
|
||||
self?.delegate?.handleInputViewButtonLongPressBegan(self, using: dependencies)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -185,13 +188,13 @@ final class InputViewButton: UIView {
|
|||
|
||||
protocol InputViewButtonDelegate: AnyObject {
|
||||
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton)
|
||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?)
|
||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?, using dependencies: Dependencies)
|
||||
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?)
|
||||
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch?)
|
||||
}
|
||||
|
||||
extension InputViewButtonDelegate {
|
||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?) { }
|
||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton?, using dependencies: Dependencies) { }
|
||||
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch?) { }
|
||||
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch?) { }
|
||||
}
|
||||
|
|
|
@ -92,6 +92,11 @@ final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDele
|
|||
),
|
||||
isLast: (indexPath.row == (candidates.count - 1))
|
||||
)
|
||||
cell.accessibilityIdentifier = "Contact"
|
||||
cell.accessibilityLabel = candidates[indexPath.row].profile.displayName(
|
||||
for: candidates[indexPath.row].threadVariant
|
||||
)
|
||||
cell.isAccessibilityElement = true
|
||||
|
||||
return cell
|
||||
}
|
||||
|
@ -111,9 +116,7 @@ private extension MentionSelectionView {
|
|||
final class Cell: UITableViewCell {
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView()
|
||||
|
||||
private lazy var moderatorIconImageView: UIImageView = UIImageView(image: #imageLiteral(resourceName: "Crown"))
|
||||
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .message)
|
||||
|
||||
private lazy var displayNameLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
|
@ -155,18 +158,12 @@ private extension MentionSelectionView {
|
|||
selectedBackgroundView.themeBackgroundColor = .highlighted(.settings_tabBackground)
|
||||
self.selectedBackgroundView = selectedBackgroundView
|
||||
|
||||
// Profile picture image view
|
||||
let profilePictureViewSize = Values.smallProfilePictureSize
|
||||
profilePictureView.set(.width, to: profilePictureViewSize)
|
||||
profilePictureView.set(.height, to: profilePictureViewSize)
|
||||
profilePictureView.size = profilePictureViewSize
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel ])
|
||||
mainStackView.axis = .horizontal
|
||||
mainStackView.alignment = .center
|
||||
mainStackView.spacing = Values.mediumSpacing
|
||||
mainStackView.set(.height, to: profilePictureViewSize)
|
||||
mainStackView.set(.height, to: ProfilePictureView.Size.message.viewSize)
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.smallSpacing)
|
||||
|
@ -174,13 +171,6 @@ private extension MentionSelectionView {
|
|||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.smallSpacing)
|
||||
mainStackView.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing)
|
||||
|
||||
// Moderator icon image view
|
||||
moderatorIconImageView.set(.width, to: 20)
|
||||
moderatorIconImageView.set(.height, to: 20)
|
||||
contentView.addSubview(moderatorIconImageView)
|
||||
moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1)
|
||||
moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5)
|
||||
|
||||
// Separator
|
||||
addSubview(separator)
|
||||
separator.pin(.leading, to: .leading, of: self)
|
||||
|
@ -199,10 +189,11 @@ private extension MentionSelectionView {
|
|||
displayNameLabel.text = profile.displayName(for: threadVariant)
|
||||
profilePictureView.update(
|
||||
publicKey: profile.id,
|
||||
threadVariant: .contact, // Always show the display picture in 'contact' mode
|
||||
customImageData: nil,
|
||||
profile: profile,
|
||||
threadVariant: threadVariant
|
||||
profileIcon: (isUserModeratorOrAdmin ? .crown : .none)
|
||||
)
|
||||
moderatorIconImageView.isHidden = !isUserModeratorOrAdmin
|
||||
separator.isHidden = isLast
|
||||
}
|
||||
}
|
||||
|
|
|
@ -310,12 +310,12 @@ final class VoiceMessageRecordingView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
func handleLongPressEnded(at location: CGPoint) {
|
||||
func handleLongPressEnded(at location: CGPoint, using dependencies: Dependencies = Dependencies()) {
|
||||
if pulseView.frame.contains(location) {
|
||||
delegate?.endVoiceMessageRecording()
|
||||
delegate?.endVoiceMessageRecording(using: dependencies)
|
||||
}
|
||||
else if isValidLockViewLocation(location) {
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleCircleViewTap))
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(onCircleViewTap))
|
||||
circleView.addGestureRecognizer(tapGestureRecognizer)
|
||||
|
||||
UIView.animate(withDuration: 0.25, delay: 0, options: .transitionCrossDissolve, animations: {
|
||||
|
@ -332,8 +332,10 @@ final class VoiceMessageRecordingView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
@objc private func handleCircleViewTap() {
|
||||
delegate?.endVoiceMessageRecording()
|
||||
@objc private func onCircleViewTap() { handleCircleViewTap() }
|
||||
|
||||
private func handleCircleViewTap(using dependencies: Dependencies = Dependencies()) {
|
||||
delegate?.endVoiceMessageRecording(using: dependencies)
|
||||
}
|
||||
|
||||
@objc private func handleCancelButtonTapped() {
|
||||
|
@ -474,7 +476,7 @@ extension VoiceMessageRecordingView {
|
|||
// MARK: - Delegate
|
||||
|
||||
protocol VoiceMessageRecordingViewDelegate: AnyObject {
|
||||
func startVoiceMessageRecording()
|
||||
func endVoiceMessageRecording()
|
||||
func startVoiceMessageRecording(using dependencies: Dependencies)
|
||||
func endVoiceMessageRecording(using dependencies: Dependencies)
|
||||
func cancelVoiceMessageRecording()
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class CallMessageCell: MessageCell {
|
||||
private static let iconSize: CGFloat = 16
|
||||
|
|
|
@ -33,20 +33,35 @@ final class DocumentView: UIView {
|
|||
)
|
||||
imageView.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
imageView.setContentHuggingPriority(.required, for: .horizontal)
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.themeTintColor = textColor
|
||||
imageView.set(.height, to: 22)
|
||||
imageView.set(.width, to: 24)
|
||||
imageView.set(.height, to: 32)
|
||||
|
||||
if attachment.isAudio {
|
||||
let audioImageView = UIImageView(
|
||||
image: UIImage(systemName: "music.note")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
audioImageView.contentMode = .scaleAspectFit
|
||||
audioImageView.themeTintColor = textColor
|
||||
imageView.addSubview(audioImageView)
|
||||
audioImageView.center(.horizontal, in: imageView)
|
||||
audioImageView.center(.vertical, in: imageView, withInset: 4)
|
||||
audioImageView.set(.height, to: .height, of: imageView, multiplier: 0.32)
|
||||
}
|
||||
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = (attachment.sourceFilename ?? "File")
|
||||
titleLabel.text = attachment.documentFileName
|
||||
titleLabel.themeTextColor = textColor
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
// Size label
|
||||
let sizeLabel = UILabel()
|
||||
sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
sizeLabel.text = OWSFormat.formatFileSize(attachment.byteCount)
|
||||
sizeLabel.text = attachment.documentFileInfo
|
||||
sizeLabel.themeTextColor = textColor
|
||||
sizeLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
|
@ -55,14 +70,19 @@ final class DocumentView: UIView {
|
|||
labelStackView.axis = .vertical
|
||||
|
||||
// Download image view
|
||||
let downloadImageView = UIImageView(
|
||||
image: UIImage(systemName: "arrow.down")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
let rightImageView = UIImageView(
|
||||
image: {
|
||||
switch attachment.isAudio {
|
||||
case true: return UIImage(systemName: "play.fill")
|
||||
case false: return UIImage(systemName: "arrow.down")
|
||||
}
|
||||
}()?.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
downloadImageView.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
downloadImageView.setContentHuggingPriority(.required, for: .horizontal)
|
||||
downloadImageView.themeTintColor = textColor
|
||||
downloadImageView.set(.height, to: 16)
|
||||
rightImageView.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
rightImageView.setContentHuggingPriority(.required, for: .horizontal)
|
||||
rightImageView.contentMode = .scaleAspectFit
|
||||
rightImageView.themeTintColor = textColor
|
||||
rightImageView.set(.height, to: 24)
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(
|
||||
|
@ -70,7 +90,7 @@ final class DocumentView: UIView {
|
|||
imageView,
|
||||
UIView.spacer(withWidth: 0),
|
||||
labelStackView,
|
||||
downloadImageView
|
||||
rightImageView
|
||||
]
|
||||
)
|
||||
stackView.axis = .horizontal
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
import SignalCoreKit
|
||||
|
||||
protocol LinkPreviewState {
|
||||
var isLoaded: Bool { get }
|
||||
|
|
|
@ -2,11 +2,15 @@
|
|||
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public class MediaAlbumView: UIStackView {
|
||||
private let items: [Attachment]
|
||||
public let itemViews: [MediaView]
|
||||
public var moreItemsView: MediaView?
|
||||
public var numItems: Int { return items.count }
|
||||
public var numVisibleItems: Int { return itemViews.count }
|
||||
|
||||
private static let kSpacingPts: CGFloat = 4
|
||||
private static let kMaxItems = 3
|
||||
|
@ -22,13 +26,22 @@ public class MediaAlbumView: UIStackView {
|
|||
isOutgoing: Bool,
|
||||
maxMessageWidth: CGFloat
|
||||
) {
|
||||
let itemsToDisplay: [Attachment] = MediaAlbumView.itemsToDisplay(forItems: items)
|
||||
|
||||
self.items = items
|
||||
self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items)
|
||||
.map {
|
||||
self.itemViews = itemsToDisplay.enumerated()
|
||||
.map { index, attachment -> MediaView in
|
||||
MediaView(
|
||||
mediaCache: mediaCache,
|
||||
attachment: $0,
|
||||
attachment: attachment,
|
||||
isOutgoing: isOutgoing,
|
||||
shouldSupressControls: (
|
||||
// If there are extra items that aren't displayed and this is the
|
||||
// last one that will be displayed then suppress any custom controls
|
||||
// otherwise the '+' icon will be obscured
|
||||
itemsToDisplay.count != items.count &&
|
||||
(index == (itemsToDisplay.count - 1))
|
||||
),
|
||||
cornerRadius: VisibleMessageCell.largeCornerRadius
|
||||
)
|
||||
}
|
||||
|
@ -110,11 +123,10 @@ public class MediaAlbumView: UIStackView {
|
|||
tintView.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
let moreCount = max(1, items.count - MediaAlbumView.kMaxItems)
|
||||
let moreCountText = OWSFormat.formatInt(Int32(moreCount))
|
||||
let moreText = String(
|
||||
// Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}.
|
||||
format: "MEDIA_GALLERY_MORE_ITEMS_FORMAT".localized(),
|
||||
moreCountText
|
||||
"\(moreCount)"
|
||||
)
|
||||
let moreLabel: UILabel = UILabel()
|
||||
moreLabel.font = .systemFont(ofSize: 24)
|
||||
|
|
|
@ -4,6 +4,9 @@ import UIKit
|
|||
import YYImage
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalCoreKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public class MediaView: UIView {
|
||||
static let contentMode: UIView.ContentMode = .scaleAspectFill
|
||||
|
@ -19,6 +22,7 @@ public class MediaView: UIView {
|
|||
private let mediaCache: NSCache<NSString, AnyObject>?
|
||||
public let attachment: Attachment
|
||||
private let isOutgoing: Bool
|
||||
private let shouldSupressControls: Bool
|
||||
private var loadBlock: (() -> Void)?
|
||||
private var unloadBlock: (() -> Void)?
|
||||
|
||||
|
@ -48,11 +52,13 @@ public class MediaView: UIView {
|
|||
mediaCache: NSCache<NSString, AnyObject>? = nil,
|
||||
attachment: Attachment,
|
||||
isOutgoing: Bool,
|
||||
shouldSupressControls: Bool,
|
||||
cornerRadius: CGFloat
|
||||
) {
|
||||
self.mediaCache = mediaCache
|
||||
self.attachment = attachment
|
||||
self.isOutgoing = isOutgoing
|
||||
self.shouldSupressControls = shouldSupressControls
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
|
@ -272,7 +278,29 @@ public class MediaView: UIView {
|
|||
addSubview(stillImageView)
|
||||
stillImageView.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
if !addUploadProgressIfNecessary(stillImageView) {
|
||||
if !addUploadProgressIfNecessary(stillImageView) && !shouldSupressControls {
|
||||
if let duration: TimeInterval = attachment.duration {
|
||||
let fadeView: GradientView = GradientView()
|
||||
fadeView.themeBackgroundGradient = [
|
||||
.value(.black, alpha: 0),
|
||||
.value(.black, alpha: 0.4)
|
||||
]
|
||||
stillImageView.addSubview(fadeView)
|
||||
fadeView.set(.height, to: 40)
|
||||
fadeView.pin(.leading, to: .leading, of: stillImageView)
|
||||
fadeView.pin(.trailing, to: .trailing, of: stillImageView)
|
||||
fadeView.pin(.bottom, to: .bottom, of: stillImageView)
|
||||
|
||||
let durationLabel: UILabel = UILabel()
|
||||
durationLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
durationLabel.text = Format.duration(duration)
|
||||
durationLabel.themeTextColor = .white
|
||||
stillImageView.addSubview(durationLabel)
|
||||
durationLabel.pin(.trailing, to: .trailing, of: stillImageView, withInset: -Values.smallSpacing)
|
||||
durationLabel.pin(.bottom, to: .bottom, of: stillImageView, withInset: -Values.smallSpacing)
|
||||
}
|
||||
|
||||
// Add the play button above the duration label and fade
|
||||
let videoPlayIcon = UIImage(named: "CirclePlay")
|
||||
let videoPlayButton = UIImageView(image: videoPlayIcon)
|
||||
videoPlayButton.set(.width, to: 72)
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class QuoteView: UIView {
|
||||
static let thumbnailSize: CGFloat = 48
|
||||
|
@ -29,7 +30,8 @@ final class QuoteView: UIView {
|
|||
quotedText: String?,
|
||||
threadVariant: SessionThread.Variant,
|
||||
currentUserPublicKey: String?,
|
||||
currentUserBlindedPublicKey: String?,
|
||||
currentUserBlinded15PublicKey: String?,
|
||||
currentUserBlinded25PublicKey: String?,
|
||||
direction: Direction,
|
||||
attachment: Attachment?,
|
||||
hInset: CGFloat,
|
||||
|
@ -46,7 +48,8 @@ final class QuoteView: UIView {
|
|||
quotedText: quotedText,
|
||||
threadVariant: threadVariant,
|
||||
currentUserPublicKey: currentUserPublicKey,
|
||||
currentUserBlindedPublicKey: currentUserBlindedPublicKey,
|
||||
currentUserBlinded15PublicKey: currentUserBlinded15PublicKey,
|
||||
currentUserBlinded25PublicKey: currentUserBlinded25PublicKey,
|
||||
direction: direction,
|
||||
attachment: attachment,
|
||||
hInset: hInset,
|
||||
|
@ -68,7 +71,8 @@ final class QuoteView: UIView {
|
|||
quotedText: String?,
|
||||
threadVariant: SessionThread.Variant,
|
||||
currentUserPublicKey: String?,
|
||||
currentUserBlindedPublicKey: String?,
|
||||
currentUserBlinded15PublicKey: String?,
|
||||
currentUserBlinded25PublicKey: String?,
|
||||
direction: Direction,
|
||||
attachment: Attachment?,
|
||||
hInset: CGFloat,
|
||||
|
@ -101,7 +105,6 @@ final class QuoteView: UIView {
|
|||
availableWidth -= cancelButtonSize
|
||||
}
|
||||
|
||||
let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude)
|
||||
var body: String? = quotedText
|
||||
|
||||
// Main stack view
|
||||
|
@ -115,19 +118,7 @@ final class QuoteView: UIView {
|
|||
// Content view
|
||||
let contentView = UIView()
|
||||
addSubview(contentView)
|
||||
contentView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self)
|
||||
contentView.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor).isActive = true
|
||||
|
||||
// Line view
|
||||
let lineColor: ThemeValue = {
|
||||
switch mode {
|
||||
case .regular: return (direction == .outgoing ? .messageBubble_outgoingText : .primary)
|
||||
case .draft: return .primary
|
||||
}
|
||||
}()
|
||||
let lineView = UIView()
|
||||
lineView.themeBackgroundColor = lineColor
|
||||
lineView.set(.width, to: Values.accentLineThickness)
|
||||
contentView.pin(to: self)
|
||||
|
||||
if let attachment: Attachment = attachment {
|
||||
let isAudio: Bool = MIMETypeUtil.isAudio(attachment.contentType)
|
||||
|
@ -163,7 +154,7 @@ final class QuoteView: UIView {
|
|||
if attachment.isVisualMedia {
|
||||
attachment.thumbnail(
|
||||
size: .small,
|
||||
success: { image, _ in
|
||||
success: { [imageView] image, _ in
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
imageView.image = image
|
||||
|
@ -180,13 +171,26 @@ final class QuoteView: UIView {
|
|||
}
|
||||
}
|
||||
else {
|
||||
// Line view
|
||||
let lineColor: ThemeValue = {
|
||||
switch mode {
|
||||
case .regular: return (direction == .outgoing ? .messageBubble_outgoingText : .primary)
|
||||
case .draft: return .primary
|
||||
}
|
||||
}()
|
||||
let lineView = UIView()
|
||||
lineView.themeBackgroundColor = lineColor
|
||||
mainStackView.addArrangedSubview(lineView)
|
||||
|
||||
lineView.pin(.top, to: .top, of: mainStackView)
|
||||
lineView.pin(.bottom, to: .bottom, of: mainStackView)
|
||||
lineView.set(.width, to: Values.accentLineThickness)
|
||||
}
|
||||
|
||||
// Body label
|
||||
let bodyLabel = TappableLabel()
|
||||
bodyLabel.numberOfLines = 0
|
||||
bodyLabel.lineBreakMode = .byTruncatingTail
|
||||
bodyLabel.numberOfLines = 2
|
||||
|
||||
let targetThemeColor: ThemeValue = {
|
||||
switch mode {
|
||||
|
@ -208,7 +212,8 @@ final class QuoteView: UIView {
|
|||
in: $0,
|
||||
threadVariant: threadVariant,
|
||||
currentUserPublicKey: currentUserPublicKey,
|
||||
currentUserBlindedPublicKey: currentUserBlindedPublicKey,
|
||||
currentUserBlinded15PublicKey: currentUserBlinded15PublicKey,
|
||||
currentUserBlinded25PublicKey: currentUserBlinded25PublicKey,
|
||||
isOutgoingMessage: (direction == .outgoing),
|
||||
textColor: textColor,
|
||||
theme: theme,
|
||||
|
@ -227,37 +232,41 @@ final class QuoteView: UIView {
|
|||
}
|
||||
|
||||
// Label stack view
|
||||
let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace)
|
||||
var authorLabelHeight: CGFloat?
|
||||
|
||||
let isCurrentUser: Bool = [
|
||||
currentUserPublicKey,
|
||||
currentUserBlindedPublicKey,
|
||||
currentUserBlinded15PublicKey,
|
||||
currentUserBlinded25PublicKey
|
||||
]
|
||||
.compactMap { $0 }
|
||||
.asSet()
|
||||
.contains(authorId)
|
||||
|
||||
let authorLabel = UILabel()
|
||||
authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
authorLabel.text = (isCurrentUser ?
|
||||
"MEDIA_GALLERY_SENDER_NAME_YOU".localized() :
|
||||
Profile.displayName(
|
||||
authorLabel.text = {
|
||||
guard !isCurrentUser else { return "MEDIA_GALLERY_SENDER_NAME_YOU".localized() }
|
||||
guard body != nil else {
|
||||
// When we can't find the quoted message we want to hide the author label
|
||||
return Profile.displayNameNoFallback(
|
||||
id: authorId,
|
||||
threadVariant: threadVariant
|
||||
)
|
||||
}
|
||||
|
||||
return Profile.displayName(
|
||||
id: authorId,
|
||||
threadVariant: threadVariant
|
||||
)
|
||||
)
|
||||
}()
|
||||
authorLabel.themeTextColor = targetThemeColor
|
||||
authorLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace)
|
||||
authorLabel.set(.height, to: authorLabelSize.height)
|
||||
authorLabelHeight = authorLabelSize.height
|
||||
authorLabel.isHidden = (authorLabel.text == nil)
|
||||
authorLabel.numberOfLines = 1
|
||||
|
||||
let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ])
|
||||
labelStackView.axis = .vertical
|
||||
labelStackView.spacing = labelStackViewSpacing
|
||||
labelStackView.distribution = .equalCentering
|
||||
labelStackView.set(.width, to: max(bodyLabelSize.width, authorLabelSize.width))
|
||||
labelStackView.isLayoutMarginsRelativeArrangement = true
|
||||
labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0)
|
||||
mainStackView.addArrangedSubview(labelStackView)
|
||||
|
@ -266,29 +275,6 @@ final class QuoteView: UIView {
|
|||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(to: contentView)
|
||||
|
||||
if threadVariant != .openGroup && threadVariant != .closedGroup {
|
||||
bodyLabel.set(.width, to: bodyLabelSize.width)
|
||||
}
|
||||
|
||||
let bodyLabelHeight = bodyLabelSize.height.clamp(0, (mode == .regular ? 60 : 40))
|
||||
let contentViewHeight: CGFloat
|
||||
|
||||
if attachment != nil {
|
||||
contentViewHeight = thumbnailSize + 8 // Add a small amount of spacing above and below the thumbnail
|
||||
bodyLabel.set(.height, to: 18) // Experimentally determined
|
||||
}
|
||||
else {
|
||||
if let authorLabelHeight = authorLabelHeight { // Group thread
|
||||
contentViewHeight = bodyLabelHeight + (authorLabelHeight + labelStackViewSpacing) + 2 * labelStackViewVMargin
|
||||
}
|
||||
else {
|
||||
contentViewHeight = bodyLabelHeight + 2 * smallSpacing
|
||||
}
|
||||
}
|
||||
|
||||
contentView.set(.height, to: contentViewHeight)
|
||||
lineView.set(.height, to: contentViewHeight - 8) // Add a small amount of spacing above and below the line
|
||||
|
||||
if mode == .draft {
|
||||
// Cancel button
|
||||
let cancelButton = UIButton(type: .custom)
|
||||
|
@ -298,9 +284,8 @@ final class QuoteView: UIView {
|
|||
cancelButton.set(.height, to: cancelButtonSize)
|
||||
cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
|
||||
|
||||
addSubview(cancelButton)
|
||||
mainStackView.addArrangedSubview(cancelButton)
|
||||
cancelButton.center(.vertical, in: self)
|
||||
cancelButton.pin(.right, to: .right, of: self)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,13 @@ final class ReactionContainerView: UIView {
|
|||
private static let arrowSize: CGSize = CGSize(width: 15, height: 13)
|
||||
private static let arrowSpacing: CGFloat = Values.verySmallSpacing
|
||||
|
||||
// We have explicit limits on the number of emoji which should be displayed before they
|
||||
// automatically get collapsed, these values are consistent across platforms so are set
|
||||
// here (even though the logic will automatically calculate and limit to a single line
|
||||
// of reactions dynamically for the size of the view)
|
||||
private static let numCollapsedEmoji: Int = 4
|
||||
private static let maxEmojiBeforeCollapse: Int = 6
|
||||
|
||||
private var maxWidth: CGFloat = 0
|
||||
private var collapsedCount: Int = 0
|
||||
private var showingAllReactions: Bool = false
|
||||
|
@ -173,7 +180,10 @@ final class ReactionContainerView: UIView {
|
|||
numReactions += 1
|
||||
}
|
||||
|
||||
return numReactions
|
||||
return (numReactions > ReactionContainerView.maxEmojiBeforeCollapse ?
|
||||
ReactionContainerView.numCollapsedEmoji :
|
||||
numReactions
|
||||
)
|
||||
}()
|
||||
self.showNumbers = showNumbers
|
||||
self.reactionViews = []
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
@objc class TypingIndicatorView: UIStackView {
|
||||
// This represents the spacing between the dots
|
||||
|
|
|
@ -112,7 +112,6 @@ public final class VoiceMessageView: UIView {
|
|||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
let toggleContainerSize = VoiceMessageView.toggleContainerSize
|
||||
let inset = VoiceMessageView.inset
|
||||
|
||||
// Width & height
|
||||
|
|
|
@ -4,6 +4,7 @@ import UIKit
|
|||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
import SessionUIKit
|
||||
|
||||
final class DateHeaderCell: MessageCell {
|
||||
// MARK: - UI
|
||||
|
|
|
@ -6,7 +6,7 @@ import SessionMessagingKit
|
|||
|
||||
final class InfoMessageCell: MessageCell {
|
||||
private static let iconSize: CGFloat = 16
|
||||
private static let inset = Values.mediumSpacing
|
||||
public static let inset = Values.mediumSpacing
|
||||
|
||||
private var isHandlingLongPress: Bool = false
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public enum SwipeState {
|
||||
case began
|
||||
|
@ -65,6 +66,7 @@ public class MessageCell: UITableViewCell {
|
|||
static func cellType(for viewModel: MessageViewModel) -> MessageCell.Type {
|
||||
guard viewModel.cellType != .typingIndicator else { return TypingIndicatorCell.self }
|
||||
guard viewModel.cellType != .dateHeader else { return DateHeaderCell.self }
|
||||
guard viewModel.cellType != .unreadMarker else { return UnreadMarkerCell.self }
|
||||
|
||||
switch viewModel.variant {
|
||||
case .standardOutgoing, .standardIncoming, .standardIncomingDeleted:
|
||||
|
@ -86,12 +88,18 @@ public class MessageCell: UITableViewCell {
|
|||
|
||||
protocol MessageCellDelegate: ReactionDelegate {
|
||||
func handleItemLongPressed(_ cellViewModel: MessageViewModel)
|
||||
func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer)
|
||||
func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer, using dependencies: Dependencies)
|
||||
func handleItemDoubleTapped(_ cellViewModel: MessageViewModel)
|
||||
func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState)
|
||||
func openUrl(_ urlString: String)
|
||||
func handleReplyButtonTapped(for cellViewModel: MessageViewModel)
|
||||
func handleReplyButtonTapped(for cellViewModel: MessageViewModel, using dependencies: Dependencies)
|
||||
func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?)
|
||||
func showReactionList(_ cellViewModel: MessageViewModel, selectedReaction: EmojiWithSkinTones?)
|
||||
func needsLayout(for cellViewModel: MessageViewModel, expandingReactions: Bool)
|
||||
}
|
||||
|
||||
extension MessageCellDelegate {
|
||||
func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer) {
|
||||
handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer, using: Dependencies())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
import SessionUIKit
|
||||
|
||||
final class UnreadMarkerCell: MessageCell {
|
||||
public static let height: CGFloat = 32
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private let leftLine: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.themeBackgroundColor = .unreadMarker
|
||||
result.set(.height, to: 1) // Intentionally 1 instead of 'separatorThickness'
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.text = "UNREAD_MESSAGES".localized()
|
||||
result.themeTextColor = .unreadMarker
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let rightLine: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.themeBackgroundColor = .unreadMarker
|
||||
result.set(.height, to: 1) // Intentionally 1 instead of 'separatorThickness'
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
override func setUpViewHierarchy() {
|
||||
super.setUpViewHierarchy()
|
||||
|
||||
addSubview(leftLine)
|
||||
addSubview(titleLabel)
|
||||
addSubview(rightLine)
|
||||
|
||||
leftLine.pin(.leading, to: .leading, of: self, withInset: Values.mediumSpacing)
|
||||
leftLine.pin(.trailing, to: .leading, of: titleLabel, withInset: -Values.smallSpacing)
|
||||
leftLine.center(.vertical, in: self)
|
||||
titleLabel.center(.horizontal, in: self)
|
||||
titleLabel.center(.vertical, in: self)
|
||||
titleLabel.pin(.top, to: .top, of: self, withInset: Values.smallSpacing)
|
||||
titleLabel.pin(.bottom, to: .bottom, of: self, withInset: -Values.smallSpacing)
|
||||
rightLine.pin(.leading, to: .trailing, of: titleLabel, withInset: Values.smallSpacing)
|
||||
rightLine.pin(.trailing, to: .trailing, of: self, withInset: -Values.mediumSpacing)
|
||||
rightLine.center(.vertical, in: self)
|
||||
}
|
||||
|
||||
// MARK: - Updating
|
||||
|
||||
override func update(
|
||||
with cellViewModel: MessageViewModel,
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||
showExpandedReactions: Bool,
|
||||
lastSearchText: String?
|
||||
) {
|
||||
guard cellViewModel.cellType == .unreadMarker else { return }
|
||||
}
|
||||
|
||||
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {}
|
||||
}
|
|
@ -22,7 +22,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
private lazy var authorLabelTopConstraint = authorLabel.pin(.top, to: .top, of: self)
|
||||
private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0)
|
||||
private lazy var profilePictureViewLeadingConstraint = profilePictureView.pin(.leading, to: .leading, of: self, withInset: VisibleMessageCell.groupThreadHSpacing)
|
||||
private lazy var profilePictureViewWidthConstraint = profilePictureView.set(.width, to: Values.verySmallProfilePictureSize)
|
||||
private lazy var contentViewLeadingConstraint1 = snContentView.pin(.leading, to: .trailing, of: profilePictureView, withInset: VisibleMessageCell.groupThreadHSpacing)
|
||||
private lazy var contentViewLeadingConstraint2 = snContentView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: VisibleMessageCell.gutterSize)
|
||||
private lazy var contentViewTopConstraint = snContentView.pin(.top, to: .bottom, of: authorLabel, withInset: VisibleMessageCell.authorLabelBottomSpacing)
|
||||
|
@ -51,22 +50,13 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
private lazy var viewsToMoveForReply: [UIView] = [
|
||||
snContentView,
|
||||
profilePictureView,
|
||||
moderatorIconImageView,
|
||||
replyButton,
|
||||
timerView,
|
||||
messageStatusImageView,
|
||||
messageStatusContainerView,
|
||||
reactionContainerView
|
||||
]
|
||||
|
||||
private lazy var profilePictureView: ProfilePictureView = {
|
||||
let result: ProfilePictureView = ProfilePictureView()
|
||||
result.set(.height, to: Values.verySmallProfilePictureSize)
|
||||
result.size = Values.verySmallProfilePictureSize
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var moderatorIconImageView = UIImageView(image: #imageLiteral(resourceName: "Crown"))
|
||||
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .message)
|
||||
|
||||
lazy var bubbleBackgroundView: UIView = {
|
||||
let result = UIView()
|
||||
|
@ -176,7 +166,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
private static let messageStatusImageViewSize: CGFloat = 12
|
||||
private static let authorLabelBottomSpacing: CGFloat = 4
|
||||
private static let groupThreadHSpacing: CGFloat = 12
|
||||
private static let profilePictureSize = Values.verySmallProfilePictureSize
|
||||
private static let authorLabelInset: CGFloat = 12
|
||||
private static let replyButtonSize: CGFloat = 24
|
||||
private static let maxBubbleTranslationX: CGFloat = 40
|
||||
|
@ -186,7 +175,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
static let contactThreadHSpacing = Values.mediumSpacing
|
||||
|
||||
static var gutterSize: CGFloat = {
|
||||
var result = groupThreadHSpacing + profilePictureSize + groupThreadHSpacing
|
||||
var result = groupThreadHSpacing + ProfilePictureView.Size.message.viewSize + groupThreadHSpacing
|
||||
|
||||
if UIDevice.current.isIPad {
|
||||
result += 168
|
||||
|
@ -195,7 +184,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
return result
|
||||
}()
|
||||
|
||||
static var leftGutterSize: CGFloat { groupThreadHSpacing + profilePictureSize + groupThreadHSpacing }
|
||||
static var leftGutterSize: CGFloat { groupThreadHSpacing + ProfilePictureView.Size.message.viewSize + groupThreadHSpacing }
|
||||
|
||||
// MARK: Direction & Position
|
||||
|
||||
|
@ -214,21 +203,13 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
// Profile picture view
|
||||
addSubview(profilePictureView)
|
||||
profilePictureViewLeadingConstraint.isActive = true
|
||||
profilePictureViewWidthConstraint.isActive = true
|
||||
|
||||
// Moderator icon image view
|
||||
moderatorIconImageView.set(.width, to: 20)
|
||||
moderatorIconImageView.set(.height, to: 20)
|
||||
addSubview(moderatorIconImageView)
|
||||
moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1)
|
||||
moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5)
|
||||
|
||||
// Content view
|
||||
addSubview(snContentView)
|
||||
contentViewLeadingConstraint1.isActive = true
|
||||
contentViewTopConstraint.isActive = true
|
||||
contentViewTrailingConstraint1.isActive = true
|
||||
snContentView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: -1)
|
||||
snContentView.pin(.bottom, to: .bottom, of: profilePictureView)
|
||||
|
||||
// Bubble background view
|
||||
bubbleBackgroundView.addSubview(bubbleView)
|
||||
|
@ -301,9 +282,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
lastSearchText: String?
|
||||
) {
|
||||
self.viewModel = cellViewModel
|
||||
self.bubbleView.accessibilityIdentifier = "Message Body"
|
||||
self.bubbleView.isAccessibilityElement = true
|
||||
self.bubbleView.accessibilityLabel = cellViewModel.body
|
||||
|
||||
// We want to add spacing between "clusters" of messages to indicate that time has
|
||||
// passed (even if there wasn't enough time to warrant showing a date header)
|
||||
let shouldAddTopInset: Bool = (
|
||||
|
@ -313,18 +292,28 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
cellViewModel.isOnlyMessageInCluster
|
||||
)
|
||||
)
|
||||
let isGroupThread: Bool = (cellViewModel.threadVariant == .openGroup || cellViewModel.threadVariant == .closedGroup)
|
||||
let isGroupThread: Bool = (
|
||||
cellViewModel.threadVariant == .community ||
|
||||
cellViewModel.threadVariant == .legacyGroup ||
|
||||
cellViewModel.threadVariant == .group
|
||||
)
|
||||
|
||||
// Profile picture view
|
||||
// Profile picture view (should always be handled as a standard 'contact' profile picture)
|
||||
let profileShouldBeVisible: Bool = (
|
||||
cellViewModel.canHaveProfile &&
|
||||
cellViewModel.shouldShowProfile &&
|
||||
cellViewModel.profile != nil
|
||||
)
|
||||
profilePictureViewLeadingConstraint.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0)
|
||||
profilePictureViewWidthConstraint.constant = (isGroupThread ? VisibleMessageCell.profilePictureSize : 0)
|
||||
profilePictureView.isHidden = (!cellViewModel.shouldShowProfile || cellViewModel.profile == nil)
|
||||
profilePictureView.isHidden = !cellViewModel.canHaveProfile
|
||||
profilePictureView.alpha = (profileShouldBeVisible ? 1 : 0)
|
||||
profilePictureView.update(
|
||||
publicKey: cellViewModel.authorId,
|
||||
threadVariant: .contact, // Always show the display picture in 'contact' mode
|
||||
customImageData: nil,
|
||||
profile: cellViewModel.profile,
|
||||
threadVariant: cellViewModel.threadVariant
|
||||
profileIcon: (cellViewModel.isSenderOpenGroupModerator ? .crown : .none)
|
||||
)
|
||||
moderatorIconImageView.isHidden = (!cellViewModel.isSenderOpenGroupModerator || !cellViewModel.shouldShowProfile)
|
||||
|
||||
// Bubble view
|
||||
contentViewLeadingConstraint1.isActive = (
|
||||
|
@ -356,6 +345,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
lastSearchText: lastSearchText
|
||||
)
|
||||
|
||||
bubbleView.accessibilityIdentifier = "Message Body"
|
||||
bubbleView.accessibilityLabel = bodyTappableLabel?.attributedText?.string
|
||||
bubbleView.isAccessibilityElement = true
|
||||
|
||||
// Author label
|
||||
authorLabelTopConstraint.constant = (shouldAddTopInset ? Values.mediumSpacing : 0)
|
||||
authorLabel.isHidden = (cellViewModel.senderName == nil)
|
||||
|
@ -392,11 +385,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
)
|
||||
|
||||
// Swipe to reply
|
||||
if cellViewModel.variant == .standardIncomingDeleted || cellViewModel.variant == .infoCall {
|
||||
removeGestureRecognizer(panGestureRecognizer)
|
||||
if ContextMenuVC.viewModelCanReply(cellViewModel) {
|
||||
addGestureRecognizer(panGestureRecognizer)
|
||||
}
|
||||
else {
|
||||
addGestureRecognizer(panGestureRecognizer)
|
||||
removeGestureRecognizer(panGestureRecognizer)
|
||||
}
|
||||
|
||||
// Under bubble content
|
||||
|
@ -474,7 +467,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
subview.removeFromSuperview()
|
||||
}
|
||||
albumView = nil
|
||||
albumView = nil
|
||||
bodyTappableLabel = nil
|
||||
|
||||
// Handle the deleted state first (it's much simpler than the others)
|
||||
|
@ -496,7 +488,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
}
|
||||
|
||||
switch cellViewModel.cellType {
|
||||
case .typingIndicator, .dateHeader: break
|
||||
case .typingIndicator, .dateHeader, .unreadMarker: break
|
||||
|
||||
case .textOnlyMessage:
|
||||
let inset: CGFloat = 12
|
||||
|
@ -549,7 +541,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
quotedText: quote.body,
|
||||
threadVariant: cellViewModel.threadVariant,
|
||||
currentUserPublicKey: cellViewModel.currentUserPublicKey,
|
||||
currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey,
|
||||
currentUserBlinded15PublicKey: cellViewModel.currentUserBlinded15PublicKey,
|
||||
currentUserBlinded25PublicKey: cellViewModel.currentUserBlinded25PublicKey,
|
||||
direction: (cellViewModel.variant == .standardOutgoing ?
|
||||
.outgoing :
|
||||
.incoming
|
||||
|
@ -618,7 +611,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
|
||||
unloadContent = { albumView.unloadMedia() }
|
||||
|
||||
case .audio:
|
||||
case .voiceMessage:
|
||||
guard let attachment: Attachment = cellViewModel.attachments?.first(where: { $0.isAudio }) else {
|
||||
return
|
||||
}
|
||||
|
@ -637,7 +630,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
snContentView.addArrangedSubview(bubbleBackgroundView)
|
||||
self.voiceMessageView = voiceMessageView
|
||||
|
||||
case .genericAttachment:
|
||||
case .audio, .genericAttachment:
|
||||
guard let attachment: Attachment = cellViewModel.attachments?.first else { preconditionFailure() }
|
||||
|
||||
let inset: CGFloat = 12
|
||||
|
@ -718,8 +711,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
maxWidth: maxWidth,
|
||||
showingAllReactions: showExpandedReactions,
|
||||
showNumbers: (
|
||||
cellViewModel.threadVariant == .closedGroup ||
|
||||
cellViewModel.threadVariant == .openGroup
|
||||
cellViewModel.threadVariant == .legacyGroup ||
|
||||
cellViewModel.threadVariant == .group ||
|
||||
cellViewModel.threadVariant == .community
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -747,7 +741,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
}
|
||||
|
||||
switch cellViewModel.cellType {
|
||||
case .audio:
|
||||
case .voiceMessage:
|
||||
guard let attachment: Attachment = cellViewModel.attachments?.first(where: { $0.isAudio }) else {
|
||||
return
|
||||
}
|
||||
|
@ -866,15 +860,18 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
isHandlingLongPress = true
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { onTap(gestureRecognizer) }
|
||||
|
||||
private func onTap(_ gestureRecognizer: UITapGestureRecognizer, using dependencies: Dependencies = Dependencies()) {
|
||||
guard let cellViewModel: MessageViewModel = self.viewModel else { return }
|
||||
|
||||
let location = gestureRecognizer.location(in: self)
|
||||
|
||||
if profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)), cellViewModel.shouldShowProfile {
|
||||
// For open groups only attempt to start a conversation if the author has a blinded id
|
||||
guard cellViewModel.threadVariant != .openGroup else {
|
||||
guard SessionId.Prefix(from: cellViewModel.authorId) == .blinded else { return }
|
||||
guard cellViewModel.threadVariant != .community else {
|
||||
// FIXME: Add in support for opening a conversation with a 'blinded25' id
|
||||
guard SessionId.Prefix(from: cellViewModel.authorId) == .blinded15 else { return }
|
||||
|
||||
delegate?.startThread(
|
||||
with: cellViewModel.authorId,
|
||||
|
@ -901,10 +898,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
if reactionContainerView.convert(reactionView.frame, from: reactionView.superview).contains(convertedLocation) {
|
||||
|
||||
if reactionView.viewModel.showBorder {
|
||||
delegate?.removeReact(cellViewModel, for: reactionView.viewModel.emoji)
|
||||
delegate?.removeReact(cellViewModel, for: reactionView.viewModel.emoji, using: dependencies)
|
||||
}
|
||||
else {
|
||||
delegate?.react(cellViewModel, with: reactionView.viewModel.emoji)
|
||||
delegate?.react(cellViewModel, with: reactionView.viewModel.emoji, using: dependencies)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
@ -921,7 +918,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
}
|
||||
}
|
||||
else if snContentView.bounds.contains(snContentView.convert(location, from: self)) {
|
||||
delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer)
|
||||
delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer, using: dependencies)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -989,11 +986,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
private func reply() {
|
||||
private func reply(using dependencies: Dependencies = Dependencies()) {
|
||||
guard let cellViewModel: MessageViewModel = self.viewModel else { return }
|
||||
|
||||
resetReply()
|
||||
delegate?.handleReplyButtonTapped(for: cellViewModel)
|
||||
delegate?.handleReplyButtonTapped(for: cellViewModel, using: dependencies)
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
@ -1083,8 +1080,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
|
||||
case .standardIncoming, .standardIncomingDeleted:
|
||||
let isGroupThread = (
|
||||
cellViewModel.threadVariant == .openGroup ||
|
||||
cellViewModel.threadVariant == .closedGroup
|
||||
cellViewModel.threadVariant == .community ||
|
||||
cellViewModel.threadVariant == .legacyGroup ||
|
||||
cellViewModel.threadVariant == .group
|
||||
)
|
||||
let leftGutterSize = (isGroupThread ? leftGutterSize : contactThreadHSpacing)
|
||||
|
||||
|
@ -1123,7 +1121,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
in: (cellViewModel.body ?? ""),
|
||||
threadVariant: cellViewModel.threadVariant,
|
||||
currentUserPublicKey: cellViewModel.currentUserPublicKey,
|
||||
currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey,
|
||||
currentUserBlinded15PublicKey: cellViewModel.currentUserBlinded15PublicKey,
|
||||
currentUserBlinded25PublicKey: cellViewModel.currentUserBlinded25PublicKey,
|
||||
isOutgoingMessage: isOutgoing,
|
||||
textColor: actualTextColor,
|
||||
theme: theme,
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
#import "OWSMath.h"
|
||||
#import "UIView+OWS.h"
|
||||
#import <QuartzCore/QuartzCore.h>
|
||||
#import <SignalCoreKit/OWSAsserts.h>
|
||||
#import <PureLayout/PureLayout.h>
|
||||
#import <SignalCoreKit/NSDate+OWS.h>
|
||||
#import <SessionUtilitiesKit/NSTimer+Proxying.h>
|
||||
|
|
|
@ -0,0 +1,199 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionSnodeKit
|
||||
|
||||
class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource {
|
||||
typealias TableItem = String
|
||||
|
||||
public let dependencies: Dependencies
|
||||
public let navigatableState: NavigatableState = NavigatableState()
|
||||
public let state: TableDataState<Section, TableItem> = TableDataState()
|
||||
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
|
||||
|
||||
private let threadId: String
|
||||
private let threadVariant: SessionThread.Variant
|
||||
private let config: DisappearingMessagesConfiguration
|
||||
private var storedSelection: TimeInterval
|
||||
private var currentSelection: CurrentValueSubject<TimeInterval, Never>
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
threadId: String,
|
||||
threadVariant: SessionThread.Variant,
|
||||
config: DisappearingMessagesConfiguration,
|
||||
using dependencies: Dependencies = Dependencies()
|
||||
) {
|
||||
self.dependencies = dependencies
|
||||
self.threadId = threadId
|
||||
self.threadVariant = threadVariant
|
||||
self.config = config
|
||||
self.storedSelection = (config.isEnabled ? config.durationSeconds : 0)
|
||||
self.currentSelection = CurrentValueSubject(self.storedSelection)
|
||||
}
|
||||
|
||||
// MARK: - Config
|
||||
|
||||
enum NavItem: Equatable {
|
||||
case cancel
|
||||
case save
|
||||
}
|
||||
|
||||
public enum Section: SessionTableSection {
|
||||
case content
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
lazy var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = [
|
||||
SessionNavItem(
|
||||
id: .cancel,
|
||||
systemItem: .cancel,
|
||||
accessibilityIdentifier: "Cancel button"
|
||||
) { [weak self] in self?.dismissScreen() }
|
||||
]
|
||||
|
||||
lazy var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = currentSelection
|
||||
.removeDuplicates()
|
||||
.map { [weak self] currentSelection in (self?.storedSelection != currentSelection) }
|
||||
.map { [weak self, dependencies] isChanged in
|
||||
guard isChanged else { return [] }
|
||||
|
||||
return [
|
||||
SessionNavItem(
|
||||
id: .save,
|
||||
systemItem: .save,
|
||||
accessibilityIdentifier: "Save button"
|
||||
) {
|
||||
self?.saveChanges(using: dependencies)
|
||||
self?.dismissScreen()
|
||||
}
|
||||
]
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
let title: String = "DISAPPEARING_MESSAGES".localized()
|
||||
|
||||
lazy var observation: TargetObservation = ObservationBuilder
|
||||
.databaseObservation(self) { [dependencies, threadId = self.threadId] db -> SessionThreadViewModel? in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies)
|
||||
|
||||
return try SessionThreadViewModel
|
||||
.conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey)
|
||||
.fetchOne(db)
|
||||
}
|
||||
.map { [weak self, config, dependencies, threadId = self.threadId] maybeThreadViewModel -> [SectionModel] in
|
||||
return [
|
||||
SectionModel(
|
||||
model: .content,
|
||||
elements: [
|
||||
SessionCell.Info(
|
||||
id: "DISAPPEARING_MESSAGES_OFF".localized(),
|
||||
title: "DISAPPEARING_MESSAGES_OFF".localized(),
|
||||
rightAccessory: .radio(
|
||||
isSelected: { (self?.currentSelection.value == 0) }
|
||||
),
|
||||
isEnabled: (
|
||||
(
|
||||
maybeThreadViewModel?.threadVariant != .legacyGroup &&
|
||||
maybeThreadViewModel?.threadVariant != .group
|
||||
) ||
|
||||
maybeThreadViewModel?.currentUserIsClosedGroupMember == true
|
||||
),
|
||||
onTap: { self?.currentSelection.send(0) }
|
||||
)
|
||||
].appending(
|
||||
contentsOf: DisappearingMessagesConfiguration.validDurationsSeconds
|
||||
.map { duration in
|
||||
let title: String = duration.formatted(format: .long)
|
||||
|
||||
return SessionCell.Info(
|
||||
id: title,
|
||||
title: title,
|
||||
rightAccessory: .radio(
|
||||
isSelected: { (self?.currentSelection.value == duration) }
|
||||
),
|
||||
isEnabled: (
|
||||
(
|
||||
maybeThreadViewModel?.threadVariant != .legacyGroup &&
|
||||
maybeThreadViewModel?.threadVariant != .group
|
||||
) ||
|
||||
maybeThreadViewModel?.currentUserIsClosedGroupMember == true
|
||||
),
|
||||
onTap: { self?.currentSelection.send(duration) }
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
private func saveChanges(using dependencies: Dependencies = Dependencies()) {
|
||||
let threadId: String = self.threadId
|
||||
let threadVariant: SessionThread.Variant = self.threadVariant
|
||||
let currentSelection: TimeInterval = self.currentSelection.value
|
||||
let updatedConfig: DisappearingMessagesConfiguration = self.config
|
||||
.with(
|
||||
isEnabled: (currentSelection != 0),
|
||||
durationSeconds: currentSelection
|
||||
)
|
||||
|
||||
guard self.config != updatedConfig else { return }
|
||||
|
||||
dependencies.storage.writeAsync { db in
|
||||
let config: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration
|
||||
.fetchOne(db, id: threadId)
|
||||
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId))
|
||||
.with(
|
||||
isEnabled: (currentSelection != 0),
|
||||
durationSeconds: currentSelection
|
||||
)
|
||||
.saved(db)
|
||||
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: threadId,
|
||||
authorId: getUserHexEncodedPublicKey(db),
|
||||
variant: .infoDisappearingMessagesUpdate,
|
||||
body: config.messageInfoString(with: nil),
|
||||
timestampMs: SnodeAPI.currentOffsetTimestampMs()
|
||||
)
|
||||
.inserted(db)
|
||||
|
||||
try MessageSender.send(
|
||||
db,
|
||||
message: ExpirationTimerUpdate(
|
||||
syncTarget: nil,
|
||||
duration: UInt32(floor(updatedConfig.isEnabled ? updatedConfig.durationSeconds : 0))
|
||||
),
|
||||
interactionId: interaction.id,
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
using: dependencies
|
||||
)
|
||||
|
||||
// Legacy closed groups
|
||||
switch threadVariant {
|
||||
case .legacyGroup:
|
||||
try SessionUtil
|
||||
.update(
|
||||
db,
|
||||
groupPublicKey: threadId,
|
||||
disappearingConfig: updatedConfig
|
||||
)
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,186 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappearingMessagesViewModel.NavButton, ThreadDisappearingMessagesViewModel.Section, ThreadDisappearingMessagesViewModel.Item> {
|
||||
// MARK: - Config
|
||||
|
||||
enum NavButton: Equatable {
|
||||
case cancel
|
||||
case save
|
||||
}
|
||||
|
||||
public enum Section: SessionTableSection {
|
||||
case content
|
||||
}
|
||||
|
||||
public struct Item: Equatable, Hashable, Differentiable {
|
||||
let title: String
|
||||
|
||||
public var differenceIdentifier: String { title }
|
||||
}
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
private let dependencies: Dependencies
|
||||
private let threadId: String
|
||||
private let config: DisappearingMessagesConfiguration
|
||||
private var storedSelection: TimeInterval
|
||||
private var currentSelection: CurrentValueSubject<TimeInterval, Never>
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
dependencies: Dependencies = Dependencies(),
|
||||
threadId: String,
|
||||
config: DisappearingMessagesConfiguration
|
||||
) {
|
||||
self.dependencies = dependencies
|
||||
self.threadId = threadId
|
||||
self.config = config
|
||||
self.storedSelection = (config.isEnabled ? config.durationSeconds : 0)
|
||||
self.currentSelection = CurrentValueSubject(self.storedSelection)
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
override var leftNavItems: AnyPublisher<[NavItem]?, Never> {
|
||||
Just([
|
||||
NavItem(
|
||||
id: .cancel,
|
||||
systemItem: .cancel,
|
||||
accessibilityIdentifier: "Cancel button"
|
||||
) { [weak self] in self?.dismissScreen() }
|
||||
]).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
override var rightNavItems: AnyPublisher<[NavItem]?, Never> {
|
||||
currentSelection
|
||||
.removeDuplicates()
|
||||
.map { [weak self] currentSelection in (self?.storedSelection != currentSelection) }
|
||||
.map { isChanged in
|
||||
guard isChanged else { return [] }
|
||||
|
||||
return [
|
||||
NavItem(
|
||||
id: .save,
|
||||
systemItem: .save,
|
||||
accessibilityIdentifier: "Save button"
|
||||
) { [weak self] in
|
||||
self?.saveChanges()
|
||||
self?.dismissScreen()
|
||||
}
|
||||
]
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
override var title: String { "DISAPPEARING_MESSAGES".localized() }
|
||||
|
||||
private var _settingsData: [SectionModel] = []
|
||||
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
|
||||
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||
///
|
||||
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
|
||||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
||||
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||
private lazy var _observableSettingsData: ObservableData = ValueObservation
|
||||
.trackingConstantRegion { [weak self, config] db -> [SectionModel] in
|
||||
return [
|
||||
SectionModel(
|
||||
model: .content,
|
||||
elements: [
|
||||
SessionCell.Info(
|
||||
id: Item(title: "DISAPPEARING_MESSAGES_OFF".localized()),
|
||||
title: "DISAPPEARING_MESSAGES_OFF".localized(),
|
||||
rightAccessory: .radio(
|
||||
isSelected: { (self?.currentSelection.value == 0) }
|
||||
),
|
||||
onTap: { self?.currentSelection.send(0) }
|
||||
)
|
||||
].appending(
|
||||
contentsOf: DisappearingMessagesConfiguration.validDurationsSeconds
|
||||
.map { duration in
|
||||
let title: String = duration.formatted(format: .long)
|
||||
|
||||
return SessionCell.Info(
|
||||
id: Item(title: title),
|
||||
title: title,
|
||||
rightAccessory: .radio(
|
||||
isSelected: { (self?.currentSelection.value == duration) }
|
||||
),
|
||||
onTap: { self?.currentSelection.send(duration) }
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
]
|
||||
}
|
||||
.removeDuplicates()
|
||||
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
||||
self._settingsData = updatedSettings
|
||||
}
|
||||
|
||||
private func saveChanges() {
|
||||
let threadId: String = self.threadId
|
||||
let currentSelection: TimeInterval = self.currentSelection.value
|
||||
let updatedConfig: DisappearingMessagesConfiguration = self.config
|
||||
.with(
|
||||
isEnabled: (currentSelection != 0),
|
||||
durationSeconds: currentSelection
|
||||
)
|
||||
|
||||
guard self.config != updatedConfig else { return }
|
||||
|
||||
dependencies.storage.writeAsync { db in
|
||||
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else {
|
||||
return
|
||||
}
|
||||
|
||||
let config: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration
|
||||
.fetchOne(db, id: threadId)
|
||||
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId))
|
||||
.with(
|
||||
isEnabled: (currentSelection != 0),
|
||||
durationSeconds: currentSelection
|
||||
)
|
||||
.saved(db)
|
||||
|
||||
let interaction: Interaction = try Interaction(
|
||||
threadId: threadId,
|
||||
authorId: getUserHexEncodedPublicKey(db),
|
||||
variant: .infoDisappearingMessagesUpdate,
|
||||
body: config.messageInfoString(with: nil),
|
||||
timestampMs: SnodeAPI.currentOffsetTimestampMs()
|
||||
)
|
||||
.inserted(db)
|
||||
|
||||
try MessageSender.send(
|
||||
db,
|
||||
message: ExpirationTimerUpdate(
|
||||
syncTarget: nil,
|
||||
duration: UInt32(floor(updatedConfig.isEnabled ? updatedConfig.durationSeconds : 0))
|
||||
),
|
||||
interactionId: interaction.id,
|
||||
in: thread
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,50 +3,21 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import YYImage
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionSnodeKit
|
||||
|
||||
class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.NavButton, ThreadSettingsViewModel.Section, ThreadSettingsViewModel.Setting> {
|
||||
// MARK: - Config
|
||||
class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, EditableStateHolder, ObservableTableSource {
|
||||
public let dependencies: Dependencies
|
||||
public let navigatableState: NavigatableState = NavigatableState()
|
||||
public let editableState: EditableState<TableItem> = EditableState()
|
||||
public let state: TableDataState<Section, TableItem> = TableDataState()
|
||||
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
|
||||
|
||||
enum NavState {
|
||||
case standard
|
||||
case editing
|
||||
}
|
||||
|
||||
enum NavButton: Equatable {
|
||||
case edit
|
||||
case cancel
|
||||
case done
|
||||
}
|
||||
|
||||
public enum Section: SessionTableSection {
|
||||
case conversationInfo
|
||||
case content
|
||||
}
|
||||
|
||||
public enum Setting: Differentiable {
|
||||
case threadInfo
|
||||
case copyThreadId
|
||||
case allMedia
|
||||
case searchConversation
|
||||
case addToOpenGroup
|
||||
case disappearingMessages
|
||||
case disappearingMessagesDuration
|
||||
case editGroup
|
||||
case leaveGroup
|
||||
case notificationSound
|
||||
case notificationMentionsOnly
|
||||
case notificationMute
|
||||
case blockUser
|
||||
}
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
private let dependencies: Dependencies
|
||||
private let threadId: String
|
||||
private let threadVariant: SessionThread.Variant
|
||||
private let didTriggerSearch: () -> ()
|
||||
|
@ -56,10 +27,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
dependencies: Dependencies = Dependencies(),
|
||||
threadId: String,
|
||||
threadVariant: SessionThread.Variant,
|
||||
didTriggerSearch: @escaping () -> ()
|
||||
didTriggerSearch: @escaping () -> (),
|
||||
using dependencies: Dependencies = Dependencies()
|
||||
) {
|
||||
self.dependencies = dependencies
|
||||
self.threadId = threadId
|
||||
|
@ -77,120 +48,156 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
)
|
||||
}
|
||||
|
||||
// MARK: - Config
|
||||
|
||||
enum NavState {
|
||||
case standard
|
||||
case editing
|
||||
}
|
||||
|
||||
enum NavItem: Equatable {
|
||||
case edit
|
||||
case cancel
|
||||
case done
|
||||
}
|
||||
|
||||
public enum Section: SessionTableSection {
|
||||
case conversationInfo
|
||||
case content
|
||||
}
|
||||
|
||||
public enum TableItem: Differentiable {
|
||||
case avatar
|
||||
case nickname
|
||||
case sessionId
|
||||
|
||||
case copyThreadId
|
||||
case allMedia
|
||||
case searchConversation
|
||||
case addToOpenGroup
|
||||
case disappearingMessages
|
||||
case disappearingMessagesDuration
|
||||
case editGroup
|
||||
case leaveGroup
|
||||
case notificationSound
|
||||
case notificationMentionsOnly
|
||||
case notificationMute
|
||||
case blockUser
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
lazy var navState: AnyPublisher<NavState, Never> = {
|
||||
isEditing
|
||||
.map { isEditing in (isEditing ? .editing : .standard) }
|
||||
Publishers
|
||||
.CombineLatest(
|
||||
isEditing,
|
||||
textChanged
|
||||
.handleEvents(
|
||||
receiveOutput: { [weak self] value, _ in
|
||||
self?.editedDisplayName = value
|
||||
}
|
||||
)
|
||||
.filter { _ in false }
|
||||
.prepend((nil, .nickname))
|
||||
)
|
||||
.map { isEditing, _ -> NavState in (isEditing ? .editing : .standard) }
|
||||
.removeDuplicates()
|
||||
.prepend(.standard) // Initial value
|
||||
.shareReplay(1)
|
||||
.eraseToAnyPublisher()
|
||||
}()
|
||||
|
||||
override var leftNavItems: AnyPublisher<[NavItem]?, Never> {
|
||||
navState
|
||||
.map { [weak self] navState -> [NavItem] in
|
||||
// Only show the 'Edit' button if it's a contact thread
|
||||
guard self?.threadVariant == .contact else { return [] }
|
||||
guard navState == .editing else { return [] }
|
||||
lazy var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = navState
|
||||
.map { [weak self] navState -> [SessionNavItem<NavItem>] in
|
||||
// Only show the 'Edit' button if it's a contact thread
|
||||
guard self?.threadVariant == .contact else { return [] }
|
||||
guard navState == .editing else { return [] }
|
||||
|
||||
return [
|
||||
NavItem(
|
||||
id: .cancel,
|
||||
systemItem: .cancel,
|
||||
accessibilityIdentifier: "Cancel button"
|
||||
) { [weak self] in
|
||||
self?.setIsEditing(false)
|
||||
self?.editedDisplayName = self?.oldDisplayName
|
||||
}
|
||||
]
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
override var rightNavItems: AnyPublisher<[NavItem]?, Never> {
|
||||
navState
|
||||
.map { [weak self, dependencies] navState -> [NavItem] in
|
||||
// Only show the 'Edit' button if it's a contact thread
|
||||
guard self?.threadVariant == .contact else { return [] }
|
||||
|
||||
switch navState {
|
||||
case .editing:
|
||||
return [
|
||||
NavItem(
|
||||
id: .done,
|
||||
systemItem: .done,
|
||||
accessibilityIdentifier: "Done"
|
||||
) { [weak self] in
|
||||
self?.setIsEditing(false)
|
||||
|
||||
guard
|
||||
self?.threadVariant == .contact,
|
||||
let threadId: String = self?.threadId,
|
||||
let editedDisplayName: String = self?.editedDisplayName
|
||||
else { return }
|
||||
|
||||
let updatedNickname: String = editedDisplayName
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self?.oldDisplayName = (updatedNickname.isEmpty ? nil : editedDisplayName)
|
||||
|
||||
dependencies.storage.writeAsync { db in
|
||||
try Profile
|
||||
.filter(id: threadId)
|
||||
.updateAll(
|
||||
db,
|
||||
Profile.Columns.nickname
|
||||
.set(to: (updatedNickname.isEmpty ? nil : editedDisplayName))
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
case .standard:
|
||||
return [
|
||||
NavItem(
|
||||
id: .edit,
|
||||
systemItem: .edit,
|
||||
accessibilityIdentifier: "Edit button",
|
||||
accessibilityLabel: "Edit user nickname"
|
||||
) { [weak self] in self?.setIsEditing(true) }
|
||||
]
|
||||
return [
|
||||
SessionNavItem(
|
||||
id: .cancel,
|
||||
systemItem: .cancel,
|
||||
accessibilityIdentifier: "Cancel button"
|
||||
) { [weak self] in
|
||||
self?.setIsEditing(false)
|
||||
self?.editedDisplayName = self?.oldDisplayName
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
]
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
lazy var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = navState
|
||||
.map { [weak self, dependencies] navState -> [SessionNavItem<NavItem>] in
|
||||
// Only show the 'Edit' button if it's a contact thread
|
||||
guard self?.threadVariant == .contact else { return [] }
|
||||
|
||||
switch navState {
|
||||
case .editing:
|
||||
return [
|
||||
SessionNavItem(
|
||||
id: .done,
|
||||
systemItem: .done,
|
||||
accessibilityIdentifier: "Done"
|
||||
) { [weak self] in
|
||||
self?.setIsEditing(false)
|
||||
|
||||
guard
|
||||
self?.threadVariant == .contact,
|
||||
let threadId: String = self?.threadId,
|
||||
let editedDisplayName: String = self?.editedDisplayName
|
||||
else { return }
|
||||
|
||||
let updatedNickname: String = editedDisplayName
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self?.oldDisplayName = (updatedNickname.isEmpty ? nil : editedDisplayName)
|
||||
|
||||
dependencies.storage.writeAsync(using: dependencies) { db in
|
||||
try Profile
|
||||
.filter(id: threadId)
|
||||
.updateAllAndConfig(
|
||||
db,
|
||||
Profile.Columns.nickname
|
||||
.set(to: (updatedNickname.isEmpty ? nil : editedDisplayName))
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
case .standard:
|
||||
return [
|
||||
SessionNavItem(
|
||||
id: .edit,
|
||||
systemItem: .edit,
|
||||
accessibilityIdentifier: "Edit button",
|
||||
accessibilityLabel: "Edit user nickname"
|
||||
) { [weak self] in self?.setIsEditing(true) }
|
||||
]
|
||||
}
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
override var title: String {
|
||||
private struct State: Equatable {
|
||||
let threadViewModel: SessionThreadViewModel?
|
||||
let notificationSound: Preferences.Sound
|
||||
let disappearingMessagesConfig: DisappearingMessagesConfiguration
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch threadVariant {
|
||||
case .contact: return "vc_settings_title".localized()
|
||||
case .closedGroup, .openGroup: return "vc_group_settings_title".localized()
|
||||
case .legacyGroup, .group, .community: return "vc_group_settings_title".localized()
|
||||
}
|
||||
}
|
||||
|
||||
private var _settingsData: [SectionModel] = []
|
||||
public override var settingsData: [SectionModel] { _settingsData }
|
||||
|
||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
||||
|
||||
/// 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
|
||||
///
|
||||
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
|
||||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
||||
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||
private lazy var _observableSettingsData: ObservableData = ValueObservation
|
||||
.trackingConstantRegion { [weak self, dependencies, threadId = self.threadId, threadVariant = self.threadVariant] db -> [SectionModel] in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
|
||||
let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
||||
lazy var observation: TargetObservation = ObservationBuilder
|
||||
.databaseObservation(self) { [dependencies, threadId = self.threadId] db -> State in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies)
|
||||
let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
||||
.conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey)
|
||||
.fetchOne(db)
|
||||
|
||||
guard let threadViewModel: SessionThreadViewModel = maybeThreadViewModel else { return [] }
|
||||
|
||||
// Additional Queries
|
||||
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
||||
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
||||
let notificationSound: Preferences.Sound = try SessionThread
|
||||
|
@ -202,69 +209,164 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
let disappearingMessagesConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration
|
||||
.fetchOne(db, id: threadId)
|
||||
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId))
|
||||
|
||||
return State(
|
||||
threadViewModel: threadViewModel,
|
||||
notificationSound: notificationSound,
|
||||
disappearingMessagesConfig: disappearingMessagesConfig
|
||||
)
|
||||
}
|
||||
.mapWithPrevious { [weak self, dependencies] previous, current -> [SectionModel] in
|
||||
// If we don't get a `SessionThreadViewModel` then it means the thread was probably deleted
|
||||
// so dismiss the screen
|
||||
guard let threadViewModel: SessionThreadViewModel = current.threadViewModel else {
|
||||
self?.dismissScreen(type: .popToRoot)
|
||||
return []
|
||||
}
|
||||
|
||||
let currentUserIsClosedGroupMember: Bool = (
|
||||
threadVariant == .closedGroup &&
|
||||
(
|
||||
threadViewModel.threadVariant == .legacyGroup ||
|
||||
threadViewModel.threadVariant == .group
|
||||
) &&
|
||||
threadViewModel.currentUserIsClosedGroupMember == true
|
||||
)
|
||||
let currentUserIsClosedGroupAdmin: Bool = (
|
||||
threadVariant == .closedGroup &&
|
||||
(
|
||||
threadViewModel.threadVariant == .legacyGroup ||
|
||||
threadViewModel.threadVariant == .group
|
||||
) &&
|
||||
threadViewModel.currentUserIsClosedGroupAdmin == true
|
||||
)
|
||||
let editIcon: UIImage? = UIImage(named: "icon_edit")
|
||||
|
||||
return [
|
||||
SectionModel(
|
||||
model: .conversationInfo,
|
||||
elements: [
|
||||
SessionCell.Info(
|
||||
id: .threadInfo,
|
||||
leftAccessory: .threadInfo(
|
||||
threadViewModel: threadViewModel,
|
||||
avatarTapped: { [weak self] in
|
||||
self?.updateProfilePicture(threadViewModel: threadViewModel)
|
||||
},
|
||||
titleTapped: { [weak self] in self?.setIsEditing(true) },
|
||||
titleChanged: { [weak self] text in self?.editedDisplayName = text }
|
||||
id: .avatar,
|
||||
accessory: .profile(
|
||||
id: threadViewModel.id,
|
||||
size: .hero,
|
||||
threadVariant: threadViewModel.threadVariant,
|
||||
customImageData: threadViewModel.openGroupProfilePictureData,
|
||||
profile: threadViewModel.profile,
|
||||
profileIcon: .none,
|
||||
additionalProfile: threadViewModel.additionalProfile,
|
||||
additionalProfileIcon: .none,
|
||||
accessibility: nil
|
||||
),
|
||||
title: threadViewModel.displayName,
|
||||
shouldHaveBackground: false
|
||||
styling: SessionCell.StyleInfo(
|
||||
alignment: .centerHugging,
|
||||
customPadding: SessionCell.Padding(bottom: Values.smallSpacing),
|
||||
backgroundStyle: .noBackground
|
||||
),
|
||||
onTap: { self?.viewProfilePicture(threadViewModel: threadViewModel) }
|
||||
),
|
||||
SessionCell.Info(
|
||||
id: .nickname,
|
||||
leftAccessory: (threadViewModel.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: (threadViewModel.threadVariant == .contact ? .editable : .none)
|
||||
),
|
||||
styling: SessionCell.StyleInfo(
|
||||
alignment: .centerHugging,
|
||||
customPadding: SessionCell.Padding(
|
||||
top: Values.smallSpacing,
|
||||
trailing: (threadViewModel.threadVariant != .contact ?
|
||||
nil :
|
||||
-(((editIcon?.size.width ?? 0) + (Values.smallSpacing * 2)) / 2)
|
||||
),
|
||||
bottom: (threadViewModel.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)
|
||||
}
|
||||
),
|
||||
|
||||
(threadViewModel.threadVariant != .contact ? nil :
|
||||
SessionCell.Info(
|
||||
id: .sessionId,
|
||||
subtitle: SessionCell.TextInfo(
|
||||
threadViewModel.id,
|
||||
font: .monoSmall,
|
||||
alignment: .center,
|
||||
interaction: .copy
|
||||
),
|
||||
styling: SessionCell.StyleInfo(
|
||||
customPadding: SessionCell.Padding(
|
||||
top: Values.smallSpacing,
|
||||
bottom: Values.largeSpacing
|
||||
),
|
||||
backgroundStyle: .noBackground
|
||||
),
|
||||
accessibility: Accessibility(
|
||||
identifier: "Session ID",
|
||||
label: threadViewModel.id
|
||||
)
|
||||
)
|
||||
)
|
||||
]
|
||||
].compactMap { $0 }
|
||||
),
|
||||
SectionModel(
|
||||
model: .content,
|
||||
elements: [
|
||||
(threadVariant == .closedGroup ? nil :
|
||||
(threadViewModel.threadVariant == .legacyGroup || threadViewModel.threadVariant == .group ? nil :
|
||||
SessionCell.Info(
|
||||
id: .copyThreadId,
|
||||
leftAccessory: .icon(
|
||||
UIImage(named: "ic_copy")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: (threadVariant == .openGroup ?
|
||||
title: (threadViewModel.threadVariant == .community ?
|
||||
"COPY_GROUP_URL".localized() :
|
||||
"vc_conversation_settings_copy_session_id_button_title".localized()
|
||||
),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).copy_thread_id",
|
||||
accessibilityLabel: "Copy Session ID",
|
||||
accessibility: Accessibility(
|
||||
identifier: "\(ThreadSettingsViewModel.self).copy_thread_id",
|
||||
label: "Copy Session ID"
|
||||
),
|
||||
onTap: {
|
||||
switch threadVariant {
|
||||
case .contact, .closedGroup:
|
||||
UIPasteboard.general.string = threadId
|
||||
|
||||
case .openGroup:
|
||||
switch threadViewModel.threadVariant {
|
||||
case .contact, .legacyGroup, .group:
|
||||
UIPasteboard.general.string = threadViewModel.threadId
|
||||
|
||||
case .community:
|
||||
guard
|
||||
let server: String = threadViewModel.openGroupServer,
|
||||
let roomToken: String = threadViewModel.openGroupRoomToken,
|
||||
let publicKey: String = threadViewModel.openGroupPublicKey
|
||||
else { return }
|
||||
|
||||
UIPasteboard.general.string = OpenGroup.urlFor(
|
||||
|
||||
UIPasteboard.general.string = SessionUtil.communityUrlFor(
|
||||
server: server,
|
||||
roomToken: roomToken,
|
||||
publicKey: publicKey
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
self?.showToast(
|
||||
text: "copied".localized(),
|
||||
backgroundColor: .backgroundSecondary
|
||||
|
@ -272,7 +374,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
}
|
||||
)
|
||||
),
|
||||
|
||||
|
||||
SessionCell.Info(
|
||||
id: .allMedia,
|
||||
leftAccessory: .icon(
|
||||
|
@ -280,19 +382,21 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: MediaStrings.allMedia,
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).all_media",
|
||||
accessibilityLabel: "All media",
|
||||
accessibility: Accessibility(
|
||||
identifier: "\(ThreadSettingsViewModel.self).all_media",
|
||||
label: "All media"
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
self?.transitionToScreen(
|
||||
MediaGalleryViewModel.createAllMediaViewController(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
threadId: threadViewModel.threadId,
|
||||
threadVariant: threadViewModel.threadVariant,
|
||||
focusedAttachmentId: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
|
||||
|
||||
SessionCell.Info(
|
||||
id: .searchConversation,
|
||||
leftAccessory: .icon(
|
||||
|
@ -300,14 +404,16 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "CONVERSATION_SETTINGS_SEARCH".localized(),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).search",
|
||||
accessibilityLabel: "Search",
|
||||
accessibility: Accessibility(
|
||||
identifier: "\(ThreadSettingsViewModel.self).search",
|
||||
label: "Search"
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
self?.didTriggerSearch()
|
||||
}
|
||||
),
|
||||
|
||||
(threadVariant != .openGroup ? nil :
|
||||
|
||||
(threadViewModel.threadVariant != .community ? nil :
|
||||
SessionCell.Info(
|
||||
id: .addToOpenGroup,
|
||||
leftAccessory: .icon(
|
||||
|
@ -315,7 +421,9 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "vc_conversation_settings_invite_button_title".localized(),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).add_to_open_group",
|
||||
accessibility: Accessibility(
|
||||
identifier: "\(ThreadSettingsViewModel.self).add_to_open_group"
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
self?.transitionToScreen(
|
||||
UserSelectionVC(
|
||||
|
@ -331,42 +439,47 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
}
|
||||
)
|
||||
),
|
||||
|
||||
(threadVariant == .openGroup || threadViewModel.threadIsBlocked == true ? nil :
|
||||
|
||||
(threadViewModel.threadVariant == .community || threadViewModel.threadIsBlocked == true ? nil :
|
||||
SessionCell.Info(
|
||||
id: .disappearingMessages,
|
||||
leftAccessory: .icon(
|
||||
UIImage(
|
||||
named: (disappearingMessagesConfig.isEnabled ?
|
||||
named: (current.disappearingMessagesConfig.isEnabled ?
|
||||
"ic_timer" :
|
||||
"ic_timer_disabled"
|
||||
)
|
||||
)?.withRenderingMode(.alwaysTemplate)
|
||||
)?.withRenderingMode(.alwaysTemplate),
|
||||
accessibility: Accessibility(
|
||||
label: "Timer icon"
|
||||
)
|
||||
),
|
||||
title: "DISAPPEARING_MESSAGES".localized(),
|
||||
subtitle: (disappearingMessagesConfig.isEnabled ?
|
||||
subtitle: (current.disappearingMessagesConfig.isEnabled ?
|
||||
String(
|
||||
format: "DISAPPEARING_MESSAGES_SUBTITLE_DISAPPEAR_AFTER".localized(),
|
||||
arguments: [disappearingMessagesConfig.durationString]
|
||||
arguments: [current.disappearingMessagesConfig.durationString]
|
||||
) :
|
||||
"DISAPPEARING_MESSAGES_SUBTITLE_OFF".localized()
|
||||
),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).disappearing_messages",
|
||||
accessibilityLabel: "Disappearing messages",
|
||||
leftAccessoryAccessibilityLabel: "Timer icon",
|
||||
accessibility: Accessibility(
|
||||
identifier: "\(ThreadSettingsViewModel.self).disappearing_messages",
|
||||
label: "Disappearing messages"
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
self?.transitionToScreen(
|
||||
SessionTableViewController(
|
||||
viewModel: ThreadDisappearingMessagesViewModel(
|
||||
threadId: threadId,
|
||||
config: disappearingMessagesConfig
|
||||
viewModel: ThreadDisappearingMessagesSettingsViewModel(
|
||||
threadId: threadViewModel.threadId,
|
||||
threadVariant: threadViewModel.threadVariant,
|
||||
config: current.disappearingMessagesConfig
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
),
|
||||
|
||||
|
||||
(!currentUserIsClosedGroupMember ? nil :
|
||||
SessionCell.Info(
|
||||
id: .editGroup,
|
||||
|
@ -375,10 +488,17 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "EDIT_GROUP_ACTION".localized(),
|
||||
accessibilityIdentifier: "Edit group",
|
||||
accessibilityLabel: "Edit group",
|
||||
accessibility: Accessibility(
|
||||
identifier: "Edit group",
|
||||
label: "Edit group"
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
self?.transitionToScreen(EditClosedGroupVC(threadId: threadId))
|
||||
self?.transitionToScreen(
|
||||
EditClosedGroupVC(
|
||||
threadId: threadViewModel.threadId,
|
||||
threadVariant: threadViewModel.threadVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
),
|
||||
|
@ -391,11 +511,13 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
.withRenderingMode(.alwaysTemplate)
|
||||
),
|
||||
title: "LEAVE_GROUP_ACTION".localized(),
|
||||
accessibilityIdentifier: "Leave group",
|
||||
accessibilityLabel: "Leave group",
|
||||
accessibility: Accessibility(
|
||||
identifier: "Leave group",
|
||||
label: "Leave group"
|
||||
),
|
||||
confirmationInfo: ConfirmationModal.Info(
|
||||
title: "leave_group_confirmation_alert_title".localized(),
|
||||
attributedExplanation: {
|
||||
body: .attributedText({
|
||||
if currentUserIsClosedGroupAdmin {
|
||||
return NSAttributedString(string: "admin_group_leave_warning".localized())
|
||||
}
|
||||
|
@ -412,14 +534,20 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
range: (mutableAttributedString.string as NSString).range(of: threadViewModel.displayName)
|
||||
)
|
||||
return mutableAttributedString
|
||||
}(),
|
||||
}()),
|
||||
confirmTitle: "LEAVE_BUTTON_TITLE".localized(),
|
||||
confirmStyle: .danger,
|
||||
cancelStyle: .alert_text
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
dependencies.storage.writeAsync { db in
|
||||
try MessageSender.leave(db, groupPublicKey: threadId, deleteThread: false)
|
||||
dependencies.storage.write { db in
|
||||
try SessionThread.deleteOrLeave(
|
||||
db,
|
||||
threadId: threadViewModel.threadId,
|
||||
threadVariant: threadViewModel.threadVariant,
|
||||
groupLeaveType: .standard,
|
||||
calledFromConfigHandling: false
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -434,19 +562,19 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
),
|
||||
title: "SETTINGS_ITEM_NOTIFICATION_SOUND".localized(),
|
||||
rightAccessory: .dropDown(
|
||||
.dynamicString { notificationSound.displayName }
|
||||
.dynamicString { current.notificationSound.displayName }
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
self?.transitionToScreen(
|
||||
SessionTableViewController(
|
||||
viewModel: NotificationSoundViewModel(threadId: threadId)
|
||||
viewModel: NotificationSoundViewModel(threadId: threadViewModel.threadId)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
),
|
||||
|
||||
(threadVariant == .contact ? nil :
|
||||
(threadViewModel.threadVariant == .contact ? nil :
|
||||
SessionCell.Info(
|
||||
id: .notificationMentionsOnly,
|
||||
leftAccessory: .icon(
|
||||
|
@ -456,20 +584,28 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
title: "vc_conversation_settings_notify_for_mentions_only_title".localized(),
|
||||
subtitle: "vc_conversation_settings_notify_for_mentions_only_explanation".localized(),
|
||||
rightAccessory: .toggle(
|
||||
.boolValue(threadViewModel.threadOnlyNotifyForMentions == true)
|
||||
.boolValue(
|
||||
threadViewModel.threadOnlyNotifyForMentions == true,
|
||||
oldValue: ((previous?.threadViewModel ?? threadViewModel).threadOnlyNotifyForMentions == true)
|
||||
)
|
||||
),
|
||||
isEnabled: (
|
||||
threadViewModel.threadVariant != .closedGroup ||
|
||||
(
|
||||
threadViewModel.threadVariant != .legacyGroup &&
|
||||
threadViewModel.threadVariant != .group
|
||||
) ||
|
||||
currentUserIsClosedGroupMember
|
||||
),
|
||||
accessibilityIdentifier: "Mentions only notification setting",
|
||||
accessibilityLabel: "Mentions only",
|
||||
accessibility: Accessibility(
|
||||
identifier: "Mentions only notification setting",
|
||||
label: "Mentions only"
|
||||
),
|
||||
onTap: {
|
||||
let newValue: Bool = !(threadViewModel.threadOnlyNotifyForMentions == true)
|
||||
|
||||
dependencies.storage.writeAsync { db in
|
||||
try SessionThread
|
||||
.filter(id: threadId)
|
||||
.filter(id: threadViewModel.threadId)
|
||||
.updateAll(
|
||||
db,
|
||||
SessionThread.Columns.onlyNotifyForMentions
|
||||
|
@ -489,24 +625,32 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
),
|
||||
title: "CONVERSATION_SETTINGS_MUTE_LABEL".localized(),
|
||||
rightAccessory: .toggle(
|
||||
.boolValue(threadViewModel.threadMutedUntilTimestamp != nil)
|
||||
.boolValue(
|
||||
threadViewModel.threadMutedUntilTimestamp != nil,
|
||||
oldValue: ((previous?.threadViewModel ?? threadViewModel).threadMutedUntilTimestamp != nil)
|
||||
)
|
||||
),
|
||||
isEnabled: (
|
||||
threadViewModel.threadVariant != .closedGroup ||
|
||||
(
|
||||
threadViewModel.threadVariant != .legacyGroup &&
|
||||
threadViewModel.threadVariant != .group
|
||||
) ||
|
||||
currentUserIsClosedGroupMember
|
||||
),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).mute",
|
||||
accessibilityLabel: "Mute notifications",
|
||||
accessibility: Accessibility(
|
||||
identifier: "\(ThreadSettingsViewModel.self).mute",
|
||||
label: "Mute notifications"
|
||||
),
|
||||
onTap: {
|
||||
dependencies.storage.writeAsync { db in
|
||||
let currentValue: TimeInterval? = try SessionThread
|
||||
.filter(id: threadId)
|
||||
.filter(id: threadViewModel.threadId)
|
||||
.select(.mutedUntilTimestamp)
|
||||
.asRequest(of: TimeInterval.self)
|
||||
.fetchOne(db)
|
||||
|
||||
try SessionThread
|
||||
.filter(id: threadId)
|
||||
.filter(id: threadViewModel.threadId)
|
||||
.updateAll(
|
||||
db,
|
||||
SessionThread.Columns.mutedUntilTimestamp.set(
|
||||
|
@ -521,7 +665,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
)
|
||||
),
|
||||
|
||||
(threadViewModel.threadIsNoteToSelf || threadVariant != .contact ? nil :
|
||||
(threadViewModel.threadIsNoteToSelf || threadViewModel.threadVariant != .contact ? nil :
|
||||
SessionCell.Info(
|
||||
id: .blockUser,
|
||||
leftAccessory: .icon(
|
||||
|
@ -530,10 +674,15 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
),
|
||||
title: "CONVERSATION_SETTINGS_BLOCK_THIS_USER".localized(),
|
||||
rightAccessory: .toggle(
|
||||
.boolValue(threadViewModel.threadIsBlocked == true)
|
||||
.boolValue(
|
||||
threadViewModel.threadIsBlocked == true,
|
||||
oldValue: ((previous?.threadViewModel ?? threadViewModel).threadIsBlocked == true)
|
||||
)
|
||||
),
|
||||
accessibility: Accessibility(
|
||||
identifier: "\(ThreadSettingsViewModel.self).block",
|
||||
label: "Block"
|
||||
),
|
||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).block",
|
||||
accessibilityLabel: "Block",
|
||||
confirmationInfo: ConfirmationModal.Info(
|
||||
title: {
|
||||
guard threadViewModel.threadIsBlocked == true else {
|
||||
|
@ -548,15 +697,14 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
threadViewModel.displayName
|
||||
)
|
||||
}(),
|
||||
explanation: (threadViewModel.threadIsBlocked == true ?
|
||||
nil :
|
||||
"BLOCK_USER_BEHAVIOR_EXPLANATION".localized()
|
||||
body: (threadViewModel.threadIsBlocked == true ? .none :
|
||||
.text("BLOCK_USER_BEHAVIOR_EXPLANATION".localized())
|
||||
),
|
||||
confirmTitle: (threadViewModel.threadIsBlocked == true ?
|
||||
"BLOCK_LIST_UNBLOCK_BUTTON".localized() :
|
||||
"BLOCK_LIST_BLOCK_BUTTON".localized()
|
||||
),
|
||||
confirmAccessibilityLabel: "Confirm block",
|
||||
confirmAccessibility: Accessibility(identifier: "Confirm block"),
|
||||
confirmStyle: .danger,
|
||||
cancelStyle: .alert_text
|
||||
),
|
||||
|
@ -566,7 +714,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
self?.updateBlockedState(
|
||||
from: isBlocked,
|
||||
isBlocked: !isBlocked,
|
||||
threadId: threadId,
|
||||
threadId: threadViewModel.threadId,
|
||||
displayName: threadViewModel.displayName
|
||||
)
|
||||
}
|
||||
|
@ -576,16 +724,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
)
|
||||
]
|
||||
}
|
||||
.removeDuplicates()
|
||||
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
||||
self._settingsData = updatedSettings
|
||||
}
|
||||
|
||||
private func updateProfilePicture(threadViewModel: SessionThreadViewModel) {
|
||||
private func viewProfilePicture(threadViewModel: SessionThreadViewModel) {
|
||||
guard
|
||||
threadViewModel.threadVariant == .contact,
|
||||
let profile: Profile = threadViewModel.profile,
|
||||
|
@ -619,18 +761,19 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
let publicKey: String = threadViewModel.openGroupPublicKey
|
||||
else { return }
|
||||
|
||||
dependencies.storage.writeAsync { db in
|
||||
let urlString: String = OpenGroup.urlFor(
|
||||
server: server,
|
||||
roomToken: roomToken,
|
||||
publicKey: publicKey
|
||||
)
|
||||
|
||||
let communityUrl: String = SessionUtil.communityUrlFor(
|
||||
server: server,
|
||||
roomToken: roomToken,
|
||||
publicKey: publicKey
|
||||
)
|
||||
|
||||
dependencies.storage.writeAsync { [dependencies] db 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(
|
||||
url: urlString,
|
||||
url: communityUrl,
|
||||
variant: .openGroupInvitation,
|
||||
title: name
|
||||
)
|
||||
|
@ -647,14 +790,16 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
.filter(DisappearingMessagesConfiguration.Columns.isEnabled == true)
|
||||
.asRequest(of: TimeInterval.self)
|
||||
.fetchOne(db),
|
||||
linkPreviewUrl: urlString
|
||||
linkPreviewUrl: communityUrl
|
||||
)
|
||||
.inserted(db)
|
||||
|
||||
try MessageSender.send(
|
||||
db,
|
||||
interaction: interaction,
|
||||
in: thread
|
||||
threadId: thread.id,
|
||||
threadVariant: thread.variant,
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -671,13 +816,13 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
dependencies.storage.writeAsync(
|
||||
updates: { db in
|
||||
try Contact
|
||||
.fetchOrCreate(db, id: threadId)
|
||||
.with(isBlocked: .updateTo(isBlocked))
|
||||
.save(db)
|
||||
.filter(id: threadId)
|
||||
.updateAllAndConfig(
|
||||
db,
|
||||
Contact.Columns.isBlocked.set(to: isBlocked)
|
||||
)
|
||||
},
|
||||
completion: { [weak self] db, _ in
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
|
@ -688,17 +833,18 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
displayName
|
||||
)
|
||||
),
|
||||
explanation: (oldBlockedState == false ?
|
||||
body: (oldBlockedState == true ? .none : .text(
|
||||
String(
|
||||
format: "BLOCK_LIST_VIEW_BLOCKED_ALERT_MESSAGE_FORMAT".localized(),
|
||||
displayName
|
||||
) :
|
||||
nil
|
||||
)
|
||||
)),
|
||||
accessibility: Accessibility(
|
||||
identifier: "Test_name",
|
||||
label: (oldBlockedState == false ? "User blocked" : "Confirm unblock")
|
||||
),
|
||||
accessibilityLabel: oldBlockedState == false ? "User blocked" : "Confirm unblock",
|
||||
accessibilityId: "Test_name",
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelAccessibilityLabel: "OK_BUTTON",
|
||||
cancelAccessibility: Accessibility(identifier: "OK_BUTTON"),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
|
|
|
@ -71,10 +71,13 @@ final class ConversationTitleView: UIView {
|
|||
|
||||
// MARK: - Content
|
||||
|
||||
public func initialSetup(with threadVariant: SessionThread.Variant) {
|
||||
public func initialSetup(
|
||||
with threadVariant: SessionThread.Variant,
|
||||
isNoteToSelf: Bool
|
||||
) {
|
||||
self.update(
|
||||
with: " ",
|
||||
isNoteToSelf: false,
|
||||
isNoteToSelf: isNoteToSelf,
|
||||
threadVariant: threadVariant,
|
||||
mutedUntilTimestamp: nil,
|
||||
onlyNotifyForMentions: false,
|
||||
|
@ -139,9 +142,9 @@ final class ConversationTitleView: UIView {
|
|||
|
||||
guard Date().timeIntervalSince1970 > (mutedUntilTimestamp ?? 0) else {
|
||||
subtitleLabel?.attributedText = NSAttributedString(
|
||||
string: "\u{e067} ",
|
||||
string: FullConversationCell.mutePrefix,
|
||||
attributes: [
|
||||
.font: UIFont.ows_elegantIconsFont(10),
|
||||
.font: UIFont(name: "ElegantIcons", size: 10) as Any,
|
||||
.foregroundColor: textPrimary
|
||||
]
|
||||
)
|
||||
|
@ -168,12 +171,12 @@ final class ConversationTitleView: UIView {
|
|||
switch threadVariant {
|
||||
case .contact: break
|
||||
|
||||
case .closedGroup:
|
||||
case .legacyGroup, .group:
|
||||
subtitleLabel?.attributedText = NSAttributedString(
|
||||
string: "\(userCount) member\(userCount == 1 ? "" : "s")"
|
||||
)
|
||||
|
||||
case .openGroup:
|
||||
case .community:
|
||||
subtitleLabel?.attributedText = NSAttributedString(
|
||||
string: "\(userCount) active member\(userCount == 1 ? "" : "s")"
|
||||
)
|
||||
|
|
|
@ -5,6 +5,7 @@ import DifferenceKit
|
|||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class ReactionListSheet: BaseVC {
|
||||
public struct ReactionSummary: Hashable, Differentiable {
|
||||
|
@ -368,10 +369,12 @@ final class ReactionListSheet: BaseVC {
|
|||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc private func clearAllTapped() {
|
||||
@objc private func clearAllTapped() { clearAll() }
|
||||
|
||||
private func clearAll(using dependencies: Dependencies = Dependencies()) {
|
||||
guard let selectedReaction: EmojiWithSkinTones = self.reactionSummaries.first(where: { $0.isSelected })?.emoji else { return }
|
||||
|
||||
delegate?.removeAllReactions(messageViewModel, for: selectedReaction.rawValue)
|
||||
delegate?.removeAllReactions(messageViewModel, for: selectedReaction.rawValue, using: dependencies)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -431,7 +434,8 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
|
|||
cell.update(
|
||||
with: SessionCell.Info(
|
||||
id: cellViewModel,
|
||||
leftAccessory: .profile(authorId, cellViewModel.profile),
|
||||
position: Position.with(indexPath.row, count: self.selectedReactionUserList.count),
|
||||
leftAccessory: .profile(id: authorId, profile: cellViewModel.profile),
|
||||
title: (
|
||||
cellViewModel.profile?.displayName() ??
|
||||
Profile.truncated(
|
||||
|
@ -446,10 +450,9 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
|
|||
size: .fit
|
||||
)
|
||||
),
|
||||
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
|
||||
isEnabled: (authorId == self.messageViewModel.currentUserPublicKey)
|
||||
),
|
||||
style: .edgeToEdge,
|
||||
position: Position.with(indexPath.row, count: self.selectedReactionUserList.count)
|
||||
)
|
||||
)
|
||||
|
||||
return cell
|
||||
|
@ -599,7 +602,13 @@ extension ReactionListSheet {
|
|||
// MARK: - Delegate
|
||||
|
||||
protocol ReactionDelegate: AnyObject {
|
||||
func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones)
|
||||
func removeReact(_ cellViewModel: MessageViewModel, for emoji: EmojiWithSkinTones)
|
||||
func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String)
|
||||
func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones, using dependencies: Dependencies)
|
||||
func removeReact(_ cellViewModel: MessageViewModel, for emoji: EmojiWithSkinTones, using dependencies: Dependencies)
|
||||
func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String, using dependencies: Dependencies)
|
||||
}
|
||||
|
||||
extension ReactionDelegate {
|
||||
func removeReact(_ cellViewModel: MessageViewModel, for emoji: EmojiWithSkinTones) {
|
||||
removeReact(cellViewModel, for: emoji, using: Dependencies())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,8 @@
|
|||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
final class ScrollToBottomButton: UIView {
|
||||
private weak var delegate: ScrollToBottomButtonDelegate?
|
||||
final class RoundIconButton: UIView {
|
||||
private let onTap: () -> ()
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
|
@ -13,12 +13,12 @@ final class ScrollToBottomButton: UIView {
|
|||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(delegate: ScrollToBottomButtonDelegate) {
|
||||
self.delegate = delegate
|
||||
init(image: UIImage?, onTap: @escaping () -> ()) {
|
||||
self.onTap = onTap
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
setUpViewHierarchy(image: image)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -29,7 +29,7 @@ final class ScrollToBottomButton: UIView {
|
|||
preconditionFailure("Use init(delegate:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
private func setUpViewHierarchy(image: UIImage?) {
|
||||
// Background & blur
|
||||
let backgroundView = UIView()
|
||||
backgroundView.themeBackgroundColor = .backgroundSecondary
|
||||
|
@ -49,9 +49,9 @@ final class ScrollToBottomButton: UIView {
|
|||
}
|
||||
|
||||
// Size & shape
|
||||
set(.width, to: ScrollToBottomButton.size)
|
||||
set(.height, to: ScrollToBottomButton.size)
|
||||
layer.cornerRadius = (ScrollToBottomButton.size / 2)
|
||||
set(.width, to: RoundIconButton.size)
|
||||
set(.height, to: RoundIconButton.size)
|
||||
layer.cornerRadius = (RoundIconButton.size / 2)
|
||||
layer.masksToBounds = true
|
||||
|
||||
// Border
|
||||
|
@ -59,16 +59,13 @@ final class ScrollToBottomButton: UIView {
|
|||
layer.borderWidth = Values.separatorThickness
|
||||
|
||||
// Icon
|
||||
let iconImageView = UIImageView(
|
||||
image: UIImage(named: "ic_chevron_down")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
let iconImageView = UIImageView(image: image)
|
||||
iconImageView.themeTintColor = .textPrimary
|
||||
iconImageView.contentMode = .scaleAspectFit
|
||||
addSubview(iconImageView)
|
||||
iconImageView.center(in: self)
|
||||
iconImageView.set(.width, to: ScrollToBottomButton.iconSize)
|
||||
iconImageView.set(.height, to: ScrollToBottomButton.iconSize)
|
||||
iconImageView.set(.width, to: RoundIconButton.iconSize)
|
||||
iconImageView.set(.height, to: RoundIconButton.iconSize)
|
||||
|
||||
// Gesture recognizer
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
|
@ -78,12 +75,6 @@ final class ScrollToBottomButton: UIView {
|
|||
// MARK: - Interaction
|
||||
|
||||
@objc private func handleTap() {
|
||||
delegate?.handleScrollToBottomButtonTapped()
|
||||
onTap()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ScrollToBottomButtonDelegate
|
||||
|
||||
protocol ScrollToBottomButtonDelegate: AnyObject {
|
||||
func handleScrollToBottomButtonTapped()
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
import Foundation
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension Emoji {
|
||||
private static let availableCache: Atomic<[Emoji:Bool]> = Atomic([:])
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
|
||||
// This file is generated by EmojiGenerator.swift, do not manually edit it.
|
||||
|
||||
// swiftlint:disable all
|
||||
// stringlint:disable
|
||||
|
||||
extension Emoji {
|
||||
enum Category: String, CaseIterable, Equatable {
|
||||
case smileysAndPeople = "Smileys & People"
|
||||
|
@ -86,6 +89,7 @@ extension Emoji {
|
|||
.grimacing,
|
||||
.faceExhaling,
|
||||
.lyingFace,
|
||||
.shakingFace,
|
||||
.relieved,
|
||||
.pensive,
|
||||
.sleepy,
|
||||
|
@ -163,7 +167,6 @@ extension Emoji {
|
|||
.seeNoEvil,
|
||||
.hearNoEvil,
|
||||
.speakNoEvil,
|
||||
.kiss,
|
||||
.loveLetter,
|
||||
.cupid,
|
||||
.giftHeart,
|
||||
|
@ -178,14 +181,18 @@ extension Emoji {
|
|||
.heartOnFire,
|
||||
.mendingHeart,
|
||||
.heart,
|
||||
.pinkHeart,
|
||||
.orangeHeart,
|
||||
.yellowHeart,
|
||||
.greenHeart,
|
||||
.blueHeart,
|
||||
.lightBlueHeart,
|
||||
.purpleHeart,
|
||||
.brownHeart,
|
||||
.blackHeart,
|
||||
.greyHeart,
|
||||
.whiteHeart,
|
||||
.kiss,
|
||||
.oneHundred,
|
||||
.anger,
|
||||
.boom,
|
||||
|
@ -193,7 +200,6 @@ extension Emoji {
|
|||
.sweatDrops,
|
||||
.dash,
|
||||
.hole,
|
||||
.bomb,
|
||||
.speechBalloon,
|
||||
.eyeInSpeechBubble,
|
||||
.leftSpeechBubble,
|
||||
|
@ -209,6 +215,8 @@ extension Emoji {
|
|||
.leftwardsHand,
|
||||
.palmDownHand,
|
||||
.palmUpHand,
|
||||
.leftwardsPushingHand,
|
||||
.rightwardsPushingHand,
|
||||
.okHand,
|
||||
.pinchedFingers,
|
||||
.pinchingHand,
|
||||
|
@ -584,6 +592,8 @@ extension Emoji {
|
|||
.tiger2,
|
||||
.leopard,
|
||||
.horse,
|
||||
.moose,
|
||||
.donkey,
|
||||
.racehorse,
|
||||
.unicornFace,
|
||||
.zebraFace,
|
||||
|
@ -646,6 +656,9 @@ extension Emoji {
|
|||
.flamingo,
|
||||
.peacock,
|
||||
.parrot,
|
||||
.wing,
|
||||
.blackBird,
|
||||
.goose,
|
||||
.frog,
|
||||
.crocodile,
|
||||
.turtle,
|
||||
|
@ -666,6 +679,7 @@ extension Emoji {
|
|||
.octopus,
|
||||
.shell,
|
||||
.coral,
|
||||
.jellyfish,
|
||||
.snail,
|
||||
.butterfly,
|
||||
.bug,
|
||||
|
@ -693,6 +707,7 @@ extension Emoji {
|
|||
.sunflower,
|
||||
.blossom,
|
||||
.tulip,
|
||||
.hyacinth,
|
||||
.seedling,
|
||||
.pottedPlant,
|
||||
.evergreenTree,
|
||||
|
@ -708,6 +723,7 @@ extension Emoji {
|
|||
.leaves,
|
||||
.emptyNest,
|
||||
.nestWithEggs,
|
||||
.mushroom,
|
||||
]
|
||||
case .food:
|
||||
return [
|
||||
|
@ -742,10 +758,11 @@ extension Emoji {
|
|||
.broccoli,
|
||||
.garlic,
|
||||
.onion,
|
||||
.mushroom,
|
||||
.peanuts,
|
||||
.beans,
|
||||
.chestnut,
|
||||
.gingerRoot,
|
||||
.peaPod,
|
||||
.bread,
|
||||
.croissant,
|
||||
.baguetteBread,
|
||||
|
@ -903,11 +920,10 @@ extension Emoji {
|
|||
.dart,
|
||||
.yoYo,
|
||||
.kite,
|
||||
.gun,
|
||||
.eightBall,
|
||||
.crystalBall,
|
||||
.magicWand,
|
||||
.nazarAmulet,
|
||||
.hamsa,
|
||||
.videoGame,
|
||||
.joystick,
|
||||
.slotMachine,
|
||||
|
@ -1176,6 +1192,7 @@ extension Emoji {
|
|||
.shorts,
|
||||
.bikini,
|
||||
.womansClothes,
|
||||
.foldingHandFan,
|
||||
.purse,
|
||||
.handbag,
|
||||
.pouch,
|
||||
|
@ -1190,6 +1207,7 @@ extension Emoji {
|
|||
.sandal,
|
||||
.balletShoes,
|
||||
.boot,
|
||||
.hairPick,
|
||||
.crown,
|
||||
.womansHat,
|
||||
.tophat,
|
||||
|
@ -1228,6 +1246,8 @@ extension Emoji {
|
|||
.banjo,
|
||||
.drumWithDrumsticks,
|
||||
.longDrum,
|
||||
.maracas,
|
||||
.flute,
|
||||
.iphone,
|
||||
.calling,
|
||||
.phone,
|
||||
|
@ -1347,7 +1367,7 @@ extension Emoji {
|
|||
.hammerAndWrench,
|
||||
.daggerKnife,
|
||||
.crossedSwords,
|
||||
.gun,
|
||||
.bomb,
|
||||
.boomerang,
|
||||
.bowAndArrow,
|
||||
.shield,
|
||||
|
@ -1408,6 +1428,8 @@ extension Emoji {
|
|||
.coffin,
|
||||
.headstone,
|
||||
.funeralUrn,
|
||||
.nazarAmulet,
|
||||
.hamsa,
|
||||
.moyai,
|
||||
.placard,
|
||||
.identificationCard,
|
||||
|
@ -1473,6 +1495,7 @@ extension Emoji {
|
|||
.peaceSymbol,
|
||||
.menorahWithNineBranches,
|
||||
.sixPointedStar,
|
||||
.khanda,
|
||||
.aries,
|
||||
.taurus,
|
||||
.gemini,
|
||||
|
@ -1508,6 +1531,7 @@ extension Emoji {
|
|||
.lowBrightness,
|
||||
.highBrightness,
|
||||
.signalStrength,
|
||||
.wireless,
|
||||
.vibrationMode,
|
||||
.mobilePhoneOff,
|
||||
.femaleSign,
|
||||
|
@ -1962,6 +1986,7 @@ extension Emoji {
|
|||
case .grimacing: return .smileysAndPeople
|
||||
case .faceExhaling: return .smileysAndPeople
|
||||
case .lyingFace: return .smileysAndPeople
|
||||
case .shakingFace: return .smileysAndPeople
|
||||
case .relieved: return .smileysAndPeople
|
||||
case .pensive: return .smileysAndPeople
|
||||
case .sleepy: return .smileysAndPeople
|
||||
|
@ -2039,7 +2064,6 @@ extension Emoji {
|
|||
case .seeNoEvil: return .smileysAndPeople
|
||||
case .hearNoEvil: return .smileysAndPeople
|
||||
case .speakNoEvil: return .smileysAndPeople
|
||||
case .kiss: return .smileysAndPeople
|
||||
case .loveLetter: return .smileysAndPeople
|
||||
case .cupid: return .smileysAndPeople
|
||||
case .giftHeart: return .smileysAndPeople
|
||||
|
@ -2054,14 +2078,18 @@ extension Emoji {
|
|||
case .heartOnFire: return .smileysAndPeople
|
||||
case .mendingHeart: return .smileysAndPeople
|
||||
case .heart: return .smileysAndPeople
|
||||
case .pinkHeart: return .smileysAndPeople
|
||||
case .orangeHeart: return .smileysAndPeople
|
||||
case .yellowHeart: return .smileysAndPeople
|
||||
case .greenHeart: return .smileysAndPeople
|
||||
case .blueHeart: return .smileysAndPeople
|
||||
case .lightBlueHeart: return .smileysAndPeople
|
||||
case .purpleHeart: return .smileysAndPeople
|
||||
case .brownHeart: return .smileysAndPeople
|
||||
case .blackHeart: return .smileysAndPeople
|
||||
case .greyHeart: return .smileysAndPeople
|
||||
case .whiteHeart: return .smileysAndPeople
|
||||
case .kiss: return .smileysAndPeople
|
||||
case .oneHundred: return .smileysAndPeople
|
||||
case .anger: return .smileysAndPeople
|
||||
case .boom: return .smileysAndPeople
|
||||
|
@ -2069,7 +2097,6 @@ extension Emoji {
|
|||
case .sweatDrops: return .smileysAndPeople
|
||||
case .dash: return .smileysAndPeople
|
||||
case .hole: return .smileysAndPeople
|
||||
case .bomb: return .smileysAndPeople
|
||||
case .speechBalloon: return .smileysAndPeople
|
||||
case .eyeInSpeechBubble: return .smileysAndPeople
|
||||
case .leftSpeechBubble: return .smileysAndPeople
|
||||
|
@ -2085,6 +2112,8 @@ extension Emoji {
|
|||
case .leftwardsHand: return .smileysAndPeople
|
||||
case .palmDownHand: return .smileysAndPeople
|
||||
case .palmUpHand: return .smileysAndPeople
|
||||
case .leftwardsPushingHand: return .smileysAndPeople
|
||||
case .rightwardsPushingHand: return .smileysAndPeople
|
||||
case .okHand: return .smileysAndPeople
|
||||
case .pinchedFingers: return .smileysAndPeople
|
||||
case .pinchingHand: return .smileysAndPeople
|
||||
|
@ -2457,6 +2486,8 @@ extension Emoji {
|
|||
case .tiger2: return .animals
|
||||
case .leopard: return .animals
|
||||
case .horse: return .animals
|
||||
case .moose: return .animals
|
||||
case .donkey: return .animals
|
||||
case .racehorse: return .animals
|
||||
case .unicornFace: return .animals
|
||||
case .zebraFace: return .animals
|
||||
|
@ -2519,6 +2550,9 @@ extension Emoji {
|
|||
case .flamingo: return .animals
|
||||
case .peacock: return .animals
|
||||
case .parrot: return .animals
|
||||
case .wing: return .animals
|
||||
case .blackBird: return .animals
|
||||
case .goose: return .animals
|
||||
case .frog: return .animals
|
||||
case .crocodile: return .animals
|
||||
case .turtle: return .animals
|
||||
|
@ -2539,6 +2573,7 @@ extension Emoji {
|
|||
case .octopus: return .animals
|
||||
case .shell: return .animals
|
||||
case .coral: return .animals
|
||||
case .jellyfish: return .animals
|
||||
case .snail: return .animals
|
||||
case .butterfly: return .animals
|
||||
case .bug: return .animals
|
||||
|
@ -2566,6 +2601,7 @@ extension Emoji {
|
|||
case .sunflower: return .animals
|
||||
case .blossom: return .animals
|
||||
case .tulip: return .animals
|
||||
case .hyacinth: return .animals
|
||||
case .seedling: return .animals
|
||||
case .pottedPlant: return .animals
|
||||
case .evergreenTree: return .animals
|
||||
|
@ -2581,6 +2617,7 @@ extension Emoji {
|
|||
case .leaves: return .animals
|
||||
case .emptyNest: return .animals
|
||||
case .nestWithEggs: return .animals
|
||||
case .mushroom: return .animals
|
||||
case .grapes: return .food
|
||||
case .melon: return .food
|
||||
case .watermelon: return .food
|
||||
|
@ -2612,10 +2649,11 @@ extension Emoji {
|
|||
case .broccoli: return .food
|
||||
case .garlic: return .food
|
||||
case .onion: return .food
|
||||
case .mushroom: return .food
|
||||
case .peanuts: return .food
|
||||
case .beans: return .food
|
||||
case .chestnut: return .food
|
||||
case .gingerRoot: return .food
|
||||
case .peaPod: return .food
|
||||
case .bread: return .food
|
||||
case .croissant: return .food
|
||||
case .baguetteBread: return .food
|
||||
|
@ -2988,11 +3026,10 @@ extension Emoji {
|
|||
case .dart: return .activities
|
||||
case .yoYo: return .activities
|
||||
case .kite: return .activities
|
||||
case .gun: return .activities
|
||||
case .eightBall: return .activities
|
||||
case .crystalBall: return .activities
|
||||
case .magicWand: return .activities
|
||||
case .nazarAmulet: return .activities
|
||||
case .hamsa: return .activities
|
||||
case .videoGame: return .activities
|
||||
case .joystick: return .activities
|
||||
case .slotMachine: return .activities
|
||||
|
@ -3037,6 +3074,7 @@ extension Emoji {
|
|||
case .shorts: return .objects
|
||||
case .bikini: return .objects
|
||||
case .womansClothes: return .objects
|
||||
case .foldingHandFan: return .objects
|
||||
case .purse: return .objects
|
||||
case .handbag: return .objects
|
||||
case .pouch: return .objects
|
||||
|
@ -3051,6 +3089,7 @@ extension Emoji {
|
|||
case .sandal: return .objects
|
||||
case .balletShoes: return .objects
|
||||
case .boot: return .objects
|
||||
case .hairPick: return .objects
|
||||
case .crown: return .objects
|
||||
case .womansHat: return .objects
|
||||
case .tophat: return .objects
|
||||
|
@ -3089,6 +3128,8 @@ extension Emoji {
|
|||
case .banjo: return .objects
|
||||
case .drumWithDrumsticks: return .objects
|
||||
case .longDrum: return .objects
|
||||
case .maracas: return .objects
|
||||
case .flute: return .objects
|
||||
case .iphone: return .objects
|
||||
case .calling: return .objects
|
||||
case .phone: return .objects
|
||||
|
@ -3208,7 +3249,7 @@ extension Emoji {
|
|||
case .hammerAndWrench: return .objects
|
||||
case .daggerKnife: return .objects
|
||||
case .crossedSwords: return .objects
|
||||
case .gun: return .objects
|
||||
case .bomb: return .objects
|
||||
case .boomerang: return .objects
|
||||
case .bowAndArrow: return .objects
|
||||
case .shield: return .objects
|
||||
|
@ -3269,6 +3310,8 @@ extension Emoji {
|
|||
case .coffin: return .objects
|
||||
case .headstone: return .objects
|
||||
case .funeralUrn: return .objects
|
||||
case .nazarAmulet: return .objects
|
||||
case .hamsa: return .objects
|
||||
case .moyai: return .objects
|
||||
case .placard: return .objects
|
||||
case .identificationCard: return .objects
|
||||
|
@ -3331,6 +3374,7 @@ extension Emoji {
|
|||
case .peaceSymbol: return .symbols
|
||||
case .menorahWithNineBranches: return .symbols
|
||||
case .sixPointedStar: return .symbols
|
||||
case .khanda: return .symbols
|
||||
case .aries: return .symbols
|
||||
case .taurus: return .symbols
|
||||
case .gemini: return .symbols
|
||||
|
@ -3366,6 +3410,7 @@ extension Emoji {
|
|||
case .lowBrightness: return .symbols
|
||||
case .highBrightness: return .symbols
|
||||
case .signalStrength: return .symbols
|
||||
case .wireless: return .symbols
|
||||
case .vibrationMode: return .symbols
|
||||
case .mobilePhoneOff: return .symbols
|
||||
case .femaleSign: return .symbols
|
||||
|
@ -3774,3 +3819,4 @@ extension Emoji {
|
|||
}
|
||||
}
|
||||
}
|
||||
// swiftlint:disable all
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,9 @@
|
|||
|
||||
// This file is generated by EmojiGenerator.swift, do not manually edit it.
|
||||
|
||||
// swiftlint:disable all
|
||||
// stringlint:disable
|
||||
|
||||
extension Emoji {
|
||||
enum SkinTone: String, CaseIterable, Equatable {
|
||||
case light = "🏻"
|
||||
|
@ -106,6 +109,22 @@ extension Emoji {
|
|||
[.mediumDark]: "🫴🏾",
|
||||
[.dark]: "🫴🏿",
|
||||
]
|
||||
case .leftwardsPushingHand:
|
||||
return [
|
||||
[.light]: "🫷🏻",
|
||||
[.mediumLight]: "🫷🏼",
|
||||
[.medium]: "🫷🏽",
|
||||
[.mediumDark]: "🫷🏾",
|
||||
[.dark]: "🫷🏿",
|
||||
]
|
||||
case .rightwardsPushingHand:
|
||||
return [
|
||||
[.light]: "🫸🏻",
|
||||
[.mediumLight]: "🫸🏼",
|
||||
[.medium]: "🫸🏽",
|
||||
[.mediumDark]: "🫸🏾",
|
||||
[.dark]: "🫸🏿",
|
||||
]
|
||||
case .okHand:
|
||||
return [
|
||||
[.light]: "👌🏻",
|
||||
|
@ -2722,3 +2741,4 @@ extension Emoji {
|
|||
}
|
||||
}
|
||||
}
|
||||
// swiftlint:disable all
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
// This file is generated by EmojiGenerator.swift, do not manually edit it.
|
||||
|
||||
// swiftlint:disable all
|
||||
// stringlint:disable
|
||||
|
||||
/// A sorted representation of all available emoji
|
||||
enum Emoji: String, CaseIterable, Equatable {
|
||||
|
@ -54,6 +55,7 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case grimacing = "😬"
|
||||
case faceExhaling = "😮💨"
|
||||
case lyingFace = "🤥"
|
||||
case shakingFace = "🫨"
|
||||
case relieved = "😌"
|
||||
case pensive = "😔"
|
||||
case sleepy = "😪"
|
||||
|
@ -131,7 +133,6 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case seeNoEvil = "🙈"
|
||||
case hearNoEvil = "🙉"
|
||||
case speakNoEvil = "🙊"
|
||||
case kiss = "💋"
|
||||
case loveLetter = "💌"
|
||||
case cupid = "💘"
|
||||
case giftHeart = "💝"
|
||||
|
@ -146,14 +147,18 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case heartOnFire = "❤️🔥"
|
||||
case mendingHeart = "❤️🩹"
|
||||
case heart = "❤️"
|
||||
case pinkHeart = "🩷"
|
||||
case orangeHeart = "🧡"
|
||||
case yellowHeart = "💛"
|
||||
case greenHeart = "💚"
|
||||
case blueHeart = "💙"
|
||||
case lightBlueHeart = "🩵"
|
||||
case purpleHeart = "💜"
|
||||
case brownHeart = "🤎"
|
||||
case blackHeart = "🖤"
|
||||
case greyHeart = "🩶"
|
||||
case whiteHeart = "🤍"
|
||||
case kiss = "💋"
|
||||
case oneHundred = "💯"
|
||||
case anger = "💢"
|
||||
case boom = "💥"
|
||||
|
@ -161,7 +166,6 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case sweatDrops = "💦"
|
||||
case dash = "💨"
|
||||
case hole = "🕳️"
|
||||
case bomb = "💣"
|
||||
case speechBalloon = "💬"
|
||||
case eyeInSpeechBubble = "👁️🗨️"
|
||||
case leftSpeechBubble = "🗨️"
|
||||
|
@ -177,6 +181,8 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case leftwardsHand = "🫲"
|
||||
case palmDownHand = "🫳"
|
||||
case palmUpHand = "🫴"
|
||||
case leftwardsPushingHand = "🫷"
|
||||
case rightwardsPushingHand = "🫸"
|
||||
case okHand = "👌"
|
||||
case pinchedFingers = "🤌"
|
||||
case pinchingHand = "🤏"
|
||||
|
@ -554,6 +560,8 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case tiger2 = "🐅"
|
||||
case leopard = "🐆"
|
||||
case horse = "🐴"
|
||||
case moose = "🫎"
|
||||
case donkey = "🫏"
|
||||
case racehorse = "🐎"
|
||||
case unicornFace = "🦄"
|
||||
case zebraFace = "🦓"
|
||||
|
@ -616,6 +624,9 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case flamingo = "🦩"
|
||||
case peacock = "🦚"
|
||||
case parrot = "🦜"
|
||||
case wing = "🪽"
|
||||
case blackBird = "🐦⬛"
|
||||
case goose = "🪿"
|
||||
case frog = "🐸"
|
||||
case crocodile = "🐊"
|
||||
case turtle = "🐢"
|
||||
|
@ -636,6 +647,7 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case octopus = "🐙"
|
||||
case shell = "🐚"
|
||||
case coral = "🪸"
|
||||
case jellyfish = "🪼"
|
||||
case snail = "🐌"
|
||||
case butterfly = "🦋"
|
||||
case bug = "🐛"
|
||||
|
@ -663,6 +675,7 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case sunflower = "🌻"
|
||||
case blossom = "🌼"
|
||||
case tulip = "🌷"
|
||||
case hyacinth = "🪻"
|
||||
case seedling = "🌱"
|
||||
case pottedPlant = "🪴"
|
||||
case evergreenTree = "🌲"
|
||||
|
@ -678,6 +691,7 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case leaves = "🍃"
|
||||
case emptyNest = "🪹"
|
||||
case nestWithEggs = "🪺"
|
||||
case mushroom = "🍄"
|
||||
case grapes = "🍇"
|
||||
case melon = "🍈"
|
||||
case watermelon = "🍉"
|
||||
|
@ -709,10 +723,11 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case broccoli = "🥦"
|
||||
case garlic = "🧄"
|
||||
case onion = "🧅"
|
||||
case mushroom = "🍄"
|
||||
case peanuts = "🥜"
|
||||
case beans = "🫘"
|
||||
case chestnut = "🌰"
|
||||
case gingerRoot = "🫚"
|
||||
case peaPod = "🫛"
|
||||
case bread = "🍞"
|
||||
case croissant = "🥐"
|
||||
case baguetteBread = "🥖"
|
||||
|
@ -1085,11 +1100,10 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case dart = "🎯"
|
||||
case yoYo = "🪀"
|
||||
case kite = "🪁"
|
||||
case gun = "🔫"
|
||||
case eightBall = "🎱"
|
||||
case crystalBall = "🔮"
|
||||
case magicWand = "🪄"
|
||||
case nazarAmulet = "🧿"
|
||||
case hamsa = "🪬"
|
||||
case videoGame = "🎮"
|
||||
case joystick = "🕹️"
|
||||
case slotMachine = "🎰"
|
||||
|
@ -1134,6 +1148,7 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case shorts = "🩳"
|
||||
case bikini = "👙"
|
||||
case womansClothes = "👚"
|
||||
case foldingHandFan = "🪭"
|
||||
case purse = "👛"
|
||||
case handbag = "👜"
|
||||
case pouch = "👝"
|
||||
|
@ -1148,6 +1163,7 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case sandal = "👡"
|
||||
case balletShoes = "🩰"
|
||||
case boot = "👢"
|
||||
case hairPick = "🪮"
|
||||
case crown = "👑"
|
||||
case womansHat = "👒"
|
||||
case tophat = "🎩"
|
||||
|
@ -1186,6 +1202,8 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case banjo = "🪕"
|
||||
case drumWithDrumsticks = "🥁"
|
||||
case longDrum = "🪘"
|
||||
case maracas = "🪇"
|
||||
case flute = "🪈"
|
||||
case iphone = "📱"
|
||||
case calling = "📲"
|
||||
case phone = "☎️"
|
||||
|
@ -1305,7 +1323,7 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case hammerAndWrench = "🛠️"
|
||||
case daggerKnife = "🗡️"
|
||||
case crossedSwords = "⚔️"
|
||||
case gun = "🔫"
|
||||
case bomb = "💣"
|
||||
case boomerang = "🪃"
|
||||
case bowAndArrow = "🏹"
|
||||
case shield = "🛡️"
|
||||
|
@ -1366,6 +1384,8 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case coffin = "⚰️"
|
||||
case headstone = "🪦"
|
||||
case funeralUrn = "⚱️"
|
||||
case nazarAmulet = "🧿"
|
||||
case hamsa = "🪬"
|
||||
case moyai = "🗿"
|
||||
case placard = "🪧"
|
||||
case identificationCard = "🪪"
|
||||
|
@ -1428,6 +1448,7 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case peaceSymbol = "☮️"
|
||||
case menorahWithNineBranches = "🕎"
|
||||
case sixPointedStar = "🔯"
|
||||
case khanda = "🪯"
|
||||
case aries = "♈"
|
||||
case taurus = "♉"
|
||||
case gemini = "♊"
|
||||
|
@ -1463,6 +1484,7 @@ enum Emoji: String, CaseIterable, Equatable {
|
|||
case lowBrightness = "🔅"
|
||||
case highBrightness = "🔆"
|
||||
case signalStrength = "📶"
|
||||
case wireless = "🛜"
|
||||
case vibrationMode = "📳"
|
||||
case mobilePhoneOff = "📴"
|
||||
case femaleSign = "♀️"
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -64,7 +64,7 @@ extension Emoji {
|
|||
guard withDefaultEmoji else { return recentReactionEmoji }
|
||||
|
||||
// Add in our default emoji if desired
|
||||
let defaultEmoji = ["😂", "🥰", "😢", "😡", "😮", "😈"]
|
||||
let defaultEmoji = ["😂", "🥰", "😢", "😡", "😮", "😈"] // stringlint:disable
|
||||
.filter { !recentReactionEmoji.contains($0) }
|
||||
|
||||
return Array(recentReactionEmoji
|
||||
|
|
|
@ -5,6 +5,7 @@ import PureLayout
|
|||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import NVActivityIndicatorView
|
||||
import SignalCoreKit
|
||||
|
||||
class EmptySearchResultCell: UITableViewCell {
|
||||
private lazy var messageLabel: UILabel = {
|
||||
|
|
|
@ -7,8 +7,9 @@ import SessionUIKit
|
|||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
import SignalCoreKit
|
||||
|
||||
class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
||||
class GlobalSearchViewController: BaseVC, SessionUtilRespondingViewController, UITableViewDelegate, UITableViewDataSource {
|
||||
fileprivate typealias SectionModel = ArraySection<SearchSection, SessionThreadViewModel>
|
||||
|
||||
// MARK: - SearchSection
|
||||
|
@ -19,6 +20,15 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
case messages
|
||||
}
|
||||
|
||||
// MARK: - SessionUtilRespondingViewController
|
||||
|
||||
let isConversationList: Bool = true
|
||||
|
||||
func forceRefreshIfNeeded() {
|
||||
// Need to do this as the 'GlobalSearchViewController' doesn't observe database changes
|
||||
updateSearchResults(searchText: searchText, force: true)
|
||||
}
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
private lazy var defaultSearchResults: [SectionModel] = {
|
||||
|
@ -91,14 +101,17 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
setupNavigationBar()
|
||||
}
|
||||
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
searchBar.becomeFirstResponder()
|
||||
}
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
searchBar.resignFirstResponder()
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
searchBar.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
|
@ -138,10 +151,6 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
}
|
||||
}
|
||||
|
||||
private func reloadTableData() {
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
// MARK: - Update Search Results
|
||||
|
||||
private func refreshSearchResults() {
|
||||
|
@ -151,16 +160,18 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
}
|
||||
}
|
||||
|
||||
private func updateSearchResults(searchText rawSearchText: String) {
|
||||
private func updateSearchResults(searchText rawSearchText: String, force: Bool = false) {
|
||||
let searchText = rawSearchText.stripped
|
||||
|
||||
guard searchText.count > 0 else {
|
||||
guard searchText != (lastSearchText ?? "") else { return }
|
||||
|
||||
searchResultSet = defaultSearchResults
|
||||
lastSearchText = nil
|
||||
reloadTableData()
|
||||
tableView.reloadData()
|
||||
return
|
||||
}
|
||||
guard lastSearchText != searchText else { return }
|
||||
guard force || lastSearchText != searchText else { return }
|
||||
|
||||
lastSearchText = searchText
|
||||
|
||||
|
@ -192,6 +203,11 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
])
|
||||
}
|
||||
catch {
|
||||
// Don't log the 'interrupt' error as that's just the user typing too fast
|
||||
if (error as? DatabaseError)?.resultCode != DatabaseError.SQLITE_INTERRUPT {
|
||||
SNLog("[GlobalSearch] Failed to find results due to error: \(error)")
|
||||
}
|
||||
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
|
@ -206,13 +222,20 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
|
||||
self?.termForCurrentSearchResultSet = searchText
|
||||
self?.searchResultSet = [
|
||||
(hasResults ? nil : [ArraySection(model: .noResults, elements: [SessionThreadViewModel(unreadCount: 0)])]),
|
||||
(hasResults ? nil : [
|
||||
ArraySection(
|
||||
model: .noResults,
|
||||
elements: [
|
||||
SessionThreadViewModel(threadId: SessionThreadViewModel.invalidId)
|
||||
]
|
||||
)
|
||||
]),
|
||||
(hasResults ? sections : nil)
|
||||
]
|
||||
.compactMap { $0 }
|
||||
.flatMap { $0 }
|
||||
self?.isLoading = false
|
||||
self?.reloadTableData()
|
||||
self?.tableView.reloadData()
|
||||
self?.refreshTimer = nil
|
||||
|
||||
default: break
|
||||
|
@ -270,31 +293,48 @@ extension GlobalSearchViewController {
|
|||
show(
|
||||
threadId: section.elements[indexPath.row].threadId,
|
||||
threadVariant: section.elements[indexPath.row].threadVariant,
|
||||
focusedInteractionId: section.elements[indexPath.row].interactionId
|
||||
focusedInteractionInfo: {
|
||||
guard
|
||||
let interactionId: Int64 = section.elements[indexPath.row].interactionId,
|
||||
let timestampMs: Int64 = section.elements[indexPath.row].interactionTimestampMs
|
||||
else { return nil }
|
||||
|
||||
return Interaction.TimestampInfo(
|
||||
id: interactionId,
|
||||
timestampMs: timestampMs
|
||||
)
|
||||
}()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func show(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64? = nil, animated: Bool = true) {
|
||||
private func show(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo? = nil, animated: Bool = true) {
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId, animated: animated)
|
||||
self?.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionInfo: focusedInteractionInfo, animated: animated)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let presentedVC = self.presentedViewController {
|
||||
presentedVC.dismiss(animated: false, completion: nil)
|
||||
// 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 viewControllers: [UIViewController] = (self.navigationController?
|
||||
.viewControllers)
|
||||
.defaulting(to: [])
|
||||
.appending(
|
||||
ConversationVC(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId)
|
||||
)
|
||||
|
||||
self.navigationController?.setViewControllers(viewControllers, animated: true)
|
||||
let viewController: ConversationVC = ConversationVC(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
focusedInteractionInfo: focusedInteractionInfo
|
||||
)
|
||||
self.navigationController?.pushViewController(viewController, animated: true)
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
|
|
@ -8,18 +8,24 @@ import SessionMessagingKit
|
|||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedReminderViewDelegate {
|
||||
final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewDataSource, UITableViewDelegate, SeedReminderViewDelegate {
|
||||
private static let loadingHeaderHeight: CGFloat = 40
|
||||
public static let newConversationButtonSize: CGFloat = 60
|
||||
|
||||
private let viewModel: HomeViewModel = HomeViewModel()
|
||||
private var dataChangeObservable: DatabaseCancellable?
|
||||
private var dataChangeObservable: DatabaseCancellable? {
|
||||
didSet { oldValue?.cancel() } // Cancel the old observable if there was one
|
||||
}
|
||||
private var hasLoadedInitialStateData: Bool = false
|
||||
private var hasLoadedInitialThreadData: Bool = false
|
||||
private var isLoadingMore: Bool = false
|
||||
private var isAutoLoadingNextPage: Bool = false
|
||||
private var viewHasAppeared: Bool = false
|
||||
|
||||
// MARK: - SessionUtilRespondingViewController
|
||||
|
||||
let isConversationList: Bool = true
|
||||
|
||||
// MARK: - Intialization
|
||||
|
||||
init() {
|
||||
|
@ -222,7 +228,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
// Preparation
|
||||
SessionApp.homeViewController.mutate { $0 = self }
|
||||
|
||||
updateNavBarButtons()
|
||||
updateNavBarButtons(userProfile: self.viewModel.state.userProfile)
|
||||
setUpNavBarSessionHeading()
|
||||
|
||||
// Recovery phrase reminder
|
||||
|
@ -277,11 +283,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
// Start polling if needed (i.e. if the user just created or restored their Session ID)
|
||||
if Identity.userExists(), let appDelegate: AppDelegate = UIApplication.shared.delegate as? AppDelegate {
|
||||
appDelegate.startPollersIfNeeded()
|
||||
|
||||
// Do this only if we created a new Session ID, or if we already received the initial configuration message
|
||||
if UserDefaults.standard[.hasSyncedInitialConfiguration] {
|
||||
appDelegate.syncConfigurationIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
// Onion request path countries cache
|
||||
|
@ -308,7 +309,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
}
|
||||
|
||||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||
startObservingChanges(didReturnFromBackground: true)
|
||||
/// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.startObservingChanges(didReturnFromBackground: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||
|
@ -317,26 +321,31 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
|
||||
// MARK: - Updating
|
||||
|
||||
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
||||
// Start observing for data changes
|
||||
public func startObservingChanges(didReturnFromBackground: Bool = false, onReceivedInitialChange: (() -> ())? = nil) {
|
||||
guard dataChangeObservable == nil else { return }
|
||||
|
||||
var runAndClearInitialChangeCallback: (() -> ())? = nil
|
||||
|
||||
runAndClearInitialChangeCallback = { [weak self] in
|
||||
guard self?.hasLoadedInitialStateData == true && self?.hasLoadedInitialThreadData == true else { return }
|
||||
|
||||
onReceivedInitialChange?()
|
||||
runAndClearInitialChangeCallback = nil
|
||||
}
|
||||
|
||||
dataChangeObservable = Storage.shared.start(
|
||||
viewModel.observableState,
|
||||
// If we haven't done the initial load the trigger it immediately (blocking the main
|
||||
// thread so we remain on the launch screen until it completes to be consistent with
|
||||
// the old behaviour)
|
||||
scheduling: (hasLoadedInitialStateData ?
|
||||
.async(onQueue: .main) :
|
||||
.immediate
|
||||
),
|
||||
onError: { _ in },
|
||||
onChange: { [weak self] state in
|
||||
// The default scheduler emits changes on the main thread
|
||||
self?.handleUpdates(state)
|
||||
runAndClearInitialChangeCallback?()
|
||||
}
|
||||
)
|
||||
|
||||
self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in
|
||||
self?.handleThreadUpdates(updatedThreadData, changeset: changeset)
|
||||
runAndClearInitialChangeCallback?()
|
||||
}
|
||||
|
||||
// Note: When returning from the background we could have received notifications but the
|
||||
|
@ -351,7 +360,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
|
||||
private func stopObservingChanges() {
|
||||
// Stop observing database changes
|
||||
dataChangeObservable?.cancel()
|
||||
self.dataChangeObservable = nil
|
||||
self.viewModel.onThreadChange = nil
|
||||
}
|
||||
|
||||
|
@ -365,7 +374,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
}
|
||||
|
||||
if updatedState.userProfile != self.viewModel.state.userProfile {
|
||||
updateNavBarButtons()
|
||||
updateNavBarButtons(userProfile: updatedState.userProfile)
|
||||
}
|
||||
|
||||
// Update the 'view seed' UI
|
||||
|
@ -392,9 +401,19 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||
// in from a frame of CGRect.zero)
|
||||
guard hasLoadedInitialThreadData else {
|
||||
hasLoadedInitialThreadData = true
|
||||
UIView.performWithoutAnimation {
|
||||
handleThreadUpdates(updatedData, changeset: changeset, initialLoad: true)
|
||||
UIView.performWithoutAnimation { [weak self] in
|
||||
// Hide the 'loading conversations' label (now that we have received conversation data)
|
||||
self?.loadingConversationsLabel.isHidden = true
|
||||
|
||||
// Show the empty state if there is no data
|
||||
self?.emptyStateView.isHidden = (
|
||||
!updatedData.isEmpty &&
|
||||
updatedData.contains(where: { !$0.elements.isEmpty })
|
||||
)
|
||||
|
||||
self?.viewModel.updateThreadData(updatedData)
|
||||
self?.tableView.reloadData()
|
||||
self?.hasLoadedInitialThreadData = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
@ -433,7 +452,11 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
}
|
||||
|
||||
private func autoLoadNextPageIfNeeded() {
|
||||
guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return }
|
||||
guard
|
||||
self.hasLoadedInitialThreadData &&
|
||||
!self.isAutoLoadingNextPage &&
|
||||
!self.isLoadingMore
|
||||
else { return }
|
||||
|
||||
self.isAutoLoadingNextPage = true
|
||||
|
||||
|
@ -462,21 +485,19 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
}
|
||||
}
|
||||
|
||||
private func updateNavBarButtons() {
|
||||
private func updateNavBarButtons(userProfile: Profile) {
|
||||
// Profile picture view
|
||||
let profilePictureSize = Values.verySmallProfilePictureSize
|
||||
let profilePictureView = ProfilePictureView()
|
||||
let profilePictureView = ProfilePictureView(size: .navigation)
|
||||
profilePictureView.accessibilityIdentifier = "User settings"
|
||||
profilePictureView.accessibilityLabel = "User settings"
|
||||
profilePictureView.isAccessibilityElement = true
|
||||
profilePictureView.size = profilePictureSize
|
||||
profilePictureView.update(
|
||||
publicKey: getUserHexEncodedPublicKey(),
|
||||
profile: Profile.fetchOrCreateCurrentUser(),
|
||||
threadVariant: .contact
|
||||
publicKey: userProfile.id,
|
||||
threadVariant: .contact,
|
||||
customImageData: nil,
|
||||
profile: userProfile,
|
||||
additionalProfile: nil
|
||||
)
|
||||
profilePictureView.set(.width, to: profilePictureSize)
|
||||
profilePictureView.set(.height, to: profilePictureSize)
|
||||
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings))
|
||||
profilePictureView.addGestureRecognizer(tapGestureRecognizer)
|
||||
|
@ -596,7 +617,9 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
|
||||
switch section.model {
|
||||
case .messageRequests:
|
||||
let viewController: MessageRequestsViewController = MessageRequestsViewController()
|
||||
let viewController: SessionTableViewController = SessionTableViewController(
|
||||
viewModel: MessageRequestsViewModel()
|
||||
)
|
||||
self.navigationController?.pushViewController(viewController, animated: true)
|
||||
|
||||
case .threads:
|
||||
|
@ -606,7 +629,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
variant: threadViewModel.threadVariant,
|
||||
isMessageRequest: (threadViewModel.threadIsMessageRequest == true),
|
||||
with: .none,
|
||||
focusedInteractionId: nil,
|
||||
focusedInteractionInfo: nil,
|
||||
animated: true
|
||||
)
|
||||
|
||||
|
@ -618,255 +641,127 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
return true
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
return nil
|
||||
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
|
||||
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
|
||||
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
||||
let unswipeAnimationDelay: DispatchTimeInterval = .milliseconds(500)
|
||||
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||
|
||||
switch section.model {
|
||||
case .messageRequests:
|
||||
let hide: UIContextualAction = UIContextualAction(style: .destructive, title: "TXT_HIDE_TITLE".localized()) { _, _, completionHandler in
|
||||
Storage.shared.write { db in db[.hasHiddenMessageRequests] = true }
|
||||
completionHandler(true)
|
||||
}
|
||||
hide.themeBackgroundColor = .conversationButton_swipeDestructive
|
||||
|
||||
return UISwipeActionsConfiguration(actions: [hide])
|
||||
|
||||
case .threads:
|
||||
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||
guard threadViewModel.interactionVariant != .infoClosedGroupCurrentUserLeaving else { return nil }
|
||||
|
||||
let pin: UIContextualAction = UIContextualAction(
|
||||
title: (threadViewModel.threadIsPinned ? "UNPIN_BUTTON_TEXT".localized() : "PIN_BUTTON_TEXT".localized()),
|
||||
icon: UIImage(systemName: "pin"),
|
||||
iconHeight: Values.mediumFontSize,
|
||||
themeTintColor: .white,
|
||||
themeBackgroundColor: .conversationButton_swipeDestructive,
|
||||
side: .trailing,
|
||||
actionIndex: 0,
|
||||
indexPath: indexPath,
|
||||
tableView: tableView
|
||||
) { _, _, completionHandler in
|
||||
(tableView.cellForRow(at: indexPath) as? FullConversationCell)?.optimisticUpdate(
|
||||
isPinned: !threadViewModel.threadIsPinned
|
||||
// Cannot properly sync outgoing blinded message requests so don't provide the option
|
||||
guard
|
||||
threadViewModel.threadVariant != .contact ||
|
||||
SessionId(from: section.elements[indexPath.row].threadId)?.prefix == .standard
|
||||
else { return nil }
|
||||
|
||||
return UIContextualAction.configuration(
|
||||
for: UIContextualAction.generateSwipeActions(
|
||||
[.toggleReadStatus],
|
||||
for: .leading,
|
||||
indexPath: indexPath,
|
||||
tableView: tableView,
|
||||
threadViewModel: threadViewModel,
|
||||
viewController: self
|
||||
)
|
||||
completionHandler(true)
|
||||
|
||||
// Delay the change to give the cell "unswipe" animation some time to complete
|
||||
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) {
|
||||
Storage.shared.writeAsync { db in
|
||||
try SessionThread
|
||||
.filter(id: threadViewModel.threadId)
|
||||
.updateAll(db, SessionThread.Columns.isPinned.set(to: !threadViewModel.threadIsPinned))
|
||||
}
|
||||
}
|
||||
}
|
||||
pin.themeBackgroundColor = .conversationButton_swipeTertiary
|
||||
|
||||
let mute: UIContextualAction = UIContextualAction(
|
||||
title: ((threadViewModel.threadMutedUntilTimestamp != nil) ? "unmute_button_text".localized() : "mute_button_text".localized()),
|
||||
icon: UIImage(systemName: "speaker.slash"),
|
||||
iconHeight: Values.mediumFontSize,
|
||||
themeTintColor: .white,
|
||||
themeBackgroundColor: .conversationButton_swipeDestructive,
|
||||
side: .trailing,
|
||||
actionIndex: 1,
|
||||
indexPath: indexPath,
|
||||
tableView: tableView
|
||||
) { _, _, completionHandler in
|
||||
(tableView.cellForRow(at: indexPath) as? FullConversationCell)?.optimisticUpdate(
|
||||
isMuted: !(threadViewModel.threadMutedUntilTimestamp != nil)
|
||||
)
|
||||
completionHandler(true)
|
||||
|
||||
// Delay the change to give the cell "unswipe" animation some time to complete
|
||||
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) {
|
||||
Storage.shared.writeAsync { db in
|
||||
let currentValue: TimeInterval? = try SessionThread
|
||||
.filter(id: threadViewModel.threadId)
|
||||
.select(.mutedUntilTimestamp)
|
||||
.asRequest(of: TimeInterval.self)
|
||||
.fetchOne(db)
|
||||
|
||||
try SessionThread
|
||||
.filter(id: threadViewModel.threadId)
|
||||
.updateAll(
|
||||
db,
|
||||
SessionThread.Columns.mutedUntilTimestamp.set(
|
||||
to: (currentValue == nil ?
|
||||
Date.distantFuture.timeIntervalSince1970 :
|
||||
nil
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
mute.themeBackgroundColor = .conversationButton_swipeSecondary
|
||||
|
||||
switch (threadViewModel.threadVariant, threadViewModel.currentUserIsClosedGroupMember) {
|
||||
case (.contact, _):
|
||||
let delete: UIContextualAction = UIContextualAction(
|
||||
title: "TXT_DELETE_TITLE".localized(),
|
||||
icon: UIImage(named: "icon_bin")?.resizedImage(to: CGSize(width: Values.mediumFontSize, height: Values.mediumFontSize)),
|
||||
iconHeight: Values.mediumFontSize,
|
||||
themeTintColor: .white,
|
||||
themeBackgroundColor: .conversationButton_swipeDestructive,
|
||||
side: .trailing,
|
||||
actionIndex: 2,
|
||||
indexPath: indexPath,
|
||||
tableView: tableView
|
||||
) { [weak self] _, _, completionHandler in
|
||||
let confirmationModalExplanation: NSAttributedString = {
|
||||
let mutableAttributedString = NSMutableAttributedString(
|
||||
string: String(
|
||||
format: "delete_conversation_confirmation_alert_message".localized(),
|
||||
threadViewModel.displayName
|
||||
)
|
||||
)
|
||||
mutableAttributedString.addAttribute(
|
||||
.font,
|
||||
value: UIFont.boldSystemFont(ofSize: Values.smallFontSize),
|
||||
range: (mutableAttributedString.string as NSString).range(of: threadViewModel.displayName)
|
||||
)
|
||||
return mutableAttributedString
|
||||
}()
|
||||
|
||||
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "delete_conversation_confirmation_alert_title".localized(),
|
||||
attributedExplanation: 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,
|
||||
attributedExplanation: confirmationModalExplanation,
|
||||
confirmTitle: "LEAVE_BUTTON_TITLE".localized(),
|
||||
confirmStyle: .danger,
|
||||
cancelStyle: .alert_text,
|
||||
dismissOnConfirm: true,
|
||||
onConfirm: { [weak self] _ in
|
||||
self?.viewModel.delete(
|
||||
threadId: threadViewModel.threadId,
|
||||
threadVariant: threadViewModel.threadVariant
|
||||
)
|
||||
self?.dismiss(animated: true, completion: nil)
|
||||
|
||||
completionHandler(true)
|
||||
},
|
||||
afterClosed: { completionHandler(false) }
|
||||
)
|
||||
)
|
||||
|
||||
self?.present(confirmationModal, animated: true, completion: nil)
|
||||
}
|
||||
leave.themeBackgroundColor = .conversationButton_swipeDestructive
|
||||
|
||||
return UISwipeActionsConfiguration(actions: [ leave, mute, pin ])
|
||||
}
|
||||
)
|
||||
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
|
||||
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
|
||||
}
|
||||
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
||||
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||
|
||||
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
|
||||
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
|
||||
switch section.model {
|
||||
case .messageRequests:
|
||||
return UIContextualAction.configuration(
|
||||
for: UIContextualAction.generateSwipeActions(
|
||||
[.hide],
|
||||
for: .trailing,
|
||||
indexPath: indexPath,
|
||||
tableView: tableView,
|
||||
threadViewModel: threadViewModel,
|
||||
viewController: self
|
||||
)
|
||||
)
|
||||
|
||||
case .threads:
|
||||
let sessionIdPrefix: SessionId.Prefix? = SessionId(from: threadViewModel.threadId)?.prefix
|
||||
|
||||
// Cannot properly sync outgoing blinded message requests so only provide valid options
|
||||
let shouldHavePinAction: Bool = (
|
||||
sessionIdPrefix != .blinded15 &&
|
||||
sessionIdPrefix != .blinded25
|
||||
)
|
||||
let shouldHaveMuteAction: Bool = {
|
||||
switch threadViewModel.threadVariant {
|
||||
case .contact: return (
|
||||
!threadViewModel.threadIsNoteToSelf &&
|
||||
sessionIdPrefix != .blinded15 &&
|
||||
sessionIdPrefix != .blinded25
|
||||
)
|
||||
|
||||
case .legacyGroup, .group: return (
|
||||
threadViewModel.currentUserIsClosedGroupMember == true
|
||||
)
|
||||
|
||||
case .community: return true
|
||||
}
|
||||
}()
|
||||
let destructiveAction: UIContextualAction.SwipeAction = {
|
||||
switch (threadViewModel.threadVariant, threadViewModel.threadIsNoteToSelf, threadViewModel.currentUserIsClosedGroupMember) {
|
||||
case (.contact, true, _): return .hide
|
||||
case (.legacyGroup, _, true), (.group, _, true), (.community, _, _): return .leave
|
||||
default: return .delete
|
||||
}
|
||||
}()
|
||||
|
||||
return UIContextualAction.configuration(
|
||||
for: UIContextualAction.generateSwipeActions(
|
||||
[
|
||||
(!shouldHavePinAction ? nil : .pin),
|
||||
(!shouldHaveMuteAction ? nil : .mute),
|
||||
destructiveAction
|
||||
].compactMap { $0 },
|
||||
for: .trailing,
|
||||
indexPath: indexPath,
|
||||
tableView: tableView,
|
||||
threadViewModel: threadViewModel,
|
||||
viewController: self
|
||||
)
|
||||
)
|
||||
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
func handleContinueButtonTapped(from seedReminderView: SeedReminderView) {
|
||||
let seedVC = SeedVC()
|
||||
let navigationController = StyledNavigationController(rootViewController: seedVC)
|
||||
present(navigationController, animated: true, completion: nil)
|
||||
let targetViewController: UIViewController = {
|
||||
if let seedVC: SeedVC = try? SeedVC() {
|
||||
return StyledNavigationController(rootViewController: seedVC)
|
||||
}
|
||||
|
||||
return ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "ALERT_ERROR_TITLE".localized(),
|
||||
body: .text("LOAD_RECOVERY_PASSWORD_ERROR".localized()),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
}()
|
||||
|
||||
present(targetViewController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func show(
|
||||
|
@ -874,7 +769,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
variant: SessionThread.Variant,
|
||||
isMessageRequest: Bool,
|
||||
with action: ConversationViewModel.Action,
|
||||
focusedInteractionId: Int64?,
|
||||
focusedInteractionInfo: Interaction.TimestampInfo?,
|
||||
animated: Bool
|
||||
) {
|
||||
if let presentedVC = self.presentedViewController {
|
||||
|
@ -883,11 +778,11 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
|||
|
||||
let finalViewControllers: [UIViewController] = [
|
||||
self,
|
||||
(isMessageRequest ? MessageRequestsViewController() : nil),
|
||||
(isMessageRequest ? SessionTableViewController(viewModel: MessageRequestsViewModel()) : nil),
|
||||
ConversationVC(
|
||||
threadId: threadId,
|
||||
threadVariant: variant,
|
||||
focusedInteractionId: focusedInteractionId
|
||||
focusedInteractionInfo: focusedInteractionInfo
|
||||
)
|
||||
].compactMap { $0 }
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import Foundation
|
|||
import GRDB
|
||||
import DifferenceKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public class HomeViewModel {
|
||||
|
@ -19,38 +20,45 @@ public class HomeViewModel {
|
|||
|
||||
// MARK: - Variables
|
||||
|
||||
public static let pageSize: Int = 15
|
||||
public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15)
|
||||
|
||||
public struct State: Equatable {
|
||||
let showViewedSeedBanner: Bool
|
||||
let hasHiddenMessageRequests: Bool
|
||||
let unreadMessageRequestThreadCount: Int
|
||||
let userProfile: Profile?
|
||||
|
||||
init(
|
||||
showViewedSeedBanner: Bool = !Storage.shared[.hasViewedSeed],
|
||||
hasHiddenMessageRequests: Bool = Storage.shared[.hasHiddenMessageRequests],
|
||||
unreadMessageRequestThreadCount: Int = 0,
|
||||
userProfile: Profile? = nil
|
||||
) {
|
||||
self.showViewedSeedBanner = showViewedSeedBanner
|
||||
self.hasHiddenMessageRequests = hasHiddenMessageRequests
|
||||
self.unreadMessageRequestThreadCount = unreadMessageRequestThreadCount
|
||||
self.userProfile = userProfile
|
||||
}
|
||||
let userProfile: Profile
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
self.state = State()
|
||||
typealias InitialData = (
|
||||
showViewedSeedBanner: Bool,
|
||||
hasHiddenMessageRequests: Bool,
|
||||
profile: Profile
|
||||
)
|
||||
|
||||
let initialData: InitialData? = Storage.shared.read { db -> InitialData in
|
||||
(
|
||||
!db[.hasViewedSeed],
|
||||
db[.hasHiddenMessageRequests],
|
||||
Profile.fetchOrCreateCurrentUser(db)
|
||||
)
|
||||
}
|
||||
|
||||
self.state = State(
|
||||
showViewedSeedBanner: (initialData?.showViewedSeedBanner ?? true),
|
||||
hasHiddenMessageRequests: (initialData?.hasHiddenMessageRequests ?? false),
|
||||
unreadMessageRequestThreadCount: 0,
|
||||
userProfile: (initialData?.profile ?? Profile.fetchOrCreateCurrentUser())
|
||||
)
|
||||
self.pagedDataObserver = nil
|
||||
|
||||
// Note: Since this references self we need to finish initializing before setting it, we
|
||||
// also want to skip the initial query and trigger it async so that the push animation
|
||||
// doesn't stutter (it should load basically immediately but without this there is a
|
||||
// distinct stutter)
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey()
|
||||
let userPublicKey: String = self.state.userProfile.id
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
self.pagedDataObserver = PagedDatabaseObserver(
|
||||
pagedTable: SessionThread.self,
|
||||
|
@ -62,9 +70,10 @@ public class HomeViewModel {
|
|||
columns: [
|
||||
.id,
|
||||
.shouldBeVisible,
|
||||
.isPinned,
|
||||
.pinnedPriority,
|
||||
.mutedUntilTimestamp,
|
||||
.onlyNotifyForMentions
|
||||
.onlyNotifyForMentions,
|
||||
.markedAsUnread
|
||||
]
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
|
@ -76,7 +85,7 @@ public class HomeViewModel {
|
|||
joinToPagedType: {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
|
||||
return SQL("JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
|
@ -85,7 +94,7 @@ public class HomeViewModel {
|
|||
joinToPagedType: {
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
|
||||
return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
|
@ -93,8 +102,53 @@ public class HomeViewModel {
|
|||
columns: [.name, .nickname, .profilePictureFileName],
|
||||
joinToPagedType: {
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
|
||||
let threadVariants: [SessionThread.Variant] = [.legacyGroup, .group]
|
||||
let targetRole: GroupMember.Role = GroupMember.Role.standard
|
||||
|
||||
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
|
||||
return SQL("""
|
||||
JOIN \(Profile.self) ON (
|
||||
( -- Contact profile change
|
||||
\(profile[.id]) = \(thread[.id]) AND
|
||||
\(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)"))
|
||||
) OR ( -- Closed group profile change
|
||||
\(SQL("\(thread[.variant]) IN \(threadVariants)")) AND (
|
||||
profile.id = ( -- Front profile
|
||||
SELECT MIN(\(groupMember[.profileId]))
|
||||
FROM \(GroupMember.self)
|
||||
JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId])
|
||||
WHERE (
|
||||
\(groupMember[.groupId]) = \(thread[.id]) AND
|
||||
\(SQL("\(groupMember[.role]) = \(targetRole)")) AND
|
||||
\(groupMember[.profileId]) != \(userPublicKey)
|
||||
)
|
||||
) OR
|
||||
profile.id = ( -- Back profile
|
||||
SELECT MAX(\(groupMember[.profileId]))
|
||||
FROM \(GroupMember.self)
|
||||
JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId])
|
||||
WHERE (
|
||||
\(groupMember[.groupId]) = \(thread[.id]) AND
|
||||
\(SQL("\(groupMember[.role]) = \(targetRole)")) AND
|
||||
\(groupMember[.profileId]) != \(userPublicKey)
|
||||
)
|
||||
) OR ( -- Fallback profile
|
||||
profile.id = \(userPublicKey) AND
|
||||
(
|
||||
SELECT COUNT(\(groupMember[.profileId]))
|
||||
FROM \(GroupMember.self)
|
||||
JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId])
|
||||
WHERE (
|
||||
\(groupMember[.groupId]) = \(thread[.id]) AND
|
||||
\(SQL("\(groupMember[.role]) = \(targetRole)")) AND
|
||||
\(groupMember[.profileId]) != \(userPublicKey)
|
||||
)
|
||||
) = 1
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
""")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
|
@ -103,7 +157,7 @@ public class HomeViewModel {
|
|||
joinToPagedType: {
|
||||
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])")
|
||||
return SQL("JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
|
@ -112,7 +166,7 @@ public class HomeViewModel {
|
|||
joinToPagedType: {
|
||||
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])")
|
||||
return SQL("JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
|
@ -123,8 +177,8 @@ public class HomeViewModel {
|
|||
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
|
||||
|
||||
return """
|
||||
LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
|
||||
LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
|
||||
JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
|
||||
JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
|
||||
"""
|
||||
}()
|
||||
),
|
||||
|
@ -134,7 +188,7 @@ public class HomeViewModel {
|
|||
joinToPagedType: {
|
||||
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])")
|
||||
return SQL("JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])")
|
||||
}()
|
||||
)
|
||||
],
|
||||
|
@ -155,15 +209,22 @@ public class HomeViewModel {
|
|||
currentDataRetriever: { self?.threadData },
|
||||
onDataChange: self?.onThreadChange,
|
||||
onUnobservedDataChange: { updatedData, changeset in
|
||||
self?.unobservedThreadDataChanges = (updatedData, changeset)
|
||||
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
|
||||
nil :
|
||||
(updatedData, changeset)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
self?.hasReceivedInitialThreadData = true
|
||||
}
|
||||
)
|
||||
|
||||
// Run the initial query on the main thread so we prevent the app from leaving the loading screen
|
||||
// until we have data (Note: the `.pageBefore` will query from a `0` offset loading the first page)
|
||||
self.pagedDataObserver?.load(.pageBefore)
|
||||
// Run the initial query on a background thread so we don't block the main thread
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
// The `.pageBefore` will query from a `0` offset loading the first page
|
||||
self?.pagedDataObserver?.load(.pageBefore)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
@ -181,6 +242,7 @@ public class HomeViewModel {
|
|||
public lazy var observableState = ValueObservation
|
||||
.trackingConstantRegion { db -> State in try HomeViewModel.retrieveState(db) }
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[HomeViewModel] Observation failed with error: \($0)") })
|
||||
|
||||
private static func retrieveState(_ db: Database) throws -> State {
|
||||
let hasViewedSeed: Bool = db[.hasViewedSeed]
|
||||
|
@ -203,8 +265,10 @@ public class HomeViewModel {
|
|||
let oldState: State = self.state
|
||||
self.state = updatedState
|
||||
|
||||
// If the messageRequest content changed then we need to re-process the thread data
|
||||
// If the messageRequest content changed then we need to re-process the thread data (assuming
|
||||
// we've received the initial thread data)
|
||||
guard
|
||||
self.hasReceivedInitialThreadData,
|
||||
(
|
||||
oldState.hasHiddenMessageRequests != updatedState.hasHiddenMessageRequests ||
|
||||
oldState.unreadMessageRequestThreadCount != updatedState.unreadMessageRequestThreadCount
|
||||
|
@ -215,7 +279,7 @@ public class HomeViewModel {
|
|||
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
|
||||
let currentData: [SectionModel] = (self.unobservedThreadDataChanges?.0 ?? self.threadData)
|
||||
let updatedThreadData: [SectionModel] = self.process(
|
||||
data: currentData.flatMap { $0.elements },
|
||||
data: (currentData.first(where: { $0.model == .threads })?.elements ?? []),
|
||||
for: currentPageInfo
|
||||
)
|
||||
|
||||
|
@ -223,14 +287,18 @@ public class HomeViewModel {
|
|||
updatedData: updatedThreadData,
|
||||
currentDataRetriever: { [weak self] in (self?.unobservedThreadDataChanges?.0 ?? self?.threadData) },
|
||||
onDataChange: onThreadChange,
|
||||
onUnobservedDataChange: { [weak self] updatedThreadData, changeset in
|
||||
self?.unobservedThreadDataChanges = (updatedThreadData, changeset)
|
||||
onUnobservedDataChange: { [weak self] updatedData, changeset in
|
||||
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
|
||||
nil :
|
||||
(updatedData, changeset)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Thread Data
|
||||
|
||||
private var hasReceivedInitialThreadData: Bool = false
|
||||
public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
|
||||
public private(set) var threadData: [SectionModel] = []
|
||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
|
||||
|
@ -239,8 +307,14 @@ public class HomeViewModel {
|
|||
didSet {
|
||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||
// data was changed while we weren't observing
|
||||
if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
|
||||
onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1)
|
||||
if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
|
||||
let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onThreadChange
|
||||
|
||||
switch Thread.isMainThread {
|
||||
case true: performChange?(changes.0, changes.1)
|
||||
case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) }
|
||||
}
|
||||
|
||||
self.unobservedThreadDataChanges = nil
|
||||
}
|
||||
}
|
||||
|
@ -264,7 +338,10 @@ public class HomeViewModel {
|
|||
[SectionModel(
|
||||
section: .messageRequests,
|
||||
elements: [
|
||||
SessionThreadViewModel(unreadCount: UInt(finalUnreadMessageRequestCount))
|
||||
SessionThreadViewModel(
|
||||
threadId: SessionThreadViewModel.messageRequestsSectionId,
|
||||
unreadCount: UInt(finalUnreadMessageRequestCount)
|
||||
)
|
||||
]
|
||||
)]
|
||||
),
|
||||
|
@ -272,18 +349,25 @@ public class HomeViewModel {
|
|||
SectionModel(
|
||||
section: .threads,
|
||||
elements: data
|
||||
.filter { $0.id != SessionThreadViewModel.invalidId }
|
||||
.filter { threadViewModel in
|
||||
threadViewModel.id != SessionThreadViewModel.invalidId &&
|
||||
threadViewModel.id != SessionThreadViewModel.messageRequestsSectionId
|
||||
}
|
||||
.sorted { lhs, rhs -> Bool in
|
||||
if lhs.threadIsPinned && !rhs.threadIsPinned { return true }
|
||||
if !lhs.threadIsPinned && rhs.threadIsPinned { return false }
|
||||
guard lhs.threadPinnedPriority == rhs.threadPinnedPriority else {
|
||||
return lhs.threadPinnedPriority > rhs.threadPinnedPriority
|
||||
}
|
||||
|
||||
return lhs.lastInteractionDate > rhs.lastInteractionDate
|
||||
}
|
||||
.map { viewModel -> SessionThreadViewModel in
|
||||
viewModel.populatingCurrentUserBlindedKey(
|
||||
currentUserBlindedPublicKeyForThisThread: groupedOldData[viewModel.threadId]?
|
||||
viewModel.populatingCurrentUserBlindedKeys(
|
||||
currentUserBlinded15PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
|
||||
.first?
|
||||
.currentUserBlindedPublicKey
|
||||
.currentUserBlinded15PublicKey,
|
||||
currentUserBlinded25PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
|
||||
.first?
|
||||
.currentUserBlinded25PublicKey
|
||||
)
|
||||
}
|
||||
)
|
||||
|
@ -298,32 +382,4 @@ public class HomeViewModel {
|
|||
public func updateThreadData(_ updatedData: [SectionModel]) {
|
||||
self.threadData = updatedData
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public func delete(threadId: String, threadVariant: SessionThread.Variant, force: Bool = false) {
|
||||
|
||||
func delete(_ db: Database, threadId: String) throws {
|
||||
_ = try SessionThread
|
||||
.filter(id: threadId)
|
||||
.deleteAll(db)
|
||||
}
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
switch (threadVariant, force) {
|
||||
case (.closedGroup, false):
|
||||
try MessageSender.leave(
|
||||
db,
|
||||
groupPublicKey: threadId,
|
||||
deleteThread: true
|
||||
)
|
||||
|
||||
case (.openGroup, _):
|
||||
OpenGroupManager.shared.delete(db, openGroupId: threadId)
|
||||
|
||||
default:
|
||||
try delete(db, threadId: threadId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,512 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
||||
private static let loadingHeaderHeight: CGFloat = 40
|
||||
|
||||
private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
|
||||
private var dataChangeObservable: DatabaseCancellable?
|
||||
private var hasLoadedInitialThreadData: Bool = false
|
||||
private var isLoadingMore: Bool = false
|
||||
private var isAutoLoadingNextPage: Bool = false
|
||||
private var viewHasAppeared: Bool = false
|
||||
|
||||
// MARK: - Intialization
|
||||
|
||||
init() {
|
||||
Storage.shared.addObserver(viewModel.pagedDataObserver)
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init() instead.")
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var loadingConversationsLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.text = "LOADING_CONVERSATIONS".localized()
|
||||
result.themeTextColor = .textSecondary
|
||||
result.textAlignment = .center
|
||||
result.numberOfLines = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let result: UITableView = UITableView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.separatorStyle = .none
|
||||
result.themeBackgroundColor = .clear
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.contentInset = UIEdgeInsets(
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: Values.footerGradientHeight(window: UIApplication.shared.keyWindow),
|
||||
right: 0
|
||||
)
|
||||
result.register(view: FullConversationCell.self)
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
|
||||
if #available(iOS 15.0, *) {
|
||||
result.sectionHeaderTopPadding = 0
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var emptyStateLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.isUserInteractionEnabled = false
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.text = "MESSAGE_REQUESTS_EMPTY_TEXT".localized()
|
||||
result.themeTextColor = .textSecondary
|
||||
result.textAlignment = .center
|
||||
result.numberOfLines = 0
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var fadeView: GradientView = {
|
||||
let result: GradientView = GradientView()
|
||||
result.themeBackgroundGradient = [
|
||||
.value(.backgroundPrimary, alpha: 0), // Want this to take up 20% (~25pt)
|
||||
.backgroundPrimary,
|
||||
.backgroundPrimary,
|
||||
.backgroundPrimary,
|
||||
.backgroundPrimary
|
||||
]
|
||||
result.set(.height, to: Values.footerGradientHeight(window: UIApplication.shared.keyWindow))
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var clearAllButton: SessionButton = {
|
||||
let result: SessionButton = SessionButton(style: .destructive, size: .large)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal)
|
||||
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
ViewControllerUtilities.setUpDefaultSessionStyle(
|
||||
for: self,
|
||||
title: "MESSAGE_REQUESTS_TITLE".localized(),
|
||||
hasCustomBackButton: false
|
||||
)
|
||||
|
||||
// Add the UI (MUST be done after the thread freeze so the 'tableView' creation and setting
|
||||
// the dataSource has the correct data)
|
||||
view.addSubview(loadingConversationsLabel)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyStateLabel)
|
||||
view.addSubview(fadeView)
|
||||
view.addSubview(clearAllButton)
|
||||
setupLayout()
|
||||
|
||||
// Notifications
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationDidBecomeActive(_:)),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationDidResignActive(_:)),
|
||||
name: UIApplication.didEnterBackgroundNotification, object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
startObservingChanges()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
self.viewHasAppeared = true
|
||||
self.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
// Stop observing database changes
|
||||
dataChangeObservable?.cancel()
|
||||
}
|
||||
|
||||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||
startObservingChanges(didReturnFromBackground: true)
|
||||
}
|
||||
|
||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||
// Stop observing database changes
|
||||
dataChangeObservable?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
private func setupLayout() {
|
||||
NSLayoutConstraint.activate([
|
||||
loadingConversationsLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.veryLargeSpacing),
|
||||
loadingConversationsLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.massiveSpacing),
|
||||
loadingConversationsLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.massiveSpacing),
|
||||
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.smallSpacing),
|
||||
tableView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||
tableView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
emptyStateLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.massiveSpacing),
|
||||
emptyStateLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.mediumSpacing),
|
||||
emptyStateLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.mediumSpacing),
|
||||
emptyStateLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
|
||||
fadeView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||
fadeView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
||||
fadeView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
clearAllButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
clearAllButton.bottomAnchor.constraint(
|
||||
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
|
||||
constant: -Values.smallSpacing
|
||||
),
|
||||
clearAllButton.widthAnchor.constraint(equalToConstant: Values.iPadButtonWidth)
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Updating
|
||||
|
||||
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
||||
self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in
|
||||
self?.handleThreadUpdates(updatedThreadData, changeset: changeset)
|
||||
}
|
||||
|
||||
// Note: When returning from the background we could have received notifications but the
|
||||
// PagedDatabaseObserver won't have them so we need to force a re-fetch of the current
|
||||
// data to ensure everything is up to date
|
||||
if didReturnFromBackground {
|
||||
self.viewModel.pagedDataObserver?.reload()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleThreadUpdates(
|
||||
_ updatedData: [MessageRequestsViewModel.SectionModel],
|
||||
changeset: StagedChangeset<[MessageRequestsViewModel.SectionModel]>,
|
||||
initialLoad: Bool = false
|
||||
) {
|
||||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||
// in from a frame of CGRect.zero)
|
||||
guard hasLoadedInitialThreadData else {
|
||||
hasLoadedInitialThreadData = true
|
||||
UIView.performWithoutAnimation {
|
||||
handleThreadUpdates(updatedData, changeset: changeset, initialLoad: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
CATransaction.begin()
|
||||
CATransaction.setCompletionBlock { [weak self] in
|
||||
// Complete page loading
|
||||
self?.isLoadingMore = false
|
||||
self?.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
|
||||
// Reload the table content (animate changes after the first load)
|
||||
tableView.reload(
|
||||
using: changeset,
|
||||
deleteSectionsAnimation: .none,
|
||||
insertSectionsAnimation: .none,
|
||||
reloadSectionsAnimation: .none,
|
||||
deleteRowsAnimation: .bottom,
|
||||
insertRowsAnimation: .top,
|
||||
reloadRowsAnimation: .none,
|
||||
interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues
|
||||
) { [weak self] updatedData in
|
||||
self?.viewModel.updateThreadData(updatedData)
|
||||
}
|
||||
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
private func autoLoadNextPageIfNeeded() {
|
||||
guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return }
|
||||
|
||||
self.isAutoLoadingNextPage = true
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in
|
||||
self?.isAutoLoadingNextPage = false
|
||||
|
||||
// Note: We sort the headers as we want to prioritise loading newer pages over older ones
|
||||
let sections: [(MessageRequestsViewModel.Section, CGRect)] = (self?.viewModel.threadData
|
||||
.enumerated()
|
||||
.map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) })
|
||||
.defaulting(to: [])
|
||||
let shouldLoadMore: Bool = sections
|
||||
.contains { section, headerRect in
|
||||
section == .loadMore &&
|
||||
headerRect != .zero &&
|
||||
(self?.tableView.bounds.contains(headerRect) == true)
|
||||
}
|
||||
|
||||
guard shouldLoadMore else { return }
|
||||
|
||||
self?.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(.pageAfter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return viewModel.threadData.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
|
||||
|
||||
return section.elements.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[indexPath.section]
|
||||
|
||||
switch section.model {
|
||||
case .threads:
|
||||
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||
let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
|
||||
cell.accessibilityIdentifier = "Message request"
|
||||
cell.isAccessibilityElement = true
|
||||
cell.update(with: threadViewModel)
|
||||
return cell
|
||||
|
||||
default: preconditionFailure("Other sections should have no content")
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
|
||||
|
||||
switch section.model {
|
||||
case .loadMore:
|
||||
let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
|
||||
loadingIndicator.themeTintColor = .textPrimary
|
||||
loadingIndicator.alpha = 0.5
|
||||
loadingIndicator.startAnimating()
|
||||
|
||||
let view: UIView = UIView()
|
||||
view.addSubview(loadingIndicator)
|
||||
loadingIndicator.center(in: view)
|
||||
|
||||
return view
|
||||
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
|
||||
|
||||
switch section.model {
|
||||
case .loadMore: return MessageRequestsViewController.loadingHeaderHeight
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
|
||||
guard self.hasLoadedInitialThreadData && self.viewHasAppeared && !self.isLoadingMore else { return }
|
||||
|
||||
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[section]
|
||||
|
||||
switch section.model {
|
||||
case .loadMore:
|
||||
self.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(.pageAfter)
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
|
||||
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
||||
|
||||
switch section.model {
|
||||
case .threads:
|
||||
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||
let conversationVC: ConversationVC = ConversationVC(
|
||||
threadId: threadViewModel.threadId,
|
||||
threadVariant: threadViewModel.threadVariant
|
||||
)
|
||||
self.navigationController?.pushViewController(conversationVC, animated: true)
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
||||
|
||||
switch section.model {
|
||||
case .threads:
|
||||
let threadId: String = section.elements[indexPath.row].threadId
|
||||
let delete: UIContextualAction = UIContextualAction(
|
||||
style: .destructive,
|
||||
title: "TXT_DELETE_TITLE".localized()
|
||||
) { [weak self] _, _, completionHandler in
|
||||
self?.delete(threadId)
|
||||
completionHandler(true)
|
||||
}
|
||||
delete.themeBackgroundColor = .conversationButton_swipeDestructive
|
||||
|
||||
let block: UIContextualAction = UIContextualAction(
|
||||
style: .normal,
|
||||
title: "BLOCK_LIST_BLOCK_BUTTON".localized()
|
||||
) { [weak self] _, _, completionHandler in
|
||||
self?.block(threadId)
|
||||
completionHandler(true)
|
||||
}
|
||||
block.themeBackgroundColor = .conversationButton_swipeSecondary
|
||||
|
||||
return UISwipeActionsConfiguration(actions: [ delete, block ])
|
||||
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func clearAllTapped() {
|
||||
guard viewModel.threadData.first(where: { $0.model == .threads })?.elements.isEmpty == false else {
|
||||
return
|
||||
}
|
||||
|
||||
let threadIds: [String] = (viewModel.threadData
|
||||
.first { $0.model == .threads }?
|
||||
.elements
|
||||
.map { $0.threadId })
|
||||
.defaulting(to: [])
|
||||
let alertVC: UIAlertController = UIAlertController(
|
||||
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE".localized(),
|
||||
message: nil,
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
alertVC.addAction(UIAlertAction(
|
||||
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON".localized(),
|
||||
style: .destructive
|
||||
) { _ in
|
||||
// Clear the requests
|
||||
Storage.shared.write { db in
|
||||
_ = try SessionThread
|
||||
.filter(ids: threadIds)
|
||||
.deleteAll(db)
|
||||
}
|
||||
})
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
|
||||
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
private func delete(_ threadId: String) {
|
||||
let alertVC: UIAlertController = UIAlertController(
|
||||
title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(),
|
||||
message: nil,
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
alertVC.addAction(UIAlertAction(
|
||||
title: "TXT_DELETE_TITLE".localized(),
|
||||
style: .destructive
|
||||
) { _ in
|
||||
Storage.shared.write { db in
|
||||
_ = try SessionThread
|
||||
.filter(id: threadId)
|
||||
.deleteAll(db)
|
||||
}
|
||||
})
|
||||
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
|
||||
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
private func block(_ threadId: String) {
|
||||
let alertVC: UIAlertController = UIAlertController(
|
||||
title: "MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON".localized(),
|
||||
message: nil,
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
alertVC.addAction(UIAlertAction(
|
||||
title: "BLOCK_LIST_BLOCK_BUTTON".localized(),
|
||||
style: .destructive
|
||||
) { _ in
|
||||
Storage.shared.write { db in
|
||||
_ = try SessionThread
|
||||
.filter(id: threadId)
|
||||
.deleteAll(db)
|
||||
_ = try Contact
|
||||
.fetchOrCreate(db, id: threadId)
|
||||
.with(
|
||||
isApproved: false,
|
||||
isBlocked: true
|
||||
)
|
||||
.saved(db)
|
||||
|
||||
// Force a config sync
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
})
|
||||
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
|
||||
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
}
|
|
@ -1,34 +1,37 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
public class MessageRequestsViewModel {
|
||||
public typealias SectionModel = ArraySection<Section, SessionThreadViewModel>
|
||||
|
||||
// MARK: - Section
|
||||
|
||||
public enum Section: Differentiable {
|
||||
case threads
|
||||
case loadMore
|
||||
}
|
||||
class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource, PagedObservationSource {
|
||||
typealias TableItem = SessionThreadViewModel
|
||||
typealias PagedTable = SessionThread
|
||||
typealias PagedDataModel = SessionThreadViewModel
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
public static let pageSize: Int = 15
|
||||
public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15)
|
||||
public let dependencies: Dependencies
|
||||
public let state: TableDataState<Section, TableItem> = TableDataState()
|
||||
public let observableState: ObservableTableSourceState<Section, SessionThreadViewModel> = ObservableTableSourceState()
|
||||
public let navigatableState: NavigatableState = NavigatableState()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
init(using dependencies: Dependencies = Dependencies()) {
|
||||
self.dependencies = dependencies
|
||||
self.pagedDataObserver = nil
|
||||
|
||||
// Note: Since this references self we need to finish initializing before setting it, we
|
||||
// also want to skip the initial query and trigger it async so that the push animation
|
||||
// doesn't stutter (it should load basically immediately but without this there is a
|
||||
// distinct stutter)
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey()
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(using: dependencies)
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
self.pagedDataObserver = PagedDatabaseObserver(
|
||||
pagedTable: SessionThread.self,
|
||||
|
@ -51,7 +54,7 @@ public class MessageRequestsViewModel {
|
|||
joinToPagedType: {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
|
||||
return SQL("JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
|
@ -60,7 +63,7 @@ public class MessageRequestsViewModel {
|
|||
joinToPagedType: {
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
|
||||
return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
|
@ -69,7 +72,7 @@ public class MessageRequestsViewModel {
|
|||
joinToPagedType: {
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
|
||||
return SQL("JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
|
@ -80,8 +83,8 @@ public class MessageRequestsViewModel {
|
|||
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
|
||||
|
||||
return """
|
||||
LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
|
||||
LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
|
||||
JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
|
||||
JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
|
||||
"""
|
||||
}()
|
||||
)
|
||||
|
@ -100,11 +103,8 @@ public class MessageRequestsViewModel {
|
|||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
PagedData.processAndTriggerUpdates(
|
||||
updatedData: self?.process(data: updatedData, for: updatedPageInfo),
|
||||
currentDataRetriever: { self?.threadData },
|
||||
onDataChange: self?.onThreadChange,
|
||||
onUnobservedDataChange: { updatedData, changeset in
|
||||
self?.unobservedThreadDataChanges = (updatedData, changeset)
|
||||
}
|
||||
currentDataRetriever: { self?.tableData },
|
||||
valueSubject: self?.pendingTableDataSubject
|
||||
)
|
||||
}
|
||||
)
|
||||
|
@ -116,29 +116,35 @@ public class MessageRequestsViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Thread Data
|
||||
// MARK: - Section
|
||||
|
||||
public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
|
||||
public private(set) var threadData: [SectionModel] = []
|
||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
|
||||
|
||||
public var onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
|
||||
didSet {
|
||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||
// data was changed while we weren't observing
|
||||
if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
|
||||
self.onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1)
|
||||
self.unobservedThreadDataChanges = nil
|
||||
public enum Section: SessionTableSection {
|
||||
case threads
|
||||
case loadMore
|
||||
|
||||
var style: SessionTableSectionStyle {
|
||||
switch self {
|
||||
case .threads: return .none
|
||||
case .loadMore: return .loadMore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
public let title: String = "MESSAGE_REQUESTS_TITLE".localized()
|
||||
public let initialLoadMessage: String? = "LOADING_CONVERSATIONS".localized()
|
||||
public let emptyStateTextPublisher: AnyPublisher<String?, Never> = Just("MESSAGE_REQUESTS_EMPTY_TEXT".localized())
|
||||
.eraseToAnyPublisher()
|
||||
public let cellType: SessionTableViewCellType = .fullConversation
|
||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
|
||||
|
||||
private func process(data: [SessionThreadViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
||||
let groupedOldData: [String: [SessionThreadViewModel]] = (self.threadData
|
||||
let groupedOldData: [String: [SessionCell.Info<SessionThreadViewModel>]] = (self.tableData
|
||||
.first(where: { $0.model == .threads })?
|
||||
.elements)
|
||||
.defaulting(to: [])
|
||||
.grouped(by: \.threadId)
|
||||
.grouped(by: \.id.threadId)
|
||||
|
||||
return [
|
||||
[
|
||||
|
@ -146,11 +152,28 @@ public class MessageRequestsViewModel {
|
|||
section: .threads,
|
||||
elements: data
|
||||
.sorted { lhs, rhs -> Bool in lhs.lastInteractionDate > rhs.lastInteractionDate }
|
||||
.map { viewModel -> SessionThreadViewModel in
|
||||
viewModel.populatingCurrentUserBlindedKey(
|
||||
currentUserBlindedPublicKeyForThisThread: groupedOldData[viewModel.threadId]?
|
||||
.first?
|
||||
.currentUserBlindedPublicKey
|
||||
.map { viewModel -> SessionCell.Info<SessionThreadViewModel> in
|
||||
SessionCell.Info(
|
||||
id: viewModel.populatingCurrentUserBlindedKeys(
|
||||
currentUserBlinded15PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
|
||||
.first?
|
||||
.id
|
||||
.currentUserBlinded15PublicKey,
|
||||
currentUserBlinded25PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
|
||||
.first?
|
||||
.id
|
||||
.currentUserBlinded25PublicKey
|
||||
),
|
||||
accessibility: Accessibility(
|
||||
identifier: "Message request"
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
let viewController: ConversationVC = ConversationVC(
|
||||
threadId: viewModel.threadId,
|
||||
threadVariant: viewModel.threadVariant
|
||||
)
|
||||
self?.transitionToScreen(viewController, transitionType: .push)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
@ -162,7 +185,100 @@ public class MessageRequestsViewModel {
|
|||
].flatMap { $0 }
|
||||
}
|
||||
|
||||
public func updateThreadData(_ updatedData: [SectionModel]) {
|
||||
self.threadData = updatedData
|
||||
lazy var footerButtonInfo: AnyPublisher<SessionButton.Info?, Never> = observableState
|
||||
.pendingTableDataSubject
|
||||
.map { [dependencies] (currentThreadData: [SectionModel], _: StagedChangeset<[SectionModel]>) in
|
||||
let threadInfo: [(id: String, variant: SessionThread.Variant)] = (currentThreadData
|
||||
.first(where: { $0.model == .threads })?
|
||||
.elements
|
||||
.map { ($0.id.id, $0.id.threadVariant) })
|
||||
.defaulting(to: [])
|
||||
|
||||
return SessionButton.Info(
|
||||
style: .destructive,
|
||||
title: "MESSAGE_REQUESTS_CLEAR_ALL".localized(),
|
||||
isEnabled: !threadInfo.isEmpty,
|
||||
accessibility: Accessibility(
|
||||
identifier: "Clear all"
|
||||
),
|
||||
onTap: { [weak self] in
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE".localized(),
|
||||
accessibility: Accessibility(
|
||||
identifier: "Clear all"
|
||||
),
|
||||
confirmTitle: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON".localized(),
|
||||
confirmAccessibility: Accessibility(
|
||||
identifier: "Clear"
|
||||
),
|
||||
confirmStyle: .danger,
|
||||
cancelStyle: .alert_text,
|
||||
onConfirm: { _ in
|
||||
// Clear the requests
|
||||
dependencies.storage.write { db in
|
||||
// Remove the one-to-one requests
|
||||
try SessionThread.deleteOrLeave(
|
||||
db,
|
||||
threadIds: threadInfo
|
||||
.filter { _, variant in variant == .contact }
|
||||
.map { id, _ in id },
|
||||
threadVariant: .contact,
|
||||
groupLeaveType: .silent,
|
||||
calledFromConfigHandling: false
|
||||
)
|
||||
|
||||
// Remove the group requests
|
||||
try SessionThread.deleteOrLeave(
|
||||
db,
|
||||
threadIds: threadInfo
|
||||
.filter { _, variant in variant == .legacyGroup || variant == .group }
|
||||
.map { id, _ in id },
|
||||
threadVariant: .group,
|
||||
groupLeaveType: .silent,
|
||||
calledFromConfigHandling: false
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
self?.transitionToScreen(modal, transitionType: .present)
|
||||
}
|
||||
)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
func canEditRow(at indexPath: IndexPath) -> Bool {
|
||||
let section: SectionModel = tableData[indexPath.section]
|
||||
|
||||
return (section.model == .threads)
|
||||
}
|
||||
|
||||
func trailingSwipeActionsConfiguration(forRowAt indexPath: IndexPath, in tableView: UITableView, of viewController: UIViewController) -> UISwipeActionsConfiguration? {
|
||||
let section: SectionModel = tableData[indexPath.section]
|
||||
|
||||
switch section.model {
|
||||
case .threads:
|
||||
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row].id
|
||||
|
||||
return UIContextualAction.configuration(
|
||||
for: UIContextualAction.generateSwipeActions(
|
||||
[
|
||||
(threadViewModel.threadVariant != .contact ? nil : .block),
|
||||
.delete
|
||||
].compactMap { $0 },
|
||||
for: .trailing,
|
||||
indexPath: indexPath,
|
||||
tableView: tableView,
|
||||
threadViewModel: threadViewModel,
|
||||
viewController: viewController
|
||||
)
|
||||
)
|
||||
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
class MessageRequestsCell: UITableViewCell {
|
||||
static let reuseIdentifier = "MessageRequestsCell"
|
||||
|
@ -29,7 +30,7 @@ class MessageRequestsCell: UITableViewCell {
|
|||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.clipsToBounds = true
|
||||
result.themeBackgroundColor = .conversationButton_unreadBubbleBackground
|
||||
result.layer.cornerRadius = (Values.mediumProfilePictureSize / 2)
|
||||
result.layer.cornerRadius = (ProfilePictureView.Size.list.viewSize / 2)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -100,8 +101,8 @@ class MessageRequestsCell: UITableViewCell {
|
|||
constant: (Values.accentLineThickness + Values.mediumSpacing)
|
||||
),
|
||||
iconContainerView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
iconContainerView.widthAnchor.constraint(equalToConstant: Values.mediumProfilePictureSize),
|
||||
iconContainerView.heightAnchor.constraint(equalToConstant: Values.mediumProfilePictureSize),
|
||||
iconContainerView.widthAnchor.constraint(equalToConstant: ProfilePictureView.Size.list.viewSize),
|
||||
iconContainerView.heightAnchor.constraint(equalToConstant: ProfilePictureView.Size.list.viewSize),
|
||||
|
||||
iconImageView.centerXAnchor.constraint(equalTo: iconContainerView.centerXAnchor),
|
||||
iconImageView.centerYAnchor.constraint(equalTo: iconContainerView.centerYAnchor),
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UITableViewDataSource {
|
||||
private let newConversationViewModel = NewConversationViewModel()
|
||||
|
@ -143,13 +143,13 @@ final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UI
|
|||
cell.update(
|
||||
with: SessionCell.Info(
|
||||
id: profile,
|
||||
leftAccessory: .profile(profile.id, profile),
|
||||
title: profile.displayName()
|
||||
),
|
||||
style: .edgeToEdge,
|
||||
position: Position.with(
|
||||
indexPath.row,
|
||||
count: newConversationViewModel.sectionData[indexPath.section].contacts.count
|
||||
position: Position.with(
|
||||
indexPath.row,
|
||||
count: newConversationViewModel.sectionData[indexPath.section].contacts.count
|
||||
),
|
||||
leftAccessory: .profile(id: profile.id, profile: profile),
|
||||
title: profile.displayName(),
|
||||
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge)
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -179,15 +179,13 @@ final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UI
|
|||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
|
||||
let sessionId = newConversationViewModel.sectionData[indexPath.section].contacts[indexPath.row].id
|
||||
let maybeThread: SessionThread? = Storage.shared.write { db in
|
||||
try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact)
|
||||
}
|
||||
|
||||
guard maybeThread != nil else { return }
|
||||
|
||||
self.navigationController?.dismiss(animated: true, completion: nil)
|
||||
|
||||
SessionApp.presentConversation(for: sessionId, action: .compose, animated: false)
|
||||
SessionApp.presentConversationCreatingIfNeeded(
|
||||
for: sessionId,
|
||||
variant: .contact,
|
||||
dismissing: navigationController,
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
|
||||
|
|
|
@ -3,11 +3,11 @@
|
|||
import UIKit
|
||||
import AVFoundation
|
||||
import GRDB
|
||||
import Curve25519Kit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionSnodeKit
|
||||
|
||||
final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, QRScannerDelegate {
|
||||
private var shouldShowBackButton: Bool = true
|
||||
|
@ -166,16 +166,45 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
|
|||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func controller(_ controller: QRCodeScanningViewController, didDetectQRCodeWith string: String) {
|
||||
func controller(_ controller: QRCodeScanningViewController, didDetectQRCodeWith string: String, onError: (() -> ())?) {
|
||||
let hexEncodedPublicKey = string
|
||||
startNewDMIfPossible(with: hexEncodedPublicKey)
|
||||
startNewDMIfPossible(with: hexEncodedPublicKey, onError: onError)
|
||||
}
|
||||
|
||||
fileprivate func startNewDMIfPossible(with onsNameOrPublicKey: String) {
|
||||
fileprivate func startNewDMIfPossible(with onsNameOrPublicKey: String, onError: (() -> ())?) {
|
||||
let maybeSessionId: SessionId? = SessionId(from: onsNameOrPublicKey)
|
||||
|
||||
if ECKeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) && maybeSessionId?.prefix == .standard {
|
||||
startNewDM(with: onsNameOrPublicKey)
|
||||
if KeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) {
|
||||
switch maybeSessionId?.prefix {
|
||||
case .standard:
|
||||
startNewDM(with: onsNameOrPublicKey)
|
||||
|
||||
case .blinded15, .blinded25:
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "ALERT_ERROR_TITLE".localized(),
|
||||
body: .text("DM_ERROR_DIRECT_BLINDED_ID".localized()),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text,
|
||||
afterClosed: onError
|
||||
)
|
||||
)
|
||||
self.present(modal, animated: true)
|
||||
|
||||
default:
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "ALERT_ERROR_TITLE".localized(),
|
||||
body: .text("DM_ERROR_INVALID".localized()),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text,
|
||||
afterClosed: onError
|
||||
)
|
||||
)
|
||||
self.present(modal, animated: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -183,58 +212,64 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
|
|||
ModalActivityIndicatorViewController
|
||||
.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
|
||||
SnodeAPI
|
||||
.getSessionID(for: onsNameOrPublicKey)
|
||||
.done { sessionID in
|
||||
.getSessionID(for: onsNameOrPublicKey)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure(let error):
|
||||
modalActivityIndicator.dismiss {
|
||||
var messageOrNil: String?
|
||||
if let error = error as? SnodeAPIError {
|
||||
switch error {
|
||||
case .decryptionFailed, .hashingFailed, .validationFailed:
|
||||
messageOrNil = error.errorDescription
|
||||
default: break
|
||||
}
|
||||
}
|
||||
let message: String = {
|
||||
if let messageOrNil: String = messageOrNil {
|
||||
return messageOrNil
|
||||
}
|
||||
|
||||
return (maybeSessionId?.prefix == .blinded15 || maybeSessionId?.prefix == .blinded25 ?
|
||||
"DM_ERROR_DIRECT_BLINDED_ID".localized() :
|
||||
"DM_ERROR_INVALID".localized()
|
||||
)
|
||||
}()
|
||||
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "ALERT_ERROR_TITLE".localized(),
|
||||
body: .text(message),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text,
|
||||
afterClosed: onError
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
}
|
||||
},
|
||||
receiveValue: { sessionId in
|
||||
modalActivityIndicator.dismiss {
|
||||
self?.startNewDM(with: sessionID)
|
||||
}
|
||||
}
|
||||
.catch { error in
|
||||
modalActivityIndicator.dismiss {
|
||||
var messageOrNil: String?
|
||||
if let error = error as? SnodeAPIError {
|
||||
switch error {
|
||||
case .decryptionFailed, .hashingFailed, .validationFailed:
|
||||
messageOrNil = error.errorDescription
|
||||
default: break
|
||||
}
|
||||
}
|
||||
let message: String = {
|
||||
if let messageOrNil: String = messageOrNil {
|
||||
return messageOrNil
|
||||
}
|
||||
|
||||
return (maybeSessionId?.prefix == .blinded ?
|
||||
"DM_ERROR_DIRECT_BLINDED_ID".localized() :
|
||||
"DM_ERROR_INVALID".localized()
|
||||
)
|
||||
}()
|
||||
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "ALERT_ERROR_TITLE".localized(),
|
||||
explanation: message,
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
self?.startNewDM(with: sessionId)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func startNewDM(with sessionId: String) {
|
||||
let maybeThread: SessionThread? = Storage.shared.write { db in
|
||||
try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact)
|
||||
}
|
||||
|
||||
guard maybeThread != nil else { return }
|
||||
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
|
||||
SessionApp.presentConversation(for: sessionId, action: .compose, animated: false)
|
||||
SessionApp.presentConversationCreatingIfNeeded(
|
||||
for: sessionId,
|
||||
variant: .contact,
|
||||
dismissing: presentingViewController,
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -632,7 +667,7 @@ private final class EnterPublicKeyVC: UIViewController {
|
|||
|
||||
@objc fileprivate func startNewDMIfPossible() {
|
||||
let text = publicKeyTextView.text?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
NewDMVC.startNewDMIfPossible(with: text)
|
||||
NewDMVC.startNewDMIfPossible(with: text, onError: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -660,7 +695,7 @@ private final class ScanQRCodePlaceholderVC: UIViewController {
|
|||
// Set up call to action button
|
||||
let callToActionButton = UIButton()
|
||||
callToActionButton.titleLabel?.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
callToActionButton.setTitle("vc_scan_qr_code_grant_camera_access_button_title".localized(), for: UIControl.State.normal)
|
||||
callToActionButton.setTitle("continue_2".localized(), for: .normal)
|
||||
callToActionButton.setThemeTitleColor(.primary, for: .normal)
|
||||
callToActionButton.addTarget(self, action: #selector(requestCameraAccess), for: UIControl.Event.touchUpInside)
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import GRDB
|
|||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
import SignalCoreKit
|
||||
|
||||
public class AllMediaViewController: UIViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
|
||||
private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MediaPlayer
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
// This kind of view is tricky. I've tried to organize things in the
|
||||
// simplest possible way.
|
||||
|
@ -32,7 +32,7 @@ import SignalUtilitiesKit
|
|||
|
||||
let srcImage: UIImage
|
||||
|
||||
let successCompletion: ((UIImage) -> Void)
|
||||
let successCompletion: ((Data) -> Void)
|
||||
|
||||
var imageView: UIView!
|
||||
|
||||
|
@ -79,7 +79,7 @@ import SignalUtilitiesKit
|
|||
notImplemented()
|
||||
}
|
||||
|
||||
@objc required init(srcImage: UIImage, successCompletion : @escaping (UIImage) -> Void) {
|
||||
@objc required init(srcImage: UIImage, successCompletion : @escaping (Data) -> Void) {
|
||||
// normalized() can be slightly expensive but in practice this is fine.
|
||||
self.srcImage = srcImage.normalized()
|
||||
self.successCompletion = successCompletion
|
||||
|
@ -360,54 +360,54 @@ import SignalUtilitiesKit
|
|||
|
||||
@objc func handlePinch(sender: UIPinchGestureRecognizer) {
|
||||
switch sender.state {
|
||||
case .possible:
|
||||
break
|
||||
case .began:
|
||||
srcTranslationAtPinchStart = srcTranslation
|
||||
imageScaleAtPinchStart = imageScale
|
||||
case .possible: break
|
||||
case .began:
|
||||
srcTranslationAtPinchStart = srcTranslation
|
||||
imageScaleAtPinchStart = imageScale
|
||||
|
||||
lastPinchLocation =
|
||||
sender.location(in: sender.view)
|
||||
lastPinchScale = sender.scale
|
||||
break
|
||||
case .changed, .ended:
|
||||
if sender.numberOfTouches > 1 {
|
||||
let location =
|
||||
lastPinchLocation =
|
||||
sender.location(in: sender.view)
|
||||
let scaleDiff = sender.scale / lastPinchScale
|
||||
|
||||
// Update scaling.
|
||||
let srcCropSizeBeforeScalePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale,
|
||||
height: srcDefaultCropSizePoints.height / imageScale)
|
||||
imageScale = max(kMinImageScale, min(kMaxImageScale, imageScale * scaleDiff))
|
||||
let srcCropSizeAfterScalePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale,
|
||||
height: srcDefaultCropSizePoints.height / imageScale)
|
||||
// Since the translation state reflects the "upper left" corner of the crop region, we need to
|
||||
// adjust the translation when scaling to preserve the "center" of the crop region.
|
||||
srcTranslation.x += (srcCropSizeBeforeScalePoints.width - srcCropSizeAfterScalePoints.width) * 0.5
|
||||
srcTranslation.y += (srcCropSizeBeforeScalePoints.height - srcCropSizeAfterScalePoints.height) * 0.5
|
||||
|
||||
// Update translation.
|
||||
let viewSizePoints = imageView.frame.size
|
||||
let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale,
|
||||
height: srcDefaultCropSizePoints.height / imageScale)
|
||||
|
||||
let viewToSrcRatio = srcCropSizePoints.width / viewSizePoints.width
|
||||
|
||||
let gestureTranslation = CGPoint(x: location.x - lastPinchLocation.x,
|
||||
y: location.y - lastPinchLocation.y)
|
||||
|
||||
srcTranslation = CGPoint(x: srcTranslation.x + gestureTranslation.x * -viewToSrcRatio,
|
||||
y: srcTranslation.y + gestureTranslation.y * -viewToSrcRatio)
|
||||
|
||||
lastPinchLocation = location
|
||||
lastPinchScale = sender.scale
|
||||
}
|
||||
break
|
||||
case .cancelled, .failed:
|
||||
srcTranslation = srcTranslationAtPinchStart
|
||||
imageScale = imageScaleAtPinchStart
|
||||
break
|
||||
|
||||
case .changed, .ended:
|
||||
if sender.numberOfTouches > 1 {
|
||||
let location =
|
||||
sender.location(in: sender.view)
|
||||
let scaleDiff = sender.scale / lastPinchScale
|
||||
|
||||
// Update scaling.
|
||||
let srcCropSizeBeforeScalePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale,
|
||||
height: srcDefaultCropSizePoints.height / imageScale)
|
||||
imageScale = max(kMinImageScale, min(kMaxImageScale, imageScale * scaleDiff))
|
||||
let srcCropSizeAfterScalePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale,
|
||||
height: srcDefaultCropSizePoints.height / imageScale)
|
||||
// Since the translation state reflects the "upper left" corner of the crop region, we need to
|
||||
// adjust the translation when scaling to preserve the "center" of the crop region.
|
||||
srcTranslation.x += (srcCropSizeBeforeScalePoints.width - srcCropSizeAfterScalePoints.width) * 0.5
|
||||
srcTranslation.y += (srcCropSizeBeforeScalePoints.height - srcCropSizeAfterScalePoints.height) * 0.5
|
||||
|
||||
// Update translation.
|
||||
let viewSizePoints = imageView.frame.size
|
||||
let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale,
|
||||
height: srcDefaultCropSizePoints.height / imageScale)
|
||||
|
||||
let viewToSrcRatio = srcCropSizePoints.width / viewSizePoints.width
|
||||
|
||||
let gestureTranslation = CGPoint(x: location.x - lastPinchLocation.x,
|
||||
y: location.y - lastPinchLocation.y)
|
||||
|
||||
srcTranslation = CGPoint(x: srcTranslation.x + gestureTranslation.x * -viewToSrcRatio,
|
||||
y: srcTranslation.y + gestureTranslation.y * -viewToSrcRatio)
|
||||
|
||||
lastPinchLocation = location
|
||||
lastPinchScale = sender.scale
|
||||
}
|
||||
|
||||
case .cancelled, .failed:
|
||||
srcTranslation = srcTranslationAtPinchStart
|
||||
imageScale = imageScaleAtPinchStart
|
||||
|
||||
@unknown default: break
|
||||
}
|
||||
|
||||
updateImageLayout()
|
||||
|
@ -417,29 +417,28 @@ import SignalUtilitiesKit
|
|||
|
||||
@objc func handlePan(sender: UIPanGestureRecognizer) {
|
||||
switch sender.state {
|
||||
case .possible:
|
||||
break
|
||||
case .began:
|
||||
srcTranslationAtPanStart = srcTranslation
|
||||
break
|
||||
case .changed, .ended:
|
||||
let viewSizePoints = imageView.frame.size
|
||||
let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale,
|
||||
height: srcDefaultCropSizePoints.height / imageScale)
|
||||
case .possible: break
|
||||
case .began:
|
||||
srcTranslationAtPanStart = srcTranslation
|
||||
|
||||
case .changed, .ended:
|
||||
let viewSizePoints = imageView.frame.size
|
||||
let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale,
|
||||
height: srcDefaultCropSizePoints.height / imageScale)
|
||||
|
||||
let viewToSrcRatio = srcCropSizePoints.width / viewSizePoints.width
|
||||
let viewToSrcRatio = srcCropSizePoints.width / viewSizePoints.width
|
||||
|
||||
let gestureTranslation =
|
||||
sender.translation(in: sender.view)
|
||||
let gestureTranslation =
|
||||
sender.translation(in: sender.view)
|
||||
|
||||
// Update translation.
|
||||
srcTranslation = CGPoint(x: srcTranslationAtPanStart.x + gestureTranslation.x * -viewToSrcRatio,
|
||||
y: srcTranslationAtPanStart.y + gestureTranslation.y * -viewToSrcRatio)
|
||||
break
|
||||
case .cancelled, .failed:
|
||||
srcTranslation
|
||||
= srcTranslationAtPanStart
|
||||
break
|
||||
// Update translation.
|
||||
srcTranslation = CGPoint(x: srcTranslationAtPanStart.x + gestureTranslation.x * -viewToSrcRatio,
|
||||
y: srcTranslationAtPanStart.y + gestureTranslation.y * -viewToSrcRatio)
|
||||
|
||||
case .cancelled, .failed:
|
||||
srcTranslation = srcTranslationAtPanStart
|
||||
|
||||
@unknown default: break
|
||||
}
|
||||
|
||||
updateImageLayout()
|
||||
|
@ -487,10 +486,9 @@ import SignalUtilitiesKit
|
|||
@objc func donePressed(sender: UIButton) {
|
||||
let successCompletion = self.successCompletion
|
||||
dismiss(animated: true, completion: {
|
||||
guard let dstImage = self.generateDstImage() else {
|
||||
return
|
||||
}
|
||||
successCompletion(dstImage)
|
||||
guard let dstImageData: Data = self.generateDstImageData() else { return }
|
||||
|
||||
successCompletion(dstImageData)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -517,4 +515,8 @@ import SignalUtilitiesKit
|
|||
UIGraphicsEndImageContext()
|
||||
return scaledImage
|
||||
}
|
||||
|
||||
func generateDstImageData() -> Data? {
|
||||
return generateDstImage().map { $0.pngData() }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@ import GRDB
|
|||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public class DocumentTileViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
|
||||
|
||||
|
@ -45,6 +47,10 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
|
|||
// MARK: - UI
|
||||
|
||||
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
if UIDevice.current.isIPad {
|
||||
return .all
|
||||
}
|
||||
|
||||
return .allButUpsideDown
|
||||
}
|
||||
|
||||
|
@ -119,7 +125,10 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
|
|||
}
|
||||
|
||||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||
startObservingChanges()
|
||||
/// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.startObservingChanges()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||
|
@ -149,7 +158,7 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
|
|||
}
|
||||
|
||||
private func autoLoadNextPageIfNeeded() {
|
||||
guard !self.isAutoLoadingNextPage else { return }
|
||||
guard self.hasLoadedInitialData && !self.isAutoLoadingNextPage else { return }
|
||||
|
||||
self.isAutoLoadingNextPage = true
|
||||
|
||||
|
@ -200,11 +209,11 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
|
|||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||
// in from a frame of CGRect.zero)
|
||||
guard hasLoadedInitialData else {
|
||||
self.hasLoadedInitialData = true
|
||||
self.viewModel.updateGalleryData(updatedGalleryData)
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.reloadData()
|
||||
self.hasLoadedInitialData = true
|
||||
self.performInitialScrollIfNeeded()
|
||||
}
|
||||
return
|
||||
|
@ -367,10 +376,17 @@ class DocumentCell: UITableViewCell {
|
|||
|
||||
// MARK: - UI
|
||||
|
||||
private static let iconImageViewSize: CGSize = CGSize(width: 31, height: 40)
|
||||
|
||||
private let iconImageView: UIImageView = {
|
||||
let result: UIImageView = UIImageView(image: #imageLiteral(resourceName: "File").withRenderingMode(.alwaysTemplate))
|
||||
let result: UIImageView = UIImageView(image: UIImage(systemName: "doc")?.withRenderingMode(.alwaysTemplate))
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.themeTintColor = .textPrimary
|
||||
result.contentMode = .scaleAspectFit
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let audioImageView: UIImageView = {
|
||||
let result = UIImageView(image: UIImage(systemName: "music.note")?.withRenderingMode(.alwaysTemplate))
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.themeTintColor = .textPrimary
|
||||
result.contentMode = .scaleAspectFit
|
||||
|
@ -430,6 +446,8 @@ class DocumentCell: UITableViewCell {
|
|||
contentView.addSubview(titleLabel)
|
||||
contentView.addSubview(timeLabel)
|
||||
contentView.addSubview(detailLabel)
|
||||
|
||||
iconImageView.addSubview(audioImageView)
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
@ -449,6 +467,8 @@ class DocumentCell: UITableViewCell {
|
|||
lessThanOrEqualTo: contentView.bottomAnchor,
|
||||
constant: -(Values.verySmallSpacing + Values.verySmallSpacing)
|
||||
),
|
||||
iconImageView.widthAnchor.constraint(equalToConstant: 36),
|
||||
iconImageView.heightAnchor.constraint(equalToConstant: 46),
|
||||
|
||||
titleLabel.topAnchor.constraint(
|
||||
equalTo: contentView.topAnchor,
|
||||
|
@ -476,6 +496,10 @@ class DocumentCell: UITableViewCell {
|
|||
lessThanOrEqualTo: contentView.bottomAnchor,
|
||||
constant: -(Values.verySmallSpacing + Values.smallSpacing)
|
||||
),
|
||||
|
||||
audioImageView.centerXAnchor.constraint(equalTo: iconImageView.centerXAnchor),
|
||||
audioImageView.centerYAnchor.constraint(equalTo: iconImageView.centerYAnchor, constant: 7),
|
||||
audioImageView.heightAnchor.constraint(equalTo: iconImageView.heightAnchor, multiplier: 0.32)
|
||||
])
|
||||
}
|
||||
|
||||
|
@ -495,11 +519,12 @@ class DocumentCell: UITableViewCell {
|
|||
|
||||
func update(with item: MediaGalleryViewModel.Item) {
|
||||
let attachment = item.attachment
|
||||
titleLabel.text = (attachment.sourceFilename ?? "File")
|
||||
detailLabel.text = "\(OWSFormat.formatFileSize(UInt(attachment.byteCount)))"
|
||||
titleLabel.text = attachment.documentFileName
|
||||
detailLabel.text = attachment.documentFileInfo
|
||||
timeLabel.text = Date(
|
||||
timeIntervalSince1970: TimeInterval(item.interactionTimestampMs / 1000)
|
||||
).formattedForDisplay
|
||||
audioImageView.isHidden = !attachment.isAudio
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import PromiseKit
|
||||
import SignalUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
import Combine
|
||||
import YYImage
|
||||
import SignalUtilitiesKit
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
class GifPickerCell: UICollectionViewCell {
|
||||
|
||||
|
@ -222,7 +221,7 @@ class GifPickerCell: UICollectionViewCell {
|
|||
self.themeBackgroundColor = nil
|
||||
|
||||
if self.isCellSelected {
|
||||
let activityIndicator = UIActivityIndicatorView(style: .gray)
|
||||
let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
self.activityIndicator = activityIndicator
|
||||
addSubview(activityIndicator)
|
||||
activityIndicator.autoCenterInSuperview()
|
||||
|
@ -245,29 +244,27 @@ class GifPickerCell: UICollectionViewCell {
|
|||
}
|
||||
}
|
||||
|
||||
public func requestRenditionForSending() -> Promise<ProxiedContentAsset> {
|
||||
public func requestRenditionForSending() -> AnyPublisher<ProxiedContentAsset, Error> {
|
||||
guard let renditionForSending = self.renditionForSending else {
|
||||
owsFailDebug("renditionForSending was unexpectedly nil")
|
||||
return Promise(error: GiphyError.assertionError(description: "renditionForSending was unexpectedly nil"))
|
||||
return Fail(error: GiphyError.assertionError(description: "renditionForSending was unexpectedly nil"))
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let (promise, resolver) = Promise<ProxiedContentAsset>.pending()
|
||||
|
||||
// We don't retain a handle on the asset request, since there will only ever
|
||||
// be one selected asset, and we never want to cancel it.
|
||||
_ = GiphyDownloader.giphyDownloader.requestAsset(assetDescription: renditionForSending,
|
||||
priority: .high,
|
||||
success: { _, asset in
|
||||
resolver.fulfill(asset)
|
||||
},
|
||||
failure: { _ in
|
||||
// TODO GiphyDownloader API should pass through a useful failing error
|
||||
// so we can pass it through here
|
||||
Logger.error("request failed")
|
||||
resolver.reject(GiphyError.fetchFailure)
|
||||
})
|
||||
|
||||
return promise
|
||||
return GiphyDownloader.giphyDownloader
|
||||
.requestAsset(
|
||||
assetDescription: renditionForSending,
|
||||
priority: .high
|
||||
)
|
||||
.mapError { _ -> Error in
|
||||
// TODO: GiphyDownloader API should pass through a useful failing error so we can pass it through here
|
||||
Logger.error("request failed")
|
||||
return GiphyError.fetchFailure
|
||||
}
|
||||
.map { asset, _ in asset }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private func clearViewState() {
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SignalCoreKit
|
||||
|
||||
protocol GifPickerLayoutDelegate: AnyObject {
|
||||
func imageInfosForLayout() -> [GiphyImageInfo]
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import Reachability
|
||||
import SignalUtilitiesKit
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate, GifPickerLayoutDelegate {
|
||||
|
||||
|
@ -36,12 +36,12 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
var activityIndicator: UIActivityIndicatorView?
|
||||
var hasSelectedCell: Bool = false
|
||||
var imageInfos = [GiphyImageInfo]()
|
||||
|
||||
var reachability: Reachability?
|
||||
|
||||
|
||||
private let kCellReuseIdentifier = "kCellReuseIdentifier"
|
||||
|
||||
var progressiveSearchTimer: Timer?
|
||||
|
||||
private var disposables: Set<AnyCancellable> = Set()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
|
@ -114,7 +114,6 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
|
||||
createViews()
|
||||
|
||||
reachability = Reachability.forInternetConnection()
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(reachabilityChanged),
|
||||
|
@ -219,7 +218,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
|
||||
private func createErrorLabel(text: String) -> UILabel {
|
||||
let label: UILabel = UILabel()
|
||||
label.font = .ows_mediumFont(withSize: 20)
|
||||
label.font = UIFont.systemFont(ofSize: 20, weight: .medium)
|
||||
label.text = text
|
||||
label.themeTextColor = .textPrimary
|
||||
label.textAlignment = .center
|
||||
|
@ -359,47 +358,53 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
|
||||
public func getFileForCell(_ cell: GifPickerCell) {
|
||||
GiphyDownloader.giphyDownloader.cancelAllRequests()
|
||||
|
||||
firstly {
|
||||
cell.requestRenditionForSending()
|
||||
}.done { [weak self] (asset: ProxiedContentAsset) in
|
||||
guard let strongSelf = self else {
|
||||
Logger.info("ignoring send, since VC was dismissed before fetching finished.")
|
||||
return
|
||||
}
|
||||
guard let rendition = asset.assetDescription as? GiphyRendition else {
|
||||
owsFailDebug("Invalid asset description.")
|
||||
return
|
||||
}
|
||||
|
||||
let filePath = asset.filePath
|
||||
guard let dataSource = DataSourcePath.dataSource(withFilePath: filePath,
|
||||
shouldDeleteOnDeallocation: false) else {
|
||||
owsFailDebug("couldn't load asset.")
|
||||
return
|
||||
}
|
||||
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: rendition.utiType, imageQuality: .medium)
|
||||
|
||||
strongSelf.dismiss(animated: true) {
|
||||
// Delegate presents view controllers, so it's important that *this* controller be dismissed before that occurs.
|
||||
strongSelf.delegate?.gifPickerDidSelect(attachment: attachment)
|
||||
}
|
||||
}.catch { [weak self] error in
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "GIF_PICKER_FAILURE_ALERT_TITLE".localized(),
|
||||
explanation: error.localizedDescription,
|
||||
confirmTitle: CommonStrings.retryButton,
|
||||
cancelTitle: CommonStrings.dismissButton,
|
||||
cancelStyle: .alert_text,
|
||||
onConfirm: { _ in
|
||||
self?.getFileForCell(cell)
|
||||
|
||||
cell
|
||||
.requestRenditionForSending()
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure(let error):
|
||||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
targetView: self?.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: "GIF_PICKER_FAILURE_ALERT_TITLE".localized(),
|
||||
body: .text(error.localizedDescription),
|
||||
confirmTitle: CommonStrings.retryButton,
|
||||
cancelTitle: CommonStrings.dismissButton,
|
||||
cancelStyle: .alert_text,
|
||||
onConfirm: { _ in
|
||||
self?.getFileForCell(cell)
|
||||
}
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}
|
||||
)
|
||||
},
|
||||
receiveValue: { [weak self] asset in
|
||||
guard let rendition = asset.assetDescription as? GiphyRendition else {
|
||||
owsFailDebug("Invalid asset description.")
|
||||
return
|
||||
}
|
||||
|
||||
let filePath = asset.filePath
|
||||
guard let dataSource = DataSourcePath.dataSource(withFilePath: filePath,
|
||||
shouldDeleteOnDeallocation: false) else {
|
||||
owsFailDebug("couldn't load asset.")
|
||||
return
|
||||
}
|
||||
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: rendition.utiType, imageQuality: .medium)
|
||||
|
||||
self?.dismiss(animated: true) {
|
||||
// Delegate presents view controllers, so it's important that *this* controller be dismissed before that occurs.
|
||||
self?.delegate?.gifPickerDidSelect(attachment: attachment)
|
||||
}
|
||||
}
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
}.retainUntilComplete()
|
||||
.store(in: &disposables)
|
||||
}
|
||||
|
||||
public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
|
||||
|
@ -458,7 +463,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
targetView: self.view,
|
||||
info: ConfirmationModal.Info(
|
||||
title: CommonStrings.errorAlertTitle,
|
||||
explanation: "GIF_PICKER_VIEW_MISSING_QUERY".localized(),
|
||||
body: .text("GIF_PICKER_VIEW_MISSING_QUERY".localized()),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
|
@ -486,22 +491,31 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
assert(progressiveSearchTimer == nil)
|
||||
assert(searchBar.text == nil || searchBar.text?.count == 0)
|
||||
|
||||
GiphyAPI.sharedInstance.trending()
|
||||
.done { [weak self] imageInfos in
|
||||
Logger.info("showing trending")
|
||||
|
||||
if imageInfos.count > 0 {
|
||||
self?.imageInfos = imageInfos
|
||||
self?.viewMode = .results
|
||||
GiphyAPI.trending()
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure(let error):
|
||||
// Don't both showing error UI feedback for default "trending" results.
|
||||
Logger.error("error: \(error)")
|
||||
}
|
||||
},
|
||||
receiveValue: { [weak self] imageInfos in
|
||||
Logger.info("showing trending")
|
||||
|
||||
if imageInfos.count > 0 {
|
||||
self?.imageInfos = imageInfos
|
||||
self?.viewMode = .results
|
||||
}
|
||||
else {
|
||||
owsFailDebug("trending results was unexpectedly empty")
|
||||
}
|
||||
}
|
||||
else {
|
||||
owsFailDebug("trending results was unexpectedly empty")
|
||||
}
|
||||
}
|
||||
.catch { error in
|
||||
// Don't both showing error UI feedback for default "trending" results.
|
||||
Logger.error("error: \(error)")
|
||||
}
|
||||
)
|
||||
.store(in: &disposables)
|
||||
}
|
||||
|
||||
private func search(query: String) {
|
||||
|
@ -514,10 +528,21 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
lastQuery = query
|
||||
self.collectionView.contentOffset = CGPoint.zero
|
||||
|
||||
GiphyAPI.sharedInstance
|
||||
.search(
|
||||
query: query,
|
||||
success: { [weak self] imageInfos in
|
||||
GiphyAPI
|
||||
.search(query: query)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure:
|
||||
Logger.info("search failed.")
|
||||
// TODO: Present this error to the user.
|
||||
self?.viewMode = .error
|
||||
}
|
||||
},
|
||||
receiveValue: { [weak self] imageInfos in
|
||||
Logger.info("search complete")
|
||||
self?.imageInfos = imageInfos
|
||||
|
||||
|
@ -527,13 +552,9 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
else {
|
||||
self?.viewMode = .noResults
|
||||
}
|
||||
},
|
||||
failure: { [weak self] _ in
|
||||
Logger.info("search failed.")
|
||||
// TODO: Present this error to the user.
|
||||
self?.viewMode = .error
|
||||
}
|
||||
)
|
||||
.store(in: &disposables)
|
||||
}
|
||||
|
||||
// MARK: - GifPickerLayoutDelegate
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import AFNetworking
|
||||
import Foundation
|
||||
import PromiseKit
|
||||
import Combine
|
||||
import CoreServices
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalCoreKit
|
||||
|
||||
// There's no UTI type for webp!
|
||||
enum GiphyFormat {
|
||||
|
@ -18,13 +16,12 @@ enum GiphyError: Error {
|
|||
case assertionError(description: String)
|
||||
case fetchFailure
|
||||
}
|
||||
|
||||
extension GiphyError: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .assertionError:
|
||||
return NSLocalizedString("GIF_PICKER_ERROR_GENERIC", comment: "Generic error displayed when picking a GIF")
|
||||
case .fetchFailure:
|
||||
return NSLocalizedString("GIF_PICKER_ERROR_FETCH_FAILURE", comment: "Error displayed when there is a failure fetching a GIF from the remote service.")
|
||||
case .assertionError: return "GIF_PICKER_ERROR_GENERIC".localized()
|
||||
case .fetchFailure: return "GIF_PICKER_ERROR_FETCH_FAILURE".localized()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -34,7 +31,7 @@ extension GiphyError: LocalizedError {
|
|||
// They vary in content size (i.e. width, height),
|
||||
// format (.jpg, .gif, .mp4, webp, etc.),
|
||||
// quality, etc.
|
||||
@objc class GiphyRendition: ProxiedContentAssetDescription {
|
||||
class GiphyRendition: ProxiedContentAssetDescription {
|
||||
let format: GiphyFormat
|
||||
let name: String
|
||||
let width: UInt
|
||||
|
@ -59,32 +56,26 @@ extension GiphyError: LocalizedError {
|
|||
|
||||
private class func fileExtension(forFormat format: GiphyFormat) -> String {
|
||||
switch format {
|
||||
case .gif:
|
||||
return "gif"
|
||||
case .mp4:
|
||||
return "mp4"
|
||||
case .jpg:
|
||||
return "jpg"
|
||||
case .gif: return "gif" // stringlint:disable
|
||||
case .mp4: return "mp4" // stringlint:disable
|
||||
case .jpg: return "jpg" // stringlint:disable
|
||||
}
|
||||
}
|
||||
|
||||
public var utiType: String {
|
||||
switch format {
|
||||
case .gif:
|
||||
return kUTTypeGIF as String
|
||||
case .mp4:
|
||||
return kUTTypeMPEG4 as String
|
||||
case .jpg:
|
||||
return kUTTypeJPEG as String
|
||||
case .gif: return kUTTypeGIF as String
|
||||
case .mp4: return kUTTypeMPEG4 as String
|
||||
case .jpg: return kUTTypeJPEG as String
|
||||
}
|
||||
}
|
||||
|
||||
public var isStill: Bool {
|
||||
return name.hasSuffix("_still")
|
||||
return name.hasSuffix("_still") // stringlint:disable
|
||||
}
|
||||
|
||||
public var isDownsampled: Bool {
|
||||
return name.hasSuffix("_downsampled")
|
||||
return name.hasSuffix("_downsampled") // stringlint:disable
|
||||
}
|
||||
|
||||
public func log() {
|
||||
|
@ -93,7 +84,7 @@ extension GiphyError: LocalizedError {
|
|||
}
|
||||
|
||||
// Represents a single Giphy image.
|
||||
@objc class GiphyImageInfo: NSObject {
|
||||
class GiphyImageInfo: NSObject {
|
||||
let giphyId: String
|
||||
let renditions: [GiphyRendition]
|
||||
// We special-case the "original" rendition because it is the
|
||||
|
@ -267,119 +258,113 @@ extension GiphyError: LocalizedError {
|
|||
}
|
||||
}
|
||||
|
||||
@objc class GiphyAPI: NSObject {
|
||||
enum GiphyAPI {
|
||||
private static let kGiphyBaseURL = "https://api.giphy.com"
|
||||
private static let urlSession: URLSession = {
|
||||
let configuration: URLSessionConfiguration = ContentProxy.sessionConfiguration()
|
||||
|
||||
// Don't use any caching to protect privacy of these requests.
|
||||
configuration.urlCache = nil
|
||||
configuration.requestCachePolicy = .reloadIgnoringCacheData
|
||||
|
||||
return URLSession(configuration: configuration)
|
||||
}()
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
static let sharedInstance = GiphyAPI()
|
||||
|
||||
// Force usage as a singleton
|
||||
override private init() {
|
||||
super.init()
|
||||
|
||||
SwiftSingletons.register(self)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
private let kGiphyBaseURL = "https://api.giphy.com/"
|
||||
|
||||
private func giphyAPISessionManager() -> AFHTTPSessionManager? {
|
||||
return AFHTTPSessionManager(baseURL: URL(string: kGiphyBaseURL), sessionConfiguration: .ephemeral)
|
||||
}
|
||||
|
||||
// MARK: Search
|
||||
// This is the Signal iOS API key.
|
||||
let kGiphyApiKey = "ZsUpUm2L6cVbvei347EQNp7HrROjbOdc"
|
||||
let kGiphyPageSize = 20
|
||||
// MARK: - Search
|
||||
|
||||
public func trending() -> Promise<[GiphyImageInfo]> {
|
||||
guard let sessionManager = giphyAPISessionManager() else {
|
||||
Logger.error("Couldn't create session manager.")
|
||||
return Promise.value([])
|
||||
// This is the Signal iOS API key.
|
||||
private static let kGiphyApiKey = "ZsUpUm2L6cVbvei347EQNp7HrROjbOdc" // stringlint:disable
|
||||
private static let kGiphyPageSize = 20
|
||||
|
||||
public static func trending() -> AnyPublisher<[GiphyImageInfo], Error> {
|
||||
let urlString = "/v1/gifs/trending?api_key=\(kGiphyApiKey)&limit=\(kGiphyPageSize)" // stringlint:disable
|
||||
|
||||
guard let url: URL = URL(string: "\(kGiphyBaseURL)\(urlString)") else {
|
||||
return Fail(error: HTTPError.invalidURL)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
let urlString = "/v1/gifs/trending?api_key=\(kGiphyApiKey)&limit=\(kGiphyPageSize)"
|
||||
let (promise, resolver) = Promise<[GiphyImageInfo]>.pending()
|
||||
sessionManager.get(urlString,
|
||||
parameters: [String: AnyObject](),
|
||||
headers:nil,
|
||||
progress: nil,
|
||||
success: { _, value in
|
||||
Logger.error("search request succeeded")
|
||||
if let imageInfos = self.parseGiphyImages(responseJson: value) {
|
||||
resolver.fulfill(imageInfos)
|
||||
} else {
|
||||
Logger.error("unable to parse trending images")
|
||||
resolver.fulfill([])
|
||||
}
|
||||
|
||||
},
|
||||
failure: { _, error in
|
||||
Logger.error("search request failed: \(error)")
|
||||
resolver.reject(error)
|
||||
})
|
||||
|
||||
return promise
|
||||
|
||||
return urlSession
|
||||
.dataTaskPublisher(for: url)
|
||||
.mapError { urlError in
|
||||
Logger.error("search request failed: \(urlError)")
|
||||
|
||||
// URLError codes are negative values
|
||||
return HTTPError.generic
|
||||
}
|
||||
.map { data, _ in
|
||||
Logger.debug("search request succeeded")
|
||||
|
||||
guard let imageInfos = self.parseGiphyImages(responseData: data) else {
|
||||
Logger.error("unable to parse trending images")
|
||||
return []
|
||||
}
|
||||
|
||||
return imageInfos
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
public func search(query: String, success: @escaping (([GiphyImageInfo]) -> Void), failure: @escaping ((NSError?) -> Void)) {
|
||||
guard let sessionManager = giphyAPISessionManager() else {
|
||||
Logger.error("Couldn't create session manager.")
|
||||
failure(nil)
|
||||
return
|
||||
}
|
||||
guard NSURL(string: kGiphyBaseURL) != nil else {
|
||||
Logger.error("Invalid base URL.")
|
||||
failure(nil)
|
||||
return
|
||||
}
|
||||
|
||||
public static func search(query: String) -> AnyPublisher<[GiphyImageInfo], Error> {
|
||||
let kGiphyPageOffset = 0
|
||||
guard let queryEncoded = query.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
||||
Logger.error("Could not URL encode query: \(query).")
|
||||
failure(nil)
|
||||
return
|
||||
|
||||
guard
|
||||
let queryEncoded = query.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
|
||||
let url: URL = URL(
|
||||
string: [
|
||||
kGiphyBaseURL,
|
||||
"/v1/gifs/search?api_key=\(kGiphyApiKey)", // stringlint:disable
|
||||
"&offset=\(kGiphyPageOffset)", // stringlint:disable
|
||||
"&limit=\(kGiphyPageSize)", // stringlint:disable
|
||||
"&q=\(queryEncoded)" // stringlint:disable
|
||||
].joined()
|
||||
)
|
||||
else {
|
||||
return Fail(error: HTTPError.invalidURL)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
let urlString = "/v1/gifs/search?api_key=\(kGiphyApiKey)&offset=\(kGiphyPageOffset)&limit=\(kGiphyPageSize)&q=\(queryEncoded)"
|
||||
|
||||
guard ContentProxy.configureSessionManager(sessionManager: sessionManager, forUrl: urlString) else {
|
||||
|
||||
var request: URLRequest = URLRequest(url: url)
|
||||
|
||||
guard ContentProxy.configureProxiedRequest(request: &request) else {
|
||||
owsFailDebug("Could not configure query: \(query).")
|
||||
failure(nil)
|
||||
return
|
||||
return Fail(error: HTTPError.generic)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
sessionManager.get(urlString,
|
||||
parameters: [String: AnyObject](),
|
||||
headers: nil,
|
||||
progress: nil,
|
||||
success: { _, value in
|
||||
Logger.error("search request succeeded")
|
||||
guard let imageInfos = self.parseGiphyImages(responseJson: value) else {
|
||||
failure(nil)
|
||||
return
|
||||
}
|
||||
success(imageInfos)
|
||||
},
|
||||
failure: { _, error in
|
||||
Logger.error("search request failed: \(error)")
|
||||
failure(error as NSError)
|
||||
})
|
||||
|
||||
return urlSession
|
||||
.dataTaskPublisher(for: request)
|
||||
.mapError { urlError in
|
||||
Logger.error("search request failed: \(urlError)")
|
||||
|
||||
// URLError codes are negative values
|
||||
return HTTPError.generic
|
||||
}
|
||||
.tryMap { data, _ -> [GiphyImageInfo] in
|
||||
Logger.debug("search request succeeded")
|
||||
|
||||
guard let imageInfos = self.parseGiphyImages(responseData: data) else {
|
||||
throw HTTPError.invalidResponse
|
||||
}
|
||||
|
||||
return imageInfos
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// MARK: Parse API Responses
|
||||
// MARK: - Parse API Responses
|
||||
|
||||
private func parseGiphyImages(responseJson: Any?) -> [GiphyImageInfo]? {
|
||||
guard let responseJson = responseJson else {
|
||||
private static func parseGiphyImages(responseData: Data?) -> [GiphyImageInfo]? {
|
||||
guard let responseData: Data = responseData else {
|
||||
Logger.error("Missing response.")
|
||||
return nil
|
||||
}
|
||||
guard let responseDict = responseJson as? [String: Any] else {
|
||||
guard let responseDict: [String: Any] = try? JSONSerialization
|
||||
.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? [String: Any] else {
|
||||
Logger.error("Invalid response.")
|
||||
return nil
|
||||
}
|
||||
guard let imageDicts = responseDict["data"] as? [[String: Any]] else {
|
||||
guard let imageDicts = responseDict["data"] as? [[String: Any]] else { // stringlint:disable
|
||||
Logger.error("Invalid response data.")
|
||||
return nil
|
||||
}
|
||||
|
@ -389,8 +374,8 @@ extension GiphyError: LocalizedError {
|
|||
}
|
||||
|
||||
// Giphy API results are often incomplete or malformed, so we need to be defensive.
|
||||
private func parseGiphyImage(imageDict: [String: Any]) -> GiphyImageInfo? {
|
||||
guard let giphyId = imageDict["id"] as? String else {
|
||||
private static func parseGiphyImage(imageDict: [String: Any]) -> GiphyImageInfo? {
|
||||
guard let giphyId = imageDict["id"] as? String else { // stringlint:disable
|
||||
Logger.warn("Image dict missing id.")
|
||||
return nil
|
||||
}
|
||||
|
@ -398,7 +383,7 @@ extension GiphyError: LocalizedError {
|
|||
Logger.warn("Image dict has invalid id.")
|
||||
return nil
|
||||
}
|
||||
guard let renditionDicts = imageDict["images"] as? [String: Any] else {
|
||||
guard let renditionDicts = imageDict["images"] as? [String: Any] else { // stringlint:disable
|
||||
Logger.warn("Image dict missing renditions.")
|
||||
return nil
|
||||
}
|
||||
|
@ -424,13 +409,15 @@ extension GiphyError: LocalizedError {
|
|||
return nil
|
||||
}
|
||||
|
||||
return GiphyImageInfo(giphyId: giphyId,
|
||||
renditions: renditions,
|
||||
originalRendition: originalRendition)
|
||||
return GiphyImageInfo(
|
||||
giphyId: giphyId,
|
||||
renditions: renditions,
|
||||
originalRendition: originalRendition
|
||||
)
|
||||
}
|
||||
|
||||
private func findOriginalRendition(renditions: [GiphyRendition]) -> GiphyRendition? {
|
||||
for rendition in renditions where rendition.name == "original" {
|
||||
private static func findOriginalRendition(renditions: [GiphyRendition]) -> GiphyRendition? {
|
||||
for rendition in renditions where rendition.name == "original" { // stringlint:disable
|
||||
return rendition
|
||||
}
|
||||
return nil
|
||||
|
@ -439,17 +426,19 @@ extension GiphyError: LocalizedError {
|
|||
// Giphy API results are often incomplete or malformed, so we need to be defensive.
|
||||
//
|
||||
// We should discard renditions which are missing or have invalid properties.
|
||||
private func parseGiphyRendition(renditionName: String,
|
||||
renditionDict: [String: Any]) -> GiphyRendition? {
|
||||
guard let width = parsePositiveUInt(dict: renditionDict, key: "width", typeName: "rendition") else {
|
||||
private static func parseGiphyRendition(
|
||||
renditionName: String,
|
||||
renditionDict: [String: Any]
|
||||
) -> GiphyRendition? {
|
||||
guard let width = parsePositiveUInt(dict: renditionDict, key: "width", typeName: "rendition") else { // stringlint:disable
|
||||
return nil
|
||||
}
|
||||
guard let height = parsePositiveUInt(dict: renditionDict, key: "height", typeName: "rendition") else {
|
||||
guard let height = parsePositiveUInt(dict: renditionDict, key: "height", typeName: "rendition") else { // stringlint:disable
|
||||
return nil
|
||||
}
|
||||
// Be lenient when parsing file sizes - we don't require them for stills.
|
||||
let fileSize = parseLenientUInt(dict: renditionDict, key: "size")
|
||||
guard let urlString = renditionDict["url"] as? String else {
|
||||
let fileSize = parseLenientUInt(dict: renditionDict, key: "size") // stringlint:disable
|
||||
guard let urlString = renditionDict["url"] as? String else { // stringlint:disable
|
||||
return nil
|
||||
}
|
||||
guard urlString.count > 0 else {
|
||||
|
@ -465,13 +454,13 @@ extension GiphyError: LocalizedError {
|
|||
return nil
|
||||
}
|
||||
var format = GiphyFormat.gif
|
||||
if fileExtension == "gif" {
|
||||
if fileExtension == "gif" { // stringlint:disable
|
||||
format = .gif
|
||||
} else if fileExtension == "jpg" {
|
||||
} else if fileExtension == "jpg" { // stringlint:disable
|
||||
format = .jpg
|
||||
} else if fileExtension == "mp4" {
|
||||
} else if fileExtension == "mp4" { // stringlint:disable
|
||||
format = .mp4
|
||||
} else if fileExtension == "webp" {
|
||||
} else if fileExtension == "webp" { // stringlint:disable
|
||||
return nil
|
||||
} else {
|
||||
Logger.warn("Invalid file extension: \(fileExtension).")
|
||||
|
@ -488,7 +477,7 @@ extension GiphyError: LocalizedError {
|
|||
)
|
||||
}
|
||||
|
||||
private func parsePositiveUInt(dict: [String: Any], key: String, typeName: String) -> UInt? {
|
||||
private static func parsePositiveUInt(dict: [String: Any], key: String, typeName: String) -> UInt? {
|
||||
guard let value = dict[key] else {
|
||||
return nil
|
||||
}
|
||||
|
@ -505,7 +494,7 @@ extension GiphyError: LocalizedError {
|
|||
return parsedValue
|
||||
}
|
||||
|
||||
private func parseLenientUInt(dict: [String: Any], key: String) -> UInt {
|
||||
private static func parseLenientUInt(dict: [String: Any], key: String) -> UInt {
|
||||
let defaultValue = UInt(0)
|
||||
|
||||
guard let value = dict[key] else {
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
@objc
|
||||
public class GiphyDownloader: ProxiedContentDownloader {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
@objc
|
||||
public static let giphyDownloader = GiphyDownloader(downloadFolderName: "GIFs")
|
||||
}
|
||||
|
|
|
@ -1,23 +1,25 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import Photos
|
||||
import PromiseKit
|
||||
import PhotosUI
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
protocol ImagePickerGridControllerDelegate: AnyObject {
|
||||
func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController)
|
||||
func imagePickerDidCancel(_ imagePicker: ImagePickerGridController)
|
||||
|
||||
func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool
|
||||
func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPromise: Promise<SignalAttachment>)
|
||||
func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPublisher: AnyPublisher<SignalAttachment, Error>)
|
||||
func imagePicker(_ imagePicker: ImagePickerGridController, didDeselectAsset asset: PHAsset)
|
||||
|
||||
var isInBatchSelectMode: Bool { get }
|
||||
func imagePickerCanSelectAdditionalItems(_ imagePicker: ImagePickerGridController) -> Bool
|
||||
func imagePicker(_ imagePicker: ImagePickerGridController, failedToRetrieveAssetAt index: Int, forCount count: Int)
|
||||
}
|
||||
|
||||
class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegate {
|
||||
|
@ -128,31 +130,35 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
}
|
||||
|
||||
switch selectionPanGesture.state {
|
||||
case .possible:
|
||||
break
|
||||
case .began:
|
||||
collectionView.isUserInteractionEnabled = false
|
||||
collectionView.isScrollEnabled = false
|
||||
case .possible: break
|
||||
case .began:
|
||||
collectionView.isUserInteractionEnabled = false
|
||||
collectionView.isScrollEnabled = false
|
||||
|
||||
let location = selectionPanGesture.location(in: collectionView)
|
||||
guard let indexPath = collectionView.indexPathForItem(at: location) else {
|
||||
return
|
||||
}
|
||||
let asset = photoCollectionContents.asset(at: indexPath.item)
|
||||
if delegate.imagePicker(self, isAssetSelected: asset) {
|
||||
selectionPanGestureMode = .deselect
|
||||
} else {
|
||||
selectionPanGestureMode = .select
|
||||
}
|
||||
case .changed:
|
||||
let location = selectionPanGesture.location(in: collectionView)
|
||||
guard let indexPath = collectionView.indexPathForItem(at: location) else {
|
||||
return
|
||||
}
|
||||
tryToToggleBatchSelect(at: indexPath)
|
||||
case .cancelled, .ended, .failed:
|
||||
collectionView.isUserInteractionEnabled = true
|
||||
collectionView.isScrollEnabled = true
|
||||
let location = selectionPanGesture.location(in: collectionView)
|
||||
guard
|
||||
let indexPath: IndexPath = collectionView.indexPathForItem(at: location),
|
||||
let asset: PHAsset = photoCollectionContents.asset(at: indexPath.item)
|
||||
else { return }
|
||||
|
||||
if delegate.imagePicker(self, isAssetSelected: asset) {
|
||||
selectionPanGestureMode = .deselect
|
||||
}
|
||||
else {
|
||||
selectionPanGestureMode = .select
|
||||
}
|
||||
|
||||
case .changed:
|
||||
let location = selectionPanGesture.location(in: collectionView)
|
||||
guard let indexPath = collectionView.indexPathForItem(at: location) else { return }
|
||||
|
||||
tryToToggleBatchSelect(at: indexPath)
|
||||
|
||||
case .cancelled, .ended, .failed:
|
||||
collectionView.isUserInteractionEnabled = true
|
||||
collectionView.isScrollEnabled = true
|
||||
|
||||
@unknown default: break
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -172,7 +178,8 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
return
|
||||
}
|
||||
|
||||
let asset = photoCollectionContents.asset(at: indexPath.item)
|
||||
guard let asset: PHAsset = photoCollectionContents.asset(at: indexPath.item) else { return }
|
||||
|
||||
switch selectionPanGestureMode {
|
||||
case .select:
|
||||
guard delegate.imagePickerCanSelectAdditionalItems(self) else {
|
||||
|
@ -180,8 +187,11 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
return
|
||||
}
|
||||
|
||||
let attachmentPromise: Promise<SignalAttachment> = photoCollectionContents.outgoingAttachment(for: asset)
|
||||
delegate.imagePicker(self, didSelectAsset: asset, attachmentPromise: attachmentPromise)
|
||||
delegate.imagePicker(
|
||||
self,
|
||||
didSelectAsset: asset,
|
||||
attachmentPublisher: photoCollectionContents.outgoingAttachment(for: asset)
|
||||
)
|
||||
collectionView.selectItem(at: indexPath, animated: true, scrollPosition: [])
|
||||
case .deselect:
|
||||
delegate.imagePicker(self, didDeselectAsset: asset)
|
||||
|
@ -201,8 +211,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
let scale = UIScreen.main.scale
|
||||
let cellSize = collectionViewFlowLayout.itemSize
|
||||
photoMediaSize.thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale)
|
||||
|
||||
reloadDataAndRestoreSelection()
|
||||
|
||||
if !hasEverAppeared {
|
||||
scrollToBottom(animated: false)
|
||||
}
|
||||
|
@ -289,30 +298,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
}
|
||||
}
|
||||
|
||||
private func reloadDataAndRestoreSelection() {
|
||||
guard let collectionView = collectionView else {
|
||||
owsFailDebug("Missing collectionView.")
|
||||
return
|
||||
}
|
||||
|
||||
guard let delegate = delegate else {
|
||||
owsFailDebug("delegate was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
collectionView.reloadData()
|
||||
collectionView.layoutIfNeeded()
|
||||
|
||||
let count = photoCollectionContents.assetCount
|
||||
for index in 0..<count {
|
||||
let asset = photoCollectionContents.asset(at: index)
|
||||
if delegate.imagePicker(self, isAssetSelected: asset) {
|
||||
collectionView.selectItem(at: IndexPath(row: index, section: 0),
|
||||
animated: false, scrollPosition: [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc
|
||||
|
@ -363,7 +348,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
}
|
||||
|
||||
collectionView.allowsMultipleSelection = delegate.isInBatchSelectMode
|
||||
reloadDataAndRestoreSelection()
|
||||
}
|
||||
|
||||
func clearCollectionViewSelection() {
|
||||
|
@ -400,7 +384,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
|
||||
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
||||
photoCollectionContents = photoCollection.contents()
|
||||
reloadDataAndRestoreSelection()
|
||||
collectionView?.reloadData()
|
||||
}
|
||||
|
||||
// MARK: - PhotoCollectionPicker Presentation
|
||||
|
@ -447,11 +431,11 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
|
||||
// Initially position offscreen, we'll animate it in.
|
||||
collectionPickerView.frame = collectionPickerView.frame.offsetBy(dx: 0, dy: collectionPickerView.frame.height)
|
||||
|
||||
UIView.animate(.promise, duration: 0.25, delay: 0, options: .curveEaseInOut) {
|
||||
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
collectionPickerView.superview?.layoutIfNeeded()
|
||||
self.titleView.rotateIcon(.up)
|
||||
}.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
func hideCollectionPicker() {
|
||||
|
@ -459,14 +443,18 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
|
||||
assert(isShowingCollectionPickerController)
|
||||
isShowingCollectionPickerController = false
|
||||
|
||||
UIView.animate(.promise, duration: 0.25, delay: 0, options: .curveEaseInOut) {
|
||||
self.collectionPickerController.view.frame = self.view.frame.offsetBy(dx: 0, dy: self.view.frame.height)
|
||||
self.titleView.rotateIcon(.down)
|
||||
}.done { _ in
|
||||
self.collectionPickerController.view.removeFromSuperview()
|
||||
self.collectionPickerController.removeFromParent()
|
||||
}.retainUntilComplete()
|
||||
|
||||
UIView.animate(
|
||||
withDuration: 0.25,
|
||||
animations: {
|
||||
self.collectionPickerController.view.frame = self.view.frame.offsetBy(dx: 0, dy: self.view.frame.height)
|
||||
self.titleView.rotateIcon(.down)
|
||||
},
|
||||
completion: { [weak self] _ in
|
||||
self?.collectionPickerController.view.removeFromSuperview()
|
||||
self?.collectionPickerController.removeFromParent()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - UICollectionView
|
||||
|
@ -490,9 +478,17 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
return
|
||||
}
|
||||
|
||||
let asset: PHAsset = photoCollectionContents.asset(at: indexPath.item)
|
||||
let attachmentPromise: Promise<SignalAttachment> = photoCollectionContents.outgoingAttachment(for: asset)
|
||||
delegate.imagePicker(self, didSelectAsset: asset, attachmentPromise: attachmentPromise)
|
||||
guard let asset: PHAsset = photoCollectionContents.asset(at: indexPath.item) else {
|
||||
SNLog("Failed to select cell for asset at \(indexPath.item)")
|
||||
delegate.imagePicker(self, failedToRetrieveAssetAt: indexPath.item, forCount: photoCollectionContents.assetCount)
|
||||
return
|
||||
}
|
||||
|
||||
delegate.imagePicker(
|
||||
self,
|
||||
didSelectAsset: asset,
|
||||
attachmentPublisher: photoCollectionContents.outgoingAttachment(for: asset)
|
||||
)
|
||||
|
||||
if !delegate.isInBatchSelectMode {
|
||||
// Don't show "selected" badge unless we're in batch mode
|
||||
|
@ -508,7 +504,12 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
return
|
||||
}
|
||||
|
||||
let asset = photoCollectionContents.asset(at: indexPath.item)
|
||||
guard let asset: PHAsset = photoCollectionContents.asset(at: indexPath.item) else {
|
||||
SNLog("Failed to deselect cell for asset at \(indexPath.item)")
|
||||
delegate.imagePicker(self, failedToRetrieveAssetAt: indexPath.item, forCount: photoCollectionContents.assetCount)
|
||||
return
|
||||
}
|
||||
|
||||
delegate.imagePicker(self, didDeselectAsset: asset)
|
||||
}
|
||||
|
||||
|
@ -522,9 +523,15 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
}
|
||||
|
||||
let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath)
|
||||
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
|
||||
|
||||
guard let assetItem: PhotoPickerAssetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize) else {
|
||||
SNLog("Failed to style cell for asset at \(indexPath.item)")
|
||||
return cell
|
||||
}
|
||||
|
||||
cell.configure(item: assetItem)
|
||||
|
||||
cell.isAccessibilityElement = true
|
||||
cell.accessibilityIdentifier = "\(assetItem.asset.modificationDate.map { "\($0)" } ?? "Unknown Date")"
|
||||
cell.isSelected = delegate.imagePicker(self, isAssetSelected: assetItem.asset)
|
||||
|
||||
return cell
|
||||
|
|
|
@ -1,17 +1,21 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import AVKit
|
||||
import AVFoundation
|
||||
import YYImage
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public enum MediaGalleryOption {
|
||||
case sliderEnabled
|
||||
case showAllMediaButton
|
||||
}
|
||||
|
||||
class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVideoPlayerDelegate, PlayerProgressBarDelegate {
|
||||
class MediaDetailViewController: OWSViewController, UIScrollViewDelegate {
|
||||
public let galleryItem: MediaGalleryViewModel.Item
|
||||
public weak var delegate: MediaDetailViewControllerDelegate?
|
||||
private var image: UIImage?
|
||||
|
@ -35,9 +39,19 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
|
|||
}()
|
||||
|
||||
public var mediaView: UIView = UIView()
|
||||
private var playVideoButton: UIButton = UIButton()
|
||||
private var videoProgressBar: PlayerProgressBar = PlayerProgressBar()
|
||||
private var videoPlayer: OWSVideoPlayer?
|
||||
private lazy var playVideoButton: UIButton = {
|
||||
let result: UIButton = UIButton()
|
||||
result.contentMode = .scaleAspectFill
|
||||
result.setBackgroundImage(UIImage(named: "CirclePlay"), for: .normal)
|
||||
result.addTarget(self, action: #selector(playVideo), for: .touchUpInside)
|
||||
result.alpha = 0
|
||||
|
||||
let playButtonSize: CGFloat = ScaleFromIPhone5(70)
|
||||
result.set(.width, to: playButtonSize)
|
||||
result.set(.height, to: playButtonSize)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
|
@ -84,10 +98,6 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
|
|||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.stopAnyVideo()
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
|
@ -96,7 +106,10 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
|
|||
self.view.themeBackgroundColor = .newConversation_background
|
||||
|
||||
self.view.addSubview(scrollView)
|
||||
self.view.addSubview(playVideoButton)
|
||||
|
||||
scrollView.pin(to: self.view)
|
||||
playVideoButton.center(in: self.view)
|
||||
|
||||
self.updateContents()
|
||||
}
|
||||
|
@ -110,12 +123,18 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
|
|||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
if self.parent == nil || !(self.parent is MediaPageViewController) {
|
||||
parentDidAppear()
|
||||
}
|
||||
}
|
||||
|
||||
public func parentDidAppear() {
|
||||
if mediaView is YYAnimatedImageView {
|
||||
// Add a slight delay before starting the gif animation to prevent it from looking
|
||||
// buggy due to the custom transition
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { [weak self] in
|
||||
(self?.mediaView as? YYAnimatedImageView)?.startAnimating()
|
||||
}
|
||||
(mediaView as? YYAnimatedImageView)?.startAnimating()
|
||||
}
|
||||
|
||||
if self.galleryItem.attachment.isVideo {
|
||||
UIView.animate(withDuration: 0.2) { self.playVideoButton.alpha = 1 }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -126,6 +145,12 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
|
|||
self.centerMediaViewConstraints()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
UIView.animate(withDuration: 0.15) { [weak playVideoButton] in playVideoButton?.alpha = 0 }
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
private func updateMinZoomScale() {
|
||||
|
@ -172,8 +197,6 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
|
|||
|
||||
private func updateContents() {
|
||||
self.mediaView.removeFromSuperview()
|
||||
self.playVideoButton.removeFromSuperview()
|
||||
self.videoProgressBar.removeFromSuperview()
|
||||
self.scrollView.zoomScale = 1
|
||||
|
||||
if self.galleryItem.attachment.isAnimated {
|
||||
|
@ -193,15 +216,6 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
|
|||
self.mediaView = UIView()
|
||||
self.mediaView.themeBackgroundColor = .newConversation_background
|
||||
}
|
||||
else if self.galleryItem.attachment.isVideo {
|
||||
if self.galleryItem.attachment.isValid {
|
||||
self.mediaView = self.buildVideoPlayerView()
|
||||
}
|
||||
else {
|
||||
self.mediaView = UIView()
|
||||
self.mediaView.themeBackgroundColor = .newConversation_background
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Present the static image using standard UIImageView
|
||||
self.mediaView = UIImageView(image: self.image)
|
||||
|
@ -228,61 +242,6 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
|
|||
// some performance cost.
|
||||
self.mediaView.layer.minificationFilter = .trilinear
|
||||
self.mediaView.layer.magnificationFilter = .trilinear
|
||||
|
||||
if self.galleryItem.attachment.isVideo {
|
||||
self.videoProgressBar = PlayerProgressBar()
|
||||
self.videoProgressBar.delegate = self
|
||||
self.videoProgressBar.player = self.videoPlayer?.avPlayer
|
||||
|
||||
// We hide the progress bar until either:
|
||||
// 1. Video completes playing
|
||||
// 2. User taps the screen
|
||||
self.videoProgressBar.isHidden = false
|
||||
|
||||
self.view.addSubview(self.videoProgressBar)
|
||||
|
||||
self.videoProgressBar.autoPinWidthToSuperview()
|
||||
self.videoProgressBar.autoPinEdge(toSuperviewSafeArea: .top)
|
||||
self.videoProgressBar.autoSetDimension(.height, toSize: 44)
|
||||
|
||||
self.playVideoButton = UIButton()
|
||||
self.playVideoButton.contentMode = .scaleAspectFill
|
||||
self.playVideoButton.setBackgroundImage(UIImage(named: "CirclePlay"), for: .normal)
|
||||
self.playVideoButton.addTarget(self, action: #selector(playVideo), for: .touchUpInside)
|
||||
self.view.addSubview(self.playVideoButton)
|
||||
|
||||
self.playVideoButton.set(.width, to: 72)
|
||||
self.playVideoButton.set(.height, to: 72)
|
||||
self.playVideoButton.center(in: self.view)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildVideoPlayerView() -> UIView {
|
||||
guard
|
||||
let originalFilePath: String = self.galleryItem.attachment.originalFilePath,
|
||||
FileManager.default.fileExists(atPath: originalFilePath)
|
||||
else {
|
||||
owsFailDebug("Missing video file")
|
||||
return UIView()
|
||||
}
|
||||
|
||||
self.videoPlayer = OWSVideoPlayer(url: URL(fileURLWithPath: originalFilePath))
|
||||
self.videoPlayer?.seek(to: .zero)
|
||||
self.videoPlayer?.delegate = self
|
||||
|
||||
let imageSize: CGSize = (self.image?.size ?? .zero)
|
||||
let playerView: VideoPlayerView = VideoPlayerView()
|
||||
playerView.player = self.videoPlayer?.avPlayer
|
||||
|
||||
NSLayoutConstraint.autoSetPriority(.defaultLow) {
|
||||
playerView.autoSetDimensions(to: imageSize)
|
||||
}
|
||||
|
||||
return playerView
|
||||
}
|
||||
|
||||
public func setShouldHideToolbars(_ shouldHideToolbars: Bool) {
|
||||
self.videoProgressBar.isHidden = shouldHideToolbars
|
||||
}
|
||||
|
||||
private func addGestureRecognizers(to view: UIView) {
|
||||
|
@ -328,14 +287,10 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
|
|||
self.scrollView.zoom(to: translatedRect, animated: true)
|
||||
}
|
||||
|
||||
@objc public func didPressPlayBarButton() {
|
||||
public func didPressPlayBarButton() {
|
||||
self.playVideo()
|
||||
}
|
||||
|
||||
@objc public func didPressPauseBarButton() {
|
||||
self.pauseVideo()
|
||||
}
|
||||
|
||||
// MARK: - UIScrollViewDelegate
|
||||
|
||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
|
@ -389,49 +344,17 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
|
|||
// MARK: - Video Playback
|
||||
|
||||
@objc public func playVideo() {
|
||||
self.playVideoButton.isHidden = true
|
||||
self.videoPlayer?.play()
|
||||
self.delegate?.mediaDetailViewController(self, isPlayingVideo: true)
|
||||
}
|
||||
|
||||
private func pauseVideo() {
|
||||
self.videoPlayer?.pause()
|
||||
self.delegate?.mediaDetailViewController(self, isPlayingVideo: false)
|
||||
}
|
||||
|
||||
public func stopAnyVideo() {
|
||||
guard self.galleryItem.attachment.isVideo else { return }
|
||||
guard
|
||||
let originalFilePath: String = self.galleryItem.attachment.originalFilePath,
|
||||
FileManager.default.fileExists(atPath: originalFilePath)
|
||||
else { return SNLog("Missing video file") }
|
||||
|
||||
self.stopVideo()
|
||||
}
|
||||
|
||||
private func stopVideo() {
|
||||
self.videoPlayer?.stop()
|
||||
self.playVideoButton.isHidden = false
|
||||
self.delegate?.mediaDetailViewController(self, isPlayingVideo: false)
|
||||
}
|
||||
|
||||
// MARK: - OWSVideoPlayerDelegate
|
||||
|
||||
func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) {
|
||||
self.stopVideo()
|
||||
}
|
||||
|
||||
// MARK: - PlayerProgressBarDelegate
|
||||
|
||||
func playerProgressBarDidStartScrubbing(_ playerProgressBar: PlayerProgressBar) {
|
||||
self.videoPlayer?.pause()
|
||||
}
|
||||
|
||||
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, scrubbedToTime time: CMTime) {
|
||||
self.videoPlayer?.seek(to: time)
|
||||
}
|
||||
|
||||
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, didFinishScrubbingAtTime time: CMTime, shouldResumePlayback: Bool) {
|
||||
self.videoPlayer?.seek(to: time)
|
||||
|
||||
if shouldResumePlayback {
|
||||
self.videoPlayer?.play()
|
||||
let videoUrl: URL = URL(fileURLWithPath: originalFilePath)
|
||||
let player: AVPlayer = AVPlayer(url: videoUrl)
|
||||
let viewController: AVPlayerViewController = AVPlayerViewController()
|
||||
viewController.player = player
|
||||
self.present(viewController, animated: true) { [weak player] in
|
||||
player?.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -439,6 +362,5 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid
|
|||
// MARK: - MediaDetailViewControllerDelegate
|
||||
|
||||
protocol MediaDetailViewControllerDelegate: AnyObject {
|
||||
func mediaDetailViewController(_ mediaDetailViewController: MediaDetailViewController, isPlayingVideo: Bool)
|
||||
func mediaDetailViewControllerDidTapMedia(_ mediaDetailViewController: MediaDetailViewController)
|
||||
}
|
||||
|
|
|
@ -44,6 +44,10 @@ class MediaGalleryNavigationController: UINavigationController {
|
|||
// MARK: - Orientation
|
||||
|
||||
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
if UIDevice.current.isIPad {
|
||||
return .all
|
||||
}
|
||||
|
||||
return .allButUpsideDown
|
||||
}
|
||||
|
||||
|
|
|
@ -48,8 +48,14 @@ public class MediaGalleryViewModel {
|
|||
didSet {
|
||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||
// data was changed while we weren't observing
|
||||
if let unobservedGalleryDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedGalleryDataChanges {
|
||||
onGalleryChange?(unobservedGalleryDataChanges.0, unobservedGalleryDataChanges.1)
|
||||
if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedGalleryDataChanges {
|
||||
let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onGalleryChange
|
||||
|
||||
switch Thread.isMainThread {
|
||||
case true: performChange?(changes.0, changes.1)
|
||||
case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) }
|
||||
}
|
||||
|
||||
self.unobservedGalleryDataChanges = nil
|
||||
}
|
||||
}
|
||||
|
@ -98,7 +104,10 @@ public class MediaGalleryViewModel {
|
|||
currentDataRetriever: { self?.galleryData },
|
||||
onDataChange: self?.onGalleryChange,
|
||||
onUnobservedDataChange: { updatedData, changeset in
|
||||
self?.unobservedGalleryDataChanges = (updatedData, changeset)
|
||||
self?.unobservedGalleryDataChanges = (changeset.isEmpty ?
|
||||
nil :
|
||||
(updatedData, changeset)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -136,7 +145,7 @@ public class MediaGalleryViewModel {
|
|||
public struct GalleryDate: Differentiable, Equatable, Comparable, Hashable {
|
||||
private static let thisYearFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM"
|
||||
formatter.dateFormat = "MMMM" // stringlint:disable
|
||||
|
||||
return formatter
|
||||
}()
|
||||
|
@ -144,7 +153,7 @@ public class MediaGalleryViewModel {
|
|||
private static let olderFormatter: DateFormatter = {
|
||||
// FIXME: localize for RTL, or is there a built in way to do this?
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM yyyy"
|
||||
formatter.dateFormat = "MMMM yyyy" // stringlint:disable
|
||||
|
||||
return formatter
|
||||
}()
|
||||
|
@ -190,16 +199,18 @@ public class MediaGalleryViewModel {
|
|||
}
|
||||
}
|
||||
|
||||
public struct Item: FetchableRecordWithRowId, Decodable, Identifiable, Differentiable, Equatable, Hashable {
|
||||
fileprivate static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue)
|
||||
fileprivate static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue)
|
||||
fileprivate static let interactionAuthorIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionAuthorId.stringValue)
|
||||
fileprivate static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue)
|
||||
fileprivate static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue)
|
||||
fileprivate static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue)
|
||||
fileprivate static let attachmentAlbumIndexKey: SQL = SQL(stringLiteral: CodingKeys.attachmentAlbumIndex.stringValue)
|
||||
|
||||
fileprivate static let attachmentString: String = CodingKeys.attachment.stringValue
|
||||
public struct Item: FetchableRecordWithRowId, Decodable, Identifiable, Differentiable, Equatable, Hashable, ColumnExpressible {
|
||||
public typealias Columns = CodingKeys
|
||||
public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
|
||||
case interactionId
|
||||
case interactionVariant
|
||||
case interactionAuthorId
|
||||
case interactionTimestampMs
|
||||
|
||||
case rowId
|
||||
case attachmentAlbumIndex
|
||||
case attachment
|
||||
}
|
||||
|
||||
public var id: String { attachment.id }
|
||||
public var differenceIdentifier: String { attachment.id }
|
||||
|
@ -297,7 +308,7 @@ public class MediaGalleryViewModel {
|
|||
let finalFilterSQL: SQL = {
|
||||
guard let customFilters: SQL = customFilters else {
|
||||
return """
|
||||
WHERE \(attachment.alias[Column.rowID]) IN \(rowIds)
|
||||
WHERE \(attachment[.rowId]) IN \(rowIds)
|
||||
"""
|
||||
}
|
||||
|
||||
|
@ -309,14 +320,14 @@ public class MediaGalleryViewModel {
|
|||
}()
|
||||
let request: SQLRequest<Item> = """
|
||||
SELECT
|
||||
\(interaction[.id]) AS \(Item.interactionIdKey),
|
||||
\(interaction[.variant]) AS \(Item.interactionVariantKey),
|
||||
\(interaction[.authorId]) AS \(Item.interactionAuthorIdKey),
|
||||
\(interaction[.timestampMs]) AS \(Item.interactionTimestampMsKey),
|
||||
\(interaction[.id]) AS \(Item.Columns.interactionId),
|
||||
\(interaction[.variant]) AS \(Item.Columns.interactionVariant),
|
||||
\(interaction[.authorId]) AS \(Item.Columns.interactionAuthorId),
|
||||
\(interaction[.timestampMs]) AS \(Item.Columns.interactionTimestampMs),
|
||||
|
||||
\(attachment.alias[Column.rowID]) AS \(Item.rowIdKey),
|
||||
\(interactionAttachment[.albumIndex]) AS \(Item.attachmentAlbumIndexKey),
|
||||
\(Item.attachmentKey).*
|
||||
\(attachment[.rowId]) AS \(Item.Columns.rowId),
|
||||
\(interactionAttachment[.albumIndex]) AS \(Item.Columns.attachmentAlbumIndex),
|
||||
\(attachment.allColumns)
|
||||
FROM \(Attachment.self)
|
||||
\(joinSQL)
|
||||
\(finalFilterSQL)
|
||||
|
@ -329,8 +340,8 @@ public class MediaGalleryViewModel {
|
|||
Attachment.numberOfSelectedColumns(db)
|
||||
])
|
||||
|
||||
return ScopeAdapter([
|
||||
Item.attachmentString: adapters[1]
|
||||
return ScopeAdapter.with(Item.self, [
|
||||
.attachment: adapters[1]
|
||||
])
|
||||
}
|
||||
}
|
||||
|
@ -357,7 +368,7 @@ public class MediaGalleryViewModel {
|
|||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
||||
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||
public typealias AlbumObservation = ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<[Item]>>>
|
||||
public typealias AlbumObservation = ValueObservation<ValueReducers.Trace<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<[Item]>>>>
|
||||
public lazy var observableAlbumData: AlbumObservation = buildAlbumObservation(for: nil)
|
||||
|
||||
private func buildAlbumObservation(for interactionId: Int64?) -> AlbumObservation {
|
||||
|
@ -380,6 +391,7 @@ public class MediaGalleryViewModel {
|
|||
.fetchAll(db)
|
||||
}
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[MediaGalleryViewModel] Observation failed with error: \($0)") })
|
||||
}
|
||||
|
||||
@discardableResult public func loadAndCacheAlbumData(for interactionId: Int64, in threadId: String) -> [Item] {
|
||||
|
@ -623,27 +635,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
extension MediaInfoVC {
|
||||
final class MediaInfoView: UIView {
|
||||
|
@ -170,6 +172,7 @@ extension MediaInfoVC {
|
|||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
public func update(attachment: Attachment?) {
|
||||
guard let attachment: Attachment = attachment else { return }
|
||||
|
||||
|
@ -177,7 +180,7 @@ extension MediaInfoVC {
|
|||
|
||||
fileIdLabel.text = attachment.serverId
|
||||
fileTypeLabel.text = attachment.contentType
|
||||
fileSizeLabel.text = OWSFormat.formatFileSize(attachment.byteCount)
|
||||
fileSizeLabel.text = Format.fileSize(attachment.byteCount)
|
||||
resolutionLabel.text = {
|
||||
guard let width = attachment.width, let height = attachment.height else { return "N/A" }
|
||||
return "\(width)×\(height)"
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
|
||||
extension MediaInfoVC {
|
||||
final class MediaPreviewView: UIView {
|
||||
|
@ -17,6 +18,7 @@ extension MediaInfoVC {
|
|||
let result: MediaView = MediaView.init(
|
||||
attachment: attachment,
|
||||
isOutgoing: isOutgoing,
|
||||
shouldSupressControls: false,
|
||||
cornerRadius: 0
|
||||
)
|
||||
|
||||
|
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class MediaInfoVC: BaseVC, SessionCarouselViewDelegate {
|
||||
internal static let mediaSize: CGFloat = UIScreen.main.bounds.width - 2 * Values.veryLargeSpacing
|
||||
|
|
|
@ -2,10 +2,12 @@
|
|||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionSnodeKit
|
||||
|
||||
class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, MediaDetailViewControllerDelegate, InteractivelyDismissableViewController {
|
||||
class DynamicallySizedView: UIView {
|
||||
|
@ -15,7 +17,9 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
fileprivate var mediaInteractiveDismiss: MediaInteractiveDismiss?
|
||||
|
||||
public let viewModel: MediaGalleryViewModel
|
||||
private var dataChangeObservable: DatabaseCancellable?
|
||||
private var dataChangeObservable: DatabaseCancellable? {
|
||||
didSet { oldValue?.cancel() } // Cancel the old observable if there was one
|
||||
}
|
||||
private var initialPage: MediaDetailViewController
|
||||
private var cachedPages: [Int64: [MediaGalleryViewModel.Item: MediaDetailViewController]] = [:]
|
||||
|
||||
|
@ -40,14 +44,16 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
)
|
||||
|
||||
// Swap out the database observer
|
||||
dataChangeObservable?.cancel()
|
||||
stopObservingChanges()
|
||||
viewModel.replaceAlbumObservation(toObservationFor: item.interactionId)
|
||||
startObservingChanges()
|
||||
|
||||
updateTitle(item: item)
|
||||
updateCaption(item: item)
|
||||
setViewControllers([galleryPage], direction: direction, animated: isAnimated)
|
||||
updateFooterBarButtonItems(isPlayingVideo: false)
|
||||
setViewControllers([galleryPage], direction: direction, animated: isAnimated) { [weak galleryPage] _ in
|
||||
galleryPage?.parentDidAppear() // Trigger any custom appearance animations
|
||||
}
|
||||
updateFooterBarButtonItems()
|
||||
updateMediaRail(item: item)
|
||||
}
|
||||
|
||||
|
@ -200,7 +206,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
updateTitle(item: currentItem)
|
||||
updateCaption(item: currentItem)
|
||||
updateMediaRail(item: currentItem)
|
||||
updateFooterBarButtonItems(isPlayingVideo: false)
|
||||
updateFooterBarButtonItems()
|
||||
|
||||
// Gestures
|
||||
|
||||
|
@ -233,24 +239,34 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
|
||||
hasAppeared = true
|
||||
becomeFirstResponder()
|
||||
|
||||
children.forEach { child in
|
||||
switch child {
|
||||
case let detailViewController as MediaDetailViewController:
|
||||
detailViewController.parentDidAppear()
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
// Stop observing database changes
|
||||
dataChangeObservable?.cancel()
|
||||
stopObservingChanges()
|
||||
|
||||
resignFirstResponder()
|
||||
}
|
||||
|
||||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||
startObservingChanges()
|
||||
/// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.startObservingChanges()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||
// Stop observing database changes
|
||||
dataChangeObservable?.cancel()
|
||||
stopObservingChanges()
|
||||
}
|
||||
|
||||
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
|
@ -286,7 +302,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
// MARK: View Helpers
|
||||
|
||||
public func willBePresentedAgain() {
|
||||
updateFooterBarButtonItems(isPlayingVideo: false)
|
||||
updateFooterBarButtonItems()
|
||||
}
|
||||
|
||||
public func wasPresented() {
|
||||
|
@ -304,7 +320,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
self.navigationController?.setNavigationBarHidden(shouldHideToolbars, animated: false)
|
||||
|
||||
UIView.animate(withDuration: 0.1) {
|
||||
self.currentViewController.setShouldHideToolbars(self.shouldHideToolbars)
|
||||
self.bottomContainer.isHidden = self.shouldHideToolbars
|
||||
}
|
||||
}
|
||||
|
@ -349,24 +364,12 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
return videoPlayBarButton
|
||||
}()
|
||||
|
||||
lazy var videoPauseBarButton: UIBarButtonItem = {
|
||||
let videoPauseBarButton = UIBarButtonItem(
|
||||
barButtonSystemItem: .pause,
|
||||
target: self,
|
||||
action: #selector(didPressPauseBarButton)
|
||||
)
|
||||
videoPauseBarButton.themeTintColor = .textPrimary
|
||||
|
||||
return videoPauseBarButton
|
||||
}()
|
||||
|
||||
private func updateFooterBarButtonItems(isPlayingVideo: Bool) {
|
||||
private func updateFooterBarButtonItems() {
|
||||
self.footerBar.setItems(
|
||||
[
|
||||
shareBarButton,
|
||||
buildFlexibleSpace(),
|
||||
(self.currentItem.isVideo && isPlayingVideo ? self.videoPauseBarButton : nil),
|
||||
(self.currentItem.isVideo && !isPlayingVideo ? self.videoPlayBarButton : nil),
|
||||
(self.currentItem.isVideo ? self.videoPlayBarButton : nil),
|
||||
(self.currentItem.isVideo ? buildFlexibleSpace() : nil),
|
||||
deleteBarButton
|
||||
].compactMap { $0 },
|
||||
|
@ -385,17 +388,23 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
// MARK: - Updating
|
||||
|
||||
private func startObservingChanges() {
|
||||
guard dataChangeObservable == nil else { return }
|
||||
|
||||
// Start observing for data changes
|
||||
dataChangeObservable = Storage.shared.start(
|
||||
viewModel.observableAlbumData,
|
||||
onError: { _ in },
|
||||
onChange: { [weak self] albumData in
|
||||
// The defaul scheduler emits changes on the main thread
|
||||
// The default scheduler emits changes on the main thread
|
||||
self?.handleUpdates(albumData)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func stopObservingChanges() {
|
||||
dataChangeObservable = nil
|
||||
}
|
||||
|
||||
private func handleUpdates(_ updatedViewData: [MediaGalleryViewModel.Item]) {
|
||||
// Determine if we swapped albums (if so we don't need to do anything else)
|
||||
guard updatedViewData.contains(where: { $0.interactionId == currentItem.interactionId }) else {
|
||||
|
@ -454,12 +463,19 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
// MARK: - Actions
|
||||
|
||||
@objc public func didPressAllMediaButton(sender: Any) {
|
||||
currentViewController.stopAnyVideo()
|
||||
|
||||
// If the screen wasn't presented or it was presented from a location which isn't the
|
||||
// MediaTileViewController then just pop/dismiss the screen
|
||||
let parentNavController: UINavigationController? = {
|
||||
switch self.presentingViewController {
|
||||
case let topBannerController as TopBannerController:
|
||||
return topBannerController.children.first as? UINavigationController
|
||||
|
||||
default: return self.presentingViewController as? UINavigationController
|
||||
}
|
||||
}()
|
||||
|
||||
guard
|
||||
let presentingNavController: UINavigationController = (self.presentingViewController as? UINavigationController),
|
||||
let presentingNavController: UINavigationController = parentNavController,
|
||||
!(presentingNavController.viewControllers.last is AllMediaViewController)
|
||||
else {
|
||||
guard self.navigationController?.viewControllers.count == 1 else {
|
||||
|
@ -496,8 +512,9 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
dismissSelf(animated: true)
|
||||
}
|
||||
|
||||
@objc
|
||||
public func didPressShare(_ sender: Any) {
|
||||
@objc public func didPressShare(_ sender: Any) { share() }
|
||||
|
||||
public func share(using dependencies: Dependencies = Dependencies()) {
|
||||
guard let currentViewController = self.viewControllers?[0] as? MediaDetailViewController else {
|
||||
owsFailDebug("currentViewController was unexpectedly nil")
|
||||
return
|
||||
|
@ -530,11 +547,10 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
self.viewModel.threadVariant == .contact
|
||||
else { return }
|
||||
|
||||
let threadId: String = self.viewModel.threadId
|
||||
let threadVariant: SessionThread.Variant = self.viewModel.threadVariant
|
||||
|
||||
Storage.shared.write { db in
|
||||
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: self.viewModel.threadId) else {
|
||||
return
|
||||
}
|
||||
|
||||
try MessageSender.send(
|
||||
db,
|
||||
message: DataExtractionNotification(
|
||||
|
@ -544,7 +560,9 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs())
|
||||
),
|
||||
interactionId: nil, // Show no interaction for the current user
|
||||
in: thread
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
using: dependencies
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -600,15 +618,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
currentViewController.didPressPlayBarButton()
|
||||
}
|
||||
|
||||
@objc public func didPressPauseBarButton() {
|
||||
guard let currentViewController = self.viewControllers?.first as? MediaDetailViewController else {
|
||||
SNLog("currentViewController was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
currentViewController.didPressPauseBarButton()
|
||||
}
|
||||
|
||||
// MARK: UIPageViewControllerDelegate
|
||||
|
||||
var pendingViewController: MediaDetailViewController?
|
||||
|
@ -628,9 +637,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
} else {
|
||||
self.captionContainerView.pendingText = nil
|
||||
}
|
||||
|
||||
// Ensure upcoming page respects current toolbar status
|
||||
pendingViewController.setShouldHideToolbars(self.shouldHideToolbars)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -654,11 +660,11 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
captionContainerView.completePagerTransition()
|
||||
}
|
||||
|
||||
currentViewController.parentDidAppear() // Trigger any custom appearance animations
|
||||
updateTitle(item: currentItem)
|
||||
updateMediaRail(item: currentItem)
|
||||
previousPage.zoomOut(animated: false)
|
||||
previousPage.stopAnyVideo()
|
||||
updateFooterBarButtonItems(isPlayingVideo: false)
|
||||
updateFooterBarButtonItems()
|
||||
} else {
|
||||
captionContainerView.pendingText = nil
|
||||
}
|
||||
|
@ -707,7 +713,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
}
|
||||
|
||||
// Swap out the database observer
|
||||
dataChangeObservable?.cancel()
|
||||
stopObservingChanges()
|
||||
viewModel.replaceAlbumObservation(toObservationFor: interactionIdAfter)
|
||||
startObservingChanges()
|
||||
|
||||
|
@ -752,7 +758,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
}
|
||||
|
||||
// Swap out the database observer
|
||||
dataChangeObservable?.cancel()
|
||||
stopObservingChanges()
|
||||
viewModel.replaceAlbumObservation(toObservationFor: interactionIdBefore)
|
||||
startObservingChanges()
|
||||
|
||||
|
@ -779,7 +785,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
// Swapping mediaView for presentationView will be perceptible if we're not zoomed out all the way.
|
||||
// currentVC
|
||||
currentViewController.zoomOut(animated: true)
|
||||
currentViewController.stopAnyVideo()
|
||||
|
||||
self.navigationController?.view.isUserInteractionEnabled = false
|
||||
self.navigationController?.dismiss(animated: true, completion: { [weak self] in
|
||||
|
@ -801,16 +806,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
self.shouldHideToolbars = !self.shouldHideToolbars
|
||||
}
|
||||
|
||||
public func mediaDetailViewController(_ mediaDetailViewController: MediaDetailViewController, isPlayingVideo: Bool) {
|
||||
guard mediaDetailViewController == currentViewController else {
|
||||
Logger.verbose("ignoring stale delegate.")
|
||||
return
|
||||
}
|
||||
|
||||
self.shouldHideToolbars = isPlayingVideo
|
||||
self.updateFooterBarButtonItems(isPlayingVideo: isPlayingVideo)
|
||||
}
|
||||
|
||||
// MARK: - Dynamic Header
|
||||
|
||||
private lazy var dateFormatter: DateFormatter = {
|
||||
|
@ -922,24 +917,19 @@ extension MediaGalleryViewModel.Item: GalleryRailItem {
|
|||
let imageView: UIImageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
|
||||
getRailImage()
|
||||
.map { [weak imageView] image in
|
||||
guard let imageView = imageView else { return }
|
||||
imageView.image = image
|
||||
self.thumbnailImage { [weak imageView] image in
|
||||
DispatchQueue.main.async {
|
||||
imageView?.image = image
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
return imageView
|
||||
}
|
||||
|
||||
public func getRailImage() -> Guarantee<UIImage> {
|
||||
return Guarantee<UIImage> { fulfill in
|
||||
self.thumbnailImage(async: { image in fulfill(image) })
|
||||
}
|
||||
}
|
||||
|
||||
public func isEqual(to other: GalleryRailItem?) -> Bool {
|
||||
guard let otherItem: MediaGalleryViewModel.Item = other as? MediaGalleryViewModel.Item else { return false }
|
||||
guard let otherItem: MediaGalleryViewModel.Item = other as? MediaGalleryViewModel.Item else {
|
||||
return false
|
||||
}
|
||||
|
||||
return (self == otherItem)
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@ import GRDB
|
|||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public class MediaTileViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
|
||||
|
||||
|
@ -53,6 +55,10 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
|||
// MARK: - UI
|
||||
|
||||
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
if UIDevice.current.isIPad {
|
||||
return .all
|
||||
}
|
||||
|
||||
return .allButUpsideDown
|
||||
}
|
||||
|
||||
|
@ -175,7 +181,10 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
|||
}
|
||||
|
||||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||
startObservingChanges(didReturnFromBackground: true)
|
||||
/// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.startObservingChanges(didReturnFromBackground: true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||
|
@ -242,7 +251,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
|||
}
|
||||
|
||||
private func autoLoadNextPageIfNeeded() {
|
||||
guard !self.isAutoLoadingNextPage else { return }
|
||||
guard self.hasLoadedInitialData && !self.isAutoLoadingNextPage else { return }
|
||||
|
||||
self.isAutoLoadingNextPage = true
|
||||
|
||||
|
@ -303,12 +312,12 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
|||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||
// in from a frame of CGRect.zero)
|
||||
guard hasLoadedInitialData else {
|
||||
self.hasLoadedInitialData = true
|
||||
self.viewModel.updateGalleryData(updatedGalleryData)
|
||||
self.updateSelectButton(updatedData: updatedGalleryData, inBatchSelectMode: isInBatchSelectMode)
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
self.collectionView.reloadData()
|
||||
self.hasLoadedInitialData = true
|
||||
self.performInitialScrollIfNeeded()
|
||||
}
|
||||
return
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc class OWSImagePickerController: UIImagePickerController {
|
||||
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// stringlint:disable
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import AVFoundation
|
||||
import PromiseKit
|
||||
import CoreServices
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalCoreKit
|
||||
|
||||
protocol PhotoCaptureDelegate: AnyObject {
|
||||
func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessingAttachment attachment: SignalAttachment)
|
||||
|
@ -83,77 +85,93 @@ class PhotoCapture: NSObject {
|
|||
Environment.shared?.audioSession.endAudioActivity(recordingAudioActivity)
|
||||
}
|
||||
|
||||
func startCapture() -> Promise<Void> {
|
||||
return sessionQueue.async(.promise) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
func startCapture() -> AnyPublisher<Void, Error> {
|
||||
return Just(())
|
||||
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
|
||||
.setFailureType(to: Error.self)
|
||||
.tryMap { [weak self] _ -> Void in
|
||||
self?.session.beginConfiguration()
|
||||
defer { self?.session.commitConfiguration() }
|
||||
|
||||
self.session.beginConfiguration()
|
||||
defer { self.session.commitConfiguration() }
|
||||
try self?.updateCurrentInput(position: .back)
|
||||
|
||||
try self.updateCurrentInput(position: .back)
|
||||
|
||||
guard let photoOutput = self.captureOutput.photoOutput else {
|
||||
throw PhotoCaptureError.initializationFailed
|
||||
}
|
||||
|
||||
guard self.session.canAddOutput(photoOutput) else {
|
||||
throw PhotoCaptureError.initializationFailed
|
||||
}
|
||||
|
||||
if let connection = photoOutput.connection(with: .video) {
|
||||
if connection.isVideoStabilizationSupported {
|
||||
connection.preferredVideoStabilizationMode = .auto
|
||||
guard
|
||||
let photoOutput = self?.captureOutput.photoOutput,
|
||||
self?.session.canAddOutput(photoOutput) == true
|
||||
else {
|
||||
throw PhotoCaptureError.initializationFailed
|
||||
}
|
||||
}
|
||||
|
||||
self.session.addOutput(photoOutput)
|
||||
|
||||
let movieOutput = self.captureOutput.movieOutput
|
||||
|
||||
if self.session.canAddOutput(movieOutput) {
|
||||
self.session.addOutput(movieOutput)
|
||||
self.session.sessionPreset = .high
|
||||
if let connection = movieOutput.connection(with: .video) {
|
||||
if let connection = photoOutput.connection(with: .video) {
|
||||
if connection.isVideoStabilizationSupported {
|
||||
connection.preferredVideoStabilizationMode = .auto
|
||||
}
|
||||
}
|
||||
|
||||
self?.session.addOutput(photoOutput)
|
||||
|
||||
if
|
||||
let movieOutput = self?.captureOutput.movieOutput,
|
||||
self?.session.canAddOutput(movieOutput) == true
|
||||
{
|
||||
self?.session.addOutput(movieOutput)
|
||||
self?.session.sessionPreset = .high
|
||||
|
||||
if let connection = movieOutput.connection(with: .video) {
|
||||
if connection.isVideoStabilizationSupported {
|
||||
connection.preferredVideoStabilizationMode = .auto
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ()
|
||||
}
|
||||
}.done(on: sessionQueue) {
|
||||
self.session.startRunning()
|
||||
}
|
||||
.handleEvents(
|
||||
receiveCompletion: { [weak self] result in
|
||||
switch result {
|
||||
case .failure: break
|
||||
case .finished: self?.session.startRunning()
|
||||
}
|
||||
}
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func stopCapture() -> Guarantee<Void> {
|
||||
return sessionQueue.async(.promise) {
|
||||
self.session.stopRunning()
|
||||
}
|
||||
func stopCapture() -> AnyPublisher<Void, Never> {
|
||||
return Just(())
|
||||
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
|
||||
.handleEvents(
|
||||
receiveOutput: { [weak self] in self?.session.stopRunning() }
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func assertIsOnSessionQueue() {
|
||||
assertOnQueue(sessionQueue)
|
||||
}
|
||||
|
||||
func switchCamera() -> Promise<Void> {
|
||||
func switchCamera() -> AnyPublisher<Void, Error> {
|
||||
AssertIsOnMainThread()
|
||||
let newPosition: AVCaptureDevice.Position
|
||||
switch desiredPosition {
|
||||
case .front:
|
||||
newPosition = .back
|
||||
case .back:
|
||||
newPosition = .front
|
||||
case .unspecified:
|
||||
newPosition = .front
|
||||
}
|
||||
desiredPosition = newPosition
|
||||
|
||||
return sessionQueue.async(.promise) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.session.beginConfiguration()
|
||||
defer { self.session.commitConfiguration() }
|
||||
try self.updateCurrentInput(position: newPosition)
|
||||
}
|
||||
desiredPosition = {
|
||||
switch desiredPosition {
|
||||
case .front: return .back
|
||||
case .back: return .front
|
||||
case .unspecified: return .front
|
||||
}
|
||||
}()
|
||||
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
|
||||
.tryMap { [weak self, newPosition = self.desiredPosition] _ -> Void in
|
||||
self?.session.beginConfiguration()
|
||||
defer { self?.session.commitConfiguration() }
|
||||
|
||||
try self?.updateCurrentInput(position: newPosition)
|
||||
return ()
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
// This method should be called on the serial queue,
|
||||
|
@ -179,20 +197,29 @@ class PhotoCapture: NSObject {
|
|||
resetFocusAndExposure()
|
||||
}
|
||||
|
||||
func switchFlashMode() -> Guarantee<Void> {
|
||||
return sessionQueue.async(.promise) {
|
||||
switch self.captureOutput.flashMode {
|
||||
case .auto:
|
||||
Logger.debug("new flashMode: on")
|
||||
self.captureOutput.flashMode = .on
|
||||
case .on:
|
||||
Logger.debug("new flashMode: off")
|
||||
self.captureOutput.flashMode = .off
|
||||
case .off:
|
||||
Logger.debug("new flashMode: auto")
|
||||
self.captureOutput.flashMode = .auto
|
||||
}
|
||||
}
|
||||
func switchFlashMode() -> AnyPublisher<Void, Never> {
|
||||
return Just(())
|
||||
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
|
||||
.handleEvents(
|
||||
receiveOutput: { [weak self] _ in
|
||||
switch self?.captureOutput.flashMode {
|
||||
case .auto:
|
||||
Logger.debug("new flashMode: on")
|
||||
self?.captureOutput.flashMode = .on
|
||||
|
||||
case .on:
|
||||
Logger.debug("new flashMode: off")
|
||||
self?.captureOutput.flashMode = .off
|
||||
|
||||
case .off:
|
||||
Logger.debug("new flashMode: auto")
|
||||
self?.captureOutput.flashMode = .auto
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func focus(with focusMode: AVCaptureDevice.FocusMode,
|
||||
|
@ -325,14 +352,24 @@ extension PhotoCapture: CaptureButtonDelegate {
|
|||
AssertIsOnMainThread()
|
||||
|
||||
Logger.verbose("")
|
||||
sessionQueue.async(.promise) {
|
||||
try self.startAudioCapture()
|
||||
self.captureOutput.beginVideo(delegate: self)
|
||||
}.done {
|
||||
self.delegate?.photoCaptureDidBeginVideo(self)
|
||||
}.catch { error in
|
||||
self.delegate?.photoCapture(self, processingDidError: error)
|
||||
}.retainUntilComplete()
|
||||
|
||||
sessionQueue.async { [weak self] in // Must run this on a specific queue to prevent crashes
|
||||
guard let strongSelf = self else { return }
|
||||
|
||||
do {
|
||||
try strongSelf.startAudioCapture()
|
||||
strongSelf.captureOutput.beginVideo(delegate: strongSelf)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
strongSelf.delegate?.photoCaptureDidBeginVideo(strongSelf)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
DispatchQueue.main.async {
|
||||
strongSelf.delegate?.photoCapture(strongSelf, processingDidError: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func didCompleteLongPressCaptureButton(_ captureButton: CaptureButton) {
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
import AVFoundation
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
protocol PhotoCaptureViewControllerDelegate: AnyObject {
|
||||
func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment)
|
||||
|
@ -39,9 +41,15 @@ class PhotoCaptureViewController: OWSViewController {
|
|||
deinit {
|
||||
UIDevice.current.endGeneratingDeviceOrientationNotifications()
|
||||
if let photoCapture = photoCapture {
|
||||
photoCapture.stopCapture().done {
|
||||
Logger.debug("stopCapture completed")
|
||||
}.retainUntilComplete()
|
||||
photoCapture.stopCapture()
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
switch result {
|
||||
case .failure: break
|
||||
case .finished: Logger.debug("stopCapture completed")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -186,17 +194,29 @@ class PhotoCaptureViewController: OWSViewController {
|
|||
let epsilonToForceCounterClockwiseRotation: CGFloat = 0.00001
|
||||
self.switchCameraControl.button.transform = self.switchCameraControl.button.transform.rotate(.pi + epsilonToForceCounterClockwiseRotation)
|
||||
}
|
||||
photoCapture.switchCamera().catch { error in
|
||||
self.showFailureUI(error: error)
|
||||
}.retainUntilComplete()
|
||||
|
||||
photoCapture.switchCamera()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { [weak self] result in
|
||||
switch result {
|
||||
case .finished: break
|
||||
case .failure(let error): self?.showFailureUI(error: error)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@objc
|
||||
func didTapFlashMode() {
|
||||
Logger.debug("")
|
||||
photoCapture.switchFlashMode().done {
|
||||
self.updateFlashModeControl()
|
||||
}.retainUntilComplete()
|
||||
photoCapture.switchFlashMode()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { [weak self] _ in
|
||||
self?.updateFlashModeControl()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@objc
|
||||
|
@ -255,14 +275,11 @@ class PhotoCaptureViewController: OWSViewController {
|
|||
|
||||
let transformFromOrientation: CGAffineTransform
|
||||
switch captureOrientation {
|
||||
case .portrait:
|
||||
transformFromOrientation = .identity
|
||||
case .portraitUpsideDown:
|
||||
transformFromOrientation = CGAffineTransform(rotationAngle: .pi)
|
||||
case .landscapeLeft:
|
||||
transformFromOrientation = CGAffineTransform(rotationAngle: .halfPi)
|
||||
case .landscapeRight:
|
||||
transformFromOrientation = CGAffineTransform(rotationAngle: -1 * .halfPi)
|
||||
case .portrait: transformFromOrientation = .identity
|
||||
case .portraitUpsideDown: transformFromOrientation = CGAffineTransform(rotationAngle: .pi)
|
||||
case .landscapeLeft: transformFromOrientation = CGAffineTransform(rotationAngle: .halfPi)
|
||||
case .landscapeRight: transformFromOrientation = CGAffineTransform(rotationAngle: -1 * .halfPi)
|
||||
@unknown default: transformFromOrientation = .identity
|
||||
}
|
||||
|
||||
// Don't "unrotate" the switch camera icon if the front facing camera had been selected.
|
||||
|
@ -287,13 +304,15 @@ class PhotoCaptureViewController: OWSViewController {
|
|||
previewView = CapturePreviewView(session: photoCapture.session)
|
||||
|
||||
photoCapture.startCapture()
|
||||
.done { [weak self] in
|
||||
self?.showCaptureUI()
|
||||
}
|
||||
.catch { [weak self] error in
|
||||
self?.showFailureUI(error: error)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { [weak self] result in
|
||||
switch result {
|
||||
case .finished: self?.showCaptureUI()
|
||||
case .failure(let error): self?.showFailureUI(error: error)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func showCaptureUI() {
|
||||
|
@ -315,7 +334,7 @@ class PhotoCaptureViewController: OWSViewController {
|
|||
let modal: ConfirmationModal = ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: CommonStrings.errorAlertTitle,
|
||||
explanation: error.localizedDescription,
|
||||
body: .text(error.localizedDescription),
|
||||
cancelTitle: CommonStrings.dismissButton,
|
||||
cancelStyle: .alert_text,
|
||||
afterClosed: { [weak self] in self?.dismiss(animated: true) }
|
||||
|
@ -580,7 +599,7 @@ class RecordingTimerView: UIView {
|
|||
|
||||
private lazy var label: UILabel = {
|
||||
let label: UILabel = UILabel()
|
||||
label.font = .ows_monospacedDigitFont(withSize: 20)
|
||||
label.font = UIFont.monospacedDigitSystemFont(ofSize: 20, weight: .regular)
|
||||
label.themeTextColor = .textPrimary
|
||||
label.textAlignment = .center
|
||||
label.layer.shadowOffset = CGSize.zero
|
||||
|
|
|
@ -8,82 +8,76 @@ import SessionUIKit
|
|||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollectionPickerViewModel.Section, PhotoCollectionPickerViewModel.Item> {
|
||||
// MARK: - Config
|
||||
class PhotoCollectionPickerViewModel: SessionTableViewModel, ObservableTableSource {
|
||||
typealias TableItem = String
|
||||
|
||||
public enum Section: SessionTableSection {
|
||||
case content
|
||||
}
|
||||
public let dependencies: Dependencies
|
||||
public let state: TableDataState<Section, TableItem> = TableDataState()
|
||||
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
|
||||
|
||||
public struct Item: Equatable, Hashable, Differentiable {
|
||||
let id: String
|
||||
}
|
||||
|
||||
private let library: PhotoLibrary
|
||||
private let onCollectionSelected: (PhotoCollection) -> Void
|
||||
private var photoCollections: CurrentValueSubject<[PhotoCollection], Error>
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(library: PhotoLibrary, onCollectionSelected: @escaping (PhotoCollection) -> Void) {
|
||||
init(
|
||||
library: PhotoLibrary,
|
||||
onCollectionSelected: @escaping (PhotoCollection) -> Void,
|
||||
using dependencies: Dependencies = Dependencies()
|
||||
) {
|
||||
self.dependencies = dependencies
|
||||
self.library = library
|
||||
self.onCollectionSelected = onCollectionSelected
|
||||
self.photoCollections = CurrentValueSubject(library.allPhotoCollections())
|
||||
}
|
||||
|
||||
// MARK: - Config
|
||||
|
||||
public enum Section: SessionTableSection {
|
||||
case content
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
override var title: String { "NOTIFICATIONS_STYLE_SOUND_TITLE".localized() }
|
||||
let title: String = "NOTIFICATIONS_STYLE_SOUND_TITLE".localized()
|
||||
|
||||
private var _settingsData: [SectionModel] = []
|
||||
public override var settingsData: [SectionModel] { _settingsData }
|
||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
||||
|
||||
private lazy var _observableSettingsData: ObservableData = {
|
||||
self.photoCollections
|
||||
.map { collections in
|
||||
[
|
||||
SectionModel(
|
||||
model: .content,
|
||||
elements: collections.map { collection in
|
||||
let contents: PhotoCollectionContents = collection.contents()
|
||||
let photoMediaSize: PhotoMediaSize = PhotoMediaSize(
|
||||
thumbnailSize: CGSize(
|
||||
width: IconSize.veryLarge.size,
|
||||
height: IconSize.veryLarge.size
|
||||
)
|
||||
lazy var observation: TargetObservation = ObservationBuilder
|
||||
.subject(photoCollections)
|
||||
.map { collections -> [SectionModel] in
|
||||
[
|
||||
SectionModel(
|
||||
model: .content,
|
||||
elements: collections.map { collection in
|
||||
let contents: PhotoCollectionContents = collection.contents()
|
||||
let photoMediaSize: PhotoMediaSize = PhotoMediaSize(
|
||||
thumbnailSize: CGSize(
|
||||
width: IconSize.extraLarge.size,
|
||||
height: IconSize.extraLarge.size
|
||||
)
|
||||
let lastAssetItem: PhotoPickerAssetItem? = contents.lastAssetItem(photoMediaSize: photoMediaSize)
|
||||
|
||||
return SessionCell.Info(
|
||||
id: Item(id: collection.id),
|
||||
leftAccessory: .iconAsync(size: .veryLarge, shouldFill: true) { imageView in
|
||||
// Note: We need to capture 'lastAssetItem' otherwise it'll be released and we won't
|
||||
// be able to load the thumbnail
|
||||
lastAssetItem?.asyncThumbnail { [weak imageView] image in
|
||||
imageView?.image = image
|
||||
}
|
||||
},
|
||||
title: collection.localizedTitle(),
|
||||
subtitle: "\(contents.assetCount)",
|
||||
onTap: { [weak self] in
|
||||
self?.onCollectionSelected(collection)
|
||||
)
|
||||
let lastAssetItem: PhotoPickerAssetItem? = contents.lastAssetItem(photoMediaSize: photoMediaSize)
|
||||
|
||||
return SessionCell.Info(
|
||||
id: collection.id,
|
||||
leftAccessory: .iconAsync(size: .extraLarge, shouldFill: true) { imageView in
|
||||
// Note: We need to capture 'lastAssetItem' otherwise it'll be released and we won't
|
||||
// be able to load the thumbnail
|
||||
lastAssetItem?.asyncThumbnail { [weak imageView] image in
|
||||
imageView?.image = image
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
.removeDuplicates()
|
||||
.eraseToAnyPublisher()
|
||||
}()
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
||||
self._settingsData = updatedSettings
|
||||
}
|
||||
|
||||
},
|
||||
title: collection.localizedTitle(),
|
||||
subtitle: "\(contents.assetCount)",
|
||||
onTap: { [weak self] in
|
||||
self?.onCollectionSelected(collection)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: PhotoLibraryDelegate
|
||||
|
||||
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue