Merge pull request #612 from mpretty-cyro/feature/database-refactor
Database refactor
This commit is contained in:
commit
7ec48baffa
49
Podfile
49
Podfile
|
@ -1,4 +1,4 @@
|
||||||
platform :ios, '12.0'
|
platform :ios, '13.0'
|
||||||
source 'https://github.com/CocoaPods/Specs.git'
|
source 'https://github.com/CocoaPods/Specs.git'
|
||||||
|
|
||||||
use_frameworks!
|
use_frameworks!
|
||||||
|
@ -8,7 +8,12 @@ inhibit_all_warnings!
|
||||||
abstract_target 'GlobalDependencies' do
|
abstract_target 'GlobalDependencies' do
|
||||||
pod 'PromiseKit'
|
pod 'PromiseKit'
|
||||||
pod 'CryptoSwift'
|
pod 'CryptoSwift'
|
||||||
pod 'Sodium', '~> 0.9.1'
|
# 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'
|
||||||
|
pod 'SQLCipher', '~> 4.0'
|
||||||
|
|
||||||
|
# FIXME: We want to remove this once it's been long enough since the migration to GRDB
|
||||||
pod 'YapDatabase/SQLCipher', :git => 'https://github.com/oxen-io/session-ios-yap-database.git', branch: 'signal-release'
|
pod 'YapDatabase/SQLCipher', :git => 'https://github.com/oxen-io/session-ios-yap-database.git', branch: 'signal-release'
|
||||||
pod 'WebRTC-lib'
|
pod 'WebRTC-lib'
|
||||||
pod 'SocketRocket', '~> 0.5.1'
|
pod 'SocketRocket', '~> 0.5.1'
|
||||||
|
@ -18,14 +23,14 @@ abstract_target 'GlobalDependencies' do
|
||||||
pod 'Reachability'
|
pod 'Reachability'
|
||||||
pod 'PureLayout', '~> 3.1.8'
|
pod 'PureLayout', '~> 3.1.8'
|
||||||
pod 'NVActivityIndicatorView'
|
pod 'NVActivityIndicatorView'
|
||||||
pod 'YYImage', git: 'https://github.com/signalapp/YYImage'
|
pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
|
||||||
pod 'Mantle', git: 'https://github.com/signalapp/Mantle', branch: 'signal-master'
|
|
||||||
pod 'ZXingObjC'
|
pod 'ZXingObjC'
|
||||||
|
pod 'DifferenceKit'
|
||||||
end
|
end
|
||||||
|
|
||||||
# Dependencies to be included only in all extensions/frameworks
|
# Dependencies to be included only in all extensions/frameworks
|
||||||
abstract_target 'FrameworkAndExtensionDependencies' do
|
abstract_target 'FrameworkAndExtensionDependencies' do
|
||||||
pod 'Curve25519Kit', git: 'https://github.com/signalapp/Curve25519Kit.git'
|
pod 'Curve25519Kit', git: 'https://github.com/oxen-io/session-ios-curve-25519-kit.git', branch: 'session-version'
|
||||||
pod 'SignalCoreKit', git: 'https://github.com/oxen-io/session-ios-core-kit', branch: 'session-version'
|
pod 'SignalCoreKit', git: 'https://github.com/oxen-io/session-ios-core-kit', branch: 'session-version'
|
||||||
|
|
||||||
target 'SessionNotificationServiceExtension'
|
target 'SessionNotificationServiceExtension'
|
||||||
|
@ -35,10 +40,10 @@ abstract_target 'GlobalDependencies' do
|
||||||
abstract_target 'ExtendedDependencies' do
|
abstract_target 'ExtendedDependencies' do
|
||||||
pod 'AFNetworking'
|
pod 'AFNetworking'
|
||||||
pod 'PureLayout', '~> 3.1.8'
|
pod 'PureLayout', '~> 3.1.8'
|
||||||
pod 'Mantle', git: 'https://github.com/signalapp/Mantle', branch: 'signal-master'
|
|
||||||
|
|
||||||
target 'SessionShareExtension' do
|
target 'SessionShareExtension' do
|
||||||
pod 'NVActivityIndicatorView'
|
pod 'NVActivityIndicatorView'
|
||||||
|
pod 'DifferenceKit'
|
||||||
end
|
end
|
||||||
|
|
||||||
target 'SignalUtilitiesKit' do
|
target 'SignalUtilitiesKit' do
|
||||||
|
@ -46,17 +51,34 @@ abstract_target 'GlobalDependencies' do
|
||||||
pod 'Reachability'
|
pod 'Reachability'
|
||||||
pod 'SAMKeychain'
|
pod 'SAMKeychain'
|
||||||
pod 'SwiftProtobuf', '~> 1.5.0'
|
pod 'SwiftProtobuf', '~> 1.5.0'
|
||||||
pod 'YYImage', git: 'https://github.com/signalapp/YYImage'
|
pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
|
||||||
|
pod 'DifferenceKit'
|
||||||
end
|
end
|
||||||
|
|
||||||
target 'SessionMessagingKit' do
|
target 'SessionMessagingKit' do
|
||||||
pod 'Reachability'
|
pod 'Reachability'
|
||||||
pod 'SAMKeychain'
|
pod 'SAMKeychain'
|
||||||
pod 'SwiftProtobuf', '~> 1.5.0'
|
pod 'SwiftProtobuf', '~> 1.5.0'
|
||||||
|
pod 'DifferenceKit'
|
||||||
|
|
||||||
|
target 'SessionMessagingKitTests' do
|
||||||
|
inherit! :complete
|
||||||
|
|
||||||
|
pod 'Quick'
|
||||||
|
pod 'Nimble'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
target 'SessionUtilitiesKit' do
|
target 'SessionUtilitiesKit' do
|
||||||
pod 'SAMKeychain'
|
pod 'SAMKeychain'
|
||||||
|
pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
|
||||||
|
|
||||||
|
target 'SessionUtilitiesKitTests' do
|
||||||
|
inherit! :complete
|
||||||
|
|
||||||
|
pod 'Quick'
|
||||||
|
pod 'Nimble'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -69,6 +91,7 @@ target 'SessionUIKit'
|
||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
enable_whole_module_optimization_for_crypto_swift(installer)
|
enable_whole_module_optimization_for_crypto_swift(installer)
|
||||||
set_minimum_deployment_target(installer)
|
set_minimum_deployment_target(installer)
|
||||||
|
enable_fts5_support(installer)
|
||||||
end
|
end
|
||||||
|
|
||||||
def enable_whole_module_optimization_for_crypto_swift(installer)
|
def enable_whole_module_optimization_for_crypto_swift(installer)
|
||||||
|
@ -85,7 +108,17 @@ end
|
||||||
def set_minimum_deployment_target(installer)
|
def set_minimum_deployment_target(installer)
|
||||||
installer.pods_project.targets.each do |target|
|
installer.pods_project.targets.each do |target|
|
||||||
target.build_configurations.each do |build_configuration|
|
target.build_configurations.each do |build_configuration|
|
||||||
build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0'
|
build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
|
||||||
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
76
Podfile.lock
76
Podfile.lock
|
@ -21,9 +21,24 @@ PODS:
|
||||||
- Curve25519Kit (2.1.0):
|
- Curve25519Kit (2.1.0):
|
||||||
- CocoaLumberjack
|
- CocoaLumberjack
|
||||||
- SignalCoreKit
|
- SignalCoreKit
|
||||||
- Mantle (2.1.0):
|
- DifferenceKit (1.2.0):
|
||||||
- Mantle/extobjc (= 2.1.0)
|
- DifferenceKit/Core (= 1.2.0)
|
||||||
- Mantle/extobjc (2.1.0)
|
- DifferenceKit/UIKitExtension (= 1.2.0)
|
||||||
|
- DifferenceKit/Core (1.2.0)
|
||||||
|
- DifferenceKit/UIKitExtension (1.2.0):
|
||||||
|
- DifferenceKit/Core
|
||||||
|
- GRDB.swift/SQLCipher (5.26.0):
|
||||||
|
- SQLCipher (>= 3.4.0)
|
||||||
|
- 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/webp
|
||||||
|
- libwebp/mux (1.2.1):
|
||||||
|
- libwebp/demux
|
||||||
|
- libwebp/webp (1.2.1)
|
||||||
|
- Nimble (10.0.0)
|
||||||
- NVActivityIndicatorView (5.1.1):
|
- NVActivityIndicatorView (5.1.1):
|
||||||
- NVActivityIndicatorView/Base (= 5.1.1)
|
- NVActivityIndicatorView/Base (= 5.1.1)
|
||||||
- NVActivityIndicatorView/Base (5.1.1)
|
- NVActivityIndicatorView/Base (5.1.1)
|
||||||
|
@ -38,6 +53,7 @@ PODS:
|
||||||
- PromiseKit/UIKit (6.15.3):
|
- PromiseKit/UIKit (6.15.3):
|
||||||
- PromiseKit/CorePromise
|
- PromiseKit/CorePromise
|
||||||
- PureLayout (3.1.9)
|
- PureLayout (3.1.9)
|
||||||
|
- Quick (5.0.1)
|
||||||
- Reachability (3.2)
|
- Reachability (3.2)
|
||||||
- SAMKeychain (1.5.3)
|
- SAMKeychain (1.5.3)
|
||||||
- SignalCoreKit (1.0.0):
|
- SignalCoreKit (1.0.0):
|
||||||
|
@ -114,9 +130,10 @@ PODS:
|
||||||
- YapDatabase/SQLCipher/Core
|
- YapDatabase/SQLCipher/Core
|
||||||
- YapDatabase/SQLCipher/Extensions/View (3.1.1):
|
- YapDatabase/SQLCipher/Extensions/View (3.1.1):
|
||||||
- YapDatabase/SQLCipher/Core
|
- YapDatabase/SQLCipher/Core
|
||||||
- YYImage (1.0.4):
|
|
||||||
- YYImage/Core (= 1.0.4)
|
|
||||||
- YYImage/Core (1.0.4)
|
- YYImage/Core (1.0.4)
|
||||||
|
- YYImage/libwebp (1.0.4):
|
||||||
|
- libwebp
|
||||||
|
- YYImage/Core
|
||||||
- ZXingObjC (3.6.5):
|
- ZXingObjC (3.6.5):
|
||||||
- ZXingObjC/All (= 3.6.5)
|
- ZXingObjC/All (= 3.6.5)
|
||||||
- ZXingObjC/All (3.6.5)
|
- ZXingObjC/All (3.6.5)
|
||||||
|
@ -124,20 +141,24 @@ PODS:
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- AFNetworking
|
- AFNetworking
|
||||||
- CryptoSwift
|
- CryptoSwift
|
||||||
- Curve25519Kit (from `https://github.com/signalapp/Curve25519Kit.git`)
|
- Curve25519Kit (from `https://github.com/oxen-io/session-ios-curve-25519-kit.git`, branch `session-version`)
|
||||||
- Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`)
|
- DifferenceKit
|
||||||
|
- GRDB.swift/SQLCipher
|
||||||
|
- Nimble
|
||||||
- NVActivityIndicatorView
|
- NVActivityIndicatorView
|
||||||
- PromiseKit
|
- PromiseKit
|
||||||
- PureLayout (~> 3.1.8)
|
- PureLayout (~> 3.1.8)
|
||||||
|
- Quick
|
||||||
- Reachability
|
- Reachability
|
||||||
- SAMKeychain
|
- SAMKeychain
|
||||||
- SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`)
|
- SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`)
|
||||||
- SocketRocket (~> 0.5.1)
|
- SocketRocket (~> 0.5.1)
|
||||||
- Sodium (~> 0.9.1)
|
- Sodium (from `https://github.com/oxen-io/session-ios-swift-sodium.git`, branch `session-build`)
|
||||||
|
- SQLCipher (~> 4.0)
|
||||||
- SwiftProtobuf (~> 1.5.0)
|
- SwiftProtobuf (~> 1.5.0)
|
||||||
- WebRTC-lib
|
- WebRTC-lib
|
||||||
- YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`)
|
- YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`)
|
||||||
- YYImage (from `https://github.com/signalapp/YYImage`)
|
- YYImage/libwebp (from `https://github.com/signalapp/YYImage`)
|
||||||
- ZXingObjC
|
- ZXingObjC
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
|
@ -145,14 +166,18 @@ SPEC REPOS:
|
||||||
- AFNetworking
|
- AFNetworking
|
||||||
- CocoaLumberjack
|
- CocoaLumberjack
|
||||||
- CryptoSwift
|
- CryptoSwift
|
||||||
|
- DifferenceKit
|
||||||
|
- GRDB.swift
|
||||||
|
- libwebp
|
||||||
|
- Nimble
|
||||||
- NVActivityIndicatorView
|
- NVActivityIndicatorView
|
||||||
- OpenSSL-Universal
|
- OpenSSL-Universal
|
||||||
- PromiseKit
|
- PromiseKit
|
||||||
- PureLayout
|
- PureLayout
|
||||||
|
- Quick
|
||||||
- Reachability
|
- Reachability
|
||||||
- SAMKeychain
|
- SAMKeychain
|
||||||
- SocketRocket
|
- SocketRocket
|
||||||
- Sodium
|
|
||||||
- SQLCipher
|
- SQLCipher
|
||||||
- SwiftProtobuf
|
- SwiftProtobuf
|
||||||
- WebRTC-lib
|
- WebRTC-lib
|
||||||
|
@ -160,13 +185,14 @@ SPEC REPOS:
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
Curve25519Kit:
|
Curve25519Kit:
|
||||||
:git: https://github.com/signalapp/Curve25519Kit.git
|
:branch: session-version
|
||||||
Mantle:
|
:git: https://github.com/oxen-io/session-ios-curve-25519-kit.git
|
||||||
:branch: signal-master
|
|
||||||
:git: https://github.com/signalapp/Mantle
|
|
||||||
SignalCoreKit:
|
SignalCoreKit:
|
||||||
:branch: session-version
|
:branch: session-version
|
||||||
:git: https://github.com/oxen-io/session-ios-core-kit
|
:git: https://github.com/oxen-io/session-ios-core-kit
|
||||||
|
Sodium:
|
||||||
|
:branch: session-build
|
||||||
|
:git: https://github.com/oxen-io/session-ios-swift-sodium.git
|
||||||
YapDatabase:
|
YapDatabase:
|
||||||
:branch: signal-release
|
:branch: signal-release
|
||||||
:git: https://github.com/oxen-io/session-ios-yap-database.git
|
:git: https://github.com/oxen-io/session-ios-yap-database.git
|
||||||
|
@ -175,14 +201,14 @@ EXTERNAL SOURCES:
|
||||||
|
|
||||||
CHECKOUT OPTIONS:
|
CHECKOUT OPTIONS:
|
||||||
Curve25519Kit:
|
Curve25519Kit:
|
||||||
:commit: 4fc1c10e98fff2534b5379a9bb587430fdb8e577
|
:commit: b79c2ace600bfd3784e9c33cf1f254b121312edc
|
||||||
:git: https://github.com/signalapp/Curve25519Kit.git
|
:git: https://github.com/oxen-io/session-ios-curve-25519-kit.git
|
||||||
Mantle:
|
|
||||||
:commit: e7e46253bb01ce39525d90aa69ed9e85e758bfc4
|
|
||||||
:git: https://github.com/signalapp/Mantle
|
|
||||||
SignalCoreKit:
|
SignalCoreKit:
|
||||||
:commit: 4590c2737a2b5dc0ef4ace9f9019b581caccc1de
|
:commit: 4590c2737a2b5dc0ef4ace9f9019b581caccc1de
|
||||||
:git: https://github.com/oxen-io/session-ios-core-kit
|
:git: https://github.com/oxen-io/session-ios-core-kit
|
||||||
|
Sodium:
|
||||||
|
:commit: 4ecfe2ddfd75e7b396c57975b4163e5c8cf4d5cc
|
||||||
|
:git: https://github.com/oxen-io/session-ios-swift-sodium.git
|
||||||
YapDatabase:
|
YapDatabase:
|
||||||
:commit: d84069e25e12a16ab4422e5258127a04b70489ad
|
:commit: d84069e25e12a16ab4422e5258127a04b70489ad
|
||||||
:git: https://github.com/oxen-io/session-ios-yap-database.git
|
:git: https://github.com/oxen-io/session-ios-yap-database.git
|
||||||
|
@ -195,16 +221,20 @@ SPEC CHECKSUMS:
|
||||||
CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646
|
CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646
|
||||||
CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17
|
CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17
|
||||||
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
|
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
|
||||||
Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b
|
DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805
|
||||||
|
GRDB.swift: 1395cb3556df6b16ed69dfc74c3886abc75d2825
|
||||||
|
libwebp: 98a37e597e40bfdb4c911fc98f2c53d0b12d05fc
|
||||||
|
Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84
|
||||||
NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667
|
NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667
|
||||||
OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2
|
OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2
|
||||||
PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5
|
PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5
|
||||||
PureLayout: 5fb5e5429519627d60d079ccb1eaa7265ce7cf88
|
PureLayout: 5fb5e5429519627d60d079ccb1eaa7265ce7cf88
|
||||||
|
Quick: 749aa754fd1e7d984f2000fe051e18a3a9809179
|
||||||
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
|
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
|
||||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||||
SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d
|
SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d
|
||||||
SocketRocket: d57c7159b83c3c6655745cd15302aa24b6bae531
|
SocketRocket: d57c7159b83c3c6655745cd15302aa24b6bae531
|
||||||
Sodium: 23d11554ecd556196d313cf6130d406dfe7ac6da
|
Sodium: a7d42cb46e789d2630fa552d35870b416ed055ae
|
||||||
SQLCipher: 98dc22f27c0b1790d39e710d440f22a466ebdb59
|
SQLCipher: 98dc22f27c0b1790d39e710d440f22a466ebdb59
|
||||||
SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2
|
SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2
|
||||||
WebRTC-lib: 508fe02efa0c1a3a8867082a77d24c9be5d29aeb
|
WebRTC-lib: 508fe02efa0c1a3a8867082a77d24c9be5d29aeb
|
||||||
|
@ -212,6 +242,6 @@ SPEC CHECKSUMS:
|
||||||
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
|
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
|
||||||
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
||||||
|
|
||||||
PODFILE CHECKSUM: a3d89a6cc8735285fd51348ca05cea71f2c28872
|
PODFILE CHECKSUM: f0857369c4831b2e5c1946345e76e493f3286805
|
||||||
|
|
||||||
COCOAPODS: 1.11.2
|
COCOAPODS: 1.11.3
|
||||||
|
|
|
@ -0,0 +1,251 @@
|
||||||
|
#!/usr/bin/xcrun --sdk macosx swift
|
||||||
|
|
||||||
|
//
|
||||||
|
// ListLocalizableStrings.swift
|
||||||
|
// Archa
|
||||||
|
//
|
||||||
|
// Created by Morgan Pretty on 18/5/20.
|
||||||
|
// Copyright © 2020 Archa. All rights reserved.
|
||||||
|
//
|
||||||
|
// This script is based on https://github.com/ginowu7/CleanSwiftLocalizableExample the main difference
|
||||||
|
// is canges to the localized usage regex
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let currentPath = (
|
||||||
|
ProcessInfo.processInfo.environment["PROJECT_DIR"] ?? fileManager.currentDirectoryPath
|
||||||
|
)
|
||||||
|
|
||||||
|
/// 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)")
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(content[range])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func create() -> [LocalizationStringsFile] {
|
||||||
|
return localizableFiles.map(LocalizationStringsFile.init(path:))
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
///
|
||||||
|
/// - 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
|
||||||
|
///
|
||||||
|
/// - 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)")
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
results[keyValue.0] = keyValue.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printPretty(_ string: String) {
|
||||||
|
print(string.replacingOccurrences(of: "\\", with: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
let stringFiles = create()
|
||||||
|
|
||||||
|
if !stringFiles.isEmpty {
|
||||||
|
print("------------ Found \(stringFiles.count) file(s) ------------")
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("------------ SUCCESS ------------")
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1020"
|
LastUpgradeVersion = "1320"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
@ -20,27 +20,15 @@
|
||||||
ReferencedContainer = "container:Session.xcodeproj">
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "11319FE11E0F163FEF714A606CCC265F"
|
|
||||||
BuildableName = "SignalServiceKit.framework"
|
|
||||||
BlueprintName = "SignalServiceKit"
|
|
||||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
</BuildAction>
|
</BuildAction>
|
||||||
<TestAction
|
<TestAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "NO">
|
shouldUseLaunchSchemeArgsEnv = "NO"
|
||||||
|
codeCoverageEnabled = "YES"
|
||||||
|
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||||
<MacroExpansion>
|
<MacroExpansion>
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
|
@ -57,173 +45,87 @@
|
||||||
isEnabled = "YES">
|
isEnabled = "YES">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
</EnvironmentVariables>
|
</EnvironmentVariables>
|
||||||
|
<CodeCoverageTargets>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D221A088169C9E5E00537ABF"
|
||||||
|
BuildableName = "Session.app"
|
||||||
|
BlueprintName = "Session"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "7BC01A3A241F40AB00BC7C55"
|
||||||
|
BuildableName = "SessionNotificationServiceExtension.appex"
|
||||||
|
BlueprintName = "SessionNotificationServiceExtension"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "453518671FC635DD00210559"
|
||||||
|
BuildableName = "SessionShareExtension.appex"
|
||||||
|
BlueprintName = "SessionShareExtension"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
|
||||||
|
BuildableName = "SessionMessagingKit.framework"
|
||||||
|
BlueprintName = "SessionMessagingKit"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "C3C2A59E255385C100C340D1"
|
||||||
|
BuildableName = "SessionSnodeKit.framework"
|
||||||
|
BlueprintName = "SessionSnodeKit"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "C331FF1A2558F9D300070591"
|
||||||
|
BuildableName = "SessionUIKit.framework"
|
||||||
|
BlueprintName = "SessionUIKit"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
|
||||||
|
BuildableName = "SessionUtilitiesKit.framework"
|
||||||
|
BlueprintName = "SessionUtilitiesKit"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "C33FD9AA255A548A00E217F9"
|
||||||
|
BuildableName = "SignalUtilitiesKit.framework"
|
||||||
|
BlueprintName = "SignalUtilitiesKit"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</CodeCoverageTargets>
|
||||||
<Testables>
|
<Testables>
|
||||||
<TestableReference
|
<TestableReference
|
||||||
skipped = "NO">
|
skipped = "NO"
|
||||||
|
parallelizable = "YES"
|
||||||
|
testExecutionOrdering = "random">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "D221A0A9169C9E5F00537ABF"
|
BlueprintIdentifier = "FDC4388D27B9FFC700C60D73"
|
||||||
BuildableName = "SignalTests.xctest"
|
BuildableName = "SessionMessagingKitTests.xctest"
|
||||||
BlueprintName = "SignalTests"
|
BlueprintName = "SessionMessagingKitTests"
|
||||||
ReferencedContainer = "container:Session.xcodeproj">
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
<TestableReference
|
<TestableReference
|
||||||
skipped = "NO">
|
skipped = "NO"
|
||||||
|
parallelizable = "YES"
|
||||||
|
testExecutionOrdering = "random">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "B772E882F193AA2F25932C514BBF0805"
|
BlueprintIdentifier = "FD83B9AE27CF200A005E1583"
|
||||||
BuildableName = "SignalServiceKit-Unit-Tests.xctest"
|
BuildableName = "SessionUtilitiesKitTests.xctest"
|
||||||
BlueprintName = "SignalServiceKit-Unit-Tests"
|
BlueprintName = "SessionUtilitiesKitTests"
|
||||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
</BuildableReference>
|
|
||||||
<SkippedTests>
|
|
||||||
<Test
|
|
||||||
Identifier = "ContactSortingTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "DeviceNamesTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "JobQueueTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "MessageSenderJobQueueTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "OWSAnalyticsTests">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "OWSDeviceProvisionerTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "OWSDisappearingMessageFinderTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "OWSDisappearingMessagesConfigurationTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "OWSDisappearingMessagesJobTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "OWSFingerprintTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "OWSIncomingMessageFinderTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "OWSLinkPreviewTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "OWSMessageManagerTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "OWSMessageSenderTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "OWSProvisioningCipherTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "OWSSignalAddressTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "OWSUDManagerTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "PhoneNumberTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "PhoneNumberUtilTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "SSKBaseTestObjC">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "SSKBaseTestSwift">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "SSKMessageSenderJobRecordTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "SignalRecipientTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "SignedPreKeyDeletionTests">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "TSContactThreadTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "TSGroupThreadTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "TSMessageStorageTests">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "TSMessageTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "TSOutgoingMessageTest">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "TSStorageIdentityKeyStoreTests">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "TSStoragePreKeyStoreTests">
|
|
||||||
</Test>
|
|
||||||
<Test
|
|
||||||
Identifier = "TSThreadTest">
|
|
||||||
</Test>
|
|
||||||
</SkippedTests>
|
|
||||||
</TestableReference>
|
|
||||||
<TestableReference
|
|
||||||
skipped = "NO">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "5C9F6BA9ADC4724B2612C9F20FBE2076"
|
|
||||||
BuildableName = "SignalCoreKit-Unit-Tests.xctest"
|
|
||||||
BlueprintName = "SignalCoreKit-Unit-Tests"
|
|
||||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
<TestableReference
|
|
||||||
skipped = "NO">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "BF2BCB29C9D47F15FB156F1EC64E5CC2"
|
|
||||||
BuildableName = "AxolotlKit-Unit-Tests.xctest"
|
|
||||||
BlueprintName = "AxolotlKit-Unit-Tests"
|
|
||||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
<TestableReference
|
|
||||||
skipped = "NO">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "78DE33AED82B26B4B8D899CC403003AF"
|
|
||||||
BuildableName = "Curve25519Kit-Unit-Tests.xctest"
|
|
||||||
BlueprintName = "Curve25519Kit-Unit-Tests"
|
|
||||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
<TestableReference
|
|
||||||
skipped = "NO">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "AF7FC2C93AA68E33600807F168BD483A"
|
|
||||||
BuildableName = "HKDFKit-Unit-Tests.xctest"
|
|
||||||
BlueprintName = "HKDFKit-Unit-Tests"
|
|
||||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
<TestableReference
|
|
||||||
skipped = "NO">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "B086B0C72F8A5814FF48795531F21635"
|
|
||||||
BuildableName = "SignalMetadataKit-Unit-Tests.xctest"
|
|
||||||
BlueprintName = "SignalMetadataKit-Unit-Tests"
|
|
||||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
|
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1320"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
|
||||||
|
BuildableName = "SessionMessagingKit.framework"
|
||||||
|
BlueprintName = "SessionMessagingKit"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
codeCoverageEnabled = "YES"
|
||||||
|
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||||
|
<CodeCoverageTargets>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
|
||||||
|
BuildableName = "SessionMessagingKit.framework"
|
||||||
|
BlueprintName = "SessionMessagingKit"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</CodeCoverageTargets>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES"
|
||||||
|
testExecutionOrdering = "random">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "FDC4388D27B9FFC700C60D73"
|
||||||
|
BuildableName = "SessionMessagingKitTests.xctest"
|
||||||
|
BlueprintName = "SessionMessagingKitTests"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "App Store Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
|
||||||
|
BuildableName = "SessionMessagingKit.framework"
|
||||||
|
BlueprintName = "SessionMessagingKit"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "App Store Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1140"
|
LastUpgradeVersion = "1320"
|
||||||
wasCreatedForAppExtension = "YES"
|
wasCreatedForAppExtension = "YES"
|
||||||
version = "2.0">
|
version = "2.0">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
|
@ -43,6 +43,16 @@
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
<Testables>
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "FDC4388027B9FF1E00C60D73"
|
||||||
|
BuildableName = "SessionTests.xctest"
|
||||||
|
BlueprintName = "SessionTests"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
|
@ -73,6 +83,7 @@
|
||||||
savedToolIdentifier = ""
|
savedToolIdentifier = ""
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
|
askForAppToLaunch = "Yes"
|
||||||
launchAutomaticallySubstyle = "2">
|
launchAutomaticallySubstyle = "2">
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1020"
|
LastUpgradeVersion = "1320"
|
||||||
wasCreatedForAppExtension = "YES"
|
wasCreatedForAppExtension = "YES"
|
||||||
version = "2.0">
|
version = "2.0">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
|
@ -52,6 +52,16 @@
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</MacroExpansion>
|
</MacroExpansion>
|
||||||
<Testables>
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "FDC4388027B9FF1E00C60D73"
|
||||||
|
BuildableName = "SessionTests.xctest"
|
||||||
|
BlueprintName = "SessionTests"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
|
@ -83,6 +93,7 @@
|
||||||
savedToolIdentifier = ""
|
savedToolIdentifier = ""
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
|
askForAppToLaunch = "Yes"
|
||||||
launchAutomaticallySubstyle = "2">
|
launchAutomaticallySubstyle = "2">
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
|
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1320"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
|
||||||
|
BuildableName = "SessionUtilitiesKit.framework"
|
||||||
|
BlueprintName = "SessionUtilitiesKit"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
codeCoverageEnabled = "YES"
|
||||||
|
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||||
|
<CodeCoverageTargets>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
|
||||||
|
BuildableName = "SessionUtilitiesKit.framework"
|
||||||
|
BlueprintName = "SessionUtilitiesKit"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</CodeCoverageTargets>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES"
|
||||||
|
testExecutionOrdering = "random">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "FD83B9AE27CF200A005E1583"
|
||||||
|
BuildableName = "SessionUtilitiesKitTests.xctest"
|
||||||
|
BlueprintName = "SessionUtilitiesKitTests"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "App Store Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
|
||||||
|
BuildableName = "SessionUtilitiesKit.framework"
|
||||||
|
BlueprintName = "SessionUtilitiesKit"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "App Store Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1210"
|
LastUpgradeVersion = "1320"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|
|
@ -1,37 +1,33 @@
|
||||||
import Foundation
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
import WebRTC
|
|
||||||
import SessionMessagingKit
|
|
||||||
import PromiseKit
|
|
||||||
import CallKit
|
|
||||||
|
|
||||||
public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
import Foundation
|
||||||
|
import CallKit
|
||||||
|
import GRDB
|
||||||
|
import WebRTC
|
||||||
|
import PromiseKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
|
public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
||||||
@objc static let isEnabled = true
|
@objc static let isEnabled = true
|
||||||
|
|
||||||
// MARK: Metadata Properties
|
// MARK: - Metadata Properties
|
||||||
let uuid: String
|
public let uuid: String
|
||||||
let callID: UUID // This is for CallKit
|
public let callId: UUID // This is for CallKit
|
||||||
let sessionID: String
|
let sessionId: String
|
||||||
let mode: Mode
|
let mode: CallMode
|
||||||
var audioMode: AudioMode
|
var audioMode: AudioMode
|
||||||
let webRTCSession: WebRTCSession
|
public let webRTCSession: WebRTCSession
|
||||||
let isOutgoing: Bool
|
let isOutgoing: Bool
|
||||||
var remoteSDP: RTCSessionDescription? = nil
|
var remoteSDP: RTCSessionDescription? = nil
|
||||||
var callMessageID: String?
|
var callInteractionId: Int64?
|
||||||
var answerCallAction: CXAnswerCallAction? = nil
|
var answerCallAction: CXAnswerCallAction? = nil
|
||||||
var contactName: String {
|
|
||||||
let contact = Storage.shared.getContact(with: self.sessionID)
|
|
||||||
return contact?.displayName(for: Contact.Context.regular) ?? "\(self.sessionID.prefix(4))...\(self.sessionID.suffix(4))"
|
|
||||||
}
|
|
||||||
var profilePicture: UIImage {
|
|
||||||
if let result = OWSProfileManager.shared().profileAvatar(forRecipientId: sessionID) {
|
|
||||||
return result
|
|
||||||
} else {
|
|
||||||
return Identicon.generatePlaceholderIcon(seed: sessionID, text: contactName, size: 300)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Control
|
let contactName: String
|
||||||
|
let profilePicture: UIImage
|
||||||
|
|
||||||
|
// MARK: - Control
|
||||||
|
|
||||||
lazy public var videoCapturer: RTCVideoCapturer = {
|
lazy public var videoCapturer: RTCVideoCapturer = {
|
||||||
return RTCCameraVideoCapturer(delegate: webRTCSession.localVideoSource)
|
return RTCCameraVideoCapturer(delegate: webRTCSession.localVideoSource)
|
||||||
}()
|
}()
|
||||||
|
@ -61,21 +57,8 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Mode
|
// MARK: - Audio I/O mode
|
||||||
enum Mode {
|
|
||||||
case offer
|
|
||||||
case answer
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: End call mode
|
|
||||||
enum EndCallMode {
|
|
||||||
case local
|
|
||||||
case remote
|
|
||||||
case unanswered
|
|
||||||
case answeredElsewhere
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Audio I/O mode
|
|
||||||
enum AudioMode {
|
enum AudioMode {
|
||||||
case earpiece
|
case earpiece
|
||||||
case speaker
|
case speaker
|
||||||
|
@ -83,7 +66,8 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
||||||
case bluetooth
|
case bluetooth
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Call State Properties
|
// MARK: - Call State Properties
|
||||||
|
|
||||||
var connectingDate: Date? {
|
var connectingDate: Date? {
|
||||||
didSet {
|
didSet {
|
||||||
stateDidChange?()
|
stateDidChange?()
|
||||||
|
@ -112,7 +96,8 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: State Change Callbacks
|
// MARK: - State Change Callbacks
|
||||||
|
|
||||||
var stateDidChange: (() -> Void)?
|
var stateDidChange: (() -> Void)?
|
||||||
var hasStartedConnectingDidChange: (() -> Void)?
|
var hasStartedConnectingDidChange: (() -> Void)?
|
||||||
var hasConnectedDidChange: (() -> Void)?
|
var hasConnectedDidChange: (() -> Void)?
|
||||||
|
@ -121,8 +106,9 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
||||||
var hasStartedReconnecting: (() -> Void)?
|
var hasStartedReconnecting: (() -> Void)?
|
||||||
var hasReconnected: (() -> Void)?
|
var hasReconnected: (() -> Void)?
|
||||||
|
|
||||||
// MARK: Derived Properties
|
// MARK: - Derived Properties
|
||||||
var hasStartedConnecting: Bool {
|
|
||||||
|
public var hasStartedConnecting: Bool {
|
||||||
get { return connectingDate != nil }
|
get { return connectingDate != nil }
|
||||||
set { connectingDate = newValue ? Date() : nil }
|
set { connectingDate = newValue ? Date() : nil }
|
||||||
}
|
}
|
||||||
|
@ -153,73 +139,114 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
||||||
|
|
||||||
var reconnectTimer: Timer? = nil
|
var reconnectTimer: Timer? = nil
|
||||||
|
|
||||||
// MARK: Initialization
|
// MARK: - Initialization
|
||||||
init(for sessionID: String, uuid: String, mode: Mode, outgoing: Bool = false) {
|
|
||||||
self.sessionID = sessionID
|
init(_ db: Database, for sessionId: String, uuid: String, mode: CallMode, outgoing: Bool = false) {
|
||||||
|
self.sessionId = sessionId
|
||||||
self.uuid = uuid
|
self.uuid = uuid
|
||||||
self.callID = UUID()
|
self.callId = UUID()
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
self.audioMode = .earpiece
|
self.audioMode = .earpiece
|
||||||
self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionID, with: uuid)
|
self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionId, with: uuid)
|
||||||
self.isOutgoing = outgoing
|
self.isOutgoing = outgoing
|
||||||
|
|
||||||
|
self.contactName = Profile.displayName(db, id: sessionId, threadVariant: .contact)
|
||||||
|
self.profilePicture = ProfileManager.profileAvatar(db, id: sessionId)
|
||||||
|
.map { UIImage(data: $0) }
|
||||||
|
.defaulting(to: Identicon.generatePlaceholderIcon(seed: sessionId, text: self.contactName, size: 300))
|
||||||
|
|
||||||
WebRTCSession.current = self.webRTCSession
|
WebRTCSession.current = self.webRTCSession
|
||||||
super.init()
|
|
||||||
self.webRTCSession.delegate = self
|
self.webRTCSession.delegate = self
|
||||||
|
|
||||||
if AppEnvironment.shared.callManager.currentCall == nil {
|
if AppEnvironment.shared.callManager.currentCall == nil {
|
||||||
AppEnvironment.shared.callManager.currentCall = self
|
AppEnvironment.shared.callManager.currentCall = self
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
SNLog("[Calls] A call is ongoing.")
|
SNLog("[Calls] A call is ongoing.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func reportIncomingCallIfNeeded(completion: @escaping (Error?) -> Void) {
|
func reportIncomingCallIfNeeded(completion: @escaping (Error?) -> Void) {
|
||||||
guard case .answer = mode else { return }
|
guard case .answer = mode else {
|
||||||
|
SessionCallManager.reportFakeCall(info: "Call not in answer mode")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setupTimeoutTimer()
|
setupTimeoutTimer()
|
||||||
AppEnvironment.shared.callManager.reportIncomingCall(self, callerName: contactName) { error in
|
AppEnvironment.shared.callManager.reportIncomingCall(self, callerName: contactName) { error in
|
||||||
completion(error)
|
completion(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceiveRemoteSDP(sdp: RTCSessionDescription) {
|
public func didReceiveRemoteSDP(sdp: RTCSessionDescription) {
|
||||||
|
guard Thread.isMainThread else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.didReceiveRemoteSDP(sdp: sdp)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
SNLog("[Calls] Did receive remote sdp.")
|
SNLog("[Calls] Did receive remote sdp.")
|
||||||
remoteSDP = sdp
|
remoteSDP = sdp
|
||||||
if hasStartedConnecting {
|
if hasStartedConnecting {
|
||||||
webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally
|
webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Actions
|
// MARK: - Actions
|
||||||
func startSessionCall() {
|
|
||||||
guard case .offer = mode else { return }
|
public func startSessionCall(_ db: Database) {
|
||||||
guard let thread = TSContactThread.fetch(uniqueId: TSContactThread.threadID(fromContactSessionID: sessionID)) else { return }
|
let sessionId: String = self.sessionId
|
||||||
|
let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .outgoing)
|
||||||
|
|
||||||
let message = CallMessage()
|
guard
|
||||||
message.sender = getUserHexEncodedPublicKey()
|
case .offer = mode,
|
||||||
message.sentTimestamp = NSDate.millisecondTimestamp()
|
let messageInfoData: Data = try? JSONEncoder().encode(messageInfo),
|
||||||
message.uuid = self.uuid
|
let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId)
|
||||||
message.kind = .preOffer
|
else { return }
|
||||||
let infoMessage = TSInfoMessage.from(message, associatedWith: thread)
|
|
||||||
infoMessage.save()
|
|
||||||
self.callMessageID = infoMessage.uniqueId
|
|
||||||
|
|
||||||
var promise: Promise<Void>!
|
let timestampMs: Int64 = Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||||
Storage.write(with: { transaction in
|
let message: CallMessage = CallMessage(
|
||||||
promise = self.webRTCSession.sendPreOffer(message, in: thread, using: transaction)
|
uuid: self.uuid,
|
||||||
}, completion: { [weak self] in
|
kind: .preOffer,
|
||||||
let _ = promise.done {
|
sdps: [],
|
||||||
Storage.shared.write { transaction in
|
sentTimestampMs: UInt64(timestampMs)
|
||||||
self?.webRTCSession.sendOffer(to: self!.sessionID, using: transaction as! YapDatabaseReadWriteTransaction).retainUntilComplete()
|
)
|
||||||
|
let interaction: Interaction? = try? Interaction(
|
||||||
|
messageUuid: self.uuid,
|
||||||
|
threadId: sessionId,
|
||||||
|
authorId: getUserHexEncodedPublicKey(db),
|
||||||
|
variant: .infoCall,
|
||||||
|
body: String(data: messageInfoData, encoding: .utf8),
|
||||||
|
timestampMs: timestampMs
|
||||||
|
)
|
||||||
|
.inserted(db)
|
||||||
|
|
||||||
|
self.callInteractionId = interaction?.id
|
||||||
|
try? self.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()
|
self?.setupTimeoutTimer()
|
||||||
}
|
}
|
||||||
})
|
.retainUntilComplete()
|
||||||
}
|
}
|
||||||
|
|
||||||
func answerSessionCall() {
|
func answerSessionCall() {
|
||||||
guard case .answer = mode else { return }
|
guard case .answer = mode else { return }
|
||||||
|
|
||||||
hasStartedConnecting = true
|
hasStartedConnecting = true
|
||||||
|
|
||||||
if let sdp = remoteSDP {
|
if let sdp = remoteSDP {
|
||||||
webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally
|
webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -230,47 +257,79 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
||||||
|
|
||||||
func endSessionCall() {
|
func endSessionCall() {
|
||||||
guard !hasEnded else { return }
|
guard !hasEnded else { return }
|
||||||
|
|
||||||
|
let sessionId: String = self.sessionId
|
||||||
|
|
||||||
webRTCSession.hangUp()
|
webRTCSession.hangUp()
|
||||||
Storage.write { transaction in
|
|
||||||
self.webRTCSession.endCall(with: self.sessionID, using: transaction)
|
Storage.shared.writeAsync { [weak self] db in
|
||||||
|
try self?.webRTCSession.endCall(db, with: sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
hasEnded = true
|
hasEnded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Update call message
|
// MARK: - Call Message Handling
|
||||||
func updateCallMessage(mode: EndCallMode) {
|
|
||||||
guard let callMessageID = callMessageID else { return }
|
public func updateCallMessage(mode: EndCallMode) {
|
||||||
Storage.write { transaction in
|
guard let callInteractionId: Int64 = callInteractionId else { return }
|
||||||
let infoMessage = TSInfoMessage.fetch(uniqueId: callMessageID, transaction: transaction)
|
|
||||||
if let messageToUpdate = infoMessage {
|
let duration: TimeInterval = self.duration
|
||||||
var shouldMarkAsRead = false
|
let hasStartedConnecting: Bool = self.hasStartedConnecting
|
||||||
if self.duration > 0 {
|
|
||||||
shouldMarkAsRead = true
|
Storage.shared.writeAsync { db in
|
||||||
} else if self.hasStartedConnecting {
|
guard let interaction: Interaction = try? Interaction.fetchOne(db, id: callInteractionId) else {
|
||||||
shouldMarkAsRead = true
|
return
|
||||||
} else {
|
|
||||||
switch mode {
|
|
||||||
case .local:
|
|
||||||
shouldMarkAsRead = true
|
|
||||||
fallthrough
|
|
||||||
case .remote:
|
|
||||||
fallthrough
|
|
||||||
case .unanswered:
|
|
||||||
if messageToUpdate.callState == .incoming {
|
|
||||||
messageToUpdate.updateCallInfoMessage(.missed, using: transaction)
|
|
||||||
}
|
|
||||||
case .answeredElsewhere:
|
|
||||||
shouldMarkAsRead = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if shouldMarkAsRead {
|
|
||||||
messageToUpdate.markAsRead(atTimestamp: NSDate.ows_millisecondTimeStamp(), trySendReadReceipt: false, transaction: transaction)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let updateToMissedIfNeeded: () throws -> () = {
|
||||||
|
let missedCallInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .missed)
|
||||||
|
|
||||||
|
guard
|
||||||
|
let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8),
|
||||||
|
let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode(
|
||||||
|
CallMessage.MessageInfo.self,
|
||||||
|
from: infoMessageData
|
||||||
|
),
|
||||||
|
messageInfo.state == .incoming,
|
||||||
|
let missedCallInfoData: Data = try? JSONEncoder().encode(missedCallInfo)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
_ = try interaction
|
||||||
|
.with(body: String(data: missedCallInfoData, encoding: .utf8))
|
||||||
|
.saved(db)
|
||||||
|
}
|
||||||
|
let shouldMarkAsRead: Bool = try {
|
||||||
|
if duration > 0 { return true }
|
||||||
|
if hasStartedConnecting { return true }
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case .local:
|
||||||
|
try updateToMissedIfNeeded()
|
||||||
|
return true
|
||||||
|
|
||||||
|
case .remote, .unanswered:
|
||||||
|
try updateToMissedIfNeeded()
|
||||||
|
return false
|
||||||
|
|
||||||
|
case .answeredElsewhere: return true
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
guard shouldMarkAsRead else { return }
|
||||||
|
|
||||||
|
try Interaction.markAsRead(
|
||||||
|
db,
|
||||||
|
interactionId: interaction.id,
|
||||||
|
threadId: interaction.threadId,
|
||||||
|
includingOlder: false,
|
||||||
|
trySendReadReceipt: false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Renderer
|
// MARK: - Renderer
|
||||||
|
|
||||||
func attachRemoteVideoRenderer(_ renderer: RTCVideoRenderer) {
|
func attachRemoteVideoRenderer(_ renderer: RTCVideoRenderer) {
|
||||||
webRTCSession.attachRemoteRenderer(renderer)
|
webRTCSession.attachRemoteRenderer(renderer)
|
||||||
}
|
}
|
||||||
|
@ -283,14 +342,17 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
||||||
webRTCSession.attachLocalRenderer(renderer)
|
webRTCSession.attachLocalRenderer(renderer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Delegate
|
// MARK: - Delegate
|
||||||
|
|
||||||
public func webRTCIsConnected() {
|
public func webRTCIsConnected() {
|
||||||
self.invalidateTimeoutTimer()
|
self.invalidateTimeoutTimer()
|
||||||
self.reconnectTimer?.invalidate()
|
self.reconnectTimer?.invalidate()
|
||||||
|
|
||||||
guard !self.hasConnected else {
|
guard !self.hasConnected else {
|
||||||
hasReconnected?()
|
hasReconnected?()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self.hasConnected = true
|
self.hasConnected = true
|
||||||
self.answerCallAction?.fulfill()
|
self.answerCallAction?.fulfill()
|
||||||
}
|
}
|
||||||
|
@ -327,23 +389,32 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
||||||
|
|
||||||
private func tryToReconnect() {
|
private func tryToReconnect() {
|
||||||
reconnectTimer?.invalidate()
|
reconnectTimer?.invalidate()
|
||||||
if SSKEnvironment.shared.reachabilityManager.isReachable {
|
|
||||||
Storage.write { transaction in
|
guard Environment.shared?.reachabilityManager.isReachable == true else {
|
||||||
self.webRTCSession.sendOffer(to: self.sessionID, using: transaction, isRestartingICEConnection: true).retainUntilComplete()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
reconnectTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 5, repeats: false) { _ in
|
reconnectTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 5, repeats: false) { _ in
|
||||||
self.tryToReconnect()
|
self.tryToReconnect()
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let sessionId: String = self.sessionId
|
||||||
|
let webRTCSession: WebRTCSession = self.webRTCSession
|
||||||
|
|
||||||
|
Storage.shared
|
||||||
|
.read { db in webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true) }
|
||||||
|
.retainUntilComplete()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Timeout
|
// MARK: - Timeout
|
||||||
|
|
||||||
public func setupTimeoutTimer() {
|
public func setupTimeoutTimer() {
|
||||||
invalidateTimeoutTimer()
|
invalidateTimeoutTimer()
|
||||||
let timeInterval: TimeInterval = hasConnected ? 60 : 30
|
|
||||||
|
let timeInterval: TimeInterval = (hasConnected ? 60 : 30)
|
||||||
|
|
||||||
timeOutTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: timeInterval, repeats: false) { _ in
|
timeOutTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: timeInterval, repeats: false) { _ in
|
||||||
self.didTimeout = true
|
self.didTimeout = true
|
||||||
|
|
||||||
AppEnvironment.shared.callManager.endCall(self) { error in
|
AppEnvironment.shared.callManager.endCall(self) { error in
|
||||||
self.timeOutTimer = nil
|
self.timeOutTimer = nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,24 +1,37 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import GRDB
|
||||||
|
|
||||||
extension SessionCallManager {
|
extension SessionCallManager {
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func startCallAction() -> Bool {
|
public func startCallAction() -> Bool {
|
||||||
guard let call = self.currentCall else { return false }
|
guard let call: CurrentCallProtocol = self.currentCall else { return false }
|
||||||
call.startSessionCall()
|
|
||||||
|
Storage.shared.writeAsync { db in
|
||||||
|
call.startSessionCall(db)
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func answerCallAction() -> Bool {
|
public func answerCallAction() -> Bool {
|
||||||
guard let call = self.currentCall else { return false }
|
guard let call: SessionCall = (self.currentCall as? SessionCall) else { return false }
|
||||||
|
|
||||||
if let _ = CurrentAppContext().frontmostViewController() as? CallVC {
|
if let _ = CurrentAppContext().frontmostViewController() as? CallVC {
|
||||||
call.answerSessionCall()
|
call.answerSessionCall()
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { return false } // FIXME: Handle more gracefully
|
guard let presentingVC = CurrentAppContext().frontmostViewController() else { return false } // FIXME: Handle more gracefully
|
||||||
let callVC = CallVC(for: self.currentCall!)
|
let callVC = CallVC(for: call)
|
||||||
|
|
||||||
if let conversationVC = presentingVC as? ConversationVC {
|
if let conversationVC = presentingVC as? ConversationVC {
|
||||||
callVC.conversationVC = conversationVC
|
callVC.conversationVC = conversationVC
|
||||||
conversationVC.inputAccessoryView?.isHidden = true
|
conversationVC.inputAccessoryView?.isHidden = true
|
||||||
conversationVC.inputAccessoryView?.alpha = 0
|
conversationVC.inputAccessoryView?.alpha = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
presentingVC.present(callVC, animated: true) {
|
presentingVC.present(callVC, animated: true) {
|
||||||
call.answerSessionCall()
|
call.answerSessionCall()
|
||||||
}
|
}
|
||||||
|
@ -28,20 +41,26 @@ extension SessionCallManager {
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func endCallAction() -> Bool {
|
public func endCallAction() -> Bool {
|
||||||
guard let call = self.currentCall else { return false }
|
guard let call: SessionCall = (self.currentCall as? SessionCall) else { return false }
|
||||||
|
|
||||||
call.endSessionCall()
|
call.endSessionCall()
|
||||||
|
|
||||||
if call.didTimeout {
|
if call.didTimeout {
|
||||||
reportCurrentCallEnded(reason: .unanswered)
|
reportCurrentCallEnded(reason: .unanswered)
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
reportCurrentCallEnded(reason: nil)
|
reportCurrentCallEnded(reason: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func setMutedCallAction(isMuted: Bool) -> Bool {
|
public func setMutedCallAction(isMuted: Bool) -> Bool {
|
||||||
guard let call = self.currentCall else { return false }
|
guard let call: SessionCall = (self.currentCall as? SessionCall) else { return false }
|
||||||
|
|
||||||
call.isMuted = isMuted
|
call.isMuted = isMuted
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
import CallKit
|
import CallKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
|
@ -5,10 +8,12 @@ extension SessionCallManager {
|
||||||
public func startCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
public func startCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||||
guard case .offer = call.mode else { return }
|
guard case .offer = call.mode else { return }
|
||||||
guard !call.hasConnected else { return }
|
guard !call.hasConnected else { return }
|
||||||
|
|
||||||
reportOutgoingCall(call)
|
reportOutgoingCall(call)
|
||||||
|
|
||||||
if callController != nil {
|
if callController != nil {
|
||||||
let handle = CXHandle(type: .generic, value: call.sessionID)
|
let handle = CXHandle(type: .generic, value: call.sessionId)
|
||||||
let startCallAction = CXStartCallAction(call: call.callID, handle: handle)
|
let startCallAction = CXStartCallAction(call: call.callId, handle: handle)
|
||||||
|
|
||||||
startCallAction.isVideo = false
|
startCallAction.isVideo = false
|
||||||
|
|
||||||
|
@ -16,7 +21,8 @@ extension SessionCallManager {
|
||||||
transaction.addAction(startCallAction)
|
transaction.addAction(startCallAction)
|
||||||
|
|
||||||
requestTransaction(transaction, completion: completion)
|
requestTransaction(transaction, completion: completion)
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
startCallAction()
|
startCallAction()
|
||||||
completion?(nil)
|
completion?(nil)
|
||||||
}
|
}
|
||||||
|
@ -24,12 +30,13 @@ extension SessionCallManager {
|
||||||
|
|
||||||
public func answerCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
public func answerCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||||
if callController != nil {
|
if callController != nil {
|
||||||
let answerCallAction = CXAnswerCallAction(call: call.callID)
|
let answerCallAction = CXAnswerCallAction(call: call.callId)
|
||||||
let transaction = CXTransaction()
|
let transaction = CXTransaction()
|
||||||
transaction.addAction(answerCallAction)
|
transaction.addAction(answerCallAction)
|
||||||
|
|
||||||
requestTransaction(transaction, completion: completion)
|
requestTransaction(transaction, completion: completion)
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
answerCallAction()
|
answerCallAction()
|
||||||
completion?(nil)
|
completion?(nil)
|
||||||
}
|
}
|
||||||
|
@ -37,12 +44,13 @@ extension SessionCallManager {
|
||||||
|
|
||||||
public func endCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
public func endCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||||
if callController != nil {
|
if callController != nil {
|
||||||
let endCallAction = CXEndCallAction(call: call.callID)
|
let endCallAction = CXEndCallAction(call: call.callId)
|
||||||
let transaction = CXTransaction()
|
let transaction = CXTransaction()
|
||||||
transaction.addAction(endCallAction)
|
transaction.addAction(endCallAction)
|
||||||
|
|
||||||
requestTransaction(transaction, completion: completion)
|
requestTransaction(transaction, completion: completion)
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
endCallAction()
|
endCallAction()
|
||||||
completion?(nil)
|
completion?(nil)
|
||||||
}
|
}
|
||||||
|
@ -51,7 +59,7 @@ extension SessionCallManager {
|
||||||
// Not currently in use
|
// Not currently in use
|
||||||
public func setOnHoldStatus(for call: SessionCall) {
|
public func setOnHoldStatus(for call: SessionCall) {
|
||||||
if callController != nil {
|
if callController != nil {
|
||||||
let setHeldCallAction = CXSetHeldCallAction(call: call.callID, onHold: true)
|
let setHeldCallAction = CXSetHeldCallAction(call: call.callId, onHold: true)
|
||||||
let transaction = CXTransaction()
|
let transaction = CXTransaction()
|
||||||
transaction.addAction(setHeldCallAction)
|
transaction.addAction(setHeldCallAction)
|
||||||
|
|
||||||
|
@ -63,9 +71,11 @@ extension SessionCallManager {
|
||||||
callController?.request(transaction) { error in
|
callController?.request(transaction) { error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SNLog("Error requesting transaction: \(error)")
|
SNLog("Error requesting transaction: \(error)")
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
SNLog("Requested transaction successfully")
|
SNLog("Requested transaction successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
completion?(error)
|
completion?(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,22 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
import CallKit
|
import CallKit
|
||||||
|
import SignalCoreKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
extension SessionCallManager: CXProviderDelegate {
|
extension SessionCallManager: CXProviderDelegate {
|
||||||
public func providerDidReset(_ provider: CXProvider) {
|
public func providerDidReset(_ provider: CXProvider) {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
currentCall?.endSessionCall()
|
(currentCall as? SessionCall)?.endSessionCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
public func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
if startCallAction() {
|
if startCallAction() {
|
||||||
action.fulfill()
|
action.fulfill()
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
action.fail()
|
action.fail()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,14 +24,18 @@ extension SessionCallManager: CXProviderDelegate {
|
||||||
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
print("[CallKit] Perform CXAnswerCallAction")
|
print("[CallKit] Perform CXAnswerCallAction")
|
||||||
guard let call = self.currentCall else { return action.fail() }
|
|
||||||
|
guard let call: SessionCall = (self.currentCall as? SessionCall) else { return action.fail() }
|
||||||
|
|
||||||
if CurrentAppContext().isMainAppAndActive {
|
if CurrentAppContext().isMainAppAndActive {
|
||||||
if answerCallAction() {
|
if answerCallAction() {
|
||||||
action.fulfill()
|
action.fulfill()
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
action.fail()
|
action.fail()
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
call.answerSessionCallInBackground(action: action)
|
call.answerSessionCallInBackground(action: action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -33,9 +43,11 @@ extension SessionCallManager: CXProviderDelegate {
|
||||||
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
||||||
print("[CallKit] Perform CXEndCallAction")
|
print("[CallKit] Perform CXEndCallAction")
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
if endCallAction() {
|
if endCallAction() {
|
||||||
action.fulfill()
|
action.fulfill()
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
action.fail()
|
action.fail()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -43,9 +55,11 @@ extension SessionCallManager: CXProviderDelegate {
|
||||||
public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
||||||
print("[CallKit] Perform CXSetMutedCallAction, isMuted: \(action.isMuted)")
|
print("[CallKit] Perform CXSetMutedCallAction, isMuted: \(action.isMuted)")
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
if setMutedCallAction(isMuted: action.isMuted) {
|
if setMutedCallAction(isMuted: action.isMuted) {
|
||||||
action.fulfill()
|
action.fulfill()
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
action.fail()
|
action.fail()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -61,7 +75,8 @@ extension SessionCallManager: CXProviderDelegate {
|
||||||
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
||||||
print("[CallKit] Audio session did activate.")
|
print("[CallKit] Audio session did activate.")
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
guard let call = self.currentCall else { return }
|
guard let call: SessionCall = (self.currentCall as? SessionCall) else { return }
|
||||||
|
|
||||||
call.webRTCSession.audioSessionDidActivate(audioSession)
|
call.webRTCSession.audioSessionDidActivate(audioSession)
|
||||||
if call.isOutgoing && !call.hasConnected { CallRingTonePlayer.shared.startPlayingRingTone() }
|
if call.isOutgoing && !call.hasConnected { CallRingTonePlayer.shared.startPlayingRingTone() }
|
||||||
}
|
}
|
||||||
|
@ -69,7 +84,8 @@ extension SessionCallManager: CXProviderDelegate {
|
||||||
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
||||||
print("[CallKit] Audio session did deactivate.")
|
print("[CallKit] Audio session did deactivate.")
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
guard let call = self.currentCall else { return }
|
guard let call: SessionCall = (self.currentCall as? SessionCall) else { return }
|
||||||
|
|
||||||
call.webRTCSession.audioSessionDidDeactivate(audioSession)
|
call.webRTCSession.audioSessionDidDeactivate(audioSession)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,15 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
import CallKit
|
import CallKit
|
||||||
|
import GRDB
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
|
||||||
public final class SessionCallManager: NSObject {
|
public final class SessionCallManager: NSObject, CallManagerProtocol {
|
||||||
let provider: CXProvider?
|
let provider: CXProvider?
|
||||||
let callController: CXCallController?
|
let callController: CXCallController?
|
||||||
var currentCall: SessionCall? = nil {
|
|
||||||
|
public var currentCall: CurrentCallProtocol? = nil {
|
||||||
willSet {
|
willSet {
|
||||||
if (newValue != nil) {
|
if (newValue != nil) {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
@ -19,13 +24,14 @@ public final class SessionCallManager: NSObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static var _sharedProvider: CXProvider?
|
private static var _sharedProvider: CXProvider?
|
||||||
class func sharedProvider(useSystemCallLog: Bool) -> CXProvider {
|
static func sharedProvider(useSystemCallLog: Bool) -> CXProvider {
|
||||||
let configuration = buildProviderConfiguration(useSystemCallLog: useSystemCallLog)
|
let configuration = buildProviderConfiguration(useSystemCallLog: useSystemCallLog)
|
||||||
|
|
||||||
if let sharedProvider = self._sharedProvider {
|
if let sharedProvider = self._sharedProvider {
|
||||||
sharedProvider.configuration = configuration
|
sharedProvider.configuration = configuration
|
||||||
return sharedProvider
|
return sharedProvider
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
SwiftSingletons.register(self)
|
SwiftSingletons.register(self)
|
||||||
let provider = CXProvider(configuration: configuration)
|
let provider = CXProvider(configuration: configuration)
|
||||||
_sharedProvider = provider
|
_sharedProvider = provider
|
||||||
|
@ -33,9 +39,8 @@ public final class SessionCallManager: NSObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration {
|
static func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration {
|
||||||
let localizedName = NSLocalizedString("APPLICATION_NAME", comment: "Name of application")
|
let providerConfiguration = CXProviderConfiguration(localizedName: "Session")
|
||||||
let providerConfiguration = CXProviderConfiguration(localizedName: localizedName)
|
|
||||||
providerConfiguration.supportsVideo = true
|
providerConfiguration.supportsVideo = true
|
||||||
providerConfiguration.maximumCallGroups = 1
|
providerConfiguration.maximumCallGroups = 1
|
||||||
providerConfiguration.maximumCallsPerCallGroup = 1
|
providerConfiguration.maximumCallsPerCallGroup = 1
|
||||||
|
@ -47,30 +52,47 @@ public final class SessionCallManager: NSObject {
|
||||||
return providerConfiguration
|
return providerConfiguration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(useSystemCallLog: Bool = false) {
|
init(useSystemCallLog: Bool = false) {
|
||||||
AssertIsOnMainThread()
|
if Preferences.isCallKitSupported {
|
||||||
if SSKPreferences.isCallKitSupported {
|
self.provider = SessionCallManager.sharedProvider(useSystemCallLog: useSystemCallLog)
|
||||||
self.provider = type(of: self).sharedProvider(useSystemCallLog: useSystemCallLog)
|
|
||||||
self.callController = CXCallController()
|
self.callController = CXCallController()
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
self.provider = nil
|
self.provider = nil
|
||||||
self.callController = nil
|
self.callController = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
// We cannot assert singleton here, because this class gets rebuilt when the user changes relevant call settings
|
// We cannot assert singleton here, because this class gets rebuilt when the user changes relevant call settings
|
||||||
self.provider?.setDelegate(self, queue: nil)
|
self.provider?.setDelegate(self, queue: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Report calls
|
// MARK: - Report calls
|
||||||
|
|
||||||
|
public static func reportFakeCall(info: String) {
|
||||||
|
SessionCallManager.sharedProvider(useSystemCallLog: false)
|
||||||
|
.reportNewIncomingCall(
|
||||||
|
with: UUID(),
|
||||||
|
update: CXCallUpdate()
|
||||||
|
) { _ in
|
||||||
|
SNLog("[Calls] Reported fake incoming call to CallKit due to: \(info)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public func reportOutgoingCall(_ call: SessionCall) {
|
public func reportOutgoingCall(_ call: SessionCall) {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing")
|
||||||
|
|
||||||
call.stateDidChange = {
|
call.stateDidChange = {
|
||||||
if call.hasStartedConnecting {
|
if call.hasStartedConnecting {
|
||||||
self.provider?.reportOutgoingCall(with: call.callID, startedConnectingAt: call.connectingDate)
|
self.provider?.reportOutgoingCall(with: call.callId, startedConnectingAt: call.connectingDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
if call.hasConnected {
|
if call.hasConnected {
|
||||||
self.provider?.reportOutgoingCall(with: call.callID, connectedAt: call.connectedDate)
|
self.provider?.reportOutgoingCall(with: call.callId, connectedAt: call.connectedDate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -82,47 +104,61 @@ public final class SessionCallManager: NSObject {
|
||||||
// Construct a CXCallUpdate describing the incoming call, including the caller.
|
// Construct a CXCallUpdate describing the incoming call, including the caller.
|
||||||
let update = CXCallUpdate()
|
let update = CXCallUpdate()
|
||||||
update.localizedCallerName = callerName
|
update.localizedCallerName = callerName
|
||||||
update.remoteHandle = CXHandle(type: .generic, value: call.callID.uuidString)
|
update.remoteHandle = CXHandle(type: .generic, value: call.callId.uuidString)
|
||||||
update.hasVideo = false
|
update.hasVideo = false
|
||||||
|
|
||||||
disableUnsupportedFeatures(callUpdate: update)
|
disableUnsupportedFeatures(callUpdate: update)
|
||||||
|
|
||||||
// Report the incoming call to the system
|
// Report the incoming call to the system
|
||||||
provider.reportNewIncomingCall(with: call.callID, update: update) { error in
|
provider.reportNewIncomingCall(with: call.callId, update: update) { error in
|
||||||
guard error == nil else {
|
guard error == nil else {
|
||||||
self.reportCurrentCallEnded(reason: .failed)
|
self.reportCurrentCallEnded(reason: .failed)
|
||||||
completion(error)
|
completion(error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing")
|
||||||
completion(nil)
|
completion(nil)
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
else {
|
||||||
|
SessionCallManager.reportFakeCall(info: "No CXProvider instance")
|
||||||
|
UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing")
|
||||||
completion(nil)
|
completion(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reportCurrentCallEnded(reason: CXCallEndedReason?) {
|
public func reportCurrentCallEnded(reason: CXCallEndedReason?) {
|
||||||
guard let call = currentCall else { return }
|
guard Thread.isMainThread else {
|
||||||
if let reason = reason {
|
DispatchQueue.main.async {
|
||||||
self.provider?.reportCall(with: call.callID, endedAt: nil, reason: reason)
|
self.reportCurrentCallEnded(reason: reason)
|
||||||
switch (reason) {
|
|
||||||
case .answeredElsewhere: call.updateCallMessage(mode: .answeredElsewhere)
|
|
||||||
case .unanswered: call.updateCallMessage(mode: .unanswered)
|
|
||||||
case .declinedElsewhere: call.updateCallMessage(mode: .local)
|
|
||||||
default: call.updateCallMessage(mode: .remote)
|
|
||||||
}
|
}
|
||||||
} else {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let call = currentCall else { return }
|
||||||
|
|
||||||
|
if let reason = reason {
|
||||||
|
self.provider?.reportCall(with: call.callId, endedAt: nil, reason: reason)
|
||||||
|
|
||||||
|
switch (reason) {
|
||||||
|
case .answeredElsewhere: call.updateCallMessage(mode: .answeredElsewhere)
|
||||||
|
case .unanswered: call.updateCallMessage(mode: .unanswered)
|
||||||
|
case .declinedElsewhere: call.updateCallMessage(mode: .local)
|
||||||
|
default: call.updateCallMessage(mode: .remote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
call.updateCallMessage(mode: .local)
|
call.updateCallMessage(mode: .local)
|
||||||
}
|
}
|
||||||
|
|
||||||
call.webRTCSession.dropConnection()
|
call.webRTCSession.dropConnection()
|
||||||
self.currentCall = nil
|
self.currentCall = nil
|
||||||
WebRTCSession.current = nil
|
WebRTCSession.current = nil
|
||||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(false, forKey: "isCallOngoing")
|
UserDefaults.sharedLokiProject?.set(false, forKey: "isCallOngoing")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Util
|
// MARK: - Util
|
||||||
|
|
||||||
private func disableUnsupportedFeatures(callUpdate: CXCallUpdate) {
|
private func disableUnsupportedFeatures(callUpdate: CXCallUpdate) {
|
||||||
// Call Holding is failing to restart audio when "swapping" calls on the CallKit screen
|
// Call Holding is failing to restart audio when "swapping" calls on the CallKit screen
|
||||||
// until user returns to in-app call screen.
|
// until user returns to in-app call screen.
|
||||||
|
@ -136,17 +172,67 @@ public final class SessionCallManager: NSObject {
|
||||||
callUpdate.supportsDTMF = false
|
callUpdate.supportsDTMF = false
|
||||||
}
|
}
|
||||||
|
|
||||||
public func handleIncomingCallOfferInBusyState(offerMessage: CallMessage, using transaction: YapDatabaseReadWriteTransaction) {
|
// MARK: - UI
|
||||||
guard let caller = offerMessage.sender, let thread = TSContactThread.fetch(for: caller, using: transaction) else { return }
|
|
||||||
let message = CallMessage()
|
public func showCallUIForCall(caller: String, uuid: String, mode: CallMode, interactionId: Int64?) {
|
||||||
message.uuid = offerMessage.uuid
|
guard Thread.isMainThread else {
|
||||||
message.kind = .endCall
|
DispatchQueue.main.async {
|
||||||
SNLog("[Calls] Sending end call message because there is an ongoing call.")
|
self.showCallUIForCall(caller: caller, uuid: uuid, mode: mode, interactionId: interactionId)
|
||||||
MessageSender.sendNonDurably(message, in: thread, using: transaction).retainUntilComplete()
|
}
|
||||||
let infoMessage = TSInfoMessage.from(offerMessage, associatedWith: thread)
|
return
|
||||||
infoMessage.updateCallInfoMessage(.missed, using: transaction)
|
}
|
||||||
|
guard let call: SessionCall = Storage.shared.read({ db in SessionCall(db, for: caller, uuid: uuid, mode: mode) }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
call.callInteractionId = interactionId
|
||||||
|
call.reportIncomingCallIfNeeded { error in
|
||||||
|
if let error = error {
|
||||||
|
SNLog("[Calls] Failed to report incoming call to CallKit due to error: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard CurrentAppContext().isMainAppAndActive else { return }
|
||||||
|
guard let presentingVC = CurrentAppContext().frontmostViewController() else {
|
||||||
|
preconditionFailure() // FIXME: Handle more gracefully
|
||||||
|
}
|
||||||
|
|
||||||
|
if let conversationVC: ConversationVC = presentingVC as? ConversationVC, conversationVC.viewModel.threadData.threadId == call.sessionId {
|
||||||
|
let callVC = CallVC(for: call)
|
||||||
|
callVC.conversationVC = conversationVC
|
||||||
|
conversationVC.inputAccessoryView?.isHidden = true
|
||||||
|
conversationVC.inputAccessoryView?.alpha = 0
|
||||||
|
presentingVC.present(callVC, animated: true, completion: nil)
|
||||||
|
}
|
||||||
|
else if !Preferences.isCallKitSupported {
|
||||||
|
let incomingCallBanner = IncomingCallBanner(for: call)
|
||||||
|
incomingCallBanner.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func handleAnswerMessage(_ message: CallMessage) {
|
||||||
|
guard Thread.isMainThread else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.handleAnswerMessage(message)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
(CurrentAppContext().frontmostViewController() as? CallVC)?.handleAnswerMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func dismissAllCallUI() {
|
||||||
|
guard Thread.isMainThread else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.dismissAllCallUI()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
IncomingCallBanner.current?.dismiss()
|
||||||
|
(CurrentAppContext().frontmostViewController() as? CallVC)?.handleEndCallMessage()
|
||||||
|
MiniCallView.current?.dismiss()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import SessionUtilitiesKit
|
||||||
import UIKit
|
import UIKit
|
||||||
import MediaPlayer
|
import MediaPlayer
|
||||||
|
|
||||||
final class CallVC : UIViewController, VideoPreviewDelegate {
|
final class CallVC: UIViewController, VideoPreviewDelegate {
|
||||||
let call: SessionCall
|
let call: SessionCall
|
||||||
var latestKnownAudioOutputDeviceName: String?
|
var latestKnownAudioOutputDeviceName: String?
|
||||||
var durationTimer: Timer?
|
var durationTimer: Timer?
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import UIKit
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
@objc
|
import UIKit
|
||||||
final class CallMissedTipsModal : Modal {
|
import SessionUIKit
|
||||||
|
|
||||||
|
final class CallMissedTipsModal: Modal {
|
||||||
private let caller: String
|
private let caller: String
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
@objc
|
|
||||||
init(caller: String) {
|
init(caller: String) {
|
||||||
self.caller = caller
|
self.caller = caller
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
@ -26,27 +28,37 @@ final class CallMissedTipsModal : Modal {
|
||||||
let tipsIconImageView = UIImageView(image: UIImage(named: "Tips")?.withTint(Colors.text))
|
let tipsIconImageView = UIImageView(image: UIImage(named: "Tips")?.withTint(Colors.text))
|
||||||
tipsIconImageView.set(.width, to: 19)
|
tipsIconImageView.set(.width, to: 19)
|
||||||
tipsIconImageView.set(.height, to: 28)
|
tipsIconImageView.set(.height, to: 28)
|
||||||
|
|
||||||
|
// Tips icon container view
|
||||||
|
let tipsIconContainerView = UIView()
|
||||||
|
tipsIconContainerView.addSubview(tipsIconImageView)
|
||||||
|
tipsIconImageView.pin(.top, to: .top, of: tipsIconContainerView)
|
||||||
|
tipsIconImageView.pin(.bottom, to: .bottom, of: tipsIconContainerView)
|
||||||
|
tipsIconImageView.center(in: tipsIconContainerView)
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
let titleLabel = UILabel()
|
let titleLabel = UILabel()
|
||||||
titleLabel.textColor = Colors.text
|
titleLabel.textColor = Colors.text
|
||||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||||
titleLabel.text = NSLocalizedString("modal_call_missed_tips_title", comment: "")
|
titleLabel.text = "modal_call_missed_tips_title".localized()
|
||||||
titleLabel.textAlignment = .center
|
titleLabel.textAlignment = .center
|
||||||
|
|
||||||
// Message
|
// Message
|
||||||
let messageLabel = UILabel()
|
let messageLabel = UILabel()
|
||||||
messageLabel.textColor = Colors.text
|
messageLabel.textColor = Colors.text
|
||||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||||
let message = String(format: NSLocalizedString("modal_call_missed_tips_explanation", comment: ""), caller)
|
messageLabel.text = String(format: "modal_call_missed_tips_explanation".localized(), caller)
|
||||||
messageLabel.text = message
|
|
||||||
messageLabel.numberOfLines = 0
|
messageLabel.numberOfLines = 0
|
||||||
messageLabel.lineBreakMode = .byWordWrapping
|
messageLabel.lineBreakMode = .byWordWrapping
|
||||||
messageLabel.textAlignment = .natural
|
messageLabel.textAlignment = .natural
|
||||||
|
|
||||||
// Cancel Button
|
// Cancel Button
|
||||||
cancelButton.setTitle(NSLocalizedString("OK", comment: ""), for: .normal)
|
cancelButton.setTitle("BUTTON_OK".localized(), for: .normal)
|
||||||
|
|
||||||
// Main stack view
|
// Main stack view
|
||||||
let mainStackView = UIStackView(arrangedSubviews: [ tipsIconImageView, titleLabel, messageLabel, cancelButton ])
|
let mainStackView = UIStackView(arrangedSubviews: [ tipsIconContainerView, titleLabel, messageLabel, cancelButton ])
|
||||||
mainStackView.axis = .vertical
|
mainStackView.axis = .vertical
|
||||||
mainStackView.alignment = .center
|
mainStackView.alignment = .fill
|
||||||
mainStackView.spacing = Values.largeSpacing
|
mainStackView.spacing = Values.largeSpacing
|
||||||
contentView.addSubview(mainStackView)
|
contentView.addSubview(mainStackView)
|
||||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import WebRTC
|
import WebRTC
|
||||||
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
|
||||||
final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
||||||
|
@ -82,8 +85,12 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
||||||
self.layer.cornerRadius = Values.largeSpacing
|
self.layer.cornerRadius = Values.largeSpacing
|
||||||
self.layer.masksToBounds = true
|
self.layer.masksToBounds = true
|
||||||
self.set(.height, to: 100)
|
self.set(.height, to: 100)
|
||||||
profilePictureView.publicKey = call.sessionID
|
|
||||||
profilePictureView.update()
|
profilePictureView.update(
|
||||||
|
publicKey: call.sessionId,
|
||||||
|
profile: Profile.fetchOrCreate(id: call.sessionId),
|
||||||
|
threadVariant: .contact
|
||||||
|
)
|
||||||
displayNameLabel.text = call.contactName
|
displayNameLabel.text = call.contactName
|
||||||
let stackView = UIStackView(arrangedSubviews: [profilePictureView, displayNameLabel, hangUpButton, answerButton])
|
let stackView = UIStackView(arrangedSubviews: [profilePictureView, displayNameLabel, hangUpButton, answerButton])
|
||||||
stackView.axis = .horizontal
|
stackView.axis = .horizontal
|
||||||
|
|
|
@ -1,63 +1,77 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import GRDB
|
||||||
import PromiseKit
|
import PromiseKit
|
||||||
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
@objc(SNEditClosedGroupVC)
|
@objc(SNEditClosedGroupVC)
|
||||||
final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate {
|
final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate {
|
||||||
private let thread: TSGroupThread
|
private struct GroupMemberDisplayInfo: FetchableRecord, Decodable {
|
||||||
private var name = ""
|
let profileId: String
|
||||||
private var zombies: Set<String> = []
|
let role: GroupMember.Role
|
||||||
private var membersAndZombies: [String] = [] { didSet { handleMembersChanged() } }
|
let profile: Profile?
|
||||||
|
}
|
||||||
|
|
||||||
|
private let threadId: String
|
||||||
|
private var originalName: String = ""
|
||||||
|
private var originalMembersAndZombieIds: Set<String> = []
|
||||||
|
private var name: String = ""
|
||||||
|
private var hasContactsToAdd: Bool = false
|
||||||
|
private var userPublicKey: String = ""
|
||||||
|
private var membersAndZombies: [GroupMemberDisplayInfo] = []
|
||||||
|
private var adminIds: Set<String> = []
|
||||||
private var isEditingGroupName = false { didSet { handleIsEditingGroupNameChanged() } }
|
private var isEditingGroupName = false { didSet { handleIsEditingGroupNameChanged() } }
|
||||||
private var tableViewHeightConstraint: NSLayoutConstraint!
|
private var tableViewHeightConstraint: NSLayoutConstraint!
|
||||||
|
|
||||||
private lazy var groupPublicKey: String = {
|
// MARK: - Components
|
||||||
let groupID = thread.groupModel.groupId
|
|
||||||
return LKGroupUtilities.getDecodedGroupID(groupID)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// MARK: Components
|
|
||||||
private lazy var groupNameLabel: UILabel = {
|
private lazy var groupNameLabel: UILabel = {
|
||||||
let result = UILabel()
|
let result: UILabel = UILabel()
|
||||||
result.textColor = Colors.text
|
result.textColor = Colors.text
|
||||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||||
result.lineBreakMode = .byTruncatingTail
|
result.lineBreakMode = .byTruncatingTail
|
||||||
result.textAlignment = .center
|
result.textAlignment = .center
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var groupNameTextField: TextField = {
|
private lazy var groupNameTextField: TextField = {
|
||||||
let result = TextField(placeholder: "Enter a group name", usesDefaultHeight: false)
|
let result: TextField = TextField(placeholder: "Enter a group name", usesDefaultHeight: false)
|
||||||
result.textAlignment = .center
|
result.textAlignment = .center
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var addMembersButton: Button = {
|
private lazy var addMembersButton: Button = {
|
||||||
let result = Button(style: .prominentOutline, size: .large)
|
let result: Button = Button(style: .prominentOutline, size: .large)
|
||||||
result.setTitle("Add Members", for: UIControl.State.normal)
|
result.setTitle("Add Members", for: UIControl.State.normal)
|
||||||
result.addTarget(self, action: #selector(addMembers), for: UIControl.Event.touchUpInside)
|
result.addTarget(self, action: #selector(addMembers), for: UIControl.Event.touchUpInside)
|
||||||
result.contentEdgeInsets = UIEdgeInsets(top: 0, leading: Values.mediumSpacing, bottom: 0, trailing: Values.mediumSpacing)
|
result.contentEdgeInsets = UIEdgeInsets(top: 0, leading: Values.mediumSpacing, bottom: 0, trailing: Values.mediumSpacing)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@objc private lazy var tableView: UITableView = {
|
@objc private lazy var tableView: UITableView = {
|
||||||
let result = UITableView()
|
let result: UITableView = UITableView()
|
||||||
result.dataSource = self
|
result.dataSource = self
|
||||||
result.delegate = self
|
result.delegate = self
|
||||||
result.register(UserCell.self, forCellReuseIdentifier: "UserCell")
|
|
||||||
result.separatorStyle = .none
|
result.separatorStyle = .none
|
||||||
result.backgroundColor = .clear
|
result.backgroundColor = .clear
|
||||||
result.isScrollEnabled = false
|
result.isScrollEnabled = false
|
||||||
|
result.register(view: UserCell.self)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
@objc(initWithThreadID:)
|
|
||||||
init(with threadID: String) {
|
@objc(initWithThreadId:)
|
||||||
var thread: TSGroupThread!
|
init(with threadId: String) {
|
||||||
Storage.read { transaction in
|
self.threadId = threadId
|
||||||
thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction)!
|
|
||||||
}
|
|
||||||
self.thread = thread
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,27 +81,62 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
setUpGradientBackground()
|
setUpGradientBackground()
|
||||||
setUpNavBarStyle()
|
setUpNavBarStyle()
|
||||||
setNavBarTitle("Edit Group")
|
setNavBarTitle("Edit Group")
|
||||||
|
|
||||||
let backButton = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil)
|
let backButton = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil)
|
||||||
backButton.tintColor = Colors.text
|
backButton.tintColor = Colors.text
|
||||||
navigationItem.backBarButtonItem = backButton
|
navigationItem.backBarButtonItem = backButton
|
||||||
func getDisplayName(for publicKey: String) -> String {
|
|
||||||
return Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey
|
let threadId: String = self.threadId
|
||||||
|
|
||||||
|
Storage.shared.read { [weak self] db in
|
||||||
|
self?.userPublicKey = getUserHexEncodedPublicKey(db)
|
||||||
|
self?.name = try ClosedGroup
|
||||||
|
.select(.name)
|
||||||
|
.filter(id: threadId)
|
||||||
|
.asRequest(of: String.self)
|
||||||
|
.fetchOne(db)
|
||||||
|
.defaulting(to: "Group")
|
||||||
|
self?.originalName = (self?.name ?? "")
|
||||||
|
|
||||||
|
let profileAlias: TypedTableAlias<Profile> = TypedTableAlias()
|
||||||
|
let allGroupMembers: [GroupMemberDisplayInfo] = try GroupMember
|
||||||
|
.filter(GroupMember.Columns.groupId == threadId)
|
||||||
|
.including(optional: GroupMember.profile.aliased(profileAlias))
|
||||||
|
.order(
|
||||||
|
(GroupMember.Columns.role == GroupMember.Role.zombie), // Non-zombies at the top
|
||||||
|
profileAlias[.nickname],
|
||||||
|
profileAlias[.name],
|
||||||
|
GroupMember.Columns.profileId
|
||||||
|
)
|
||||||
|
.asRequest(of: GroupMemberDisplayInfo.self)
|
||||||
|
.fetchAll(db)
|
||||||
|
self?.membersAndZombies = allGroupMembers
|
||||||
|
.filter { $0.role == .standard || $0.role == .zombie }
|
||||||
|
self?.adminIds = allGroupMembers
|
||||||
|
.filter { $0.role == .admin }
|
||||||
|
.map { $0.profileId }
|
||||||
|
.asSet()
|
||||||
|
|
||||||
|
let uniqueGroupMemberIds: Set<String> = allGroupMembers
|
||||||
|
.map { $0.profileId }
|
||||||
|
.asSet()
|
||||||
|
self?.originalMembersAndZombieIds = uniqueGroupMemberIds
|
||||||
|
self?.hasContactsToAdd = ((try Profile.fetchCount(db) - uniqueGroupMemberIds.count) > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
// Always show zombies at the bottom
|
|
||||||
zombies = Storage.shared.getZombieMembers(for: groupPublicKey)
|
|
||||||
membersAndZombies = GroupUtilities.getClosedGroupMembers(thread).sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
|
|
||||||
+ zombies.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
|
|
||||||
updateNavigationBarButtons()
|
updateNavigationBarButtons()
|
||||||
name = thread.groupModel.groupName!
|
handleMembersChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy() {
|
||||||
// Group name container
|
// Group name container
|
||||||
groupNameLabel.text = thread.groupModel.groupName
|
groupNameLabel.text = name
|
||||||
|
|
||||||
let groupNameContainer = UIView()
|
let groupNameContainer = UIView()
|
||||||
groupNameContainer.addSubview(groupNameLabel)
|
groupNameContainer.addSubview(groupNameLabel)
|
||||||
groupNameLabel.pin(to: groupNameContainer)
|
groupNameLabel.pin(to: groupNameContainer)
|
||||||
|
@ -95,6 +144,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
||||||
groupNameTextField.pin(to: groupNameContainer)
|
groupNameTextField.pin(to: groupNameContainer)
|
||||||
groupNameContainer.set(.height, to: 40)
|
groupNameContainer.set(.height, to: 40)
|
||||||
groupNameTextField.alpha = 0
|
groupNameTextField.alpha = 0
|
||||||
|
|
||||||
// Top container
|
// Top container
|
||||||
let topContainer = UIView()
|
let topContainer = UIView()
|
||||||
topContainer.addSubview(groupNameContainer)
|
topContainer.addSubview(groupNameContainer)
|
||||||
|
@ -102,19 +152,21 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
||||||
topContainer.set(.height, to: 40)
|
topContainer.set(.height, to: 40)
|
||||||
let topContainerTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(showEditGroupNameUI))
|
let topContainerTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(showEditGroupNameUI))
|
||||||
topContainer.addGestureRecognizer(topContainerTapGestureRecognizer)
|
topContainer.addGestureRecognizer(topContainerTapGestureRecognizer)
|
||||||
|
|
||||||
// Members label
|
// Members label
|
||||||
let membersLabel = UILabel()
|
let membersLabel = UILabel()
|
||||||
membersLabel.textColor = Colors.text
|
membersLabel.textColor = Colors.text
|
||||||
membersLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
membersLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||||
membersLabel.text = "Members"
|
membersLabel.text = "Members"
|
||||||
|
|
||||||
// Add members button
|
// Add members button
|
||||||
let hasContactsToAdd = !Set(ContactUtilities.getAllContacts()).subtracting(self.membersAndZombies).isEmpty
|
if !self.hasContactsToAdd {
|
||||||
if (!hasContactsToAdd) {
|
|
||||||
addMembersButton.isUserInteractionEnabled = false
|
addMembersButton.isUserInteractionEnabled = false
|
||||||
let disabledColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
let disabledColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||||
addMembersButton.layer.borderColor = disabledColor.cgColor
|
addMembersButton.layer.borderColor = disabledColor.cgColor
|
||||||
addMembersButton.setTitleColor(disabledColor, for: UIControl.State.normal)
|
addMembersButton.setTitleColor(disabledColor, for: UIControl.State.normal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middle stack view
|
// Middle stack view
|
||||||
let middleStackView = UIStackView(arrangedSubviews: [ membersLabel, addMembersButton ])
|
let middleStackView = UIStackView(arrangedSubviews: [ membersLabel, addMembersButton ])
|
||||||
middleStackView.axis = .horizontal
|
middleStackView.axis = .horizontal
|
||||||
|
@ -122,8 +174,10 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
||||||
middleStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.mediumSpacing, bottom: Values.smallSpacing, trailing: Values.mediumSpacing)
|
middleStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.mediumSpacing, bottom: Values.smallSpacing, trailing: Values.mediumSpacing)
|
||||||
middleStackView.isLayoutMarginsRelativeArrangement = true
|
middleStackView.isLayoutMarginsRelativeArrangement = true
|
||||||
middleStackView.set(.height, to: Values.largeButtonHeight + Values.smallSpacing * 2)
|
middleStackView.set(.height, to: Values.largeButtonHeight + Values.smallSpacing * 2)
|
||||||
|
|
||||||
// Table view
|
// Table view
|
||||||
tableViewHeightConstraint = tableView.set(.height, to: 0)
|
tableViewHeightConstraint = tableView.set(.height, to: 0)
|
||||||
|
|
||||||
// Main stack view
|
// Main stack view
|
||||||
let mainStackView = UIStackView(arrangedSubviews: [
|
let mainStackView = UIStackView(arrangedSubviews: [
|
||||||
UIView.vSpacer(Values.veryLargeSpacing),
|
UIView.vSpacer(Values.veryLargeSpacing),
|
||||||
|
@ -137,6 +191,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
||||||
mainStackView.axis = .vertical
|
mainStackView.axis = .vertical
|
||||||
mainStackView.alignment = .fill
|
mainStackView.alignment = .fill
|
||||||
mainStackView.set(.width, to: UIScreen.main.bounds.width)
|
mainStackView.set(.width, to: UIScreen.main.bounds.width)
|
||||||
|
|
||||||
// Scroll view
|
// Scroll view
|
||||||
let scrollView = UIScrollView()
|
let scrollView = UIScrollView()
|
||||||
scrollView.showsVerticalScrollIndicator = false
|
scrollView.showsVerticalScrollIndicator = false
|
||||||
|
@ -152,41 +207,49 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell
|
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
|
||||||
let publicKey = membersAndZombies[indexPath.row]
|
cell.update(
|
||||||
cell.publicKey = publicKey
|
with: membersAndZombies[indexPath.row].profileId,
|
||||||
cell.isZombie = zombies.contains(publicKey)
|
profile: membersAndZombies[indexPath.row].profile,
|
||||||
let userPublicKey = getUserHexEncodedPublicKey()
|
isZombie: (membersAndZombies[indexPath.row].role == .zombie),
|
||||||
let isCurrentUserAdmin = thread.groupModel.groupAdminIds.contains(userPublicKey)
|
accessory: (adminIds.contains(userPublicKey) ?
|
||||||
cell.accessory = !isCurrentUserAdmin ? .lock : .none
|
.none :
|
||||||
cell.update()
|
.lock
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||||
let userPublicKey = getUserHexEncodedPublicKey()
|
return adminIds.contains(userPublicKey)
|
||||||
return thread.groupModel.groupAdminIds.contains(userPublicKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
||||||
let publicKey = membersAndZombies[indexPath.row]
|
let profileId: String = self.membersAndZombies[indexPath.row].profileId
|
||||||
|
|
||||||
let removeAction = UITableViewRowAction(style: .destructive, title: "Remove") { [weak self] _, _ in
|
let removeAction = UITableViewRowAction(style: .destructive, title: "Remove") { [weak self] _, _ in
|
||||||
guard let self = self, let index = self.membersAndZombies.firstIndex(of: publicKey) else { return }
|
self?.adminIds.remove(profileId)
|
||||||
self.membersAndZombies.remove(at: index)
|
self?.membersAndZombies.remove(at: indexPath.row)
|
||||||
|
self?.handleMembersChanged()
|
||||||
}
|
}
|
||||||
removeAction.backgroundColor = Colors.destructive
|
removeAction.backgroundColor = Colors.destructive
|
||||||
|
|
||||||
return [ removeAction ]
|
return [ removeAction ]
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Updating
|
// MARK: - Updating
|
||||||
|
|
||||||
private func updateNavigationBarButtons() {
|
private func updateNavigationBarButtons() {
|
||||||
if isEditingGroupName {
|
if isEditingGroupName {
|
||||||
let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(handleCancelGroupNameEditingButtonTapped))
|
let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(handleCancelGroupNameEditingButtonTapped))
|
||||||
cancelButton.tintColor = Colors.text
|
cancelButton.tintColor = Colors.text
|
||||||
navigationItem.leftBarButtonItem = cancelButton
|
navigationItem.leftBarButtonItem = cancelButton
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
navigationItem.leftBarButtonItem = nil
|
navigationItem.leftBarButtonItem = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(handleDoneButtonTapped))
|
let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(handleDoneButtonTapped))
|
||||||
doneButton.tintColor = Colors.text
|
doneButton.tintColor = Colors.text
|
||||||
navigationItem.rightBarButtonItem = doneButton
|
navigationItem.rightBarButtonItem = doneButton
|
||||||
|
@ -196,21 +259,25 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
||||||
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 67
|
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 67
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleIsEditingGroupNameChanged() {
|
private func handleIsEditingGroupNameChanged() {
|
||||||
updateNavigationBarButtons()
|
updateNavigationBarButtons()
|
||||||
|
|
||||||
UIView.animate(withDuration: 0.25) {
|
UIView.animate(withDuration: 0.25) {
|
||||||
self.groupNameLabel.alpha = self.isEditingGroupName ? 0 : 1
|
self.groupNameLabel.alpha = self.isEditingGroupName ? 0 : 1
|
||||||
self.groupNameTextField.alpha = self.isEditingGroupName ? 1 : 0
|
self.groupNameTextField.alpha = self.isEditingGroupName ? 1 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if isEditingGroupName {
|
if isEditingGroupName {
|
||||||
groupNameTextField.becomeFirstResponder()
|
groupNameTextField.becomeFirstResponder()
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
groupNameTextField.resignFirstResponder()
|
groupNameTextField.resignFirstResponder()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
@objc private func showEditGroupNameUI() {
|
@objc private func showEditGroupNameUI() {
|
||||||
isEditingGroupName = true
|
isEditingGroupName = true
|
||||||
}
|
}
|
||||||
|
@ -222,93 +289,163 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
||||||
@objc private func handleDoneButtonTapped() {
|
@objc private func handleDoneButtonTapped() {
|
||||||
if isEditingGroupName {
|
if isEditingGroupName {
|
||||||
updateGroupName()
|
updateGroupName()
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
commitChanges()
|
commitChanges()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateGroupName() {
|
private func updateGroupName() {
|
||||||
let name = groupNameTextField.text!.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
let updatedName: String = groupNameTextField.text
|
||||||
guard !name.isEmpty else {
|
.defaulting(to: "")
|
||||||
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_missing_error", comment: ""))
|
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||||
|
|
||||||
|
guard !updatedName.isEmpty else {
|
||||||
|
return showError(title: "vc_create_closed_group_group_name_missing_error".lowercased())
|
||||||
}
|
}
|
||||||
guard name.count < 64 else {
|
guard updatedName.count < 64 else {
|
||||||
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_too_long_error", comment: ""))
|
return showError(title: "vc_create_closed_group_group_name_too_long_error".localized())
|
||||||
}
|
}
|
||||||
|
|
||||||
isEditingGroupName = false
|
isEditingGroupName = false
|
||||||
self.name = name
|
groupNameLabel.text = updatedName
|
||||||
groupNameLabel.text = name
|
self.name = updatedName
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func addMembers() {
|
@objc private func addMembers() {
|
||||||
let title = "Add Members"
|
let title = "Add Members"
|
||||||
let userSelectionVC = UserSelectionVC(with: title, excluding: Set(membersAndZombies)) { [weak self] selectedUsers in
|
|
||||||
guard let self = self else { return }
|
let userSelectionVC: UserSelectionVC = UserSelectionVC(
|
||||||
var members = self.membersAndZombies
|
with: title,
|
||||||
members.append(contentsOf: selectedUsers)
|
excluding: membersAndZombies
|
||||||
func getDisplayName(for publicKey: String) -> String {
|
.map { $0.profileId }
|
||||||
return Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey
|
.asSet()
|
||||||
|
) { [weak self] selectedUserIds in
|
||||||
|
Storage.shared.read { [weak self] db in
|
||||||
|
let selectedGroupMembers: [GroupMemberDisplayInfo] = try Profile
|
||||||
|
.filter(selectedUserIds.contains(Profile.Columns.id))
|
||||||
|
.fetchAll(db)
|
||||||
|
.map { profile in
|
||||||
|
GroupMemberDisplayInfo(
|
||||||
|
profileId: profile.id,
|
||||||
|
role: .standard,
|
||||||
|
profile: profile
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self?.membersAndZombies = (self?.membersAndZombies ?? [])
|
||||||
|
.appending(contentsOf: selectedGroupMembers)
|
||||||
|
.sorted(by: { lhs, rhs in
|
||||||
|
if lhs.role == .zombie && rhs.role != .zombie {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
else if lhs.role != .zombie && rhs.role == .zombie {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let lhsDisplayName: String = Profile.displayName(
|
||||||
|
for: .contact,
|
||||||
|
id: lhs.profileId,
|
||||||
|
name: lhs.profile?.name,
|
||||||
|
nickname: lhs.profile?.nickname
|
||||||
|
)
|
||||||
|
let rhsDisplayName: String = Profile.displayName(
|
||||||
|
for: .contact,
|
||||||
|
id: rhs.profileId,
|
||||||
|
name: rhs.profile?.name,
|
||||||
|
nickname: rhs.profile?.nickname
|
||||||
|
)
|
||||||
|
|
||||||
|
return (lhsDisplayName < rhsDisplayName)
|
||||||
|
})
|
||||||
|
.filter { $0.role == .standard || $0.role == .zombie }
|
||||||
|
|
||||||
|
let uniqueGroupMemberIds: Set<String> = (self?.membersAndZombies ?? [])
|
||||||
|
.map { $0.profileId }
|
||||||
|
.asSet()
|
||||||
|
.inserting(contentsOf: self?.adminIds)
|
||||||
|
self?.hasContactsToAdd = ((try Profile.fetchCount(db) - uniqueGroupMemberIds.count) > 0)
|
||||||
}
|
}
|
||||||
self.membersAndZombies = members.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
|
|
||||||
let hasContactsToAdd = !Set(ContactUtilities.getAllContacts()).subtracting(self.membersAndZombies).isEmpty
|
let color = (self?.hasContactsToAdd == true ?
|
||||||
self.addMembersButton.isUserInteractionEnabled = hasContactsToAdd
|
Colors.accent :
|
||||||
let color = hasContactsToAdd ? Colors.accent : Colors.text.withAlphaComponent(Values.mediumOpacity)
|
Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||||
self.addMembersButton.layer.borderColor = color.cgColor
|
)
|
||||||
self.addMembersButton.setTitleColor(color, for: UIControl.State.normal)
|
self?.addMembersButton.isUserInteractionEnabled = (self?.hasContactsToAdd == true)
|
||||||
|
self?.addMembersButton.layer.borderColor = color.cgColor
|
||||||
|
self?.addMembersButton.setTitleColor(color, for: UIControl.State.normal)
|
||||||
|
self?.handleMembersChanged()
|
||||||
}
|
}
|
||||||
navigationController!.pushViewController(userSelectionVC, animated: true, completion: nil)
|
|
||||||
|
navigationController?.pushViewController(userSelectionVC, animated: true, completion: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func commitChanges() {
|
private func commitChanges() {
|
||||||
let popToConversationVC: (EditClosedGroupVC) -> Void = { editVC in
|
let popToConversationVC: ((EditClosedGroupVC?) -> ()) = { editVC in
|
||||||
if let conversationVC = editVC.navigationController!.viewControllers.first(where: { $0 is ConversationVC }) {
|
guard
|
||||||
editVC.navigationController!.popToViewController(conversationVC, animated: true)
|
let viewControllers: [UIViewController] = editVC?.navigationController?.viewControllers,
|
||||||
} else {
|
let conversationVC: ConversationVC = viewControllers.first(where: { $0 is ConversationVC }) as? ConversationVC
|
||||||
editVC.navigationController!.popViewController(animated: true)
|
else {
|
||||||
|
editVC?.navigationController?.popViewController(animated: true)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
editVC?.navigationController?.popToViewController(conversationVC, animated: true)
|
||||||
}
|
}
|
||||||
let storage = SNMessagingKitConfiguration.shared.storage
|
|
||||||
let members = Set(self.membersAndZombies)
|
let threadId: String = self.threadId
|
||||||
let name = self.name
|
let updatedName: String = self.name
|
||||||
let zombies = storage.getZombieMembers(for: groupPublicKey)
|
let userPublicKey: String = self.userPublicKey
|
||||||
guard members != Set(thread.groupModel.groupMemberIds + zombies) || name != thread.groupModel.groupName else {
|
let updatedMemberIds: Set<String> = self.membersAndZombies
|
||||||
|
.map { $0.profileId }
|
||||||
|
.asSet()
|
||||||
|
|
||||||
|
guard updatedMemberIds != self.originalMembersAndZombieIds || updatedName != self.originalName else {
|
||||||
return popToConversationVC(self)
|
return popToConversationVC(self)
|
||||||
}
|
}
|
||||||
if !members.contains(getUserHexEncodedPublicKey()) {
|
|
||||||
guard Set(thread.groupModel.groupMemberIds).subtracting([ getUserHexEncodedPublicKey() ]) == members else {
|
if !updatedMemberIds.contains(userPublicKey) {
|
||||||
return showError(title: "Couldn't Update Group", message: "Can't leave while adding or removing other members.")
|
guard self.originalMembersAndZombieIds.removing(userPublicKey) == updatedMemberIds else {
|
||||||
|
return showError(
|
||||||
|
title: "Couldn't Update Group",
|
||||||
|
message: "Can't leave while adding or removing other members."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard members.count <= 100 else {
|
guard updatedMemberIds.count <= 100 else {
|
||||||
return showError(title: NSLocalizedString("vc_create_closed_group_too_many_group_members_error", comment: ""))
|
return showError(title: "vc_create_closed_group_too_many_group_members_error".localized())
|
||||||
}
|
}
|
||||||
var promise: Promise<Void>!
|
|
||||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController!) { [groupPublicKey, weak self] _ in
|
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
|
||||||
Storage.write(with: { transaction in
|
Storage.shared
|
||||||
if !members.contains(getUserHexEncodedPublicKey()) {
|
.writeAsync { db in
|
||||||
promise = MessageSender.leave(groupPublicKey, using: transaction)
|
if !updatedMemberIds.contains(userPublicKey) {
|
||||||
} else {
|
return try MessageSender.leave(db, groupPublicKey: threadId)
|
||||||
promise = MessageSender.update(groupPublicKey, with: members, name: name, transaction: transaction)
|
}
|
||||||
|
|
||||||
|
return try MessageSender.update(
|
||||||
|
db,
|
||||||
|
groupPublicKey: threadId,
|
||||||
|
with: updatedMemberIds,
|
||||||
|
name: updatedName
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}, completion: {
|
.done(on: DispatchQueue.main) { [weak self] in
|
||||||
let _ = promise.done(on: DispatchQueue.main) {
|
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||||
guard let self = self else { return }
|
|
||||||
MentionsManager.populateUserPublicKeyCacheIfNeeded(for: self.thread.uniqueId!)
|
|
||||||
self.dismiss(animated: true, completion: nil) // Dismiss the loader
|
|
||||||
popToConversationVC(self)
|
popToConversationVC(self)
|
||||||
}
|
}
|
||||||
promise.catch(on: DispatchQueue.main) { error in
|
.catch(on: DispatchQueue.main) { [weak self] error in
|
||||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||||
self?.showError(title: "Couldn't Update Group", message: error.localizedDescription)
|
self?.showError(title: "Couldn't Update Group", message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
})
|
.retainUntilComplete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Convenience
|
// MARK: - Convenience
|
||||||
|
|
||||||
private func showError(title: String, message: String = "") {
|
private func showError(title: String, message: String = "") {
|
||||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||||
presentAlert(alert)
|
presentAlert(alert)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import GRDB
|
||||||
import PromiseKit
|
import PromiseKit
|
||||||
|
import SessionUIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
private protocol TableViewTouchDelegate {
|
private protocol TableViewTouchDelegate {
|
||||||
|
|
||||||
func tableViewWasTouched(_ tableView: TableView)
|
func tableViewWasTouched(_ tableView: TableView)
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class TableView : UITableView {
|
private final class TableView: UITableView {
|
||||||
var touchDelegate: TableViewTouchDelegate?
|
var touchDelegate: TableViewTouchDelegate?
|
||||||
|
|
||||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||||
|
@ -14,107 +19,127 @@ private final class TableView : UITableView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate {
|
final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate {
|
||||||
private let contacts = ContactUtilities.getAllContacts()
|
private let contactProfiles: [Profile] = Profile.fetchAllContactProfiles(excludeCurrentUser: true)
|
||||||
private var selectedContacts: Set<String> = []
|
private var selectedContacts: Set<String> = []
|
||||||
|
|
||||||
// MARK: Components
|
// MARK: - Components
|
||||||
private lazy var nameTextField = TextField(placeholder: NSLocalizedString("vc_create_closed_group_text_field_hint", comment: ""))
|
|
||||||
|
private lazy var nameTextField = TextField(placeholder: "vc_create_closed_group_text_field_hint".localized())
|
||||||
|
|
||||||
private lazy var tableView: TableView = {
|
private lazy var tableView: TableView = {
|
||||||
let result = TableView()
|
let result: TableView = TableView()
|
||||||
result.dataSource = self
|
result.dataSource = self
|
||||||
result.delegate = self
|
result.delegate = self
|
||||||
result.touchDelegate = self
|
result.touchDelegate = self
|
||||||
result.register(UserCell.self, forCellReuseIdentifier: "UserCell")
|
|
||||||
result.separatorStyle = .none
|
result.separatorStyle = .none
|
||||||
result.backgroundColor = .clear
|
result.backgroundColor = .clear
|
||||||
result.isScrollEnabled = false
|
result.isScrollEnabled = false
|
||||||
|
result.register(view: UserCell.self)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
setUpGradientBackground()
|
setUpGradientBackground()
|
||||||
setUpNavBarStyle()
|
setUpNavBarStyle()
|
||||||
|
|
||||||
let customTitleFontSize = Values.largeFontSize
|
let customTitleFontSize = Values.largeFontSize
|
||||||
setNavBarTitle(NSLocalizedString("vc_create_closed_group_title", comment: ""), customFontSize: customTitleFontSize)
|
setNavBarTitle("vc_create_closed_group_title".localized(), customFontSize: customTitleFontSize)
|
||||||
|
|
||||||
// Set up navigation bar buttons
|
// Set up navigation bar buttons
|
||||||
let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close))
|
let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close))
|
||||||
closeButton.tintColor = Colors.text
|
closeButton.tintColor = Colors.text
|
||||||
navigationItem.leftBarButtonItem = closeButton
|
navigationItem.leftBarButtonItem = closeButton
|
||||||
|
|
||||||
let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(createClosedGroup))
|
let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(createClosedGroup))
|
||||||
doneButton.tintColor = Colors.text
|
doneButton.tintColor = Colors.text
|
||||||
navigationItem.rightBarButtonItem = doneButton
|
navigationItem.rightBarButtonItem = doneButton
|
||||||
|
|
||||||
// Set up content
|
// Set up content
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy() {
|
||||||
if !contacts.isEmpty {
|
guard !contactProfiles.isEmpty else {
|
||||||
let mainStackView = UIStackView()
|
let explanationLabel: UILabel = UILabel()
|
||||||
mainStackView.axis = .vertical
|
|
||||||
nameTextField.delegate = self
|
|
||||||
let nameTextFieldContainer = UIView()
|
|
||||||
nameTextFieldContainer.addSubview(nameTextField)
|
|
||||||
nameTextField.pin(.leading, to: .leading, of: nameTextFieldContainer, withInset: Values.largeSpacing)
|
|
||||||
nameTextField.pin(.top, to: .top, of: nameTextFieldContainer, withInset: Values.mediumSpacing)
|
|
||||||
nameTextFieldContainer.pin(.trailing, to: .trailing, of: nameTextField, withInset: Values.largeSpacing)
|
|
||||||
nameTextFieldContainer.pin(.bottom, to: .bottom, of: nameTextField, withInset: Values.largeSpacing)
|
|
||||||
mainStackView.addArrangedSubview(nameTextFieldContainer)
|
|
||||||
let separator = UIView()
|
|
||||||
separator.backgroundColor = Colors.separator
|
|
||||||
separator.set(.height, to: Values.separatorThickness)
|
|
||||||
mainStackView.addArrangedSubview(separator)
|
|
||||||
tableView.set(.height, to: CGFloat(contacts.count * 65)) // A cell is exactly 65 points high
|
|
||||||
tableView.set(.width, to: UIScreen.main.bounds.width)
|
|
||||||
mainStackView.addArrangedSubview(tableView)
|
|
||||||
let scrollView = UIScrollView(wrapping: mainStackView, withInsets: UIEdgeInsets.zero)
|
|
||||||
scrollView.showsVerticalScrollIndicator = false
|
|
||||||
scrollView.delegate = self
|
|
||||||
view.addSubview(scrollView)
|
|
||||||
scrollView.set(.width, to: UIScreen.main.bounds.width)
|
|
||||||
scrollView.pin(to: view)
|
|
||||||
} else {
|
|
||||||
let explanationLabel = UILabel()
|
|
||||||
explanationLabel.textColor = Colors.text
|
explanationLabel.textColor = Colors.text
|
||||||
explanationLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
explanationLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||||
explanationLabel.numberOfLines = 0
|
explanationLabel.numberOfLines = 0
|
||||||
explanationLabel.lineBreakMode = .byWordWrapping
|
explanationLabel.lineBreakMode = .byWordWrapping
|
||||||
explanationLabel.textAlignment = .center
|
explanationLabel.textAlignment = .center
|
||||||
explanationLabel.text = NSLocalizedString("vc_create_closed_group_empty_state_message", comment: "")
|
explanationLabel.text = NSLocalizedString("vc_create_closed_group_empty_state_message", comment: "")
|
||||||
let createNewPrivateChatButton = Button(style: .prominentOutline, size: .large)
|
|
||||||
|
let createNewPrivateChatButton: Button = Button(style: .prominentOutline, size: .large)
|
||||||
createNewPrivateChatButton.setTitle(NSLocalizedString("vc_create_closed_group_empty_state_button_title", comment: ""), for: UIControl.State.normal)
|
createNewPrivateChatButton.setTitle(NSLocalizedString("vc_create_closed_group_empty_state_button_title", comment: ""), for: UIControl.State.normal)
|
||||||
createNewPrivateChatButton.addTarget(self, action: #selector(createNewDM), for: UIControl.Event.touchUpInside)
|
createNewPrivateChatButton.addTarget(self, action: #selector(createNewDM), for: UIControl.Event.touchUpInside)
|
||||||
createNewPrivateChatButton.set(.width, to: 196)
|
createNewPrivateChatButton.set(.width, to: 196)
|
||||||
let stackView = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ])
|
|
||||||
|
let stackView: UIStackView = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ])
|
||||||
stackView.axis = .vertical
|
stackView.axis = .vertical
|
||||||
stackView.spacing = Values.mediumSpacing
|
stackView.spacing = Values.mediumSpacing
|
||||||
stackView.alignment = .center
|
stackView.alignment = .center
|
||||||
view.addSubview(stackView)
|
view.addSubview(stackView)
|
||||||
stackView.center(.horizontal, in: view)
|
stackView.center(.horizontal, in: view)
|
||||||
|
|
||||||
let verticalCenteringConstraint = stackView.center(.vertical, in: view)
|
let verticalCenteringConstraint = stackView.center(.vertical, in: view)
|
||||||
verticalCenteringConstraint.constant = -16 // Makes things appear centered visually
|
verticalCenteringConstraint.constant = -16 // Makes things appear centered visually
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mainStackView: UIStackView = UIStackView()
|
||||||
|
mainStackView.axis = .vertical
|
||||||
|
nameTextField.delegate = self
|
||||||
|
|
||||||
|
let nameTextFieldContainer: UIView = UIView()
|
||||||
|
nameTextFieldContainer.addSubview(nameTextField)
|
||||||
|
nameTextField.pin(.leading, to: .leading, of: nameTextFieldContainer, withInset: Values.largeSpacing)
|
||||||
|
nameTextField.pin(.top, to: .top, of: nameTextFieldContainer, withInset: Values.mediumSpacing)
|
||||||
|
nameTextFieldContainer.pin(.trailing, to: .trailing, of: nameTextField, withInset: Values.largeSpacing)
|
||||||
|
nameTextFieldContainer.pin(.bottom, to: .bottom, of: nameTextField, withInset: Values.largeSpacing)
|
||||||
|
mainStackView.addArrangedSubview(nameTextFieldContainer)
|
||||||
|
|
||||||
|
let separator: UIView = UIView()
|
||||||
|
separator.backgroundColor = Colors.separator
|
||||||
|
separator.set(.height, to: Values.separatorThickness)
|
||||||
|
mainStackView.addArrangedSubview(separator)
|
||||||
|
tableView.set(.height, to: CGFloat(contactProfiles.count * 65)) // A cell is exactly 65 points high
|
||||||
|
tableView.set(.width, to: UIScreen.main.bounds.width)
|
||||||
|
mainStackView.addArrangedSubview(tableView)
|
||||||
|
|
||||||
|
let scrollView: UIScrollView = UIScrollView(wrapping: mainStackView, withInsets: UIEdgeInsets.zero)
|
||||||
|
scrollView.showsVerticalScrollIndicator = false
|
||||||
|
scrollView.delegate = self
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
|
||||||
|
scrollView.set(.width, to: UIScreen.main.bounds.width)
|
||||||
|
scrollView.pin(to: view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Table View Data Source
|
// MARK: - Table View Data Source
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
return contacts.count
|
return contactProfiles.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell
|
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
|
||||||
let publicKey = contacts[indexPath.row]
|
cell.update(
|
||||||
cell.publicKey = publicKey
|
with: contactProfiles[indexPath.row].id,
|
||||||
let isSelected = selectedContacts.contains(publicKey)
|
profile: contactProfiles[indexPath.row],
|
||||||
cell.accessory = .tick(isSelected: isSelected)
|
isZombie: false,
|
||||||
cell.update()
|
accessory: .tick(isSelected: selectedContacts.contains(contactProfiles[indexPath.row].id))
|
||||||
|
)
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
func textFieldDidEndEditing(_ textField: UITextField) {
|
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||||
crossfadeLabel.text = textField.text!.isEmpty ? NSLocalizedString("vc_create_closed_group_title", comment: "") : textField.text!
|
crossfadeLabel.text = textField.text!.isEmpty ? NSLocalizedString("vc_create_closed_group_title", comment: "") : textField.text!
|
||||||
}
|
}
|
||||||
|
@ -135,13 +160,15 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
let publicKey = contacts[indexPath.row]
|
if !selectedContacts.contains(contactProfiles[indexPath.row].id) {
|
||||||
if !selectedContacts.contains(publicKey) { selectedContacts.insert(publicKey) } else { selectedContacts.remove(publicKey) }
|
selectedContacts.insert(contactProfiles[indexPath.row].id)
|
||||||
guard let cell = tableView.cellForRow(at: indexPath) as? UserCell else { return }
|
}
|
||||||
let isSelected = selectedContacts.contains(publicKey)
|
else {
|
||||||
cell.accessory = .tick(isSelected: isSelected)
|
selectedContacts.remove(contactProfiles[indexPath.row].id)
|
||||||
cell.update()
|
}
|
||||||
|
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
tableView.reloadRows(at: [indexPath], with: .none)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func close() {
|
@objc private func close() {
|
||||||
|
@ -169,28 +196,34 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat
|
||||||
let selectedContacts = self.selectedContacts
|
let selectedContacts = self.selectedContacts
|
||||||
let message: String? = (selectedContacts.count > 20) ? "Please wait while the group is created..." : nil
|
let message: String? = (selectedContacts.count > 20) ? "Please wait while the group is created..." : nil
|
||||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in
|
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in
|
||||||
var promise: Promise<TSGroupThread>!
|
Storage.shared
|
||||||
Storage.writeSync { transaction in
|
.writeAsync { db in
|
||||||
promise = MessageSender.createClosedGroup(name: name, members: selectedContacts, transaction: transaction)
|
try MessageSender.createClosedGroup(db, name: name, members: selectedContacts)
|
||||||
}
|
}
|
||||||
let _ = promise.done(on: DispatchQueue.main) { thread in
|
.done(on: DispatchQueue.main) { thread in
|
||||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
Storage.shared.writeAsync { db in
|
||||||
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||||
SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false)
|
}
|
||||||
}
|
|
||||||
promise.catch(on: DispatchQueue.main) { _ in
|
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
SessionApp.presentConversation(for: thread.id, action: .compose, animated: false)
|
||||||
let title = "Couldn't Create Group"
|
}
|
||||||
let message = "Please check your internet connection and try again."
|
.catch(on: DispatchQueue.main) { [weak self] _ in
|
||||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
|
||||||
self?.presentAlert(alert)
|
let title = "Couldn't Create Group"
|
||||||
}
|
let message = "Please check your internet connection and try again."
|
||||||
|
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
||||||
|
self?.presentAlert(alert)
|
||||||
|
}
|
||||||
|
.retainUntilComplete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func createNewDM() {
|
@objc private func createNewDM() {
|
||||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||||
SignalApp.shared().homeViewController!.createNewDM()
|
|
||||||
|
SessionApp.homeViewController.wrappedValue?.createNewDM()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,99 +1,156 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
extension ContextMenuVC {
|
extension ContextMenuVC {
|
||||||
|
|
||||||
struct Action {
|
struct Action {
|
||||||
let icon: UIImage
|
let icon: UIImage?
|
||||||
let title: String
|
let title: String
|
||||||
|
let isDismissAction: Bool
|
||||||
let work: () -> Void
|
let work: () -> Void
|
||||||
|
|
||||||
static func reply(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
let title = NSLocalizedString("context_menu_reply", comment: "")
|
return Action(
|
||||||
return Action(icon: UIImage(named: "ic_reply")!, title: title) { delegate?.reply(viewItem) }
|
icon: UIImage(named: "ic_reply"),
|
||||||
|
title: "context_menu_reply".localized(),
|
||||||
|
isDismissAction: false
|
||||||
|
) { delegate?.reply(cellViewModel) }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func copy(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
let title = NSLocalizedString("copy", comment: "")
|
return Action(
|
||||||
return Action(icon: UIImage(named: "ic_copy")!, title: title) { delegate?.copy(viewItem) }
|
icon: UIImage(named: "ic_copy"),
|
||||||
|
title: "copy".localized(),
|
||||||
|
isDismissAction: false
|
||||||
|
) { delegate?.copy(cellViewModel) }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func copySessionID(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
let title = NSLocalizedString("vc_conversation_settings_copy_session_id_button_title", comment: "")
|
return Action(
|
||||||
return Action(icon: UIImage(named: "ic_copy")!, title: title) { delegate?.copySessionID(viewItem) }
|
icon: UIImage(named: "ic_copy"),
|
||||||
|
title: "vc_conversation_settings_copy_session_id_button_title".localized(),
|
||||||
|
isDismissAction: false
|
||||||
|
) { delegate?.copySessionID(cellViewModel) }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func delete(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
let title = NSLocalizedString("TXT_DELETE_TITLE", comment: "")
|
return Action(
|
||||||
return Action(icon: UIImage(named: "ic_trash")!, title: title) { delegate?.delete(viewItem) }
|
icon: UIImage(named: "ic_trash"),
|
||||||
|
title: "TXT_DELETE_TITLE".localized(),
|
||||||
|
isDismissAction: false
|
||||||
|
) { delegate?.delete(cellViewModel) }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func save(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
let title = NSLocalizedString("context_menu_save", comment: "")
|
return Action(
|
||||||
return Action(icon: UIImage(named: "ic_download")!, title: title) { delegate?.save(viewItem) }
|
icon: UIImage(named: "ic_download"),
|
||||||
|
title: "context_menu_save".localized(),
|
||||||
|
isDismissAction: false
|
||||||
|
) { delegate?.save(cellViewModel) }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func ban(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
let title = NSLocalizedString("context_menu_ban_user", comment: "")
|
return Action(
|
||||||
return Action(icon: UIImage(named: "ic_block")!, title: title) { delegate?.ban(viewItem) }
|
icon: UIImage(named: "ic_block"),
|
||||||
|
title: "context_menu_ban_user".localized(),
|
||||||
|
isDismissAction: false
|
||||||
|
) { delegate?.ban(cellViewModel) }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func banAndDeleteAllMessages(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
let title = NSLocalizedString("context_menu_ban_and_delete_all", comment: "")
|
return Action(
|
||||||
return Action(icon: UIImage(named: "ic_block")!, title: title) { delegate?.banAndDeleteAllMessages(viewItem) }
|
icon: UIImage(named: "ic_block"),
|
||||||
|
title: "context_menu_ban_and_delete_all".localized(),
|
||||||
|
isDismissAction: false
|
||||||
|
) { delegate?.banAndDeleteAllMessages(cellViewModel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dismiss(_ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
|
return Action(
|
||||||
|
icon: nil,
|
||||||
|
title: "",
|
||||||
|
isDismissAction: true
|
||||||
|
) { delegate?.contextMenuDismissed() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func actions(for viewItem: ConversationViewItem, delegate: ContextMenuActionDelegate?) -> [Action] {
|
static func actions(for cellViewModel: MessageViewModel, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? {
|
||||||
func isReplyingAllowed() -> Bool {
|
// No context items for info messages
|
||||||
guard let message = viewItem.interaction as? TSOutgoingMessage else { return true }
|
guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else {
|
||||||
switch message.messageState {
|
return nil
|
||||||
case .failed, .sending: return false
|
|
||||||
default: return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch viewItem.messageCellType {
|
|
||||||
case .textOnlyMessage:
|
|
||||||
var result: [Action] = []
|
|
||||||
if isReplyingAllowed() { result.append(Action.reply(viewItem, delegate)) }
|
|
||||||
result.append(Action.copy(viewItem, delegate))
|
|
||||||
let isGroup = viewItem.isGroupThread
|
|
||||||
if let message = viewItem.interaction as? TSIncomingMessage, isGroup, !message.isOpenGroupMessage {
|
|
||||||
result.append(Action.copySessionID(viewItem, delegate))
|
|
||||||
}
|
|
||||||
if !isGroup || viewItem.userCanDeleteGroupMessage { result.append(Action.delete(viewItem, delegate)) }
|
|
||||||
if isGroup && viewItem.interaction is TSIncomingMessage && viewItem.userHasModerationPermission {
|
|
||||||
result.append(Action.ban(viewItem, delegate))
|
|
||||||
result.append(Action.banAndDeleteAllMessages(viewItem, delegate))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
case .mediaMessage, .audio, .genericAttachment:
|
|
||||||
var result: [Action] = []
|
|
||||||
if isReplyingAllowed() { result.append(Action.reply(viewItem, delegate)) }
|
|
||||||
if viewItem.canCopyMedia() { result.append(Action.copy(viewItem, delegate)) }
|
|
||||||
if viewItem.canSaveMedia() { result.append(Action.save(viewItem, delegate)) }
|
|
||||||
let isGroup = viewItem.isGroupThread
|
|
||||||
if let message = viewItem.interaction as? TSIncomingMessage, isGroup, !message.isOpenGroupMessage {
|
|
||||||
result.append(Action.copySessionID(viewItem, delegate))
|
|
||||||
}
|
|
||||||
if !isGroup || viewItem.userCanDeleteGroupMessage { result.append(Action.delete(viewItem, delegate)) }
|
|
||||||
if isGroup && viewItem.interaction is TSIncomingMessage && viewItem.userHasModerationPermission {
|
|
||||||
result.append(Action.ban(viewItem, delegate))
|
|
||||||
result.append(Action.banAndDeleteAllMessages(viewItem, delegate))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
default: return []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let canReply: Bool = (
|
||||||
|
cellViewModel.variant != .standardOutgoing || (
|
||||||
|
cellViewModel.state != .failed &&
|
||||||
|
cellViewModel.state != .sending
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let canCopy: Bool = (
|
||||||
|
cellViewModel.cellType == .textOnlyMessage || (
|
||||||
|
(
|
||||||
|
cellViewModel.cellType == .genericAttachment ||
|
||||||
|
cellViewModel.cellType == .mediaMessage
|
||||||
|
) &&
|
||||||
|
(cellViewModel.attachments ?? []).count == 1 &&
|
||||||
|
(cellViewModel.attachments ?? []).first?.isVisualMedia == true &&
|
||||||
|
(cellViewModel.attachments ?? []).first?.isValid == true && (
|
||||||
|
(cellViewModel.attachments ?? []).first?.state == .downloaded ||
|
||||||
|
(cellViewModel.attachments ?? []).first?.state == .uploaded
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let canSave: Bool = (
|
||||||
|
cellViewModel.cellType == .mediaMessage &&
|
||||||
|
(cellViewModel.attachments ?? [])
|
||||||
|
.filter { attachment in
|
||||||
|
attachment.isValid &&
|
||||||
|
attachment.isVisualMedia && (
|
||||||
|
attachment.state == .downloaded ||
|
||||||
|
attachment.state == .uploaded
|
||||||
|
)
|
||||||
|
}.isEmpty == false
|
||||||
|
)
|
||||||
|
let canCopySessionId: Bool = (
|
||||||
|
cellViewModel.variant == .standardIncoming &&
|
||||||
|
cellViewModel.threadVariant != .openGroup
|
||||||
|
)
|
||||||
|
let canDelete: Bool = (
|
||||||
|
cellViewModel.threadVariant != .openGroup ||
|
||||||
|
currentUserIsOpenGroupModerator
|
||||||
|
)
|
||||||
|
let canBan: Bool = (
|
||||||
|
cellViewModel.threadVariant == .openGroup &&
|
||||||
|
currentUserIsOpenGroupModerator
|
||||||
|
)
|
||||||
|
|
||||||
|
let generatedActions: [Action] = [
|
||||||
|
(canReply ? Action.reply(cellViewModel, delegate) : nil),
|
||||||
|
(canCopy ? Action.copy(cellViewModel, delegate) : nil),
|
||||||
|
(canSave ? Action.save(cellViewModel, delegate) : 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)
|
||||||
|
]
|
||||||
|
.compactMap { $0 }
|
||||||
|
|
||||||
|
guard !generatedActions.isEmpty else { return [] }
|
||||||
|
|
||||||
|
return generatedActions.appending(Action.dismiss(delegate))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Delegate
|
// MARK: - Delegate
|
||||||
protocol ContextMenuActionDelegate : AnyObject {
|
|
||||||
|
protocol ContextMenuActionDelegate {
|
||||||
func reply(_ viewItem: ConversationViewItem)
|
func reply(_ cellViewModel: MessageViewModel)
|
||||||
func copy(_ viewItem: ConversationViewItem)
|
func copy(_ cellViewModel: MessageViewModel)
|
||||||
func copySessionID(_ viewItem: ConversationViewItem)
|
func copySessionID(_ cellViewModel: MessageViewModel)
|
||||||
func delete(_ viewItem: ConversationViewItem)
|
func delete(_ cellViewModel: MessageViewModel)
|
||||||
func save(_ viewItem: ConversationViewItem)
|
func save(_ cellViewModel: MessageViewModel)
|
||||||
func ban(_ viewItem: ConversationViewItem)
|
func ban(_ cellViewModel: MessageViewModel)
|
||||||
func banAndDeleteAllMessages(_ viewItem: ConversationViewItem)
|
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel)
|
||||||
func contextMenuDismissed()
|
func contextMenuDismissed()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,19 +1,25 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SessionUIKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
extension ContextMenuVC {
|
extension ContextMenuVC {
|
||||||
|
final class ActionView: UIView {
|
||||||
final class ActionView : UIView {
|
|
||||||
private let action: Action
|
|
||||||
private let dismiss: () -> Void
|
|
||||||
|
|
||||||
// MARK: Settings
|
|
||||||
private static let iconSize: CGFloat = 16
|
private static let iconSize: CGFloat = 16
|
||||||
private static let iconImageViewSize: CGFloat = 24
|
private static let iconImageViewSize: CGFloat = 24
|
||||||
|
|
||||||
// MARK: Lifecycle
|
private let action: Action
|
||||||
|
private let dismiss: () -> Void
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
init(for action: Action, dismiss: @escaping () -> Void) {
|
init(for action: Action, dismiss: @escaping () -> Void) {
|
||||||
self.action = action
|
self.action = action
|
||||||
self.dismiss = dismiss
|
self.dismiss = dismiss
|
||||||
|
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,32 +34,46 @@ extension ContextMenuVC {
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy() {
|
||||||
// Icon
|
// Icon
|
||||||
let iconSize = ActionView.iconSize
|
let iconSize = ActionView.iconSize
|
||||||
let iconImageView = UIImageView(image: action.icon.resizedImage(to: CGSize(width: iconSize, height: iconSize))!.withTint(Colors.text))
|
let iconImageView: UIImageView = UIImageView(
|
||||||
let iconImageViewSize = ActionView.iconImageViewSize
|
image: action.icon?
|
||||||
iconImageView.set(.width, to: iconImageViewSize)
|
.resizedImage(to: CGSize(width: iconSize, height: iconSize))?
|
||||||
iconImageView.set(.height, to: iconImageViewSize)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
|
)
|
||||||
|
iconImageView.set(.width, to: ActionView.iconImageViewSize)
|
||||||
|
iconImageView.set(.height, to: ActionView.iconImageViewSize)
|
||||||
iconImageView.contentMode = .center
|
iconImageView.contentMode = .center
|
||||||
|
iconImageView.tintColor = Colors.text
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
let titleLabel = UILabel()
|
let titleLabel = UILabel()
|
||||||
titleLabel.text = action.title
|
titleLabel.text = action.title
|
||||||
titleLabel.textColor = Colors.text
|
titleLabel.textColor = Colors.text
|
||||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||||
|
|
||||||
// Stack view
|
// Stack view
|
||||||
let stackView = UIStackView(arrangedSubviews: [ iconImageView, titleLabel ])
|
let stackView: UIStackView = UIStackView(arrangedSubviews: [ iconImageView, titleLabel ])
|
||||||
stackView.axis = .horizontal
|
stackView.axis = .horizontal
|
||||||
stackView.spacing = Values.smallSpacing
|
stackView.spacing = Values.smallSpacing
|
||||||
stackView.alignment = .center
|
stackView.alignment = .center
|
||||||
stackView.isLayoutMarginsRelativeArrangement = true
|
stackView.isLayoutMarginsRelativeArrangement = true
|
||||||
|
|
||||||
let smallSpacing = Values.smallSpacing
|
let smallSpacing = Values.smallSpacing
|
||||||
stackView.layoutMargins = UIEdgeInsets(top: smallSpacing, leading: smallSpacing, bottom: smallSpacing, trailing: Values.mediumSpacing)
|
stackView.layoutMargins = UIEdgeInsets(
|
||||||
|
top: smallSpacing,
|
||||||
|
leading: smallSpacing,
|
||||||
|
bottom: smallSpacing,
|
||||||
|
trailing: Values.mediumSpacing
|
||||||
|
)
|
||||||
addSubview(stackView)
|
addSubview(stackView)
|
||||||
stackView.pin(to: self)
|
stackView.pin(to: self)
|
||||||
|
|
||||||
// Tap gesture recognizer
|
// Tap gesture recognizer
|
||||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||||
addGestureRecognizer(tapGestureRecognizer)
|
addGestureRecognizer(tapGestureRecognizer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
@objc private func handleTap() {
|
@objc private func handleTap() {
|
||||||
action.work()
|
action.work()
|
||||||
dismiss()
|
dismiss()
|
||||||
|
|
|
@ -1,43 +1,60 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class ContextMenuVC : UIViewController {
|
import UIKit
|
||||||
|
import SessionUIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
|
final class ContextMenuVC: UIViewController {
|
||||||
|
private static let actionViewHeight: CGFloat = 40
|
||||||
|
private static let menuCornerRadius: CGFloat = 8
|
||||||
|
|
||||||
private let snapshot: UIView
|
private let snapshot: UIView
|
||||||
private let viewItem: ConversationViewItem
|
|
||||||
private let frame: CGRect
|
private let frame: CGRect
|
||||||
|
private let cellViewModel: MessageViewModel
|
||||||
|
private let actions: [Action]
|
||||||
private let dismiss: () -> Void
|
private let dismiss: () -> Void
|
||||||
private weak var delegate: ContextMenuActionDelegate?
|
|
||||||
|
|
||||||
// MARK: UI Components
|
// MARK: - UI
|
||||||
private lazy var blurView = UIVisualEffectView(effect: nil)
|
|
||||||
|
private lazy var blurView: UIVisualEffectView = UIVisualEffectView(effect: nil)
|
||||||
|
|
||||||
private lazy var menuView: UIView = {
|
private lazy var menuView: UIView = {
|
||||||
let result = UIView()
|
let result: UIView = UIView()
|
||||||
result.layer.shadowColor = UIColor.black.cgColor
|
result.layer.shadowColor = UIColor.black.cgColor
|
||||||
result.layer.shadowOffset = CGSize.zero
|
result.layer.shadowOffset = CGSize.zero
|
||||||
result.layer.shadowOpacity = 0.4
|
result.layer.shadowOpacity = 0.4
|
||||||
result.layer.shadowRadius = 4
|
result.layer.shadowRadius = 4
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var timestampLabel: UILabel = {
|
private lazy var timestampLabel: UILabel = {
|
||||||
let result = UILabel()
|
let result: UILabel = UILabel()
|
||||||
let date = viewItem.interaction.dateForUI()
|
|
||||||
result.text = DateUtil.formatDate(forDisplay: date)
|
|
||||||
result.font = .systemFont(ofSize: Values.verySmallFontSize)
|
result.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||||
result.textColor = isLightMode ? .black : .white
|
result.textColor = (isLightMode ? .black : .white)
|
||||||
|
|
||||||
|
if let dateForUI: Date = cellViewModel.dateForUI {
|
||||||
|
result.text = dateForUI.formattedForDisplay
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: Settings
|
|
||||||
private static let actionViewHeight: CGFloat = 40
|
|
||||||
private static let menuCornerRadius: CGFloat = 8
|
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Initialization
|
||||||
init(snapshot: UIView, viewItem: ConversationViewItem, frame: CGRect, delegate: ContextMenuActionDelegate, dismiss: @escaping () -> Void) {
|
|
||||||
|
init(
|
||||||
|
snapshot: UIView,
|
||||||
|
frame: CGRect,
|
||||||
|
cellViewModel: MessageViewModel,
|
||||||
|
actions: [Action],
|
||||||
|
dismiss: @escaping () -> Void
|
||||||
|
) {
|
||||||
self.snapshot = snapshot
|
self.snapshot = snapshot
|
||||||
self.viewItem = viewItem
|
|
||||||
self.frame = frame
|
self.frame = frame
|
||||||
self.delegate = delegate
|
self.cellViewModel = cellViewModel
|
||||||
|
self.actions = actions
|
||||||
self.dismiss = dismiss
|
self.dismiss = dismiss
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,33 +65,42 @@ final class ContextMenuVC : UIViewController {
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
preconditionFailure("Use init(coder:) instead.")
|
preconditionFailure("Use init(coder:) instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
// Background color
|
// Background color
|
||||||
view.backgroundColor = .clear
|
view.backgroundColor = .clear
|
||||||
|
|
||||||
// Blur
|
// Blur
|
||||||
view.addSubview(blurView)
|
view.addSubview(blurView)
|
||||||
blurView.pin(to: view)
|
blurView.pin(to: view)
|
||||||
|
|
||||||
// Snapshot
|
// Snapshot
|
||||||
snapshot.layer.shadowColor = UIColor.black.cgColor
|
snapshot.layer.shadowColor = UIColor.black.cgColor
|
||||||
snapshot.layer.shadowOffset = CGSize.zero
|
snapshot.layer.shadowOffset = CGSize.zero
|
||||||
snapshot.layer.shadowOpacity = 0.4
|
snapshot.layer.shadowOpacity = 0.4
|
||||||
snapshot.layer.shadowRadius = 4
|
snapshot.layer.shadowRadius = 4
|
||||||
view.addSubview(snapshot)
|
view.addSubview(snapshot)
|
||||||
|
|
||||||
snapshot.pin(.left, to: .left, of: view, withInset: frame.origin.x)
|
snapshot.pin(.left, to: .left, of: view, withInset: frame.origin.x)
|
||||||
snapshot.pin(.top, to: .top, of: view, withInset: frame.origin.y)
|
snapshot.pin(.top, to: .top, of: view, withInset: frame.origin.y)
|
||||||
snapshot.set(.width, to: frame.width)
|
snapshot.set(.width, to: frame.width)
|
||||||
snapshot.set(.height, to: frame.height)
|
snapshot.set(.height, to: frame.height)
|
||||||
|
|
||||||
// Timestamp
|
// Timestamp
|
||||||
view.addSubview(timestampLabel)
|
view.addSubview(timestampLabel)
|
||||||
timestampLabel.center(.vertical, in: snapshot)
|
timestampLabel.center(.vertical, in: snapshot)
|
||||||
let isOutgoing = (viewItem.interaction.interactionType() == .outgoingMessage)
|
|
||||||
if isOutgoing {
|
if cellViewModel.variant == .standardOutgoing {
|
||||||
timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing)
|
timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing)
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
timestampLabel.pin(.left, to: .right, of: snapshot, withInset: Values.smallSpacing)
|
timestampLabel.pin(.left, to: .right, of: snapshot, withInset: Values.smallSpacing)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Menu
|
// Menu
|
||||||
let menuBackgroundView = UIView()
|
let menuBackgroundView = UIView()
|
||||||
menuBackgroundView.backgroundColor = Colors.receivedMessageBackground
|
menuBackgroundView.backgroundColor = Colors.receivedMessageBackground
|
||||||
|
@ -82,25 +108,35 @@ final class ContextMenuVC : UIViewController {
|
||||||
menuBackgroundView.layer.masksToBounds = true
|
menuBackgroundView.layer.masksToBounds = true
|
||||||
menuView.addSubview(menuBackgroundView)
|
menuView.addSubview(menuBackgroundView)
|
||||||
menuBackgroundView.pin(to: menuView)
|
menuBackgroundView.pin(to: menuView)
|
||||||
let actionViews = ContextMenuVC.actions(for: viewItem, delegate: delegate).map { ActionView(for: $0, dismiss: snDismiss) }
|
|
||||||
let menuStackView = UIStackView(arrangedSubviews: actionViews)
|
let menuStackView = UIStackView(
|
||||||
|
arrangedSubviews: actions
|
||||||
|
.filter { !$0.isDismissAction }
|
||||||
|
.map { action -> ActionView in ActionView(for: action, dismiss: snDismiss) }
|
||||||
|
)
|
||||||
menuStackView.axis = .vertical
|
menuStackView.axis = .vertical
|
||||||
menuView.addSubview(menuStackView)
|
menuView.addSubview(menuStackView)
|
||||||
menuStackView.pin(to: menuView)
|
menuStackView.pin(to: menuView)
|
||||||
view.addSubview(menuView)
|
view.addSubview(menuView)
|
||||||
let menuHeight = CGFloat(actionViews.count) * ContextMenuVC.actionViewHeight
|
|
||||||
|
let menuHeight = (CGFloat(actions.count) * ContextMenuVC.actionViewHeight)
|
||||||
let spacing = Values.smallSpacing
|
let spacing = Values.smallSpacing
|
||||||
|
// FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement)
|
||||||
let margin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing)
|
let margin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing)
|
||||||
|
|
||||||
if frame.maxY + spacing + menuHeight > UIScreen.main.bounds.height - margin {
|
if frame.maxY + spacing + menuHeight > UIScreen.main.bounds.height - margin {
|
||||||
menuView.pin(.bottom, to: .top, of: snapshot, withInset: -spacing)
|
menuView.pin(.bottom, to: .top, of: snapshot, withInset: -spacing)
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing)
|
menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing)
|
||||||
}
|
}
|
||||||
switch viewItem.interaction.interactionType() {
|
|
||||||
case .outgoingMessage: menuView.pin(.right, to: .right, of: snapshot)
|
switch cellViewModel.variant {
|
||||||
case .incomingMessage: menuView.pin(.left, to: .left, of: snapshot)
|
case .standardOutgoing: menuView.pin(.right, to: .right, of: snapshot)
|
||||||
default: break // Should never occur
|
case .standardIncoming: menuView.pin(.left, to: .left, of: snapshot)
|
||||||
|
default: break // Should never occur
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tap gesture
|
// Tap gesture
|
||||||
let mainTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
let mainTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||||
view.addGestureRecognizer(mainTapGestureRecognizer)
|
view.addGestureRecognizer(mainTapGestureRecognizer)
|
||||||
|
@ -108,31 +144,43 @@ final class ContextMenuVC : UIViewController {
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
|
|
||||||
UIView.animate(withDuration: 0.25) {
|
UIView.animate(withDuration: 0.25) {
|
||||||
self.blurView.effect = UIBlurEffect(style: .regular)
|
self.blurView.effect = UIBlurEffect(style: .regular)
|
||||||
self.menuView.alpha = 1
|
self.menuView.alpha = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Updating
|
// MARK: - Layout
|
||||||
|
|
||||||
override func viewDidLayoutSubviews() {
|
override func viewDidLayoutSubviews() {
|
||||||
super.viewDidLayoutSubviews()
|
super.viewDidLayoutSubviews()
|
||||||
menuView.layer.shadowPath = UIBezierPath(roundedRect: menuView.bounds, cornerRadius: ContextMenuVC.menuCornerRadius).cgPath
|
|
||||||
|
menuView.layer.shadowPath = UIBezierPath(
|
||||||
|
roundedRect: menuView.bounds,
|
||||||
|
cornerRadius: ContextMenuVC.menuCornerRadius
|
||||||
|
).cgPath
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
@objc private func handleTap() {
|
@objc private func handleTap() {
|
||||||
snDismiss()
|
snDismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
func snDismiss() {
|
func snDismiss() {
|
||||||
UIView.animate(withDuration: 0.25, animations: {
|
UIView.animate(
|
||||||
self.blurView.effect = nil
|
withDuration: 0.25,
|
||||||
self.menuView.alpha = 0
|
animations: { [weak self] in
|
||||||
self.timestampLabel.alpha = 0
|
self?.blurView.effect = nil
|
||||||
}, completion: { _ in
|
self?.menuView.alpha = 0
|
||||||
self.dismiss()
|
self?.snapshot.alpha = 0
|
||||||
self.delegate?.contextMenuDismissed()
|
self?.timestampLabel.alpha = 0
|
||||||
})
|
},
|
||||||
|
completion: { [weak self] _ in
|
||||||
|
self?.dismiss()
|
||||||
|
self?.actions.first(where: { $0.isDismissAction })?.work()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,6 @@ final class ContextMenuWindow : UIWindow {
|
||||||
initialize()
|
initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 13.0, *)
|
|
||||||
override init(windowScene: UIWindowScene) {
|
override init(windowScene: UIWindowScene) {
|
||||||
super.init(windowScene: windowScene)
|
super.init(windowScene: windowScene)
|
||||||
initialize()
|
initialize()
|
||||||
|
|
|
@ -1,333 +0,0 @@
|
||||||
//
|
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public class ConversationMessageMapping: NSObject {
|
|
||||||
private let viewName: String
|
|
||||||
private let group: String?
|
|
||||||
|
|
||||||
// The desired number of the items to load BEFORE the pivot (see below).
|
|
||||||
@objc
|
|
||||||
public var desiredLength: UInt
|
|
||||||
|
|
||||||
typealias ItemId = String
|
|
||||||
|
|
||||||
// The list of currently loaded items.
|
|
||||||
private var itemIds = [ItemId]()
|
|
||||||
|
|
||||||
// When we enter a conversation, we want to load up to N interactions. This
|
|
||||||
// is the "initial load window".
|
|
||||||
//
|
|
||||||
// We subsequently expand the load window in two directions using two very
|
|
||||||
// different behaviors.
|
|
||||||
//
|
|
||||||
// * We expand the load window "upwards" (backwards in time) only when
|
|
||||||
// loadMore() is called, in "pages".
|
|
||||||
// * We auto-expand the load window "downwards" (forward in time) to include
|
|
||||||
// any new interactions created after the initial load.
|
|
||||||
//
|
|
||||||
// We define the "pivot" as the last item in the initial load window. This
|
|
||||||
// value is only set once.
|
|
||||||
//
|
|
||||||
// For example, if you enter a conversation with messages, 1..15:
|
|
||||||
//
|
|
||||||
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
|
||||||
//
|
|
||||||
// We initially load just the last 5 (if 5 is the initial desired length):
|
|
||||||
//
|
|
||||||
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
|
||||||
// | pivot ^ | <-- load window
|
|
||||||
// pivot: 15, desired length=5.
|
|
||||||
//
|
|
||||||
// If a few more messages (16..18) are sent or received, we'll always load
|
|
||||||
// them immediately (they're after the pivot):
|
|
||||||
//
|
|
||||||
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
|
||||||
// | pivot ^ | <-- load window
|
|
||||||
// pivot: 15, desired length=5.
|
|
||||||
//
|
|
||||||
// To load an additional page of items (perhaps due to user scrolling
|
|
||||||
// upward), we extend the desired length and thereby load more items
|
|
||||||
// before the pivot.
|
|
||||||
//
|
|
||||||
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
|
||||||
// | pivot ^ | <-- load window
|
|
||||||
// pivot: 15, desired length=10.
|
|
||||||
//
|
|
||||||
// To reiterate:
|
|
||||||
//
|
|
||||||
// * The pivot doesn't move.
|
|
||||||
// * The desired length applies _before_ the pivot.
|
|
||||||
// * Everything after the pivot is auto-loaded.
|
|
||||||
//
|
|
||||||
// One last optimization:
|
|
||||||
//
|
|
||||||
// After an update, we _can sometimes_ move the pivot (for perf
|
|
||||||
// reasons), but we also adjust the "desired length" so that this
|
|
||||||
// no effect on the load behavior.
|
|
||||||
//
|
|
||||||
// And note: we use the pivot's sort id, not its uniqueId, which works
|
|
||||||
// even if the pivot itself is deleted.
|
|
||||||
private var pivotSortId: UInt64?
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public var canLoadMore = false
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public required init(group: String?, desiredLength: UInt) {
|
|
||||||
self.viewName = TSMessageDatabaseViewExtensionName
|
|
||||||
self.group = group
|
|
||||||
self.desiredLength = desiredLength
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public func loadedUniqueIds() -> [String] {
|
|
||||||
return itemIds
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public func contains(uniqueId: String) -> Bool {
|
|
||||||
return loadedUniqueIds().contains(uniqueId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// This method can be used to extend the desired length
|
|
||||||
// and update.
|
|
||||||
@objc
|
|
||||||
public func update(withDesiredLength desiredLength: UInt, transaction: YapDatabaseReadTransaction) {
|
|
||||||
assert(desiredLength >= self.desiredLength)
|
|
||||||
|
|
||||||
self.desiredLength = desiredLength
|
|
||||||
|
|
||||||
update(transaction: transaction)
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is the core method of the class. It updates the state to
|
|
||||||
// reflect the latest database state & the current desired length.
|
|
||||||
@objc
|
|
||||||
public func update(transaction: YapDatabaseReadTransaction) {
|
|
||||||
AssertIsOnMainThread()
|
|
||||||
|
|
||||||
guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else {
|
|
||||||
owsFailDebug("Could not load view.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let group = group else {
|
|
||||||
owsFailDebug("No group.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deserializing interactions is expensive, so we only
|
|
||||||
// do that when necessary.
|
|
||||||
let sortIdForItemId: (String) -> UInt64? = { (itemId) in
|
|
||||||
guard let interaction = TSInteraction.fetch(uniqueId: itemId, transaction: transaction) else {
|
|
||||||
owsFailDebug("Could not load interaction.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return interaction.sortId
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have a "pivot", load all items AFTER the pivot and up to minDesiredLength items BEFORE the pivot.
|
|
||||||
// If we do not have a "pivot", load up to minDesiredLength BEFORE the pivot.
|
|
||||||
var newItemIds = [ItemId]()
|
|
||||||
var canLoadMore = false
|
|
||||||
let desiredLength = self.desiredLength
|
|
||||||
// Not all items "count" towards the desired length. On an initial load, all items count. Subsequently,
|
|
||||||
// only items above the pivot count.
|
|
||||||
var afterPivotCount: UInt = 0
|
|
||||||
var beforePivotCount: UInt = 0
|
|
||||||
// (void (^)(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop))block;
|
|
||||||
view.enumerateKeys(inGroup: group, with: NSEnumerationOptions.reverse) { (_, key, _, stop) in
|
|
||||||
let itemId = key
|
|
||||||
|
|
||||||
// Load "uncounted" items after the pivot if possible.
|
|
||||||
//
|
|
||||||
// As an optimization, we can skip this check (which requires
|
|
||||||
// deserializing the interaction) if beforePivotCount is non-zero,
|
|
||||||
// e.g. after we "pass" the pivot.
|
|
||||||
if beforePivotCount == 0,
|
|
||||||
let pivotSortId = self.pivotSortId {
|
|
||||||
if let sortId = sortIdForItemId(itemId) {
|
|
||||||
let isAfterPivot = sortId > pivotSortId
|
|
||||||
if isAfterPivot {
|
|
||||||
newItemIds.append(itemId)
|
|
||||||
afterPivotCount += 1
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
owsFailDebug("Could not determine sort id for interaction: \(itemId)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load "counted" items unless the load window overflows.
|
|
||||||
if beforePivotCount >= desiredLength {
|
|
||||||
// Overflow
|
|
||||||
canLoadMore = true
|
|
||||||
stop.pointee = true
|
|
||||||
} else {
|
|
||||||
newItemIds.append(itemId)
|
|
||||||
beforePivotCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The items need to be reversed, since we load them in reverse order.
|
|
||||||
self.itemIds = Array(newItemIds.reversed())
|
|
||||||
self.canLoadMore = canLoadMore
|
|
||||||
|
|
||||||
// Establish the pivot, if necessary and possible.
|
|
||||||
//
|
|
||||||
// Deserializing interactions is expensive. We only need to deserialize
|
|
||||||
// interactions that are "after" the pivot. So there would be performance
|
|
||||||
// benefits to moving the pivot after each update to the last loaded item.
|
|
||||||
//
|
|
||||||
// However, this would undesirable side effects. The desired length for
|
|
||||||
// conversations with very short disappearing message durations would
|
|
||||||
// continuously grow as messages appeared and disappeared.
|
|
||||||
//
|
|
||||||
// Therefore, we only move the pivot when we've accumulated N items after
|
|
||||||
// the pivot. This puts an upper bound on the number of interactions we
|
|
||||||
// have to deserialize while minimizing "load window size creep".
|
|
||||||
let kMaxItemCountAfterPivot = 32
|
|
||||||
let shouldSetPivot = (self.pivotSortId == nil ||
|
|
||||||
afterPivotCount > kMaxItemCountAfterPivot)
|
|
||||||
if shouldSetPivot {
|
|
||||||
if let newLastItemId = newItemIds.first {
|
|
||||||
// newItemIds is in reverse order, so its "first" element is actually last.
|
|
||||||
if let sortId = sortIdForItemId(newLastItemId) {
|
|
||||||
// Update the pivot.
|
|
||||||
if self.pivotSortId != nil {
|
|
||||||
self.desiredLength += afterPivotCount
|
|
||||||
}
|
|
||||||
self.pivotSortId = sortId
|
|
||||||
} else {
|
|
||||||
owsFailDebug("Could not determine sort id for interaction: \(newLastItemId)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tries to ensure that the load window includes a given item.
|
|
||||||
// On success, returns the index path of that item.
|
|
||||||
// On failure, returns nil.
|
|
||||||
@objc(ensureLoadWindowContainsUniqueId:transaction:)
|
|
||||||
public func ensureLoadWindowContains(uniqueId: String,
|
|
||||||
transaction: YapDatabaseReadTransaction) -> IndexPath? {
|
|
||||||
if let oldIndex = loadedUniqueIds().firstIndex(of: uniqueId) {
|
|
||||||
return IndexPath(row: oldIndex, section: 0)
|
|
||||||
}
|
|
||||||
guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else {
|
|
||||||
SNLog("Could not load view.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let group = group else {
|
|
||||||
SNLog("No group.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let indexPtr: UnsafeMutablePointer<UInt> = UnsafeMutablePointer<UInt>.allocate(capacity: 1)
|
|
||||||
let wasFound = view.getGroup(nil, index: indexPtr, forKey: uniqueId, inCollection: TSInteraction.collection())
|
|
||||||
guard wasFound else {
|
|
||||||
SNLog("Could not find interaction.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let index = indexPtr.pointee
|
|
||||||
let threadInteractionCount = view.numberOfItems(inGroup: group)
|
|
||||||
guard index < threadInteractionCount else {
|
|
||||||
SNLog("Invalid index.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// This math doesn't take into account the number of items loaded _after_ the pivot.
|
|
||||||
// That's fine; it's okay to load too many interactions here.
|
|
||||||
let desiredWindowSize: UInt = threadInteractionCount - index
|
|
||||||
self.update(withDesiredLength: desiredWindowSize, transaction: transaction)
|
|
||||||
|
|
||||||
guard let newIndex = loadedUniqueIds().firstIndex(of: uniqueId) else {
|
|
||||||
SNLog("Couldn't find interaction.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return IndexPath(row: newIndex, section: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public class ConversationMessageMappingDiff: NSObject {
|
|
||||||
@objc
|
|
||||||
public let addedItemIds: Set<String>
|
|
||||||
@objc
|
|
||||||
public let removedItemIds: Set<String>
|
|
||||||
@objc
|
|
||||||
public let updatedItemIds: Set<String>
|
|
||||||
|
|
||||||
init(addedItemIds: Set<String>, removedItemIds: Set<String>, updatedItemIds: Set<String>) {
|
|
||||||
self.addedItemIds = addedItemIds
|
|
||||||
self.removedItemIds = removedItemIds
|
|
||||||
self.updatedItemIds = updatedItemIds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updates and then calculates which items were inserted, removed or modified.
|
|
||||||
@objc
|
|
||||||
public func updateAndCalculateDiff(transaction: YapDatabaseReadTransaction,
|
|
||||||
notifications: [NSNotification]) -> ConversationMessageMappingDiff? {
|
|
||||||
let oldItemIds = Set(self.itemIds)
|
|
||||||
self.update(transaction: transaction)
|
|
||||||
let newItemIds = Set(self.itemIds)
|
|
||||||
|
|
||||||
let removedItemIds = oldItemIds.subtracting(newItemIds)
|
|
||||||
let addedItemIds = newItemIds.subtracting(oldItemIds)
|
|
||||||
// We only notify for updated items that a) were previously loaded b) weren't also inserted or removed.
|
|
||||||
let updatedItemIds = (self.updatedItemIds(for: notifications)
|
|
||||||
.subtracting(addedItemIds)
|
|
||||||
.subtracting(removedItemIds)
|
|
||||||
.intersection(oldItemIds))
|
|
||||||
|
|
||||||
return ConversationMessageMappingDiff(addedItemIds: addedItemIds,
|
|
||||||
removedItemIds: removedItemIds,
|
|
||||||
updatedItemIds: updatedItemIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
// For performance reasons, the database modification notifications are used
|
|
||||||
// to determine which items were modified. If YapDatabase ever changes the
|
|
||||||
// structure or semantics of these notifications, we'll need to update this
|
|
||||||
// code to reflect that.
|
|
||||||
private func updatedItemIds(for notifications: [NSNotification]) -> Set<String> {
|
|
||||||
var updatedItemIds = Set<String>()
|
|
||||||
for notification in notifications {
|
|
||||||
// Unpack the YDB notification, looking for row changes.
|
|
||||||
guard let userInfo =
|
|
||||||
notification.userInfo else {
|
|
||||||
owsFailDebug("Missing userInfo.")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
guard let viewChangesets =
|
|
||||||
userInfo[YapDatabaseExtensionsKey] as? NSDictionary else {
|
|
||||||
// No changes for any views, skip.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
guard let changeset =
|
|
||||||
viewChangesets[viewName] as? NSDictionary else {
|
|
||||||
// No changes for this view, skip.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// This constant matches a private constant in YDB.
|
|
||||||
let changeset_key_changes: String = "changes"
|
|
||||||
guard let changesetChanges = changeset[changeset_key_changes] as? [Any] else {
|
|
||||||
owsFailDebug("Missing changeset changes.")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for change in changesetChanges {
|
|
||||||
if change as? YapDatabaseViewSectionChange != nil {
|
|
||||||
// Ignore.
|
|
||||||
} else if let rowChange = change as? YapDatabaseViewRowChange {
|
|
||||||
updatedItemIds.insert(rowChange.collectionKey.key)
|
|
||||||
} else {
|
|
||||||
owsFailDebug("Invalid change: \(type(of: change)).")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatedItemIds
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,143 +1,100 @@
|
||||||
//
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import UIKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
@objc
|
public class ConversationSearchController: NSObject {
|
||||||
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate {
|
public static let minimumSearchTextLength: UInt = 2
|
||||||
|
|
||||||
@objc
|
private let threadId: String
|
||||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController,
|
|
||||||
didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?)
|
|
||||||
|
|
||||||
@objc
|
|
||||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController,
|
|
||||||
didSelectMessageId: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public class ConversationSearchController : NSObject {
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public static let kMinimumSearchTextLength: UInt = 2
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public let uiSearchController = UISearchController(searchResultsController: nil)
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public weak var delegate: ConversationSearchControllerDelegate?
|
public weak var delegate: ConversationSearchControllerDelegate?
|
||||||
|
public let uiSearchController: UISearchController = UISearchController(searchResultsController: nil)
|
||||||
let thread: TSThread
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public let resultsBar: SearchResultsBar = SearchResultsBar()
|
public let resultsBar: SearchResultsBar = SearchResultsBar()
|
||||||
|
|
||||||
private var lastSearchText: String?
|
private var lastSearchText: String?
|
||||||
|
|
||||||
// MARK: Initializer
|
// MARK: Initializer
|
||||||
|
|
||||||
@objc
|
public init(threadId: String) {
|
||||||
required public init(thread: TSThread) {
|
self.threadId = threadId
|
||||||
self.thread = thread
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
|
self.resultsBar.resultsBarDelegate = self
|
||||||
|
self.uiSearchController.delegate = self
|
||||||
|
self.uiSearchController.searchResultsUpdater = self
|
||||||
|
|
||||||
resultsBar.resultsBarDelegate = self
|
self.uiSearchController.hidesNavigationBarDuringPresentation = false
|
||||||
uiSearchController.delegate = self
|
self.uiSearchController.searchBar.inputAccessoryView = resultsBar
|
||||||
uiSearchController.searchResultsUpdater = self
|
|
||||||
|
|
||||||
uiSearchController.hidesNavigationBarDuringPresentation = false
|
|
||||||
if #available(iOS 13, *) {
|
|
||||||
// Do nothing
|
|
||||||
} else {
|
|
||||||
uiSearchController.dimsBackgroundDuringPresentation = false
|
|
||||||
}
|
|
||||||
uiSearchController.searchBar.inputAccessoryView = resultsBar
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Dependencies
|
|
||||||
|
|
||||||
var dbReadConnection: YapDatabaseConnection {
|
|
||||||
return OWSPrimaryStorage.shared().dbReadConnection
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConversationSearchController : UISearchControllerDelegate {
|
// MARK: - UISearchControllerDelegate
|
||||||
|
|
||||||
|
extension ConversationSearchController: UISearchControllerDelegate {
|
||||||
public func didPresentSearchController(_ searchController: UISearchController) {
|
public func didPresentSearchController(_ searchController: UISearchController) {
|
||||||
Logger.verbose("")
|
|
||||||
delegate?.didPresentSearchController?(searchController)
|
delegate?.didPresentSearchController?(searchController)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func didDismissSearchController(_ searchController: UISearchController) {
|
public func didDismissSearchController(_ searchController: UISearchController) {
|
||||||
Logger.verbose("")
|
|
||||||
delegate?.didDismissSearchController?(searchController)
|
delegate?.didDismissSearchController?(searchController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConversationSearchController : UISearchResultsUpdating {
|
// MARK: - UISearchResultsUpdating
|
||||||
|
|
||||||
var dbSearcher: FullTextSearcher {
|
|
||||||
return FullTextSearcher.shared
|
|
||||||
}
|
|
||||||
|
|
||||||
|
extension ConversationSearchController: UISearchResultsUpdating {
|
||||||
public func updateSearchResults(for searchController: UISearchController) {
|
public func updateSearchResults(for searchController: UISearchController) {
|
||||||
Logger.verbose("searchBar.text: \( searchController.searchBar.text ?? "<blank>")")
|
Logger.verbose("searchBar.text: \( searchController.searchBar.text ?? "<blank>")")
|
||||||
|
|
||||||
guard let rawSearchText = searchController.searchBar.text?.stripped else {
|
guard
|
||||||
self.resultsBar.updateResults(resultSet: nil)
|
let searchText: String = searchController.searchBar.text?.stripped,
|
||||||
self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil)
|
searchText.count >= ConversationSearchController.minimumSearchTextLength
|
||||||
|
else {
|
||||||
|
self.resultsBar.updateResults(results: nil)
|
||||||
|
self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil, searchText: nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let searchText = FullTextSearchFinder.normalize(text: rawSearchText)
|
|
||||||
lastSearchText = searchText
|
let threadId: String = self.threadId
|
||||||
|
let results: [Int64] = Storage.shared.read { db -> [Int64] in
|
||||||
guard searchText.count >= ConversationSearchController.kMinimumSearchTextLength else {
|
try Interaction.idsForTermWithin(
|
||||||
lastSearchText = nil
|
threadId: threadId,
|
||||||
self.resultsBar.updateResults(resultSet: nil)
|
pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText)
|
||||||
self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil)
|
)
|
||||||
return
|
.fetchAll(db)
|
||||||
}
|
}
|
||||||
|
.defaulting(to: [])
|
||||||
var resultSet: ConversationScreenSearchResultSet?
|
|
||||||
self.dbReadConnection.asyncRead({ [weak self] transaction in
|
self.resultsBar.updateResults(results: results)
|
||||||
guard let self = self else {
|
self.delegate?.conversationSearchController(self, didUpdateSearchResults: results, searchText: searchText)
|
||||||
return
|
|
||||||
}
|
|
||||||
resultSet = self.dbSearcher.searchWithinConversation(thread: self.thread, searchText: searchText, transaction: transaction)
|
|
||||||
}, completionBlock: { [weak self] in
|
|
||||||
guard let self = self, searchText == self.lastSearchText else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.resultsBar.updateResults(resultSet: resultSet)
|
|
||||||
self.delegate?.conversationSearchController(self, didUpdateSearchResults: resultSet)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConversationSearchController : SearchResultsBarDelegate {
|
// MARK: - SearchResultsBarDelegate
|
||||||
|
|
||||||
func searchResultsBar(_ searchResultsBar: SearchResultsBar,
|
|
||||||
setCurrentIndex currentIndex: Int,
|
|
||||||
resultSet: ConversationScreenSearchResultSet) {
|
|
||||||
guard let searchResult = resultSet.messages[safe: currentIndex] else {
|
|
||||||
owsFailDebug("messageId was unexpectedly nil")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.delegate?.conversationSearchController(self, didSelectMessageId: searchResult.messageId)
|
extension ConversationSearchController: SearchResultsBarDelegate {
|
||||||
|
func searchResultsBar(
|
||||||
|
_ searchResultsBar: SearchResultsBar,
|
||||||
|
setCurrentIndex currentIndex: Int,
|
||||||
|
results: [Int64]
|
||||||
|
) {
|
||||||
|
guard let interactionId: Int64 = results[safe: currentIndex] else { return }
|
||||||
|
|
||||||
|
self.delegate?.conversationSearchController(self, didSelectInteractionId: interactionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol SearchResultsBarDelegate : AnyObject {
|
protocol SearchResultsBarDelegate: AnyObject {
|
||||||
|
func searchResultsBar(
|
||||||
func searchResultsBar(_ searchResultsBar: SearchResultsBar,
|
_ searchResultsBar: SearchResultsBar,
|
||||||
setCurrentIndex currentIndex: Int,
|
setCurrentIndex currentIndex: Int,
|
||||||
resultSet: ConversationScreenSearchResultSet)
|
results: [Int64]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public final class SearchResultsBar : UIView {
|
public final class SearchResultsBar: UIView {
|
||||||
private var resultSet: ConversationScreenSearchResultSet?
|
private var results: [Int64]?
|
||||||
var currentIndex: Int?
|
var currentIndex: Int?
|
||||||
weak var resultsBarDelegate: SearchResultsBarDelegate?
|
weak var resultsBarDelegate: SearchResultsBarDelegate?
|
||||||
|
|
||||||
|
@ -145,7 +102,6 @@ public final class SearchResultsBar : UIView {
|
||||||
|
|
||||||
private lazy var label: UILabel = {
|
private lazy var label: UILabel = {
|
||||||
let result = UILabel()
|
let result = UILabel()
|
||||||
result.text = "Test"
|
|
||||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||||
result.textColor = Colors.text
|
result.textColor = Colors.text
|
||||||
return result
|
return result
|
||||||
|
@ -169,6 +125,14 @@ public final class SearchResultsBar : UIView {
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
private lazy var loadingIndicator: UIActivityIndicatorView = {
|
||||||
|
let result = UIActivityIndicatorView(style: .medium)
|
||||||
|
result.tintColor = Colors.text
|
||||||
|
result.alpha = 0.5
|
||||||
|
result.hidesWhenStopped = true
|
||||||
|
return result
|
||||||
|
}()
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
|
@ -181,6 +145,7 @@ public final class SearchResultsBar : UIView {
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy() {
|
||||||
autoresizingMask = .flexibleHeight
|
autoresizingMask = .flexibleHeight
|
||||||
|
|
||||||
// Background & blur
|
// Background & blur
|
||||||
let backgroundView = UIView()
|
let backgroundView = UIView()
|
||||||
backgroundView.backgroundColor = isLightMode ? .white : .black
|
backgroundView.backgroundColor = isLightMode ? .white : .black
|
||||||
|
@ -190,18 +155,22 @@ public final class SearchResultsBar : UIView {
|
||||||
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||||
addSubview(blurView)
|
addSubview(blurView)
|
||||||
blurView.pin(to: self)
|
blurView.pin(to: self)
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
let separator = UIView()
|
let separator = UIView()
|
||||||
separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
|
separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
|
||||||
separator.set(.height, to: 1 / UIScreen.main.scale)
|
separator.set(.height, to: 1 / UIScreen.main.scale)
|
||||||
addSubview(separator)
|
addSubview(separator)
|
||||||
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
|
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
|
||||||
|
|
||||||
// Spacers
|
// Spacers
|
||||||
let spacer1 = UIView.hStretchingSpacer()
|
let spacer1 = UIView.hStretchingSpacer()
|
||||||
let spacer2 = UIView.hStretchingSpacer()
|
let spacer2 = UIView.hStretchingSpacer()
|
||||||
|
|
||||||
// Button containers
|
// Button containers
|
||||||
let upButtonContainer = UIView(wrapping: upButton, withInsets: UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0))
|
let upButtonContainer = UIView(wrapping: upButton, withInsets: UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0))
|
||||||
let downButtonContainer = UIView(wrapping: downButton, withInsets: UIEdgeInsets(top: 0, left: 0, bottom: 2, right: 0))
|
let downButtonContainer = UIView(wrapping: downButton, withInsets: UIEdgeInsets(top: 0, left: 0, bottom: 2, right: 0))
|
||||||
|
|
||||||
// Main stack view
|
// Main stack view
|
||||||
let mainStackView = UIStackView(arrangedSubviews: [ upButtonContainer, downButtonContainer, spacer1, label, spacer2 ])
|
let mainStackView = UIStackView(arrangedSubviews: [ upButtonContainer, downButtonContainer, spacer1, label, spacer2 ])
|
||||||
mainStackView.axis = .horizontal
|
mainStackView.axis = .horizontal
|
||||||
|
@ -209,110 +178,116 @@ public final class SearchResultsBar : UIView {
|
||||||
mainStackView.isLayoutMarginsRelativeArrangement = true
|
mainStackView.isLayoutMarginsRelativeArrangement = true
|
||||||
mainStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.largeSpacing, bottom: Values.smallSpacing, trailing: Values.largeSpacing)
|
mainStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.largeSpacing, bottom: Values.smallSpacing, trailing: Values.largeSpacing)
|
||||||
addSubview(mainStackView)
|
addSubview(mainStackView)
|
||||||
|
|
||||||
mainStackView.pin(.top, to: .bottom, of: separator)
|
mainStackView.pin(.top, to: .bottom, of: separator)
|
||||||
mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self)
|
mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self)
|
||||||
mainStackView.pin(.bottom, to: .bottom, of: self, withInset: -2)
|
mainStackView.pin(.bottom, to: .bottom, of: self, withInset: -2)
|
||||||
|
|
||||||
|
addSubview(loadingIndicator)
|
||||||
|
loadingIndicator.pin(.left, to: .right, of: label, withInset: 10)
|
||||||
|
loadingIndicator.centerYAnchor.constraint(equalTo: label.centerYAnchor).isActive = true
|
||||||
|
|
||||||
// Remaining constraints
|
// Remaining constraints
|
||||||
label.center(.horizontal, in: self)
|
label.center(.horizontal, in: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Functions
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
public func handleUpButtonTapped() {
|
public func handleUpButtonTapped() {
|
||||||
Logger.debug("")
|
guard let results: [Int64] = results else { return }
|
||||||
guard let resultSet = resultSet else {
|
guard let currentIndex: Int = currentIndex else { return }
|
||||||
owsFailDebug("resultSet was unexpectedly nil")
|
guard currentIndex + 1 < results.count else { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let currentIndex = currentIndex else {
|
|
||||||
owsFailDebug("currentIndex was unexpectedly nil")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard currentIndex + 1 < resultSet.messages.count else {
|
|
||||||
owsFailDebug("showLessRecent button should be disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let newIndex = currentIndex + 1
|
let newIndex = currentIndex + 1
|
||||||
self.currentIndex = newIndex
|
self.currentIndex = newIndex
|
||||||
updateBarItems()
|
updateBarItems()
|
||||||
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, resultSet: resultSet)
|
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, results: results)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
public func handleDownButtonTapped() {
|
public func handleDownButtonTapped() {
|
||||||
Logger.debug("")
|
Logger.debug("")
|
||||||
guard let resultSet = resultSet else {
|
guard let results: [Int64] = results else { return }
|
||||||
owsFailDebug("resultSet was unexpectedly nil")
|
guard let currentIndex: Int = currentIndex, currentIndex > 0 else { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let currentIndex = currentIndex else {
|
|
||||||
owsFailDebug("currentIndex was unexpectedly nil")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard currentIndex > 0 else {
|
|
||||||
owsFailDebug("showMoreRecent button should be disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let newIndex = currentIndex - 1
|
let newIndex = currentIndex - 1
|
||||||
self.currentIndex = newIndex
|
self.currentIndex = newIndex
|
||||||
updateBarItems()
|
updateBarItems()
|
||||||
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, resultSet: resultSet)
|
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, results: results)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateResults(resultSet: ConversationScreenSearchResultSet?) {
|
func updateResults(results: [Int64]?) {
|
||||||
if let resultSet = resultSet {
|
currentIndex = {
|
||||||
if resultSet.messages.count > 0 {
|
guard let results: [Int64] = results, !results.isEmpty else { return nil }
|
||||||
currentIndex = min(currentIndex ?? 0, resultSet.messages.count - 1)
|
|
||||||
} else {
|
if let currentIndex: Int = currentIndex {
|
||||||
currentIndex = nil
|
return max(0, min(currentIndex, results.count - 1))
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
currentIndex = nil
|
return 0
|
||||||
}
|
}()
|
||||||
|
|
||||||
self.resultSet = resultSet
|
self.results = results
|
||||||
|
|
||||||
updateBarItems()
|
updateBarItems()
|
||||||
if let currentIndex = currentIndex, let resultSet = resultSet {
|
|
||||||
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: currentIndex, resultSet: resultSet)
|
if let currentIndex = currentIndex, let results = results {
|
||||||
|
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: currentIndex, results: results)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateBarItems() {
|
func updateBarItems() {
|
||||||
guard let resultSet = resultSet else {
|
guard let results: [Int64] = results else {
|
||||||
label.text = ""
|
label.text = ""
|
||||||
downButton.isEnabled = false
|
downButton.isEnabled = false
|
||||||
upButton.isEnabled = false
|
upButton.isEnabled = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch resultSet.messages.count {
|
switch results.count {
|
||||||
case 0:
|
case 0:
|
||||||
label.text = NSLocalizedString("CONVERSATION_SEARCH_NO_RESULTS", comment: "keyboard toolbar label when no messages match the search string")
|
// Keyboard toolbar label when no messages match the search string
|
||||||
case 1:
|
label.text = "CONVERSATION_SEARCH_NO_RESULTS".localized()
|
||||||
label.text = NSLocalizedString("CONVERSATION_SEARCH_ONE_RESULT", comment: "keyboard toolbar label when exactly 1 message matches the search string")
|
|
||||||
default:
|
case 1:
|
||||||
let format = NSLocalizedString("CONVERSATION_SEARCH_RESULTS_FORMAT",
|
// Keyboard toolbar label when exactly 1 message matches the search string
|
||||||
comment: "keyboard toolbar label when more than 1 message matches the search string. Embeds {{number/position of the 'currently viewed' result}} and the {{total number of results}}")
|
label.text = "CONVERSATION_SEARCH_ONE_RESULT".localized()
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Keyboard toolbar label when more than 1 message matches the search string
|
||||||
|
//
|
||||||
|
// Embeds {{number/position of the 'currently viewed' result}} and
|
||||||
|
// the {{total number of results}}
|
||||||
|
let format = "CONVERSATION_SEARCH_RESULTS_FORMAT".localized()
|
||||||
|
|
||||||
guard let currentIndex = currentIndex else {
|
guard let currentIndex: Int = currentIndex else { return }
|
||||||
owsFailDebug("currentIndex was unexpectedly nil")
|
|
||||||
return
|
label.text = String(format: format, currentIndex + 1, results.count)
|
||||||
}
|
}
|
||||||
label.text = String(format: format, currentIndex + 1, resultSet.messages.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let currentIndex = currentIndex {
|
if let currentIndex: Int = currentIndex {
|
||||||
downButton.isEnabled = currentIndex > 0
|
downButton.isEnabled = currentIndex > 0
|
||||||
upButton.isEnabled = currentIndex + 1 < resultSet.messages.count
|
upButton.isEnabled = (currentIndex + 1 < results.count)
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
downButton.isEnabled = false
|
downButton.isEnabled = false
|
||||||
upButton.isEnabled = false
|
upButton.isEnabled = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func startLoading() {
|
||||||
|
loadingIndicator.startAnimating()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stopLoading() {
|
||||||
|
loadingIndicator.stopAnimating()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ConversationSearchControllerDelegate
|
||||||
|
|
||||||
|
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate {
|
||||||
|
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?)
|
||||||
|
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId: Int64)
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -1,8 +0,0 @@
|
||||||
@import Foundation;
|
|
||||||
|
|
||||||
typedef NS_ENUM(NSUInteger, ConversationViewAction) {
|
|
||||||
ConversationViewActionNone,
|
|
||||||
ConversationViewActionCompose,
|
|
||||||
ConversationViewActionAudioCall,
|
|
||||||
ConversationViewActionVideoCall,
|
|
||||||
};
|
|
|
@ -1,165 +0,0 @@
|
||||||
//
|
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <SessionMessagingKit/OWSAudioPlayer.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
extern NSString *const SNAudioDidFinishPlayingNotification;
|
|
||||||
|
|
||||||
typedef NS_ENUM(NSInteger, OWSMessageCellType) {
|
|
||||||
OWSMessageCellType_Unknown,
|
|
||||||
OWSMessageCellType_TextOnlyMessage,
|
|
||||||
OWSMessageCellType_Audio,
|
|
||||||
OWSMessageCellType_GenericAttachment,
|
|
||||||
OWSMessageCellType_MediaMessage,
|
|
||||||
OWSMessageCellType_OversizeTextDownloading,
|
|
||||||
OWSMessageCellType_DeletedMessage
|
|
||||||
};
|
|
||||||
|
|
||||||
NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType);
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
|
|
||||||
@class ContactShareViewModel;
|
|
||||||
@class ConversationViewCell;
|
|
||||||
@class DisplayableText;
|
|
||||||
@class SNVoiceMessageView;
|
|
||||||
@class OWSLinkPreview;
|
|
||||||
@class OWSQuotedReplyModel;
|
|
||||||
@class OWSUnreadIndicator;
|
|
||||||
@class TSAttachment;
|
|
||||||
@class TSAttachmentPointer;
|
|
||||||
@class TSAttachmentStream;
|
|
||||||
@class TSInteraction;
|
|
||||||
@class TSThread;
|
|
||||||
@class YapDatabaseReadTransaction;
|
|
||||||
|
|
||||||
@interface ConversationMediaAlbumItem : NSObject
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) TSAttachment *attachment;
|
|
||||||
|
|
||||||
// This property will only be set if the attachment is downloaded.
|
|
||||||
@property (nonatomic, readonly, nullable) TSAttachmentStream *attachmentStream;
|
|
||||||
|
|
||||||
// This property will be non-zero if the attachment is valid.
|
|
||||||
@property (nonatomic, readonly) CGSize mediaSize;
|
|
||||||
|
|
||||||
@property (nonatomic, readonly, nullable) NSString *caption;
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) BOOL isFailedDownload;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
|
|
||||||
@protocol ConversationViewItem <NSObject, OWSAudioPlayerDelegate>
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) TSInteraction *interaction;
|
|
||||||
|
|
||||||
@property (nonatomic, readonly, nullable) OWSQuotedReplyModel *quotedReply;
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) BOOL isGroupThread;
|
|
||||||
@property (nonatomic, readonly) BOOL userCanDeleteGroupMessage;
|
|
||||||
@property (nonatomic, readonly) BOOL userHasModerationPermission;
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) BOOL hasBodyText;
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) BOOL isQuotedReply;
|
|
||||||
@property (nonatomic, readonly) BOOL hasQuotedAttachment;
|
|
||||||
@property (nonatomic, readonly) BOOL hasQuotedText;
|
|
||||||
@property (nonatomic, readonly) BOOL hasCellHeader;
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) BOOL isExpiringMessage;
|
|
||||||
|
|
||||||
@property (nonatomic) BOOL shouldShowDate;
|
|
||||||
@property (nonatomic) BOOL shouldShowSenderProfilePicture;
|
|
||||||
@property (nonatomic, nullable) NSAttributedString *senderName;
|
|
||||||
@property (nonatomic) BOOL shouldHideFooter;
|
|
||||||
@property (nonatomic) BOOL isFirstInCluster;
|
|
||||||
@property (nonatomic) BOOL isOnlyMessageInCluster;
|
|
||||||
@property (nonatomic) BOOL isLastInCluster;
|
|
||||||
@property (nonatomic) BOOL wasPreviousItemInfoMessage;
|
|
||||||
|
|
||||||
@property (nonatomic, nullable) OWSUnreadIndicator *unreadIndicator;
|
|
||||||
|
|
||||||
- (void)replaceInteraction:(TSInteraction *)interaction transaction:(YapDatabaseReadTransaction *)transaction;
|
|
||||||
|
|
||||||
- (void)clearCachedLayoutState;
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) BOOL hasCachedLayoutState;
|
|
||||||
|
|
||||||
#pragma mark - Audio Playback
|
|
||||||
|
|
||||||
@property (nonatomic, weak) SNVoiceMessageView *lastAudioMessageView;
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) CGFloat audioDurationSeconds;
|
|
||||||
@property (nonatomic, readonly) CGFloat audioProgressSeconds;
|
|
||||||
|
|
||||||
#pragma mark - View State Caching
|
|
||||||
|
|
||||||
// These methods only apply to text & attachment messages.
|
|
||||||
@property (nonatomic, readonly) OWSMessageCellType messageCellType;
|
|
||||||
@property (nonatomic, readonly, nullable) DisplayableText *displayableBodyText;
|
|
||||||
@property (nonatomic, readonly, nullable) TSAttachmentStream *attachmentStream;
|
|
||||||
@property (nonatomic, readonly, nullable) TSAttachmentPointer *attachmentPointer;
|
|
||||||
@property (nonatomic, readonly, nullable) NSArray<ConversationMediaAlbumItem *> *mediaAlbumItems;
|
|
||||||
|
|
||||||
@property (nonatomic, readonly, nullable) DisplayableText *displayableQuotedText;
|
|
||||||
@property (nonatomic, readonly, nullable) NSString *quotedAttachmentMimetype;
|
|
||||||
@property (nonatomic, readonly, nullable) NSString *quotedRecipientId;
|
|
||||||
|
|
||||||
// We don't want to try to load the media for this item (if any)
|
|
||||||
// if a load has previously failed.
|
|
||||||
@property (nonatomic) BOOL didCellMediaFailToLoad;
|
|
||||||
|
|
||||||
@property (nonatomic, readonly, nullable) ContactShareViewModel *contactShare;
|
|
||||||
|
|
||||||
@property (nonatomic, readonly, nullable) OWSLinkPreview *linkPreview;
|
|
||||||
@property (nonatomic, readonly, nullable) TSAttachment *linkPreviewAttachment;
|
|
||||||
|
|
||||||
@property (nonatomic, readonly, nullable) NSString *systemMessageText;
|
|
||||||
|
|
||||||
// NOTE: This property is only set for incoming messages.
|
|
||||||
@property (nonatomic, readonly, nullable) NSString *authorConversationColorName;
|
|
||||||
|
|
||||||
#pragma mark - MessageActions
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) BOOL hasBodyTextActionContent;
|
|
||||||
@property (nonatomic, readonly) BOOL hasMediaActionContent;
|
|
||||||
|
|
||||||
- (void)copyMediaAction;
|
|
||||||
- (void)copyTextAction;
|
|
||||||
- (void)saveMediaAction;
|
|
||||||
- (void)deleteLocallyAction;
|
|
||||||
- (void)deleteRemotelyAction;
|
|
||||||
|
|
||||||
- (void)deleteAction; // Remove this after the unsend request is enabled
|
|
||||||
|
|
||||||
- (BOOL)canCopyMedia;
|
|
||||||
- (BOOL)canSaveMedia;
|
|
||||||
|
|
||||||
// For view items that correspond to interactions, this is the interaction's unique id.
|
|
||||||
// For other view views (like the typing indicator), this is a unique, stable string.
|
|
||||||
- (NSString *)itemId;
|
|
||||||
|
|
||||||
- (nullable TSAttachmentStream *)firstValidAlbumAttachment;
|
|
||||||
|
|
||||||
- (BOOL)mediaAlbumHasFailedAttachment;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
|
|
||||||
@interface ConversationInteractionViewItem
|
|
||||||
: NSObject <ConversationViewItem, OWSAudioPlayerDelegate>
|
|
||||||
|
|
||||||
- (instancetype)init NS_UNAVAILABLE;
|
|
||||||
- (instancetype)initWithInteraction:(TSInteraction *)interaction
|
|
||||||
isGroupThread:(BOOL)isGroupThread
|
|
||||||
transaction:(YapDatabaseReadTransaction *)transaction;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,142 +0,0 @@
|
||||||
//
|
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
@class ConversationStyle;
|
|
||||||
@class ConversationViewModel;
|
|
||||||
@class OWSQuotedReplyModel;
|
|
||||||
@class TSOutgoingMessage;
|
|
||||||
@class TSThread;
|
|
||||||
@class ThreadDynamicInteractions;
|
|
||||||
|
|
||||||
@protocol ConversationViewItem;
|
|
||||||
|
|
||||||
typedef NS_ENUM(NSUInteger, ConversationUpdateType) {
|
|
||||||
// No view items in the load window were effected.
|
|
||||||
ConversationUpdateType_Minor,
|
|
||||||
// A subset of view items in the load window were effected;
|
|
||||||
// the view should be updated using the update items.
|
|
||||||
ConversationUpdateType_Diff,
|
|
||||||
// Complicated or unexpected changes occurred in the load window;
|
|
||||||
// the view should be reloaded.
|
|
||||||
ConversationUpdateType_Reload,
|
|
||||||
};
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
|
|
||||||
typedef NS_ENUM(NSUInteger, ConversationUpdateItemType) {
|
|
||||||
ConversationUpdateItemType_Insert,
|
|
||||||
ConversationUpdateItemType_Delete,
|
|
||||||
ConversationUpdateItemType_Update,
|
|
||||||
};
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
|
|
||||||
@interface ConversationViewState : NSObject
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) NSArray<id<ConversationViewItem>> *viewItems;
|
|
||||||
@property (nonatomic, readonly) NSDictionary<NSString *, NSNumber *> *interactionIndexMap;
|
|
||||||
// We have to track interactionIds separately. We can't just use interactionIndexMap.allKeys,
|
|
||||||
// as that won't preserve ordering.
|
|
||||||
@property (nonatomic, readonly) NSArray<NSString *> *interactionIds;
|
|
||||||
@property (nonatomic, readonly, nullable) NSNumber *unreadIndicatorIndex;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
|
|
||||||
@interface ConversationUpdateItem : NSObject
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) ConversationUpdateItemType updateItemType;
|
|
||||||
// Only applies in the "delete" and "update" cases.
|
|
||||||
@property (nonatomic, readonly) NSUInteger oldIndex;
|
|
||||||
// Only applies in the "insert" and "update" cases.
|
|
||||||
@property (nonatomic, readonly) NSUInteger newIndex;
|
|
||||||
// Only applies in the "insert" and "update" cases.
|
|
||||||
@property (nonatomic, readonly, nullable) id<ConversationViewItem> viewItem;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
|
|
||||||
@interface ConversationUpdate : NSObject
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) ConversationUpdateType conversationUpdateType;
|
|
||||||
// Only applies in the "diff" case.
|
|
||||||
@property (nonatomic, readonly, nullable) NSArray<ConversationUpdateItem *> *updateItems;
|
|
||||||
//// Only applies in the "diff" case.
|
|
||||||
@property (nonatomic, readonly) BOOL shouldAnimateUpdates;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
|
|
||||||
@protocol ConversationViewModelDelegate <NSObject>
|
|
||||||
|
|
||||||
- (void)conversationViewModelWillUpdate;
|
|
||||||
- (void)conversationViewModelDidUpdate:(ConversationUpdate *)conversationUpdate;
|
|
||||||
|
|
||||||
- (void)conversationViewModelWillLoadMoreItems;
|
|
||||||
- (void)conversationViewModelDidLoadMoreItems;
|
|
||||||
- (void)conversationViewModelDidLoadPrevPage;
|
|
||||||
- (void)conversationViewModelRangeDidChange;
|
|
||||||
|
|
||||||
// Called after the view model recovers from a severe error
|
|
||||||
// to prod the view to reset its scroll state, etc.
|
|
||||||
- (void)conversationViewModelDidReset;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
|
|
||||||
// Always load up to n messages when user arrives.
|
|
||||||
//
|
|
||||||
// The smaller this number is, the faster the conversation can display.
|
|
||||||
// To test, shrink you accessibility font as much as possible, then count how many 1-line system info messages (our
|
|
||||||
// shortest cells) can fit on screen at a time on an iPhoneX
|
|
||||||
//
|
|
||||||
// PERF: we could do less messages on shorter (older, slower) devices
|
|
||||||
// PERF: we could cache the cell height, since some messages will be much taller.
|
|
||||||
static const int kYapDatabasePageSize = 250;
|
|
||||||
|
|
||||||
// Never show more than n messages in conversation view when user arrives.
|
|
||||||
static const int kConversationInitialMaxRangeSize = 250;
|
|
||||||
|
|
||||||
// Never show more than n messages in conversation view at a time.
|
|
||||||
static const int kYapDatabaseRangeMaxLength = 250000;
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
|
|
||||||
@interface ConversationViewModel : NSObject
|
|
||||||
|
|
||||||
@property (nonatomic, readonly) ConversationViewState *viewState;
|
|
||||||
@property (nonatomic, nullable) NSString *focusMessageIdOnOpen;
|
|
||||||
@property (nonatomic, readonly, nullable) ThreadDynamicInteractions *dynamicInteractions;
|
|
||||||
|
|
||||||
- (instancetype)init NS_UNAVAILABLE;
|
|
||||||
- (instancetype)initWithThread:(TSThread *)thread
|
|
||||||
focusMessageIdOnOpen:(nullable NSString *)focusMessageIdOnOpen
|
|
||||||
delegate:(id<ConversationViewModelDelegate>)delegate NS_DESIGNATED_INITIALIZER;
|
|
||||||
|
|
||||||
- (void)ensureDynamicInteractionsAndUpdateIfNecessary;
|
|
||||||
|
|
||||||
- (void)loadAnotherPageOfMessages;
|
|
||||||
|
|
||||||
- (void)viewDidResetContentAndLayout;
|
|
||||||
|
|
||||||
- (void)viewDidLoad;
|
|
||||||
|
|
||||||
- (BOOL)canLoadMoreItems;
|
|
||||||
|
|
||||||
- (nullable NSIndexPath *)ensureLoadWindowContainsQuotedReply:(OWSQuotedReplyModel *)quotedReply;
|
|
||||||
- (nullable NSIndexPath *)ensureLoadWindowContainsInteractionId:(NSString *)interactionId;
|
|
||||||
|
|
||||||
- (void)appendUnsavedOutgoingTextMessage:(TSOutgoingMessage *)outgoingMessage;
|
|
||||||
|
|
||||||
- (BOOL)reloadViewItems;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,714 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import DifferenceKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
|
public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
|
public typealias SectionModel = ArraySection<Section, MessageViewModel>
|
||||||
|
|
||||||
|
// MARK: - Action
|
||||||
|
|
||||||
|
public enum Action {
|
||||||
|
case none
|
||||||
|
case compose
|
||||||
|
case audioCall
|
||||||
|
case videoCall
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Section
|
||||||
|
|
||||||
|
public enum Section: Differentiable, Equatable, Comparable, Hashable {
|
||||||
|
case loadOlder
|
||||||
|
case messages
|
||||||
|
case loadNewer
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Variables
|
||||||
|
|
||||||
|
public static let pageSize: Int = 50
|
||||||
|
|
||||||
|
private var threadId: String
|
||||||
|
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 lazy var blockedBannerMessage: String = {
|
||||||
|
switch self.threadData.threadVariant {
|
||||||
|
case .contact:
|
||||||
|
let name: String = Profile.displayName(
|
||||||
|
id: self.threadData.threadId,
|
||||||
|
threadVariant: self.threadData.threadVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\(name) is blocked. Unblock them?"
|
||||||
|
|
||||||
|
default: return "Thread is blocked. Unblock it?"
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
|
||||||
|
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)
|
||||||
|
.fetchOne(db)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
self.threadId = threadId
|
||||||
|
self.initialThreadVariant = threadVariant
|
||||||
|
self.focusedInteractionId = targetInteractionId
|
||||||
|
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)
|
||||||
|
self.pagedDataObserver = self.setupPagedObserver(
|
||||||
|
for: threadId,
|
||||||
|
userPublicKey: getUserHexEncodedPublicKey()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run the initial query on a background thread so we don't block the push transition
|
||||||
|
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||||
|
// If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query
|
||||||
|
// from a `0` offset)
|
||||||
|
guard let initialFocusedId: Int64 = targetInteractionId else {
|
||||||
|
self?.pagedDataObserver?.load(.pageBefore)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Thread Data
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
/// 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:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static
|
||||||
|
/// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
|
||||||
|
///
|
||||||
|
/// **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
|
||||||
|
public lazy var observableThreadData: ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> = setupObservableThreadData(for: self.threadId)
|
||||||
|
|
||||||
|
private func setupObservableThreadData(for threadId: String) -> ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> {
|
||||||
|
return ValueObservation
|
||||||
|
.trackingConstantRegion { db -> SessionThreadViewModel? in
|
||||||
|
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||||
|
|
||||||
|
return try SessionThreadViewModel
|
||||||
|
.conversationQuery(threadId: threadId, userPublicKey: userPublicKey)
|
||||||
|
.fetchOne(db)
|
||||||
|
}
|
||||||
|
.removeDuplicates()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateThreadData(_ updatedData: SessionThreadViewModel) {
|
||||||
|
self.threadData = updatedData
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Interaction Data
|
||||||
|
|
||||||
|
public private(set) var unobservedInteractionDataChanges: [SectionModel]?
|
||||||
|
public private(set) var interactionData: [SectionModel] = []
|
||||||
|
public private(set) var pagedDataObserver: PagedDatabaseObserver<Interaction, MessageViewModel>?
|
||||||
|
|
||||||
|
public var onInteractionChange: (([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 unobservedInteractionDataChanges: [SectionModel] = self.unobservedInteractionDataChanges {
|
||||||
|
onInteractionChange?(unobservedInteractionDataChanges)
|
||||||
|
self.unobservedInteractionDataChanges = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupPagedObserver(for threadId: String, userPublicKey: String) -> PagedDatabaseObserver<Interaction, MessageViewModel> {
|
||||||
|
return PagedDatabaseObserver(
|
||||||
|
pagedTable: Interaction.self,
|
||||||
|
pageSize: ConversationViewModel.pageSize,
|
||||||
|
idColumn: .id,
|
||||||
|
observedChanges: [
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: Interaction.self,
|
||||||
|
columns: Interaction.Columns
|
||||||
|
.allCases
|
||||||
|
.filter { $0 != .wasRead }
|
||||||
|
),
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: Contact.self,
|
||||||
|
columns: [.isTrusted],
|
||||||
|
joinToPagedType: {
|
||||||
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
|
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||||
|
|
||||||
|
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])")
|
||||||
|
}()
|
||||||
|
),
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: Profile.self,
|
||||||
|
columns: [.profilePictureFileName],
|
||||||
|
joinToPagedType: {
|
||||||
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
|
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||||
|
|
||||||
|
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
|
||||||
|
}()
|
||||||
|
)
|
||||||
|
],
|
||||||
|
filterSQL: MessageViewModel.filterSQL(threadId: threadId),
|
||||||
|
groupSQL: MessageViewModel.groupSQL,
|
||||||
|
orderSQL: MessageViewModel.orderSQL,
|
||||||
|
dataQuery: MessageViewModel.baseQuery(
|
||||||
|
userPublicKey: userPublicKey,
|
||||||
|
orderSQL: MessageViewModel.orderSQL,
|
||||||
|
groupSQL: MessageViewModel.groupSQL
|
||||||
|
),
|
||||||
|
associatedRecords: [
|
||||||
|
AssociatedRecord<MessageViewModel.AttachmentInteractionInfo, MessageViewModel>(
|
||||||
|
trackedAgainst: Attachment.self,
|
||||||
|
observedChanges: [
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: Attachment.self,
|
||||||
|
columns: [.state]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
dataQuery: MessageViewModel.AttachmentInteractionInfo.baseQuery,
|
||||||
|
joinToPagedType: MessageViewModel.AttachmentInteractionInfo.joinToViewModelQuerySQL,
|
||||||
|
associateData: MessageViewModel.AttachmentInteractionInfo.createAssociateDataClosure()
|
||||||
|
),
|
||||||
|
AssociatedRecord<MessageViewModel.TypingIndicatorInfo, MessageViewModel>(
|
||||||
|
trackedAgainst: ThreadTypingIndicator.self,
|
||||||
|
observedChanges: [
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: ThreadTypingIndicator.self,
|
||||||
|
events: [.insert, .delete],
|
||||||
|
columns: []
|
||||||
|
)
|
||||||
|
],
|
||||||
|
dataQuery: MessageViewModel.TypingIndicatorInfo.baseQuery,
|
||||||
|
joinToPagedType: MessageViewModel.TypingIndicatorInfo.joinToViewModelQuerySQL,
|
||||||
|
associateData: MessageViewModel.TypingIndicatorInfo.createAssociateDataClosure()
|
||||||
|
)
|
||||||
|
],
|
||||||
|
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||||
|
guard let updatedInteractionData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have the 'onInteractionChanged' callback then trigger it, otherwise just store the changes
|
||||||
|
// to be sent to the callback if we ever start observing again (when we have the callback it needs
|
||||||
|
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
|
||||||
|
// correct order)
|
||||||
|
guard let onInteractionChange: (([SectionModel]) -> ()) = self?.onInteractionChange else {
|
||||||
|
self?.unobservedInteractionDataChanges = updatedInteractionData
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onInteractionChange(updatedInteractionData)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
||||||
|
let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true })
|
||||||
|
let sortedData: [MessageViewModel] = data
|
||||||
|
.filter { $0.isTypingIndicator != true }
|
||||||
|
.sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs }
|
||||||
|
|
||||||
|
// We load messages from newest to oldest so having a pageOffset larger than zero means
|
||||||
|
// there are newer pages to load
|
||||||
|
return [
|
||||||
|
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
|
||||||
|
[SectionModel(section: .loadOlder)] :
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
[
|
||||||
|
SectionModel(
|
||||||
|
section: .messages,
|
||||||
|
elements: sortedData
|
||||||
|
.enumerated()
|
||||||
|
.map { index, cellViewModel -> MessageViewModel in
|
||||||
|
cellViewModel.withClusteringChanges(
|
||||||
|
prevModel: (index > 0 ? sortedData[index - 1] : nil),
|
||||||
|
nextModel: (index < (sortedData.count - 1) ? sortedData[index + 1] : nil),
|
||||||
|
isLast: (
|
||||||
|
// The database query sorts by timestampMs descending so the "last"
|
||||||
|
// interaction will actually have a 'pageOffset' of '0' even though
|
||||||
|
// it's the last element in the 'sortedData' array
|
||||||
|
index == (sortedData.count - 1) &&
|
||||||
|
pageInfo.pageOffset == 0
|
||||||
|
),
|
||||||
|
currentUserBlindedPublicKey: threadData.currentUserBlindedPublicKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.appending(typingIndicator)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
(!data.isEmpty && pageInfo.pageOffset > 0 ?
|
||||||
|
[SectionModel(section: .loadNewer)] :
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
].flatMap { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateInteractionData(_ updatedData: [SectionModel]) {
|
||||||
|
self.interactionData = updatedData
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mentions
|
||||||
|
|
||||||
|
public struct MentionInfo: FetchableRecord, Decodable {
|
||||||
|
fileprivate static let threadVariantKey = CodingKeys.threadVariant.stringValue
|
||||||
|
fileprivate static let openGroupServerKey = CodingKeys.openGroupServer.stringValue
|
||||||
|
fileprivate static let openGroupRoomTokenKey = CodingKeys.openGroupRoomToken.stringValue
|
||||||
|
|
||||||
|
let profile: Profile
|
||||||
|
let threadVariant: SessionThread.Variant
|
||||||
|
let openGroupServer: String?
|
||||||
|
let openGroupRoomToken: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public func mentions(for query: String = "") -> [MentionInfo] {
|
||||||
|
let threadData: SessionThreadViewModel = self.threadData
|
||||||
|
|
||||||
|
let results: [MentionInfo] = Storage.shared
|
||||||
|
.read { db -> [MentionInfo] in
|
||||||
|
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||||
|
|
||||||
|
switch threadData.threadVariant {
|
||||||
|
case .contact:
|
||||||
|
guard userPublicKey != threadData.threadId else { return [] }
|
||||||
|
|
||||||
|
return [Profile.fetchOrCreate(db, id: threadData.threadId)]
|
||||||
|
.map { profile in
|
||||||
|
MentionInfo(
|
||||||
|
profile: profile,
|
||||||
|
threadVariant: threadData.threadVariant,
|
||||||
|
openGroupServer: nil,
|
||||||
|
openGroupRoomToken: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.filter {
|
||||||
|
query.count < 2 ||
|
||||||
|
$0.profile.displayName(for: $0.threadVariant).contains(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .closedGroup:
|
||||||
|
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||||
|
|
||||||
|
return try GroupMember
|
||||||
|
.select(
|
||||||
|
profile.allColumns(),
|
||||||
|
SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey)
|
||||||
|
)
|
||||||
|
.filter(GroupMember.Columns.groupId == threadData.threadId)
|
||||||
|
.filter(GroupMember.Columns.profileId != userPublicKey)
|
||||||
|
.filter(GroupMember.Columns.role == GroupMember.Role.standard)
|
||||||
|
.joining(
|
||||||
|
required: GroupMember.profile
|
||||||
|
.aliased(profile)
|
||||||
|
// Note: LIKE is case-insensitive in SQLite
|
||||||
|
.filter(
|
||||||
|
query.count < 2 || (
|
||||||
|
profile[.nickname] != nil &&
|
||||||
|
profile[.nickname].like("%\(query)%")
|
||||||
|
) || (
|
||||||
|
profile[.nickname] == nil &&
|
||||||
|
profile[.name].like("%\(query)%")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.asRequest(of: MentionInfo.self)
|
||||||
|
.fetchAll(db)
|
||||||
|
|
||||||
|
case .openGroup:
|
||||||
|
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||||
|
|
||||||
|
return try Interaction
|
||||||
|
.select(
|
||||||
|
profile.allColumns(),
|
||||||
|
SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey),
|
||||||
|
SQL("\(threadData.openGroupServer)").forKey(MentionInfo.openGroupServerKey),
|
||||||
|
SQL("\(threadData.openGroupRoomToken)").forKey(MentionInfo.openGroupRoomTokenKey)
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.group(Interaction.Columns.authorId)
|
||||||
|
.filter(Interaction.Columns.threadId == threadData.threadId)
|
||||||
|
.filter(Interaction.Columns.authorId != userPublicKey)
|
||||||
|
.joining(
|
||||||
|
required: Interaction.profile
|
||||||
|
.aliased(profile)
|
||||||
|
// Note: LIKE is case-insensitive in SQLite
|
||||||
|
.filter(
|
||||||
|
query.count < 2 || (
|
||||||
|
profile[.nickname] != nil &&
|
||||||
|
profile[.nickname].like("%\(query)%")
|
||||||
|
) || (
|
||||||
|
profile[.nickname] == nil &&
|
||||||
|
profile[.name].like("%\(query)%")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order(Interaction.Columns.timestampMs.desc)
|
||||||
|
.limit(20)
|
||||||
|
.asRequest(of: MentionInfo.self)
|
||||||
|
.fetchAll(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.defaulting(to: [])
|
||||||
|
|
||||||
|
guard query.count >= 2 else {
|
||||||
|
return results.sorted { lhs, rhs -> Bool in
|
||||||
|
lhs.profile.displayName(for: lhs.threadVariant) < rhs.profile.displayName(for: rhs.threadVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
.sorted { lhs, rhs -> Bool in
|
||||||
|
let maybeLhsRange = lhs.profile.displayName(for: lhs.threadVariant).lowercased().range(of: query.lowercased())
|
||||||
|
let maybeRhsRange = rhs.profile.displayName(for: rhs.threadVariant).lowercased().range(of: query.lowercased())
|
||||||
|
|
||||||
|
guard let lhsRange: Range<String.Index> = maybeLhsRange, let rhsRange: Range<String.Index> = maybeRhsRange else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return (lhsRange.lowerBound < rhsRange.lowerBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Functions
|
||||||
|
|
||||||
|
public func updateDraft(to draft: String) {
|
||||||
|
let threadId: String = self.threadId
|
||||||
|
let currentDraft: String = Storage.shared
|
||||||
|
.read { db in
|
||||||
|
try SessionThread
|
||||||
|
.select(.messageDraft)
|
||||||
|
.filter(id: threadId)
|
||||||
|
.asRequest(of: String.self)
|
||||||
|
.fetchOne(db)
|
||||||
|
}
|
||||||
|
.defaulting(to: "")
|
||||||
|
|
||||||
|
// Only write the updated draft to the database if it's changed (avoid unnecessary writes)
|
||||||
|
guard draft != currentDraft else { return }
|
||||||
|
|
||||||
|
Storage.shared.writeAsync { db in
|
||||||
|
try SessionThread
|
||||||
|
.filter(id: threadId)
|
||||||
|
.updateAll(db, SessionThread.Columns.messageDraft.set(to: draft))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func markAllAsRead() {
|
||||||
|
// Don't bother marking anything as read if there are no unread interactions (we can rely
|
||||||
|
// on the 'threadData.threadUnreadCount' to always be accurate)
|
||||||
|
guard
|
||||||
|
(self.threadData.threadUnreadCount ?? 0) > 0,
|
||||||
|
let lastInteractionId: Int64 = self.threadData.interactionId
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let threadId: String = self.threadData.threadId
|
||||||
|
let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false)
|
||||||
|
|
||||||
|
Storage.shared.writeAsync { db in
|
||||||
|
try Interaction.markAsRead(
|
||||||
|
db,
|
||||||
|
interactionId: lastInteractionId,
|
||||||
|
threadId: threadId,
|
||||||
|
includingOlder: true,
|
||||||
|
trySendReadReceipt: trySendReadReceipt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func swapToThread(updatedThreadId: String) {
|
||||||
|
let oldestMessageId: Int64? = self.interactionData
|
||||||
|
.filter { $0.model == .messages }
|
||||||
|
.first?
|
||||||
|
.elements
|
||||||
|
.first?
|
||||||
|
.id
|
||||||
|
|
||||||
|
self.threadId = updatedThreadId
|
||||||
|
self.observableThreadData = self.setupObservableThreadData(for: updatedThreadId)
|
||||||
|
self.pagedDataObserver = self.setupPagedObserver(
|
||||||
|
for: updatedThreadId,
|
||||||
|
userPublicKey: getUserHexEncodedPublicKey()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Try load everything up to the initial visible message, fallback to just the initial page of messages
|
||||||
|
// if we don't have one
|
||||||
|
switch oldestMessageId {
|
||||||
|
case .some(let id): self.pagedDataObserver?.load(.untilInclusive(id: id, padding: 0))
|
||||||
|
case .none: self.pagedDataObserver?.load(.pageBefore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Audio Playback
|
||||||
|
|
||||||
|
public struct PlaybackInfo {
|
||||||
|
let state: AudioPlaybackState
|
||||||
|
let progress: TimeInterval
|
||||||
|
let playbackRate: Double
|
||||||
|
let oldPlaybackRate: Double
|
||||||
|
let updateCallback: (PlaybackInfo?, Error?) -> ()
|
||||||
|
|
||||||
|
public func with(
|
||||||
|
state: AudioPlaybackState? = nil,
|
||||||
|
progress: TimeInterval? = nil,
|
||||||
|
playbackRate: Double? = nil,
|
||||||
|
updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil
|
||||||
|
) -> PlaybackInfo {
|
||||||
|
return PlaybackInfo(
|
||||||
|
state: (state ?? self.state),
|
||||||
|
progress: (progress ?? self.progress),
|
||||||
|
playbackRate: (playbackRate ?? self.playbackRate),
|
||||||
|
oldPlaybackRate: self.playbackRate,
|
||||||
|
updateCallback: (updateCallback ?? self.updateCallback)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var audioPlayer: Atomic<OWSAudioPlayer?> = Atomic(nil)
|
||||||
|
private var currentPlayingInteraction: Atomic<Int64?> = Atomic(nil)
|
||||||
|
private var playbackInfo: Atomic<[Int64: PlaybackInfo]> = Atomic([:])
|
||||||
|
|
||||||
|
public func playbackInfo(for viewModel: MessageViewModel, updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil) -> PlaybackInfo? {
|
||||||
|
// Use the existing info if it already exists (update it's callback if provided as that means
|
||||||
|
// the cell was reloaded)
|
||||||
|
if let currentPlaybackInfo: PlaybackInfo = playbackInfo.wrappedValue[viewModel.id] {
|
||||||
|
let updatedPlaybackInfo: PlaybackInfo = currentPlaybackInfo
|
||||||
|
.with(updateCallback: updateCallback)
|
||||||
|
|
||||||
|
playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
|
||||||
|
|
||||||
|
return updatedPlaybackInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the item is a valid audio item
|
||||||
|
guard
|
||||||
|
let updateCallback: ((PlaybackInfo?, Error?) -> ()) = updateCallback,
|
||||||
|
let attachment: Attachment = viewModel.attachments?.first,
|
||||||
|
attachment.isAudio,
|
||||||
|
attachment.isValid,
|
||||||
|
let originalFilePath: String = attachment.originalFilePath,
|
||||||
|
FileManager.default.fileExists(atPath: originalFilePath)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
// Create the info with the update callback
|
||||||
|
let newPlaybackInfo: PlaybackInfo = PlaybackInfo(
|
||||||
|
state: .stopped,
|
||||||
|
progress: 0,
|
||||||
|
playbackRate: 1,
|
||||||
|
oldPlaybackRate: 1,
|
||||||
|
updateCallback: updateCallback
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cache the info
|
||||||
|
playbackInfo.mutate { $0[viewModel.id] = newPlaybackInfo }
|
||||||
|
|
||||||
|
return newPlaybackInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
public func playOrPauseAudio(for viewModel: MessageViewModel) {
|
||||||
|
guard
|
||||||
|
let attachment: Attachment = viewModel.attachments?.first,
|
||||||
|
let originalFilePath: String = attachment.originalFilePath,
|
||||||
|
FileManager.default.fileExists(atPath: originalFilePath)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
// If the user interacted with the currently playing item
|
||||||
|
guard currentPlayingInteraction.wrappedValue != viewModel.id else {
|
||||||
|
let currentPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[viewModel.id]
|
||||||
|
let updatedPlaybackInfo: PlaybackInfo? = currentPlaybackInfo?
|
||||||
|
.with(
|
||||||
|
state: (currentPlaybackInfo?.state != .playing ? .playing : .paused),
|
||||||
|
playbackRate: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
audioPlayer.wrappedValue?.playbackRate = 1
|
||||||
|
|
||||||
|
switch currentPlaybackInfo?.state {
|
||||||
|
case .playing: audioPlayer.wrappedValue?.pause()
|
||||||
|
default: audioPlayer.wrappedValue?.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the state and then update the UI with the updated state
|
||||||
|
playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
|
||||||
|
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// First stop any existing audio
|
||||||
|
audioPlayer.wrappedValue?.stop()
|
||||||
|
|
||||||
|
// Then setup the state for the new audio
|
||||||
|
currentPlayingInteraction.mutate { $0 = viewModel.id }
|
||||||
|
|
||||||
|
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
|
||||||
|
player?.delegate = nil
|
||||||
|
player = nil
|
||||||
|
|
||||||
|
let audioPlayer: OWSAudioPlayer = OWSAudioPlayer(
|
||||||
|
mediaUrl: URL(fileURLWithPath: originalFilePath),
|
||||||
|
audioBehavior: .audioMessagePlayback,
|
||||||
|
delegate: self
|
||||||
|
)
|
||||||
|
audioPlayer.play()
|
||||||
|
audioPlayer.setCurrentTime(playbackInfo.wrappedValue[viewModel.id]?.progress ?? 0)
|
||||||
|
player = audioPlayer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func speedUpAudio(for viewModel: MessageViewModel) {
|
||||||
|
// If we aren't playing the specified item then just start playing it
|
||||||
|
guard viewModel.id == currentPlayingInteraction.wrappedValue else {
|
||||||
|
playOrPauseAudio(for: viewModel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[viewModel.id]?
|
||||||
|
.with(playbackRate: 1.5)
|
||||||
|
|
||||||
|
// Speed up the audio player
|
||||||
|
audioPlayer.wrappedValue?.playbackRate = 1.5
|
||||||
|
|
||||||
|
playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
|
||||||
|
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stopAudio() {
|
||||||
|
audioPlayer.wrappedValue?.stop()
|
||||||
|
|
||||||
|
currentPlayingInteraction.mutate { $0 = nil }
|
||||||
|
audioPlayer.mutate {
|
||||||
|
// 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
|
||||||
|
$0?.delegate = nil
|
||||||
|
$0 = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OWSAudioPlayerDelegate
|
||||||
|
|
||||||
|
public func audioPlaybackState() -> AudioPlaybackState {
|
||||||
|
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return .stopped }
|
||||||
|
|
||||||
|
return (playbackInfo.wrappedValue[interactionId]?.state ?? .stopped)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setAudioPlaybackState(_ state: AudioPlaybackState) {
|
||||||
|
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
|
||||||
|
|
||||||
|
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
|
||||||
|
.with(state: state)
|
||||||
|
|
||||||
|
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
|
||||||
|
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setAudioProgress(_ progress: CGFloat, duration: CGFloat) {
|
||||||
|
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
|
||||||
|
|
||||||
|
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
|
||||||
|
.with(progress: TimeInterval(progress))
|
||||||
|
|
||||||
|
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
|
||||||
|
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully: Bool) {
|
||||||
|
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
|
||||||
|
guard successfully else { return }
|
||||||
|
|
||||||
|
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
|
||||||
|
.with(
|
||||||
|
state: .stopped,
|
||||||
|
progress: 0,
|
||||||
|
playbackRate: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// Safe the changes and send one final update to the UI
|
||||||
|
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
|
||||||
|
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
||||||
|
|
||||||
|
// Clear out the currently playing record
|
||||||
|
currentPlayingInteraction.mutate { $0 = nil }
|
||||||
|
audioPlayer.mutate {
|
||||||
|
// 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
|
||||||
|
$0?.delegate = nil
|
||||||
|
$0 = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the next interaction is another voice message then autoplay it
|
||||||
|
guard
|
||||||
|
let messageSection: SectionModel = self.interactionData
|
||||||
|
.first(where: { $0.model == .messages }),
|
||||||
|
let currentIndex: Int = messageSection.elements
|
||||||
|
.firstIndex(where: { $0.id == interactionId }),
|
||||||
|
currentIndex < (messageSection.elements.count - 1),
|
||||||
|
messageSection.elements[currentIndex + 1].cellType == .audio
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let nextItem: MessageViewModel = messageSection.elements[currentIndex + 1]
|
||||||
|
playOrPauseAudio(for: nextItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func showInvalidAudioFileAlert() {
|
||||||
|
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
|
||||||
|
|
||||||
|
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
|
||||||
|
.with(
|
||||||
|
state: .stopped,
|
||||||
|
progress: 0,
|
||||||
|
playbackRate: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
currentPlayingInteraction.mutate { $0 = nil }
|
||||||
|
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
|
||||||
|
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, AttachmentError.invalidData)
|
||||||
|
}
|
||||||
|
}
|
|
@ -143,7 +143,7 @@ final class ExpandingAttachmentsButton : UIView, InputViewButtonDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Delegate
|
// MARK: - Delegate
|
||||||
|
|
||||||
protocol ExpandingAttachmentsButtonDelegate: AnyObject {
|
protocol ExpandingAttachmentsButtonDelegate: AnyObject {
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ public final class InputTextView : UITextView, UITextViewDelegate {
|
||||||
private let maxWidth: CGFloat
|
private let maxWidth: CGFloat
|
||||||
private lazy var heightConstraint = self.set(.height, to: minHeight)
|
private lazy var heightConstraint = self.set(.height, to: minHeight)
|
||||||
|
|
||||||
public override var text: String! { didSet { handleTextChanged() } }
|
public override var text: String? { didSet { handleTextChanged() } }
|
||||||
|
|
||||||
// MARK: UI Components
|
// MARK: UI Components
|
||||||
private lazy var placeholderLabel: UILabel = {
|
private lazy var placeholderLabel: UILabel = {
|
||||||
|
@ -79,21 +79,26 @@ public final class InputTextView : UITextView, UITextViewDelegate {
|
||||||
|
|
||||||
private func handleTextChanged() {
|
private func handleTextChanged() {
|
||||||
defer { snDelegate?.inputTextViewDidChangeContent(self) }
|
defer { snDelegate?.inputTextViewDidChangeContent(self) }
|
||||||
placeholderLabel.isHidden = !text.isEmpty
|
|
||||||
|
placeholderLabel.isHidden = !(text ?? "").isEmpty
|
||||||
|
|
||||||
let height = frame.height
|
let height = frame.height
|
||||||
let size = sizeThatFits(CGSize(width: maxWidth, height: .greatestFiniteMagnitude))
|
let size = sizeThatFits(CGSize(width: maxWidth, height: .greatestFiniteMagnitude))
|
||||||
|
|
||||||
// `textView.contentSize` isn't accurate when restoring a multiline draft, so we set it here manually
|
// `textView.contentSize` isn't accurate when restoring a multiline draft, so we set it here manually
|
||||||
self.contentSize = size
|
self.contentSize = size
|
||||||
let newHeight = size.height.clamp(minHeight, maxHeight)
|
let newHeight = size.height.clamp(minHeight, maxHeight)
|
||||||
|
|
||||||
guard newHeight != height else { return }
|
guard newHeight != height else { return }
|
||||||
|
|
||||||
heightConstraint.constant = newHeight
|
heightConstraint.constant = newHeight
|
||||||
snDelegate?.inputTextViewDidChangeSize(self)
|
snDelegate?.inputTextViewDidChangeSize(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Delegate
|
// MARK: - InputTextViewDelegate
|
||||||
protocol InputTextViewDelegate : AnyObject {
|
|
||||||
|
protocol InputTextViewDelegate: AnyObject {
|
||||||
func inputTextViewDidChangeSize(_ inputTextView: InputTextView)
|
func inputTextViewDidChangeSize(_ inputTextView: InputTextView)
|
||||||
func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
|
func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
|
||||||
func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage)
|
func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage)
|
||||||
|
|
|
@ -1,51 +1,64 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, QuoteViewDelegate, LinkPreviewViewDelegate, MentionSelectionViewDelegate {
|
final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate {
|
||||||
enum MessageTypes {
|
// MARK: - Variables
|
||||||
case all
|
|
||||||
case textOnly
|
|
||||||
case none
|
|
||||||
}
|
|
||||||
|
|
||||||
|
private static let linkPreviewViewInset: CGFloat = 6
|
||||||
|
|
||||||
|
private let threadVariant: SessionThread.Variant
|
||||||
private weak var delegate: InputViewDelegate?
|
private weak var delegate: InputViewDelegate?
|
||||||
var quoteDraftInfo: (model: OWSQuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } }
|
|
||||||
var linkPreviewInfo: (url: String, draft: OWSLinkPreviewDraft?)?
|
var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } }
|
||||||
|
var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)?
|
||||||
private var voiceMessageRecordingView: VoiceMessageRecordingView?
|
private var voiceMessageRecordingView: VoiceMessageRecordingView?
|
||||||
private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0)
|
private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0)
|
||||||
|
|
||||||
private lazy var linkPreviewView: LinkPreviewView = {
|
private lazy var linkPreviewView: LinkPreviewView = {
|
||||||
let maxWidth = self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset
|
let maxWidth: CGFloat = (self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset)
|
||||||
return LinkPreviewView(for: nil, maxWidth: maxWidth, delegate: self)
|
|
||||||
|
return LinkPreviewView(maxWidth: maxWidth) { [weak self] in
|
||||||
|
self?.linkPreviewInfo = nil
|
||||||
|
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var text: String {
|
var text: String {
|
||||||
get { inputTextView.text }
|
get { inputTextView.text ?? "" }
|
||||||
set { inputTextView.text = newValue }
|
set { inputTextView.text = newValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
var enabledMessageTypes: MessageTypes = .all {
|
var selectedRange: NSRange {
|
||||||
|
get { inputTextView.selectedRange }
|
||||||
|
set { inputTextView.selectedRange = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var inputTextViewIsFirstResponder: Bool { inputTextView.isFirstResponder }
|
||||||
|
|
||||||
|
var enabledMessageTypes: MessageInputTypes = .all {
|
||||||
didSet {
|
didSet {
|
||||||
setEnabledMessageTypes(enabledMessageTypes, message: nil)
|
setEnabledMessageTypes(enabledMessageTypes, message: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override var intrinsicContentSize: CGSize { CGSize.zero }
|
override var intrinsicContentSize: CGSize { CGSize.zero }
|
||||||
var lastSearchedText: String? { nil }
|
var lastSearchedText: String? { nil }
|
||||||
|
|
||||||
// MARK: UI Components
|
// MARK: - UI
|
||||||
|
|
||||||
private var bottomStackView: UIStackView?
|
private var bottomStackView: UIStackView?
|
||||||
private lazy var attachmentsButton = ExpandingAttachmentsButton(delegate: delegate)
|
private lazy var attachmentsButton = ExpandingAttachmentsButton(delegate: delegate)
|
||||||
|
|
||||||
private lazy var voiceMessageButton: InputViewButton = {
|
private lazy var voiceMessageButton: InputViewButton = {
|
||||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self)
|
let result = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self)
|
||||||
result.accessibilityLabel = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE", comment: "")
|
result.accessibilityLabel = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE", comment: "")
|
||||||
result.accessibilityHint = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE", comment: "")
|
result.accessibilityHint = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE", comment: "")
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
||||||
private lazy var sendButton: InputViewButton = {
|
private lazy var sendButton: InputViewButton = {
|
||||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self)
|
let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self)
|
||||||
result.isHidden = true
|
result.isHidden = true
|
||||||
|
@ -55,25 +68,28 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
||||||
private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton)
|
private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton)
|
||||||
|
|
||||||
private lazy var mentionsView: MentionSelectionView = {
|
private lazy var mentionsView: MentionSelectionView = {
|
||||||
let result = MentionSelectionView()
|
let result: MentionSelectionView = MentionSelectionView()
|
||||||
result.delegate = self
|
result.delegate = self
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var mentionsViewContainer: UIView = {
|
private lazy var mentionsViewContainer: UIView = {
|
||||||
let result = UIView()
|
let result: UIView = UIView()
|
||||||
let backgroundView = UIView()
|
let backgroundView = UIView()
|
||||||
backgroundView.backgroundColor = isLightMode ? .white : .black
|
backgroundView.backgroundColor = (isLightMode ? .white : .black)
|
||||||
backgroundView.alpha = Values.lowOpacity
|
backgroundView.alpha = Values.lowOpacity
|
||||||
result.addSubview(backgroundView)
|
result.addSubview(backgroundView)
|
||||||
backgroundView.pin(to: result)
|
backgroundView.pin(to: result)
|
||||||
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
|
||||||
|
let blurView: UIVisualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||||
result.addSubview(blurView)
|
result.addSubview(blurView)
|
||||||
blurView.pin(to: result)
|
blurView.pin(to: result)
|
||||||
result.alpha = 0
|
result.alpha = 0
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var inputTextView: InputTextView = {
|
private lazy var inputTextView: InputTextView = {
|
||||||
// HACK: When restoring a draft the input text view won't have a frame yet, and therefore it won't
|
// HACK: When restoring a draft the input text view won't have a frame yet, and therefore it won't
|
||||||
// be able to calculate what size it should be to accommodate the draft text. As a workaround, we
|
// be able to calculate what size it should be to accommodate the draft text. As a workaround, we
|
||||||
|
@ -83,7 +99,7 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
||||||
let maxWidth = UIScreen.main.bounds.width - 2 * InputViewButton.expandedSize - 2 * Values.smallSpacing - 2 * (Values.mediumSpacing - adjustment)
|
let maxWidth = UIScreen.main.bounds.width - 2 * InputViewButton.expandedSize - 2 * Values.smallSpacing - 2 * (Values.mediumSpacing - adjustment)
|
||||||
return InputTextView(delegate: self, maxWidth: maxWidth)
|
return InputTextView(delegate: self, maxWidth: maxWidth)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var disabledInputLabel: UILabel = {
|
private lazy var disabledInputLabel: UILabel = {
|
||||||
let label: UILabel = UILabel()
|
let label: UILabel = UILabel()
|
||||||
label.translatesAutoresizingMaskIntoConstraints = false
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
@ -91,71 +107,78 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
||||||
label.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
label.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||||
label.textAlignment = .center
|
label.textAlignment = .center
|
||||||
label.alpha = 0
|
label.alpha = 0
|
||||||
|
|
||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var additionalContentContainer = UIView()
|
private lazy var additionalContentContainer = UIView()
|
||||||
|
|
||||||
// MARK: Settings
|
// MARK: - Initialization
|
||||||
private static let linkPreviewViewInset: CGFloat = 6
|
|
||||||
|
|
||||||
// MARK: Lifecycle
|
init(threadVariant: SessionThread.Variant, delegate: InputViewDelegate) {
|
||||||
init(delegate: InputViewDelegate) {
|
self.threadVariant = threadVariant
|
||||||
self.delegate = delegate
|
self.delegate = delegate
|
||||||
|
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
preconditionFailure("Use init(delegate:) instead.")
|
preconditionFailure("Use init(delegate:) instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
preconditionFailure("Use init(delegate:) instead.")
|
preconditionFailure("Use init(delegate:) instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy() {
|
||||||
autoresizingMask = .flexibleHeight
|
autoresizingMask = .flexibleHeight
|
||||||
|
|
||||||
// Background & blur
|
// Background & blur
|
||||||
let backgroundView = UIView()
|
let backgroundView = UIView()
|
||||||
backgroundView.backgroundColor = isLightMode ? .white : .black
|
backgroundView.backgroundColor = isLightMode ? .white : .black
|
||||||
backgroundView.alpha = Values.lowOpacity
|
backgroundView.alpha = Values.lowOpacity
|
||||||
addSubview(backgroundView)
|
addSubview(backgroundView)
|
||||||
backgroundView.pin(to: self)
|
backgroundView.pin(to: self)
|
||||||
|
|
||||||
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||||
addSubview(blurView)
|
addSubview(blurView)
|
||||||
blurView.pin(to: self)
|
blurView.pin(to: self)
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
let separator = UIView()
|
let separator = UIView()
|
||||||
separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
|
separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
|
||||||
separator.set(.height, to: 1 / UIScreen.main.scale)
|
separator.set(.height, to: 1 / UIScreen.main.scale)
|
||||||
addSubview(separator)
|
addSubview(separator)
|
||||||
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
|
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
|
||||||
|
|
||||||
// Bottom stack view
|
// Bottom stack view
|
||||||
let bottomStackView = UIStackView(arrangedSubviews: [ attachmentsButton, inputTextView, container(for: sendButton) ])
|
let bottomStackView = UIStackView(arrangedSubviews: [ attachmentsButton, inputTextView, container(for: sendButton) ])
|
||||||
bottomStackView.axis = .horizontal
|
bottomStackView.axis = .horizontal
|
||||||
bottomStackView.spacing = Values.smallSpacing
|
bottomStackView.spacing = Values.smallSpacing
|
||||||
bottomStackView.alignment = .center
|
bottomStackView.alignment = .center
|
||||||
self.bottomStackView = bottomStackView
|
self.bottomStackView = bottomStackView
|
||||||
|
|
||||||
// Main stack view
|
// Main stack view
|
||||||
let mainStackView = UIStackView(arrangedSubviews: [ additionalContentContainer, bottomStackView ])
|
let mainStackView = UIStackView(arrangedSubviews: [ additionalContentContainer, bottomStackView ])
|
||||||
mainStackView.axis = .vertical
|
mainStackView.axis = .vertical
|
||||||
mainStackView.isLayoutMarginsRelativeArrangement = true
|
mainStackView.isLayoutMarginsRelativeArrangement = true
|
||||||
|
|
||||||
let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2
|
let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2
|
||||||
mainStackView.layoutMargins = UIEdgeInsets(top: 2, leading: Values.mediumSpacing - adjustment, bottom: 2, trailing: Values.mediumSpacing - adjustment)
|
mainStackView.layoutMargins = UIEdgeInsets(top: 2, leading: Values.mediumSpacing - adjustment, bottom: 2, trailing: Values.mediumSpacing - adjustment)
|
||||||
addSubview(mainStackView)
|
addSubview(mainStackView)
|
||||||
mainStackView.pin(.top, to: .bottom, of: separator)
|
mainStackView.pin(.top, to: .bottom, of: separator)
|
||||||
mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self)
|
mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self)
|
||||||
mainStackView.pin(.bottom, to: .bottom, of: self)
|
mainStackView.pin(.bottom, to: .bottom, of: self)
|
||||||
|
|
||||||
addSubview(disabledInputLabel)
|
addSubview(disabledInputLabel)
|
||||||
|
|
||||||
disabledInputLabel.pin(.top, to: .top, of: mainStackView)
|
disabledInputLabel.pin(.top, to: .top, of: mainStackView)
|
||||||
disabledInputLabel.pin(.left, to: .left, of: mainStackView)
|
disabledInputLabel.pin(.left, to: .left, of: mainStackView)
|
||||||
disabledInputLabel.pin(.right, to: .right, of: mainStackView)
|
disabledInputLabel.pin(.right, to: .right, of: mainStackView)
|
||||||
disabledInputLabel.set(.height, to: InputViewButton.expandedSize)
|
disabledInputLabel.set(.height, to: InputViewButton.expandedSize)
|
||||||
|
|
||||||
// Mentions
|
// Mentions
|
||||||
insertSubview(mentionsViewContainer, belowSubview: mainStackView)
|
insertSubview(mentionsViewContainer, belowSubview: mainStackView)
|
||||||
mentionsViewContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self)
|
mentionsViewContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self)
|
||||||
|
@ -163,12 +186,14 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
||||||
mentionsViewContainer.addSubview(mentionsView)
|
mentionsViewContainer.addSubview(mentionsView)
|
||||||
mentionsView.pin(to: mentionsViewContainer)
|
mentionsView.pin(to: mentionsViewContainer)
|
||||||
mentionsViewHeightConstraint.isActive = true
|
mentionsViewHeightConstraint.isActive = true
|
||||||
|
|
||||||
// Voice message button
|
// Voice message button
|
||||||
addSubview(voiceMessageButtonContainer)
|
addSubview(voiceMessageButtonContainer)
|
||||||
voiceMessageButtonContainer.center(in: sendButton)
|
voiceMessageButtonContainer.center(in: sendButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Updating
|
||||||
|
|
||||||
// MARK: Updating
|
|
||||||
func inputTextViewDidChangeSize(_ inputTextView: InputTextView) {
|
func inputTextViewDidChangeSize(_ inputTextView: InputTextView) {
|
||||||
invalidateIntrinsicContentSize()
|
invalidateIntrinsicContentSize()
|
||||||
}
|
}
|
||||||
|
@ -180,7 +205,7 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
||||||
autoGenerateLinkPreviewIfPossible()
|
autoGenerateLinkPreviewIfPossible()
|
||||||
delegate?.inputTextViewDidChangeContent(inputTextView)
|
delegate?.inputTextViewDidChangeContent(inputTextView)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) {
|
func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) {
|
||||||
delegate?.didPasteImageFromPasteboard(image)
|
delegate?.didPasteImageFromPasteboard(image)
|
||||||
}
|
}
|
||||||
|
@ -188,15 +213,31 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
||||||
// We want to show either a link preview or a quote draft, but never both at the same time. When trying to
|
// We want to show either a link preview or a quote draft, but never both at the same time. When trying to
|
||||||
// generate a link preview, wait until we're sure that we'll be able to build a link preview from the given
|
// generate a link preview, wait until we're sure that we'll be able to build a link preview from the given
|
||||||
// URL before removing the quote draft.
|
// URL before removing the quote draft.
|
||||||
|
|
||||||
private func handleQuoteDraftChanged() {
|
private func handleQuoteDraftChanged() {
|
||||||
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||||
linkPreviewInfo = nil
|
linkPreviewInfo = nil
|
||||||
|
|
||||||
guard let quoteDraftInfo = quoteDraftInfo else { return }
|
guard let quoteDraftInfo = quoteDraftInfo else { return }
|
||||||
let direction: QuoteView.Direction = quoteDraftInfo.isOutgoing ? .outgoing : .incoming
|
|
||||||
let hInset: CGFloat = 6 // Slight visual adjustment
|
let hInset: CGFloat = 6 // Slight visual adjustment
|
||||||
let maxWidth = additionalContentContainer.bounds.width
|
let maxWidth = additionalContentContainer.bounds.width
|
||||||
let quoteView = QuoteView(for: quoteDraftInfo.model, direction: direction, hInset: hInset, maxWidth: maxWidth, delegate: self)
|
|
||||||
|
let quoteView: QuoteView = QuoteView(
|
||||||
|
for: .draft,
|
||||||
|
authorId: quoteDraftInfo.model.authorId,
|
||||||
|
quotedText: quoteDraftInfo.model.body,
|
||||||
|
threadVariant: threadVariant,
|
||||||
|
currentUserPublicKey: nil,
|
||||||
|
currentUserBlindedPublicKey: nil,
|
||||||
|
direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming),
|
||||||
|
attachment: quoteDraftInfo.model.attachment,
|
||||||
|
hInset: hInset,
|
||||||
|
maxWidth: maxWidth
|
||||||
|
) { [weak self] in
|
||||||
|
self?.quoteDraftInfo = nil
|
||||||
|
}
|
||||||
|
|
||||||
additionalContentContainer.addSubview(quoteView)
|
additionalContentContainer.addSubview(quoteView)
|
||||||
quoteView.pin(.left, to: .left, of: additionalContentContainer, withInset: hInset)
|
quoteView.pin(.left, to: .left, of: additionalContentContainer, withInset: hInset)
|
||||||
quoteView.pin(.top, to: .top, of: additionalContentContainer, withInset: 12)
|
quoteView.pin(.top, to: .top, of: additionalContentContainer, withInset: 12)
|
||||||
|
@ -207,64 +248,78 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
||||||
private func autoGenerateLinkPreviewIfPossible() {
|
private func autoGenerateLinkPreviewIfPossible() {
|
||||||
// Don't allow link previews on 'none' or 'textOnly' input
|
// Don't allow link previews on 'none' or 'textOnly' input
|
||||||
guard enabledMessageTypes == .all else { return }
|
guard enabledMessageTypes == .all else { return }
|
||||||
|
|
||||||
// Suggest that the user enable link previews if they haven't already and we haven't
|
// Suggest that the user enable link previews if they haven't already and we haven't
|
||||||
// told them about link previews yet
|
// told them about link previews yet
|
||||||
let text = inputTextView.text!
|
let text = inputTextView.text!
|
||||||
let userDefaults = UserDefaults.standard
|
let areLinkPreviewsEnabled: Bool = Storage.shared[.areLinkPreviewsEnabled]
|
||||||
if !OWSLinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && !SSKPreferences.areLinkPreviewsEnabled
|
|
||||||
&& !userDefaults[.hasSeenLinkPreviewSuggestion] {
|
if
|
||||||
|
!LinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty &&
|
||||||
|
!areLinkPreviewsEnabled &&
|
||||||
|
!UserDefaults.standard[.hasSeenLinkPreviewSuggestion]
|
||||||
|
{
|
||||||
delegate?.showLinkPreviewSuggestionModal()
|
delegate?.showLinkPreviewSuggestionModal()
|
||||||
userDefaults[.hasSeenLinkPreviewSuggestion] = true
|
UserDefaults.standard[.hasSeenLinkPreviewSuggestion] = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Check that link previews are enabled
|
// Check that link previews are enabled
|
||||||
guard SSKPreferences.areLinkPreviewsEnabled else { return }
|
guard areLinkPreviewsEnabled else { return }
|
||||||
|
|
||||||
// Proceed
|
// Proceed
|
||||||
autoGenerateLinkPreview()
|
autoGenerateLinkPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
func autoGenerateLinkPreview() {
|
func autoGenerateLinkPreview() {
|
||||||
// Check that a valid URL is present
|
// Check that a valid URL is present
|
||||||
guard let linkPreviewURL = OWSLinkPreview.previewUrl(forRawBodyText: text, selectedRange: inputTextView.selectedRange) else {
|
guard let linkPreviewURL = LinkPreview.previewUrl(for: text, selectedRange: inputTextView.selectedRange) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard against obsolete updates
|
// Guard against obsolete updates
|
||||||
guard linkPreviewURL != self.linkPreviewInfo?.url else { return }
|
guard linkPreviewURL != self.linkPreviewInfo?.url else { return }
|
||||||
|
|
||||||
// Clear content container
|
// Clear content container
|
||||||
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||||
quoteDraftInfo = nil
|
quoteDraftInfo = nil
|
||||||
|
|
||||||
// Set the state to loading
|
// Set the state to loading
|
||||||
linkPreviewInfo = (url: linkPreviewURL, draft: nil)
|
linkPreviewInfo = (url: linkPreviewURL, draft: nil)
|
||||||
linkPreviewView.linkPreviewState = LinkPreviewLoading()
|
linkPreviewView.update(with: LinkPreview.LoadingState(), isOutgoing: false)
|
||||||
|
|
||||||
// Add the link preview view
|
// Add the link preview view
|
||||||
additionalContentContainer.addSubview(linkPreviewView)
|
additionalContentContainer.addSubview(linkPreviewView)
|
||||||
linkPreviewView.pin(.left, to: .left, of: additionalContentContainer, withInset: InputView.linkPreviewViewInset)
|
linkPreviewView.pin(.left, to: .left, of: additionalContentContainer, withInset: InputView.linkPreviewViewInset)
|
||||||
linkPreviewView.pin(.top, to: .top, of: additionalContentContainer, withInset: 10)
|
linkPreviewView.pin(.top, to: .top, of: additionalContentContainer, withInset: 10)
|
||||||
linkPreviewView.pin(.right, to: .right, of: additionalContentContainer)
|
linkPreviewView.pin(.right, to: .right, of: additionalContentContainer)
|
||||||
linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4)
|
linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4)
|
||||||
// Build the link preview
|
|
||||||
OWSLinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL).done { [weak self] draft in
|
|
||||||
guard let self = self else { return }
|
|
||||||
guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
|
|
||||||
self.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
|
|
||||||
self.linkPreviewView.linkPreviewState = LinkPreviewDraft(linkPreviewDraft: draft)
|
|
||||||
}.catch { _ in
|
|
||||||
guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
|
|
||||||
self.linkPreviewInfo = nil
|
|
||||||
self.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
|
||||||
}.retainUntilComplete()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setEnabledMessageTypes(_ messageTypes: MessageTypes, message: String?) {
|
|
||||||
guard enabledMessageTypes != messageTypes else { return }
|
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setEnabledMessageTypes(_ messageTypes: MessageInputTypes, message: String?) {
|
||||||
|
guard enabledMessageTypes != messageTypes else { return }
|
||||||
|
|
||||||
enabledMessageTypes = messageTypes
|
enabledMessageTypes = messageTypes
|
||||||
disabledInputLabel.text = (message ?? "")
|
disabledInputLabel.text = (message ?? "")
|
||||||
|
|
||||||
attachmentsButton.isUserInteractionEnabled = (messageTypes == .all)
|
attachmentsButton.isUserInteractionEnabled = (messageTypes == .all)
|
||||||
voiceMessageButton.isUserInteractionEnabled = (messageTypes == .all)
|
voiceMessageButton.isUserInteractionEnabled = (messageTypes == .all)
|
||||||
|
|
||||||
UIView.animate(withDuration: 0.3) { [weak self] in
|
UIView.animate(withDuration: 0.3) { [weak self] in
|
||||||
self?.bottomStackView?.alpha = (messageTypes != .none ? 1 : 0)
|
self?.bottomStackView?.alpha = (messageTypes != .none ? 1 : 0)
|
||||||
self?.attachmentsButton.alpha = (messageTypes == .all ?
|
self?.attachmentsButton.alpha = (messageTypes == .all ?
|
||||||
|
@ -278,35 +333,40 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
||||||
self?.disabledInputLabel.alpha = (messageTypes != .none ? 0 : 1)
|
self?.disabledInputLabel.alpha = (messageTypes != .none ? 0 : 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Interaction
|
||||||
|
|
||||||
// MARK: Interaction
|
|
||||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||||
// Needed so that the user can tap the buttons when the expanding attachments button is expanded
|
// Needed so that the user can tap the buttons when the expanding attachments button is expanded
|
||||||
let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton,
|
let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton,
|
||||||
attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ]
|
attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ]
|
||||||
let buttonContainer = buttonContainers.first { $0.superview!.convert($0.frame, to: self).contains(point) }
|
|
||||||
if let buttonContainer = buttonContainer {
|
if let buttonContainer: InputViewButton = buttonContainers.first(where: { $0.superview?.convert($0.frame, to: self).contains(point) == true }) {
|
||||||
return buttonContainer
|
return buttonContainer
|
||||||
} else {
|
|
||||||
return super.hitTest(point, with: event)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return super.hitTest(point, with: event)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
||||||
let buttonContainers = [ attachmentsButton.gifButtonContainer, attachmentsButton.documentButtonContainer,
|
let buttonContainers = [ attachmentsButton.gifButtonContainer, attachmentsButton.documentButtonContainer,
|
||||||
attachmentsButton.libraryButtonContainer, attachmentsButton.cameraButtonContainer, attachmentsButton.mainButtonContainer ]
|
attachmentsButton.libraryButtonContainer, attachmentsButton.cameraButtonContainer, attachmentsButton.mainButtonContainer ]
|
||||||
let isPointInsideAttachmentsButton = buttonContainers.contains { $0.superview!.convert($0.frame, to: self).contains(point) }
|
let isPointInsideAttachmentsButton = buttonContainers
|
||||||
|
.contains { $0.superview!.convert($0.frame, to: self).contains(point) }
|
||||||
|
|
||||||
if isPointInsideAttachmentsButton {
|
if isPointInsideAttachmentsButton {
|
||||||
// Needed so that the user can tap the buttons when the expanding attachments button is expanded
|
// Needed so that the user can tap the buttons when the expanding attachments button is expanded
|
||||||
return true
|
return true
|
||||||
} else if mentionsViewContainer.frame.contains(point) {
|
}
|
||||||
|
|
||||||
|
if mentionsViewContainer.frame.contains(point) {
|
||||||
// Needed so that the user can tap mentions
|
// Needed so that the user can tap mentions
|
||||||
return true
|
return true
|
||||||
} else {
|
|
||||||
return super.point(inside: point, with: event)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return super.point(inside: point, with: event)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) {
|
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) {
|
||||||
if inputViewButton == sendButton { delegate?.handleSendButtonTapped() }
|
if inputViewButton == sendButton { delegate?.handleSendButtonTapped() }
|
||||||
}
|
}
|
||||||
|
@ -329,23 +389,18 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
||||||
voiceMessageRecordingView.handleLongPressEnded(at: location)
|
voiceMessageRecordingView.handleLongPressEnded(at: location)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleQuoteViewCancelButtonTapped() {
|
|
||||||
delegate?.handleQuoteViewCancelButtonTapped()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func resignFirstResponder() -> Bool {
|
override func resignFirstResponder() -> Bool {
|
||||||
inputTextView.resignFirstResponder()
|
inputTextView.resignFirstResponder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inputTextViewBecomeFirstResponder() {
|
||||||
|
inputTextView.becomeFirstResponder()
|
||||||
|
}
|
||||||
|
|
||||||
func handleLongPress() {
|
func handleLongPress() {
|
||||||
// Not relevant in this case
|
// Not relevant in this case
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLinkPreviewCanceled() {
|
|
||||||
linkPreviewInfo = nil
|
|
||||||
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func showVoiceMessageUI() {
|
@objc private func showVoiceMessageUI() {
|
||||||
voiceMessageRecordingView?.removeFromSuperview()
|
voiceMessageRecordingView?.removeFromSuperview()
|
||||||
let voiceMessageButtonFrame = voiceMessageButton.superview!.convert(voiceMessageButton.frame, to: self)
|
let voiceMessageButtonFrame = voiceMessageButton.superview!.convert(voiceMessageButton.frame, to: self)
|
||||||
|
@ -373,50 +428,53 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
||||||
}
|
}
|
||||||
|
|
||||||
func hideMentionsUI() {
|
func hideMentionsUI() {
|
||||||
UIView.animate(withDuration: 0.25, animations: {
|
UIView.animate(
|
||||||
self.mentionsViewContainer.alpha = 0
|
withDuration: 0.25,
|
||||||
}, completion: { _ in
|
animations: { [weak self] in
|
||||||
self.mentionsViewHeightConstraint.constant = 0
|
self?.mentionsViewContainer.alpha = 0
|
||||||
self.mentionsView.tableView.contentOffset = CGPoint.zero
|
},
|
||||||
})
|
completion: { [weak self] _ in
|
||||||
|
self?.mentionsViewHeightConstraint.constant = 0
|
||||||
|
self?.mentionsView.contentOffset = CGPoint.zero
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func showMentionsUI(for candidates: [Mention], in thread: TSThread) {
|
func showMentionsUI(for candidates: [ConversationViewModel.MentionInfo]) {
|
||||||
if let openGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) {
|
|
||||||
mentionsView.openGroupServer = openGroupV2.server
|
|
||||||
mentionsView.openGroupRoom = openGroupV2.room
|
|
||||||
}
|
|
||||||
mentionsView.candidates = candidates
|
mentionsView.candidates = candidates
|
||||||
let mentionCellHeight = Values.smallProfilePictureSize + 2 * Values.smallSpacing
|
|
||||||
|
let mentionCellHeight = (Values.smallProfilePictureSize + 2 * Values.smallSpacing)
|
||||||
mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight
|
mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight
|
||||||
layoutIfNeeded()
|
layoutIfNeeded()
|
||||||
|
|
||||||
UIView.animate(withDuration: 0.25) {
|
UIView.animate(withDuration: 0.25) {
|
||||||
self.mentionsViewContainer.alpha = 1
|
self.mentionsViewContainer.alpha = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) {
|
func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView) {
|
||||||
delegate?.handleMentionSelected(mention, from: view)
|
delegate?.handleMentionSelected(mentionInfo, from: view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Convenience
|
// MARK: - Convenience
|
||||||
|
|
||||||
private func container(for button: InputViewButton) -> UIView {
|
private func container(for button: InputViewButton) -> UIView {
|
||||||
let result = UIView()
|
let result: UIView = UIView()
|
||||||
result.addSubview(button)
|
result.addSubview(button)
|
||||||
result.set(.width, to: InputViewButton.expandedSize)
|
result.set(.width, to: InputViewButton.expandedSize)
|
||||||
result.set(.height, to: InputViewButton.expandedSize)
|
result.set(.height, to: InputViewButton.expandedSize)
|
||||||
button.center(in: result)
|
button.center(in: result)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Delegate
|
// MARK: - Delegate
|
||||||
protocol InputViewDelegate : AnyObject, ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate {
|
|
||||||
|
|
||||||
|
protocol InputViewDelegate: ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate {
|
||||||
func showLinkPreviewSuggestionModal()
|
func showLinkPreviewSuggestionModal()
|
||||||
func handleSendButtonTapped()
|
func handleSendButtonTapped()
|
||||||
func handleQuoteViewCancelButtonTapped()
|
|
||||||
func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
|
func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
|
||||||
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView)
|
func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView)
|
||||||
func didPasteImageFromPasteboard(_ image: UIImage)
|
func didPasteImageFromPasteboard(_ image: UIImage)
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,8 +59,8 @@ final class InputViewButton : UIView {
|
||||||
isUserInteractionEnabled = true
|
isUserInteractionEnabled = true
|
||||||
widthConstraint.isActive = true
|
widthConstraint.isActive = true
|
||||||
heightConstraint.isActive = true
|
heightConstraint.isActive = true
|
||||||
let tint = isSendButton ? UIColor.black : Colors.text
|
let iconImageView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
|
||||||
let iconImageView = UIImageView(image: icon.withTint(tint))
|
iconImageView.tintColor = (isSendButton ? UIColor.black : Colors.text)
|
||||||
iconImageView.contentMode = .scaleAspectFit
|
iconImageView.contentMode = .scaleAspectFit
|
||||||
let iconSize = InputViewButton.iconSize
|
let iconSize = InputViewButton.iconSize
|
||||||
iconImageView.set(.width, to: iconSize)
|
iconImageView.set(.width, to: iconSize)
|
||||||
|
@ -141,17 +141,16 @@ final class InputViewButton : UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Delegate
|
// MARK: - Delegate
|
||||||
protocol InputViewButtonDelegate : class {
|
|
||||||
|
protocol InputViewButtonDelegate: AnyObject {
|
||||||
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton)
|
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton)
|
||||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton)
|
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton)
|
||||||
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch)
|
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch)
|
||||||
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch)
|
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InputViewButtonDelegate {
|
extension InputViewButtonDelegate {
|
||||||
|
|
||||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) { }
|
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) { }
|
||||||
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch) { }
|
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch) { }
|
||||||
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch) { }
|
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch) { }
|
||||||
|
|
|
@ -1,36 +1,50 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDelegate {
|
import UIKit
|
||||||
var candidates: [Mention] = [] {
|
import SessionUIKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
|
final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDelegate {
|
||||||
|
var candidates: [ConversationViewModel.MentionInfo] = [] {
|
||||||
didSet {
|
didSet {
|
||||||
tableView.isScrollEnabled = (candidates.count > 4)
|
tableView.isScrollEnabled = (candidates.count > 4)
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var openGroupServer: String?
|
|
||||||
var openGroupChannel: UInt64?
|
|
||||||
var openGroupRoom: String?
|
|
||||||
weak var delegate: MentionSelectionViewDelegate?
|
weak var delegate: MentionSelectionViewDelegate?
|
||||||
|
|
||||||
|
var contentOffset: CGPoint {
|
||||||
|
get { tableView.contentOffset }
|
||||||
|
set { tableView.contentOffset = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Components
|
// MARK: - Components
|
||||||
lazy var tableView: UITableView = { // TODO: Make this private
|
|
||||||
let result = UITableView()
|
private lazy var tableView: UITableView = {
|
||||||
|
let result: UITableView = UITableView()
|
||||||
result.dataSource = self
|
result.dataSource = self
|
||||||
result.delegate = self
|
result.delegate = self
|
||||||
result.register(Cell.self, forCellReuseIdentifier: "Cell")
|
|
||||||
result.separatorStyle = .none
|
result.separatorStyle = .none
|
||||||
result.backgroundColor = .clear
|
result.backgroundColor = .clear
|
||||||
result.showsVerticalScrollIndicator = false
|
result.showsVerticalScrollIndicator = false
|
||||||
|
result.register(view: Cell.self)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
super.init(coder: coder)
|
super.init(coder: coder)
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,43 +52,54 @@ final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDel
|
||||||
// Table view
|
// Table view
|
||||||
addSubview(tableView)
|
addSubview(tableView)
|
||||||
tableView.pin(to: self)
|
tableView.pin(to: self)
|
||||||
|
|
||||||
// Top separator
|
// Top separator
|
||||||
let topSeparator = UIView()
|
let topSeparator: UIView = UIView()
|
||||||
topSeparator.backgroundColor = Colors.separator
|
topSeparator.backgroundColor = Colors.separator
|
||||||
topSeparator.set(.height, to: Values.separatorThickness)
|
topSeparator.set(.height, to: Values.separatorThickness)
|
||||||
addSubview(topSeparator)
|
addSubview(topSeparator)
|
||||||
topSeparator.pin(.leading, to: .leading, of: self)
|
topSeparator.pin(.leading, to: .leading, of: self)
|
||||||
topSeparator.pin(.top, to: .top, of: self)
|
topSeparator.pin(.top, to: .top, of: self)
|
||||||
topSeparator.pin(.trailing, to: .trailing, of: self)
|
topSeparator.pin(.trailing, to: .trailing, of: self)
|
||||||
|
|
||||||
// Bottom separator
|
// Bottom separator
|
||||||
let bottomSeparator = UIView()
|
let bottomSeparator: UIView = UIView()
|
||||||
bottomSeparator.backgroundColor = Colors.separator
|
bottomSeparator.backgroundColor = Colors.separator
|
||||||
bottomSeparator.set(.height, to: Values.separatorThickness)
|
bottomSeparator.set(.height, to: Values.separatorThickness)
|
||||||
addSubview(bottomSeparator)
|
addSubview(bottomSeparator)
|
||||||
|
|
||||||
bottomSeparator.pin(.leading, to: .leading, of: self)
|
bottomSeparator.pin(.leading, to: .leading, of: self)
|
||||||
bottomSeparator.pin(.trailing, to: .trailing, of: self)
|
bottomSeparator.pin(.trailing, to: .trailing, of: self)
|
||||||
bottomSeparator.pin(.bottom, to: .bottom, of: self)
|
bottomSeparator.pin(.bottom, to: .bottom, of: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Data
|
// MARK: - Data
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
return candidates.count
|
return candidates.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! Cell
|
let cell: Cell = tableView.dequeue(type: Cell.self, for: indexPath)
|
||||||
let mentionCandidate = candidates[indexPath.row]
|
cell.update(
|
||||||
cell.mentionCandidate = mentionCandidate
|
with: candidates[indexPath.row].profile,
|
||||||
cell.openGroupServer = openGroupServer
|
threadVariant: candidates[indexPath.row].threadVariant,
|
||||||
cell.openGroupChannel = openGroupChannel
|
isUserModeratorOrAdmin: OpenGroupManager.isUserModeratorOrAdmin(
|
||||||
cell.openGroupRoom = openGroupRoom
|
candidates[indexPath.row].profile.id,
|
||||||
cell.separator.isHidden = (indexPath.row == (candidates.count - 1))
|
for: candidates[indexPath.row].openGroupRoomToken,
|
||||||
|
on: candidates[indexPath.row].openGroupServer
|
||||||
|
),
|
||||||
|
isLast: (indexPath.row == (candidates.count - 1))
|
||||||
|
)
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
let mentionCandidate = candidates[indexPath.row]
|
let mentionCandidate = candidates[indexPath.row]
|
||||||
|
|
||||||
delegate?.handleMentionSelected(mentionCandidate, from: self)
|
delegate?.handleMentionSelected(mentionCandidate, from: self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -82,56 +107,59 @@ final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDel
|
||||||
// MARK: - Cell
|
// MARK: - Cell
|
||||||
|
|
||||||
private extension MentionSelectionView {
|
private extension MentionSelectionView {
|
||||||
|
final class Cell: UITableViewCell {
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
|
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView()
|
||||||
|
|
||||||
final class Cell : UITableViewCell {
|
private lazy var moderatorIconImageView: UIImageView = UIImageView(image: #imageLiteral(resourceName: "Crown"))
|
||||||
var mentionCandidate = Mention(publicKey: "", displayName: "") { didSet { update() } }
|
|
||||||
var openGroupServer: String?
|
|
||||||
var openGroupChannel: UInt64?
|
|
||||||
var openGroupRoom: String?
|
|
||||||
|
|
||||||
// MARK: Components
|
|
||||||
private lazy var profilePictureView = ProfilePictureView()
|
|
||||||
|
|
||||||
private lazy var moderatorIconImageView = UIImageView(image: #imageLiteral(resourceName: "Crown"))
|
|
||||||
|
|
||||||
private lazy var displayNameLabel: UILabel = {
|
private lazy var displayNameLabel: UILabel = {
|
||||||
let result = UILabel()
|
let result: UILabel = UILabel()
|
||||||
result.textColor = Colors.text
|
result.textColor = Colors.text
|
||||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||||
result.lineBreakMode = .byTruncatingTail
|
result.lineBreakMode = .byTruncatingTail
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
lazy var separator: UIView = {
|
lazy var separator: UIView = {
|
||||||
let result = UIView()
|
let result: UIView = UIView()
|
||||||
result.backgroundColor = Colors.separator
|
result.backgroundColor = Colors.separator
|
||||||
result.set(.height, to: Values.separatorThickness)
|
result.set(.height, to: Values.separatorThickness)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
super.init(coder: coder)
|
super.init(coder: coder)
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy() {
|
||||||
// Cell background color
|
// Cell background color
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
|
|
||||||
// Highlight color
|
// Highlight color
|
||||||
let selectedBackgroundView = UIView()
|
let selectedBackgroundView = UIView()
|
||||||
selectedBackgroundView.backgroundColor = .clear
|
selectedBackgroundView.backgroundColor = .clear
|
||||||
self.selectedBackgroundView = selectedBackgroundView
|
self.selectedBackgroundView = selectedBackgroundView
|
||||||
|
|
||||||
// Profile picture image view
|
// Profile picture image view
|
||||||
let profilePictureViewSize = Values.smallProfilePictureSize
|
let profilePictureViewSize = Values.smallProfilePictureSize
|
||||||
profilePictureView.set(.width, to: profilePictureViewSize)
|
profilePictureView.set(.width, to: profilePictureViewSize)
|
||||||
profilePictureView.set(.height, to: profilePictureViewSize)
|
profilePictureView.set(.height, to: profilePictureViewSize)
|
||||||
profilePictureView.size = profilePictureViewSize
|
profilePictureView.size = profilePictureViewSize
|
||||||
|
|
||||||
// Main stack view
|
// Main stack view
|
||||||
let mainStackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel ])
|
let mainStackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel ])
|
||||||
mainStackView.axis = .horizontal
|
mainStackView.axis = .horizontal
|
||||||
|
@ -144,12 +172,14 @@ private extension MentionSelectionView {
|
||||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.mediumSpacing)
|
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.mediumSpacing)
|
||||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.smallSpacing)
|
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.smallSpacing)
|
||||||
mainStackView.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing)
|
mainStackView.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing)
|
||||||
|
|
||||||
// Moderator icon image view
|
// Moderator icon image view
|
||||||
moderatorIconImageView.set(.width, to: 20)
|
moderatorIconImageView.set(.width, to: 20)
|
||||||
moderatorIconImageView.set(.height, to: 20)
|
moderatorIconImageView.set(.height, to: 20)
|
||||||
contentView.addSubview(moderatorIconImageView)
|
contentView.addSubview(moderatorIconImageView)
|
||||||
moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1)
|
moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1)
|
||||||
moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5)
|
moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5)
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
addSubview(separator)
|
addSubview(separator)
|
||||||
separator.pin(.leading, to: .leading, of: self)
|
separator.pin(.leading, to: .leading, of: self)
|
||||||
|
@ -157,24 +187,28 @@ private extension MentionSelectionView {
|
||||||
separator.pin(.bottom, to: .bottom, of: self)
|
separator.pin(.bottom, to: .bottom, of: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Updating
|
// MARK: - Updating
|
||||||
private func update() {
|
|
||||||
displayNameLabel.text = mentionCandidate.displayName
|
fileprivate func update(
|
||||||
profilePictureView.publicKey = mentionCandidate.publicKey
|
with profile: Profile,
|
||||||
profilePictureView.update()
|
threadVariant: SessionThread.Variant,
|
||||||
if let server = openGroupServer, let room = openGroupRoom {
|
isUserModeratorOrAdmin: Bool,
|
||||||
let isUserModerator = OpenGroupAPIV2.isUserModerator(mentionCandidate.publicKey, for: room, on: server)
|
isLast: Bool
|
||||||
moderatorIconImageView.isHidden = !isUserModerator
|
) {
|
||||||
} else {
|
displayNameLabel.text = profile.displayName(for: threadVariant)
|
||||||
moderatorIconImageView.isHidden = true
|
profilePictureView.update(
|
||||||
}
|
publicKey: profile.id,
|
||||||
|
profile: profile,
|
||||||
|
threadVariant: threadVariant
|
||||||
|
)
|
||||||
|
moderatorIconImageView.isHidden = !isUserModeratorOrAdmin
|
||||||
|
separator.isHidden = isLast
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Delegate
|
// MARK: - Delegate
|
||||||
|
|
||||||
protocol MentionSelectionViewDelegate : class {
|
protocol MentionSelectionViewDelegate: AnyObject {
|
||||||
|
func handleMentionSelected(_ mention: ConversationViewModel.MentionInfo, from view: MentionSelectionView)
|
||||||
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -396,9 +396,9 @@ extension VoiceMessageRecordingView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Delegate
|
// MARK: - Delegate
|
||||||
protocol VoiceMessageRecordingViewDelegate : class {
|
|
||||||
|
|
||||||
|
protocol VoiceMessageRecordingViewDelegate: AnyObject {
|
||||||
func startVoiceMessageRecording()
|
func startVoiceMessageRecording()
|
||||||
func endVoiceMessageRecording()
|
func endVoiceMessageRecording()
|
||||||
func cancelVoiceMessageRecording()
|
func cancelVoiceMessageRecording()
|
||||||
|
|
|
@ -1,177 +0,0 @@
|
||||||
//
|
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SignalUtilitiesKit
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public protocol LongTextViewDelegate {
|
|
||||||
@objc
|
|
||||||
func longTextViewMessageWasDeleted(_ longTextViewController: LongTextViewController)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public class LongTextViewController: OWSViewController {
|
|
||||||
|
|
||||||
// MARK: - Dependencies
|
|
||||||
|
|
||||||
var uiDatabaseConnection: YapDatabaseConnection {
|
|
||||||
return OWSPrimaryStorage.shared().uiDatabaseConnection
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Properties
|
|
||||||
|
|
||||||
@objc
|
|
||||||
weak var delegate: LongTextViewDelegate?
|
|
||||||
|
|
||||||
let viewItem: ConversationViewItem
|
|
||||||
|
|
||||||
var messageTextView: UITextView!
|
|
||||||
|
|
||||||
var displayableText: DisplayableText? {
|
|
||||||
return viewItem.displayableBodyText
|
|
||||||
}
|
|
||||||
|
|
||||||
var fullText: String {
|
|
||||||
return displayableText?.fullText ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Initializers
|
|
||||||
|
|
||||||
@available(*, unavailable, message:"use other constructor instead.")
|
|
||||||
public required init?(coder aDecoder: NSCoder) {
|
|
||||||
notImplemented()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public required init(viewItem: ConversationViewItem) {
|
|
||||||
self.viewItem = viewItem
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: View Lifecycle
|
|
||||||
|
|
||||||
public override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: NSLocalizedString("LONG_TEXT_VIEW_TITLE", comment: ""), hasCustomBackButton: false)
|
|
||||||
|
|
||||||
createViews()
|
|
||||||
|
|
||||||
self.messageTextView.contentOffset = CGPoint(x: 0, y: self.messageTextView.contentInset.top)
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self,
|
|
||||||
selector: #selector(uiDatabaseDidUpdate),
|
|
||||||
name: .OWSUIDatabaseConnectionDidUpdate,
|
|
||||||
object: OWSPrimaryStorage.shared().dbNotificationObject)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - DB
|
|
||||||
|
|
||||||
@objc internal func uiDatabaseDidUpdate(notification: NSNotification) {
|
|
||||||
AssertIsOnMainThread()
|
|
||||||
|
|
||||||
guard let notifications = notification.userInfo?[OWSUIDatabaseConnectionNotificationsKey] as? [Notification] else {
|
|
||||||
owsFailDebug("notifications was unexpectedly nil")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let uniqueId = self.viewItem.interaction.uniqueId else {
|
|
||||||
Logger.error("Message is missing uniqueId.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard self.uiDatabaseConnection.hasChange(forKey: uniqueId,
|
|
||||||
inCollection: TSInteraction.collection(),
|
|
||||||
in: notifications) else {
|
|
||||||
Logger.debug("No relevant changes.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
try uiDatabaseConnection.read { transaction in
|
|
||||||
guard TSInteraction.fetch(uniqueId: uniqueId, transaction: transaction) != nil else {
|
|
||||||
Logger.error("Message was deleted")
|
|
||||||
throw LongTextViewError.messageWasDeleted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch LongTextViewError.messageWasDeleted {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.delegate?.longTextViewMessageWasDeleted(self)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
owsFailDebug("unexpected error: \(error)")
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum LongTextViewError: Error {
|
|
||||||
case messageWasDeleted
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Create Views
|
|
||||||
|
|
||||||
private func createViews() {
|
|
||||||
view.backgroundColor = Colors.navigationBarBackground
|
|
||||||
|
|
||||||
let messageTextView = OWSTextView()
|
|
||||||
self.messageTextView = messageTextView
|
|
||||||
messageTextView.font = .systemFont(ofSize: Values.smallFontSize)
|
|
||||||
messageTextView.backgroundColor = .clear
|
|
||||||
messageTextView.isOpaque = true
|
|
||||||
messageTextView.isEditable = false
|
|
||||||
messageTextView.isSelectable = true
|
|
||||||
messageTextView.isScrollEnabled = true
|
|
||||||
messageTextView.showsHorizontalScrollIndicator = false
|
|
||||||
messageTextView.showsVerticalScrollIndicator = true
|
|
||||||
messageTextView.isUserInteractionEnabled = true
|
|
||||||
messageTextView.textColor = Colors.text
|
|
||||||
messageTextView.contentInset = UIEdgeInsets(top: Values.mediumSpacing, leading: 0, bottom: 0, trailing: 0)
|
|
||||||
if let displayableText = displayableText {
|
|
||||||
messageTextView.text = fullText
|
|
||||||
messageTextView.ensureShouldLinkifyText(displayableText.shouldAllowLinkification)
|
|
||||||
} else {
|
|
||||||
owsFailDebug("displayableText was unexpectedly nil")
|
|
||||||
messageTextView.text = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
let linkTextAttributes: [NSAttributedString.Key: Any] = [
|
|
||||||
NSAttributedString.Key.foregroundColor: Colors.text,
|
|
||||||
NSAttributedString.Key.underlineColor: Colors.text,
|
|
||||||
NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue
|
|
||||||
]
|
|
||||||
messageTextView.linkTextAttributes = linkTextAttributes
|
|
||||||
|
|
||||||
view.addSubview(messageTextView)
|
|
||||||
messageTextView.autoPinEdge(toSuperviewEdge: .top)
|
|
||||||
messageTextView.autoPinEdge(toSuperviewEdge: .leading)
|
|
||||||
messageTextView.autoPinEdge(toSuperviewEdge: .trailing)
|
|
||||||
messageTextView.textContainerInset = UIEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
|
|
||||||
|
|
||||||
let footer = UIToolbar()
|
|
||||||
view.addSubview(footer)
|
|
||||||
footer.autoPinWidthToSuperview()
|
|
||||||
footer.autoPinEdge(.top, to: .bottom, of: messageTextView)
|
|
||||||
footer.autoPinEdge(toSuperviewSafeArea: .bottom)
|
|
||||||
|
|
||||||
footer.items = [
|
|
||||||
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
|
|
||||||
UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(shareButtonPressed)),
|
|
||||||
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Actions
|
|
||||||
|
|
||||||
@objc func shareButtonPressed() {
|
|
||||||
let shareVC = UIActivityViewController(activityItems: [ fullText ], applicationActivities: nil)
|
|
||||||
if UIDevice.current.isIPad {
|
|
||||||
shareVC.excludedActivityTypes = []
|
|
||||||
shareVC.popoverPresentationController?.permittedArrowDirections = []
|
|
||||||
shareVC.popoverPresentationController?.sourceView = self.view
|
|
||||||
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
|
|
||||||
}
|
|
||||||
self.present(shareVC, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,73 +1,88 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
|
||||||
final class CallMessageCell : MessageCell {
|
final class CallMessageCell: MessageCell {
|
||||||
|
private static let iconSize: CGFloat = 16
|
||||||
|
private static let inset = Values.mediumSpacing
|
||||||
|
private static let margin = UIScreen.main.bounds.width * 0.1
|
||||||
|
|
||||||
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: 0)
|
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: 0)
|
||||||
private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: 0)
|
private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: 0)
|
||||||
|
|
||||||
private lazy var infoImageViewWidthConstraint = infoImageView.set(.width, to: 0)
|
private lazy var infoImageViewWidthConstraint = infoImageView.set(.width, to: 0)
|
||||||
private lazy var infoImageViewHeightConstraint = infoImageView.set(.height, to: 0)
|
private lazy var infoImageViewHeightConstraint = infoImageView.set(.height, to: 0)
|
||||||
|
|
||||||
// MARK: UI Components
|
// MARK: - UI
|
||||||
private lazy var iconImageView = UIImageView()
|
|
||||||
|
|
||||||
private lazy var infoImageView = UIImageView(image: UIImage(named: "ic_info")?.withTint(Colors.text))
|
private lazy var iconImageView: UIImageView = UIImageView()
|
||||||
|
private lazy var infoImageView: UIImageView = {
|
||||||
|
let result: UIImageView = UIImageView(image: UIImage(named: "ic_info")?.withRenderingMode(.alwaysTemplate))
|
||||||
|
result.tintColor = Colors.text
|
||||||
|
|
||||||
|
return result
|
||||||
|
}()
|
||||||
|
|
||||||
private lazy var timestampLabel: UILabel = {
|
private lazy var timestampLabel: UILabel = {
|
||||||
let result = UILabel()
|
let result: UILabel = UILabel()
|
||||||
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
||||||
result.textColor = Colors.text
|
result.textColor = Colors.text
|
||||||
result.textAlignment = .center
|
result.textAlignment = .center
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var label: UILabel = {
|
private lazy var label: UILabel = {
|
||||||
let result = UILabel()
|
let result: UILabel = UILabel()
|
||||||
result.numberOfLines = 0
|
result.numberOfLines = 0
|
||||||
result.lineBreakMode = .byWordWrapping
|
result.lineBreakMode = .byWordWrapping
|
||||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||||
result.textColor = Colors.text
|
result.textColor = Colors.text
|
||||||
result.textAlignment = .center
|
result.textAlignment = .center
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var container: UIView = {
|
private lazy var container: UIView = {
|
||||||
let result = UIView()
|
let result: UIView = UIView()
|
||||||
result.set(.height, to: 50)
|
result.set(.height, to: 50)
|
||||||
result.layer.cornerRadius = 18
|
result.layer.cornerRadius = 18
|
||||||
result.backgroundColor = Colors.callMessageBackground
|
result.backgroundColor = Colors.callMessageBackground
|
||||||
result.addSubview(label)
|
result.addSubview(label)
|
||||||
|
|
||||||
label.autoCenterInSuperview()
|
label.autoCenterInSuperview()
|
||||||
result.addSubview(iconImageView)
|
result.addSubview(iconImageView)
|
||||||
|
|
||||||
iconImageView.autoVCenterInSuperview()
|
iconImageView.autoVCenterInSuperview()
|
||||||
iconImageView.pin(.left, to: .left, of: result, withInset: CallMessageCell.inset)
|
iconImageView.pin(.left, to: .left, of: result, withInset: CallMessageCell.inset)
|
||||||
result.addSubview(infoImageView)
|
result.addSubview(infoImageView)
|
||||||
|
|
||||||
infoImageView.autoVCenterInSuperview()
|
infoImageView.autoVCenterInSuperview()
|
||||||
infoImageView.pin(.right, to: .right, of: result, withInset: -CallMessageCell.inset)
|
infoImageView.pin(.right, to: .right, of: result, withInset: -CallMessageCell.inset)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var stackView: UIStackView = {
|
private lazy var stackView: UIStackView = {
|
||||||
let result = UIStackView(arrangedSubviews: [ timestampLabel, container ])
|
let result: UIStackView = UIStackView(arrangedSubviews: [ timestampLabel, container ])
|
||||||
result.axis = .vertical
|
result.axis = .vertical
|
||||||
result.alignment = .center
|
result.alignment = .center
|
||||||
result.spacing = Values.smallSpacing
|
result.spacing = Values.smallSpacing
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: Settings
|
// MARK: - Lifecycle
|
||||||
private static let iconSize: CGFloat = 16
|
|
||||||
private static let inset = Values.mediumSpacing
|
|
||||||
private static let margin = UIScreen.main.bounds.width * 0.1
|
|
||||||
|
|
||||||
override class var identifier: String { "CallMessageCell" }
|
|
||||||
|
|
||||||
// MARK: Lifecycle
|
|
||||||
override func setUpViewHierarchy() {
|
override func setUpViewHierarchy() {
|
||||||
super.setUpViewHierarchy()
|
super.setUpViewHierarchy()
|
||||||
|
|
||||||
iconImageViewWidthConstraint.isActive = true
|
iconImageViewWidthConstraint.isActive = true
|
||||||
iconImageViewHeightConstraint.isActive = true
|
iconImageViewHeightConstraint.isActive = true
|
||||||
addSubview(stackView)
|
addSubview(stackView)
|
||||||
|
|
||||||
container.autoPinWidthToSuperview()
|
container.autoPinWidthToSuperview()
|
||||||
stackView.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin)
|
stackView.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin)
|
||||||
stackView.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset)
|
stackView.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset)
|
||||||
|
@ -81,39 +96,71 @@ final class CallMessageCell : MessageCell {
|
||||||
addGestureRecognizer(tapGestureRecognizer)
|
addGestureRecognizer(tapGestureRecognizer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Updating
|
// MARK: - Updating
|
||||||
override func update() {
|
|
||||||
guard let message = viewItem?.interaction as? TSInfoMessage, message.messageType == .call else { return }
|
override func update(
|
||||||
let icon: UIImage?
|
with cellViewModel: MessageViewModel,
|
||||||
switch message.callState {
|
mediaCache: NSCache<NSString, AnyObject>,
|
||||||
case .outgoing: icon = UIImage(named: "CallOutgoing")?.withTint(Colors.text)
|
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||||
case .incoming: icon = UIImage(named: "CallIncoming")?.withTint(Colors.text)
|
lastSearchText: String?
|
||||||
case .missed, .permissionDenied: icon = UIImage(named: "CallMissed")?.withTint(Colors.destructive)
|
) {
|
||||||
default: icon = nil
|
guard
|
||||||
}
|
cellViewModel.variant == .infoCall,
|
||||||
iconImageView.image = icon
|
let infoMessageData: Data = (cellViewModel.rawBody ?? "").data(using: .utf8),
|
||||||
iconImageViewWidthConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0
|
let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode(
|
||||||
iconImageViewHeightConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0
|
CallMessage.MessageInfo.self,
|
||||||
|
from: infoMessageData
|
||||||
|
)
|
||||||
|
else { return }
|
||||||
|
|
||||||
let shouldShowInfoIcon = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled
|
self.viewModel = cellViewModel
|
||||||
infoImageViewWidthConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0
|
|
||||||
infoImageViewHeightConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0
|
|
||||||
|
|
||||||
Storage.read { transaction in
|
iconImageView.image = {
|
||||||
self.label.text = message.previewText(with: transaction)
|
switch messageInfo.state {
|
||||||
}
|
case .outgoing: return UIImage(named: "CallOutgoing")?.withRenderingMode(.alwaysTemplate)
|
||||||
|
case .incoming: return UIImage(named: "CallIncoming")?.withRenderingMode(.alwaysTemplate)
|
||||||
|
case .missed, .permissionDenied: return UIImage(named: "CallMissed")?.withRenderingMode(.alwaysTemplate)
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
iconImageView.tintColor = {
|
||||||
|
switch messageInfo.state {
|
||||||
|
case .outgoing, .incoming: return Colors.text
|
||||||
|
case .missed, .permissionDenied: return Colors.destructive
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
iconImageViewWidthConstraint.constant = (iconImageView.image != nil ? CallMessageCell.iconSize : 0)
|
||||||
|
iconImageViewHeightConstraint.constant = (iconImageView.image != nil ? CallMessageCell.iconSize : 0)
|
||||||
|
|
||||||
let date = message.dateForUI()
|
let shouldShowInfoIcon: Bool = (
|
||||||
let description = DateUtil.formatDate(forDisplay: date)
|
messageInfo.state == .permissionDenied &&
|
||||||
timestampLabel.text = description
|
!Storage.shared[.areCallsEnabled]
|
||||||
|
)
|
||||||
|
infoImageViewWidthConstraint.constant = (shouldShowInfoIcon ? CallMessageCell.iconSize : 0)
|
||||||
|
infoImageViewHeightConstraint.constant = (shouldShowInfoIcon ? CallMessageCell.iconSize : 0)
|
||||||
|
|
||||||
|
label.text = cellViewModel.body
|
||||||
|
timestampLabel.text = cellViewModel.dateForUI?.formattedForDisplay
|
||||||
|
}
|
||||||
|
|
||||||
|
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||||
guard let viewItem = viewItem, let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call else { return }
|
guard
|
||||||
let shouldBeTappable = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled
|
let cellViewModel: MessageViewModel = self.viewModel,
|
||||||
if shouldBeTappable {
|
cellViewModel.variant == .infoCall,
|
||||||
delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer)
|
let infoMessageData: Data = (cellViewModel.rawBody ?? "").data(using: .utf8),
|
||||||
}
|
let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode(
|
||||||
|
CallMessage.MessageInfo.self,
|
||||||
|
from: infoMessageData
|
||||||
|
)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
// Should only be tappable if the info icon is visible
|
||||||
|
guard messageInfo.state == .permissionDenied && !Storage.shared[.areCallsEnabled] else { return }
|
||||||
|
|
||||||
|
self.delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,19 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class CallMessageView : UIView {
|
import UIKit
|
||||||
private let viewItem: ConversationViewItem
|
import SessionUIKit
|
||||||
private let textColor: UIColor
|
import SessionMessagingKit
|
||||||
|
|
||||||
// MARK: Settings
|
final class CallMessageView: UIView {
|
||||||
private static let iconSize: CGFloat = 24
|
private static let iconSize: CGFloat = 24
|
||||||
private static let iconImageViewSize: CGFloat = 40
|
private static let iconImageViewSize: CGFloat = 40
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
|
||||||
self.viewItem = viewItem
|
init(cellViewModel: MessageViewModel, textColor: UIColor) {
|
||||||
self.textColor = textColor
|
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
setUpViewHierarchy()
|
|
||||||
|
setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
|
@ -23,22 +24,27 @@ final class CallMessageView : UIView {
|
||||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy(cellViewModel: MessageViewModel, textColor: UIColor) {
|
||||||
guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() }
|
|
||||||
// Image view
|
// Image view
|
||||||
let iconSize = CallMessageView.iconSize
|
let imageView: UIImageView = UIImageView(
|
||||||
let icon = UIImage(named: "Phone")?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
image: UIImage(named: "Phone")?
|
||||||
let imageView = UIImageView(image: icon)
|
.resizedImage(to: CGSize(width: CallMessageView.iconSize, height: CallMessageView.iconSize))?
|
||||||
|
.withRenderingMode(.alwaysTemplate)
|
||||||
|
)
|
||||||
|
imageView.tintColor = textColor
|
||||||
imageView.contentMode = .center
|
imageView.contentMode = .center
|
||||||
|
|
||||||
let iconImageViewSize = CallMessageView.iconImageViewSize
|
let iconImageViewSize = CallMessageView.iconImageViewSize
|
||||||
imageView.set(.width, to: iconImageViewSize)
|
imageView.set(.width, to: iconImageViewSize)
|
||||||
imageView.set(.height, to: iconImageViewSize)
|
imageView.set(.height, to: iconImageViewSize)
|
||||||
|
|
||||||
// Body label
|
// Body label
|
||||||
let titleLabel = UILabel()
|
let titleLabel = UILabel()
|
||||||
titleLabel.lineBreakMode = .byTruncatingTail
|
titleLabel.lineBreakMode = .byTruncatingTail
|
||||||
titleLabel.text = message.body
|
titleLabel.text = cellViewModel.body
|
||||||
titleLabel.textColor = textColor
|
titleLabel.textColor = textColor
|
||||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||||
|
|
||||||
// Stack view
|
// Stack view
|
||||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||||
stackView.axis = .horizontal
|
stackView.axis = .horizontal
|
||||||
|
|
|
@ -1,43 +1,51 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class DeletedMessageView : UIView {
|
import UIKit
|
||||||
private let viewItem: ConversationViewItem
|
import SignalUtilitiesKit
|
||||||
private let textColor: UIColor
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
// MARK: Settings
|
final class DeletedMessageView: UIView {
|
||||||
private static let iconSize: CGFloat = 18
|
private static let iconSize: CGFloat = 18
|
||||||
private static let iconImageViewSize: CGFloat = 30
|
private static let iconImageViewSize: CGFloat = 30
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
|
||||||
self.viewItem = viewItem
|
init(textColor: UIColor) {
|
||||||
self.textColor = textColor
|
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
setUpViewHierarchy()
|
|
||||||
|
setUpViewHierarchy(textColor: textColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
preconditionFailure("Use init(textColor:) instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
preconditionFailure("Use init(textColor:) instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy(textColor: UIColor) {
|
||||||
// Image view
|
// Image view
|
||||||
let iconSize = DeletedMessageView.iconSize
|
let icon = UIImage(named: "ic_trash")?
|
||||||
let icon = UIImage(named: "ic_trash")?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
.resizedImage(to: CGSize(
|
||||||
|
width: DeletedMessageView.iconSize,
|
||||||
|
height: DeletedMessageView.iconSize
|
||||||
|
))?
|
||||||
|
.withRenderingMode(.alwaysTemplate)
|
||||||
|
|
||||||
let imageView = UIImageView(image: icon)
|
let imageView = UIImageView(image: icon)
|
||||||
|
imageView.tintColor = textColor
|
||||||
imageView.contentMode = .center
|
imageView.contentMode = .center
|
||||||
let iconImageViewSize = DeletedMessageView.iconImageViewSize
|
imageView.set(.width, to: DeletedMessageView.iconImageViewSize)
|
||||||
imageView.set(.width, to: iconImageViewSize)
|
imageView.set(.height, to: DeletedMessageView.iconImageViewSize)
|
||||||
imageView.set(.height, to: iconImageViewSize)
|
|
||||||
// Body label
|
// Body label
|
||||||
let titleLabel = UILabel()
|
let titleLabel = UILabel()
|
||||||
titleLabel.lineBreakMode = .byTruncatingTail
|
titleLabel.lineBreakMode = .byTruncatingTail
|
||||||
titleLabel.text = NSLocalizedString("message_deleted", comment: "")
|
titleLabel.text = "message_deleted".localized()
|
||||||
titleLabel.textColor = textColor
|
titleLabel.textColor = textColor
|
||||||
titleLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
titleLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||||
|
|
||||||
// Stack view
|
// Stack view
|
||||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||||
stackView.axis = .horizontal
|
stackView.axis = .horizontal
|
||||||
|
@ -45,7 +53,8 @@ final class DeletedMessageView : UIView {
|
||||||
stackView.isLayoutMarginsRelativeArrangement = true
|
stackView.isLayoutMarginsRelativeArrangement = true
|
||||||
stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 6)
|
stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 6)
|
||||||
addSubview(stackView)
|
addSubview(stackView)
|
||||||
|
|
||||||
stackView.pin(to: self, withInset: Values.smallSpacing)
|
stackView.pin(to: self, withInset: Values.smallSpacing)
|
||||||
|
stackView.set(.height, to: .height, of: imageView)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,17 +1,18 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class DocumentView : UIView {
|
import UIKit
|
||||||
private let viewItem: ConversationViewItem
|
import SessionUIKit
|
||||||
private let textColor: UIColor
|
import SessionMessagingKit
|
||||||
|
|
||||||
// MARK: Settings
|
final class DocumentView: UIView {
|
||||||
private static let iconImageViewSize: CGSize = CGSize(width: 31, height: 40)
|
private static let iconImageViewSize: CGSize = CGSize(width: 31, height: 40)
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
|
||||||
self.viewItem = viewItem
|
init(attachment: Attachment, textColor: UIColor) {
|
||||||
self.textColor = textColor
|
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
setUpViewHierarchy()
|
|
||||||
|
setUpViewHierarchy(attachment: attachment, textColor: textColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
|
@ -22,30 +23,34 @@ final class DocumentView : UIView {
|
||||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy(attachment: Attachment, textColor: UIColor) {
|
||||||
guard let attachment = viewItem.attachmentStream ?? viewItem.attachmentPointer else { return }
|
|
||||||
// Image view
|
// Image view
|
||||||
let icon = UIImage(named: "File")?.withTint(textColor)
|
let imageView = UIImageView(image: UIImage(named: "File")?.withRenderingMode(.alwaysTemplate))
|
||||||
let imageView = UIImageView(image: icon)
|
imageView.tintColor = textColor
|
||||||
imageView.contentMode = .center
|
imageView.contentMode = .center
|
||||||
|
|
||||||
let iconImageViewSize = DocumentView.iconImageViewSize
|
let iconImageViewSize = DocumentView.iconImageViewSize
|
||||||
imageView.set(.width, to: iconImageViewSize.width)
|
imageView.set(.width, to: iconImageViewSize.width)
|
||||||
imageView.set(.height, to: iconImageViewSize.height)
|
imageView.set(.height, to: iconImageViewSize.height)
|
||||||
|
|
||||||
// Body label
|
// Body label
|
||||||
let titleLabel = UILabel()
|
let titleLabel = UILabel()
|
||||||
titleLabel.lineBreakMode = .byTruncatingTail
|
titleLabel.lineBreakMode = .byTruncatingTail
|
||||||
titleLabel.text = attachment.sourceFilename ?? "File"
|
titleLabel.text = (attachment.sourceFilename ?? "File")
|
||||||
titleLabel.textColor = textColor
|
titleLabel.textColor = textColor
|
||||||
titleLabel.font = .systemFont(ofSize: Values.smallFontSize, weight: .light)
|
titleLabel.font = .systemFont(ofSize: Values.smallFontSize, weight: .light)
|
||||||
|
|
||||||
// Size label
|
// Size label
|
||||||
let sizeLabel = UILabel()
|
let sizeLabel = UILabel()
|
||||||
sizeLabel.lineBreakMode = .byTruncatingTail
|
sizeLabel.lineBreakMode = .byTruncatingTail
|
||||||
sizeLabel.text = OWSFormat.formatFileSize(UInt(attachment.byteCount))
|
sizeLabel.text = OWSFormat.formatFileSize(UInt(attachment.byteCount))
|
||||||
sizeLabel.textColor = textColor
|
sizeLabel.textColor = textColor
|
||||||
sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||||
|
|
||||||
// Label stack view
|
// Label stack view
|
||||||
let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, sizeLabel ])
|
let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, sizeLabel ])
|
||||||
labelStackView.axis = .vertical
|
labelStackView.axis = .vertical
|
||||||
|
|
||||||
// Stack view
|
// Stack view
|
||||||
let stackView = UIStackView(arrangedSubviews: [ imageView, labelStackView ])
|
let stackView = UIStackView(arrangedSubviews: [ imageView, labelStackView ])
|
||||||
stackView.axis = .horizontal
|
stackView.axis = .horizontal
|
||||||
|
|
|
@ -1,220 +1,138 @@
|
||||||
//
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
extension CGPoint {
|
import UIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
|
protocol LinkPreviewState {
|
||||||
|
var isLoaded: Bool { get }
|
||||||
|
var urlString: String? { get }
|
||||||
|
var title: String? { get }
|
||||||
|
var imageState: LinkPreview.ImageState { get }
|
||||||
|
var image: UIImage? { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension LinkPreview {
|
||||||
|
enum ImageState: Int {
|
||||||
|
case none
|
||||||
|
case loading
|
||||||
|
case loaded
|
||||||
|
case invalid
|
||||||
|
}
|
||||||
|
|
||||||
public func offsetBy(dx: CGFloat) -> CGPoint {
|
// MARK: LoadingState
|
||||||
return CGPoint(x: x + dx, y: y)
|
|
||||||
|
struct LoadingState: LinkPreviewState {
|
||||||
|
var isLoaded: Bool { false }
|
||||||
|
var urlString: String? { nil }
|
||||||
|
var title: String? { nil }
|
||||||
|
var imageState: LinkPreview.ImageState { .none }
|
||||||
|
var image: UIImage? { nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: DraftState
|
||||||
|
|
||||||
|
struct DraftState: LinkPreviewState {
|
||||||
|
var isLoaded: Bool { true }
|
||||||
|
var urlString: String? { linkPreviewDraft.urlString }
|
||||||
|
|
||||||
public func offsetBy(dy: CGFloat) -> CGPoint {
|
var title: String? {
|
||||||
return CGPoint(x: x, y: y + dy)
|
guard let value = linkPreviewDraft.title, value.count > 0 else { return nil }
|
||||||
}
|
|
||||||
}
|
return value
|
||||||
|
|
||||||
// MARK: -
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public enum LinkPreviewImageState: Int {
|
|
||||||
case none
|
|
||||||
case loading
|
|
||||||
case loaded
|
|
||||||
case invalid
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: -
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public protocol LinkPreviewState {
|
|
||||||
func isLoaded() -> Bool
|
|
||||||
func urlString() -> String?
|
|
||||||
func displayDomain() -> String?
|
|
||||||
func title() -> String?
|
|
||||||
func imageState() -> LinkPreviewImageState
|
|
||||||
func image() -> UIImage?
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: -
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public class LinkPreviewLoading: NSObject, LinkPreviewState {
|
|
||||||
|
|
||||||
override init() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public func isLoaded() -> Bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
public func urlString() -> String? {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public func displayDomain() -> String? {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public func title() -> String? {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public func imageState() -> LinkPreviewImageState {
|
|
||||||
return .none
|
|
||||||
}
|
|
||||||
|
|
||||||
public func image() -> UIImage? {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: -
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public class LinkPreviewDraft: NSObject, LinkPreviewState {
|
|
||||||
private let linkPreviewDraft: OWSLinkPreviewDraft
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public required init(linkPreviewDraft: OWSLinkPreviewDraft) {
|
|
||||||
self.linkPreviewDraft = linkPreviewDraft
|
|
||||||
}
|
|
||||||
|
|
||||||
public func isLoaded() -> Bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
public func urlString() -> String? {
|
|
||||||
return linkPreviewDraft.urlString
|
|
||||||
}
|
|
||||||
|
|
||||||
public func displayDomain() -> String? {
|
|
||||||
guard let displayDomain = linkPreviewDraft.displayDomain() else {
|
|
||||||
owsFailDebug("Missing display domain")
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
return displayDomain
|
|
||||||
}
|
var imageState: LinkPreview.ImageState {
|
||||||
|
if linkPreviewDraft.jpegImageData != nil { return .loaded }
|
||||||
public func title() -> String? {
|
|
||||||
guard let value = linkPreviewDraft.title,
|
return .none
|
||||||
value.count > 0 else {
|
}
|
||||||
|
|
||||||
|
var image: UIImage? {
|
||||||
|
guard let jpegImageData = linkPreviewDraft.jpegImageData else { return nil }
|
||||||
|
guard let image = UIImage(data: jpegImageData) else {
|
||||||
|
owsFailDebug("Could not load image: \(jpegImageData.count)")
|
||||||
return nil
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return image
|
||||||
}
|
}
|
||||||
return value
|
|
||||||
}
|
// MARK: - Type Specific
|
||||||
|
|
||||||
|
private let linkPreviewDraft: LinkPreviewDraft
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
public func imageState() -> LinkPreviewImageState {
|
init(linkPreviewDraft: LinkPreviewDraft) {
|
||||||
if linkPreviewDraft.jpegImageData != nil {
|
self.linkPreviewDraft = linkPreviewDraft
|
||||||
return .loaded
|
|
||||||
} else {
|
|
||||||
return .none
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: SentState
|
||||||
|
|
||||||
|
struct SentState: LinkPreviewState {
|
||||||
|
var isLoaded: Bool { true }
|
||||||
|
var urlString: String? { linkPreview.url }
|
||||||
|
|
||||||
public func image() -> UIImage? {
|
var title: String? {
|
||||||
guard let jpegImageData = linkPreviewDraft.jpegImageData else {
|
guard let value = linkPreview.title, value.count > 0 else { return nil }
|
||||||
return nil
|
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
guard let image = UIImage(data: jpegImageData) else {
|
|
||||||
owsFailDebug("Could not load image: \(jpegImageData.count)")
|
var imageState: LinkPreview.ImageState {
|
||||||
return nil
|
guard linkPreview.attachmentId != nil else { return .none }
|
||||||
|
guard let imageAttachment: Attachment = imageAttachment else {
|
||||||
|
owsFailDebug("Missing imageAttachment.")
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
|
||||||
|
switch imageAttachment.state {
|
||||||
|
case .downloaded, .uploaded:
|
||||||
|
guard imageAttachment.isImage && imageAttachment.isValid else {
|
||||||
|
return .invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
return .loaded
|
||||||
|
|
||||||
|
case .pendingDownload, .downloading, .uploading: return .loading
|
||||||
|
case .failedDownload, .failedUpload, .invalid: return .invalid
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return image
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: -
|
var image: UIImage? {
|
||||||
|
// Note: We don't check if the image is valid here because that can be confirmed
|
||||||
@objc
|
// in 'imageState' and it's a little inefficient
|
||||||
public class LinkPreviewSent: NSObject, LinkPreviewState {
|
guard imageAttachment?.isImage == true else { return nil }
|
||||||
private let linkPreview: OWSLinkPreview
|
guard let imageData: Data = try? imageAttachment?.readDataFromFile() else {
|
||||||
private let imageAttachment: TSAttachment?
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public var imageSize: CGSize {
|
|
||||||
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
|
|
||||||
return CGSize.zero
|
|
||||||
}
|
|
||||||
return attachmentStream.imageSize()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public required init(linkPreview: OWSLinkPreview,
|
|
||||||
imageAttachment: TSAttachment?) {
|
|
||||||
self.linkPreview = linkPreview
|
|
||||||
self.imageAttachment = imageAttachment
|
|
||||||
}
|
|
||||||
|
|
||||||
public func isLoaded() -> Bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
public func urlString() -> String? {
|
|
||||||
guard let urlString = linkPreview.urlString else {
|
|
||||||
owsFailDebug("Missing url")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return urlString
|
|
||||||
}
|
|
||||||
|
|
||||||
public func displayDomain() -> String? {
|
|
||||||
guard let displayDomain = linkPreview.displayDomain() else {
|
|
||||||
Logger.error("Missing display domain")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return displayDomain
|
|
||||||
}
|
|
||||||
|
|
||||||
public func title() -> String? {
|
|
||||||
guard let value = linkPreview.title,
|
|
||||||
value.count > 0 else {
|
|
||||||
return nil
|
return nil
|
||||||
|
}
|
||||||
|
guard let image = UIImage(data: imageData) else {
|
||||||
|
owsFailDebug("Could not load image: \(imageAttachment?.localRelativeFilePath ?? "unknown")")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return image
|
||||||
}
|
}
|
||||||
return value
|
|
||||||
}
|
// MARK: - Type Specific
|
||||||
|
|
||||||
|
private let linkPreview: LinkPreview
|
||||||
|
private let imageAttachment: Attachment?
|
||||||
|
|
||||||
public func imageState() -> LinkPreviewImageState {
|
public var imageSize: CGSize {
|
||||||
guard linkPreview.imageAttachmentId != nil else {
|
guard let width: UInt = imageAttachment?.width, let height: UInt = imageAttachment?.height else {
|
||||||
return .none
|
return CGSize.zero
|
||||||
|
}
|
||||||
|
|
||||||
|
return CGSize(width: CGFloat(width), height: CGFloat(height))
|
||||||
}
|
}
|
||||||
guard let imageAttachment = imageAttachment else {
|
|
||||||
owsFailDebug("Missing imageAttachment.")
|
// MARK: - Initialization
|
||||||
return .none
|
|
||||||
}
|
|
||||||
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
|
|
||||||
return .loading
|
|
||||||
}
|
|
||||||
guard attachmentStream.isImage,
|
|
||||||
attachmentStream.isValidImage else {
|
|
||||||
return .invalid
|
|
||||||
}
|
|
||||||
return .loaded
|
|
||||||
}
|
|
||||||
|
|
||||||
public func image() -> UIImage? {
|
init(linkPreview: LinkPreview, imageAttachment: Attachment?) {
|
||||||
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
|
self.linkPreview = linkPreview
|
||||||
return nil
|
self.imageAttachment = imageAttachment
|
||||||
}
|
}
|
||||||
guard attachmentStream.isImage,
|
|
||||||
attachmentStream.isValidImage else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let imageFilepath = attachmentStream.originalFilePath else {
|
|
||||||
owsFailDebug("Attachment is missing file path.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let image = UIImage(contentsOfFile: imageFilepath) else {
|
|
||||||
owsFailDebug("Could not load image: \(imageFilepath)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return image
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: -
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public protocol LinkPreviewViewDraftDelegate {
|
|
||||||
func linkPreviewCanCancel() -> Bool
|
|
||||||
func linkPreviewDidCancel()
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,97 +1,106 @@
|
||||||
import NVActivityIndicatorView
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class LinkPreviewView : UIView {
|
import UIKit
|
||||||
private let viewItem: ConversationViewItem?
|
import NVActivityIndicatorView
|
||||||
|
import SessionUIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
|
final class LinkPreviewView: UIView {
|
||||||
|
private static let loaderSize: CGFloat = 24
|
||||||
|
private static let cancelButtonSize: CGFloat = 45
|
||||||
|
|
||||||
private let maxWidth: CGFloat
|
private let maxWidth: CGFloat
|
||||||
private let delegate: LinkPreviewViewDelegate
|
private let onCancel: (() -> ())?
|
||||||
var linkPreviewState: LinkPreviewState? { didSet { update() } }
|
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
private lazy var imageViewContainerWidthConstraint = imageView.set(.width, to: 100)
|
private lazy var imageViewContainerWidthConstraint = imageView.set(.width, to: 100)
|
||||||
private lazy var imageViewContainerHeightConstraint = imageView.set(.height, to: 100)
|
private lazy var imageViewContainerHeightConstraint = imageView.set(.height, to: 100)
|
||||||
|
|
||||||
private lazy var sentLinkPreviewTextColor: UIColor = {
|
|
||||||
let isOutgoing = (viewItem?.interaction.interactionType() == .outgoingMessage)
|
|
||||||
switch (isOutgoing, AppModeManager.shared.currentAppMode) {
|
|
||||||
case (false, .light): return .black
|
|
||||||
case (true, .light): return Colors.grey
|
|
||||||
default: return .white
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// MARK: UI Components
|
// MARK: UI Components
|
||||||
|
|
||||||
private lazy var imageView: UIImageView = {
|
private lazy var imageView: UIImageView = {
|
||||||
let result = UIImageView()
|
let result: UIImageView = UIImageView()
|
||||||
result.contentMode = .scaleAspectFill
|
result.contentMode = .scaleAspectFill
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var imageViewContainer: UIView = {
|
private lazy var imageViewContainer: UIView = {
|
||||||
let result = UIView()
|
let result: UIView = UIView()
|
||||||
result.clipsToBounds = true
|
result.clipsToBounds = true
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var loader: NVActivityIndicatorView = {
|
private lazy var loader: NVActivityIndicatorView = {
|
||||||
let color: UIColor = isLightMode ? .black : .white
|
// FIXME: This will have issues with theme transitions
|
||||||
|
let color: UIColor = (isLightMode ? .black : .white)
|
||||||
|
|
||||||
return NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: color, padding: nil)
|
return NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: color, padding: nil)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var titleLabel: UILabel = {
|
private lazy var titleLabel: UILabel = {
|
||||||
let result = UILabel()
|
let result: UILabel = UILabel()
|
||||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||||
result.numberOfLines = 0
|
result.numberOfLines = 0
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var bodyTextViewContainer = UIView()
|
private lazy var bodyTextViewContainer: UIView = UIView()
|
||||||
|
|
||||||
private lazy var hStackViewContainer = UIView()
|
private lazy var hStackViewContainer: UIView = UIView()
|
||||||
|
|
||||||
private lazy var hStackView = UIStackView()
|
private lazy var hStackView: UIStackView = UIStackView()
|
||||||
|
|
||||||
private lazy var cancelButton: UIButton = {
|
private lazy var cancelButton: UIButton = {
|
||||||
let result = UIButton(type: .custom)
|
// FIXME: This will have issues with theme transitions
|
||||||
let tint: UIColor = isLightMode ? .black : .white
|
let result: UIButton = UIButton(type: .custom)
|
||||||
result.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal)
|
result.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: UIControl.State.normal)
|
||||||
|
result.tintColor = (isLightMode ? .black : .white)
|
||||||
|
|
||||||
let cancelButtonSize = LinkPreviewView.cancelButtonSize
|
let cancelButtonSize = LinkPreviewView.cancelButtonSize
|
||||||
result.set(.width, to: cancelButtonSize)
|
result.set(.width, to: cancelButtonSize)
|
||||||
result.set(.height, to: cancelButtonSize)
|
result.set(.height, to: cancelButtonSize)
|
||||||
result.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
|
result.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var bodyTextView: UITextView?
|
var bodyTextView: UITextView?
|
||||||
|
|
||||||
// MARK: Settings
|
// MARK: - Initialization
|
||||||
private static let loaderSize: CGFloat = 24
|
|
||||||
private static let cancelButtonSize: CGFloat = 45
|
init(maxWidth: CGFloat, onCancel: (() -> ())? = nil) {
|
||||||
|
|
||||||
// MARK: Lifecycle
|
|
||||||
init(for viewItem: ConversationViewItem?, maxWidth: CGFloat, delegate: LinkPreviewViewDelegate) {
|
|
||||||
self.viewItem = viewItem
|
|
||||||
self.maxWidth = maxWidth
|
self.maxWidth = maxWidth
|
||||||
self.delegate = delegate
|
self.onCancel = onCancel
|
||||||
|
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
preconditionFailure("Use init(for:maxWidth:delegate:) instead.")
|
preconditionFailure("Use init(for:maxWidth:delegate:) instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
preconditionFailure("Use init(for:maxWidth:delegate:) instead.")
|
preconditionFailure("Use init(for:maxWidth:delegate:) instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy() {
|
||||||
// Image view
|
// Image view
|
||||||
imageViewContainerWidthConstraint.isActive = true
|
imageViewContainerWidthConstraint.isActive = true
|
||||||
imageViewContainerHeightConstraint.isActive = true
|
imageViewContainerHeightConstraint.isActive = true
|
||||||
imageViewContainer.addSubview(imageView)
|
imageViewContainer.addSubview(imageView)
|
||||||
imageView.pin(to: imageViewContainer)
|
imageView.pin(to: imageViewContainer)
|
||||||
|
|
||||||
// Title label
|
// Title label
|
||||||
let titleLabelContainer = UIView()
|
let titleLabelContainer = UIView()
|
||||||
titleLabelContainer.addSubview(titleLabel)
|
titleLabelContainer.addSubview(titleLabel)
|
||||||
titleLabel.pin(to: titleLabelContainer, withInset: Values.smallSpacing)
|
titleLabel.pin(to: titleLabelContainer, withInset: Values.smallSpacing)
|
||||||
|
|
||||||
// Horizontal stack view
|
// Horizontal stack view
|
||||||
hStackView.addArrangedSubview(imageViewContainer)
|
hStackView.addArrangedSubview(imageViewContainer)
|
||||||
hStackView.addArrangedSubview(titleLabelContainer)
|
hStackView.addArrangedSubview(titleLabelContainer)
|
||||||
|
@ -99,72 +108,106 @@ final class LinkPreviewView : UIView {
|
||||||
hStackView.alignment = .center
|
hStackView.alignment = .center
|
||||||
hStackViewContainer.addSubview(hStackView)
|
hStackViewContainer.addSubview(hStackView)
|
||||||
hStackView.pin(to: hStackViewContainer)
|
hStackView.pin(to: hStackViewContainer)
|
||||||
|
|
||||||
// Vertical stack view
|
// Vertical stack view
|
||||||
let vStackView = UIStackView(arrangedSubviews: [ hStackViewContainer, bodyTextViewContainer ])
|
let vStackView = UIStackView(arrangedSubviews: [ hStackViewContainer, bodyTextViewContainer ])
|
||||||
vStackView.axis = .vertical
|
vStackView.axis = .vertical
|
||||||
addSubview(vStackView)
|
addSubview(vStackView)
|
||||||
vStackView.pin(to: self)
|
vStackView.pin(to: self)
|
||||||
|
|
||||||
// Loader
|
// Loader
|
||||||
addSubview(loader)
|
addSubview(loader)
|
||||||
|
|
||||||
let loaderSize = LinkPreviewView.loaderSize
|
let loaderSize = LinkPreviewView.loaderSize
|
||||||
loader.set(.width, to: loaderSize)
|
loader.set(.width, to: loaderSize)
|
||||||
loader.set(.height, to: loaderSize)
|
loader.set(.height, to: loaderSize)
|
||||||
loader.center(in: self)
|
loader.center(in: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Updating
|
// MARK: - Updating
|
||||||
private func update() {
|
|
||||||
|
public func update(
|
||||||
|
with state: LinkPreviewState,
|
||||||
|
isOutgoing: Bool,
|
||||||
|
delegate: (UITextViewDelegate & BodyTextViewDelegate)? = nil,
|
||||||
|
cellViewModel: MessageViewModel? = nil,
|
||||||
|
bodyLabelTextColor: UIColor? = nil,
|
||||||
|
lastSearchText: String? = nil
|
||||||
|
) {
|
||||||
cancelButton.removeFromSuperview()
|
cancelButton.removeFromSuperview()
|
||||||
guard let linkPreviewState = linkPreviewState else { return }
|
|
||||||
var image = linkPreviewState.image()
|
var image: UIImage? = state.image
|
||||||
if image == nil && (linkPreviewState is LinkPreviewDraft || linkPreviewState is LinkPreviewSent) {
|
let stateHasImage: Bool = (image != nil)
|
||||||
|
if image == nil && (state is LinkPreview.DraftState || state is LinkPreview.SentState) {
|
||||||
image = UIImage(named: "Link")?.withTint(isLightMode ? .black : .white)
|
image = UIImage(named: "Link")?.withTint(isLightMode ? .black : .white)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image view
|
// Image view
|
||||||
let imageViewContainerSize: CGFloat = (linkPreviewState is LinkPreviewSent) ? 100 : 80
|
let imageViewContainerSize: CGFloat = (state is LinkPreview.SentState ? 100 : 80)
|
||||||
imageViewContainerWidthConstraint.constant = imageViewContainerSize
|
imageViewContainerWidthConstraint.constant = imageViewContainerSize
|
||||||
imageViewContainerHeightConstraint.constant = imageViewContainerSize
|
imageViewContainerHeightConstraint.constant = imageViewContainerSize
|
||||||
imageViewContainer.layer.cornerRadius = (linkPreviewState is LinkPreviewSent) ? 0 : 8
|
imageViewContainer.layer.cornerRadius = (state is LinkPreview.SentState ? 0 : 8)
|
||||||
if linkPreviewState is LinkPreviewLoading {
|
|
||||||
|
if state is LinkPreview.LoadingState {
|
||||||
imageViewContainer.backgroundColor = .clear
|
imageViewContainer.backgroundColor = .clear
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
imageViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06)
|
imageViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06)
|
||||||
}
|
}
|
||||||
|
|
||||||
imageView.image = image
|
imageView.image = image
|
||||||
imageView.contentMode = (linkPreviewState.image() == nil) ? .center : .scaleAspectFill
|
imageView.contentMode = (stateHasImage ? .scaleAspectFill : .center)
|
||||||
|
|
||||||
// Loader
|
// Loader
|
||||||
loader.alpha = (image != nil) ? 0 : 1
|
loader.alpha = (image != nil ? 0 : 1)
|
||||||
if image != nil { loader.stopAnimating() } else { loader.startAnimating() }
|
if image != nil { loader.stopAnimating() } else { loader.startAnimating() }
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
|
let sentLinkPreviewTextColor: UIColor = {
|
||||||
|
switch (isOutgoing, AppModeManager.shared.currentAppMode) {
|
||||||
|
case (false, .light): return .black
|
||||||
|
case (true, .light): return Colors.grey
|
||||||
|
default: return .white
|
||||||
|
}
|
||||||
|
}()
|
||||||
titleLabel.textColor = sentLinkPreviewTextColor
|
titleLabel.textColor = sentLinkPreviewTextColor
|
||||||
titleLabel.text = linkPreviewState.title()
|
titleLabel.text = state.title
|
||||||
|
|
||||||
// Horizontal stack view
|
// Horizontal stack view
|
||||||
switch linkPreviewState {
|
switch state {
|
||||||
case is LinkPreviewSent: hStackViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06)
|
case is LinkPreview.SentState:
|
||||||
default: hStackViewContainer.backgroundColor = nil
|
// FIXME: This will have issues with theme transitions
|
||||||
|
hStackViewContainer.backgroundColor = (isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06))
|
||||||
|
|
||||||
|
default:
|
||||||
|
hStackViewContainer.backgroundColor = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Body text view
|
// Body text view
|
||||||
bodyTextViewContainer.subviews.forEach { $0.removeFromSuperview() }
|
bodyTextViewContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||||
if let viewItem = viewItem {
|
|
||||||
let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: sentLinkPreviewTextColor, delegate: delegate)
|
if let cellViewModel: MessageViewModel = cellViewModel {
|
||||||
|
let bodyTextView = VisibleMessageCell.getBodyTextView(
|
||||||
|
for: cellViewModel,
|
||||||
|
with: maxWidth,
|
||||||
|
textColor: (bodyLabelTextColor ?? sentLinkPreviewTextColor),
|
||||||
|
searchText: lastSearchText,
|
||||||
|
delegate: delegate
|
||||||
|
)
|
||||||
|
|
||||||
self.bodyTextView = bodyTextView
|
self.bodyTextView = bodyTextView
|
||||||
bodyTextViewContainer.addSubview(bodyTextView)
|
bodyTextViewContainer.addSubview(bodyTextView)
|
||||||
bodyTextView.pin(to: bodyTextViewContainer, withInset: 12)
|
bodyTextView.pin(to: bodyTextViewContainer, withInset: 12)
|
||||||
}
|
}
|
||||||
if linkPreviewState is LinkPreviewDraft {
|
|
||||||
|
if state is LinkPreview.DraftState {
|
||||||
hStackView.addArrangedSubview(cancelButton)
|
hStackView.addArrangedSubview(cancelButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
@objc private func cancel() {
|
@objc private func cancel() {
|
||||||
delegate.handleLinkPreviewCanceled()
|
onCancel?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Delegate
|
|
||||||
protocol LinkPreviewViewDelegate : UITextViewDelegate & BodyTextViewDelegate {
|
|
||||||
var lastSearchedText: String? { get }
|
|
||||||
|
|
||||||
func handleLinkPreviewCanceled()
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,17 +1,11 @@
|
||||||
//
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import UIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
@objc(OWSMediaAlbumView)
|
|
||||||
public class MediaAlbumView: UIStackView {
|
public class MediaAlbumView: UIStackView {
|
||||||
private let items: [ConversationMediaAlbumItem]
|
private let items: [Attachment]
|
||||||
|
|
||||||
@objc
|
|
||||||
public let itemViews: [MediaView]
|
public let itemViews: [MediaView]
|
||||||
|
|
||||||
@objc
|
|
||||||
public var moreItemsView: MediaView?
|
public var moreItemsView: MediaView?
|
||||||
|
|
||||||
private static let kSpacingPts: CGFloat = 2
|
private static let kSpacingPts: CGFloat = 2
|
||||||
|
@ -22,19 +16,22 @@ public class MediaAlbumView: UIStackView {
|
||||||
notImplemented()
|
notImplemented()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
public required init(
|
||||||
public required init(mediaCache: NSCache<NSString, AnyObject>,
|
mediaCache: NSCache<NSString, AnyObject>,
|
||||||
items: [ConversationMediaAlbumItem],
|
items: [Attachment],
|
||||||
isOutgoing: Bool,
|
isOutgoing: Bool,
|
||||||
maxMessageWidth: CGFloat) {
|
maxMessageWidth: CGFloat
|
||||||
|
) {
|
||||||
self.items = items
|
self.items = items
|
||||||
self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items).map {
|
self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items)
|
||||||
let result = MediaView(mediaCache: mediaCache,
|
.map {
|
||||||
attachment: $0.attachment,
|
MediaView(
|
||||||
isOutgoing: isOutgoing,
|
mediaCache: mediaCache,
|
||||||
maxMessageWidth: maxMessageWidth)
|
attachment: $0,
|
||||||
return result
|
isOutgoing: isOutgoing,
|
||||||
}
|
maxMessageWidth: maxMessageWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
@ -46,110 +43,137 @@ public class MediaAlbumView: UIStackView {
|
||||||
|
|
||||||
private func createContents(maxMessageWidth: CGFloat) {
|
private func createContents(maxMessageWidth: CGFloat) {
|
||||||
switch itemViews.count {
|
switch itemViews.count {
|
||||||
case 0:
|
case 0: return owsFailDebug("No item views.")
|
||||||
owsFailDebug("No item views.")
|
|
||||||
return
|
case 1:
|
||||||
case 1:
|
// X
|
||||||
// X
|
guard let itemView = itemViews.first else {
|
||||||
guard let itemView = itemViews.first else {
|
owsFailDebug("Missing item view.")
|
||||||
owsFailDebug("Missing item view.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
addSubview(itemView)
|
|
||||||
itemView.autoPinEdgesToSuperviewEdges()
|
|
||||||
case 2:
|
|
||||||
// X X
|
|
||||||
// side-by-side.
|
|
||||||
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
|
||||||
autoSet(viewSize: imageSize, ofViews: itemViews)
|
|
||||||
for itemView in itemViews {
|
|
||||||
addArrangedSubview(itemView)
|
|
||||||
}
|
|
||||||
self.axis = .horizontal
|
|
||||||
self.spacing = MediaAlbumView.kSpacingPts
|
|
||||||
case 3:
|
|
||||||
// x
|
|
||||||
// X x
|
|
||||||
// Big on left, 2 small on right.
|
|
||||||
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
|
|
||||||
let bigImageSize = smallImageSize * 2 + MediaAlbumView.kSpacingPts
|
|
||||||
|
|
||||||
guard let leftItemView = itemViews.first else {
|
|
||||||
owsFailDebug("Missing view")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
autoSet(viewSize: bigImageSize, ofViews: [leftItemView])
|
|
||||||
addArrangedSubview(leftItemView)
|
|
||||||
|
|
||||||
let rightViews = Array(itemViews[1..<3])
|
|
||||||
addArrangedSubview(newRow(rowViews: rightViews,
|
|
||||||
axis: .vertical,
|
|
||||||
viewSize: smallImageSize))
|
|
||||||
self.axis = .horizontal
|
|
||||||
self.spacing = MediaAlbumView.kSpacingPts
|
|
||||||
case 4:
|
|
||||||
// X X
|
|
||||||
// X X
|
|
||||||
// Square
|
|
||||||
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
|
||||||
|
|
||||||
let topViews = Array(itemViews[0..<2])
|
|
||||||
addArrangedSubview(newRow(rowViews: topViews,
|
|
||||||
axis: .horizontal,
|
|
||||||
viewSize: imageSize))
|
|
||||||
|
|
||||||
let bottomViews = Array(itemViews[2..<4])
|
|
||||||
addArrangedSubview(newRow(rowViews: bottomViews,
|
|
||||||
axis: .horizontal,
|
|
||||||
viewSize: imageSize))
|
|
||||||
|
|
||||||
self.axis = .vertical
|
|
||||||
self.spacing = MediaAlbumView.kSpacingPts
|
|
||||||
default:
|
|
||||||
// X X
|
|
||||||
// xxx
|
|
||||||
// 2 big on top, 3 small on bottom.
|
|
||||||
let bigImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
|
||||||
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
|
|
||||||
|
|
||||||
let topViews = Array(itemViews[0..<2])
|
|
||||||
addArrangedSubview(newRow(rowViews: topViews,
|
|
||||||
axis: .horizontal,
|
|
||||||
viewSize: bigImageSize))
|
|
||||||
|
|
||||||
let bottomViews = Array(itemViews[2..<5])
|
|
||||||
addArrangedSubview(newRow(rowViews: bottomViews,
|
|
||||||
axis: .horizontal,
|
|
||||||
viewSize: smallImageSize))
|
|
||||||
|
|
||||||
self.axis = .vertical
|
|
||||||
self.spacing = MediaAlbumView.kSpacingPts
|
|
||||||
|
|
||||||
if items.count > MediaAlbumView.kMaxItems {
|
|
||||||
guard let lastView = bottomViews.last else {
|
|
||||||
owsFailDebug("Missing lastView")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
addSubview(itemView)
|
||||||
|
itemView.autoPinEdgesToSuperviewEdges()
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
// X X
|
||||||
|
// side-by-side.
|
||||||
|
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
||||||
|
autoSet(viewSize: imageSize, ofViews: itemViews)
|
||||||
|
for itemView in itemViews {
|
||||||
|
addArrangedSubview(itemView)
|
||||||
|
}
|
||||||
|
self.axis = .horizontal
|
||||||
|
self.distribution = .fillEqually
|
||||||
|
self.spacing = MediaAlbumView.kSpacingPts
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
// x
|
||||||
|
// X x
|
||||||
|
// Big on left, 2 small on right.
|
||||||
|
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
|
||||||
|
let bigImageSize = smallImageSize * 2 + MediaAlbumView.kSpacingPts
|
||||||
|
|
||||||
moreItemsView = lastView
|
guard let leftItemView = itemViews.first else {
|
||||||
|
owsFailDebug("Missing view")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
autoSet(viewSize: bigImageSize, ofViews: [leftItemView])
|
||||||
|
addArrangedSubview(leftItemView)
|
||||||
|
|
||||||
let tintView = UIView()
|
let rightViews = Array(itemViews[1..<3])
|
||||||
tintView.backgroundColor = UIColor(white: 0, alpha: 0.4)
|
addArrangedSubview(
|
||||||
lastView.addSubview(tintView)
|
newRow(
|
||||||
tintView.autoPinEdgesToSuperviewEdges()
|
rowViews: rightViews,
|
||||||
|
axis: .vertical,
|
||||||
|
viewSize: smallImageSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.axis = .horizontal
|
||||||
|
self.spacing = MediaAlbumView.kSpacingPts
|
||||||
|
|
||||||
|
case 4:
|
||||||
|
// X X
|
||||||
|
// X X
|
||||||
|
// Square
|
||||||
|
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
||||||
|
|
||||||
let moreCount = max(1, items.count - MediaAlbumView.kMaxItems)
|
let topViews = Array(itemViews[0..<2])
|
||||||
let moreCountText = OWSFormat.formatInt(Int32(moreCount))
|
addArrangedSubview(
|
||||||
let moreText = String(format: NSLocalizedString("MEDIA_GALLERY_MORE_ITEMS_FORMAT",
|
newRow(
|
||||||
comment: "Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}."), moreCountText)
|
rowViews: topViews,
|
||||||
let moreLabel = UILabel()
|
axis: .horizontal,
|
||||||
moreLabel.text = moreText
|
viewSize: imageSize
|
||||||
moreLabel.textColor = UIColor.ows_white
|
)
|
||||||
// We don't want to use dynamic text here.
|
)
|
||||||
moreLabel.font = UIFont.systemFont(ofSize: 24)
|
|
||||||
lastView.addSubview(moreLabel)
|
let bottomViews = Array(itemViews[2..<4])
|
||||||
moreLabel.autoCenterInSuperview()
|
addArrangedSubview(
|
||||||
}
|
newRow(
|
||||||
|
rowViews: bottomViews,
|
||||||
|
axis: .horizontal,
|
||||||
|
viewSize: imageSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.axis = .vertical
|
||||||
|
self.spacing = MediaAlbumView.kSpacingPts
|
||||||
|
|
||||||
|
default:
|
||||||
|
// X X
|
||||||
|
// xxx
|
||||||
|
// 2 big on top, 3 small on bottom.
|
||||||
|
let bigImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
||||||
|
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
|
||||||
|
|
||||||
|
let topViews = Array(itemViews[0..<2])
|
||||||
|
addArrangedSubview(
|
||||||
|
newRow(
|
||||||
|
rowViews: topViews,
|
||||||
|
axis: .horizontal,
|
||||||
|
viewSize: bigImageSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let bottomViews = Array(itemViews[2..<5])
|
||||||
|
addArrangedSubview(
|
||||||
|
newRow(
|
||||||
|
rowViews: bottomViews,
|
||||||
|
axis: .horizontal,
|
||||||
|
viewSize: smallImageSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.axis = .vertical
|
||||||
|
self.spacing = MediaAlbumView.kSpacingPts
|
||||||
|
|
||||||
|
if items.count > MediaAlbumView.kMaxItems {
|
||||||
|
guard let lastView = bottomViews.last else {
|
||||||
|
owsFailDebug("Missing lastView")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
moreItemsView = lastView
|
||||||
|
|
||||||
|
let tintView = UIView()
|
||||||
|
tintView.backgroundColor = UIColor(white: 0, alpha: 0.4)
|
||||||
|
lastView.addSubview(tintView)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
let moreLabel = UILabel()
|
||||||
|
moreLabel.text = moreText
|
||||||
|
moreLabel.textColor = UIColor.ows_white
|
||||||
|
// We don't want to use dynamic text here.
|
||||||
|
moreLabel.font = UIFont.systemFont(ofSize: 24)
|
||||||
|
lastView.addSubview(moreLabel)
|
||||||
|
moreLabel.autoCenterInSuperview()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for itemView in itemViews {
|
for itemView in itemViews {
|
||||||
|
@ -181,43 +205,47 @@ public class MediaAlbumView: UIStackView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func autoSet(viewSize: CGFloat,
|
private func autoSet(
|
||||||
ofViews views: [MediaView]) {
|
viewSize: CGFloat,
|
||||||
|
ofViews views: [MediaView]
|
||||||
|
) {
|
||||||
for itemView in views {
|
for itemView in views {
|
||||||
itemView.autoSetDimensions(to: CGSize(width: viewSize, height: viewSize))
|
itemView.autoSetDimensions(to: CGSize(width: viewSize, height: viewSize))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func newRow(rowViews: [MediaView],
|
private func newRow(
|
||||||
axis: NSLayoutConstraint.Axis,
|
rowViews: [MediaView],
|
||||||
viewSize: CGFloat) -> UIStackView {
|
axis: NSLayoutConstraint.Axis,
|
||||||
|
viewSize: CGFloat
|
||||||
|
) -> UIStackView {
|
||||||
autoSet(viewSize: viewSize, ofViews: rowViews)
|
autoSet(viewSize: viewSize, ofViews: rowViews)
|
||||||
return newRow(rowViews: rowViews, axis: axis)
|
return newRow(rowViews: rowViews, axis: axis)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func newRow(rowViews: [MediaView],
|
private func newRow(
|
||||||
axis: NSLayoutConstraint.Axis) -> UIStackView {
|
rowViews: [MediaView],
|
||||||
|
axis: NSLayoutConstraint.Axis
|
||||||
|
) -> UIStackView {
|
||||||
let stackView = UIStackView(arrangedSubviews: rowViews)
|
let stackView = UIStackView(arrangedSubviews: rowViews)
|
||||||
stackView.axis = axis
|
stackView.axis = axis
|
||||||
stackView.spacing = MediaAlbumView.kSpacingPts
|
stackView.spacing = MediaAlbumView.kSpacingPts
|
||||||
return stackView
|
return stackView
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
|
||||||
public func loadMedia() {
|
public func loadMedia() {
|
||||||
for itemView in itemViews {
|
for itemView in itemViews {
|
||||||
itemView.loadMedia()
|
itemView.loadMedia()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
|
||||||
public func unloadMedia() {
|
public func unloadMedia() {
|
||||||
for itemView in itemViews {
|
for itemView in itemViews {
|
||||||
itemView.unloadMedia()
|
itemView.unloadMedia()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class func itemsToDisplay(forItems items: [ConversationMediaAlbumItem]) -> [ConversationMediaAlbumItem] {
|
private class func itemsToDisplay(forItems items: [Attachment]) -> [Attachment] {
|
||||||
// TODO: Unless design changes, we want to display
|
// TODO: Unless design changes, we want to display
|
||||||
// items which are still downloading and invalid
|
// items which are still downloading and invalid
|
||||||
// items.
|
// items.
|
||||||
|
@ -228,43 +256,47 @@ public class MediaAlbumView: UIStackView {
|
||||||
return validItems
|
return validItems
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
public class func layoutSize(
|
||||||
public class func layoutSize(forMaxMessageWidth maxMessageWidth: CGFloat,
|
forMaxMessageWidth maxMessageWidth: CGFloat,
|
||||||
items: [ConversationMediaAlbumItem]) -> CGSize {
|
items: [Attachment]
|
||||||
|
) -> CGSize {
|
||||||
let itemCount = itemsToDisplay(forItems: items).count
|
let itemCount = itemsToDisplay(forItems: items).count
|
||||||
|
|
||||||
switch itemCount {
|
switch itemCount {
|
||||||
case 0, 1, 4:
|
case 0, 1, 4:
|
||||||
// X
|
// X
|
||||||
//
|
//
|
||||||
// or
|
// or
|
||||||
//
|
//
|
||||||
// XX
|
// XX
|
||||||
// XX
|
// XX
|
||||||
// Square
|
// Square
|
||||||
return CGSize(width: maxMessageWidth, height: maxMessageWidth)
|
return CGSize(width: maxMessageWidth, height: maxMessageWidth)
|
||||||
case 2:
|
|
||||||
// X X
|
case 2:
|
||||||
// side-by-side.
|
// X X
|
||||||
let imageSize = (maxMessageWidth - kSpacingPts) / 2
|
// side-by-side.
|
||||||
return CGSize(width: maxMessageWidth, height: imageSize)
|
let imageSize = (maxMessageWidth - kSpacingPts) / 2
|
||||||
case 3:
|
return CGSize(width: maxMessageWidth, height: imageSize)
|
||||||
// x
|
|
||||||
// X x
|
case 3:
|
||||||
// Big on left, 2 small on right.
|
// x
|
||||||
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
// X x
|
||||||
let bigImageSize = smallImageSize * 2 + kSpacingPts
|
// Big on left, 2 small on right.
|
||||||
return CGSize(width: maxMessageWidth, height: bigImageSize)
|
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
||||||
default:
|
let bigImageSize = smallImageSize * 2 + kSpacingPts
|
||||||
// X X
|
return CGSize(width: maxMessageWidth, height: bigImageSize)
|
||||||
// xxx
|
|
||||||
// 2 big on top, 3 small on bottom.
|
default:
|
||||||
let bigImageSize = (maxMessageWidth - kSpacingPts) / 2
|
// X X
|
||||||
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
// xxx
|
||||||
return CGSize(width: maxMessageWidth, height: bigImageSize + smallImageSize + kSpacingPts)
|
// 2 big on top, 3 small on bottom.
|
||||||
|
let bigImageSize = (maxMessageWidth - kSpacingPts) / 2
|
||||||
|
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
||||||
|
return CGSize(width: maxMessageWidth, height: bigImageSize + smallImageSize + kSpacingPts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
|
||||||
public func mediaView(forLocation location: CGPoint) -> MediaView? {
|
public func mediaView(forLocation location: CGPoint) -> MediaView? {
|
||||||
var bestMediaView: MediaView?
|
var bestMediaView: MediaView?
|
||||||
var bestDistance: CGFloat = 0
|
var bestDistance: CGFloat = 0
|
||||||
|
@ -280,7 +312,6 @@ public class MediaAlbumView: UIStackView {
|
||||||
return bestMediaView
|
return bestMediaView
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
|
||||||
public func isMoreItemsView(mediaView: MediaView) -> Bool {
|
public func isMoreItemsView(mediaView: MediaView) -> Bool {
|
||||||
return moreItemsView == mediaView
|
return moreItemsView == mediaView
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class MediaPlaceholderView : UIView {
|
import UIKit
|
||||||
private let viewItem: ConversationViewItem
|
import SessionMessagingKit
|
||||||
private let textColor: UIColor
|
|
||||||
|
final class MediaPlaceholderView: UIView {
|
||||||
// MARK: Settings
|
|
||||||
private static let iconSize: CGFloat = 24
|
private static let iconSize: CGFloat = 24
|
||||||
private static let iconImageViewSize: CGFloat = 40
|
private static let iconImageViewSize: CGFloat = 40
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
|
||||||
self.viewItem = viewItem
|
init(cellViewModel: MessageViewModel, textColor: UIColor) {
|
||||||
self.textColor = textColor
|
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
setUpViewHierarchy()
|
|
||||||
|
setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
|
@ -23,32 +23,47 @@ final class MediaPlaceholderView : UIView {
|
||||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy(
|
||||||
|
cellViewModel: MessageViewModel,
|
||||||
|
textColor: UIColor
|
||||||
|
) {
|
||||||
let (iconName, attachmentDescription): (String, String) = {
|
let (iconName, attachmentDescription): (String, String) = {
|
||||||
guard let message = viewItem.interaction as? TSIncomingMessage else { return ("actionsheet_document_black", "file") } // Should never occur
|
guard
|
||||||
var attachments: [TSAttachment] = []
|
cellViewModel.variant == .standardIncoming,
|
||||||
Storage.read { transaction in
|
let attachment: Attachment = cellViewModel.attachments?.first
|
||||||
attachments = message.attachments(with: transaction)
|
else {
|
||||||
|
return ("actionsheet_document_black", "file") // Should never occur
|
||||||
}
|
}
|
||||||
guard let contentType = attachments.first?.contentType else { return ("actionsheet_document_black", "file") } // Should never occur
|
|
||||||
if MIMETypeUtil.isAudio(contentType) { return ("attachment_audio", "audio") }
|
if attachment.isAudio { return ("attachment_audio", "audio") }
|
||||||
if MIMETypeUtil.isImage(contentType) || MIMETypeUtil.isVideo(contentType) { return ("actionsheet_camera_roll_black", "media") }
|
if attachment.isImage || attachment.isVideo { return ("actionsheet_camera_roll_black", "media") }
|
||||||
|
|
||||||
return ("actionsheet_document_black", "file")
|
return ("actionsheet_document_black", "file")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Image view
|
// Image view
|
||||||
let iconSize = MediaPlaceholderView.iconSize
|
let imageView = UIImageView(
|
||||||
let icon = UIImage(named: iconName)?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
image: UIImage(named: iconName)?
|
||||||
let imageView = UIImageView(image: icon)
|
.resizedImage(
|
||||||
|
to: CGSize(
|
||||||
|
width: MediaPlaceholderView.iconSize,
|
||||||
|
height: MediaPlaceholderView.iconSize
|
||||||
|
)
|
||||||
|
)?
|
||||||
|
.withRenderingMode(.alwaysTemplate)
|
||||||
|
)
|
||||||
|
imageView.tintColor = textColor
|
||||||
imageView.contentMode = .center
|
imageView.contentMode = .center
|
||||||
let iconImageViewSize = MediaPlaceholderView.iconImageViewSize
|
imageView.set(.width, to: MediaPlaceholderView.iconImageViewSize)
|
||||||
imageView.set(.width, to: iconImageViewSize)
|
imageView.set(.height, to: MediaPlaceholderView.iconImageViewSize)
|
||||||
imageView.set(.height, to: iconImageViewSize)
|
|
||||||
// Body label
|
// Body label
|
||||||
let titleLabel = UILabel()
|
let titleLabel = UILabel()
|
||||||
titleLabel.lineBreakMode = .byTruncatingTail
|
titleLabel.lineBreakMode = .byTruncatingTail
|
||||||
titleLabel.text = "Tap to download \(attachmentDescription)"
|
titleLabel.text = "Tap to download \(attachmentDescription)"
|
||||||
titleLabel.textColor = textColor
|
titleLabel.textColor = textColor
|
||||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||||
|
|
||||||
// Stack view
|
// Stack view
|
||||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||||
stackView.axis = .horizontal
|
stackView.axis = .horizontal
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
//
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import UIKit
|
||||||
|
import YYImage
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
@objc(OWSMediaView)
|
|
||||||
public class MediaView: UIView {
|
public class MediaView: UIView {
|
||||||
|
static let contentMode: UIView.ContentMode = .scaleAspectFill
|
||||||
|
|
||||||
private enum MediaError {
|
private enum MediaError {
|
||||||
case missing
|
case missing
|
||||||
case invalid
|
case invalid
|
||||||
|
@ -17,8 +17,7 @@ public class MediaView: UIView {
|
||||||
// MARK: -
|
// MARK: -
|
||||||
|
|
||||||
private let mediaCache: NSCache<NSString, AnyObject>
|
private let mediaCache: NSCache<NSString, AnyObject>
|
||||||
@objc
|
public let attachment: Attachment
|
||||||
public let attachment: TSAttachment
|
|
||||||
private let isOutgoing: Bool
|
private let isOutgoing: Bool
|
||||||
private let maxMessageWidth: CGFloat
|
private let maxMessageWidth: CGFloat
|
||||||
private var loadBlock: (() -> Void)?
|
private var loadBlock: (() -> Void)?
|
||||||
|
@ -42,50 +41,16 @@ public class MediaView: UIView {
|
||||||
case failed
|
case failed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thread-safe access to load state.
|
private let loadState: Atomic<LoadState> = Atomic(.unloaded)
|
||||||
//
|
|
||||||
// We use a "box" class so that we can capture a reference
|
|
||||||
// to this box (rather than self) and a) safely access
|
|
||||||
// if off the main thread b) not prevent deallocation of
|
|
||||||
// self.
|
|
||||||
private class ThreadSafeLoadState {
|
|
||||||
private var value: LoadState
|
|
||||||
|
|
||||||
required init(_ value: LoadState) {
|
|
||||||
self.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
func get() -> LoadState {
|
|
||||||
objc_sync_enter(self)
|
|
||||||
let valueCopy = value
|
|
||||||
objc_sync_exit(self)
|
|
||||||
return valueCopy
|
|
||||||
}
|
|
||||||
|
|
||||||
func set(_ newValue: LoadState) {
|
|
||||||
objc_sync_enter(self)
|
|
||||||
value = newValue
|
|
||||||
objc_sync_exit(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private let threadSafeLoadState = ThreadSafeLoadState(.unloaded)
|
|
||||||
// Convenience accessors.
|
|
||||||
private var loadState: LoadState {
|
|
||||||
get {
|
|
||||||
return threadSafeLoadState.get()
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
threadSafeLoadState.set(newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Initializers
|
// MARK: - Initializers
|
||||||
|
|
||||||
@objc
|
public required init(
|
||||||
public required init(mediaCache: NSCache<NSString, AnyObject>,
|
mediaCache: NSCache<NSString, AnyObject>,
|
||||||
attachment: TSAttachment,
|
attachment: Attachment,
|
||||||
isOutgoing: Bool,
|
isOutgoing: Bool,
|
||||||
maxMessageWidth: CGFloat) {
|
maxMessageWidth: CGFloat
|
||||||
|
) {
|
||||||
self.mediaCache = mediaCache
|
self.mediaCache = mediaCache
|
||||||
self.attachment = attachment
|
self.attachment = attachment
|
||||||
self.isOutgoing = isOutgoing
|
self.isOutgoing = isOutgoing
|
||||||
|
@ -105,9 +70,7 @@ public class MediaView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
AssertIsOnMainThread()
|
loadState.mutate { $0 = .unloaded }
|
||||||
|
|
||||||
loadState = .unloaded
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: -
|
// MARK: -
|
||||||
|
@ -115,41 +78,45 @@ public class MediaView: UIView {
|
||||||
private func createContents() {
|
private func createContents() {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
guard let attachmentStream = attachment as? TSAttachmentStream else {
|
guard attachment.state != .pendingDownload && attachment.state != .downloading else {
|
||||||
addDownloadProgressIfNecessary()
|
addDownloadProgressIfNecessary()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard !isFailedDownload else {
|
guard attachment.state != .failedDownload else {
|
||||||
configure(forError: .failed)
|
configure(forError: .failed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if attachmentStream.isAnimated {
|
guard attachment.isValid else {
|
||||||
configureForAnimatedImage(attachmentStream: attachmentStream)
|
configure(forError: .invalid)
|
||||||
} else if attachmentStream.isImage {
|
return
|
||||||
configureForStillImage(attachmentStream: attachmentStream)
|
}
|
||||||
} else if attachmentStream.isVideo {
|
|
||||||
configureForVideo(attachmentStream: attachmentStream)
|
if attachment.isAnimated {
|
||||||
} else {
|
configureForAnimatedImage(attachment: attachment)
|
||||||
|
}
|
||||||
|
else if attachment.isImage {
|
||||||
|
configureForStillImage(attachment: attachment)
|
||||||
|
}
|
||||||
|
else if attachment.isVideo {
|
||||||
|
configureForVideo(attachment: attachment)
|
||||||
|
}
|
||||||
|
else {
|
||||||
owsFailDebug("Attachment has unexpected type.")
|
owsFailDebug("Attachment has unexpected type.")
|
||||||
configure(forError: .invalid)
|
configure(forError: .invalid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addDownloadProgressIfNecessary() {
|
private func addDownloadProgressIfNecessary() {
|
||||||
guard !isFailedDownload else {
|
guard attachment.state != .failedDownload else {
|
||||||
configure(forError: .failed)
|
configure(forError: .failed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let attachmentPointer = attachment as? TSAttachmentPointer else {
|
guard attachment.state != .uploading && attachment.state != .uploaded else {
|
||||||
owsFailDebug("Attachment has unexpected type.")
|
|
||||||
configure(forError: .invalid)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard attachmentPointer.pointerType == .incoming else {
|
|
||||||
// TODO: Show "restoring" indicator and possibly progress.
|
// TODO: Show "restoring" indicator and possibly progress.
|
||||||
configure(forError: .missing)
|
configure(forError: .missing)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
||||||
let loader = MediaLoaderView()
|
let loader = MediaLoaderView()
|
||||||
addSubview(loader)
|
addSubview(loader)
|
||||||
|
@ -158,65 +125,71 @@ public class MediaView: UIView {
|
||||||
|
|
||||||
private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool {
|
private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool {
|
||||||
guard isOutgoing else { return false }
|
guard isOutgoing else { return false }
|
||||||
guard let attachmentStream = attachment as? TSAttachmentStream else { return false }
|
guard attachment.state != .failedUpload else {
|
||||||
guard !attachmentStream.isUploaded else { return false }
|
configure(forError: .failed)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this message was uploaded on a different device it'll now be seen as 'downloaded' (but
|
||||||
|
// will still be outgoing - we don't want to show a loading indicator in this case)
|
||||||
|
guard attachment.state != .uploaded && attachment.state != .downloaded else { return false }
|
||||||
|
|
||||||
let loader = MediaLoaderView()
|
let loader = MediaLoaderView()
|
||||||
addSubview(loader)
|
addSubview(loader)
|
||||||
loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self)
|
loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureForAnimatedImage(attachmentStream: TSAttachmentStream) {
|
private func configureForAnimatedImage(attachment: Attachment) {
|
||||||
guard let cacheKey = attachmentStream.uniqueId else {
|
let animatedImageView: YYAnimatedImageView = YYAnimatedImageView()
|
||||||
owsFailDebug("Attachment stream missing unique ID.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let animatedImageView = YYAnimatedImageView()
|
|
||||||
// We need to specify a contentMode since the size of the image
|
// We need to specify a contentMode since the size of the image
|
||||||
// might not match the aspect ratio of the view.
|
// might not match the aspect ratio of the view.
|
||||||
animatedImageView.contentMode = .scaleAspectFill
|
animatedImageView.contentMode = MediaView.contentMode
|
||||||
// Use trilinear filters for better scaling quality at
|
// Use trilinear filters for better scaling quality at
|
||||||
// some performance cost.
|
// some performance cost.
|
||||||
animatedImageView.layer.minificationFilter = .trilinear
|
animatedImageView.layer.minificationFilter = .trilinear
|
||||||
animatedImageView.layer.magnificationFilter = .trilinear
|
animatedImageView.layer.magnificationFilter = .trilinear
|
||||||
animatedImageView.backgroundColor = Colors.unimportant
|
animatedImageView.backgroundColor = Colors.unimportant
|
||||||
|
animatedImageView.isHidden = !attachment.isValid
|
||||||
addSubview(animatedImageView)
|
addSubview(animatedImageView)
|
||||||
animatedImageView.autoPinEdgesToSuperviewEdges()
|
animatedImageView.autoPinEdgesToSuperviewEdges()
|
||||||
_ = addUploadProgressIfNecessary(animatedImageView)
|
_ = addUploadProgressIfNecessary(animatedImageView)
|
||||||
|
|
||||||
loadBlock = { [weak self] in
|
loadBlock = { [weak self] in
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
guard let strongSelf = self else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if animatedImageView.image != nil {
|
if animatedImageView.image != nil {
|
||||||
owsFailDebug("Unexpectedly already loaded.")
|
owsFailDebug("Unexpectedly already loaded.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
strongSelf.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
|
self?.tryToLoadMedia(
|
||||||
guard attachmentStream.isValidImage else {
|
loadMediaBlock: { applyMediaBlock in
|
||||||
Logger.warn("Ignoring invalid attachment.")
|
guard attachment.isValid else {
|
||||||
return nil
|
self?.configure(forError: .invalid)
|
||||||
}
|
return
|
||||||
guard let filePath = attachmentStream.originalFilePath else {
|
}
|
||||||
owsFailDebug("Attachment stream missing original file path.")
|
guard let filePath: String = attachment.originalFilePath else {
|
||||||
return nil
|
owsFailDebug("Attachment stream missing original file path.")
|
||||||
}
|
self?.configure(forError: .invalid)
|
||||||
let animatedImage = YYImage(contentsOfFile: filePath)
|
return
|
||||||
return animatedImage
|
}
|
||||||
},
|
|
||||||
applyMediaBlock: { (media) in
|
applyMediaBlock(YYImage(contentsOfFile: filePath))
|
||||||
AssertIsOnMainThread()
|
},
|
||||||
|
applyMediaBlock: { media in
|
||||||
guard let image = media as? YYImage else {
|
AssertIsOnMainThread()
|
||||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
|
||||||
return
|
guard let image: YYImage = media as? YYImage else {
|
||||||
}
|
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||||
animatedImageView.image = image
|
self?.configure(forError: .invalid)
|
||||||
},
|
return
|
||||||
cacheKey: cacheKey)
|
}
|
||||||
|
// FIXME: Animated images flicker when reloading the cells (even though they are in the cache)
|
||||||
|
animatedImageView.image = image
|
||||||
|
},
|
||||||
|
cacheKey: attachment.id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
unloadBlock = {
|
unloadBlock = {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
@ -225,23 +198,21 @@ public class MediaView: UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureForStillImage(attachmentStream: TSAttachmentStream) {
|
private func configureForStillImage(attachment: Attachment) {
|
||||||
guard let cacheKey = attachmentStream.uniqueId else {
|
|
||||||
owsFailDebug("Attachment stream missing unique ID.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let stillImageView = UIImageView()
|
let stillImageView = UIImageView()
|
||||||
// We need to specify a contentMode since the size of the image
|
// We need to specify a contentMode since the size of the image
|
||||||
// might not match the aspect ratio of the view.
|
// might not match the aspect ratio of the view.
|
||||||
stillImageView.contentMode = .scaleAspectFill
|
stillImageView.contentMode = MediaView.contentMode
|
||||||
// Use trilinear filters for better scaling quality at
|
// Use trilinear filters for better scaling quality at
|
||||||
// some performance cost.
|
// some performance cost.
|
||||||
stillImageView.layer.minificationFilter = .trilinear
|
stillImageView.layer.minificationFilter = .trilinear
|
||||||
stillImageView.layer.magnificationFilter = .trilinear
|
stillImageView.layer.magnificationFilter = .trilinear
|
||||||
stillImageView.backgroundColor = Colors.unimportant
|
stillImageView.backgroundColor = Colors.unimportant
|
||||||
|
stillImageView.isHidden = !attachment.isValid
|
||||||
addSubview(stillImageView)
|
addSubview(stillImageView)
|
||||||
stillImageView.autoPinEdgesToSuperviewEdges()
|
stillImageView.autoPinEdgesToSuperviewEdges()
|
||||||
_ = addUploadProgressIfNecessary(stillImageView)
|
_ = addUploadProgressIfNecessary(stillImageView)
|
||||||
|
|
||||||
loadBlock = { [weak self] in
|
loadBlock = { [weak self] in
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
|
@ -249,29 +220,35 @@ public class MediaView: UIView {
|
||||||
owsFailDebug("Unexpectedly already loaded.")
|
owsFailDebug("Unexpectedly already loaded.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
|
self?.tryToLoadMedia(
|
||||||
guard attachmentStream.isValidImage else {
|
loadMediaBlock: { applyMediaBlock in
|
||||||
Logger.warn("Ignoring invalid attachment.")
|
guard attachment.isValid else {
|
||||||
return nil
|
self?.configure(forError: .invalid)
|
||||||
}
|
return
|
||||||
return attachmentStream.thumbnailImageLarge(success: { (image) in
|
}
|
||||||
|
|
||||||
|
attachment.thumbnail(
|
||||||
|
size: .large,
|
||||||
|
success: { image, _ in applyMediaBlock(image) },
|
||||||
|
failure: {
|
||||||
|
Logger.error("Could not load thumbnail")
|
||||||
|
self?.configure(forError: .invalid)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
applyMediaBlock: { media in
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
|
guard let image: UIImage = media as? UIImage else {
|
||||||
|
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||||
|
self?.configure(forError: .invalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
stillImageView.image = image
|
stillImageView.image = image
|
||||||
}, failure: {
|
},
|
||||||
Logger.error("Could not load thumbnail")
|
cacheKey: attachment.id
|
||||||
})
|
)
|
||||||
},
|
|
||||||
applyMediaBlock: { (media) in
|
|
||||||
AssertIsOnMainThread()
|
|
||||||
|
|
||||||
guard let image = media as? UIImage else {
|
|
||||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stillImageView.image = image
|
|
||||||
},
|
|
||||||
cacheKey: cacheKey)
|
|
||||||
}
|
}
|
||||||
unloadBlock = {
|
unloadBlock = {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
@ -280,20 +257,17 @@ public class MediaView: UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureForVideo(attachmentStream: TSAttachmentStream) {
|
private func configureForVideo(attachment: Attachment) {
|
||||||
guard let cacheKey = attachmentStream.uniqueId else {
|
|
||||||
owsFailDebug("Attachment stream missing unique ID.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let stillImageView = UIImageView()
|
let stillImageView = UIImageView()
|
||||||
// We need to specify a contentMode since the size of the image
|
// We need to specify a contentMode since the size of the image
|
||||||
// might not match the aspect ratio of the view.
|
// might not match the aspect ratio of the view.
|
||||||
stillImageView.contentMode = .scaleAspectFill
|
stillImageView.contentMode = MediaView.contentMode
|
||||||
// Use trilinear filters for better scaling quality at
|
// Use trilinear filters for better scaling quality at
|
||||||
// some performance cost.
|
// some performance cost.
|
||||||
stillImageView.layer.minificationFilter = .trilinear
|
stillImageView.layer.minificationFilter = .trilinear
|
||||||
stillImageView.layer.magnificationFilter = .trilinear
|
stillImageView.layer.magnificationFilter = .trilinear
|
||||||
stillImageView.backgroundColor = Colors.unimportant
|
stillImageView.backgroundColor = Colors.unimportant
|
||||||
|
stillImageView.isHidden = !attachment.isValid
|
||||||
|
|
||||||
addSubview(stillImageView)
|
addSubview(stillImageView)
|
||||||
stillImageView.autoPinEdgesToSuperviewEdges()
|
stillImageView.autoPinEdgesToSuperviewEdges()
|
||||||
|
@ -314,29 +288,35 @@ public class MediaView: UIView {
|
||||||
owsFailDebug("Unexpectedly already loaded.")
|
owsFailDebug("Unexpectedly already loaded.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
|
self?.tryToLoadMedia(
|
||||||
guard attachmentStream.isValidVideo else {
|
loadMediaBlock: { applyMediaBlock in
|
||||||
Logger.warn("Ignoring invalid attachment.")
|
guard attachment.isValid else {
|
||||||
return nil
|
self?.configure(forError: .invalid)
|
||||||
}
|
return
|
||||||
return attachmentStream.thumbnailImageMedium(success: { (image) in
|
}
|
||||||
|
|
||||||
|
attachment.thumbnail(
|
||||||
|
size: .medium,
|
||||||
|
success: { image, _ in applyMediaBlock(image) },
|
||||||
|
failure: {
|
||||||
|
Logger.error("Could not load thumbnail")
|
||||||
|
self?.configure(forError: .invalid)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
applyMediaBlock: { media in
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
|
guard let image: UIImage = media as? UIImage else {
|
||||||
|
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||||
|
self?.configure(forError: .invalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
stillImageView.image = image
|
stillImageView.image = image
|
||||||
}, failure: {
|
},
|
||||||
Logger.error("Could not load thumbnail")
|
cacheKey: attachment.id
|
||||||
})
|
)
|
||||||
},
|
|
||||||
applyMediaBlock: { (media) in
|
|
||||||
AssertIsOnMainThread()
|
|
||||||
|
|
||||||
guard let image = media as? UIImage else {
|
|
||||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stillImageView.image = image
|
|
||||||
},
|
|
||||||
cacheKey: cacheKey)
|
|
||||||
}
|
}
|
||||||
unloadBlock = {
|
unloadBlock = {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
@ -345,100 +325,115 @@ public class MediaView: UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isFailedDownload: Bool {
|
|
||||||
guard let attachmentPointer = attachment as? TSAttachmentPointer else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return attachmentPointer.state == .failed
|
|
||||||
}
|
|
||||||
|
|
||||||
private func configure(forError error: MediaError) {
|
private func configure(forError error: MediaError) {
|
||||||
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
// When there is a failure in the 'loadMediaBlock' closure this can be called
|
||||||
let icon: UIImage
|
// on a background thread - rather than dispatching in every 'loadMediaBlock'
|
||||||
switch error {
|
// usage we just do so here
|
||||||
case .failed:
|
guard Thread.isMainThread else {
|
||||||
guard let asset = UIImage(named: "media_retry") else {
|
DispatchQueue.main.async {
|
||||||
owsFailDebug("Missing image")
|
self.configure(forError: error)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
icon = asset
|
|
||||||
case .invalid:
|
|
||||||
guard let asset = UIImage(named: "media_invalid") else {
|
|
||||||
owsFailDebug("Missing image")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
icon = asset
|
|
||||||
case .missing:
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let icon: UIImage
|
||||||
|
|
||||||
|
switch error {
|
||||||
|
case .failed:
|
||||||
|
guard let asset = UIImage(named: "media_retry") else {
|
||||||
|
owsFailDebug("Missing image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
icon = asset
|
||||||
|
|
||||||
|
case .invalid:
|
||||||
|
guard let asset = UIImage(named: "media_invalid") else {
|
||||||
|
owsFailDebug("Missing image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
icon = asset
|
||||||
|
|
||||||
|
case .missing: return
|
||||||
|
}
|
||||||
|
|
||||||
|
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
||||||
|
|
||||||
|
// For failed ougoing messages add an overlay to make the icon more visible
|
||||||
|
if isOutgoing {
|
||||||
|
let attachmentOverlayView: UIView = UIView()
|
||||||
|
attachmentOverlayView.backgroundColor = Colors.navigationBarBackground
|
||||||
|
.withAlphaComponent(Values.lowOpacity)
|
||||||
|
addSubview(attachmentOverlayView)
|
||||||
|
attachmentOverlayView.pin(to: self)
|
||||||
|
}
|
||||||
|
|
||||||
let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
|
let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
|
||||||
iconView.tintColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
iconView.tintColor = Colors.text
|
||||||
|
.withAlphaComponent(Values.mediumOpacity)
|
||||||
addSubview(iconView)
|
addSubview(iconView)
|
||||||
iconView.autoCenterInSuperview()
|
iconView.autoCenterInSuperview()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tryToLoadMedia(loadMediaBlock: @escaping () -> AnyObject?,
|
private func tryToLoadMedia(
|
||||||
applyMediaBlock: @escaping (AnyObject) -> Void,
|
loadMediaBlock: @escaping (@escaping (AnyObject?) -> Void) -> Void,
|
||||||
cacheKey: String) {
|
applyMediaBlock: @escaping (AnyObject) -> Void,
|
||||||
AssertIsOnMainThread()
|
cacheKey: String
|
||||||
|
) {
|
||||||
// It's critical that we update loadState once
|
// It's critical that we update loadState once
|
||||||
// our load attempt is complete.
|
// our load attempt is complete.
|
||||||
let loadCompletion: (AnyObject?) -> Void = { [weak self] (possibleMedia) in
|
let loadCompletion: (AnyObject?) -> Void = { [weak self] possibleMedia in
|
||||||
AssertIsOnMainThread()
|
guard self?.loadState.wrappedValue == .loading else {
|
||||||
|
|
||||||
guard let strongSelf = self else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard strongSelf.loadState == .loading else {
|
|
||||||
Logger.verbose("Skipping obsolete load.")
|
Logger.verbose("Skipping obsolete load.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let media = possibleMedia else {
|
guard let media: AnyObject = possibleMedia else {
|
||||||
strongSelf.loadState = .failed
|
self?.loadState.mutate { $0 = .failed }
|
||||||
// TODO:
|
// TODO:
|
||||||
// [self showAttachmentErrorViewWithMediaView:mediaView];
|
// [self showAttachmentErrorViewWithMediaView:mediaView];
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
applyMediaBlock(media)
|
applyMediaBlock(media)
|
||||||
|
|
||||||
strongSelf.loadState = .loaded
|
self?.mediaCache.setObject(media, forKey: cacheKey as NSString)
|
||||||
|
self?.loadState.mutate { $0 = .loaded }
|
||||||
}
|
}
|
||||||
|
|
||||||
guard loadState == .loading else {
|
guard loadState.wrappedValue == .loading else {
|
||||||
owsFailDebug("Unexpected load state: \(loadState)")
|
owsFailDebug("Unexpected load state: \(loadState)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let mediaCache = self.mediaCache
|
if let media: AnyObject = self.mediaCache.object(forKey: cacheKey as NSString) {
|
||||||
if let media = mediaCache.object(forKey: cacheKey as NSString) {
|
|
||||||
Logger.verbose("media cache hit")
|
Logger.verbose("media cache hit")
|
||||||
|
|
||||||
|
guard Thread.isMainThread else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
loadCompletion(media)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
loadCompletion(media)
|
loadCompletion(media)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.verbose("media cache miss")
|
Logger.verbose("media cache miss")
|
||||||
|
|
||||||
let threadSafeLoadState = self.threadSafeLoadState
|
MediaView.loadQueue.async { [weak self] in
|
||||||
MediaView.loadQueue.async {
|
guard self?.loadState.wrappedValue == .loading else {
|
||||||
guard threadSafeLoadState.get() == .loading else {
|
|
||||||
Logger.verbose("Skipping obsolete load.")
|
Logger.verbose("Skipping obsolete load.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let media = loadMediaBlock() else {
|
loadMediaBlock { media in
|
||||||
Logger.error("Failed to load media.")
|
guard Thread.isMainThread else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
DispatchQueue.main.async {
|
loadCompletion(media)
|
||||||
loadCompletion(nil)
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
mediaCache.setObject(media, forKey: cacheKey as NSString)
|
|
||||||
|
|
||||||
loadCompletion(media)
|
loadCompletion(media)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -459,32 +454,18 @@ public class MediaView: UIView {
|
||||||
// "skip rate" of obsolete loads.
|
// "skip rate" of obsolete loads.
|
||||||
private static let loadQueue = ReverseDispatchQueue(label: "org.signal.asyncMediaLoadQueue")
|
private static let loadQueue = ReverseDispatchQueue(label: "org.signal.asyncMediaLoadQueue")
|
||||||
|
|
||||||
@objc
|
|
||||||
public func loadMedia() {
|
public func loadMedia() {
|
||||||
AssertIsOnMainThread()
|
switch loadState.wrappedValue {
|
||||||
|
case .unloaded:
|
||||||
switch loadState {
|
loadState.mutate { $0 = .loading }
|
||||||
case .unloaded:
|
loadBlock?()
|
||||||
loadState = .loading
|
|
||||||
|
case .loading, .loaded, .failed: break
|
||||||
guard let loadBlock = loadBlock else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loadBlock()
|
|
||||||
case .loading, .loaded, .failed:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
|
||||||
public func unloadMedia() {
|
public func unloadMedia() {
|
||||||
AssertIsOnMainThread()
|
loadState.mutate { $0 = .unloaded }
|
||||||
|
unloadBlock?()
|
||||||
loadState = .unloaded
|
|
||||||
|
|
||||||
guard let unloadBlock = unloadBlock else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
unloadBlock()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,30 +1,24 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class OpenGroupInvitationView : UIView {
|
import UIKit
|
||||||
private let name: String
|
import SessionUIKit
|
||||||
private let rawURL: String
|
import SessionMessagingKit
|
||||||
private let textColor: UIColor
|
|
||||||
private let isOutgoing: Bool
|
final class OpenGroupInvitationView: UIView {
|
||||||
|
|
||||||
private lazy var url: String = {
|
|
||||||
if let range = rawURL.range(of: "?public_key=") {
|
|
||||||
return String(rawURL[..<range.lowerBound])
|
|
||||||
} else {
|
|
||||||
return rawURL
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// MARK: Settings
|
|
||||||
private static let iconSize: CGFloat = 24
|
private static let iconSize: CGFloat = 24
|
||||||
private static let iconImageViewSize: CGFloat = 48
|
private static let iconImageViewSize: CGFloat = 48
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
init(name: String, url: String, textColor: UIColor, isOutgoing: Bool) {
|
init(name: String, url: String, textColor: UIColor, isOutgoing: Bool) {
|
||||||
self.name = name
|
|
||||||
self.rawURL = url
|
|
||||||
self.textColor = textColor
|
|
||||||
self.isOutgoing = isOutgoing
|
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
setUpViewHierarchy()
|
|
||||||
|
setUpViewHierarchy(
|
||||||
|
name: name,
|
||||||
|
rawUrl: url,
|
||||||
|
textColor: textColor,
|
||||||
|
isOutgoing: isOutgoing
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
|
@ -35,41 +29,56 @@ final class OpenGroupInvitationView : UIView {
|
||||||
preconditionFailure("Use init(name:url:textColor:) instead.")
|
preconditionFailure("Use init(name:url:textColor:) instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy(name: String, rawUrl: String, textColor: UIColor, isOutgoing: Bool) {
|
||||||
// Title
|
// Title
|
||||||
let titleLabel = UILabel()
|
let titleLabel = UILabel()
|
||||||
titleLabel.lineBreakMode = .byTruncatingTail
|
titleLabel.lineBreakMode = .byTruncatingTail
|
||||||
titleLabel.text = name
|
titleLabel.text = name
|
||||||
titleLabel.textColor = textColor
|
titleLabel.textColor = textColor
|
||||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||||
|
|
||||||
// Subtitle
|
// Subtitle
|
||||||
let subtitleLabel = UILabel()
|
let subtitleLabel = UILabel()
|
||||||
subtitleLabel.lineBreakMode = .byTruncatingTail
|
subtitleLabel.lineBreakMode = .byTruncatingTail
|
||||||
subtitleLabel.text = NSLocalizedString("view_open_group_invitation_description", comment: "")
|
subtitleLabel.text = NSLocalizedString("view_open_group_invitation_description", comment: "")
|
||||||
subtitleLabel.textColor = textColor
|
subtitleLabel.textColor = textColor
|
||||||
subtitleLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
subtitleLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||||
|
|
||||||
// URL
|
// URL
|
||||||
let urlLabel = UILabel()
|
let urlLabel = UILabel()
|
||||||
urlLabel.lineBreakMode = .byCharWrapping
|
urlLabel.lineBreakMode = .byCharWrapping
|
||||||
urlLabel.text = url
|
urlLabel.text = {
|
||||||
|
if let range = rawUrl.range(of: "?public_key=") {
|
||||||
|
return String(rawUrl[..<range.lowerBound])
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawUrl
|
||||||
|
}()
|
||||||
urlLabel.textColor = textColor
|
urlLabel.textColor = textColor
|
||||||
urlLabel.numberOfLines = 0
|
urlLabel.numberOfLines = 0
|
||||||
urlLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
urlLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||||
|
|
||||||
// Label stack
|
// Label stack
|
||||||
let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, UIView.vSpacer(2), subtitleLabel, UIView.vSpacer(4), urlLabel ])
|
let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, UIView.vSpacer(2), subtitleLabel, UIView.vSpacer(4), urlLabel ])
|
||||||
labelStackView.axis = .vertical
|
labelStackView.axis = .vertical
|
||||||
|
|
||||||
// Icon
|
// Icon
|
||||||
let iconSize = OpenGroupInvitationView.iconSize
|
let iconSize = OpenGroupInvitationView.iconSize
|
||||||
let iconName = isOutgoing ? "Globe" : "Plus"
|
let iconName = (isOutgoing ? "Globe" : "Plus")
|
||||||
let icon = UIImage(named: iconName)?.withTint(.white)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
|
||||||
let iconImageViewSize = OpenGroupInvitationView.iconImageViewSize
|
let iconImageViewSize = OpenGroupInvitationView.iconImageViewSize
|
||||||
let iconImageView = UIImageView(image: icon)
|
let iconImageView = UIImageView(
|
||||||
|
image: UIImage(named: iconName)?
|
||||||
|
.resizedImage(to: CGSize(width: iconSize, height: iconSize))?
|
||||||
|
.withRenderingMode(.alwaysTemplate)
|
||||||
|
)
|
||||||
|
iconImageView.tintColor = .white
|
||||||
iconImageView.contentMode = .center
|
iconImageView.contentMode = .center
|
||||||
iconImageView.layer.cornerRadius = iconImageViewSize / 2
|
iconImageView.layer.cornerRadius = iconImageViewSize / 2
|
||||||
iconImageView.layer.masksToBounds = true
|
iconImageView.layer.masksToBounds = true
|
||||||
iconImageView.backgroundColor = Colors.accent
|
iconImageView.backgroundColor = Colors.accent
|
||||||
iconImageView.set(.width, to: iconImageViewSize)
|
iconImageView.set(.width, to: iconImageViewSize)
|
||||||
iconImageView.set(.height, to: iconImageViewSize)
|
iconImageView.set(.height, to: iconImageViewSize)
|
||||||
|
|
||||||
// Main stack
|
// Main stack
|
||||||
let mainStackView = UIStackView(arrangedSubviews: [ iconImageView, labelStackView ])
|
let mainStackView = UIStackView(arrangedSubviews: [ iconImageView, labelStackView ])
|
||||||
mainStackView.axis = .horizontal
|
mainStackView.axis = .horizontal
|
||||||
|
|
|
@ -1,100 +1,57 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class QuoteView : UIView {
|
import UIKit
|
||||||
private let mode: Mode
|
import SessionUIKit
|
||||||
private let thread: TSThread
|
import SessionMessagingKit
|
||||||
private let direction: Direction
|
|
||||||
private let hInset: CGFloat
|
|
||||||
private let maxWidth: CGFloat
|
|
||||||
private let delegate: QuoteViewDelegate?
|
|
||||||
|
|
||||||
private var maxBodyLabelHeight: CGFloat {
|
final class QuoteView: UIView {
|
||||||
switch mode {
|
|
||||||
case .regular: return 60
|
|
||||||
case .draft: return 40
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var attachments: [OWSAttachmentInfo] {
|
|
||||||
switch mode {
|
|
||||||
case .regular(let viewItem): return (viewItem.interaction as? TSMessage)?.quotedMessage!.quotedAttachments ?? []
|
|
||||||
case .draft(let model): return given(model.attachmentStream) { [ OWSAttachmentInfo(attachmentStream: $0) ] } ?? []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var thumbnail: UIImage? {
|
|
||||||
switch mode {
|
|
||||||
case .regular(let viewItem): return viewItem.quotedReply!.thumbnailImage
|
|
||||||
case .draft(let model): return model.thumbnailImage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var body: String? {
|
|
||||||
switch mode {
|
|
||||||
case .regular(let viewItem): return (viewItem.interaction as? TSMessage)?.quotedMessage!.body
|
|
||||||
case .draft(let model): return model.body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var authorID: String {
|
|
||||||
switch mode {
|
|
||||||
case .regular(let viewItem): return viewItem.quotedReply!.authorId
|
|
||||||
case .draft(let model): return model.authorId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lineColor: UIColor {
|
|
||||||
switch (mode, AppModeManager.shared.currentAppMode) {
|
|
||||||
case (.regular, .light), (.draft, .light): return .black
|
|
||||||
case (.regular, .dark): return (direction == .outgoing) ? .black : Colors.accent
|
|
||||||
case (.draft, .dark): return Colors.accent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var textColor: UIColor {
|
|
||||||
if case .draft = mode { return Colors.text }
|
|
||||||
switch (direction, AppModeManager.shared.currentAppMode) {
|
|
||||||
case (.outgoing, .dark), (.incoming, .light): return .black
|
|
||||||
default: return .white
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Mode
|
|
||||||
enum Mode {
|
|
||||||
case regular(ConversationViewItem)
|
|
||||||
case draft(OWSQuotedReplyModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Direction
|
|
||||||
enum Direction { case incoming, outgoing }
|
|
||||||
|
|
||||||
// MARK: Settings
|
|
||||||
static let thumbnailSize: CGFloat = 48
|
static let thumbnailSize: CGFloat = 48
|
||||||
static let iconSize: CGFloat = 24
|
static let iconSize: CGFloat = 24
|
||||||
static let labelStackViewSpacing: CGFloat = 2
|
static let labelStackViewSpacing: CGFloat = 2
|
||||||
static let labelStackViewVMargin: CGFloat = 4
|
static let labelStackViewVMargin: CGFloat = 4
|
||||||
static let cancelButtonSize: CGFloat = 33
|
static let cancelButtonSize: CGFloat = 33
|
||||||
|
|
||||||
// MARK: Lifecycle
|
enum Mode {
|
||||||
init(for viewItem: ConversationViewItem, in thread: TSThread?, direction: Direction, hInset: CGFloat, maxWidth: CGFloat) {
|
case regular
|
||||||
self.mode = .regular(viewItem)
|
case draft
|
||||||
self.thread = thread ?? TSThread.fetch(uniqueId: viewItem.interaction.uniqueThreadId)!
|
|
||||||
self.maxWidth = maxWidth
|
|
||||||
self.direction = direction
|
|
||||||
self.hInset = hInset
|
|
||||||
self.delegate = nil
|
|
||||||
super.init(frame: CGRect.zero)
|
|
||||||
setUpViewHierarchy()
|
|
||||||
}
|
}
|
||||||
|
enum Direction { case incoming, outgoing }
|
||||||
|
|
||||||
|
// MARK: - Variables
|
||||||
|
|
||||||
|
private let onCancel: (() -> ())?
|
||||||
|
|
||||||
init(for model: OWSQuotedReplyModel, direction: Direction, hInset: CGFloat, maxWidth: CGFloat, delegate: QuoteViewDelegate) {
|
// MARK: - Lifecycle
|
||||||
self.mode = .draft(model)
|
|
||||||
self.thread = TSThread.fetch(uniqueId: model.threadId)!
|
init(
|
||||||
self.maxWidth = maxWidth
|
for mode: Mode,
|
||||||
self.direction = direction
|
authorId: String,
|
||||||
self.hInset = hInset
|
quotedText: String?,
|
||||||
self.delegate = delegate
|
threadVariant: SessionThread.Variant,
|
||||||
|
currentUserPublicKey: String?,
|
||||||
|
currentUserBlindedPublicKey: String?,
|
||||||
|
direction: Direction,
|
||||||
|
attachment: Attachment?,
|
||||||
|
hInset: CGFloat,
|
||||||
|
maxWidth: CGFloat,
|
||||||
|
onCancel: (() -> ())? = nil
|
||||||
|
) {
|
||||||
|
self.onCancel = onCancel
|
||||||
|
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
setUpViewHierarchy()
|
|
||||||
|
setUpViewHierarchy(
|
||||||
|
mode: mode,
|
||||||
|
authorId: authorId,
|
||||||
|
quotedText: quotedText,
|
||||||
|
threadVariant: threadVariant,
|
||||||
|
currentUserPublicKey: currentUserPublicKey,
|
||||||
|
currentUserBlindedPublicKey: currentUserBlindedPublicKey,
|
||||||
|
direction: direction,
|
||||||
|
attachment: attachment,
|
||||||
|
hInset: hInset,
|
||||||
|
maxWidth: maxWidth
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
|
@ -105,14 +62,24 @@ final class QuoteView : UIView {
|
||||||
preconditionFailure("Use init(for:maxMessageWidth:) instead.")
|
preconditionFailure("Use init(for:maxMessageWidth:) instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy(
|
||||||
|
mode: Mode,
|
||||||
|
authorId: String,
|
||||||
|
quotedText: String?,
|
||||||
|
threadVariant: SessionThread.Variant,
|
||||||
|
currentUserPublicKey: String?,
|
||||||
|
currentUserBlindedPublicKey: String?,
|
||||||
|
direction: Direction,
|
||||||
|
attachment: Attachment?,
|
||||||
|
hInset: CGFloat,
|
||||||
|
maxWidth: CGFloat
|
||||||
|
) {
|
||||||
// There's quite a bit of calculation going on here. It's a bit complex so don't make changes
|
// There's quite a bit of calculation going on here. It's a bit complex so don't make changes
|
||||||
// if you don't need to. If you do then test:
|
// if you don't need to. If you do then test:
|
||||||
// • Quoted text in both private chats and group chats
|
// • Quoted text in both private chats and group chats
|
||||||
// • Quoted images and videos in both private chats and group chats
|
// • Quoted images and videos in both private chats and group chats
|
||||||
// • Quoted voice messages and documents in both private chats and group chats
|
// • Quoted voice messages and documents in both private chats and group chats
|
||||||
// • All of the above in both dark mode and light mode
|
// • All of the above in both dark mode and light mode
|
||||||
let hasAttachments = !attachments.isEmpty
|
|
||||||
let thumbnailSize = QuoteView.thumbnailSize
|
let thumbnailSize = QuoteView.thumbnailSize
|
||||||
let iconSize = QuoteView.iconSize
|
let iconSize = QuoteView.iconSize
|
||||||
let labelStackViewSpacing = QuoteView.labelStackViewSpacing
|
let labelStackViewSpacing = QuoteView.labelStackViewSpacing
|
||||||
|
@ -120,18 +87,23 @@ final class QuoteView : UIView {
|
||||||
let smallSpacing = Values.smallSpacing
|
let smallSpacing = Values.smallSpacing
|
||||||
let cancelButtonSize = QuoteView.cancelButtonSize
|
let cancelButtonSize = QuoteView.cancelButtonSize
|
||||||
var availableWidth: CGFloat
|
var availableWidth: CGFloat
|
||||||
|
|
||||||
// Subtract smallSpacing twice; once for the spacing in between the stack view elements and
|
// Subtract smallSpacing twice; once for the spacing in between the stack view elements and
|
||||||
// once for the trailing margin.
|
// once for the trailing margin.
|
||||||
if !hasAttachments {
|
if attachment == nil {
|
||||||
availableWidth = maxWidth - 2 * hInset - Values.accentLineThickness - 2 * smallSpacing
|
availableWidth = maxWidth - 2 * hInset - Values.accentLineThickness - 2 * smallSpacing
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
availableWidth = maxWidth - 2 * hInset - thumbnailSize - 2 * smallSpacing
|
availableWidth = maxWidth - 2 * hInset - thumbnailSize - 2 * smallSpacing
|
||||||
}
|
}
|
||||||
|
|
||||||
if case .draft = mode {
|
if case .draft = mode {
|
||||||
availableWidth -= cancelButtonSize
|
availableWidth -= cancelButtonSize
|
||||||
}
|
}
|
||||||
|
|
||||||
let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude)
|
let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude)
|
||||||
var body = self.body
|
var body: String? = quotedText
|
||||||
|
|
||||||
// Main stack view
|
// Main stack view
|
||||||
let mainStackView = UIStackView(arrangedSubviews: [])
|
let mainStackView = UIStackView(arrangedSubviews: [])
|
||||||
mainStackView.axis = .horizontal
|
mainStackView.axis = .horizontal
|
||||||
|
@ -139,49 +111,129 @@ final class QuoteView : UIView {
|
||||||
mainStackView.isLayoutMarginsRelativeArrangement = true
|
mainStackView.isLayoutMarginsRelativeArrangement = true
|
||||||
mainStackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: smallSpacing)
|
mainStackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: smallSpacing)
|
||||||
mainStackView.alignment = .center
|
mainStackView.alignment = .center
|
||||||
|
|
||||||
// Content view
|
// Content view
|
||||||
let contentView = UIView()
|
let contentView = UIView()
|
||||||
addSubview(contentView)
|
addSubview(contentView)
|
||||||
contentView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self)
|
contentView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self)
|
||||||
contentView.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor).isActive = true
|
contentView.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor).isActive = true
|
||||||
|
|
||||||
// Line view
|
// Line view
|
||||||
|
let lineColor: UIColor = {
|
||||||
|
switch (mode, AppModeManager.shared.currentAppMode) {
|
||||||
|
case (.regular, .light), (.draft, .light): return .black
|
||||||
|
case (.regular, .dark): return (direction == .outgoing) ? .black : Colors.accent
|
||||||
|
case (.draft, .dark): return Colors.accent
|
||||||
|
}
|
||||||
|
}()
|
||||||
let lineView = UIView()
|
let lineView = UIView()
|
||||||
lineView.backgroundColor = lineColor
|
lineView.backgroundColor = lineColor
|
||||||
lineView.set(.width, to: Values.accentLineThickness)
|
lineView.set(.width, to: Values.accentLineThickness)
|
||||||
if !hasAttachments {
|
|
||||||
mainStackView.addArrangedSubview(lineView)
|
if let attachment: Attachment = attachment {
|
||||||
} else {
|
let isAudio: Bool = MIMETypeUtil.isAudio(attachment.contentType)
|
||||||
let isAudio = MIMETypeUtil.isAudio(attachments.first!.contentType ?? "")
|
let fallbackImageName: String = (isAudio ? "attachment_audio" : "actionsheet_document_black")
|
||||||
let fallbackImageName = isAudio ? "attachment_audio" : "actionsheet_document_black"
|
let imageView: UIImageView = UIImageView(
|
||||||
let fallbackImage = UIImage(named: fallbackImageName)?.withTint(.white)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
image: UIImage(named: fallbackImageName)?
|
||||||
let imageView = UIImageView(image: thumbnail ?? fallbackImage)
|
.resizedImage(to: CGSize(width: iconSize, height: iconSize))?
|
||||||
imageView.contentMode = (thumbnail != nil) ? .scaleAspectFill : .center
|
.withRenderingMode(.alwaysTemplate)
|
||||||
|
)
|
||||||
|
|
||||||
|
imageView.tintColor = .white
|
||||||
|
imageView.contentMode = .center
|
||||||
imageView.backgroundColor = lineColor
|
imageView.backgroundColor = lineColor
|
||||||
imageView.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
|
imageView.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
|
||||||
imageView.layer.masksToBounds = true
|
imageView.layer.masksToBounds = true
|
||||||
imageView.set(.width, to: thumbnailSize)
|
imageView.set(.width, to: thumbnailSize)
|
||||||
imageView.set(.height, to: thumbnailSize)
|
imageView.set(.height, to: thumbnailSize)
|
||||||
mainStackView.addArrangedSubview(imageView)
|
mainStackView.addArrangedSubview(imageView)
|
||||||
|
|
||||||
if (body ?? "").isEmpty {
|
if (body ?? "").isEmpty {
|
||||||
body = (thumbnail != nil) ? "Image" : (isAudio ? "Audio" : "Document")
|
body = (attachment.isImage ?
|
||||||
|
"Image" :
|
||||||
|
(isAudio ? "Audio" : "Document")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the thumbnail if needed
|
||||||
|
if attachment.isVisualMedia {
|
||||||
|
attachment.thumbnail(
|
||||||
|
size: .small,
|
||||||
|
success: { image, _ in
|
||||||
|
guard Thread.isMainThread else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
imageView.image = image
|
||||||
|
imageView.contentMode = .scaleAspectFill
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imageView.image = image
|
||||||
|
imageView.contentMode = .scaleAspectFill
|
||||||
|
},
|
||||||
|
failure: {}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
mainStackView.addArrangedSubview(lineView)
|
||||||
|
}
|
||||||
|
|
||||||
// Body label
|
// Body label
|
||||||
|
let textColor: UIColor = {
|
||||||
|
guard mode != .draft else { return Colors.text }
|
||||||
|
|
||||||
|
switch (direction, AppModeManager.shared.currentAppMode) {
|
||||||
|
case (.outgoing, .dark), (.incoming, .light): return .black
|
||||||
|
default: return .white
|
||||||
|
}
|
||||||
|
}()
|
||||||
let bodyLabel = UILabel()
|
let bodyLabel = UILabel()
|
||||||
bodyLabel.numberOfLines = 0
|
bodyLabel.numberOfLines = 0
|
||||||
bodyLabel.lineBreakMode = .byTruncatingTail
|
bodyLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
|
||||||
let isOutgoing = (direction == .outgoing)
|
let isOutgoing = (direction == .outgoing)
|
||||||
bodyLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
bodyLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||||
bodyLabel.attributedText = given(body) { MentionUtilities.highlightMentions(in: $0, isOutgoingMessage: isOutgoing, threadID: thread.uniqueId!, attributes: [:]) } ?? given(attachments.first?.contentType) { NSAttributedString(string: MIMETypeUtil.isAudio($0) ? "Audio" : "Document") } ?? NSAttributedString(string: "Document")
|
bodyLabel.attributedText = body
|
||||||
|
.map {
|
||||||
|
MentionUtilities.highlightMentions(
|
||||||
|
in: $0,
|
||||||
|
threadVariant: threadVariant,
|
||||||
|
currentUserPublicKey: currentUserPublicKey,
|
||||||
|
currentUserBlindedPublicKey: currentUserBlindedPublicKey,
|
||||||
|
isOutgoingMessage: isOutgoing,
|
||||||
|
attributes: [:]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.defaulting(
|
||||||
|
to: attachment.map {
|
||||||
|
NSAttributedString(string: MIMETypeUtil.isAudio($0.contentType) ? "Audio" : "Document")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.defaulting(to: NSAttributedString(string: "Document"))
|
||||||
bodyLabel.textColor = textColor
|
bodyLabel.textColor = textColor
|
||||||
|
|
||||||
let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace)
|
let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace)
|
||||||
|
|
||||||
// Label stack view
|
// Label stack view
|
||||||
var authorLabelHeight: CGFloat?
|
var authorLabelHeight: CGFloat?
|
||||||
if let groupThread = thread as? TSGroupThread {
|
if threadVariant == .openGroup || threadVariant == .closedGroup {
|
||||||
|
let isCurrentUser: Bool = [
|
||||||
|
currentUserPublicKey,
|
||||||
|
currentUserBlindedPublicKey,
|
||||||
|
]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.asSet()
|
||||||
|
.contains(authorId)
|
||||||
let authorLabel = UILabel()
|
let authorLabel = UILabel()
|
||||||
authorLabel.lineBreakMode = .byTruncatingTail
|
authorLabel.lineBreakMode = .byTruncatingTail
|
||||||
let context: Contact.Context = groupThread.isOpenGroup ? .openGroup : .regular
|
authorLabel.text = (isCurrentUser ?
|
||||||
authorLabel.text = Storage.shared.getContact(with: authorID)?.displayName(for: context) ?? authorID
|
"MEDIA_GALLERY_SENDER_NAME_YOU".localized() :
|
||||||
|
Profile.displayName(
|
||||||
|
id: authorId,
|
||||||
|
threadVariant: threadVariant
|
||||||
|
)
|
||||||
|
)
|
||||||
authorLabel.textColor = textColor
|
authorLabel.textColor = textColor
|
||||||
authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||||
let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace)
|
let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace)
|
||||||
|
@ -195,51 +247,56 @@ final class QuoteView : UIView {
|
||||||
labelStackView.isLayoutMarginsRelativeArrangement = true
|
labelStackView.isLayoutMarginsRelativeArrangement = true
|
||||||
labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0)
|
labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0)
|
||||||
mainStackView.addArrangedSubview(labelStackView)
|
mainStackView.addArrangedSubview(labelStackView)
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
mainStackView.addArrangedSubview(bodyLabel)
|
mainStackView.addArrangedSubview(bodyLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel button
|
// Cancel button
|
||||||
let cancelButton = UIButton(type: .custom)
|
let cancelButton = UIButton(type: .custom)
|
||||||
let tint: UIColor = isLightMode ? .black : .white
|
cancelButton.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: UIControl.State.normal)
|
||||||
cancelButton.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal)
|
cancelButton.tintColor = (isLightMode ? .black : .white)
|
||||||
cancelButton.set(.width, to: cancelButtonSize)
|
cancelButton.set(.width, to: cancelButtonSize)
|
||||||
cancelButton.set(.height, to: cancelButtonSize)
|
cancelButton.set(.height, to: cancelButtonSize)
|
||||||
cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
|
cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
|
||||||
|
|
||||||
// Constraints
|
// Constraints
|
||||||
contentView.addSubview(mainStackView)
|
contentView.addSubview(mainStackView)
|
||||||
mainStackView.pin(to: contentView)
|
mainStackView.pin(to: contentView)
|
||||||
if !thread.isGroupThread() {
|
|
||||||
|
if threadVariant != .openGroup && threadVariant != .closedGroup {
|
||||||
bodyLabel.set(.width, to: bodyLabelSize.width)
|
bodyLabel.set(.width, to: bodyLabelSize.width)
|
||||||
}
|
}
|
||||||
let bodyLabelHeight = bodyLabelSize.height.clamp(0, maxBodyLabelHeight)
|
|
||||||
|
let bodyLabelHeight = bodyLabelSize.height.clamp(0, (mode == .regular ? 60 : 40))
|
||||||
let contentViewHeight: CGFloat
|
let contentViewHeight: CGFloat
|
||||||
if hasAttachments {
|
|
||||||
|
if attachment != nil {
|
||||||
contentViewHeight = thumbnailSize + 8 // Add a small amount of spacing above and below the thumbnail
|
contentViewHeight = thumbnailSize + 8 // Add a small amount of spacing above and below the thumbnail
|
||||||
bodyLabel.set(.height, to: 18) // Experimentally determined
|
bodyLabel.set(.height, to: 18) // Experimentally determined
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
if let authorLabelHeight = authorLabelHeight { // Group thread
|
if let authorLabelHeight = authorLabelHeight { // Group thread
|
||||||
contentViewHeight = bodyLabelHeight + (authorLabelHeight + labelStackViewSpacing) + 2 * labelStackViewVMargin
|
contentViewHeight = bodyLabelHeight + (authorLabelHeight + labelStackViewSpacing) + 2 * labelStackViewVMargin
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
contentViewHeight = bodyLabelHeight + 2 * smallSpacing
|
contentViewHeight = bodyLabelHeight + 2 * smallSpacing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
contentView.set(.height, to: contentViewHeight)
|
contentView.set(.height, to: contentViewHeight)
|
||||||
lineView.set(.height, to: contentViewHeight - 8) // Add a small amount of spacing above and below the line
|
lineView.set(.height, to: contentViewHeight - 8) // Add a small amount of spacing above and below the line
|
||||||
if case .draft = mode {
|
|
||||||
|
if mode == .draft {
|
||||||
addSubview(cancelButton)
|
addSubview(cancelButton)
|
||||||
cancelButton.center(.vertical, in: self)
|
cancelButton.center(.vertical, in: self)
|
||||||
cancelButton.pin(.right, to: .right, of: self)
|
cancelButton.pin(.right, to: .right, of: self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
@objc private func cancel() {
|
@objc private func cancel() {
|
||||||
delegate?.handleQuoteViewCancelButtonTapped()
|
onCancel?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Delegate
|
|
||||||
protocol QuoteViewDelegate {
|
|
||||||
|
|
||||||
func handleQuoteViewCancelButtonTapped()
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,78 +1,84 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
import NVActivityIndicatorView
|
import NVActivityIndicatorView
|
||||||
|
import SessionUIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
@objc(SNVoiceMessageView)
|
public final class VoiceMessageView: UIView {
|
||||||
public final class VoiceMessageView : UIView {
|
private static let width: CGFloat = 160
|
||||||
private let viewItem: ConversationViewItem
|
private static let toggleContainerSize: CGFloat = 20
|
||||||
private var isShowingSpeedUpLabel = false
|
private static let inset = Values.smallSpacing
|
||||||
@objc var progress: Int = 0 { didSet { handleProgressChanged() } }
|
|
||||||
@objc var isPlaying = false { didSet { handleIsPlayingChanged() } }
|
// MARK: - UI
|
||||||
|
|
||||||
private lazy var progressViewRightConstraint = progressView.pin(.right, to: .right, of: self, withInset: -VoiceMessageView.width)
|
private lazy var progressViewRightConstraint = progressView.pin(.right, to: .right, of: self, withInset: -VoiceMessageView.width)
|
||||||
|
|
||||||
private var attachment: TSAttachment? { viewItem.attachmentStream ?? viewItem.attachmentPointer }
|
|
||||||
private var duration: Int { Int(viewItem.audioDurationSeconds) }
|
|
||||||
|
|
||||||
// MARK: UI Components
|
|
||||||
private lazy var progressView: UIView = {
|
private lazy var progressView: UIView = {
|
||||||
let result = UIView()
|
let result: UIView = UIView()
|
||||||
result.backgroundColor = UIColor.black.withAlphaComponent(0.2)
|
result.backgroundColor = UIColor.black.withAlphaComponent(0.2)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var toggleImageView: UIImageView = {
|
private lazy var toggleImageView: UIImageView = {
|
||||||
let result = UIImageView(image: UIImage(named: "Play"))
|
let result: UIImageView = UIImageView(image: UIImage(named: "Play"))
|
||||||
|
result.contentMode = .scaleAspectFit
|
||||||
result.set(.width, to: 8)
|
result.set(.width, to: 8)
|
||||||
result.set(.height, to: 8)
|
result.set(.height, to: 8)
|
||||||
result.contentMode = .scaleAspectFit
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var loader: NVActivityIndicatorView = {
|
private lazy var loader: NVActivityIndicatorView = {
|
||||||
let result = NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: Colors.text, padding: nil)
|
let result: NVActivityIndicatorView = NVActivityIndicatorView(
|
||||||
|
frame: .zero,
|
||||||
|
type: .circleStrokeSpin,
|
||||||
|
color: Colors.text,
|
||||||
|
padding: nil
|
||||||
|
)
|
||||||
result.set(.width, to: VoiceMessageView.toggleContainerSize + 2)
|
result.set(.width, to: VoiceMessageView.toggleContainerSize + 2)
|
||||||
result.set(.height, to: VoiceMessageView.toggleContainerSize + 2)
|
result.set(.height, to: VoiceMessageView.toggleContainerSize + 2)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var countdownLabelContainer: UIView = {
|
private lazy var countdownLabelContainer: UIView = {
|
||||||
let result = UIView()
|
let result: UIView = UIView()
|
||||||
result.backgroundColor = .white
|
result.backgroundColor = .white
|
||||||
result.layer.masksToBounds = true
|
result.layer.masksToBounds = true
|
||||||
result.set(.height, to: VoiceMessageView.toggleContainerSize)
|
result.set(.height, to: VoiceMessageView.toggleContainerSize)
|
||||||
result.set(.width, to: 44)
|
result.set(.width, to: 44)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var countdownLabel: UILabel = {
|
private lazy var countdownLabel: UILabel = {
|
||||||
let result = UILabel()
|
let result: UILabel = UILabel()
|
||||||
result.textColor = .black
|
result.textColor = .black
|
||||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||||
result.text = "0:00"
|
result.text = "0:00"
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var speedUpLabel: UILabel = {
|
private lazy var speedUpLabel: UILabel = {
|
||||||
let result = UILabel()
|
let result: UILabel = UILabel()
|
||||||
result.textColor = .black
|
result.textColor = .black
|
||||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||||
result.alpha = 0
|
result.alpha = 0
|
||||||
result.text = "1.5x"
|
result.text = "1.5x"
|
||||||
result.textAlignment = .center
|
result.textAlignment = .center
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: Settings
|
// MARK: - Lifecycle
|
||||||
private static let width: CGFloat = 160
|
|
||||||
private static let toggleContainerSize: CGFloat = 20
|
init() {
|
||||||
private static let inset = Values.smallSpacing
|
|
||||||
|
|
||||||
// MARK: Lifecycle
|
|
||||||
init(viewItem: ConversationViewItem) {
|
|
||||||
self.viewItem = viewItem
|
|
||||||
self.progress = Int(viewItem.audioProgressSeconds)
|
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
handleProgressChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
|
@ -86,27 +92,33 @@ public final class VoiceMessageView : UIView {
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy() {
|
||||||
let toggleContainerSize = VoiceMessageView.toggleContainerSize
|
let toggleContainerSize = VoiceMessageView.toggleContainerSize
|
||||||
let inset = VoiceMessageView.inset
|
let inset = VoiceMessageView.inset
|
||||||
|
|
||||||
// Width & height
|
// Width & height
|
||||||
set(.width, to: VoiceMessageView.width)
|
set(.width, to: VoiceMessageView.width)
|
||||||
|
|
||||||
// Toggle
|
// Toggle
|
||||||
let toggleContainer = UIView()
|
let toggleContainer: UIView = UIView()
|
||||||
toggleContainer.backgroundColor = .white
|
toggleContainer.backgroundColor = .white
|
||||||
toggleContainer.set(.width, to: toggleContainerSize)
|
toggleContainer.set(.width, to: toggleContainerSize)
|
||||||
toggleContainer.set(.height, to: toggleContainerSize)
|
toggleContainer.set(.height, to: toggleContainerSize)
|
||||||
toggleContainer.addSubview(toggleImageView)
|
toggleContainer.addSubview(toggleImageView)
|
||||||
toggleImageView.center(in: toggleContainer)
|
toggleImageView.center(in: toggleContainer)
|
||||||
toggleContainer.layer.cornerRadius = toggleContainerSize / 2
|
toggleContainer.layer.cornerRadius = (toggleContainerSize / 2)
|
||||||
toggleContainer.layer.masksToBounds = true
|
toggleContainer.layer.masksToBounds = true
|
||||||
|
|
||||||
// Line
|
// Line
|
||||||
let lineView = UIView()
|
let lineView = UIView()
|
||||||
lineView.backgroundColor = .white
|
lineView.backgroundColor = .white
|
||||||
lineView.set(.height, to: 1)
|
lineView.set(.height, to: 1)
|
||||||
|
|
||||||
// Countdown label
|
// Countdown label
|
||||||
countdownLabelContainer.addSubview(countdownLabel)
|
countdownLabelContainer.addSubview(countdownLabel)
|
||||||
countdownLabel.center(in: countdownLabelContainer)
|
countdownLabel.center(in: countdownLabelContainer)
|
||||||
|
|
||||||
// Speed up label
|
// Speed up label
|
||||||
countdownLabelContainer.addSubview(speedUpLabel)
|
countdownLabelContainer.addSubview(speedUpLabel)
|
||||||
speedUpLabel.center(in: countdownLabelContainer)
|
speedUpLabel.center(in: countdownLabelContainer)
|
||||||
|
|
||||||
// Constraints
|
// Constraints
|
||||||
addSubview(progressView)
|
addSubview(progressView)
|
||||||
progressView.pin(.left, to: .left, of: self)
|
progressView.pin(.left, to: .left, of: self)
|
||||||
|
@ -114,60 +126,73 @@ public final class VoiceMessageView : UIView {
|
||||||
progressViewRightConstraint.isActive = true
|
progressViewRightConstraint.isActive = true
|
||||||
progressView.pin(.bottom, to: .bottom, of: self)
|
progressView.pin(.bottom, to: .bottom, of: self)
|
||||||
addSubview(toggleContainer)
|
addSubview(toggleContainer)
|
||||||
|
|
||||||
toggleContainer.pin(.left, to: .left, of: self, withInset: inset)
|
toggleContainer.pin(.left, to: .left, of: self, withInset: inset)
|
||||||
toggleContainer.pin(.top, to: .top, of: self, withInset: inset)
|
toggleContainer.pin(.top, to: .top, of: self, withInset: inset)
|
||||||
toggleContainer.pin(.bottom, to: .bottom, of: self, withInset: -inset)
|
toggleContainer.pin(.bottom, to: .bottom, of: self, withInset: -inset)
|
||||||
addSubview(lineView)
|
addSubview(lineView)
|
||||||
|
|
||||||
lineView.pin(.left, to: .right, of: toggleContainer)
|
lineView.pin(.left, to: .right, of: toggleContainer)
|
||||||
lineView.center(.vertical, in: self)
|
lineView.center(.vertical, in: self)
|
||||||
addSubview(countdownLabelContainer)
|
addSubview(countdownLabelContainer)
|
||||||
|
|
||||||
countdownLabelContainer.pin(.left, to: .right, of: lineView)
|
countdownLabelContainer.pin(.left, to: .right, of: lineView)
|
||||||
countdownLabelContainer.pin(.right, to: .right, of: self, withInset: -inset)
|
countdownLabelContainer.pin(.right, to: .right, of: self, withInset: -inset)
|
||||||
countdownLabelContainer.center(.vertical, in: self)
|
countdownLabelContainer.center(.vertical, in: self)
|
||||||
|
|
||||||
addSubview(loader)
|
addSubview(loader)
|
||||||
loader.center(in: toggleContainer)
|
loader.center(in: toggleContainer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Updating
|
|
||||||
public override func layoutSubviews() {
|
public override func layoutSubviews() {
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
countdownLabelContainer.layer.cornerRadius = countdownLabelContainer.bounds.height / 2
|
|
||||||
|
countdownLabelContainer.layer.cornerRadius = (countdownLabelContainer.bounds.height / 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleIsPlayingChanged() {
|
// MARK: - Updating
|
||||||
toggleImageView.image = isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play")
|
|
||||||
if !isPlaying { progress = 0 }
|
public func update(
|
||||||
}
|
with attachment: Attachment,
|
||||||
|
isPlaying: Bool,
|
||||||
private func handleProgressChanged() {
|
progress: TimeInterval,
|
||||||
let isDownloaded = (attachment?.isDownloaded == true)
|
playbackRate: Double,
|
||||||
loader.isHidden = isDownloaded
|
oldPlaybackRate: Double
|
||||||
if isDownloaded { loader.stopAnimating() } else if !loader.isAnimating { loader.startAnimating() }
|
) {
|
||||||
guard isDownloaded else { return }
|
switch attachment.state {
|
||||||
countdownLabel.text = OWSFormat.formatDurationSeconds(duration - progress)
|
case .downloaded, .uploaded:
|
||||||
guard viewItem.audioProgressSeconds > 0 && viewItem.audioDurationSeconds > 0 else {
|
loader.isHidden = true
|
||||||
return progressViewRightConstraint.constant = -VoiceMessageView.width
|
loader.stopAnimating()
|
||||||
}
|
|
||||||
let fraction = viewItem.audioProgressSeconds / viewItem.audioDurationSeconds
|
toggleImageView.image = (isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play"))
|
||||||
progressViewRightConstraint.constant = -(VoiceMessageView.width * (1 - fraction))
|
countdownLabel.text = OWSFormat.formatDurationSeconds(max(0, Int(floor(attachment.duration.defaulting(to: 0) - progress))))
|
||||||
}
|
|
||||||
|
guard let duration: TimeInterval = attachment.duration, duration > 0, progress > 0 else {
|
||||||
func showSpeedUpLabel() {
|
return progressViewRightConstraint.constant = -VoiceMessageView.width
|
||||||
guard !isShowingSpeedUpLabel else { return }
|
}
|
||||||
isShowingSpeedUpLabel = true
|
|
||||||
UIView.animate(withDuration: 0.25) { [weak self] in
|
let fraction: Double = (progress / duration)
|
||||||
guard let self = self else { return }
|
progressViewRightConstraint.constant = -(VoiceMessageView.width * (1 - fraction))
|
||||||
self.countdownLabel.alpha = 0
|
|
||||||
self.speedUpLabel.alpha = 1
|
// If the playback rate changed then show the 'speedUpLabel' briefly
|
||||||
}
|
guard playbackRate > oldPlaybackRate else { return }
|
||||||
Timer.scheduledTimer(withTimeInterval: 1.25, repeats: false) { [weak self] _ in
|
|
||||||
UIView.animate(withDuration: 0.25, animations: {
|
UIView.animate(withDuration: 0.25) { [weak self] in
|
||||||
guard let self = self else { return }
|
self?.countdownLabel.alpha = 0
|
||||||
self.countdownLabel.alpha = 1
|
self?.speedUpLabel.alpha = 1
|
||||||
self.speedUpLabel.alpha = 0
|
}
|
||||||
}, completion: { _ in
|
|
||||||
self?.isShowingSpeedUpLabel = false
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1250)) {
|
||||||
})
|
UIView.animate(withDuration: 0.25) { [weak self] in
|
||||||
|
self?.countdownLabel.alpha = 1
|
||||||
|
self?.speedUpLabel.alpha = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
if !loader.isAnimating {
|
||||||
|
loader.startAnimating()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,72 +1,87 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class InfoMessageCell : MessageCell {
|
import UIKit
|
||||||
|
import SessionUIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
|
final class InfoMessageCell: MessageCell {
|
||||||
|
private static let iconSize: CGFloat = 16
|
||||||
|
private static let inset = Values.mediumSpacing
|
||||||
|
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: InfoMessageCell.iconSize)
|
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: InfoMessageCell.iconSize)
|
||||||
private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: InfoMessageCell.iconSize)
|
private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: InfoMessageCell.iconSize)
|
||||||
|
|
||||||
// MARK: UI Components
|
private lazy var iconImageView: UIImageView = UIImageView()
|
||||||
private lazy var iconImageView = UIImageView()
|
|
||||||
|
|
||||||
private lazy var label: UILabel = {
|
private lazy var label: UILabel = {
|
||||||
let result = UILabel()
|
let result: UILabel = UILabel()
|
||||||
result.numberOfLines = 0
|
result.numberOfLines = 0
|
||||||
result.lineBreakMode = .byWordWrapping
|
result.lineBreakMode = .byWordWrapping
|
||||||
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
||||||
result.textColor = Colors.text
|
result.textColor = Colors.text
|
||||||
result.textAlignment = .center
|
result.textAlignment = .center
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var stackView: UIStackView = {
|
private lazy var stackView: UIStackView = {
|
||||||
let result = UIStackView(arrangedSubviews: [ iconImageView, label ])
|
let result: UIStackView = UIStackView(arrangedSubviews: [ iconImageView, label ])
|
||||||
result.axis = .vertical
|
result.axis = .vertical
|
||||||
result.alignment = .center
|
result.alignment = .center
|
||||||
result.spacing = Values.smallSpacing
|
result.spacing = Values.smallSpacing
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
// MARK: Settings
|
|
||||||
private static let iconSize: CGFloat = 16
|
|
||||||
private static let inset = Values.mediumSpacing
|
|
||||||
|
|
||||||
override class var identifier: String { "InfoMessageCell" }
|
|
||||||
|
|
||||||
// MARK: Lifecycle
|
|
||||||
override func setUpViewHierarchy() {
|
override func setUpViewHierarchy() {
|
||||||
super.setUpViewHierarchy()
|
super.setUpViewHierarchy()
|
||||||
|
|
||||||
iconImageViewWidthConstraint.isActive = true
|
iconImageViewWidthConstraint.isActive = true
|
||||||
iconImageViewHeightConstraint.isActive = true
|
iconImageViewHeightConstraint.isActive = true
|
||||||
addSubview(stackView)
|
addSubview(stackView)
|
||||||
|
|
||||||
stackView.pin(.left, to: .left, of: self, withInset: InfoMessageCell.inset)
|
stackView.pin(.left, to: .left, of: self, withInset: InfoMessageCell.inset)
|
||||||
stackView.pin(.top, to: .top, of: self, withInset: InfoMessageCell.inset)
|
stackView.pin(.top, to: .top, of: self, withInset: InfoMessageCell.inset)
|
||||||
stackView.pin(.right, to: .right, of: self, withInset: -InfoMessageCell.inset)
|
stackView.pin(.right, to: .right, of: self, withInset: -InfoMessageCell.inset)
|
||||||
stackView.pin(.bottom, to: .bottom, of: self, withInset: -InfoMessageCell.inset)
|
stackView.pin(.bottom, to: .bottom, of: self, withInset: -InfoMessageCell.inset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Updating
|
||||||
|
|
||||||
// MARK: Updating
|
override func update(with cellViewModel: MessageViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
|
||||||
override func update() {
|
guard cellViewModel.variant.isInfoMessage else { return }
|
||||||
guard let message = viewItem?.interaction as? TSInfoMessage else { return }
|
|
||||||
let icon: UIImage?
|
self.viewModel = cellViewModel
|
||||||
switch message.messageType {
|
|
||||||
case .disappearingMessagesUpdate:
|
let icon: UIImage? = {
|
||||||
var configuration: OWSDisappearingMessagesConfiguration?
|
switch cellViewModel.variant {
|
||||||
Storage.read { transaction in
|
case .infoDisappearingMessagesUpdate:
|
||||||
configuration = message.thread(with: transaction).disappearingMessagesConfiguration(with: transaction)
|
return (cellViewModel.threadHasDisappearingMessagesEnabled ?
|
||||||
|
UIImage(named: "ic_timer") :
|
||||||
|
UIImage(named: "ic_timer_disabled")
|
||||||
|
)
|
||||||
|
|
||||||
|
case .infoMediaSavedNotification: return UIImage(named: "ic_download")
|
||||||
|
|
||||||
|
default: return nil
|
||||||
}
|
}
|
||||||
if let configuration = configuration {
|
}()
|
||||||
icon = configuration.isEnabled ? UIImage(named: "ic_timer") : UIImage(named: "ic_timer_disabled")
|
|
||||||
} else {
|
|
||||||
icon = nil
|
|
||||||
}
|
|
||||||
case .mediaSavedNotification: icon = UIImage(named: "ic_download")
|
|
||||||
default: icon = nil
|
|
||||||
}
|
|
||||||
if let icon = icon {
|
if let icon = icon {
|
||||||
iconImageView.image = icon.withTint(Colors.text)
|
iconImageView.image = icon.withRenderingMode(.alwaysTemplate)
|
||||||
|
iconImageView.tintColor = Colors.text
|
||||||
}
|
}
|
||||||
|
|
||||||
iconImageViewWidthConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0
|
iconImageViewWidthConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0
|
||||||
iconImageViewHeightConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0
|
iconImageViewHeightConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0
|
||||||
Storage.read { transaction in
|
|
||||||
self.label.text = message.previewText(with: transaction)
|
self.label.text = cellViewModel.body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
|
||||||
|
@ -7,77 +9,79 @@ public enum SwipeState {
|
||||||
case cancelled
|
case cancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
class MessageCell : UITableViewCell {
|
public class MessageCell: UITableViewCell {
|
||||||
weak var delegate: MessageCellDelegate?
|
weak var delegate: MessageCellDelegate?
|
||||||
var thread: TSThread? {
|
var viewModel: MessageViewModel?
|
||||||
didSet {
|
|
||||||
if viewItem != nil { update() }
|
// MARK: - Lifecycle
|
||||||
}
|
|
||||||
}
|
|
||||||
var viewItem: ConversationViewItem? {
|
|
||||||
didSet {
|
|
||||||
if thread != nil { update() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Settings
|
|
||||||
class var identifier: String { preconditionFailure("Must be overridden by subclasses.") }
|
|
||||||
|
|
||||||
// MARK: Lifecycle
|
|
||||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
setUpGestureRecognizers()
|
setUpGestureRecognizers()
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
super.init(coder: coder)
|
super.init(coder: coder)
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
setUpGestureRecognizers()
|
setUpGestureRecognizers()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setUpViewHierarchy() {
|
func setUpViewHierarchy() {
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
|
|
||||||
let selectedBackgroundView = UIView()
|
let selectedBackgroundView = UIView()
|
||||||
selectedBackgroundView.backgroundColor = .clear
|
selectedBackgroundView.backgroundColor = .clear
|
||||||
self.selectedBackgroundView = selectedBackgroundView
|
self.selectedBackgroundView = selectedBackgroundView
|
||||||
}
|
}
|
||||||
|
|
||||||
func setUpGestureRecognizers() {
|
func setUpGestureRecognizers() {
|
||||||
// To be overridden by subclasses
|
// To be overridden by subclasses
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Updating
|
||||||
|
|
||||||
// MARK: Updating
|
func update(with cellViewModel: MessageViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
|
||||||
func update() {
|
|
||||||
preconditionFailure("Must be overridden by subclasses.")
|
preconditionFailure("Must be overridden by subclasses.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Convenience
|
/// This is a cut-down version of the 'update' function which doesn't re-create the UI (it should be used for dynamically-updating content
|
||||||
static func getCellType(for viewItem: ConversationViewItem) -> MessageCell.Type {
|
/// like playing inline audio/video)
|
||||||
switch viewItem.interaction {
|
func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||||
case is TSIncomingMessage: fallthrough
|
preconditionFailure("Must be overridden by subclasses.")
|
||||||
case is TSOutgoingMessage: return VisibleMessageCell.self
|
}
|
||||||
case is TSInfoMessage:
|
|
||||||
if let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call {
|
// MARK: - Convenience
|
||||||
|
|
||||||
|
static func cellType(for viewModel: MessageViewModel) -> MessageCell.Type {
|
||||||
|
guard viewModel.cellType != .typingIndicator else { return TypingIndicatorCell.self }
|
||||||
|
|
||||||
|
switch viewModel.variant {
|
||||||
|
case .standardOutgoing, .standardIncoming, .standardIncomingDeleted:
|
||||||
|
return VisibleMessageCell.self
|
||||||
|
|
||||||
|
case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft,
|
||||||
|
.infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification,
|
||||||
|
.infoMessageRequestAccepted:
|
||||||
|
return InfoMessageCell.self
|
||||||
|
|
||||||
|
case .infoCall:
|
||||||
return CallMessageCell.self
|
return CallMessageCell.self
|
||||||
}
|
|
||||||
return InfoMessageCell.self
|
|
||||||
case is TypingIndicatorInteraction: return TypingIndicatorCell.self
|
|
||||||
default: preconditionFailure()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol MessageCellDelegate : AnyObject {
|
// MARK: - MessageCellDelegate
|
||||||
var lastSearchedText: String? { get }
|
|
||||||
|
protocol MessageCellDelegate: AnyObject {
|
||||||
func getMediaCache() -> NSCache<NSString, AnyObject>
|
func handleItemLongPressed(_ cellViewModel: MessageViewModel)
|
||||||
func handleViewItemLongPressed(_ viewItem: ConversationViewItem)
|
func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer)
|
||||||
func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer)
|
func handleItemDoubleTapped(_ cellViewModel: MessageViewModel)
|
||||||
func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem)
|
func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState)
|
||||||
func handleViewItemSwiped(_ viewItem: ConversationViewItem, state: SwipeState)
|
func openUrl(_ urlString: String)
|
||||||
func showFullText(_ viewItem: ConversationViewItem)
|
func handleReplyButtonTapped(for cellViewModel: MessageViewModel)
|
||||||
func openURL(_ url: URL)
|
func showUserDetails(for profile: Profile)
|
||||||
func handleReplyButtonTapped(for viewItem: ConversationViewItem)
|
func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?)
|
||||||
func showUserDetails(for sessionID: String)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,85 +1,94 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SessionUIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
// Assumptions
|
// Assumptions
|
||||||
// • We'll never encounter an outgoing typing indicator.
|
// • We'll never encounter an outgoing typing indicator.
|
||||||
// • Typing indicators are only sent in contact threads.
|
// • Typing indicators are only sent in contact threads.
|
||||||
|
final class TypingIndicatorCell: MessageCell {
|
||||||
final class TypingIndicatorCell : MessageCell {
|
// MARK: - UI
|
||||||
|
|
||||||
private var positionInCluster: Position? {
|
|
||||||
guard let viewItem = viewItem else { return nil }
|
|
||||||
if viewItem.isFirstInCluster { return .top }
|
|
||||||
if viewItem.isLastInCluster { return .bottom }
|
|
||||||
return .middle
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isOnlyMessageInCluster: Bool { viewItem?.isFirstInCluster == true && viewItem?.isLastInCluster == true }
|
|
||||||
|
|
||||||
// MARK: UI Components
|
|
||||||
private lazy var bubbleView: UIView = {
|
private lazy var bubbleView: UIView = {
|
||||||
let result = UIView()
|
let result: UIView = UIView()
|
||||||
result.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
|
result.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
|
||||||
result.backgroundColor = Colors.receivedMessageBackground
|
result.backgroundColor = Colors.receivedMessageBackground
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private let bubbleViewMaskLayer = CAShapeLayer()
|
private let bubbleViewMaskLayer: CAShapeLayer = CAShapeLayer()
|
||||||
|
|
||||||
private lazy var typingIndicatorView = TypingIndicatorView()
|
private lazy var typingIndicatorView: TypingIndicatorView = TypingIndicatorView()
|
||||||
|
|
||||||
// MARK: Settings
|
// MARK: - Lifecycle
|
||||||
override class var identifier: String { "TypingIndicatorCell" }
|
|
||||||
|
|
||||||
// MARK: Direction & Position
|
|
||||||
enum Position { case top, middle, bottom }
|
|
||||||
|
|
||||||
// MARK: Lifecycle
|
|
||||||
override func setUpViewHierarchy() {
|
override func setUpViewHierarchy() {
|
||||||
super.setUpViewHierarchy()
|
super.setUpViewHierarchy()
|
||||||
|
|
||||||
// Bubble view
|
// Bubble view
|
||||||
addSubview(bubbleView)
|
addSubview(bubbleView)
|
||||||
bubbleView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing)
|
bubbleView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing)
|
||||||
bubbleView.pin(.top, to: .top, of: self, withInset: 1)
|
bubbleView.pin(.top, to: .top, of: self, withInset: 1)
|
||||||
|
|
||||||
// Typing indicator view
|
// Typing indicator view
|
||||||
bubbleView.addSubview(typingIndicatorView)
|
bubbleView.addSubview(typingIndicatorView)
|
||||||
typingIndicatorView.pin(to: bubbleView, withInset: 12)
|
typingIndicatorView.pin(to: bubbleView, withInset: 12)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Updating
|
// MARK: - Updating
|
||||||
override func update() {
|
|
||||||
guard let viewItem = viewItem, viewItem.interaction is TypingIndicatorInteraction else { return }
|
override func update(with cellViewModel: MessageViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
|
||||||
|
guard cellViewModel.cellType == .typingIndicator else { return }
|
||||||
|
|
||||||
|
self.viewModel = cellViewModel
|
||||||
|
|
||||||
// Bubble view
|
// Bubble view
|
||||||
updateBubbleViewCorners()
|
updateBubbleViewCorners()
|
||||||
|
|
||||||
// Typing indicator view
|
// Typing indicator view
|
||||||
typingIndicatorView.startAnimation()
|
typingIndicatorView.startAnimation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||||
|
}
|
||||||
|
|
||||||
override func layoutSubviews() {
|
override func layoutSubviews() {
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
|
|
||||||
updateBubbleViewCorners()
|
updateBubbleViewCorners()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateBubbleViewCorners() {
|
private func updateBubbleViewCorners() {
|
||||||
let maskPath = UIBezierPath(roundedRect: bubbleView.bounds, byRoundingCorners: getCornersToRound(),
|
let maskPath = UIBezierPath(
|
||||||
cornerRadii: CGSize(width: VisibleMessageCell.largeCornerRadius, height: VisibleMessageCell.largeCornerRadius))
|
roundedRect: bubbleView.bounds,
|
||||||
|
byRoundingCorners: getCornersToRound(),
|
||||||
|
cornerRadii: CGSize(
|
||||||
|
width: VisibleMessageCell.largeCornerRadius,
|
||||||
|
height: VisibleMessageCell.largeCornerRadius)
|
||||||
|
)
|
||||||
|
|
||||||
bubbleViewMaskLayer.path = maskPath.cgPath
|
bubbleViewMaskLayer.path = maskPath.cgPath
|
||||||
bubbleView.layer.mask = bubbleViewMaskLayer
|
bubbleView.layer.mask = bubbleViewMaskLayer
|
||||||
}
|
}
|
||||||
|
|
||||||
override func prepareForReuse() {
|
override func prepareForReuse() {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
|
|
||||||
typingIndicatorView.stopAnimation()
|
typingIndicatorView.stopAnimation()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Convenience
|
// MARK: - Convenience
|
||||||
|
|
||||||
private func getCornersToRound() -> UIRectCorner {
|
private func getCornersToRound() -> UIRectCorner {
|
||||||
guard !isOnlyMessageInCluster else { return .allCorners }
|
guard viewModel?.isOnlyMessageInCluster == false else { return .allCorners }
|
||||||
let result: UIRectCorner
|
|
||||||
switch positionInCluster {
|
switch viewModel?.positionInCluster {
|
||||||
case .top: result = [ .topLeft, .topRight, .bottomRight ]
|
case .top: return [ .topLeft, .topRight, .bottomRight ]
|
||||||
case .middle: result = [ .topRight, .bottomRight ]
|
case .middle: return [ .topRight, .bottomRight ]
|
||||||
case .bottom: result = [ .topRight, .bottomRight, .bottomLeft ]
|
case .bottom: return [ .topRight, .bottomRight, .bottomLeft ]
|
||||||
case nil: result = .allCorners
|
case .none: return .allCorners
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -17,7 +17,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
@property (nonatomic) BOOL showVerificationOnAppear;
|
@property (nonatomic) BOOL showVerificationOnAppear;
|
||||||
|
|
||||||
- (void)configureWithThread:(TSThread *)thread uiDatabaseConnection:(YapDatabaseConnection *)uiDatabaseConnection;
|
- (void)configureWithThreadId:(NSString *)threadId threadName:(NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
|
|
|
@ -9,17 +9,8 @@
|
||||||
#import "UIView+OWS.h"
|
#import "UIView+OWS.h"
|
||||||
#import <Curve25519Kit/Curve25519.h>
|
#import <Curve25519Kit/Curve25519.h>
|
||||||
#import <SignalCoreKit/NSDate+OWS.h>
|
#import <SignalCoreKit/NSDate+OWS.h>
|
||||||
#import <SessionMessagingKit/Environment.h>
|
|
||||||
#import <SignalUtilitiesKit/OWSProfileManager.h>
|
|
||||||
#import <SessionMessagingKit/OWSSounds.h>
|
|
||||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||||
#import <SignalUtilitiesKit/UIUtil.h>
|
#import <SignalUtilitiesKit/UIUtil.h>
|
||||||
#import <SessionMessagingKit/OWSDisappearingConfigurationUpdateInfoMessage.h>
|
|
||||||
#import <SessionMessagingKit/OWSDisappearingMessagesConfiguration.h>
|
|
||||||
#import <SessionMessagingKit/OWSPrimaryStorage.h>
|
|
||||||
#import <SessionMessagingKit/TSGroupThread.h>
|
|
||||||
#import <SessionMessagingKit/TSOutgoingMessage.h>
|
|
||||||
#import <SessionMessagingKit/TSThread.h>
|
|
||||||
|
|
||||||
@import ContactsUI;
|
@import ContactsUI;
|
||||||
@import PromiseKit;
|
@import PromiseKit;
|
||||||
|
@ -30,12 +21,18 @@ CGFloat kIconViewLength = 24;
|
||||||
|
|
||||||
@interface OWSConversationSettingsViewController () <OWSSheetViewControllerDelegate>
|
@interface OWSConversationSettingsViewController () <OWSSheetViewControllerDelegate>
|
||||||
|
|
||||||
@property (nonatomic) TSThread *thread;
|
@property (nonatomic) NSString *threadId;
|
||||||
@property (nonatomic) YapDatabaseConnection *uiDatabaseConnection;
|
@property (nonatomic) NSString *threadName;
|
||||||
@property (nonatomic, readonly) YapDatabaseConnection *editingDatabaseConnection;
|
@property (nonatomic) BOOL isNoteToSelf;
|
||||||
|
@property (nonatomic) BOOL isClosedGroup;
|
||||||
|
@property (nonatomic) BOOL isOpenGroup;
|
||||||
@property (nonatomic) NSArray<NSNumber *> *disappearingMessagesDurations;
|
@property (nonatomic) NSArray<NSNumber *> *disappearingMessagesDurations;
|
||||||
@property (nonatomic) OWSDisappearingMessagesConfiguration *disappearingMessagesConfiguration;
|
|
||||||
@property (nullable, nonatomic) MediaGallery *mediaGallery;
|
@property (nonatomic) BOOL originalIsDisappearingMessagesEnabled;
|
||||||
|
@property (nonatomic) NSInteger originalDisappearingMessagesDurationIndex;
|
||||||
|
@property (nonatomic) BOOL isDisappearingMessagesEnabled;
|
||||||
|
@property (nonatomic) NSInteger disappearingMessagesDurationIndex;
|
||||||
|
|
||||||
@property (nonatomic, readonly) UIImageView *avatarView;
|
@property (nonatomic, readonly) UIImageView *avatarView;
|
||||||
@property (nonatomic, readonly) UILabel *disappearingMessagesDurationLabel;
|
@property (nonatomic, readonly) UILabel *disappearingMessagesDurationLabel;
|
||||||
@property (nonatomic) UILabel *displayNameLabel;
|
@property (nonatomic) UILabel *displayNameLabel;
|
||||||
|
@ -56,8 +53,6 @@ CGFloat kIconViewLength = 24;
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
[self commonInit];
|
|
||||||
|
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,8 +63,6 @@ CGFloat kIconViewLength = 24;
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
[self commonInit];
|
|
||||||
|
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,95 +73,24 @@ CGFloat kIconViewLength = 24;
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
[self commonInit];
|
|
||||||
|
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)commonInit
|
|
||||||
{
|
|
||||||
|
|
||||||
[self observeNotifications];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)dealloc
|
|
||||||
{
|
|
||||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Dependencies
|
|
||||||
|
|
||||||
- (TSAccountManager *)tsAccountManager
|
|
||||||
{
|
|
||||||
OWSAssertDebug(SSKEnvironment.shared.tsAccountManager);
|
|
||||||
|
|
||||||
return SSKEnvironment.shared.tsAccountManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (OWSProfileManager *)profileManager
|
|
||||||
{
|
|
||||||
return [OWSProfileManager sharedManager];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark
|
#pragma mark
|
||||||
|
|
||||||
- (void)observeNotifications
|
- (void)configureWithThreadId:(NSString *)threadId threadName:(NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf {
|
||||||
{
|
self.threadId = threadId;
|
||||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
self.threadName = threadName;
|
||||||
selector:@selector(identityStateDidChange:)
|
self.isClosedGroup = isClosedGroup;
|
||||||
name:kNSNotificationName_IdentityStateDidChange
|
self.isOpenGroup = isOpenGroup;
|
||||||
object:nil];
|
self.isNoteToSelf = isNoteToSelf;
|
||||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
||||||
selector:@selector(otherUsersProfileDidChange:)
|
if (!isClosedGroup && !isOpenGroup) {
|
||||||
name:kNSNotificationName_OtherUsersProfileDidChange
|
self.threadName = [SMKProfile displayNameWithId:threadId customFallback:@"Anonymous"];
|
||||||
object:nil];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (YapDatabaseConnection *)editingDatabaseConnection
|
|
||||||
{
|
|
||||||
return [OWSPrimaryStorage sharedManager].dbReadWriteConnection;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (nullable NSString *)threadName
|
|
||||||
{
|
|
||||||
NSString *threadName = self.thread.name;
|
|
||||||
if ([self.thread isKindOfClass:TSContactThread.class]) {
|
|
||||||
TSContactThread *thread = (TSContactThread *)self.thread;
|
|
||||||
return [[LKStorage.shared getContactWithSessionID:thread.contactSessionID] displayNameFor:SNContactContextRegular] ?: @"Anonymous";
|
|
||||||
} else if (threadName.length == 0 && [self isGroupThread]) {
|
|
||||||
threadName = [MessageStrings newGroupDefaultTitle];
|
|
||||||
}
|
}
|
||||||
return threadName;
|
else {
|
||||||
}
|
self.threadName = threadName;
|
||||||
|
|
||||||
- (BOOL)isGroupThread
|
|
||||||
{
|
|
||||||
return [self.thread isKindOfClass:[TSGroupThread class]];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (BOOL)isOpenGroup
|
|
||||||
{
|
|
||||||
if ([self isGroupThread]) {
|
|
||||||
TSGroupThread *thread = (TSGroupThread *)self.thread;
|
|
||||||
return thread.isOpenGroup;
|
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
-(BOOL)isClosedGroup
|
|
||||||
{
|
|
||||||
if (self.isGroupThread) {
|
|
||||||
TSGroupThread *thread = (TSGroupThread *)self.thread;
|
|
||||||
return thread.groupModel.groupType == closedGroup;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)configureWithThread:(TSThread *)thread uiDatabaseConnection:(YapDatabaseConnection *)uiDatabaseConnection
|
|
||||||
{
|
|
||||||
OWSAssertDebug(thread);
|
|
||||||
self.thread = thread;
|
|
||||||
self.uiDatabaseConnection = uiDatabaseConnection;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - ContactEditingDelegate
|
#pragma mark - ContactEditingDelegate
|
||||||
|
@ -211,7 +133,7 @@ CGFloat kIconViewLength = 24;
|
||||||
self.displayNameLabel.font = [UIFont boldSystemFontOfSize:LKValues.largeFontSize];
|
self.displayNameLabel.font = [UIFont boldSystemFontOfSize:LKValues.largeFontSize];
|
||||||
self.displayNameLabel.lineBreakMode = NSLineBreakByTruncatingTail;
|
self.displayNameLabel.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||||
self.displayNameLabel.textAlignment = NSTextAlignmentCenter;
|
self.displayNameLabel.textAlignment = NSTextAlignmentCenter;
|
||||||
|
|
||||||
self.displayNameTextField = [[SNTextField alloc] initWithPlaceholder:@"Enter a name" usesDefaultHeight:NO];
|
self.displayNameTextField = [[SNTextField alloc] initWithPlaceholder:@"Enter a name" usesDefaultHeight:NO];
|
||||||
self.displayNameTextField.textAlignment = NSTextAlignmentCenter;
|
self.displayNameTextField.textAlignment = NSTextAlignmentCenter;
|
||||||
self.displayNameTextField.accessibilityLabel = @"Edit name text field";
|
self.displayNameTextField.accessibilityLabel = @"Edit name text field";
|
||||||
|
@ -220,46 +142,42 @@ CGFloat kIconViewLength = 24;
|
||||||
self.displayNameContainer = [UIView new];
|
self.displayNameContainer = [UIView new];
|
||||||
self.displayNameContainer.accessibilityLabel = @"Edit name text field";
|
self.displayNameContainer.accessibilityLabel = @"Edit name text field";
|
||||||
self.displayNameContainer.isAccessibilityElement = YES;
|
self.displayNameContainer.isAccessibilityElement = YES;
|
||||||
|
|
||||||
[self.displayNameContainer autoSetDimension:ALDimensionHeight toSize:40];
|
[self.displayNameContainer autoSetDimension:ALDimensionHeight toSize:40];
|
||||||
[self.displayNameContainer addSubview:self.displayNameLabel];
|
[self.displayNameContainer addSubview:self.displayNameLabel];
|
||||||
[self.displayNameLabel autoPinToEdgesOfView:self.displayNameContainer];
|
[self.displayNameLabel autoPinToEdgesOfView:self.displayNameContainer];
|
||||||
[self.displayNameContainer addSubview:self.displayNameTextField];
|
[self.displayNameContainer addSubview:self.displayNameTextField];
|
||||||
[self.displayNameTextField autoPinToEdgesOfView:self.displayNameContainer];
|
[self.displayNameTextField autoPinToEdgesOfView:self.displayNameContainer];
|
||||||
|
|
||||||
if ([self.thread isKindOfClass:TSContactThread.class]) {
|
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||||
UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showEditNameUI)];
|
UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showEditNameUI)];
|
||||||
[self.displayNameContainer addGestureRecognizer:tapGestureRecognizer];
|
[self.displayNameContainer addGestureRecognizer:tapGestureRecognizer];
|
||||||
}
|
}
|
||||||
|
|
||||||
self.tableView.estimatedRowHeight = 45;
|
self.tableView.estimatedRowHeight = 45;
|
||||||
self.tableView.rowHeight = UITableViewAutomaticDimension;
|
self.tableView.rowHeight = UITableViewAutomaticDimension;
|
||||||
|
|
||||||
_disappearingMessagesDurationLabel = [UILabel new];
|
_disappearingMessagesDurationLabel = [UILabel new];
|
||||||
SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _disappearingMessagesDurationLabel);
|
SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _disappearingMessagesDurationLabel);
|
||||||
|
|
||||||
self.disappearingMessagesDurations = [OWSDisappearingMessagesConfiguration validDurationsSeconds];
|
self.disappearingMessagesDurations = [SMKDisappearingMessagesConfiguration validDurationsSeconds];
|
||||||
|
self.isDisappearingMessagesEnabled = [SMKDisappearingMessagesConfiguration isEnabledFor: self.threadId];
|
||||||
self.disappearingMessagesConfiguration =
|
self.disappearingMessagesDurationIndex = [SMKDisappearingMessagesConfiguration durationIndexFor: self.threadId];
|
||||||
[OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:self.thread.uniqueId];
|
self.originalIsDisappearingMessagesEnabled = self.isDisappearingMessagesEnabled;
|
||||||
|
self.originalDisappearingMessagesDurationIndex = self.disappearingMessagesDurationIndex;
|
||||||
if (!self.disappearingMessagesConfiguration) {
|
|
||||||
self.disappearingMessagesConfiguration =
|
|
||||||
[[OWSDisappearingMessagesConfiguration alloc] initDefaultWithThreadId:self.thread.uniqueId];
|
|
||||||
}
|
|
||||||
|
|
||||||
[self updateTableContents];
|
|
||||||
|
|
||||||
|
[self updateTableContents];
|
||||||
|
|
||||||
NSString *title;
|
NSString *title;
|
||||||
if ([self.thread isKindOfClass:[TSContactThread class]]) {
|
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||||
title = NSLocalizedString(@"Settings", @"");
|
title = NSLocalizedString(@"Settings", @"");
|
||||||
} else {
|
} else {
|
||||||
title = NSLocalizedString(@"Group Settings", @"");
|
title = NSLocalizedString(@"Group Settings", @"");
|
||||||
}
|
}
|
||||||
[LKViewControllerUtilities setUpDefaultSessionStyleForVC:self withTitle:title customBackButton:YES];
|
[LKViewControllerUtilities setUpDefaultSessionStyleForVC:self withTitle:title customBackButton:YES];
|
||||||
self.tableView.backgroundColor = UIColor.clearColor;
|
self.tableView.backgroundColor = UIColor.clearColor;
|
||||||
|
|
||||||
if ([self.thread isKindOfClass:TSContactThread.class]) {
|
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||||
[self updateNavBarButtons];
|
[self updateNavBarButtons];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -269,8 +187,6 @@ CGFloat kIconViewLength = 24;
|
||||||
OWSTableContents *contents = [OWSTableContents new];
|
OWSTableContents *contents = [OWSTableContents new];
|
||||||
contents.title = NSLocalizedString(@"CONVERSATION_SETTINGS", @"title for conversation settings screen");
|
contents.title = NSLocalizedString(@"CONVERSATION_SETTINGS", @"title for conversation settings screen");
|
||||||
|
|
||||||
BOOL isNoteToSelf = self.thread.isNoteToSelf;
|
|
||||||
|
|
||||||
__weak OWSConversationSettingsViewController *weakSelf = self;
|
__weak OWSConversationSettingsViewController *weakSelf = self;
|
||||||
|
|
||||||
OWSTableSection *section = [OWSTableSection new];
|
OWSTableSection *section = [OWSTableSection new];
|
||||||
|
@ -279,7 +195,7 @@ CGFloat kIconViewLength = 24;
|
||||||
section.customHeaderHeight = @(UITableViewAutomaticDimension);
|
section.customHeaderHeight = @(UITableViewAutomaticDimension);
|
||||||
|
|
||||||
// Copy Session ID
|
// Copy Session ID
|
||||||
if ([self.thread isKindOfClass:TSContactThread.class]) {
|
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||||
return [weakSelf
|
return [weakSelf
|
||||||
disclosureCellWithName:NSLocalizedString(@"vc_conversation_settings_copy_session_id_button_title", "")
|
disclosureCellWithName:NSLocalizedString(@"vc_conversation_settings_copy_session_id_button_title", "")
|
||||||
|
@ -300,7 +216,7 @@ CGFloat kIconViewLength = 24;
|
||||||
} actionBlock:^{
|
} actionBlock:^{
|
||||||
[weakSelf showMediaGallery];
|
[weakSelf showMediaGallery];
|
||||||
}]];
|
}]];
|
||||||
|
|
||||||
// Invite button
|
// Invite button
|
||||||
if (self.isOpenGroup) {
|
if (self.isOpenGroup) {
|
||||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||||
|
@ -325,9 +241,9 @@ CGFloat kIconViewLength = 24;
|
||||||
} actionBlock:^{
|
} actionBlock:^{
|
||||||
[weakSelf tappedConversationSearch];
|
[weakSelf tappedConversationSearch];
|
||||||
}]];
|
}]];
|
||||||
|
|
||||||
// Disappearing messages
|
// Disappearing messages
|
||||||
if (![self isOpenGroup] && !self.thread.isBlocked) {
|
if (![self isOpenGroup] && ![SMKContact isBlockedFor:self.threadId]) {
|
||||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||||
UITableViewCell *cell = [OWSTableItem newCell];
|
UITableViewCell *cell = [OWSTableItem newCell];
|
||||||
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
||||||
|
@ -337,7 +253,7 @@ CGFloat kIconViewLength = 24;
|
||||||
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||||
|
|
||||||
NSString *iconName
|
NSString *iconName
|
||||||
= (strongSelf.disappearingMessagesConfiguration.isEnabled ? @"ic_timer" : @"ic_timer_disabled");
|
= (strongSelf.isDisappearingMessagesEnabled ? @"ic_timer" : @"ic_timer_disabled");
|
||||||
UIImageView *iconView = [strongSelf viewForIconWithName:iconName];
|
UIImageView *iconView = [strongSelf viewForIconWithName:iconName];
|
||||||
|
|
||||||
UILabel *rowLabel = [UILabel new];
|
UILabel *rowLabel = [UILabel new];
|
||||||
|
@ -348,7 +264,7 @@ CGFloat kIconViewLength = 24;
|
||||||
rowLabel.lineBreakMode = NSLineBreakByTruncatingTail;
|
rowLabel.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||||
|
|
||||||
UISwitch *switchView = [UISwitch new];
|
UISwitch *switchView = [UISwitch new];
|
||||||
switchView.on = strongSelf.disappearingMessagesConfiguration.isEnabled;
|
switchView.on = strongSelf.isDisappearingMessagesEnabled;
|
||||||
[switchView addTarget:strongSelf action:@selector(disappearingMessagesSwitchValueDidChange:)
|
[switchView addTarget:strongSelf action:@selector(disappearingMessagesSwitchValueDidChange:)
|
||||||
forControlEvents:UIControlEventValueChanged];
|
forControlEvents:UIControlEventValueChanged];
|
||||||
|
|
||||||
|
@ -361,11 +277,10 @@ CGFloat kIconViewLength = 24;
|
||||||
|
|
||||||
UILabel *subtitleLabel = [UILabel new];
|
UILabel *subtitleLabel = [UILabel new];
|
||||||
NSString *displayName;
|
NSString *displayName;
|
||||||
if (self.thread.isGroupThread) {
|
if (self.isClosedGroup || self.isOpenGroup) {
|
||||||
displayName = @"the group";
|
displayName = @"the group";
|
||||||
} else {
|
} else {
|
||||||
TSContactThread *thread = (TSContactThread *)self.thread;
|
displayName = [SMKProfile displayNameWithId:self.threadId customFallback:@"anonymous"];
|
||||||
displayName = [[LKStorage.shared getContactWithSessionID:thread.contactSessionID] displayNameFor:SNContactContextRegular] ?: @"anonymous";
|
|
||||||
}
|
}
|
||||||
subtitleLabel.text = [NSString stringWithFormat:NSLocalizedString(@"When enabled, messages between you and %@ will disappear after they have been seen.", ""), displayName];
|
subtitleLabel.text = [NSString stringWithFormat:NSLocalizedString(@"When enabled, messages between you and %@ will disappear after they have been seen.", ""), displayName];
|
||||||
subtitleLabel.textColor = LKColors.text;
|
subtitleLabel.textColor = LKColors.text;
|
||||||
|
@ -385,7 +300,7 @@ CGFloat kIconViewLength = 24;
|
||||||
return cell;
|
return cell;
|
||||||
} customRowHeight:UITableViewAutomaticDimension actionBlock:nil]];
|
} customRowHeight:UITableViewAutomaticDimension actionBlock:nil]];
|
||||||
|
|
||||||
if (self.disappearingMessagesConfiguration.isEnabled) {
|
if (self.isDisappearingMessagesEnabled) {
|
||||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||||
UITableViewCell *cell = [OWSTableItem newCell];
|
UITableViewCell *cell = [OWSTableItem newCell];
|
||||||
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
||||||
|
@ -415,7 +330,7 @@ CGFloat kIconViewLength = 24;
|
||||||
slider.minimumValue = 0;
|
slider.minimumValue = 0;
|
||||||
slider.tintColor = LKColors.accent;
|
slider.tintColor = LKColors.accent;
|
||||||
slider.continuous = NO;
|
slider.continuous = NO;
|
||||||
slider.value = strongSelf.disappearingMessagesConfiguration.durationIndex;
|
slider.value = strongSelf.disappearingMessagesDurationIndex;
|
||||||
[slider addTarget:strongSelf action:@selector(durationSliderDidChange:)
|
[slider addTarget:strongSelf action:@selector(durationSliderDidChange:)
|
||||||
forControlEvents:UIControlEventValueChanged];
|
forControlEvents:UIControlEventValueChanged];
|
||||||
[cell.contentView addSubview:slider];
|
[cell.contentView addSubview:slider];
|
||||||
|
@ -423,7 +338,7 @@ CGFloat kIconViewLength = 24;
|
||||||
[slider autoPinEdge:ALEdgeLeading toEdge:ALEdgeLeading ofView:rowLabel];
|
[slider autoPinEdge:ALEdgeLeading toEdge:ALEdgeLeading ofView:rowLabel];
|
||||||
[slider autoPinTrailingToSuperviewMargin];
|
[slider autoPinTrailingToSuperviewMargin];
|
||||||
[slider autoPinBottomToSuperviewMargin];
|
[slider autoPinBottomToSuperviewMargin];
|
||||||
|
|
||||||
cell.userInteractionEnabled = !strongSelf.hasLeftGroup;
|
cell.userInteractionEnabled = !strongSelf.hasLeftGroup;
|
||||||
|
|
||||||
cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(
|
cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(
|
||||||
|
@ -438,11 +353,10 @@ CGFloat kIconViewLength = 24;
|
||||||
|
|
||||||
// Closed group settings
|
// Closed group settings
|
||||||
__block BOOL isUserMember = NO;
|
__block BOOL isUserMember = NO;
|
||||||
if (self.isGroupThread) {
|
if (self.isClosedGroup || self.isOpenGroup) {
|
||||||
NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey];
|
isUserMember = [SMKGroupMember isCurrentUserMemberOf:self.threadId];
|
||||||
isUserMember = [(TSGroupThread *)self.thread isUserMemberInGroup:userPublicKey];
|
|
||||||
}
|
}
|
||||||
if (self.isGroupThread && self.isClosedGroup && isUserMember) {
|
if (self.isClosedGroup && isUserMember) {
|
||||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||||
UITableViewCell *cell =
|
UITableViewCell *cell =
|
||||||
[weakSelf disclosureCellWithName:NSLocalizedString(@"EDIT_GROUP_ACTION", @"table cell label in conversation settings")
|
[weakSelf disclosureCellWithName:NSLocalizedString(@"EDIT_GROUP_ACTION", @"table cell label in conversation settings")
|
||||||
|
@ -465,8 +379,8 @@ CGFloat kIconViewLength = 24;
|
||||||
[weakSelf didTapLeaveGroup];
|
[weakSelf didTapLeaveGroup];
|
||||||
}]];
|
}]];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isNoteToSelf) {
|
if (!self.isNoteToSelf) {
|
||||||
// Notification sound
|
// Notification sound
|
||||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||||
UITableViewCell *cell =
|
UITableViewCell *cell =
|
||||||
|
@ -493,8 +407,8 @@ CGFloat kIconViewLength = 24;
|
||||||
[cell.contentView addSubview:contentRow];
|
[cell.contentView addSubview:contentRow];
|
||||||
[contentRow autoPinEdgesToSuperviewMargins];
|
[contentRow autoPinEdgesToSuperviewMargins];
|
||||||
|
|
||||||
OWSSound sound = [OWSSounds notificationSoundForThread:strongSelf.thread];
|
NSInteger sound = [SMKSound notificationSoundFor:strongSelf.threadId];
|
||||||
cell.detailTextLabel.text = [OWSSounds displayNameForSound:sound];
|
cell.detailTextLabel.text = [SMKSound displayNameFor:sound];
|
||||||
|
|
||||||
cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(
|
cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(
|
||||||
OWSConversationSettingsViewController, @"notifications");
|
OWSConversationSettingsViewController, @"notifications");
|
||||||
|
@ -504,11 +418,11 @@ CGFloat kIconViewLength = 24;
|
||||||
customRowHeight:UITableViewAutomaticDimension
|
customRowHeight:UITableViewAutomaticDimension
|
||||||
actionBlock:^{
|
actionBlock:^{
|
||||||
OWSSoundSettingsViewController *vc = [OWSSoundSettingsViewController new];
|
OWSSoundSettingsViewController *vc = [OWSSoundSettingsViewController new];
|
||||||
vc.thread = weakSelf.thread;
|
vc.threadId = weakSelf.threadId;
|
||||||
[weakSelf.navigationController pushViewController:vc animated:YES];
|
[weakSelf.navigationController pushViewController:vc animated:YES];
|
||||||
}]];
|
}]];
|
||||||
|
|
||||||
if (self.isGroupThread) {
|
if (self.isClosedGroup || self.isOpenGroup) {
|
||||||
// Notification Settings
|
// Notification Settings
|
||||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||||
UITableViewCell *cell = [OWSTableItem newCell];
|
UITableViewCell *cell = [OWSTableItem newCell];
|
||||||
|
@ -527,7 +441,7 @@ CGFloat kIconViewLength = 24;
|
||||||
rowLabel.lineBreakMode = NSLineBreakByTruncatingTail;
|
rowLabel.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||||
|
|
||||||
UISwitch *switchView = [UISwitch new];
|
UISwitch *switchView = [UISwitch new];
|
||||||
switchView.on = ((TSGroupThread *)strongSelf.thread).isOnlyNotifyingForMentions;
|
switchView.on = [SMKThread isOnlyNotifyingForMentions:strongSelf.threadId];
|
||||||
[switchView addTarget:strongSelf action:@selector(notifyForMentionsOnlySwitchValueDidChange:)
|
[switchView addTarget:strongSelf action:@selector(notifyForMentionsOnlySwitchValueDidChange:)
|
||||||
forControlEvents:UIControlEventValueChanged];
|
forControlEvents:UIControlEventValueChanged];
|
||||||
|
|
||||||
|
@ -557,7 +471,7 @@ CGFloat kIconViewLength = 24;
|
||||||
return cell;
|
return cell;
|
||||||
} customRowHeight:UITableViewAutomaticDimension actionBlock:nil]];
|
} customRowHeight:UITableViewAutomaticDimension actionBlock:nil]];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mute thread
|
// Mute thread
|
||||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||||
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
||||||
|
@ -570,7 +484,7 @@ CGFloat kIconViewLength = 24;
|
||||||
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||||
|
|
||||||
UISwitch *muteConversationSwitch = [UISwitch new];
|
UISwitch *muteConversationSwitch = [UISwitch new];
|
||||||
NSDate *mutedUntilDate = strongSelf.thread.mutedUntilDate;
|
NSDate *mutedUntilDate = [SMKThread mutedUntilDateFor:strongSelf.threadId];
|
||||||
NSDate *now = [NSDate date];
|
NSDate *now = [NSDate date];
|
||||||
muteConversationSwitch.on = (mutedUntilDate != nil && [mutedUntilDate timeIntervalSinceDate:now] > 0);
|
muteConversationSwitch.on = (mutedUntilDate != nil && [mutedUntilDate timeIntervalSinceDate:now] > 0);
|
||||||
[muteConversationSwitch addTarget:strongSelf action:@selector(handleMuteSwitchToggled:)
|
[muteConversationSwitch addTarget:strongSelf action:@selector(handleMuteSwitchToggled:)
|
||||||
|
@ -580,9 +494,9 @@ CGFloat kIconViewLength = 24;
|
||||||
return cell;
|
return cell;
|
||||||
} actionBlock:nil]];
|
} actionBlock:nil]];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block contact
|
// Block contact
|
||||||
if (!isNoteToSelf && [self.thread isKindOfClass:TSContactThread.class]) {
|
if (!self.isNoteToSelf && !self.isClosedGroup && !self.isOpenGroup) {
|
||||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||||
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
||||||
if (!strongSelf) { return [UITableViewCell new]; }
|
if (!strongSelf) { return [UITableViewCell new]; }
|
||||||
|
@ -594,7 +508,7 @@ CGFloat kIconViewLength = 24;
|
||||||
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||||
|
|
||||||
UISwitch *blockConversationSwitch = [UISwitch new];
|
UISwitch *blockConversationSwitch = [UISwitch new];
|
||||||
blockConversationSwitch.on = strongSelf.thread.isBlocked;
|
blockConversationSwitch.on = [SMKContact isBlockedFor:strongSelf.threadId];
|
||||||
[blockConversationSwitch addTarget:strongSelf action:@selector(blockConversationSwitchDidChange:)
|
[blockConversationSwitch addTarget:strongSelf action:@selector(blockConversationSwitchDidChange:)
|
||||||
forControlEvents:UIControlEventValueChanged];
|
forControlEvents:UIControlEventValueChanged];
|
||||||
cell.accessoryView = blockConversationSwitch;
|
cell.accessoryView = blockConversationSwitch;
|
||||||
|
@ -681,36 +595,36 @@ CGFloat kIconViewLength = 24;
|
||||||
[profilePictureView autoSetDimension:ALDimensionWidth toSize:size];
|
[profilePictureView autoSetDimension:ALDimensionWidth toSize:size];
|
||||||
[profilePictureView autoSetDimension:ALDimensionHeight toSize:size];
|
[profilePictureView autoSetDimension:ALDimensionHeight toSize:size];
|
||||||
[profilePictureView addGestureRecognizer:profilePictureTapGestureRecognizer];
|
[profilePictureView addGestureRecognizer:profilePictureTapGestureRecognizer];
|
||||||
|
|
||||||
self.displayNameLabel.text = (self.threadName != nil && self.threadName.length > 0) ? self.threadName : @"Anonymous";
|
self.displayNameLabel.text = (self.threadName != nil && self.threadName.length > 0) ? self.threadName : @"Anonymous";
|
||||||
if ([self.thread isKindOfClass:TSContactThread.class]) {
|
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||||
UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showEditNameUI)];
|
UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showEditNameUI)];
|
||||||
[self.displayNameContainer addGestureRecognizer:tapGestureRecognizer];
|
[self.displayNameContainer addGestureRecognizer:tapGestureRecognizer];
|
||||||
}
|
}
|
||||||
|
|
||||||
UIStackView *stackView = [[UIStackView alloc] initWithArrangedSubviews:@[ profilePictureView, self.displayNameContainer ]];
|
UIStackView *stackView = [[UIStackView alloc] initWithArrangedSubviews:@[ profilePictureView, self.displayNameContainer ]];
|
||||||
stackView.axis = UILayoutConstraintAxisVertical;
|
stackView.axis = UILayoutConstraintAxisVertical;
|
||||||
stackView.spacing = LKValues.mediumSpacing;
|
stackView.spacing = LKValues.mediumSpacing;
|
||||||
stackView.distribution = UIStackViewDistributionEqualCentering;
|
stackView.distribution = UIStackViewDistributionEqualCentering;
|
||||||
stackView.alignment = UIStackViewAlignmentCenter;
|
stackView.alignment = UIStackViewAlignmentCenter;
|
||||||
BOOL isSmallScreen = (UIScreen.mainScreen.bounds.size.height - 568) < 1;
|
BOOL isSmallScreen = (UIScreen.mainScreen.bounds.size.height - 568) < 1;
|
||||||
CGFloat horizontalSpacing = isSmallScreen ? LKValues.largeSpacing : LKValues.veryLargeSpacing;
|
CGFloat horizontalSpacing = isSmallScreen ? LKValues.largeSpacing : LKValues.veryLargeSpacing;
|
||||||
stackView.layoutMargins = UIEdgeInsetsMake(LKValues.mediumSpacing, horizontalSpacing, LKValues.mediumSpacing, horizontalSpacing);
|
stackView.layoutMargins = UIEdgeInsetsMake(LKValues.mediumSpacing, horizontalSpacing, LKValues.mediumSpacing, horizontalSpacing);
|
||||||
[stackView setLayoutMarginsRelativeArrangement:YES];
|
[stackView setLayoutMarginsRelativeArrangement:YES];
|
||||||
|
|
||||||
if (!self.isGroupThread) {
|
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||||
SRCopyableLabel *subtitleView = [SRCopyableLabel new];
|
SRCopyableLabel *subtitleView = [SRCopyableLabel new];
|
||||||
subtitleView.textColor = LKColors.text;
|
subtitleView.textColor = LKColors.text;
|
||||||
subtitleView.font = [LKFonts spaceMonoOfSize:LKValues.smallFontSize];
|
subtitleView.font = [LKFonts spaceMonoOfSize:LKValues.smallFontSize];
|
||||||
subtitleView.lineBreakMode = NSLineBreakByCharWrapping;
|
subtitleView.lineBreakMode = NSLineBreakByCharWrapping;
|
||||||
subtitleView.numberOfLines = 2;
|
subtitleView.numberOfLines = 2;
|
||||||
subtitleView.text = ((TSContactThread *)self.thread).contactSessionID;
|
subtitleView.text = self.threadId;
|
||||||
subtitleView.textAlignment = NSTextAlignmentCenter;
|
subtitleView.textAlignment = NSTextAlignmentCenter;
|
||||||
[stackView addArrangedSubview:subtitleView];
|
[stackView addArrangedSubview:subtitleView];
|
||||||
}
|
}
|
||||||
|
|
||||||
[profilePictureView updateForThread:self.thread];
|
[profilePictureView updateForThreadId:self.threadId];
|
||||||
|
|
||||||
return stackView;
|
return stackView;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -749,48 +663,41 @@ CGFloat kIconViewLength = 24;
|
||||||
{
|
{
|
||||||
[super viewWillDisappear:animated];
|
[super viewWillDisappear:animated];
|
||||||
|
|
||||||
if (self.disappearingMessagesConfiguration.isNewRecord && !self.disappearingMessagesConfiguration.isEnabled) {
|
// Do nothing if the values haven't changed (or if it's disabled and only the 'durationIndex'
|
||||||
// don't save defaults, else we'll unintentionally save the configuration and notify the contact.
|
// has changed as the 'durationIndex' value defaults to 1 hour when disabled)
|
||||||
|
if (
|
||||||
|
self.isDisappearingMessagesEnabled == self.originalIsDisappearingMessagesEnabled && (
|
||||||
|
!self.originalIsDisappearingMessagesEnabled ||
|
||||||
|
self.disappearingMessagesDurationIndex == self.originalDisappearingMessagesDurationIndex
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self.disappearingMessagesConfiguration.dictionaryValueDidChange) {
|
[SMKDisappearingMessagesConfiguration
|
||||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) {
|
update:self.threadId
|
||||||
[self.disappearingMessagesConfiguration saveWithTransaction:transaction];
|
isEnabled: self.isDisappearingMessagesEnabled
|
||||||
OWSDisappearingConfigurationUpdateInfoMessage *infoMessage = [[OWSDisappearingConfigurationUpdateInfoMessage alloc]
|
durationIndex: self.disappearingMessagesDurationIndex
|
||||||
initWithTimestamp:[NSDate ows_millisecondTimeStamp]
|
];
|
||||||
thread:self.thread
|
|
||||||
configuration:self.disappearingMessagesConfiguration
|
|
||||||
createdByRemoteName:nil
|
|
||||||
createdInExistingGroup:NO];
|
|
||||||
[infoMessage saveWithTransaction:transaction];
|
|
||||||
|
|
||||||
SNExpirationTimerUpdate *expirationTimerUpdate = [SNExpirationTimerUpdate new];
|
|
||||||
BOOL isEnabled = self.disappearingMessagesConfiguration.enabled;
|
|
||||||
expirationTimerUpdate.duration = isEnabled ? self.disappearingMessagesConfiguration.durationSeconds : 0;
|
|
||||||
[SNMessageSender send:expirationTimerUpdate inThread:self.thread usingTransaction:transaction];
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma mark - Actions
|
#pragma mark - Actions
|
||||||
|
|
||||||
- (void)editGroup
|
- (void)editGroup
|
||||||
{
|
{
|
||||||
SNEditClosedGroupVC *editClosedGroupVC = [[SNEditClosedGroupVC alloc] initWithThreadID:self.thread.uniqueId];
|
SNEditClosedGroupVC *editClosedGroupVC = [[SNEditClosedGroupVC alloc] initWithThreadId:self.threadId];
|
||||||
[self.navigationController pushViewController:editClosedGroupVC animated:YES completion:nil];
|
[self.navigationController pushViewController:editClosedGroupVC animated:YES completion:nil];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)didTapLeaveGroup
|
- (void)didTapLeaveGroup
|
||||||
{
|
{
|
||||||
NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey];
|
|
||||||
NSString *message;
|
NSString *message;
|
||||||
if ([((TSGroupThread *)self.thread).groupModel.groupAdminIds containsObject:userPublicKey]) {
|
if ([SMKGroupMember isCurrentUserAdminOf:self.threadId]) {
|
||||||
message = @"Because you are the creator of this group it will be deleted for everyone. This cannot be undone.";
|
message = @"Because you are the creator of this group it will be deleted for everyone. This cannot be undone.";
|
||||||
} else {
|
} else {
|
||||||
message = NSLocalizedString(@"CONFIRM_LEAVE_GROUP_DESCRIPTION", @"Alert body");
|
message = NSLocalizedString(@"CONFIRM_LEAVE_GROUP_DESCRIPTION", @"Alert body");
|
||||||
}
|
}
|
||||||
|
|
||||||
UIAlertController *alert =
|
UIAlertController *alert =
|
||||||
[UIAlertController alertControllerWithTitle:NSLocalizedString(@"CONFIRM_LEAVE_GROUP_TITLE", @"Alert title")
|
[UIAlertController alertControllerWithTitle:NSLocalizedString(@"CONFIRM_LEAVE_GROUP_TITLE", @"Alert title")
|
||||||
message:message
|
message:message
|
||||||
|
@ -811,9 +718,8 @@ CGFloat kIconViewLength = 24;
|
||||||
|
|
||||||
- (BOOL)hasLeftGroup
|
- (BOOL)hasLeftGroup
|
||||||
{
|
{
|
||||||
if (self.isGroupThread) {
|
if (self.isClosedGroup) {
|
||||||
TSGroupThread *groupThread = (TSGroupThread *)self.thread;
|
return ![SMKGroupMember isCurrentUserMemberOf:self.threadId];
|
||||||
return !groupThread.isCurrentUserMemberInGroup;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NO;
|
return NO;
|
||||||
|
@ -821,13 +727,8 @@ CGFloat kIconViewLength = 24;
|
||||||
|
|
||||||
- (void)leaveGroup
|
- (void)leaveGroup
|
||||||
{
|
{
|
||||||
TSGroupThread *gThread = (TSGroupThread *)self.thread;
|
if (self.isClosedGroup) {
|
||||||
|
[[SMKMessageSender leaveClosedGroupWithPublicKey:self.threadId] retainUntilComplete];
|
||||||
if (gThread.isClosedGroup) {
|
|
||||||
NSString *groupPublicKey = [LKGroupUtilities getDecodedGroupID:gThread.groupModel.groupId];
|
|
||||||
[LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) {
|
|
||||||
[[SNMessageSender leaveClosedGroupWithPublicKey:groupPublicKey using:transaction] retainUntilComplete];
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[self.navigationController popViewControllerAnimated:YES];
|
[self.navigationController popViewControllerAnimated:YES];
|
||||||
|
@ -846,13 +747,9 @@ CGFloat kIconViewLength = 24;
|
||||||
{
|
{
|
||||||
UISwitch *uiSwitch = (UISwitch *)sender;
|
UISwitch *uiSwitch = (UISwitch *)sender;
|
||||||
if (uiSwitch.isOn) {
|
if (uiSwitch.isOn) {
|
||||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
[SMKThread updateWithMutedUntilDateTo:[NSDate distantFuture] forThreadId:self.threadId];
|
||||||
[self.thread updateWithMutedUntilDate:[NSDate distantFuture] transaction:transaction];
|
|
||||||
}];
|
|
||||||
} else {
|
} else {
|
||||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
[SMKThread updateWithMutedUntilDateTo:nil forThreadId:self.threadId];
|
||||||
[self.thread updateWithMutedUntilDate:nil transaction:transaction];
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -861,13 +758,12 @@ CGFloat kIconViewLength = 24;
|
||||||
if (![sender isKindOfClass:[UISwitch class]]) {
|
if (![sender isKindOfClass:[UISwitch class]]) {
|
||||||
OWSFailDebug(@"Unexpected sender for block user switch: %@", sender);
|
OWSFailDebug(@"Unexpected sender for block user switch: %@", sender);
|
||||||
}
|
}
|
||||||
if (![self.thread isKindOfClass:[TSContactThread class]]) {
|
if (self.isClosedGroup || self.isOpenGroup) {
|
||||||
OWSFailDebug(@"unexpected thread type: %@", self.thread.class);
|
OWSFailDebug(@"unexpected group thread");
|
||||||
}
|
}
|
||||||
UISwitch *blockConversationSwitch = (UISwitch *)sender;
|
UISwitch *blockConversationSwitch = (UISwitch *)sender;
|
||||||
TSContactThread *contactThread = (TSContactThread *)self.thread;
|
|
||||||
|
|
||||||
BOOL isCurrentlyBlocked = contactThread.isBlocked;
|
BOOL isCurrentlyBlocked = [SMKContact isBlockedFor:self.threadId];
|
||||||
|
|
||||||
__weak OWSConversationSettingsViewController *weakSelf = self;
|
__weak OWSConversationSettingsViewController *weakSelf = self;
|
||||||
if (blockConversationSwitch.isOn) {
|
if (blockConversationSwitch.isOn) {
|
||||||
|
@ -875,15 +771,15 @@ CGFloat kIconViewLength = 24;
|
||||||
if (isCurrentlyBlocked) {
|
if (isCurrentlyBlocked) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
[BlockListUIUtils showBlockThreadActionSheet:contactThread
|
[BlockListUIUtils showBlockThreadActionSheet:self.threadId
|
||||||
from:self
|
from:self
|
||||||
completionBlock:^(BOOL isBlocked) {
|
completionBlock:^(BOOL isBlocked) {
|
||||||
// Update switch state if user cancels action.
|
// Update switch state if user cancels action.
|
||||||
blockConversationSwitch.on = isBlocked;
|
blockConversationSwitch.on = isBlocked;
|
||||||
|
|
||||||
// If we successfully blocked then force a config sync
|
// If we successfully blocked then force a config sync
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
[SNMessageSender forceSyncConfigurationNow];
|
[SMKMessageSender forceSyncConfigurationNow];
|
||||||
}
|
}
|
||||||
|
|
||||||
[weakSelf updateTableContents];
|
[weakSelf updateTableContents];
|
||||||
|
@ -894,15 +790,15 @@ CGFloat kIconViewLength = 24;
|
||||||
if (!isCurrentlyBlocked) {
|
if (!isCurrentlyBlocked) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
[BlockListUIUtils showUnblockThreadActionSheet:contactThread
|
[BlockListUIUtils showUnblockThreadActionSheet:self.threadId
|
||||||
from:self
|
from:self
|
||||||
completionBlock:^(BOOL isBlocked) {
|
completionBlock:^(BOOL isBlocked) {
|
||||||
// Update switch state if user cancels action.
|
// Update switch state if user cancels action.
|
||||||
blockConversationSwitch.on = isBlocked;
|
blockConversationSwitch.on = isBlocked;
|
||||||
|
|
||||||
// If we successfully unblocked then force a config sync
|
// If we successfully unblocked then force a config sync
|
||||||
if (!isBlocked) {
|
if (!isBlocked) {
|
||||||
[SNMessageSender forceSyncConfigurationNow];
|
[SMKMessageSender forceSyncConfigurationNow];
|
||||||
}
|
}
|
||||||
|
|
||||||
[weakSelf updateTableContents];
|
[weakSelf updateTableContents];
|
||||||
|
@ -912,7 +808,7 @@ CGFloat kIconViewLength = 24;
|
||||||
|
|
||||||
- (void)toggleDisappearingMessages:(BOOL)flag
|
- (void)toggleDisappearingMessages:(BOOL)flag
|
||||||
{
|
{
|
||||||
self.disappearingMessagesConfiguration.enabled = flag;
|
self.isDisappearingMessagesEnabled = flag;
|
||||||
|
|
||||||
[self updateTableContents];
|
[self updateTableContents];
|
||||||
}
|
}
|
||||||
|
@ -920,21 +816,23 @@ CGFloat kIconViewLength = 24;
|
||||||
- (void)durationSliderDidChange:(UISlider *)slider
|
- (void)durationSliderDidChange:(UISlider *)slider
|
||||||
{
|
{
|
||||||
// snap the slider to a valid value
|
// snap the slider to a valid value
|
||||||
NSUInteger index = (NSUInteger)(slider.value + 0.5);
|
NSInteger index = (NSInteger)(slider.value + 0.5);
|
||||||
[slider setValue:index animated:YES];
|
[slider setValue:index animated:YES];
|
||||||
NSNumber *numberOfSeconds = self.disappearingMessagesDurations[index];
|
self.disappearingMessagesDurationIndex = index;
|
||||||
self.disappearingMessagesConfiguration.durationSeconds = [numberOfSeconds unsignedIntValue];
|
|
||||||
|
|
||||||
[self updateDisappearingMessagesDurationLabel];
|
[self updateDisappearingMessagesDurationLabel];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)updateDisappearingMessagesDurationLabel
|
- (void)updateDisappearingMessagesDurationLabel
|
||||||
{
|
{
|
||||||
if (self.disappearingMessagesConfiguration.isEnabled) {
|
if (self.isDisappearingMessagesEnabled) {
|
||||||
NSString *keepForFormat = @"Disappear after %@";
|
NSString *keepForFormat = @"Disappear after %@";
|
||||||
self.disappearingMessagesDurationLabel.text =
|
self.disappearingMessagesDurationLabel.text = [NSString
|
||||||
[NSString stringWithFormat:keepForFormat, self.disappearingMessagesConfiguration.durationString];
|
stringWithFormat:keepForFormat,
|
||||||
} else {
|
[SMKDisappearingMessagesConfiguration durationStringFor: self.disappearingMessagesDurationIndex]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
else {
|
||||||
self.disappearingMessagesDurationLabel.text
|
self.disappearingMessagesDurationLabel.text
|
||||||
= NSLocalizedString(@"KEEP_MESSAGES_FOREVER", @"Slider label when disappearing messages is off");
|
= NSLocalizedString(@"KEEP_MESSAGES_FOREVER", @"Slider label when disappearing messages is off");
|
||||||
}
|
}
|
||||||
|
@ -945,30 +843,16 @@ CGFloat kIconViewLength = 24;
|
||||||
|
|
||||||
- (void)copySessionID
|
- (void)copySessionID
|
||||||
{
|
{
|
||||||
UIPasteboard.generalPasteboard.string = ((TSContactThread *)self.thread).contactSessionID;
|
UIPasteboard.generalPasteboard.string = self.threadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)inviteUsersToOpenGroup
|
- (void)inviteUsersToOpenGroup
|
||||||
{
|
{
|
||||||
NSString *threadID = self.thread.uniqueId;
|
NSString *threadId = self.threadId;
|
||||||
SNOpenGroupV2 *openGroup = [LKStorage.shared getV2OpenGroupForThreadID:threadID];
|
|
||||||
NSString *url = [NSString stringWithFormat:@"%@/%@?public_key=%@", openGroup.server, openGroup.room, openGroup.publicKey];
|
|
||||||
SNUserSelectionVC *userSelectionVC = [[SNUserSelectionVC alloc] initWithTitle:NSLocalizedString(@"vc_conversation_settings_invite_button_title", @"")
|
SNUserSelectionVC *userSelectionVC = [[SNUserSelectionVC alloc] initWithTitle:NSLocalizedString(@"vc_conversation_settings_invite_button_title", @"")
|
||||||
excluding:[NSSet new]
|
excluding:[NSSet new]
|
||||||
completion:^(NSSet<NSString *> *selectedUsers) {
|
completion:^(NSSet<NSString *> *selectedUsers) {
|
||||||
for (NSString *user in selectedUsers) {
|
[SMKOpenGroup inviteUsers:selectedUsers toOpenGroupFor:threadId];
|
||||||
SNVisibleMessage *message = [SNVisibleMessage new];
|
|
||||||
message.sentTimestamp = [NSDate millisecondTimestamp];
|
|
||||||
message.openGroupInvitation = [[SNOpenGroupInvitation alloc] initWithName:openGroup.name url:url];
|
|
||||||
TSContactThread *thread = [TSContactThread getOrCreateThreadWithContactSessionID:user];
|
|
||||||
TSOutgoingMessage *tsMessage = [TSOutgoingMessage from:message associatedWith:thread];
|
|
||||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
|
||||||
[tsMessage saveWithTransaction:transaction];
|
|
||||||
}];
|
|
||||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
|
||||||
[SNMessageSender send:message inThread:thread usingTransaction:transaction];
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
}];
|
}];
|
||||||
[self.navigationController pushViewController:userSelectionVC animated:YES];
|
[self.navigationController pushViewController:userSelectionVC animated:YES];
|
||||||
}
|
}
|
||||||
|
@ -977,13 +861,8 @@ CGFloat kIconViewLength = 24;
|
||||||
{
|
{
|
||||||
OWSLogDebug(@"");
|
OWSLogDebug(@"");
|
||||||
|
|
||||||
MediaGallery *mediaGallery = [[MediaGallery alloc] initWithThread:self.thread
|
|
||||||
options:MediaGalleryOptionSliderEnabled];
|
|
||||||
|
|
||||||
self.mediaGallery = mediaGallery;
|
|
||||||
|
|
||||||
OWSAssertDebug([self.navigationController isKindOfClass:[OWSNavigationController class]]);
|
OWSAssertDebug([self.navigationController isKindOfClass:[OWSNavigationController class]]);
|
||||||
[mediaGallery pushTileViewFromNavController:(OWSNavigationController *)self.navigationController];
|
[SNMediaGallery pushTileViewWithSliderEnabledForThreadId:self.threadId isClosedGroup:self.isClosedGroup isOpenGroup:self.isOpenGroup fromNavController:(OWSNavigationController *)self.navigationController];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)tappedConversationSearch
|
- (void)tappedConversationSearch
|
||||||
|
@ -995,9 +874,8 @@ CGFloat kIconViewLength = 24;
|
||||||
{
|
{
|
||||||
UISwitch *uiSwitch = (UISwitch *)sender;
|
UISwitch *uiSwitch = (UISwitch *)sender;
|
||||||
BOOL isEnabled = uiSwitch.isOn;
|
BOOL isEnabled = uiSwitch.isOn;
|
||||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
|
||||||
[(TSGroupThread *)self.thread setIsOnlyNotifyingForMentions:isEnabled withTransaction:transaction];
|
[SMKThread setIsOnlyNotifyingForMentions:self.threadId to:isEnabled];
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)hideEditNameUI
|
- (void)hideEditNameUI
|
||||||
|
@ -1013,9 +891,9 @@ CGFloat kIconViewLength = 24;
|
||||||
- (void)setIsEditingDisplayName:(BOOL)isEditingDisplayName
|
- (void)setIsEditingDisplayName:(BOOL)isEditingDisplayName
|
||||||
{
|
{
|
||||||
_isEditingDisplayName = isEditingDisplayName;
|
_isEditingDisplayName = isEditingDisplayName;
|
||||||
|
|
||||||
[self updateNavBarButtons];
|
[self updateNavBarButtons];
|
||||||
|
|
||||||
[UIView animateWithDuration:0.25 animations:^{
|
[UIView animateWithDuration:0.25 animations:^{
|
||||||
self.displayNameLabel.alpha = self.isEditingDisplayName ? 0 : 1;
|
self.displayNameLabel.alpha = self.isEditingDisplayName ? 0 : 1;
|
||||||
self.displayNameTextField.alpha = self.isEditingDisplayName ? 1 : 0;
|
self.displayNameTextField.alpha = self.isEditingDisplayName ? 1 : 0;
|
||||||
|
@ -1029,18 +907,10 @@ CGFloat kIconViewLength = 24;
|
||||||
|
|
||||||
- (void)saveName
|
- (void)saveName
|
||||||
{
|
{
|
||||||
if (![self.thread isKindOfClass:TSContactThread.class]) { return; }
|
if (self.isClosedGroup || self.isOpenGroup) { return; }
|
||||||
NSString *sessionID = ((TSContactThread *)self.thread).contactSessionID;
|
|
||||||
SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID];
|
|
||||||
if (contact == nil) {
|
|
||||||
contact = [[SNContact alloc] initWithSessionID:sessionID];
|
|
||||||
}
|
|
||||||
NSString *text = [self.displayNameTextField.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
NSString *text = [self.displayNameTextField.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
||||||
contact.nickname = text.length > 0 ? text : nil;
|
self.displayNameLabel.text = [SMKProfile displayNameAfterSavingNickname:text forProfileId:self.threadId];
|
||||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
|
||||||
[LKStorage.shared setContact:contact usingTransaction:transaction];
|
|
||||||
}];
|
|
||||||
self.displayNameLabel.text = text.length > 0 ? text : contact.name;
|
|
||||||
[self hideEditNameUI];
|
[self hideEditNameUI];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1069,23 +939,16 @@ CGFloat kIconViewLength = 24;
|
||||||
|
|
||||||
#pragma mark - Notifications
|
#pragma mark - Notifications
|
||||||
|
|
||||||
- (void)identityStateDidChange:(NSNotification *)notification
|
// FIXME: When this screen gets refactored, make sure to observe changes for relevant profile image updates
|
||||||
{
|
|
||||||
OWSAssertIsOnMainThread();
|
|
||||||
|
|
||||||
[self updateTableContents];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)otherUsersProfileDidChange:(NSNotification *)notification
|
- (void)otherUsersProfileDidChange:(NSNotification *)notification
|
||||||
{
|
{
|
||||||
OWSAssertIsOnMainThread();
|
NSString *recipientId = @"";//notification.userInfo[NSNotification.profileRecipientIdKey];
|
||||||
|
|
||||||
NSString *recipientId = notification.userInfo[kNSNotificationKey_ProfileRecipientId];
|
|
||||||
OWSAssertDebug(recipientId.length > 0);
|
OWSAssertDebug(recipientId.length > 0);
|
||||||
|
|
||||||
if (recipientId.length > 0 && [self.thread isKindOfClass:[TSContactThread class]] &&
|
if (recipientId.length > 0 && !self.isClosedGroup && !self.isOpenGroup && self.threadId == recipientId) {
|
||||||
[((TSContactThread *)self.thread).contactSessionID isEqualToString:recipientId]) {
|
DispatchMainThreadSafe(^{
|
||||||
[self updateTableContents];
|
[self updateTableContents];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,11 +9,8 @@ NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
@protocol OWSConversationSettingsViewDelegate <NSObject>
|
@protocol OWSConversationSettingsViewDelegate <NSObject>
|
||||||
|
|
||||||
- (void)groupWasUpdated:(TSGroupModel *)groupModel;
|
|
||||||
- (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController;
|
- (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController;
|
||||||
|
|
||||||
- (void)popAllConversationSettingsViewsWithCompletion:(void (^_Nullable)(void))completionBlock;
|
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
/// Shown when the user taps a profile picture in the conversation settings.
|
/// Shown when the user taps a profile picture in the conversation settings.
|
||||||
@objc(SNProfilePictureVC)
|
@objc(SNProfilePictureVC)
|
||||||
final class ProfilePictureVC : BaseVC {
|
final class ProfilePictureVC: BaseVC {
|
||||||
private let image: UIImage
|
private let image: UIImage
|
||||||
private let snTitle: String
|
private let snTitle: String
|
||||||
|
|
||||||
@objc init(image: UIImage, title: String) {
|
@objc init(image: UIImage, title: String) {
|
||||||
self.image = image
|
self.image = image
|
||||||
self.snTitle = title
|
self.snTitle = title
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import GRDB
|
||||||
|
import SessionUIKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
|
||||||
final class BlockedModal: Modal {
|
final class BlockedModal: Modal {
|
||||||
|
@ -19,7 +25,7 @@ final class BlockedModal: Modal {
|
||||||
|
|
||||||
override func populateContentView() {
|
override func populateContentView() {
|
||||||
// Name
|
// Name
|
||||||
let name = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey
|
let name = Profile.displayName(id: publicKey)
|
||||||
// Title
|
// Title
|
||||||
let titleLabel = UILabel()
|
let titleLabel = UILabel()
|
||||||
titleLabel.textColor = Colors.text
|
titleLabel.textColor = Colors.text
|
||||||
|
@ -67,23 +73,20 @@ final class BlockedModal: Modal {
|
||||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
@objc private func unblock() {
|
@objc private func unblock() {
|
||||||
let publicKey: String = self.publicKey
|
let publicKey: String = self.publicKey
|
||||||
|
|
||||||
Storage.shared.write(
|
Storage.shared.writeAsync { db in
|
||||||
with: { transaction in
|
try Contact
|
||||||
guard let transaction = transaction as? YapDatabaseReadWriteTransaction, let contact: Contact = Storage.shared.getContact(with: publicKey, using: transaction) else {
|
.filter(id: publicKey)
|
||||||
return
|
.updateAll(db, Contact.Columns.isBlocked.set(to: false))
|
||||||
}
|
|
||||||
|
try MessageSender
|
||||||
contact.isBlocked = false
|
.syncConfiguration(db, forceSyncNow: true)
|
||||||
Storage.shared.setContact(contact, using: transaction as Any)
|
.retainUntilComplete()
|
||||||
},
|
}
|
||||||
completion: {
|
|
||||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,37 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
// Requirements:
|
// Requirements:
|
||||||
// • Links should show up properly and be tappable.
|
// • Links should show up properly and be tappable
|
||||||
// • Text should * not * be selectable.
|
// • Text should * not * be selectable (this is handled via the 'textViewDidChangeSelection(_:)'
|
||||||
// • The long press interaction that shows the context menu should still work.
|
// delegate method)
|
||||||
|
// • The long press interaction that shows the context menu should still work
|
||||||
final class BodyTextView : UITextView {
|
final class BodyTextView: UITextView {
|
||||||
private let snDelegate: BodyTextViewDelegate
|
private let snDelegate: BodyTextViewDelegate?
|
||||||
|
private let highlightedMentionBackgroundView: HighlightMentionBackgroundView = HighlightMentionBackgroundView()
|
||||||
|
|
||||||
override var selectedTextRange: UITextRange? {
|
override var attributedText: NSAttributedString! {
|
||||||
get { return nil }
|
didSet {
|
||||||
set { }
|
guard attributedText != nil else { return }
|
||||||
|
|
||||||
|
highlightedMentionBackgroundView.maxPadding = highlightedMentionBackgroundView
|
||||||
|
.calculateMaxPadding(for: attributedText)
|
||||||
|
highlightedMentionBackgroundView.frame = self.bounds.insetBy(
|
||||||
|
dx: -highlightedMentionBackgroundView.maxPadding,
|
||||||
|
dy: -highlightedMentionBackgroundView.maxPadding
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(snDelegate: BodyTextViewDelegate) {
|
init(snDelegate: BodyTextViewDelegate?) {
|
||||||
self.snDelegate = snDelegate
|
self.snDelegate = snDelegate
|
||||||
|
|
||||||
super.init(frame: CGRect.zero, textContainer: nil)
|
super.init(frame: CGRect.zero, textContainer: nil)
|
||||||
|
|
||||||
|
self.clipsToBounds = false // Needed for the 'HighlightMentionBackgroundView'
|
||||||
|
addSubview(highlightedMentionBackgroundView)
|
||||||
|
|
||||||
setUpGestureRecognizers()
|
setUpGestureRecognizers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -35,12 +52,21 @@ final class BodyTextView : UITextView {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleLongPress() {
|
@objc private func handleLongPress() {
|
||||||
snDelegate.handleLongPress()
|
snDelegate?.handleLongPress()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleDoubleTap() {
|
@objc private func handleDoubleTap() {
|
||||||
// Do nothing
|
// Do nothing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
highlightedMentionBackgroundView.frame = self.bounds.insetBy(
|
||||||
|
dx: -highlightedMentionBackgroundView.maxPadding,
|
||||||
|
dy: -highlightedMentionBackgroundView.maxPadding
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol BodyTextViewDelegate {
|
protocol BodyTextViewDelegate {
|
||||||
|
|
|
@ -1,13 +1,21 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SessionUIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
final class CallModal : Modal {
|
final class CallModal: Modal {
|
||||||
private let onCallEnabled: () -> Void
|
private let onCallEnabled: () -> Void
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
init(onCallEnabled: @escaping () -> Void) {
|
init(onCallEnabled: @escaping () -> Void) {
|
||||||
self.onCallEnabled = onCallEnabled
|
self.onCallEnabled = onCallEnabled
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
self.modalPresentationStyle = .overFullScreen
|
self.modalPresentationStyle = .overFullScreen
|
||||||
self.modalTransitionStyle = .crossDissolve
|
self.modalTransitionStyle = .crossDissolve
|
||||||
}
|
}
|
||||||
|
@ -27,15 +35,16 @@ final class CallModal : Modal {
|
||||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||||
titleLabel.text = NSLocalizedString("modal_call_title", comment: "")
|
titleLabel.text = NSLocalizedString("modal_call_title", comment: "")
|
||||||
titleLabel.textAlignment = .center
|
titleLabel.textAlignment = .center
|
||||||
|
|
||||||
// Message
|
// Message
|
||||||
let messageLabel = UILabel()
|
let messageLabel = UILabel()
|
||||||
messageLabel.textColor = Colors.text
|
messageLabel.textColor = Colors.text
|
||||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||||
let message = NSLocalizedString("modal_call_explanation", comment: "")
|
messageLabel.text = "modal_call_explanation".localized()
|
||||||
messageLabel.text = message
|
|
||||||
messageLabel.numberOfLines = 0
|
messageLabel.numberOfLines = 0
|
||||||
messageLabel.lineBreakMode = .byWordWrapping
|
messageLabel.lineBreakMode = .byWordWrapping
|
||||||
messageLabel.textAlignment = .center
|
messageLabel.textAlignment = .center
|
||||||
|
|
||||||
// Enable button
|
// Enable button
|
||||||
let enableButton = UIButton()
|
let enableButton = UIButton()
|
||||||
enableButton.set(.height, to: Values.mediumButtonHeight)
|
enableButton.set(.height, to: Values.mediumButtonHeight)
|
||||||
|
@ -45,25 +54,29 @@ final class CallModal : Modal {
|
||||||
enableButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
enableButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||||
enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal)
|
enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal)
|
||||||
enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside)
|
enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside)
|
||||||
|
|
||||||
// Button stack view
|
// Button stack view
|
||||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
|
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
|
||||||
buttonStackView.axis = .horizontal
|
buttonStackView.axis = .horizontal
|
||||||
buttonStackView.spacing = Values.mediumSpacing
|
buttonStackView.spacing = Values.mediumSpacing
|
||||||
buttonStackView.distribution = .fillEqually
|
buttonStackView.distribution = .fillEqually
|
||||||
|
|
||||||
// Main stack view
|
// Main stack view
|
||||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||||
mainStackView.axis = .vertical
|
mainStackView.axis = .vertical
|
||||||
mainStackView.spacing = Values.largeSpacing
|
mainStackView.spacing = Values.largeSpacing
|
||||||
contentView.addSubview(mainStackView)
|
contentView.addSubview(mainStackView)
|
||||||
|
|
||||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
@objc private func enable() {
|
@objc private func enable() {
|
||||||
SSKPreferences.areCallsEnabled = true
|
Storage.shared.writeAsync { db in db[.areCallsEnabled] = true }
|
||||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||||
onCallEnabled()
|
onCallEnabled()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,26 +1,35 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class ConversationTitleView : UIView {
|
import UIKit
|
||||||
private let thread: TSThread
|
import SessionUIKit
|
||||||
weak var delegate: ConversationTitleViewDelegate?
|
import SessionMessagingKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
|
final class ConversationTitleView: UIView {
|
||||||
|
private static let leftInset: CGFloat = 8
|
||||||
|
private static let leftInsetWithCallButton: CGFloat = 54
|
||||||
|
|
||||||
override var intrinsicContentSize: CGSize {
|
override var intrinsicContentSize: CGSize {
|
||||||
return UIView.layoutFittingExpandedSize
|
return UIView.layoutFittingExpandedSize
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: UI Components
|
// MARK: - UI Components
|
||||||
|
|
||||||
private lazy var titleLabel: UILabel = {
|
private lazy var titleLabel: UILabel = {
|
||||||
let result = UILabel()
|
let result: UILabel = UILabel()
|
||||||
result.textColor = Colors.text
|
result.textColor = Colors.text
|
||||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||||
result.lineBreakMode = .byTruncatingTail
|
result.lineBreakMode = .byTruncatingTail
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var subtitleLabel: UILabel = {
|
private lazy var subtitleLabel: UILabel = {
|
||||||
let result = UILabel()
|
let result: UILabel = UILabel()
|
||||||
result.textColor = Colors.text
|
result.textColor = Colors.text
|
||||||
result.font = .systemFont(ofSize: 13)
|
result.font = .systemFont(ofSize: 13)
|
||||||
result.lineBreakMode = .byTruncatingTail
|
result.lineBreakMode = .byTruncatingTail
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -29,114 +38,119 @@ final class ConversationTitleView : UIView {
|
||||||
result.axis = .vertical
|
result.axis = .vertical
|
||||||
result.alignment = .center
|
result.alignment = .center
|
||||||
result.isLayoutMarginsRelativeArrangement = true
|
result.isLayoutMarginsRelativeArrangement = true
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Initialization
|
||||||
init(thread: TSThread) {
|
|
||||||
self.thread = thread
|
init() {
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: .zero)
|
||||||
initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
|
||||||
preconditionFailure("Use init(thread:) instead.")
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
preconditionFailure("Use init(coder:) instead.")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initialize() {
|
|
||||||
addSubview(stackView)
|
|
||||||
stackView.pin(to: self)
|
|
||||||
let shouldShowCallButton = SessionCall.isEnabled && !thread.isNoteToSelf() && !thread.isGroupThread()
|
|
||||||
let leftMargin: CGFloat = shouldShowCallButton ? 54 : 8 // Contact threads also have the call button to compensate for
|
|
||||||
stackView.layoutMargins = UIEdgeInsets(top: 0, left: leftMargin, bottom: 0, right: 0)
|
|
||||||
|
|
||||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
addSubview(stackView)
|
||||||
addGestureRecognizer(tapGestureRecognizer)
|
|
||||||
let notificationCenter = NotificationCenter.default
|
stackView.pin(to: self)
|
||||||
notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.groupThreadUpdated, object: nil)
|
|
||||||
notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.muteSettingUpdated, object: nil)
|
|
||||||
notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.contactUpdated, object: nil)
|
|
||||||
update()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Updating
|
required init?(coder: NSCoder) {
|
||||||
@objc private func update() {
|
preconditionFailure("Use init() instead.")
|
||||||
titleLabel.text = getTitle()
|
|
||||||
let subtitle = getSubtitle()
|
|
||||||
subtitleLabel.attributedText = subtitle
|
|
||||||
let titleFontSize = (subtitle != nil) ? Values.mediumFontSize : Values.veryLargeFontSize
|
|
||||||
titleLabel.font = .boldSystemFont(ofSize: titleFontSize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: General
|
// MARK: - Content
|
||||||
private func getTitle() -> String {
|
|
||||||
if let thread = thread as? TSGroupThread {
|
public func initialSetup(with threadVariant: SessionThread.Variant) {
|
||||||
return thread.groupModel.groupName!
|
self.update(
|
||||||
}
|
with: " ",
|
||||||
else if thread.isNoteToSelf() {
|
isNoteToSelf: false,
|
||||||
return "Note to Self"
|
threadVariant: threadVariant,
|
||||||
}
|
mutedUntilTimestamp: nil,
|
||||||
else {
|
onlyNotifyForMentions: false,
|
||||||
let sessionID = (thread as! TSContactThread).contactSessionID()
|
userCount: (threadVariant != .contact ? 0 : nil)
|
||||||
var result = sessionID
|
)
|
||||||
Storage.read { transaction in
|
}
|
||||||
let displayName: String = ((Storage.shared.getContact(with: sessionID)?.displayName(for: .regular)) ?? sessionID)
|
|
||||||
let middleTruncatedHexKey: String = "\(sessionID.prefix(4))...\(sessionID.suffix(4))"
|
public func update(
|
||||||
result = (displayName == sessionID ? middleTruncatedHexKey : displayName)
|
with name: String,
|
||||||
|
isNoteToSelf: Bool,
|
||||||
|
threadVariant: SessionThread.Variant,
|
||||||
|
mutedUntilTimestamp: TimeInterval?,
|
||||||
|
onlyNotifyForMentions: Bool,
|
||||||
|
userCount: Int?
|
||||||
|
) {
|
||||||
|
guard Thread.isMainThread else {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.update(
|
||||||
|
with: name,
|
||||||
|
isNoteToSelf: isNoteToSelf,
|
||||||
|
threadVariant: threadVariant,
|
||||||
|
mutedUntilTimestamp: mutedUntilTimestamp,
|
||||||
|
onlyNotifyForMentions: onlyNotifyForMentions,
|
||||||
|
userCount: userCount
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return result
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Generate the subtitle
|
||||||
private func getSubtitle() -> NSAttributedString? {
|
let subtitle: NSAttributedString? = {
|
||||||
let result = NSMutableAttributedString()
|
guard Date().timeIntervalSince1970 > (mutedUntilTimestamp ?? 0) else {
|
||||||
if thread.isMuted {
|
return NSAttributedString(
|
||||||
result.append(NSAttributedString(string: "\u{e067} ", attributes: [ .font : UIFont.ows_elegantIconsFont(10), .foregroundColor : Colors.text ]))
|
string: "\u{e067} ",
|
||||||
result.append(NSAttributedString(string: "Muted"))
|
attributes: [
|
||||||
return result
|
.font: UIFont.ows_elegantIconsFont(10),
|
||||||
} else if let thread = self.thread as? TSGroupThread {
|
.foregroundColor: Colors.text
|
||||||
if thread.isOnlyNotifyingForMentions {
|
]
|
||||||
|
)
|
||||||
|
.appending(string: "Muted")
|
||||||
|
}
|
||||||
|
guard !onlyNotifyForMentions else {
|
||||||
|
// FIXME: This is going to have issues when swapping between light/dark mode
|
||||||
let imageAttachment = NSTextAttachment()
|
let imageAttachment = NSTextAttachment()
|
||||||
let color: UIColor = isDarkMode ? .white : .black
|
let color: UIColor = (isDarkMode ? .white : .black)
|
||||||
imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: color)
|
imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: color)
|
||||||
imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize)
|
imageAttachment.bounds = CGRect(
|
||||||
let imageAsString = NSAttributedString(attachment: imageAttachment)
|
x: 0,
|
||||||
result.append(imageAsString)
|
y: -2,
|
||||||
result.append(NSAttributedString(string: " " + NSLocalizedString("view_conversation_title_notify_for_mentions_only", comment: "")))
|
width: Values.smallFontSize,
|
||||||
return result
|
height: Values.smallFontSize
|
||||||
} else {
|
)
|
||||||
var userCount: UInt64?
|
|
||||||
switch thread.groupModel.groupType {
|
return NSAttributedString(attachment: imageAttachment)
|
||||||
case .closedGroup: userCount = UInt64(thread.groupModel.groupMemberIds.count)
|
.appending(string: " ")
|
||||||
case .openGroup:
|
.appending(string: "view_conversation_title_notify_for_mentions_only".localized())
|
||||||
guard let openGroupV2 = Storage.shared.getV2OpenGroup(for: self.thread.uniqueId!) else { return nil }
|
|
||||||
userCount = Storage.shared.getUserCount(forV2OpenGroupWithID: openGroupV2.id)
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
if let userCount = userCount {
|
|
||||||
return NSAttributedString(string: "\(userCount) members")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
guard let userCount: Int = userCount else { return nil }
|
||||||
return nil
|
|
||||||
}
|
return NSAttributedString(string: "\(userCount) member\(userCount == 1 ? "" : "s")")
|
||||||
|
}()
|
||||||
// MARK: Interaction
|
|
||||||
@objc private func handleTap() {
|
self.titleLabel.text = name
|
||||||
delegate?.handleTitleViewTapped()
|
self.titleLabel.font = .boldSystemFont(
|
||||||
|
ofSize: (subtitle != nil ?
|
||||||
|
Values.mediumFontSize :
|
||||||
|
Values.veryLargeFontSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.subtitleLabel.attributedText = subtitle
|
||||||
|
|
||||||
|
// Contact threads also have the call button to compensate for
|
||||||
|
let shouldShowCallButton: Bool = (
|
||||||
|
SessionCall.isEnabled &&
|
||||||
|
!isNoteToSelf &&
|
||||||
|
threadVariant == .contact
|
||||||
|
)
|
||||||
|
self.stackView.layoutMargins = UIEdgeInsets(
|
||||||
|
top: 0,
|
||||||
|
left: (shouldShowCallButton ?
|
||||||
|
ConversationTitleView.leftInsetWithCallButton :
|
||||||
|
ConversationTitleView.leftInset
|
||||||
|
),
|
||||||
|
bottom: 0,
|
||||||
|
right: 0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Delegate
|
|
||||||
protocol ConversationTitleViewDelegate : AnyObject {
|
|
||||||
|
|
||||||
func handleTitleViewTapped()
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,42 +1,58 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class DownloadAttachmentModal : Modal {
|
import UIKit
|
||||||
private let viewItem: ConversationViewItem
|
import GRDB
|
||||||
|
import SessionUIKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
|
final class DownloadAttachmentModal: Modal {
|
||||||
|
private let profile: Profile?
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
// MARK: Lifecycle
|
init(profile: Profile?) {
|
||||||
init(viewItem: ConversationViewItem) {
|
self.profile = profile
|
||||||
self.viewItem = viewItem
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(nibName: String?, bundle: Bundle?) {
|
override init(nibName: String?, bundle: Bundle?) {
|
||||||
preconditionFailure("Use init(viewItem:) instead.")
|
preconditionFailure("Use init(viewItem:) instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
preconditionFailure("Use init(viewItem:) instead.")
|
preconditionFailure("Use init(viewItem:) instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
override func populateContentView() {
|
override func populateContentView() {
|
||||||
guard let publicKey = (viewItem.interaction as? TSIncomingMessage)?.authorId else { return }
|
guard let profile: Profile = profile else { return }
|
||||||
|
|
||||||
// Name
|
// Name
|
||||||
let name = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey
|
let name: String = profile.displayName()
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
let titleLabel = UILabel()
|
let titleLabel = UILabel()
|
||||||
titleLabel.textColor = Colors.text
|
titleLabel.textColor = Colors.text
|
||||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||||
titleLabel.text = String(format: NSLocalizedString("modal_download_attachment_title", comment: ""), name)
|
titleLabel.text = String(format: NSLocalizedString("modal_download_attachment_title", comment: ""), name)
|
||||||
titleLabel.textAlignment = .center
|
titleLabel.textAlignment = .center
|
||||||
|
|
||||||
// Message
|
// Message
|
||||||
let messageLabel = UILabel()
|
let messageLabel = UILabel()
|
||||||
messageLabel.textColor = Colors.text
|
messageLabel.textColor = Colors.text
|
||||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||||
let message = String(format: NSLocalizedString("modal_download_attachment_explanation", comment: ""), name)
|
let message = String(format: NSLocalizedString("modal_download_attachment_explanation", comment: ""), name)
|
||||||
let attributedMessage = NSMutableAttributedString(string: message)
|
let attributedMessage = NSMutableAttributedString(string: message)
|
||||||
attributedMessage.addAttributes([ .font : UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], range: (message as NSString).range(of: name))
|
attributedMessage.addAttributes(
|
||||||
|
[.font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ],
|
||||||
|
range: (message as NSString).range(of: name)
|
||||||
|
)
|
||||||
messageLabel.attributedText = attributedMessage
|
messageLabel.attributedText = attributedMessage
|
||||||
messageLabel.numberOfLines = 0
|
messageLabel.numberOfLines = 0
|
||||||
messageLabel.lineBreakMode = .byWordWrapping
|
messageLabel.lineBreakMode = .byWordWrapping
|
||||||
messageLabel.textAlignment = .center
|
messageLabel.textAlignment = .center
|
||||||
|
|
||||||
// Download button
|
// Download button
|
||||||
let downloadButton = UIButton()
|
let downloadButton = UIButton()
|
||||||
downloadButton.set(.height, to: Values.mediumButtonHeight)
|
downloadButton.set(.height, to: Values.mediumButtonHeight)
|
||||||
|
@ -45,15 +61,18 @@ final class DownloadAttachmentModal : Modal {
|
||||||
downloadButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
downloadButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||||
downloadButton.setTitle(NSLocalizedString("modal_download_button_title", comment: ""), for: UIControl.State.normal)
|
downloadButton.setTitle(NSLocalizedString("modal_download_button_title", comment: ""), for: UIControl.State.normal)
|
||||||
downloadButton.addTarget(self, action: #selector(trust), for: UIControl.Event.touchUpInside)
|
downloadButton.addTarget(self, action: #selector(trust), for: UIControl.Event.touchUpInside)
|
||||||
|
|
||||||
// Button stack view
|
// Button stack view
|
||||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, downloadButton ])
|
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, downloadButton ])
|
||||||
buttonStackView.axis = .horizontal
|
buttonStackView.axis = .horizontal
|
||||||
buttonStackView.spacing = Values.mediumSpacing
|
buttonStackView.spacing = Values.mediumSpacing
|
||||||
buttonStackView.distribution = .fillEqually
|
buttonStackView.distribution = .fillEqually
|
||||||
|
|
||||||
// Content stack view
|
// Content stack view
|
||||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||||
contentStackView.axis = .vertical
|
contentStackView.axis = .vertical
|
||||||
contentStackView.spacing = Values.largeSpacing
|
contentStackView.spacing = Values.largeSpacing
|
||||||
|
|
||||||
// Main stack view
|
// Main stack view
|
||||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||||
|
@ -65,19 +84,37 @@ final class DownloadAttachmentModal : Modal {
|
||||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Interaction
|
||||||
|
|
||||||
// MARK: Interaction
|
|
||||||
@objc private func trust() {
|
@objc private func trust() {
|
||||||
guard let message = viewItem.interaction as? TSIncomingMessage else { return }
|
guard let profileId: String = profile?.id else { return }
|
||||||
let publicKey = message.authorId
|
|
||||||
let contact = Storage.shared.getContact(with: publicKey) ?? Contact(sessionID: publicKey)
|
Storage.shared.writeAsync { db in
|
||||||
contact.isTrusted = true
|
try Contact
|
||||||
Storage.write(with: { transaction in
|
.filter(id: profileId)
|
||||||
Storage.shared.setContact(contact, using: transaction)
|
.updateAll(db, Contact.Columns.isTrusted.set(to: true))
|
||||||
MessageInvalidator.invalidate(message, with: transaction)
|
|
||||||
}, completion: {
|
// Start downloading any pending attachments for this contact (UI will automatically be
|
||||||
Storage.shared.resumeAttachmentDownloadJobsIfNeeded(for: message.uniqueThreadId)
|
// updated due to the database observation)
|
||||||
})
|
try Attachment
|
||||||
|
.stateInfo(authorId: profileId, state: .pendingDownload)
|
||||||
|
.fetchAll(db)
|
||||||
|
.forEach { attachmentDownloadInfo in
|
||||||
|
JobRunner.add(
|
||||||
|
db,
|
||||||
|
job: Job(
|
||||||
|
variant: .attachmentDownload,
|
||||||
|
threadId: profileId,
|
||||||
|
interactionId: attachmentDownloadInfo.interactionId,
|
||||||
|
details: AttachmentDownloadJob.Details(
|
||||||
|
attachmentId: attachmentDownloadInfo.attachmentId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,80 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// This custom UITableView gives us two convenience behaviours:
|
||||||
|
///
|
||||||
|
/// 1. It allows us to lock the contentOffset to a specific value - it's currently used to prevent the ConversationVC first
|
||||||
|
/// responder resignation from making the MediaGalleryDetailViewController transition from looking buggy (ie. the table
|
||||||
|
/// scrolls down with the resignation during the transition)
|
||||||
|
///
|
||||||
|
/// 2. It allows us to provode a callback which gets triggered if a condition closure returns true - it's currently used to prevent
|
||||||
|
/// the table view from jumping when inserting new pages at the top of a conversation screen
|
||||||
|
public class InsetLockableTableView: UITableView {
|
||||||
|
public var lockContentOffset: Bool = false {
|
||||||
|
didSet {
|
||||||
|
guard !lockContentOffset else { return }
|
||||||
|
|
||||||
|
self.contentOffset = newOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public var oldOffset: CGPoint = .zero
|
||||||
|
public var newOffset: CGPoint = .zero
|
||||||
|
private var callbackCondition: ((Int, [Int], CGSize) -> Bool)?
|
||||||
|
private var afterLayoutSubviewsCallback: (() -> ())?
|
||||||
|
|
||||||
|
public override func layoutSubviews() {
|
||||||
|
self.newOffset = self.contentOffset
|
||||||
|
|
||||||
|
// Store the callback locally to prevent infinite loops
|
||||||
|
var callback: (() -> ())?
|
||||||
|
|
||||||
|
if self.checkCallbackCondition() {
|
||||||
|
callback = self.afterLayoutSubviewsCallback
|
||||||
|
self.afterLayoutSubviewsCallback = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !lockContentOffset else {
|
||||||
|
self.contentOffset = CGPoint(
|
||||||
|
x: newOffset.x,
|
||||||
|
y: oldOffset.y
|
||||||
|
)
|
||||||
|
|
||||||
|
super.layoutSubviews()
|
||||||
|
callback?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
super.layoutSubviews()
|
||||||
|
callback?()
|
||||||
|
|
||||||
|
self.oldOffset = self.contentOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Functions
|
||||||
|
|
||||||
|
public func afterNextLayoutSubviews(
|
||||||
|
when condition: @escaping (Int, [Int], CGSize) -> Bool,
|
||||||
|
then callback: @escaping () -> ()
|
||||||
|
) {
|
||||||
|
self.callbackCondition = condition
|
||||||
|
self.afterLayoutSubviewsCallback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkCallbackCondition() -> Bool {
|
||||||
|
guard self.callbackCondition != nil else { return false }
|
||||||
|
|
||||||
|
let numSections: Int = self.numberOfSections
|
||||||
|
let numRowInSections: [Int] = (0..<numSections)
|
||||||
|
.map { self.numberOfRows(inSection: $0) }
|
||||||
|
|
||||||
|
// Store the layout info locally so if they pass we can clear the states before running to
|
||||||
|
// prevent layouts within the callbacks from triggering infinite loops
|
||||||
|
guard self.callbackCondition?(numSections, numRowInSections, self.contentSize) == true else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
self.callbackCondition = nil
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,12 +1,20 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class JoinOpenGroupModal : Modal {
|
import UIKit
|
||||||
|
import GRDB
|
||||||
|
import SessionMessagingKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
|
final class JoinOpenGroupModal: Modal {
|
||||||
private let name: String
|
private let name: String
|
||||||
private let url: String
|
private let url: String
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
init(name: String, url: String) {
|
|
||||||
self.name = name
|
init(name: String?, url: String) {
|
||||||
|
self.name = (name ?? "Open Group")
|
||||||
self.url = url
|
self.url = url
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,6 +33,7 @@ final class JoinOpenGroupModal : Modal {
|
||||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||||
titleLabel.text = "Join \(name)?"
|
titleLabel.text = "Join \(name)?"
|
||||||
titleLabel.textAlignment = .center
|
titleLabel.textAlignment = .center
|
||||||
|
|
||||||
// Message
|
// Message
|
||||||
let messageLabel = UILabel()
|
let messageLabel = UILabel()
|
||||||
messageLabel.textColor = Colors.text
|
messageLabel.textColor = Colors.text
|
||||||
|
@ -36,6 +45,7 @@ final class JoinOpenGroupModal : Modal {
|
||||||
messageLabel.numberOfLines = 0
|
messageLabel.numberOfLines = 0
|
||||||
messageLabel.lineBreakMode = .byWordWrapping
|
messageLabel.lineBreakMode = .byWordWrapping
|
||||||
messageLabel.textAlignment = .center
|
messageLabel.textAlignment = .center
|
||||||
|
|
||||||
// Join button
|
// Join button
|
||||||
let joinButton = UIButton()
|
let joinButton = UIButton()
|
||||||
joinButton.set(.height, to: Values.mediumButtonHeight)
|
joinButton.set(.height, to: Values.mediumButtonHeight)
|
||||||
|
@ -45,15 +55,18 @@ final class JoinOpenGroupModal : Modal {
|
||||||
joinButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
joinButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||||
joinButton.setTitle("Join", for: UIControl.State.normal)
|
joinButton.setTitle("Join", for: UIControl.State.normal)
|
||||||
joinButton.addTarget(self, action: #selector(joinOpenGroup), for: UIControl.Event.touchUpInside)
|
joinButton.addTarget(self, action: #selector(joinOpenGroup), for: UIControl.Event.touchUpInside)
|
||||||
|
|
||||||
// Button stack view
|
// Button stack view
|
||||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, joinButton ])
|
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, joinButton ])
|
||||||
buttonStackView.axis = .horizontal
|
buttonStackView.axis = .horizontal
|
||||||
buttonStackView.spacing = Values.mediumSpacing
|
buttonStackView.spacing = Values.mediumSpacing
|
||||||
buttonStackView.distribution = .fillEqually
|
buttonStackView.distribution = .fillEqually
|
||||||
|
|
||||||
// Content stack view
|
// Content stack view
|
||||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||||
contentStackView.axis = .vertical
|
contentStackView.axis = .vertical
|
||||||
contentStackView.spacing = Values.largeSpacing
|
contentStackView.spacing = Values.largeSpacing
|
||||||
|
|
||||||
// Main stack view
|
// Main stack view
|
||||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||||
|
@ -66,24 +79,39 @@ final class JoinOpenGroupModal : Modal {
|
||||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
@objc private func joinOpenGroup() {
|
@objc private func joinOpenGroup() {
|
||||||
guard let (room, server, publicKey) = OpenGroupManagerV2.parseV2OpenGroup(from: url) else {
|
guard let presentingViewController: UIViewController = self.presentingViewController else { return }
|
||||||
|
guard let (room, server, publicKey) = OpenGroupManager.parseOpenGroup(from: url) else {
|
||||||
let alert = UIAlertController(title: "Couldn't Join", message: nil, preferredStyle: .alert)
|
let alert = UIAlertController(title: "Couldn't Join", message: nil, preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||||
return presentingViewController!.presentAlert(alert)
|
|
||||||
|
return presentingViewController.present(alert, animated: true, completion: nil)
|
||||||
}
|
}
|
||||||
presentingViewController!.dismiss(animated: true, completion: nil)
|
|
||||||
Storage.shared.write { [presentingViewController = self.presentingViewController!] transaction in
|
presentingViewController.dismiss(animated: true, completion: nil)
|
||||||
OpenGroupManagerV2.shared.add(room: room, server: server, publicKey: publicKey, using: transaction)
|
|
||||||
|
Storage.shared
|
||||||
|
.writeAsync { db in
|
||||||
|
OpenGroupManager.shared.add(
|
||||||
|
db,
|
||||||
|
roomToken: room,
|
||||||
|
server: server,
|
||||||
|
publicKey: publicKey,
|
||||||
|
isConfigMessage: false
|
||||||
|
)
|
||||||
|
}
|
||||||
.done(on: DispatchQueue.main) { _ in
|
.done(on: DispatchQueue.main) { _ in
|
||||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...)
|
Storage.shared.writeAsync { db in
|
||||||
|
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.catch(on: DispatchQueue.main) { error in
|
.catch(on: DispatchQueue.main) { error in
|
||||||
let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert)
|
let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||||
presentingViewController.presentAlert(alert)
|
presentingViewController.present(alert, animated: true, completion: nil)
|
||||||
}
|
}
|
||||||
}
|
.retainUntilComplete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,15 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class LinkPreviewModal : Modal {
|
import UIKit
|
||||||
|
import GRDB
|
||||||
|
import SessionUIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
|
final class LinkPreviewModal: Modal {
|
||||||
private let onLinkPreviewsEnabled: () -> Void
|
private let onLinkPreviewsEnabled: () -> Void
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
init(onLinkPreviewsEnabled: @escaping () -> Void) {
|
init(onLinkPreviewsEnabled: @escaping () -> Void) {
|
||||||
self.onLinkPreviewsEnabled = onLinkPreviewsEnabled
|
self.onLinkPreviewsEnabled = onLinkPreviewsEnabled
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
@ -18,22 +25,23 @@ final class LinkPreviewModal : Modal {
|
||||||
|
|
||||||
override func populateContentView() {
|
override func populateContentView() {
|
||||||
// Title
|
// Title
|
||||||
let titleLabel = UILabel()
|
let titleLabel: UILabel = UILabel()
|
||||||
titleLabel.textColor = Colors.text
|
titleLabel.textColor = Colors.text
|
||||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||||
titleLabel.text = NSLocalizedString("modal_link_previews_title", comment: "")
|
titleLabel.text = "modal_link_previews_title".localized()
|
||||||
titleLabel.textAlignment = .center
|
titleLabel.textAlignment = .center
|
||||||
|
|
||||||
// Message
|
// Message
|
||||||
let messageLabel = UILabel()
|
let messageLabel: UILabel = UILabel()
|
||||||
messageLabel.textColor = Colors.text
|
messageLabel.textColor = Colors.text
|
||||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||||
let message = NSLocalizedString("modal_link_previews_explanation", comment: "")
|
messageLabel.text = "modal_link_previews_explanation".localized()
|
||||||
messageLabel.text = message
|
|
||||||
messageLabel.numberOfLines = 0
|
messageLabel.numberOfLines = 0
|
||||||
messageLabel.lineBreakMode = .byWordWrapping
|
messageLabel.lineBreakMode = .byWordWrapping
|
||||||
messageLabel.textAlignment = .center
|
messageLabel.textAlignment = .center
|
||||||
|
|
||||||
// Enable button
|
// Enable button
|
||||||
let enableButton = UIButton()
|
let enableButton: UIButton = UIButton()
|
||||||
enableButton.set(.height, to: Values.mediumButtonHeight)
|
enableButton.set(.height, to: Values.mediumButtonHeight)
|
||||||
enableButton.layer.cornerRadius = Modal.buttonCornerRadius
|
enableButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||||
enableButton.backgroundColor = Colors.buttonBackground
|
enableButton.backgroundColor = Colors.buttonBackground
|
||||||
|
@ -41,18 +49,22 @@ final class LinkPreviewModal : Modal {
|
||||||
enableButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
enableButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||||
enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal)
|
enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal)
|
||||||
enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside)
|
enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside)
|
||||||
|
|
||||||
// Button stack view
|
// Button stack view
|
||||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
|
let buttonStackView: UIStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
|
||||||
buttonStackView.axis = .horizontal
|
buttonStackView.axis = .horizontal
|
||||||
buttonStackView.spacing = Values.mediumSpacing
|
buttonStackView.spacing = Values.mediumSpacing
|
||||||
buttonStackView.distribution = .fillEqually
|
buttonStackView.distribution = .fillEqually
|
||||||
|
|
||||||
// Content stack view
|
// Content stack view
|
||||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||||
contentStackView.axis = .vertical
|
contentStackView.axis = .vertical
|
||||||
contentStackView.spacing = Values.largeSpacing
|
contentStackView.spacing = Values.largeSpacing
|
||||||
|
|
||||||
// Main stack view
|
// Main stack view
|
||||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||||
|
|
||||||
mainStackView.axis = .vertical
|
mainStackView.axis = .vertical
|
||||||
mainStackView.spacing = spacing
|
mainStackView.spacing = spacing
|
||||||
contentView.addSubview(mainStackView)
|
contentView.addSubview(mainStackView)
|
||||||
|
@ -62,9 +74,13 @@ final class LinkPreviewModal : Modal {
|
||||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
@objc private func enable() {
|
@objc private func enable() {
|
||||||
SSKPreferences.areLinkPreviewsEnabled = true
|
Storage.shared.writeAsync { db in
|
||||||
|
db[.areLinkPreviewsEnabled] = true
|
||||||
|
}
|
||||||
|
|
||||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||||
onLinkPreviewsEnabled()
|
onLinkPreviewsEnabled()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
|
|
||||||
final class MessagesTableView : UITableView {
|
|
||||||
override init(frame: CGRect, style: UITableView.Style) {
|
|
||||||
super.init(frame: frame, style: style)
|
|
||||||
initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
super.init(coder: coder)
|
|
||||||
initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initialize() {
|
|
||||||
register(VisibleMessageCell.self, forCellReuseIdentifier: VisibleMessageCell.identifier)
|
|
||||||
register(InfoMessageCell.self, forCellReuseIdentifier: InfoMessageCell.identifier)
|
|
||||||
register(TypingIndicatorCell.self, forCellReuseIdentifier: TypingIndicatorCell.identifier)
|
|
||||||
register(CallMessageCell.self, forCellReuseIdentifier: CallMessageCell.identifier)
|
|
||||||
separatorStyle = .none
|
|
||||||
backgroundColor = .clear
|
|
||||||
showsVerticalScrollIndicator = false
|
|
||||||
contentInsetAdjustmentBehavior = .never
|
|
||||||
keyboardDismissMode = .interactive
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,12 +1,17 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
final class ScrollToBottomButton : UIView {
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class ScrollToBottomButton: UIView {
|
||||||
private weak var delegate: ScrollToBottomButtonDelegate?
|
private weak var delegate: ScrollToBottomButtonDelegate?
|
||||||
|
|
||||||
// MARK: Settings
|
// MARK: - Settings
|
||||||
|
|
||||||
private static let size: CGFloat = 40
|
private static let size: CGFloat = 40
|
||||||
private static let iconSize: CGFloat = 16
|
private static let iconSize: CGFloat = 16
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
init(delegate: ScrollToBottomButtonDelegate) {
|
init(delegate: ScrollToBottomButtonDelegate) {
|
||||||
self.delegate = delegate
|
self.delegate = delegate
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
|
@ -55,13 +60,15 @@ final class ScrollToBottomButton : UIView {
|
||||||
addGestureRecognizer(tapGestureRecognizer)
|
addGestureRecognizer(tapGestureRecognizer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
@objc private func handleTap() {
|
@objc private func handleTap() {
|
||||||
delegate?.handleScrollToBottomButtonTapped()
|
delegate?.handleScrollToBottomButtonTapped()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol ScrollToBottomButtonDelegate : class {
|
// MARK: - ScrollToBottomButtonDelegate
|
||||||
|
|
||||||
|
protocol ScrollToBottomButtonDelegate: AnyObject {
|
||||||
func handleScrollToBottomButtonTapped()
|
func handleScrollToBottomButtonTapped()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class URLModal : Modal {
|
import UIKit
|
||||||
|
import SessionUIKit
|
||||||
|
|
||||||
|
final class URLModal: Modal {
|
||||||
private let url: URL
|
private let url: URL
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
init(url: URL) {
|
init(url: URL) {
|
||||||
self.url = url
|
self.url = url
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
@ -23,6 +28,7 @@ final class URLModal : Modal {
|
||||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||||
titleLabel.text = NSLocalizedString("modal_open_url_title", comment: "")
|
titleLabel.text = NSLocalizedString("modal_open_url_title", comment: "")
|
||||||
titleLabel.textAlignment = .center
|
titleLabel.textAlignment = .center
|
||||||
|
|
||||||
// Message
|
// Message
|
||||||
let messageLabel = UILabel()
|
let messageLabel = UILabel()
|
||||||
messageLabel.textColor = Colors.text
|
messageLabel.textColor = Colors.text
|
||||||
|
@ -34,6 +40,7 @@ final class URLModal : Modal {
|
||||||
messageLabel.numberOfLines = 0
|
messageLabel.numberOfLines = 0
|
||||||
messageLabel.lineBreakMode = .byWordWrapping
|
messageLabel.lineBreakMode = .byWordWrapping
|
||||||
messageLabel.textAlignment = .center
|
messageLabel.textAlignment = .center
|
||||||
|
|
||||||
// Open button
|
// Open button
|
||||||
let openButton = UIButton()
|
let openButton = UIButton()
|
||||||
openButton.set(.height, to: Values.mediumButtonHeight)
|
openButton.set(.height, to: Values.mediumButtonHeight)
|
||||||
|
@ -42,16 +49,19 @@ final class URLModal : Modal {
|
||||||
openButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
openButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||||
openButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
openButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||||
openButton.setTitle(NSLocalizedString("modal_open_url_button_title", comment: ""), for: UIControl.State.normal)
|
openButton.setTitle(NSLocalizedString("modal_open_url_button_title", comment: ""), for: UIControl.State.normal)
|
||||||
openButton.addTarget(self, action: #selector(openURL), for: UIControl.Event.touchUpInside)
|
openButton.addTarget(self, action: #selector(openUrl), for: UIControl.Event.touchUpInside)
|
||||||
|
|
||||||
// Button stack view
|
// Button stack view
|
||||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, openButton ])
|
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, openButton ])
|
||||||
buttonStackView.axis = .horizontal
|
buttonStackView.axis = .horizontal
|
||||||
buttonStackView.spacing = Values.mediumSpacing
|
buttonStackView.spacing = Values.mediumSpacing
|
||||||
buttonStackView.distribution = .fillEqually
|
buttonStackView.distribution = .fillEqually
|
||||||
|
|
||||||
// Content stack view
|
// Content stack view
|
||||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||||
contentStackView.axis = .vertical
|
contentStackView.axis = .vertical
|
||||||
contentStackView.spacing = Values.largeSpacing
|
contentStackView.spacing = Values.largeSpacing
|
||||||
|
|
||||||
// Main stack view
|
// Main stack view
|
||||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||||
|
@ -64,9 +74,11 @@ final class URLModal : Modal {
|
||||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: - Interaction
|
||||||
@objc private func openURL() {
|
|
||||||
|
@objc private func openUrl() {
|
||||||
let url = self.url
|
let url = self.url
|
||||||
|
|
||||||
presentingViewController?.dismiss(animated: true, completion: {
|
presentingViewController?.dismiss(animated: true, completion: {
|
||||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class UserDetailsSheet : Sheet {
|
import UIKit
|
||||||
private let sessionID: String
|
import SessionMessagingKit
|
||||||
|
|
||||||
|
final class UserDetailsSheet: Sheet {
|
||||||
|
private let profile: Profile
|
||||||
|
|
||||||
init(for sessionID: String) {
|
init(for profile: Profile) {
|
||||||
self.sessionID = sessionID
|
self.profile = profile
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,16 +27,21 @@ final class UserDetailsSheet : Sheet {
|
||||||
profilePictureView.size = size
|
profilePictureView.size = size
|
||||||
profilePictureView.set(.width, to: size)
|
profilePictureView.set(.width, to: size)
|
||||||
profilePictureView.set(.height, to: size)
|
profilePictureView.set(.height, to: size)
|
||||||
profilePictureView.publicKey = sessionID
|
profilePictureView.update(
|
||||||
profilePictureView.update()
|
publicKey: profile.id,
|
||||||
|
profile: profile,
|
||||||
|
threadVariant: .contact
|
||||||
|
)
|
||||||
|
|
||||||
// Display name label
|
// Display name label
|
||||||
let displayNameLabel = UILabel()
|
let displayNameLabel = UILabel()
|
||||||
let displayName = Storage.shared.getContact(with: sessionID)?.displayName(for: .regular) ?? sessionID
|
let displayName = profile.displayName()
|
||||||
displayNameLabel.text = displayName
|
displayNameLabel.text = displayName
|
||||||
displayNameLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
displayNameLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||||
displayNameLabel.textColor = Colors.text
|
displayNameLabel.textColor = Colors.text
|
||||||
displayNameLabel.numberOfLines = 1
|
displayNameLabel.numberOfLines = 1
|
||||||
displayNameLabel.lineBreakMode = .byTruncatingTail
|
displayNameLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
|
||||||
// Session ID label
|
// Session ID label
|
||||||
let sessionIDLabel = UILabel()
|
let sessionIDLabel = UILabel()
|
||||||
sessionIDLabel.textColor = Colors.text
|
sessionIDLabel.textColor = Colors.text
|
||||||
|
@ -39,7 +49,8 @@ final class UserDetailsSheet : Sheet {
|
||||||
sessionIDLabel.numberOfLines = 0
|
sessionIDLabel.numberOfLines = 0
|
||||||
sessionIDLabel.lineBreakMode = .byCharWrapping
|
sessionIDLabel.lineBreakMode = .byCharWrapping
|
||||||
sessionIDLabel.accessibilityLabel = "Session ID label"
|
sessionIDLabel.accessibilityLabel = "Session ID label"
|
||||||
sessionIDLabel.text = sessionID
|
sessionIDLabel.text = profile.id
|
||||||
|
|
||||||
// Session ID label container
|
// Session ID label container
|
||||||
let sessionIDLabelContainer = UIView()
|
let sessionIDLabelContainer = UIView()
|
||||||
sessionIDLabelContainer.addSubview(sessionIDLabel)
|
sessionIDLabelContainer.addSubview(sessionIDLabel)
|
||||||
|
@ -47,23 +58,26 @@ final class UserDetailsSheet : Sheet {
|
||||||
sessionIDLabelContainer.layer.cornerRadius = TextField.cornerRadius
|
sessionIDLabelContainer.layer.cornerRadius = TextField.cornerRadius
|
||||||
sessionIDLabelContainer.layer.borderWidth = 1
|
sessionIDLabelContainer.layer.borderWidth = 1
|
||||||
sessionIDLabelContainer.layer.borderColor = isLightMode ? UIColor.black.cgColor : UIColor.white.cgColor
|
sessionIDLabelContainer.layer.borderColor = isLightMode ? UIColor.black.cgColor : UIColor.white.cgColor
|
||||||
|
|
||||||
// Copy button
|
// Copy button
|
||||||
let copyButton = Button(style: .prominentOutline, size: .medium)
|
let copyButton = Button(style: .prominentOutline, size: .medium)
|
||||||
copyButton.setTitle(NSLocalizedString("copy", comment: ""), for: UIControl.State.normal)
|
copyButton.setTitle(NSLocalizedString("copy", comment: ""), for: UIControl.State.normal)
|
||||||
copyButton.addTarget(self, action: #selector(copySessionID), for: UIControl.Event.touchUpInside)
|
copyButton.addTarget(self, action: #selector(copySessionID), for: UIControl.Event.touchUpInside)
|
||||||
copyButton.set(.width, to: 160)
|
copyButton.set(.width, to: 160)
|
||||||
|
|
||||||
// Stack view
|
// Stack view
|
||||||
let stackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel, sessionIDLabelContainer, copyButton, UIView.vSpacer(Values.largeSpacing) ])
|
let stackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel, sessionIDLabelContainer, copyButton, UIView.vSpacer(Values.largeSpacing) ])
|
||||||
stackView.axis = .vertical
|
stackView.axis = .vertical
|
||||||
stackView.spacing = Values.largeSpacing
|
stackView.spacing = Values.largeSpacing
|
||||||
stackView.alignment = .center
|
stackView.alignment = .center
|
||||||
|
|
||||||
// Constraints
|
// Constraints
|
||||||
contentView.addSubview(stackView)
|
contentView.addSubview(stackView)
|
||||||
stackView.pin(to: contentView, withInset: Values.largeSpacing)
|
stackView.pin(to: contentView, withInset: Values.largeSpacing)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func copySessionID() {
|
@objc private func copySessionID() {
|
||||||
UIPasteboard.general.string = sessionID
|
UIPasteboard.general.string = profile.id
|
||||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,10 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import GRDB
|
||||||
|
import Curve25519Kit
|
||||||
|
import SessionMessagingKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate {
|
final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate {
|
||||||
private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
||||||
|
@ -71,12 +78,7 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
||||||
// Set up tab bar
|
// Set up tab bar
|
||||||
view.addSubview(tabBar)
|
view.addSubview(tabBar)
|
||||||
tabBar.pin(.leading, to: .leading, of: view)
|
tabBar.pin(.leading, to: .leading, of: view)
|
||||||
let tabBarInset: CGFloat
|
let tabBarInset: CGFloat = (UIDevice.current.isIPad ? navigationBar.height() + 20 : navigationBar.height())
|
||||||
if #available(iOS 13, *) {
|
|
||||||
tabBarInset = UIDevice.current.isIPad ? navigationBar.height() + 20 : navigationBar.height()
|
|
||||||
} else {
|
|
||||||
tabBarInset = 0
|
|
||||||
}
|
|
||||||
tabBar.pin(.top, to: .top, of: view, withInset: tabBarInset)
|
tabBar.pin(.top, to: .top, of: view, withInset: tabBarInset)
|
||||||
view.pin(.trailing, to: .trailing, of: tabBar)
|
view.pin(.trailing, to: .trailing, of: tabBar)
|
||||||
// Set up page VC constraints
|
// Set up page VC constraints
|
||||||
|
@ -88,13 +90,7 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
||||||
view.pin(.bottom, to: .bottom, of: pageVCView)
|
view.pin(.bottom, to: .bottom, of: pageVCView)
|
||||||
let screen = UIScreen.main.bounds
|
let screen = UIScreen.main.bounds
|
||||||
pageVCView.set(.width, to: screen.width)
|
pageVCView.set(.width, to: screen.width)
|
||||||
let height: CGFloat
|
let height: CGFloat = (navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight)
|
||||||
if #available(iOS 13, *) {
|
|
||||||
height = navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight
|
|
||||||
} else {
|
|
||||||
let statusBarHeight = UIApplication.shared.statusBarFrame.height
|
|
||||||
height = navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight - statusBarHeight
|
|
||||||
}
|
|
||||||
pageVCView.set(.height, to: height)
|
pageVCView.set(.height, to: height)
|
||||||
enterPublicKeyVC.constrainHeight(to: height)
|
enterPublicKeyVC.constrainHeight(to: height)
|
||||||
scanQRCodePlaceholderVC.constrainHeight(to: height)
|
scanQRCodePlaceholderVC.constrainHeight(to: height)
|
||||||
|
@ -138,38 +134,57 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate func startNewDMIfPossible(with onsNameOrPublicKey: String) {
|
fileprivate func startNewDMIfPossible(with onsNameOrPublicKey: String) {
|
||||||
if ECKeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) {
|
let maybeSessionId: SessionId? = SessionId(from: onsNameOrPublicKey)
|
||||||
|
|
||||||
|
if ECKeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) && maybeSessionId?.prefix == .standard {
|
||||||
startNewDM(with: onsNameOrPublicKey)
|
startNewDM(with: onsNameOrPublicKey)
|
||||||
} else {
|
return
|
||||||
// This could be an ONS name
|
}
|
||||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
|
|
||||||
SnodeAPI.getSessionID(for: onsNameOrPublicKey).done { sessionID in
|
// This could be an ONS name
|
||||||
modalActivityIndicator.dismiss {
|
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
|
||||||
self?.startNewDM(with: sessionID)
|
SnodeAPI.getSessionID(for: onsNameOrPublicKey).done { sessionID in
|
||||||
}
|
modalActivityIndicator.dismiss {
|
||||||
}.catch { error in
|
self?.startNewDM(with: sessionID)
|
||||||
modalActivityIndicator.dismiss {
|
}
|
||||||
var messageOrNil: String?
|
}.catch { error in
|
||||||
if let error = error as? SnodeAPI.Error {
|
modalActivityIndicator.dismiss {
|
||||||
switch error {
|
var messageOrNil: String?
|
||||||
case .decryptionFailed, .hashingFailed, .validationFailed: messageOrNil = error.errorDescription
|
if let error = error as? SnodeAPIError {
|
||||||
|
switch error {
|
||||||
|
case .decryptionFailed, .hashingFailed, .validationFailed:
|
||||||
|
messageOrNil = error.errorDescription
|
||||||
default: break
|
default: break
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let message = messageOrNil ?? "Please check the Session ID or ONS name and try again"
|
|
||||||
let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
|
|
||||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
|
||||||
self?.presentAlert(alert)
|
|
||||||
}
|
}
|
||||||
|
let message: String = {
|
||||||
|
if let messageOrNil: String = messageOrNil {
|
||||||
|
return messageOrNil
|
||||||
|
}
|
||||||
|
|
||||||
|
return (maybeSessionId?.prefix == .blinded ?
|
||||||
|
"You can only send messages to Blinded IDs from within an Open Group" :
|
||||||
|
"Please check the Session ID or ONS name and try again"
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||||
|
self?.presentAlert(alert)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startNewDM(with sessionID: String) {
|
private func startNewDM(with sessionId: String) {
|
||||||
let thread = TSContactThread.getOrCreateThread(contactSessionID: sessionID)
|
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)
|
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||||
SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false)
|
|
||||||
|
SessionApp.presentConversation(for: sessionId, action: .compose, animated: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import PureLayout
|
||||||
|
import SessionUIKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
import NVActivityIndicatorView
|
import NVActivityIndicatorView
|
||||||
|
|
||||||
class EmptySearchResultCell: UITableViewCell {
|
class EmptySearchResultCell: UITableViewCell {
|
||||||
static let reuseIdentifier = "EmptySearchResultCell"
|
|
||||||
|
|
||||||
private lazy var messageLabel: UILabel = {
|
private lazy var messageLabel: UILabel = {
|
||||||
let result = UILabel()
|
let result = UILabel()
|
||||||
result.textAlignment = .center
|
result.textAlignment = .center
|
||||||
|
@ -24,6 +26,7 @@ class EmptySearchResultCell: UITableViewCell {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
|
selectionStyle = .none
|
||||||
|
|
||||||
contentView.addSubview(messageLabel)
|
contentView.addSubview(messageLabel)
|
||||||
messageLabel.autoSetDimension(.height, toSize: 150)
|
messageLabel.autoSetDimension(.height, toSize: 150)
|
||||||
|
|
|
@ -1,11 +1,42 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import GRDB
|
||||||
|
import DifferenceKit
|
||||||
|
import SessionUIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
@objc
|
|
||||||
class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
||||||
|
fileprivate typealias SectionModel = ArraySection<SearchSection, SessionThreadViewModel>
|
||||||
|
|
||||||
let isRecentSearchResultsEnabled = false
|
// MARK: - SearchSection
|
||||||
|
|
||||||
|
enum SearchSection: Int, Differentiable {
|
||||||
|
case noResults
|
||||||
|
case contactsAndGroups
|
||||||
|
case messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Variables
|
||||||
|
|
||||||
|
private lazy var defaultSearchResults: [SectionModel] = {
|
||||||
|
let result: SessionThreadViewModel? = Storage.shared.read { db -> SessionThreadViewModel? in
|
||||||
|
try SessionThreadViewModel
|
||||||
|
.noteToSelfOnlyQuery(userPublicKey: getUserHexEncodedPublicKey(db))
|
||||||
|
.fetchOne(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [ result.map { ArraySection(model: .contactsAndGroups, elements: [$0]) } ]
|
||||||
|
.compactMap { $0 }
|
||||||
|
}()
|
||||||
|
private lazy var searchResultSet: [SectionModel] = self.defaultSearchResults
|
||||||
|
private var termForCurrentSearchResultSet: String = ""
|
||||||
|
private var lastSearchText: String?
|
||||||
|
private var refreshTimer: Timer?
|
||||||
|
|
||||||
|
var isLoading = false
|
||||||
|
|
||||||
@objc public var searchText = "" {
|
@objc public var searchText = "" {
|
||||||
didSet {
|
didSet {
|
||||||
|
@ -14,55 +45,37 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
||||||
refreshSearchResults()
|
refreshSearchResults()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var recentSearchResults: [String] = Array(Storage.shared.getRecentSearchResults().reversed())
|
|
||||||
var defaultSearchResults: HomeScreenSearchResultSet = HomeScreenSearchResultSet.noteToSelfOnly
|
|
||||||
var searchResultSet: HomeScreenSearchResultSet = HomeScreenSearchResultSet.empty
|
|
||||||
private var lastSearchText: String?
|
|
||||||
var searcher: FullTextSearcher {
|
|
||||||
return FullTextSearcher.shared
|
|
||||||
}
|
|
||||||
var isLoading = false
|
|
||||||
|
|
||||||
enum SearchSection: Int {
|
// MARK: - UI Components
|
||||||
case noResults
|
|
||||||
case contacts
|
|
||||||
case messages
|
|
||||||
case recent
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: UI Components
|
|
||||||
|
|
||||||
internal lazy var searchBar: SearchBar = {
|
internal lazy var searchBar: SearchBar = {
|
||||||
let result = SearchBar()
|
let result: SearchBar = SearchBar()
|
||||||
result.tintColor = Colors.text
|
result.tintColor = Colors.text
|
||||||
result.delegate = self
|
result.delegate = self
|
||||||
result.showsCancelButton = true
|
result.showsCancelButton = true
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
internal lazy var tableView: UITableView = {
|
internal lazy var tableView: UITableView = {
|
||||||
let result = UITableView(frame: .zero, style: .grouped)
|
let result: UITableView = UITableView(frame: .zero, style: .grouped)
|
||||||
result.rowHeight = UITableView.automaticDimension
|
result.rowHeight = UITableView.automaticDimension
|
||||||
result.estimatedRowHeight = 60
|
result.estimatedRowHeight = 60
|
||||||
result.separatorStyle = .none
|
result.separatorStyle = .none
|
||||||
result.keyboardDismissMode = .onDrag
|
result.keyboardDismissMode = .onDrag
|
||||||
result.register(EmptySearchResultCell.self, forCellReuseIdentifier: EmptySearchResultCell.reuseIdentifier)
|
result.register(view: EmptySearchResultCell.self)
|
||||||
result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier)
|
result.register(view: FullConversationCell.self)
|
||||||
result.showsVerticalScrollIndicator = false
|
result.showsVerticalScrollIndicator = false
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: Dependencies
|
|
||||||
|
|
||||||
var dbReadConnection: YapDatabaseConnection {
|
// MARK: - View Lifecycle
|
||||||
return OWSPrimaryStorage.shared().dbReadConnection
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: View Lifecycle
|
|
||||||
public override func viewDidLoad() {
|
public override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
setUpGradientBackground()
|
|
||||||
|
|
||||||
|
setUpGradientBackground()
|
||||||
|
|
||||||
tableView.dataSource = self
|
tableView.dataSource = self
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
|
@ -74,22 +87,22 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
||||||
navigationItem.hidesBackButton = true
|
navigationItem.hidesBackButton = true
|
||||||
setupNavigationBar()
|
setupNavigationBar()
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewWillAppear(_ animated: Bool) {
|
public override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
searchBar.becomeFirstResponder()
|
searchBar.becomeFirstResponder()
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewWillDisappear(_ animated: Bool) {
|
public override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
searchBar.resignFirstResponder()
|
searchBar.resignFirstResponder()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupNavigationBar() {
|
private func setupNavigationBar() {
|
||||||
// This is a workaround for a UI issue that the navigation bar can be a bit higher if
|
// This is a workaround for a UI issue that the navigation bar can be a bit higher if
|
||||||
// the search bar is put directly to be the titleView. And this can cause the tableView
|
// the search bar is put directly to be the titleView. And this can cause the tableView
|
||||||
// in home screen doing a weird scrolling when going back to home screen.
|
// in home screen doing a weird scrolling when going back to home screen.
|
||||||
let searchBarContainer = UIView()
|
let searchBarContainer: UIView = UIView()
|
||||||
searchBarContainer.layoutMargins = UIEdgeInsets.zero
|
searchBarContainer.layoutMargins = UIEdgeInsets.zero
|
||||||
searchBar.sizeToFit()
|
searchBar.sizeToFit()
|
||||||
searchBar.layoutMargins = UIEdgeInsets.zero
|
searchBar.layoutMargins = UIEdgeInsets.zero
|
||||||
|
@ -103,37 +116,35 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
||||||
if UIDevice.current.isIPad {
|
if UIDevice.current.isIPad {
|
||||||
let ipadCancelButton = UIButton()
|
let ipadCancelButton = UIButton()
|
||||||
ipadCancelButton.setTitle("Cancel", for: .normal)
|
ipadCancelButton.setTitle("Cancel", for: .normal)
|
||||||
ipadCancelButton.addTarget(self, action: #selector(cancel(_:)), for: .touchUpInside)
|
ipadCancelButton.addTarget(self, action: #selector(cancel), for: .touchUpInside)
|
||||||
ipadCancelButton.setTitleColor(Colors.text, for: .normal)
|
ipadCancelButton.setTitleColor(Colors.text, for: .normal)
|
||||||
searchBarContainer.addSubview(ipadCancelButton)
|
searchBarContainer.addSubview(ipadCancelButton)
|
||||||
ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer)
|
ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer)
|
||||||
ipadCancelButton.autoVCenterInSuperview()
|
ipadCancelButton.autoVCenterInSuperview()
|
||||||
searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing)
|
searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing)
|
||||||
searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing)
|
searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing)
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
searchBar.autoPinEdgesToSuperviewMargins()
|
searchBar.autoPinEdgesToSuperviewMargins()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reloadTableData() {
|
private func reloadTableData() {
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Update Search Results
|
|
||||||
|
|
||||||
var refreshTimer: Timer?
|
// MARK: - Update Search Results
|
||||||
|
|
||||||
private func refreshSearchResults() {
|
private func refreshSearchResults() {
|
||||||
refreshTimer?.invalidate()
|
refreshTimer?.invalidate()
|
||||||
refreshTimer = WeakTimer.scheduledTimer(timeInterval: 0.1, target: self, userInfo: nil, repeats: false) { [weak self] _ in
|
refreshTimer = WeakTimer.scheduledTimer(timeInterval: 0.1, target: self, userInfo: nil, repeats: false) { [weak self] _ in
|
||||||
guard let self = self else { return }
|
self?.updateSearchResults(searchText: (self?.searchText ?? ""))
|
||||||
self.updateSearchResults(searchText: self.searchText)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateSearchResults(searchText rawSearchText: String) {
|
|
||||||
|
|
||||||
|
private func updateSearchResults(searchText rawSearchText: String) {
|
||||||
let searchText = rawSearchText.stripped
|
let searchText = rawSearchText.stripped
|
||||||
|
|
||||||
guard searchText.count > 0 else {
|
guard searchText.count > 0 else {
|
||||||
searchResultSet = defaultSearchResults
|
searchResultSet = defaultSearchResults
|
||||||
lastSearchText = nil
|
lastSearchText = nil
|
||||||
|
@ -144,56 +155,81 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
||||||
|
|
||||||
lastSearchText = searchText
|
lastSearchText = searchText
|
||||||
|
|
||||||
var searchResults: HomeScreenSearchResultSet?
|
let result: Result<[SectionModel], Error>? = Storage.shared.read { db -> Result<[SectionModel], Error> in
|
||||||
self.dbReadConnection.asyncRead({[weak self] transaction in
|
do {
|
||||||
guard let self = self else { return }
|
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||||
self.isLoading = true
|
let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel
|
||||||
// The max search result count is set according to the keyword length. This is just a workaround for performance issue.
|
.contactsAndGroupsQuery(
|
||||||
// The longer and more accurate the keyword is, the less search results should there be.
|
userPublicKey: userPublicKey,
|
||||||
searchResults = self.searcher.searchForHomeScreen(searchText: searchText, maxSearchResults: 500, transaction: transaction)
|
pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText),
|
||||||
}, completionBlock: { [weak self] in
|
searchTerm: searchText
|
||||||
AssertIsOnMainThread()
|
)
|
||||||
guard let self = self, let results = searchResults, self.lastSearchText == searchText else { return }
|
.fetchAll(db)
|
||||||
self.searchResultSet = results
|
let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel
|
||||||
self.isLoading = false
|
.messagesQuery(
|
||||||
self.reloadTableData()
|
userPublicKey: userPublicKey,
|
||||||
self.refreshTimer = nil
|
pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText)
|
||||||
})
|
)
|
||||||
|
.fetchAll(db)
|
||||||
|
|
||||||
|
return .success([
|
||||||
|
ArraySection(model: .contactsAndGroups, elements: contactsAndGroupsResults),
|
||||||
|
ArraySection(model: .messages, elements: messageResults)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return .failure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let sections):
|
||||||
|
let hasResults: Bool = (
|
||||||
|
!searchText.isEmpty &&
|
||||||
|
(sections.map { $0.elements.count }.reduce(0, +) > 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.termForCurrentSearchResultSet = searchText
|
||||||
|
self.searchResultSet = [
|
||||||
|
(hasResults ? nil : [ArraySection(model: .noResults, elements: [SessionThreadViewModel(unreadCount: 0)])]),
|
||||||
|
(hasResults ? sections : nil)
|
||||||
|
]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.flatMap { $0 }
|
||||||
|
self.isLoading = false
|
||||||
|
self.reloadTableData()
|
||||||
|
self.refreshTimer = nil
|
||||||
|
|
||||||
|
default: break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
@objc func cancel() {
|
||||||
@objc func clearRecentSearchResults() {
|
|
||||||
recentSearchResults = []
|
|
||||||
tableView.reloadSections([ SearchSection.recent.rawValue ], with: .top)
|
|
||||||
Storage.shared.clearRecentSearchResults()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func cancel(_ sender: Any) {
|
|
||||||
self.navigationController?.popViewController(animated: true)
|
self.navigationController?.popViewController(animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UISearchBarDelegate
|
// MARK: - UISearchBarDelegate
|
||||||
|
|
||||||
extension GlobalSearchViewController: UISearchBarDelegate {
|
extension GlobalSearchViewController: UISearchBarDelegate {
|
||||||
public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
|
public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
|
||||||
self.updateSearchText()
|
self.updateSearchText()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
|
public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
|
||||||
self.updateSearchText()
|
self.updateSearchText()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||||
self.updateSearchText()
|
self.updateSearchText()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
||||||
searchBar.text = nil
|
searchBar.text = nil
|
||||||
searchBar.resignFirstResponder()
|
searchBar.resignFirstResponder()
|
||||||
self.navigationController?.popViewController(animated: true)
|
self.navigationController?.popViewController(animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSearchText() {
|
func updateSearchText() {
|
||||||
guard let searchText = searchBar.text?.ows_stripped() else { return }
|
guard let searchText = searchBar.text?.ows_stripped() else { return }
|
||||||
self.searchText = searchText
|
self.searchText = searchText
|
||||||
|
@ -201,53 +237,59 @@ extension GlobalSearchViewController: UISearchBarDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UITableViewDelegate & UITableViewDataSource
|
// MARK: - UITableViewDelegate & UITableViewDataSource
|
||||||
|
|
||||||
extension GlobalSearchViewController {
|
extension GlobalSearchViewController {
|
||||||
|
|
||||||
// MARK: UITableViewDelegate
|
// MARK: - UITableViewDelegate
|
||||||
|
|
||||||
public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: false)
|
tableView.deselectRow(at: indexPath, animated: false)
|
||||||
guard let searchSection = SearchSection(rawValue: indexPath.section) else { return }
|
|
||||||
switch searchSection {
|
|
||||||
case .noResults:
|
|
||||||
SNLog("shouldn't be able to tap 'no results' section")
|
|
||||||
case .contacts:
|
|
||||||
let sectionResults = searchResultSet.conversations
|
|
||||||
guard let searchResult = sectionResults[safe: indexPath.row] else { return }
|
|
||||||
show(searchResult.thread.threadRecord, highlightedMessageID: nil, animated: true)
|
|
||||||
case .messages:
|
|
||||||
let sectionResults = searchResultSet.messages
|
|
||||||
guard let searchResult = sectionResults[safe: indexPath.row] else { return }
|
|
||||||
show(searchResult.thread.threadRecord, highlightedMessageID: searchResult.message?.uniqueId, animated: true)
|
|
||||||
case .recent:
|
|
||||||
guard let threadId = recentSearchResults[safe: indexPath.row], let thread = TSThread.fetch(uniqueId: threadId) else { return }
|
|
||||||
show(thread, highlightedMessageID: nil, animated: true, isFromRecent: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func show(_ thread: TSThread, highlightedMessageID: String?, animated: Bool, isFromRecent: Bool = false) {
|
|
||||||
if let threadId = thread.uniqueId {
|
|
||||||
recentSearchResults = Array(Storage.shared.addSearchResults(threadID: threadId).reversed())
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchMainThreadSafe {
|
let section: SectionModel = self.searchResultSet[indexPath.section]
|
||||||
if let presentedVC = self.presentedViewController {
|
|
||||||
presentedVC.dismiss(animated: false, completion: nil)
|
switch section.model {
|
||||||
}
|
case .noResults: break
|
||||||
let conversationVC = ConversationVC(thread: thread, focusedMessageID: highlightedMessageID)
|
case .contactsAndGroups, .messages:
|
||||||
var viewControllers = self.navigationController?.viewControllers
|
show(
|
||||||
if isFromRecent, let index = viewControllers?.firstIndex(of: self) { viewControllers?.remove(at: index) }
|
threadId: section.elements[indexPath.row].threadId,
|
||||||
viewControllers?.append(conversationVC)
|
threadVariant: section.elements[indexPath.row].threadVariant,
|
||||||
self.navigationController?.setViewControllers(viewControllers!, animated: true)
|
focusedInteractionId: section.elements[indexPath.row].interactionId
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: UITableViewDataSource
|
private func show(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64? = nil, animated: Bool = true) {
|
||||||
|
guard Thread.isMainThread else {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId, animated: animated)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let presentedVC = self.presentedViewController {
|
||||||
|
presentedVC.dismiss(animated: false, completion: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
let viewControllers: [UIViewController] = (self.navigationController?
|
||||||
|
.viewControllers)
|
||||||
|
.defaulting(to: [])
|
||||||
|
.appending(
|
||||||
|
ConversationVC(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.navigationController?.setViewControllers(viewControllers, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UITableViewDataSource
|
||||||
|
|
||||||
public func numberOfSections(in tableView: UITableView) -> Int {
|
public func numberOfSections(in tableView: UITableView) -> Int {
|
||||||
return 4
|
return self.searchResultSet.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
return self.searchResultSet[section].elements.count
|
||||||
|
}
|
||||||
|
|
||||||
public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
|
public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
|
||||||
UIView()
|
UIView()
|
||||||
}
|
}
|
||||||
|
@ -260,79 +302,36 @@ extension GlobalSearchViewController {
|
||||||
guard nil != self.tableView(tableView, titleForHeaderInSection: section) else {
|
guard nil != self.tableView(tableView, titleForHeaderInSection: section) else {
|
||||||
return .leastNonzeroMagnitude
|
return .leastNonzeroMagnitude
|
||||||
}
|
}
|
||||||
|
|
||||||
return UITableView.automaticDimension
|
return UITableView.automaticDimension
|
||||||
}
|
}
|
||||||
|
|
||||||
public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||||
guard let searchSection = SearchSection(rawValue: section) else { return nil }
|
guard let title: String = self.tableView(tableView, titleForHeaderInSection: section) else {
|
||||||
|
|
||||||
guard let title = self.tableView(tableView, titleForHeaderInSection: section) else {
|
|
||||||
return UIView()
|
return UIView()
|
||||||
}
|
}
|
||||||
|
|
||||||
let titleLabel = UILabel()
|
let titleLabel = UILabel()
|
||||||
titleLabel.text = title
|
titleLabel.text = title
|
||||||
titleLabel.textColor = Colors.text
|
titleLabel.textColor = Colors.text
|
||||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||||
|
|
||||||
let container = UIView()
|
let container = UIView()
|
||||||
container.backgroundColor = Colors.cellBackground
|
container.backgroundColor = Colors.cellBackground
|
||||||
container.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, left: Values.mediumSpacing, bottom: Values.smallSpacing, right: Values.mediumSpacing)
|
container.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, left: Values.mediumSpacing, bottom: Values.smallSpacing, right: Values.mediumSpacing)
|
||||||
container.addSubview(titleLabel)
|
container.addSubview(titleLabel)
|
||||||
titleLabel.autoPinEdgesToSuperviewMargins()
|
titleLabel.autoPinEdgesToSuperviewMargins()
|
||||||
|
|
||||||
if searchSection == .recent {
|
|
||||||
let clearButton = UIButton()
|
|
||||||
clearButton.setTitle("Clear", for: .normal)
|
|
||||||
clearButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
|
||||||
clearButton.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
|
||||||
clearButton.addTarget(self, action: #selector(clearRecentSearchResults), for: .touchUpInside)
|
|
||||||
container.addSubview(clearButton)
|
|
||||||
clearButton.autoPinTrailingToSuperviewMargin()
|
|
||||||
clearButton.autoVCenterInSuperview()
|
|
||||||
}
|
|
||||||
|
|
||||||
return container
|
return container
|
||||||
}
|
}
|
||||||
|
|
||||||
public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
guard let searchSection = SearchSection(rawValue: section) else { return nil }
|
let section: SectionModel = self.searchResultSet[section]
|
||||||
|
|
||||||
switch searchSection {
|
switch section.model {
|
||||||
case .noResults:
|
case .noResults: return nil
|
||||||
return nil
|
case .contactsAndGroups: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_CONTACTS".localized())
|
||||||
case .contacts:
|
case .messages: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_MESSAGES".localized())
|
||||||
if searchResultSet.conversations.count > 0 {
|
|
||||||
return NSLocalizedString("SEARCH_SECTION_CONTACTS", comment: "")
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case .messages:
|
|
||||||
if searchResultSet.messages.count > 0 {
|
|
||||||
return NSLocalizedString("SEARCH_SECTION_MESSAGES", comment: "")
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
case .recent:
|
|
||||||
if recentSearchResults.count > 0 && searchText.isEmpty && isRecentSearchResultsEnabled {
|
|
||||||
return NSLocalizedString("SEARCH_SECTION_RECENT", comment: "")
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
||||||
guard let searchSection = SearchSection(rawValue: section) else { return 0 }
|
|
||||||
switch searchSection {
|
|
||||||
case .noResults:
|
|
||||||
return (searchText.count > 0 && searchResultSet.isEmpty) ? 1 : 0
|
|
||||||
case .contacts:
|
|
||||||
return searchResultSet.conversations.count
|
|
||||||
case .messages:
|
|
||||||
return searchResultSet.messages.count
|
|
||||||
case .recent:
|
|
||||||
return searchText.isEmpty && isRecentSearchResultsEnabled ? recentSearchResults.count : 0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -341,41 +340,23 @@ extension GlobalSearchViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
|
let section: SectionModel = self.searchResultSet[indexPath.section]
|
||||||
guard let searchSection = SearchSection(rawValue: indexPath.section) else {
|
|
||||||
return UITableViewCell()
|
switch section.model {
|
||||||
}
|
case .noResults:
|
||||||
|
let cell: EmptySearchResultCell = tableView.dequeue(type: EmptySearchResultCell.self, for: indexPath)
|
||||||
switch searchSection {
|
cell.configure(isLoading: isLoading)
|
||||||
case .noResults:
|
return cell
|
||||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: EmptySearchResultCell.reuseIdentifier) as? EmptySearchResultCell, indexPath.row == 0 else { return UITableViewCell() }
|
|
||||||
cell.configure(isLoading: isLoading)
|
case .contactsAndGroups:
|
||||||
return cell
|
let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
|
||||||
case .contacts:
|
cell.updateForContactAndGroupSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet)
|
||||||
let sectionResults = searchResultSet.conversations
|
return cell
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
|
|
||||||
cell.isShowingGlobalSearchResult = true
|
case .messages:
|
||||||
let searchResult = sectionResults[safe: indexPath.row]
|
let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
|
||||||
cell.threadViewModel = searchResult?.thread
|
cell.updateForMessageSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet)
|
||||||
cell.configure(snippet: searchResult?.snippet, searchText: searchResultSet.searchText)
|
return cell
|
||||||
return cell
|
|
||||||
case .messages:
|
|
||||||
let sectionResults = searchResultSet.messages
|
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
|
|
||||||
cell.isShowingGlobalSearchResult = true
|
|
||||||
let searchResult = sectionResults[safe: indexPath.row]
|
|
||||||
cell.threadViewModel = searchResult?.thread
|
|
||||||
cell.configure(snippet: searchResult?.snippet, searchText: searchResultSet.searchText, message: searchResult?.message)
|
|
||||||
return cell
|
|
||||||
case .recent:
|
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
|
|
||||||
cell.isShowingGlobalSearchResult = true
|
|
||||||
dbReadConnection.read { transaction in
|
|
||||||
guard let threadId = self.recentSearchResults[safe: indexPath.row], let thread = TSThread.fetch(uniqueId: threadId, transaction: transaction) else { return }
|
|
||||||
cell.threadViewModel = ThreadViewModel(thread: thread, transaction: transaction)
|
|
||||||
}
|
|
||||||
cell.configureForRecent()
|
|
||||||
return cell
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
||||||
|
|
||||||
extension Storage{
|
|
||||||
|
|
||||||
private static let recentSearchResultDatabaseCollection = "RecentSearchResultDatabaseCollection"
|
|
||||||
private static let recentSearchResultKey = "RecentSearchResult"
|
|
||||||
|
|
||||||
public func getRecentSearchResults() -> [String] {
|
|
||||||
var result: [String]?
|
|
||||||
Storage.read { transaction in
|
|
||||||
result = transaction.object(forKey: Storage.recentSearchResultKey, inCollection: Storage.recentSearchResultDatabaseCollection) as? [String]
|
|
||||||
}
|
|
||||||
return result ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
public func clearRecentSearchResults() {
|
|
||||||
Storage.write { transaction in
|
|
||||||
transaction.removeObject(forKey: Storage.recentSearchResultKey, inCollection: Storage.recentSearchResultDatabaseCollection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func addSearchResults(threadID: String) -> [String] {
|
|
||||||
var recentSearchResults = getRecentSearchResults()
|
|
||||||
if recentSearchResults.count > 20 { recentSearchResults.remove(at: 0) } // Limit the size of the collection to 20
|
|
||||||
if let index = recentSearchResults.firstIndex(of: threadID) { recentSearchResults.remove(at: index) }
|
|
||||||
recentSearchResults.append(threadID)
|
|
||||||
Storage.write { transaction in
|
|
||||||
transaction.setObject(recentSearchResults, forKey: Storage.recentSearchResultKey, inCollection: Storage.recentSearchResultDatabaseCollection)
|
|
||||||
}
|
|
||||||
return recentSearchResults
|
|
||||||
}
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,302 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import DifferenceKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
|
public class HomeViewModel {
|
||||||
|
public typealias SectionModel = ArraySection<Section, SessionThreadViewModel>
|
||||||
|
|
||||||
|
// MARK: - Section
|
||||||
|
|
||||||
|
public enum Section: Differentiable {
|
||||||
|
case messageRequests
|
||||||
|
case threads
|
||||||
|
case loadMore
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Variables
|
||||||
|
|
||||||
|
public static let pageSize: Int = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.state = Storage.shared.read { db in try HomeViewModel.retrieveState(db) }
|
||||||
|
.defaulting(to: State())
|
||||||
|
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 thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||||
|
self.pagedDataObserver = PagedDatabaseObserver(
|
||||||
|
pagedTable: SessionThread.self,
|
||||||
|
pageSize: HomeViewModel.pageSize,
|
||||||
|
idColumn: .id,
|
||||||
|
observedChanges: [
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: SessionThread.self,
|
||||||
|
columns: [
|
||||||
|
.id,
|
||||||
|
.shouldBeVisible,
|
||||||
|
.isPinned,
|
||||||
|
.mutedUntilTimestamp,
|
||||||
|
.onlyNotifyForMentions
|
||||||
|
]
|
||||||
|
),
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: Interaction.self,
|
||||||
|
columns: [
|
||||||
|
.body,
|
||||||
|
.wasRead
|
||||||
|
],
|
||||||
|
joinToPagedType: {
|
||||||
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
|
|
||||||
|
return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
|
||||||
|
}()
|
||||||
|
),
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: Contact.self,
|
||||||
|
columns: [.isBlocked],
|
||||||
|
joinToPagedType: {
|
||||||
|
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||||
|
|
||||||
|
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
|
||||||
|
}()
|
||||||
|
),
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: Profile.self,
|
||||||
|
columns: [.name, .nickname, .profilePictureFileName],
|
||||||
|
joinToPagedType: {
|
||||||
|
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||||
|
|
||||||
|
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
|
||||||
|
}()
|
||||||
|
),
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: ClosedGroup.self,
|
||||||
|
columns: [.name],
|
||||||
|
joinToPagedType: {
|
||||||
|
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
|
||||||
|
|
||||||
|
return SQL("LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])")
|
||||||
|
}()
|
||||||
|
),
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: OpenGroup.self,
|
||||||
|
columns: [.name, .imageData],
|
||||||
|
joinToPagedType: {
|
||||||
|
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
|
||||||
|
|
||||||
|
return SQL("LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])")
|
||||||
|
}()
|
||||||
|
),
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: RecipientState.self,
|
||||||
|
columns: [.state],
|
||||||
|
joinToPagedType: {
|
||||||
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
|
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
|
||||||
|
|
||||||
|
return """
|
||||||
|
LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
|
||||||
|
LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
|
||||||
|
"""
|
||||||
|
}()
|
||||||
|
),
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: ThreadTypingIndicator.self,
|
||||||
|
columns: [.threadId],
|
||||||
|
joinToPagedType: {
|
||||||
|
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
|
||||||
|
|
||||||
|
return SQL("LEFT JOIN \(typingIndicator[.threadId]) = \(thread[.id])")
|
||||||
|
}()
|
||||||
|
)
|
||||||
|
],
|
||||||
|
/// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query
|
||||||
|
joinSQL: SessionThreadViewModel.optimisedJoinSQL,
|
||||||
|
filterSQL: SessionThreadViewModel.homeFilterSQL(userPublicKey: userPublicKey),
|
||||||
|
groupSQL: SessionThreadViewModel.groupSQL,
|
||||||
|
orderSQL: SessionThreadViewModel.homeOrderSQL,
|
||||||
|
dataQuery: SessionThreadViewModel.baseQuery(
|
||||||
|
userPublicKey: userPublicKey,
|
||||||
|
filterSQL: SessionThreadViewModel.homeFilterSQL(userPublicKey: userPublicKey),
|
||||||
|
groupSQL: SessionThreadViewModel.groupSQL,
|
||||||
|
orderSQL: SessionThreadViewModel.homeOrderSQL
|
||||||
|
),
|
||||||
|
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||||
|
guard let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes
|
||||||
|
// to be sent to the callback if we ever start observing again (when we have the callback it needs
|
||||||
|
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
|
||||||
|
// correct order)
|
||||||
|
guard let onThreadChange: (([SectionModel]) -> ()) = self?.onThreadChange else {
|
||||||
|
self?.unobservedThreadDataChanges = updatedThreadData
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onThreadChange(updatedThreadData)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State
|
||||||
|
|
||||||
|
/// This value is the current state of the view
|
||||||
|
public private(set) var state: State
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
public lazy var observableState = ValueObservation
|
||||||
|
.trackingConstantRegion { db -> State in try HomeViewModel.retrieveState(db) }
|
||||||
|
.removeDuplicates()
|
||||||
|
|
||||||
|
private static func retrieveState(_ db: Database) throws -> State {
|
||||||
|
let hasViewedSeed: Bool = db[.hasViewedSeed]
|
||||||
|
let hasHiddenMessageRequests: Bool = db[.hasHiddenMessageRequests]
|
||||||
|
let userProfile: Profile = Profile.fetchOrCreateCurrentUser(db)
|
||||||
|
let unreadMessageRequestThreadCount: Int = try SessionThread
|
||||||
|
.unreadMessageRequestsThreadIdQuery(userPublicKey: userProfile.id)
|
||||||
|
.fetchCount(db)
|
||||||
|
|
||||||
|
return State(
|
||||||
|
showViewedSeedBanner: !hasViewedSeed,
|
||||||
|
hasHiddenMessageRequests: hasHiddenMessageRequests,
|
||||||
|
unreadMessageRequestThreadCount: unreadMessageRequestThreadCount,
|
||||||
|
userProfile: userProfile
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateState(_ updatedState: State) {
|
||||||
|
let oldState: State = self.state
|
||||||
|
self.state = updatedState
|
||||||
|
|
||||||
|
// If the messageRequest content changed then we need to re-process the thread data
|
||||||
|
guard
|
||||||
|
(
|
||||||
|
oldState.hasHiddenMessageRequests != updatedState.hasHiddenMessageRequests ||
|
||||||
|
oldState.unreadMessageRequestThreadCount != updatedState.unreadMessageRequestThreadCount
|
||||||
|
),
|
||||||
|
let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo.wrappedValue
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
|
||||||
|
let currentData: [SessionThreadViewModel] = self.threadData.flatMap { $0.elements }
|
||||||
|
let updatedThreadData: [SectionModel] = self.process(data: currentData, for: currentPageInfo)
|
||||||
|
|
||||||
|
guard let onThreadChange: (([SectionModel]) -> ()) = self.onThreadChange else {
|
||||||
|
self.unobservedThreadDataChanges = updatedThreadData
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onThreadChange(updatedThreadData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Thread Data
|
||||||
|
|
||||||
|
public private(set) var unobservedThreadDataChanges: [SectionModel]?
|
||||||
|
public private(set) var threadData: [SectionModel] = []
|
||||||
|
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
|
||||||
|
|
||||||
|
public var onThreadChange: (([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] = self.unobservedThreadDataChanges {
|
||||||
|
onThreadChange?(unobservedThreadDataChanges)
|
||||||
|
self.unobservedThreadDataChanges = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func process(data: [SessionThreadViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
||||||
|
let finalUnreadMessageRequestCount: Int = (self.state.hasHiddenMessageRequests ?
|
||||||
|
0 :
|
||||||
|
self.state.unreadMessageRequestThreadCount
|
||||||
|
)
|
||||||
|
let groupedOldData: [String: [SessionThreadViewModel]] = (self.threadData
|
||||||
|
.first(where: { $0.model == .threads })?
|
||||||
|
.elements)
|
||||||
|
.defaulting(to: [])
|
||||||
|
.grouped(by: \.threadId)
|
||||||
|
|
||||||
|
return [
|
||||||
|
// If there are no unread message requests then hide the message request banner
|
||||||
|
(finalUnreadMessageRequestCount == 0 ?
|
||||||
|
[] :
|
||||||
|
[SectionModel(
|
||||||
|
section: .messageRequests,
|
||||||
|
elements: [
|
||||||
|
SessionThreadViewModel(unreadCount: UInt(finalUnreadMessageRequestCount))
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
),
|
||||||
|
[
|
||||||
|
SectionModel(
|
||||||
|
section: .threads,
|
||||||
|
elements: data
|
||||||
|
.filter { $0.id != SessionThreadViewModel.invalidId }
|
||||||
|
.sorted { lhs, rhs -> Bool in
|
||||||
|
if lhs.threadIsPinned && !rhs.threadIsPinned { return true }
|
||||||
|
if !lhs.threadIsPinned && rhs.threadIsPinned { return false }
|
||||||
|
|
||||||
|
return lhs.lastInteractionDate > rhs.lastInteractionDate
|
||||||
|
}
|
||||||
|
.map { viewModel -> SessionThreadViewModel in
|
||||||
|
viewModel.populatingCurrentUserBlindedKey(
|
||||||
|
currentUserBlindedPublicKeyForThisThread: groupedOldData[viewModel.threadId]?
|
||||||
|
.first?
|
||||||
|
.currentUserBlindedPublicKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
],
|
||||||
|
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
|
||||||
|
[SectionModel(section: .loadMore)] :
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
].flatMap { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateThreadData(_ updatedData: [SectionModel]) {
|
||||||
|
self.threadData = updatedData
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,49 +1,60 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import GRDB
|
||||||
|
import DifferenceKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
@objc
|
|
||||||
class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
||||||
private var threads: YapDatabaseViewMappings! = {
|
private static let loadingHeaderHeight: CGFloat = 20
|
||||||
let result = YapDatabaseViewMappings(groups: [ TSMessageRequestGroup ], view: TSThreadDatabaseViewExtensionName)
|
|
||||||
result.setIsReversed(true, forGroup: TSMessageRequestGroup)
|
|
||||||
return result
|
|
||||||
}()
|
|
||||||
private var threadViewModelCache: [String: ThreadViewModel] = [:] // Thread ID to ThreadViewModel
|
|
||||||
private var tableViewTopConstraint: NSLayoutConstraint!
|
|
||||||
|
|
||||||
private var messageRequestCount: UInt {
|
private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
|
||||||
threads.numberOfItems(inGroup: TSMessageRequestGroup)
|
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.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private lazy var dbConnection: YapDatabaseConnection = {
|
deinit {
|
||||||
let result = OWSPrimaryStorage.shared().newDatabaseConnection()
|
NotificationCenter.default.removeObserver(self)
|
||||||
result.objectCacheLimit = 500
|
}
|
||||||
|
|
||||||
return result
|
|
||||||
}()
|
|
||||||
|
|
||||||
// MARK: - UI
|
// MARK: - UI
|
||||||
|
|
||||||
private lazy var tableView: UITableView = {
|
private lazy var tableView: UITableView = {
|
||||||
let result: UITableView = UITableView()
|
let result: UITableView = UITableView()
|
||||||
result.translatesAutoresizingMaskIntoConstraints = false
|
result.translatesAutoresizingMaskIntoConstraints = false
|
||||||
result.backgroundColor = .clear
|
result.backgroundColor = .clear
|
||||||
result.separatorStyle = .none
|
result.separatorStyle = .none
|
||||||
result.register(MessageRequestsCell.self, forCellReuseIdentifier: MessageRequestsCell.reuseIdentifier)
|
result.register(view: FullConversationCell.self)
|
||||||
result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier)
|
|
||||||
result.dataSource = self
|
result.dataSource = self
|
||||||
result.delegate = self
|
result.delegate = self
|
||||||
|
|
||||||
let bottomInset = Values.newConversationButtonBottomOffset + NewConversationButtonSet.expandedButtonSize + Values.largeSpacing + NewConversationButtonSet.collapsedButtonSize
|
let bottomInset = Values.newConversationButtonBottomOffset + NewConversationButtonSet.expandedButtonSize + Values.largeSpacing + NewConversationButtonSet.collapsedButtonSize
|
||||||
result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
|
result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
|
||||||
result.showsVerticalScrollIndicator = false
|
result.showsVerticalScrollIndicator = false
|
||||||
|
|
||||||
|
if #available(iOS 15.0, *) {
|
||||||
|
result.sectionHeaderTopPadding = 0
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var emptyStateLabel: UILabel = {
|
private lazy var emptyStateLabel: UILabel = {
|
||||||
let result: UILabel = UILabel()
|
let result: UILabel = UILabel()
|
||||||
result.translatesAutoresizingMaskIntoConstraints = false
|
result.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
@ -54,19 +65,19 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
||||||
result.textAlignment = .center
|
result.textAlignment = .center
|
||||||
result.numberOfLines = 0
|
result.numberOfLines = 0
|
||||||
result.isHidden = true
|
result.isHidden = true
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var fadeView: UIView = {
|
private lazy var fadeView: UIView = {
|
||||||
let result: UIView = UIView()
|
let result: UIView = UIView()
|
||||||
result.translatesAutoresizingMaskIntoConstraints = false
|
result.translatesAutoresizingMaskIntoConstraints = false
|
||||||
result.isUserInteractionEnabled = false
|
result.isUserInteractionEnabled = false
|
||||||
result.setGradient(Gradients.homeVCFade)
|
result.setGradient(Gradients.homeVCFade)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var clearAllButton: Button = {
|
private lazy var clearAllButton: Button = {
|
||||||
let result: Button = Button(style: .destructiveOutline, size: .large)
|
let result: Button = Button(style: .destructiveOutline, size: .large)
|
||||||
result.translatesAutoresizingMaskIntoConstraints = false
|
result.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
@ -78,17 +89,21 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
||||||
for: .highlighted
|
for: .highlighted
|
||||||
)
|
)
|
||||||
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
|
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: NSLocalizedString("MESSAGE_REQUESTS_TITLE", comment: ""), hasCustomBackButton: false)
|
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
|
// Add the UI (MUST be done after the thread freeze so the 'tableView' creation and setting
|
||||||
// the dataSource has the correct data)
|
// the dataSource has the correct data)
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
|
@ -96,58 +111,69 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
||||||
view.addSubview(fadeView)
|
view.addSubview(fadeView)
|
||||||
view.addSubview(clearAllButton)
|
view.addSubview(clearAllButton)
|
||||||
setupLayout()
|
setupLayout()
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(handleYapDatabaseModifiedNotification(_:)),
|
selector: #selector(applicationDidBecomeActive(_:)),
|
||||||
name: .YapDatabaseModified,
|
name: UIApplication.didBecomeActiveNotification,
|
||||||
object: OWSPrimaryStorage.shared().dbNotificationObject
|
|
||||||
)
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(handleProfileDidChangeNotification(_:)),
|
|
||||||
name: NSNotification.Name(rawValue: kNSNotificationName_OtherUsersProfileDidChange),
|
|
||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(handleBlockedContactsUpdatedNotification(_:)),
|
selector: #selector(applicationDidResignActive(_:)),
|
||||||
name: .blockedContactsUpdated,
|
name: UIApplication.didEnterBackgroundNotification, object: nil
|
||||||
object: nil
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
reload()
|
startObservingChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
reload()
|
|
||||||
|
self.viewHasAppeared = true
|
||||||
|
self.autoLoadNextPageIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
NotificationCenter.default.removeObserver(self)
|
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
|
// MARK: - Layout
|
||||||
|
|
||||||
private func setupLayout() {
|
private func setupLayout() {
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
tableView.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.smallSpacing),
|
tableView.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.smallSpacing),
|
||||||
tableView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
tableView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||||
tableView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
tableView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
||||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
|
||||||
emptyStateLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.massiveSpacing),
|
emptyStateLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.massiveSpacing),
|
||||||
emptyStateLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.mediumSpacing),
|
emptyStateLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.mediumSpacing),
|
||||||
emptyStateLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.mediumSpacing),
|
emptyStateLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.mediumSpacing),
|
||||||
emptyStateLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
emptyStateLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
|
|
||||||
fadeView.topAnchor.constraint(equalTo: view.topAnchor, constant: (0.15 * view.bounds.height)),
|
fadeView.topAnchor.constraint(equalTo: view.topAnchor, constant: (0.15 * view.bounds.height)),
|
||||||
fadeView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
fadeView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||||
fadeView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
fadeView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
||||||
fadeView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
fadeView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
|
||||||
clearAllButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
clearAllButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
clearAllButton.bottomAnchor.constraint(
|
clearAllButton.bottomAnchor.constraint(
|
||||||
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
|
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
|
||||||
|
@ -158,277 +184,285 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UITableViewDataSource
|
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
||||||
return Int(messageRequestCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
|
|
||||||
cell.threadViewModel = threadViewModel(at: indexPath.row)
|
|
||||||
return cell
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Updating
|
// MARK: - Updating
|
||||||
|
|
||||||
private func reload() {
|
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
||||||
AssertIsOnMainThread()
|
self.viewModel.onThreadChange = { [weak self] updatedThreadData in
|
||||||
dbConnection.beginLongLivedReadTransaction() // Jump to the latest commit
|
self?.handleThreadUpdates(updatedThreadData)
|
||||||
dbConnection.read { transaction in
|
}
|
||||||
self.threads.update(with: transaction)
|
|
||||||
|
// 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()
|
||||||
}
|
}
|
||||||
threadViewModelCache.removeAll()
|
|
||||||
tableView.reloadData()
|
|
||||||
clearAllButton.isHidden = (messageRequestCount == 0)
|
|
||||||
emptyStateLabel.isHidden = (messageRequestCount != 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleYapDatabaseModifiedNotification(_ yapDatabase: YapDatabase) {
|
private func handleThreadUpdates(_ updatedData: [MessageRequestsViewModel.SectionModel], initialLoad: Bool = false) {
|
||||||
// NOTE: This code is very finicky and crashes easily. Modify with care.
|
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||||
AssertIsOnMainThread()
|
// in from a frame of CGRect.zero)
|
||||||
|
guard hasLoadedInitialThreadData else {
|
||||||
|
hasLoadedInitialThreadData = true
|
||||||
|
UIView.performWithoutAnimation { handleThreadUpdates(updatedData, initialLoad: true) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// If we don't capture `threads` here, a race condition can occur where the
|
// Show the empty state if there is no data
|
||||||
// `thread.snapshotOfLastUpdate != firstSnapshot - 1` check below evaluates to
|
clearAllButton.isHidden = updatedData.isEmpty
|
||||||
// `false`, but `threads` then changes between that check and the
|
emptyStateLabel.isHidden = !updatedData.isEmpty
|
||||||
// `ext.getSectionChanges(§ionChanges, rowChanges: &rowChanges, for: notifications, with: threads)`
|
|
||||||
// line. This causes `tableView.endUpdates()` to crash with an `NSInternalInconsistencyException`.
|
|
||||||
let threads = threads!
|
|
||||||
|
|
||||||
// Create a stable state for the connection and jump to the latest commit
|
CATransaction.begin()
|
||||||
let notifications = dbConnection.beginLongLivedReadTransaction()
|
CATransaction.setCompletionBlock { [weak self] in
|
||||||
|
// Complete page loading
|
||||||
|
self?.isLoadingMore = false
|
||||||
|
self?.autoLoadNextPageIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
guard !notifications.isEmpty else { return }
|
// Reload the table content (animate changes after the first load)
|
||||||
|
tableView.reload(
|
||||||
|
using: StagedChangeset(source: viewModel.threadData, target: updatedData),
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
let ext = dbConnection.ext(TSThreadDatabaseViewExtensionName) as! YapDatabaseViewConnection
|
CATransaction.commit()
|
||||||
let hasChanges = ext.hasChanges(forGroup: TSMessageRequestGroup, in: notifications)
|
}
|
||||||
|
|
||||||
|
private func autoLoadNextPageIfNeeded() {
|
||||||
|
guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return }
|
||||||
|
|
||||||
guard hasChanges else { return }
|
self.isAutoLoadingNextPage = true
|
||||||
|
|
||||||
if let firstChangeSet = notifications[0].userInfo {
|
DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in
|
||||||
let firstSnapshot = firstChangeSet[YapDatabaseSnapshotKey] as! UInt64
|
self?.isAutoLoadingNextPage = false
|
||||||
|
|
||||||
if threads.snapshotOfLastUpdate != firstSnapshot - 1 {
|
// Note: We sort the headers as we want to prioritise loading newer pages over older ones
|
||||||
return reload() // The code below will crash if we try to process multiple commits at once
|
let sections: [(MessageRequestsViewModel.Section, CGRect)] = (self?.viewModel.threadData
|
||||||
}
|
.enumerated()
|
||||||
}
|
.map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) })
|
||||||
|
.defaulting(to: [])
|
||||||
var sectionChanges = NSArray()
|
let shouldLoadMore: Bool = sections
|
||||||
var rowChanges = NSArray()
|
.contains { section, headerRect in
|
||||||
ext.getSectionChanges(§ionChanges, rowChanges: &rowChanges, for: notifications, with: threads)
|
section == .loadMore &&
|
||||||
|
headerRect != .zero &&
|
||||||
guard sectionChanges.count > 0 || rowChanges.count > 0 else { return }
|
(self?.tableView.bounds.contains(headerRect) == true)
|
||||||
|
}
|
||||||
tableView.beginUpdates()
|
|
||||||
|
|
||||||
rowChanges.forEach { rowChange in
|
|
||||||
let rowChange = rowChange as! YapDatabaseViewRowChange
|
|
||||||
let key = rowChange.collectionKey.key
|
|
||||||
threadViewModelCache[key] = nil
|
|
||||||
switch rowChange.type {
|
|
||||||
case .delete: tableView.deleteRows(at: [ rowChange.indexPath! ], with: UITableView.RowAnimation.automatic)
|
|
||||||
case .insert: tableView.insertRows(at: [ rowChange.newIndexPath! ], with: UITableView.RowAnimation.automatic)
|
|
||||||
case .update: tableView.reloadRows(at: [ rowChange.indexPath! ], with: UITableView.RowAnimation.automatic)
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tableView.endUpdates()
|
|
||||||
|
|
||||||
// HACK: Moves can have conflicts with the other 3 types of change.
|
|
||||||
// Just batch perform all the moves separately to prevent crashing.
|
|
||||||
// Since all the changes are from the original state to the final state,
|
|
||||||
// it will still be correct if we pick the moves out.
|
|
||||||
|
|
||||||
tableView.beginUpdates()
|
|
||||||
|
|
||||||
rowChanges.forEach { rowChange in
|
|
||||||
let rowChange = rowChange as! YapDatabaseViewRowChange
|
|
||||||
let key = rowChange.collectionKey.key
|
|
||||||
threadViewModelCache[key] = nil
|
|
||||||
|
|
||||||
switch rowChange.type {
|
guard shouldLoadMore else { return }
|
||||||
case .move: tableView.moveRow(at: rowChange.indexPath!, to: rowChange.newIndexPath!)
|
|
||||||
default: break
|
self?.isLoadingMore = true
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||||
|
self?.viewModel.pagedDataObserver?.load(.pageAfter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tableView.endUpdates()
|
|
||||||
clearAllButton.isHidden = (messageRequestCount == 0)
|
|
||||||
emptyStateLabel.isHidden = (messageRequestCount != 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func handleProfileDidChangeNotification(_ notification: Notification) {
|
|
||||||
tableView.reloadData() // TODO: Just reload the affected cell
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func handleBlockedContactsUpdatedNotification(_ notification: Notification) {
|
|
||||||
tableView.reloadData() // TODO: Just reload the affected cell
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc override internal func handleAppModeChangedNotification(_ notification: Notification) {
|
@objc override internal func handleAppModeChangedNotification(_ notification: Notification) {
|
||||||
super.handleAppModeChangedNotification(notification)
|
super.handleAppModeChangedNotification(notification)
|
||||||
|
|
||||||
let gradient = Gradients.homeVCFade
|
let gradient = Gradients.homeVCFade
|
||||||
fadeView.setGradient(gradient) // Re-do the gradient
|
fadeView.setGradient(gradient) // Re-do the gradient
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.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.tintColor = Colors.text
|
||||||
|
loadingIndicator.alpha = 0.5
|
||||||
|
loadingIndicator.startAnimating()
|
||||||
|
|
||||||
|
let view: UIView = UIView()
|
||||||
|
view.addSubview(loadingIndicator)
|
||||||
|
loadingIndicator.center(in: view)
|
||||||
|
|
||||||
|
return view
|
||||||
|
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - UITableViewDelegate
|
// 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: .default).async { [weak self] in
|
||||||
|
self?.viewModel.pagedDataObserver?.load(.pageAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
|
||||||
guard let thread = self.thread(at: indexPath.row) else { return }
|
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
||||||
|
|
||||||
let conversationVC = ConversationVC(thread: thread)
|
switch section.model {
|
||||||
self.navigationController?.pushViewController(conversationVC, animated: true)
|
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 {
|
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
||||||
guard let thread = self.thread(at: indexPath.row) else { return [] }
|
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
||||||
|
|
||||||
let delete = UITableViewRowAction(style: .destructive, title: NSLocalizedString("TXT_DELETE_TITLE", comment: "")) { [weak self] _, _ in
|
switch section.model {
|
||||||
self?.delete(thread)
|
case .threads:
|
||||||
|
let threadId: String = section.elements[indexPath.row].threadId
|
||||||
|
let delete = UITableViewRowAction(
|
||||||
|
style: .destructive,
|
||||||
|
title: "TXT_DELETE_TITLE".localized()
|
||||||
|
) { [weak self] _, _ in
|
||||||
|
self?.delete(threadId)
|
||||||
|
}
|
||||||
|
delete.backgroundColor = Colors.destructive
|
||||||
|
|
||||||
|
return [ delete ]
|
||||||
|
|
||||||
|
default: return []
|
||||||
}
|
}
|
||||||
delete.backgroundColor = Colors.destructive
|
|
||||||
|
|
||||||
return [ delete ]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
private func updateContactAndThread(thread: TSThread, with transaction: YapDatabaseReadWriteTransaction, onComplete: ((Bool) -> ())? = nil) {
|
@objc private func clearAllTapped() {
|
||||||
guard let contactThread: TSContactThread = thread as? TSContactThread else {
|
guard viewModel.threadData.first(where: { $0.model == .threads })?.elements.isEmpty == false else {
|
||||||
onComplete?(false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var needsSync: Bool = false
|
let threadIds: [String] = (viewModel.threadData
|
||||||
|
.first { $0.model == .threads }?
|
||||||
// Update the contact
|
.elements
|
||||||
let sessionId: String = contactThread.contactSessionID()
|
.map { $0.threadId })
|
||||||
|
.defaulting(to: [])
|
||||||
if let contact: Contact = Storage.shared.getContact(with: sessionId), (contact.isApproved || !contact.isBlocked) {
|
let alertVC: UIAlertController = UIAlertController(
|
||||||
contact.isApproved = false
|
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE".localized(),
|
||||||
contact.isBlocked = true
|
message: nil,
|
||||||
|
preferredStyle: .actionSheet
|
||||||
Storage.shared.setContact(contact, using: transaction)
|
)
|
||||||
needsSync = true
|
alertVC.addAction(UIAlertAction(
|
||||||
}
|
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON".localized(),
|
||||||
|
style: .destructive
|
||||||
// Delete all thread content
|
) { _ in
|
||||||
thread.removeAllThreadInteractions(with: transaction)
|
|
||||||
thread.remove(with: transaction)
|
|
||||||
|
|
||||||
onComplete?(needsSync)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func clearAllTapped() {
|
|
||||||
let threadCount: Int = Int(messageRequestCount)
|
|
||||||
let threads: [TSThread] = (0..<threadCount).compactMap { self.thread(at: $0) }
|
|
||||||
var needsSync: Bool = false
|
|
||||||
|
|
||||||
let alertVC: UIAlertController = UIAlertController(title: NSLocalizedString("MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE", comment: ""), message: nil, preferredStyle: .actionSheet)
|
|
||||||
alertVC.addAction(UIAlertAction(title: NSLocalizedString("MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON", comment: ""), style: .destructive) { _ in
|
|
||||||
// Clear the requests
|
// Clear the requests
|
||||||
Storage.write(
|
Storage.shared.write { db in
|
||||||
with: { [weak self] transaction in
|
_ = try SessionThread
|
||||||
threads.forEach { thread in
|
.filter(ids: threadIds)
|
||||||
if let uniqueId: String = thread.uniqueId {
|
.deleteAll(db)
|
||||||
Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction)
|
|
||||||
}
|
try threadIds.forEach { threadId in
|
||||||
|
_ = try Contact
|
||||||
self?.updateContactAndThread(thread: thread, with: transaction) { threadNeedsSync in
|
.fetchOrCreate(db, id: threadId)
|
||||||
if threadNeedsSync {
|
.with(
|
||||||
needsSync = true
|
isApproved: false,
|
||||||
}
|
isBlocked: true
|
||||||
}
|
)
|
||||||
|
.saved(db)
|
||||||
// Block the contact
|
|
||||||
if
|
|
||||||
let sessionId: String = (thread as? TSContactThread)?.contactSessionID(),
|
|
||||||
!thread.isBlocked(),
|
|
||||||
let contact: Contact = Storage.shared.getContact(with: sessionId, using: transaction)
|
|
||||||
{
|
|
||||||
contact.isBlocked = true
|
|
||||||
Storage.shared.setContact(contact, using: transaction)
|
|
||||||
needsSync = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
completion: {
|
|
||||||
// Force a config sync
|
|
||||||
if needsSync {
|
|
||||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
})
|
// Force a config sync
|
||||||
alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .cancel, handler: nil))
|
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||||
self.present(alertVC, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func delete(_ thread: TSThread) {
|
|
||||||
guard let uniqueId: String = thread.uniqueId else { return }
|
|
||||||
|
|
||||||
let alertVC: UIAlertController = UIAlertController(title: NSLocalizedString("MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON", comment: ""), message: nil, preferredStyle: .actionSheet)
|
|
||||||
alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_DELETE_TITLE", comment: ""), style: .destructive) { _ in
|
|
||||||
Storage.write(
|
|
||||||
with: { [weak self] transaction in
|
|
||||||
Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction)
|
|
||||||
self?.updateContactAndThread(thread: thread, with: transaction)
|
|
||||||
|
|
||||||
// Block the contact
|
|
||||||
if
|
|
||||||
let sessionId: String = (thread as? TSContactThread)?.contactSessionID(),
|
|
||||||
!thread.isBlocked(),
|
|
||||||
let contact: Contact = Storage.shared.getContact(with: sessionId, using: transaction)
|
|
||||||
{
|
|
||||||
contact.isBlocked = true
|
|
||||||
Storage.shared.setContact(contact, using: transaction)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
completion: {
|
|
||||||
// Force a config sync
|
|
||||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .cancel, handler: nil))
|
|
||||||
self.present(alertVC, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Convenience
|
|
||||||
|
|
||||||
private func thread(at index: Int) -> TSThread? {
|
|
||||||
var thread: TSThread? = nil
|
|
||||||
|
|
||||||
dbConnection.read { transaction in
|
|
||||||
let ext: YapDatabaseViewTransaction? = transaction.ext(TSThreadDatabaseViewExtensionName) as? YapDatabaseViewTransaction
|
|
||||||
thread = ext?.object(atRow: UInt(index), inSection: 0, with: self.threads) as? TSThread
|
|
||||||
}
|
|
||||||
|
|
||||||
return thread
|
|
||||||
}
|
|
||||||
|
|
||||||
private func threadViewModel(at index: Int) -> ThreadViewModel? {
|
|
||||||
guard let thread = thread(at: index), let uniqueId: String = thread.uniqueId else { return nil }
|
|
||||||
|
|
||||||
if let cachedThreadViewModel = threadViewModelCache[uniqueId] {
|
|
||||||
return cachedThreadViewModel
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
var threadViewModel: ThreadViewModel? = nil
|
|
||||||
dbConnection.read { transaction in
|
|
||||||
threadViewModel = ThreadViewModel(thread: thread, transaction: transaction)
|
|
||||||
}
|
}
|
||||||
threadViewModelCache[uniqueId] = threadViewModel
|
})
|
||||||
|
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||||
return threadViewModel
|
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)
|
||||||
|
_ = 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))
|
||||||
|
self.present(alertVC, animated: true, completion: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,174 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import DifferenceKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
|
public class MessageRequestsViewModel {
|
||||||
|
public typealias SectionModel = ArraySection<Section, SessionThreadViewModel>
|
||||||
|
|
||||||
|
// MARK: - Section
|
||||||
|
|
||||||
|
public enum Section: Differentiable {
|
||||||
|
case threads
|
||||||
|
case loadMore
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Variables
|
||||||
|
|
||||||
|
public static let pageSize: Int = 20
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init() {
|
||||||
|
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 thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||||
|
self.pagedDataObserver = PagedDatabaseObserver(
|
||||||
|
pagedTable: SessionThread.self,
|
||||||
|
pageSize: MessageRequestsViewModel.pageSize,
|
||||||
|
idColumn: .id,
|
||||||
|
observedChanges: [
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: SessionThread.self,
|
||||||
|
columns: [
|
||||||
|
.id,
|
||||||
|
.shouldBeVisible
|
||||||
|
]
|
||||||
|
),
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: Interaction.self,
|
||||||
|
columns: [
|
||||||
|
.body,
|
||||||
|
.wasRead
|
||||||
|
],
|
||||||
|
joinToPagedType: {
|
||||||
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
|
|
||||||
|
return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
|
||||||
|
}()
|
||||||
|
),
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: Contact.self,
|
||||||
|
columns: [.isBlocked],
|
||||||
|
joinToPagedType: {
|
||||||
|
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||||
|
|
||||||
|
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
|
||||||
|
}()
|
||||||
|
),
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: Profile.self,
|
||||||
|
columns: [.name, .nickname, .profilePictureFileName],
|
||||||
|
joinToPagedType: {
|
||||||
|
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||||
|
|
||||||
|
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
|
||||||
|
}()
|
||||||
|
),
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: RecipientState.self,
|
||||||
|
columns: [.state],
|
||||||
|
joinToPagedType: {
|
||||||
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
|
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
|
||||||
|
|
||||||
|
return """
|
||||||
|
LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
|
||||||
|
LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
|
||||||
|
"""
|
||||||
|
}()
|
||||||
|
)
|
||||||
|
],
|
||||||
|
/// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query
|
||||||
|
joinSQL: SessionThreadViewModel.optimisedJoinSQL,
|
||||||
|
filterSQL: SessionThreadViewModel.messageRequestsFilterSQL(userPublicKey: userPublicKey),
|
||||||
|
groupSQL: SessionThreadViewModel.groupSQL,
|
||||||
|
orderSQL: SessionThreadViewModel.messageRequetsOrderSQL,
|
||||||
|
dataQuery: SessionThreadViewModel.baseQuery(
|
||||||
|
userPublicKey: userPublicKey,
|
||||||
|
filterSQL: SessionThreadViewModel.messageRequestsFilterSQL(userPublicKey: userPublicKey),
|
||||||
|
groupSQL: SessionThreadViewModel.groupSQL,
|
||||||
|
orderSQL: SessionThreadViewModel.messageRequetsOrderSQL
|
||||||
|
),
|
||||||
|
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||||
|
guard let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes
|
||||||
|
// to be sent to the callback if we ever start observing again (when we have the callback it needs
|
||||||
|
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
|
||||||
|
// correct order)
|
||||||
|
guard let onThreadChange: (([SectionModel]) -> ()) = self?.onThreadChange else {
|
||||||
|
self?.unobservedThreadDataChanges = updatedThreadData
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onThreadChange(updatedThreadData)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run the initial query on a background thread so we don't block the push transition
|
||||||
|
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||||
|
// The `.pageBefore` will query from a `0` offset loading the first page
|
||||||
|
self?.pagedDataObserver?.load(.pageBefore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Thread Data
|
||||||
|
|
||||||
|
public private(set) var unobservedThreadDataChanges: [SectionModel]?
|
||||||
|
public private(set) var threadData: [SectionModel] = []
|
||||||
|
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
|
||||||
|
|
||||||
|
public var onThreadChange: (([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] = self.unobservedThreadDataChanges {
|
||||||
|
onThreadChange?(unobservedThreadDataChanges)
|
||||||
|
self.unobservedThreadDataChanges = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func process(data: [SessionThreadViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
||||||
|
let groupedOldData: [String: [SessionThreadViewModel]] = (self.threadData
|
||||||
|
.first(where: { $0.model == .threads })?
|
||||||
|
.elements)
|
||||||
|
.defaulting(to: [])
|
||||||
|
.grouped(by: \.threadId)
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
SectionModel(
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
],
|
||||||
|
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
|
||||||
|
[SectionModel(section: .loadMore)] :
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
].flatMap { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateThreadData(_ updatedData: [SectionModel]) {
|
||||||
|
self.threadData = updatedData
|
||||||
|
}
|
||||||
|
}
|
|
@ -60,7 +60,7 @@ class MessageRequestsCell: UITableViewCell {
|
||||||
result.translatesAutoresizingMaskIntoConstraints = false
|
result.translatesAutoresizingMaskIntoConstraints = false
|
||||||
result.clipsToBounds = true
|
result.clipsToBounds = true
|
||||||
result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity)
|
result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity)
|
||||||
result.layer.cornerRadius = (ConversationCell.unreadCountViewSize / 2)
|
result.layer.cornerRadius = (FullConversationCell.unreadCountViewSize / 2)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
@ -115,8 +115,8 @@ class MessageRequestsCell: UITableViewCell {
|
||||||
|
|
||||||
unreadCountView.leftAnchor.constraint(equalTo: titleLabel.rightAnchor, constant: (Values.smallSpacing / 2)),
|
unreadCountView.leftAnchor.constraint(equalTo: titleLabel.rightAnchor, constant: (Values.smallSpacing / 2)),
|
||||||
unreadCountView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
|
unreadCountView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
|
||||||
unreadCountView.widthAnchor.constraint(equalToConstant: ConversationCell.unreadCountViewSize),
|
unreadCountView.widthAnchor.constraint(equalToConstant: FullConversationCell.unreadCountViewSize),
|
||||||
unreadCountView.heightAnchor.constraint(equalToConstant: ConversationCell.unreadCountViewSize),
|
unreadCountView.heightAnchor.constraint(equalToConstant: FullConversationCell.unreadCountViewSize),
|
||||||
|
|
||||||
unreadCountLabel.topAnchor.constraint(equalTo: unreadCountView.topAnchor),
|
unreadCountLabel.topAnchor.constraint(equalTo: unreadCountView.topAnchor),
|
||||||
unreadCountLabel.leftAnchor.constraint(equalTo: unreadCountView.leftAnchor),
|
unreadCountLabel.leftAnchor.constraint(equalTo: unreadCountView.leftAnchor),
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
protocol GifPickerLayoutDelegate: class {
|
protocol GifPickerLayoutDelegate: AnyObject {
|
||||||
func imageInfosForLayout() -> [GiphyImageInfo]
|
func imageInfosForLayout() -> [GiphyImageInfo]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,11 +8,6 @@ import SignalUtilitiesKit
|
||||||
import PromiseKit
|
import PromiseKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
|
||||||
@objc
|
|
||||||
protocol GifPickerViewControllerDelegate: class {
|
|
||||||
func gifPickerDidSelect(attachment: SignalAttachment)
|
|
||||||
}
|
|
||||||
|
|
||||||
class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate, GifPickerLayoutDelegate {
|
class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate, GifPickerLayoutDelegate {
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
@ -31,11 +26,8 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
||||||
|
|
||||||
var lastQuery: String = ""
|
var lastQuery: String = ""
|
||||||
|
|
||||||
@objc
|
|
||||||
public weak var delegate: GifPickerViewControllerDelegate?
|
public weak var delegate: GifPickerViewControllerDelegate?
|
||||||
|
|
||||||
let thread: TSThread
|
|
||||||
|
|
||||||
let searchBar: SearchBar
|
let searchBar: SearchBar
|
||||||
let layout: GifPickerLayout
|
let layout: GifPickerLayout
|
||||||
let collectionView: UICollectionView
|
let collectionView: UICollectionView
|
||||||
|
@ -51,17 +43,14 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
||||||
|
|
||||||
var progressiveSearchTimer: Timer?
|
var progressiveSearchTimer: Timer?
|
||||||
|
|
||||||
// MARK: Initializers
|
// MARK: - Initialization
|
||||||
|
|
||||||
@available(*, unavailable, message:"use other constructor instead.")
|
@available(*, unavailable, message:"use other constructor instead.")
|
||||||
required init?(coder aDecoder: NSCoder) {
|
required init?(coder aDecoder: NSCoder) {
|
||||||
notImplemented()
|
notImplemented()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
required init() {
|
||||||
required init(thread: TSThread) {
|
|
||||||
self.thread = thread
|
|
||||||
|
|
||||||
self.searchBar = SearchBar()
|
self.searchBar = SearchBar()
|
||||||
self.layout = GifPickerLayout()
|
self.layout = GifPickerLayout()
|
||||||
self.collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: self.layout)
|
self.collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: self.layout)
|
||||||
|
@ -116,7 +105,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
||||||
|
|
||||||
// Loki: Customize title
|
// Loki: Customize title
|
||||||
let titleLabel = UILabel()
|
let titleLabel = UILabel()
|
||||||
titleLabel.text = NSLocalizedString("GIF", comment: "")
|
titleLabel.text = "accessibility_gif_button".localized().uppercased()
|
||||||
titleLabel.textColor = Colors.text
|
titleLabel.textColor = Colors.text
|
||||||
titleLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
titleLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||||
navigationItem.titleView = titleLabel
|
navigationItem.titleView = titleLabel
|
||||||
|
@ -469,8 +458,8 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
||||||
progressiveSearchTimer = nil
|
progressiveSearchTimer = nil
|
||||||
|
|
||||||
guard let text = searchBar.text else {
|
guard let text = searchBar.text else {
|
||||||
OWSAlerts.showErrorAlert(message: NSLocalizedString("GIF_PICKER_VIEW_MISSING_QUERY",
|
// Alert message shown when user tries to search for GIFs without entering any search terms
|
||||||
comment: "Alert message shown when user tries to search for GIFs without entering any search terms."))
|
OWSAlerts.showErrorAlert(message: "GIF_PICKER_VIEW_MISSING_QUERY".localized())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -556,3 +545,9 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
||||||
layout.invalidateLayout()
|
layout.invalidateLayout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - GifPickerViewControllerDelegate
|
||||||
|
|
||||||
|
protocol GifPickerViewControllerDelegate: AnyObject {
|
||||||
|
func gifPickerDidSelect(attachment: SignalAttachment)
|
||||||
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Photos
|
import Photos
|
||||||
import PromiseKit
|
import PromiseKit
|
||||||
|
import SessionUIKit
|
||||||
|
|
||||||
protocol ImagePickerGridControllerDelegate: AnyObject {
|
protocol ImagePickerGridControllerDelegate: AnyObject {
|
||||||
func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController)
|
func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController)
|
||||||
|
@ -46,6 +47,8 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
self.view.backgroundColor = Colors.navigationBarBackground
|
||||||
|
|
||||||
library.add(delegate: self)
|
library.add(delegate: self)
|
||||||
|
|
||||||
|
@ -54,12 +57,11 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
collectionView.register(PhotoGridViewCell.self, forCellWithReuseIdentifier: PhotoGridViewCell.reuseIdentifier)
|
collectionView.register(view: PhotoGridViewCell.self)
|
||||||
|
|
||||||
// ensure images at the end of the list can be scrolled above the bottom buttons
|
// ensure images at the end of the list can be scrolled above the bottom buttons
|
||||||
let bottomButtonInset = -1 * SendMediaNavigationController.bottomButtonsCenterOffset + SendMediaNavigationController.bottomButtonWidth / 2 + 16
|
let bottomButtonInset = -1 * SendMediaNavigationController.bottomButtonsCenterOffset + SendMediaNavigationController.bottomButtonWidth / 2 + 16
|
||||||
collectionView.contentInset.bottom = bottomButtonInset + 16
|
collectionView.contentInset.bottom = bottomButtonInset + 16
|
||||||
view.backgroundColor = .white
|
|
||||||
|
|
||||||
// The PhotoCaptureVC needs a shadow behind it's cancel button, so we use a custom icon.
|
// The PhotoCaptureVC needs a shadow behind it's cancel button, so we use a custom icon.
|
||||||
// This VC has a visible navbar so doesn't need the shadow, but because the user can
|
// This VC has a visible navbar so doesn't need the shadow, but because the user can
|
||||||
|
@ -69,24 +71,16 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
let cancelImage = UIImage(imageLiteralResourceName: "X")
|
let cancelImage = UIImage(imageLiteralResourceName: "X")
|
||||||
let cancelButton = UIBarButtonItem(image: cancelImage, style: .plain, target: self, action: #selector(didPressCancel))
|
let cancelButton = UIBarButtonItem(image: cancelImage, style: .plain, target: self, action: #selector(didPressCancel))
|
||||||
|
|
||||||
cancelButton.tintColor = .black
|
cancelButton.tintColor = Colors.text
|
||||||
navigationItem.leftBarButtonItem = cancelButton
|
navigationItem.leftBarButtonItem = cancelButton
|
||||||
|
|
||||||
let titleView = TitleView()
|
let titleView = TitleView()
|
||||||
titleView.delegate = self
|
titleView.delegate = self
|
||||||
titleView.text = photoCollection.localizedTitle()
|
titleView.text = photoCollection.localizedTitle()
|
||||||
|
|
||||||
if #available(iOS 11, *) {
|
|
||||||
// do nothing
|
|
||||||
} else {
|
|
||||||
// must assign titleView frame manually on older iOS
|
|
||||||
titleView.frame = CGRect(origin: .zero, size: titleView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize))
|
|
||||||
}
|
|
||||||
|
|
||||||
navigationItem.titleView = titleView
|
navigationItem.titleView = titleView
|
||||||
self.titleView = titleView
|
self.titleView = titleView
|
||||||
|
|
||||||
collectionView.backgroundColor = .white
|
collectionView.backgroundColor = Colors.navigationBarBackground
|
||||||
|
|
||||||
let selectionPanGesture = DirectionalPanGestureRecognizer(direction: [.horizontal], target: self, action: #selector(didPanSelection))
|
let selectionPanGesture = DirectionalPanGestureRecognizer(direction: [.horizontal], target: self, action: #selector(didPanSelection))
|
||||||
selectionPanGesture.delegate = self
|
selectionPanGesture.delegate = self
|
||||||
|
@ -200,16 +194,15 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
// Loki: Set navigation bar background color
|
let backgroundImage: UIImage = UIImage(color: Colors.navigationBarBackground)
|
||||||
let navigationBar = navigationController!.navigationBar
|
self.navigationItem.title = nil
|
||||||
navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default)
|
self.navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default)
|
||||||
navigationBar.shadowImage = UIImage()
|
self.navigationController?.navigationBar.shadowImage = UIImage()
|
||||||
navigationBar.isTranslucent = false
|
self.navigationController?.navigationBar.isTranslucent = false
|
||||||
navigationBar.barTintColor = .white
|
self.navigationController?.navigationBar.barTintColor = Colors.navigationBarBackground
|
||||||
(navigationBar as! OWSNavigationBar).respectsTheme = false
|
(self.navigationController?.navigationBar as? OWSNavigationBar)?.respectsTheme = true
|
||||||
navigationBar.backgroundColor = .white
|
self.navigationController?.navigationBar.backgroundColor = Colors.navigationBarBackground
|
||||||
let backgroundImage = UIImage(color: .white)
|
self.navigationController?.navigationBar.setBackgroundImage(backgroundImage, for: .default)
|
||||||
navigationBar.setBackgroundImage(backgroundImage, for: .default)
|
|
||||||
|
|
||||||
// Determine the size of the thumbnails to request
|
// Determine the size of the thumbnails to request
|
||||||
let scale = UIScreen.main.scale
|
let scale = UIScreen.main.scale
|
||||||
|
@ -268,11 +261,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
// MARK:
|
// MARK:
|
||||||
|
|
||||||
var lastPageYOffset: CGFloat {
|
var lastPageYOffset: CGFloat {
|
||||||
var yOffset = collectionView.contentSize.height - collectionView.bounds.height + collectionView.adjustedContentInset.bottom
|
return (collectionView.contentSize.height - collectionView.bounds.height + collectionView.adjustedContentInset.bottom + view.safeAreaInsets.bottom)
|
||||||
if #available(iOS 11.0, *) {
|
|
||||||
yOffset += view.safeAreaInsets.bottom
|
|
||||||
}
|
|
||||||
return yOffset
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func scrollToBottom(animated: Bool) {
|
func scrollToBottom(animated: Bool) {
|
||||||
|
@ -343,10 +332,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
static let kInterItemSpacing: CGFloat = 2
|
static let kInterItemSpacing: CGFloat = 2
|
||||||
private class func buildLayout() -> UICollectionViewFlowLayout {
|
private class func buildLayout() -> UICollectionViewFlowLayout {
|
||||||
let layout = UICollectionViewFlowLayout()
|
let layout = UICollectionViewFlowLayout()
|
||||||
|
layout.sectionInsetReference = .fromSafeArea
|
||||||
if #available(iOS 11, *) {
|
|
||||||
layout.sectionInsetReference = .fromSafeArea
|
|
||||||
}
|
|
||||||
layout.minimumInteritemSpacing = kInterItemSpacing
|
layout.minimumInteritemSpacing = kInterItemSpacing
|
||||||
layout.minimumLineSpacing = kInterItemSpacing
|
layout.minimumLineSpacing = kInterItemSpacing
|
||||||
layout.sectionHeadersPinToVisibleBounds = true
|
layout.sectionHeadersPinToVisibleBounds = true
|
||||||
|
@ -355,13 +341,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateLayout() {
|
func updateLayout() {
|
||||||
let containerWidth: CGFloat
|
let containerWidth: CGFloat = self.view.safeAreaLayoutGuide.layoutFrame.size.width
|
||||||
if #available(iOS 11.0, *) {
|
|
||||||
containerWidth = self.view.safeAreaLayoutGuide.layoutFrame.size.width
|
|
||||||
} else {
|
|
||||||
containerWidth = self.view.frame.size.width
|
|
||||||
}
|
|
||||||
|
|
||||||
let kItemsPerPortraitRow = 4
|
let kItemsPerPortraitRow = 4
|
||||||
let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
|
let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
|
||||||
let approxItemWidth = screenWidth / CGFloat(kItemsPerPortraitRow)
|
let approxItemWidth = screenWidth / CGFloat(kItemsPerPortraitRow)
|
||||||
|
@ -556,11 +536,9 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
return UICollectionViewCell(forAutoLayout: ())
|
return UICollectionViewCell(forAutoLayout: ())
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PhotoGridViewCell.reuseIdentifier, for: indexPath) as? PhotoGridViewCell else {
|
let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath)
|
||||||
owsFail("cell was unexpectedly nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
cell.loadingColor = UIColor(white: 0.2, alpha: 1)
|
cell.loadingColor = UIColor(white: 0.2, alpha: 1)
|
||||||
|
|
||||||
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
|
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
|
||||||
cell.configure(item: assetItem)
|
cell.configure(item: assetItem)
|
||||||
|
|
||||||
|
@ -587,7 +565,7 @@ extension ImagePickerGridController: UIGestureRecognizerDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol TitleViewDelegate: class {
|
protocol TitleViewDelegate: AnyObject {
|
||||||
func titleViewWasTapped(_ titleView: TitleView)
|
func titleViewWasTapped(_ titleView: TitleView)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -615,10 +593,10 @@ class TitleView: UIView {
|
||||||
addSubview(stackView)
|
addSubview(stackView)
|
||||||
stackView.autoPinEdgesToSuperviewEdges()
|
stackView.autoPinEdgesToSuperviewEdges()
|
||||||
|
|
||||||
label.textColor = .black
|
label.textColor = Colors.text
|
||||||
label.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
label.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||||
|
|
||||||
iconView.tintColor = .black
|
iconView.tintColor = Colors.text
|
||||||
iconView.image = UIImage(named: "navbar_disclosure_down")?.withRenderingMode(.alwaysTemplate)
|
iconView.image = UIImage(named: "navbar_disclosure_down")?.withRenderingMode(.alwaysTemplate)
|
||||||
|
|
||||||
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(titleTapped)))
|
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(titleTapped)))
|
||||||
|
|
|
@ -1,54 +0,0 @@
|
||||||
//
|
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <SignalUtilitiesKit/OWSViewController.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
@protocol ConversationViewItem;
|
|
||||||
|
|
||||||
@class GalleryItemBox;
|
|
||||||
@class MediaDetailViewController;
|
|
||||||
@class TSAttachment;
|
|
||||||
|
|
||||||
typedef NS_OPTIONS(NSInteger, MediaGalleryOption) {
|
|
||||||
MediaGalleryOptionSliderEnabled = 1 << 0,
|
|
||||||
MediaGalleryOptionShowAllMediaButton = 1 << 1
|
|
||||||
};
|
|
||||||
|
|
||||||
@protocol MediaDetailViewControllerDelegate <NSObject>
|
|
||||||
|
|
||||||
- (void)mediaDetailViewController:(MediaDetailViewController *)mediaDetailViewController
|
|
||||||
requestDeleteAttachment:(TSAttachment *)attachment;
|
|
||||||
|
|
||||||
- (void)mediaDetailViewController:(MediaDetailViewController *)mediaDetailViewController
|
|
||||||
isPlayingVideo:(BOOL)isPlayingVideo;
|
|
||||||
|
|
||||||
- (void)mediaDetailViewControllerDidTapMedia:(MediaDetailViewController *)mediaDetailViewController;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
@interface MediaDetailViewController : OWSViewController
|
|
||||||
|
|
||||||
@property (nonatomic, weak) id<MediaDetailViewControllerDelegate> delegate;
|
|
||||||
@property (nonatomic, readonly) GalleryItemBox *galleryItemBox;
|
|
||||||
|
|
||||||
// If viewItem is non-null, long press will show a menu controller.
|
|
||||||
- (instancetype)initWithGalleryItemBox:(GalleryItemBox *)galleryItemBox
|
|
||||||
viewItem:(nullable id<ConversationViewItem>)viewItem;
|
|
||||||
#pragma mark - Actions
|
|
||||||
|
|
||||||
- (void)didPressPlayBarButton:(id)sender;
|
|
||||||
- (void)didPressPauseBarButton:(id)sender;
|
|
||||||
- (void)playVideo;
|
|
||||||
|
|
||||||
// Stops playback and rewinds
|
|
||||||
- (void)stopAnyVideo;
|
|
||||||
|
|
||||||
- (void)setShouldHideToolbars:(BOOL)shouldHideToolbars;
|
|
||||||
- (void)zoomOutAnimated:(BOOL)isAnimated;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
|
@ -1,500 +0,0 @@
|
||||||
//
|
|
||||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import "MediaDetailViewController.h"
|
|
||||||
#import "ConversationViewItem.h"
|
|
||||||
#import "Session-Swift.h"
|
|
||||||
#import "TSAttachmentStream.h"
|
|
||||||
#import "TSInteraction.h"
|
|
||||||
#import "UIColor+OWS.h"
|
|
||||||
#import "UIUtil.h"
|
|
||||||
#import "UIView+OWS.h"
|
|
||||||
#import <AVKit/AVKit.h>
|
|
||||||
#import <MediaPlayer/MPMoviePlayerViewController.h>
|
|
||||||
#import <MediaPlayer/MediaPlayer.h>
|
|
||||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
|
||||||
#import <SessionUtilitiesKit/NSData+Image.h>
|
|
||||||
#import <SessionUIKit/SessionUIKit.h>
|
|
||||||
#import <YYImage/YYImage.h>
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
|
|
||||||
@interface MediaDetailViewController () <UIScrollViewDelegate,
|
|
||||||
UIGestureRecognizerDelegate,
|
|
||||||
PlayerProgressBarDelegate,
|
|
||||||
OWSVideoPlayerDelegate>
|
|
||||||
|
|
||||||
@property (nonatomic) UIScrollView *scrollView;
|
|
||||||
@property (nonatomic) UIView *mediaView;
|
|
||||||
@property (nonatomic) UIView *presentationView;
|
|
||||||
@property (nonatomic) UIView *replacingView;
|
|
||||||
@property (nonatomic) UIButton *shareButton;
|
|
||||||
|
|
||||||
@property (nonatomic) TSAttachmentStream *attachmentStream;
|
|
||||||
@property (nonatomic, nullable) id<ConversationViewItem> viewItem;
|
|
||||||
@property (nonatomic, nullable) UIImage *image;
|
|
||||||
|
|
||||||
@property (nonatomic, nullable) OWSVideoPlayer *videoPlayer;
|
|
||||||
@property (nonatomic, nullable) UIButton *playVideoButton;
|
|
||||||
@property (nonatomic, nullable) PlayerProgressBar *videoProgressBar;
|
|
||||||
@property (nonatomic, nullable) UIBarButtonItem *videoPlayBarButton;
|
|
||||||
@property (nonatomic, nullable) UIBarButtonItem *videoPauseBarButton;
|
|
||||||
|
|
||||||
@property (nonatomic, nullable) NSArray<NSLayoutConstraint *> *presentationViewConstraints;
|
|
||||||
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewBottomConstraint;
|
|
||||||
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewLeadingConstraint;
|
|
||||||
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewTopConstraint;
|
|
||||||
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewTrailingConstraint;
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
#pragma mark -
|
|
||||||
|
|
||||||
@implementation MediaDetailViewController
|
|
||||||
|
|
||||||
- (void)dealloc
|
|
||||||
{
|
|
||||||
[self stopAnyVideo];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (instancetype)initWithGalleryItemBox:(GalleryItemBox *)galleryItemBox
|
|
||||||
viewItem:(nullable id<ConversationViewItem>)viewItem
|
|
||||||
{
|
|
||||||
self = [super initWithNibName:nil bundle:nil];
|
|
||||||
if (!self) {
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
_galleryItemBox = galleryItemBox;
|
|
||||||
_viewItem = viewItem;
|
|
||||||
|
|
||||||
// We cache the image data in case the attachment stream is deleted.
|
|
||||||
__weak MediaDetailViewController *weakSelf = self;
|
|
||||||
_image = [galleryItemBox.attachmentStream
|
|
||||||
thumbnailImageLargeWithSuccess:^(UIImage *image) {
|
|
||||||
weakSelf.image = image;
|
|
||||||
[weakSelf updateContents];
|
|
||||||
[weakSelf updateMinZoomScale];
|
|
||||||
}
|
|
||||||
failure:^{
|
|
||||||
OWSLogWarn(@"Could not load media.");
|
|
||||||
}];
|
|
||||||
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (TSAttachmentStream *)attachmentStream
|
|
||||||
{
|
|
||||||
return self.galleryItemBox.attachmentStream;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (BOOL)isAnimated
|
|
||||||
{
|
|
||||||
return self.attachmentStream.isAnimated;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (BOOL)isVideo
|
|
||||||
{
|
|
||||||
return self.attachmentStream.isVideo;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)viewDidLoad
|
|
||||||
{
|
|
||||||
[super viewDidLoad];
|
|
||||||
|
|
||||||
self.view.backgroundColor = LKColors.navigationBarBackground;
|
|
||||||
|
|
||||||
[self updateContents];
|
|
||||||
|
|
||||||
// Loki: Set navigation bar background color
|
|
||||||
UINavigationBar *navigationBar = self.navigationController.navigationBar;
|
|
||||||
[navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault];
|
|
||||||
navigationBar.shadowImage = [UIImage new];
|
|
||||||
[navigationBar setTranslucent:NO];
|
|
||||||
navigationBar.barTintColor = LKColors.navigationBarBackground;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)viewWillAppear:(BOOL)animated
|
|
||||||
{
|
|
||||||
[super viewWillAppear:animated];
|
|
||||||
[self resetMediaFrame];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)viewDidLayoutSubviews
|
|
||||||
{
|
|
||||||
[super viewDidLayoutSubviews];
|
|
||||||
|
|
||||||
[self updateMinZoomScale];
|
|
||||||
[self centerMediaViewConstraints];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)updateMinZoomScale
|
|
||||||
{
|
|
||||||
if (!self.image) {
|
|
||||||
self.scrollView.minimumZoomScale = 1.f;
|
|
||||||
self.scrollView.maximumZoomScale = 1.f;
|
|
||||||
self.scrollView.zoomScale = 1.f;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
CGSize viewSize = self.scrollView.bounds.size;
|
|
||||||
UIImage *image = self.image;
|
|
||||||
OWSAssertDebug(image);
|
|
||||||
|
|
||||||
if (image.size.width == 0 || image.size.height == 0) {
|
|
||||||
OWSFailDebug(@"Invalid image dimensions. %@", NSStringFromCGSize(image.size));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
CGFloat scaleWidth = viewSize.width / image.size.width;
|
|
||||||
CGFloat scaleHeight = viewSize.height / image.size.height;
|
|
||||||
CGFloat minScale = MIN(scaleWidth, scaleHeight);
|
|
||||||
|
|
||||||
if (minScale != self.scrollView.minimumZoomScale) {
|
|
||||||
self.scrollView.minimumZoomScale = minScale;
|
|
||||||
self.scrollView.maximumZoomScale = minScale * 8;
|
|
||||||
self.scrollView.zoomScale = minScale;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)zoomOutAnimated:(BOOL)isAnimated
|
|
||||||
{
|
|
||||||
if (self.scrollView.zoomScale != self.scrollView.minimumZoomScale) {
|
|
||||||
[self.scrollView setZoomScale:self.scrollView.minimumZoomScale animated:isAnimated];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Initializers
|
|
||||||
|
|
||||||
- (void)updateContents
|
|
||||||
{
|
|
||||||
[self.mediaView removeFromSuperview];
|
|
||||||
[self.scrollView removeFromSuperview];
|
|
||||||
[self.playVideoButton removeFromSuperview];
|
|
||||||
[self.videoProgressBar removeFromSuperview];
|
|
||||||
|
|
||||||
UIScrollView *scrollView = [UIScrollView new];
|
|
||||||
[self.view addSubview:scrollView];
|
|
||||||
self.scrollView = scrollView;
|
|
||||||
scrollView.delegate = self;
|
|
||||||
|
|
||||||
scrollView.showsVerticalScrollIndicator = NO;
|
|
||||||
scrollView.showsHorizontalScrollIndicator = NO;
|
|
||||||
scrollView.decelerationRate = UIScrollViewDecelerationRateFast;
|
|
||||||
|
|
||||||
if (@available(iOS 11.0, *)) {
|
|
||||||
[scrollView contentInsetAdjustmentBehavior];
|
|
||||||
} else {
|
|
||||||
self.automaticallyAdjustsScrollViewInsets = NO;
|
|
||||||
}
|
|
||||||
|
|
||||||
[scrollView ows_autoPinToSuperviewEdges];
|
|
||||||
|
|
||||||
if (self.isAnimated) {
|
|
||||||
if (self.attachmentStream.isValidImage) {
|
|
||||||
YYImage *animatedGif = [YYImage imageWithContentsOfFile:self.attachmentStream.originalFilePath];
|
|
||||||
YYAnimatedImageView *animatedView = [YYAnimatedImageView new];
|
|
||||||
animatedView.image = animatedGif;
|
|
||||||
self.mediaView = animatedView;
|
|
||||||
} else {
|
|
||||||
self.mediaView = [UIView new];
|
|
||||||
self.mediaView.backgroundColor = LKColors.unimportant;
|
|
||||||
}
|
|
||||||
} else if (!self.image) {
|
|
||||||
// Still loading thumbnail.
|
|
||||||
self.mediaView = [UIView new];
|
|
||||||
self.mediaView.backgroundColor = LKColors.unimportant;
|
|
||||||
} else if (self.isVideo) {
|
|
||||||
if (self.attachmentStream.isValidVideo) {
|
|
||||||
self.mediaView = [self buildVideoPlayerView];
|
|
||||||
} else {
|
|
||||||
self.mediaView = [UIView new];
|
|
||||||
self.mediaView.backgroundColor = LKColors.unimportant;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Present the static image using standard UIImageView
|
|
||||||
UIImageView *imageView = [[UIImageView alloc] initWithImage:self.image];
|
|
||||||
self.mediaView = imageView;
|
|
||||||
}
|
|
||||||
|
|
||||||
OWSAssertDebug(self.mediaView);
|
|
||||||
|
|
||||||
// We add these gestures to mediaView rather than
|
|
||||||
// the root view so that interacting with the video player
|
|
||||||
// progres bar doesn't trigger any of these gestures.
|
|
||||||
[self addGestureRecognizersToView:self.mediaView];
|
|
||||||
|
|
||||||
[scrollView addSubview:self.mediaView];
|
|
||||||
self.mediaViewLeadingConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeLeading];
|
|
||||||
self.mediaViewTopConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTop];
|
|
||||||
self.mediaViewTrailingConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTrailing];
|
|
||||||
self.mediaViewBottomConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeBottom];
|
|
||||||
|
|
||||||
self.mediaView.contentMode = UIViewContentModeScaleAspectFit;
|
|
||||||
self.mediaView.userInteractionEnabled = YES;
|
|
||||||
self.mediaView.clipsToBounds = YES;
|
|
||||||
self.mediaView.layer.allowsEdgeAntialiasing = YES;
|
|
||||||
self.mediaView.translatesAutoresizingMaskIntoConstraints = NO;
|
|
||||||
|
|
||||||
// Use trilinear filters for better scaling quality at
|
|
||||||
// some performance cost.
|
|
||||||
self.mediaView.layer.minificationFilter = kCAFilterTrilinear;
|
|
||||||
self.mediaView.layer.magnificationFilter = kCAFilterTrilinear;
|
|
||||||
|
|
||||||
if (self.isVideo) {
|
|
||||||
PlayerProgressBar *videoProgressBar = [PlayerProgressBar new];
|
|
||||||
videoProgressBar.delegate = self;
|
|
||||||
videoProgressBar.player = self.videoPlayer.avPlayer;
|
|
||||||
|
|
||||||
// We hide the progress bar until either:
|
|
||||||
// 1. Video completes playing
|
|
||||||
// 2. User taps the screen
|
|
||||||
videoProgressBar.hidden = YES;
|
|
||||||
|
|
||||||
self.videoProgressBar = videoProgressBar;
|
|
||||||
[self.view addSubview:videoProgressBar];
|
|
||||||
[videoProgressBar autoPinWidthToSuperview];
|
|
||||||
[videoProgressBar autoPinEdgeToSuperviewSafeArea:ALEdgeTop];
|
|
||||||
CGFloat kVideoProgressBarHeight = 44;
|
|
||||||
[videoProgressBar autoSetDimension:ALDimensionHeight toSize:kVideoProgressBarHeight];
|
|
||||||
|
|
||||||
UIButton *playVideoButton = [UIButton new];
|
|
||||||
self.playVideoButton = playVideoButton;
|
|
||||||
|
|
||||||
[playVideoButton addTarget:self action:@selector(playVideo) forControlEvents:UIControlEventTouchUpInside];
|
|
||||||
|
|
||||||
UIImage *playImage = [UIImage imageNamed:@"CirclePlay"];
|
|
||||||
[playVideoButton setBackgroundImage:playImage forState:UIControlStateNormal];
|
|
||||||
playVideoButton.contentMode = UIViewContentModeScaleAspectFill;
|
|
||||||
|
|
||||||
[self.view addSubview:playVideoButton];
|
|
||||||
|
|
||||||
CGFloat playVideoButtonWidth = 72.f;
|
|
||||||
[playVideoButton autoSetDimensionsToSize:CGSizeMake(playVideoButtonWidth, playVideoButtonWidth)];
|
|
||||||
[playVideoButton autoCenterInSuperview];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (UIView *)buildVideoPlayerView
|
|
||||||
{
|
|
||||||
NSURL *_Nullable attachmentUrl = self.attachmentStream.originalMediaURL;
|
|
||||||
|
|
||||||
NSFileManager *fileManager = [NSFileManager defaultManager];
|
|
||||||
if (![fileManager fileExistsAtPath:[attachmentUrl path]]) {
|
|
||||||
OWSFailDebug(@"Missing video file");
|
|
||||||
}
|
|
||||||
|
|
||||||
OWSVideoPlayer *player = [[OWSVideoPlayer alloc] initWithUrl:attachmentUrl];
|
|
||||||
[player seekToTime:kCMTimeZero];
|
|
||||||
player.delegate = self;
|
|
||||||
self.videoPlayer = player;
|
|
||||||
|
|
||||||
VideoPlayerView *playerView = [VideoPlayerView new];
|
|
||||||
playerView.player = player.avPlayer;
|
|
||||||
|
|
||||||
[NSLayoutConstraint autoSetPriority:UILayoutPriorityDefaultLow
|
|
||||||
forConstraints:^{
|
|
||||||
[playerView autoSetDimensionsToSize:self.image.size];
|
|
||||||
}];
|
|
||||||
|
|
||||||
return playerView;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)setShouldHideToolbars:(BOOL)shouldHideToolbars
|
|
||||||
{
|
|
||||||
self.videoProgressBar.hidden = shouldHideToolbars;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)addGestureRecognizersToView:(UIView *)view
|
|
||||||
{
|
|
||||||
UITapGestureRecognizer *doubleTap =
|
|
||||||
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didDoubleTapImage:)];
|
|
||||||
doubleTap.numberOfTapsRequired = 2;
|
|
||||||
[view addGestureRecognizer:doubleTap];
|
|
||||||
|
|
||||||
UITapGestureRecognizer *singleTap =
|
|
||||||
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didSingleTapImage:)];
|
|
||||||
[singleTap requireGestureRecognizerToFail:doubleTap];
|
|
||||||
[view addGestureRecognizer:singleTap];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Gesture Recognizers
|
|
||||||
|
|
||||||
- (void)didSingleTapImage:(UITapGestureRecognizer *)gesture
|
|
||||||
{
|
|
||||||
[self.delegate mediaDetailViewControllerDidTapMedia:self];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)didDoubleTapImage:(UITapGestureRecognizer *)gesture
|
|
||||||
{
|
|
||||||
OWSLogVerbose(@"did double tap image.");
|
|
||||||
if (self.scrollView.zoomScale == self.scrollView.minimumZoomScale) {
|
|
||||||
CGFloat kDoubleTapZoomScale = 2;
|
|
||||||
|
|
||||||
CGFloat zoomWidth = self.scrollView.width / kDoubleTapZoomScale;
|
|
||||||
CGFloat zoomHeight = self.scrollView.height / kDoubleTapZoomScale;
|
|
||||||
|
|
||||||
// center zoom rect around tapLocation
|
|
||||||
CGPoint tapLocation = [gesture locationInView:self.scrollView];
|
|
||||||
CGFloat zoomX = MAX(0, tapLocation.x - zoomWidth / 2);
|
|
||||||
CGFloat zoomY = MAX(0, tapLocation.y - zoomHeight / 2);
|
|
||||||
|
|
||||||
CGRect zoomRect = CGRectMake(zoomX, zoomY, zoomWidth, zoomHeight);
|
|
||||||
|
|
||||||
CGRect translatedRect = [self.mediaView convertRect:zoomRect fromView:self.scrollView];
|
|
||||||
|
|
||||||
[self.scrollView zoomToRect:translatedRect animated:YES];
|
|
||||||
} else {
|
|
||||||
// If already zoomed in at all, zoom out all the way.
|
|
||||||
[self zoomOutAnimated:YES];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)didPressPlayBarButton:(id)sender
|
|
||||||
{
|
|
||||||
OWSAssertDebug(self.isVideo);
|
|
||||||
OWSAssertDebug(self.videoPlayer);
|
|
||||||
[self playVideo];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)didPressPauseBarButton:(id)sender
|
|
||||||
{
|
|
||||||
OWSAssertDebug(self.isVideo);
|
|
||||||
OWSAssertDebug(self.videoPlayer);
|
|
||||||
[self pauseVideo];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - UIScrollViewDelegate
|
|
||||||
|
|
||||||
- (nullable UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
|
|
||||||
{
|
|
||||||
return self.mediaView;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)centerMediaViewConstraints
|
|
||||||
{
|
|
||||||
OWSAssertDebug(self.scrollView);
|
|
||||||
|
|
||||||
CGSize scrollViewSize = self.scrollView.bounds.size;
|
|
||||||
CGSize imageViewSize = self.mediaView.frame.size;
|
|
||||||
|
|
||||||
CGFloat yOffset = MAX(0, (scrollViewSize.height - imageViewSize.height) / 2);
|
|
||||||
self.mediaViewTopConstraint.constant = yOffset;
|
|
||||||
self.mediaViewBottomConstraint.constant = yOffset;
|
|
||||||
|
|
||||||
CGFloat xOffset = MAX(0, (scrollViewSize.width - imageViewSize.width) / 2);
|
|
||||||
self.mediaViewLeadingConstraint.constant = xOffset;
|
|
||||||
self.mediaViewTrailingConstraint.constant = xOffset;
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)scrollViewDidZoom:(UIScrollView *)scrollView
|
|
||||||
{
|
|
||||||
[self centerMediaViewConstraints];
|
|
||||||
[self.view layoutIfNeeded];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)resetMediaFrame
|
|
||||||
{
|
|
||||||
// HACK: Setting the frame to itself *seems* like it should be a no-op, but
|
|
||||||
// it ensures the content is drawn at the right frame. In particular I was
|
|
||||||
// reproducibly seeing some images squished (they were EXIF rotated, maybe
|
|
||||||
// related). similar to this report:
|
|
||||||
// https://stackoverflow.com/questions/27961884/swift-uiimageview-stretched-aspect
|
|
||||||
[self.view layoutIfNeeded];
|
|
||||||
self.mediaView.frame = self.mediaView.frame;
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Video Playback
|
|
||||||
|
|
||||||
- (void)playVideo
|
|
||||||
{
|
|
||||||
OWSAssertDebug(self.videoPlayer);
|
|
||||||
|
|
||||||
self.playVideoButton.hidden = YES;
|
|
||||||
|
|
||||||
[self.videoPlayer play];
|
|
||||||
|
|
||||||
[self.delegate mediaDetailViewController:self isPlayingVideo:YES];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)pauseVideo
|
|
||||||
{
|
|
||||||
OWSAssertDebug(self.isVideo);
|
|
||||||
OWSAssertDebug(self.videoPlayer);
|
|
||||||
|
|
||||||
[self.videoPlayer pause];
|
|
||||||
|
|
||||||
[self.delegate mediaDetailViewController:self isPlayingVideo:NO];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)stopAnyVideo
|
|
||||||
{
|
|
||||||
if (self.isVideo) {
|
|
||||||
[self stopVideo];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)stopVideo
|
|
||||||
{
|
|
||||||
OWSAssertDebug(self.isVideo);
|
|
||||||
OWSAssertDebug(self.videoPlayer);
|
|
||||||
|
|
||||||
[self.videoPlayer stop];
|
|
||||||
|
|
||||||
self.playVideoButton.hidden = NO;
|
|
||||||
|
|
||||||
[self.delegate mediaDetailViewController:self isPlayingVideo:NO];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - OWSVideoPlayer
|
|
||||||
|
|
||||||
- (void)videoPlayerDidPlayToCompletion:(OWSVideoPlayer *)videoPlayer
|
|
||||||
{
|
|
||||||
OWSAssertDebug(self.isVideo);
|
|
||||||
OWSAssertDebug(self.videoPlayer);
|
|
||||||
OWSLogVerbose(@"");
|
|
||||||
|
|
||||||
[self stopVideo];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - PlayerProgressBarDelegate
|
|
||||||
|
|
||||||
- (void)playerProgressBarDidStartScrubbing:(PlayerProgressBar *)playerProgressBar
|
|
||||||
{
|
|
||||||
OWSAssertDebug(self.videoPlayer);
|
|
||||||
[self.videoPlayer pause];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)playerProgressBar:(PlayerProgressBar *)playerProgressBar scrubbedToTime:(CMTime)time
|
|
||||||
{
|
|
||||||
OWSAssertDebug(self.videoPlayer);
|
|
||||||
[self.videoPlayer seekToTime:time];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)playerProgressBar:(PlayerProgressBar *)playerProgressBar
|
|
||||||
didFinishScrubbingAtTime:(CMTime)time
|
|
||||||
shouldResumePlayback:(BOOL)shouldResumePlayback
|
|
||||||
{
|
|
||||||
OWSAssertDebug(self.videoPlayer);
|
|
||||||
[self.videoPlayer seekToTime:time];
|
|
||||||
|
|
||||||
if (shouldResumePlayback) {
|
|
||||||
[self.videoPlayer play];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - Saving images to Camera Roll
|
|
||||||
|
|
||||||
- (void)image:(UIImage *)image didFinishSavingWithError:(NSError *)error contextInfo:(void *)contextInfo
|
|
||||||
{
|
|
||||||
if (error) {
|
|
||||||
OWSLogWarn(@"There was a problem saving <%@> to camera roll.", error.localizedDescription);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@end
|
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
|
|
@ -0,0 +1,444 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import YYImage
|
||||||
|
import SessionUIKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
|
public enum MediaGalleryOption {
|
||||||
|
case sliderEnabled
|
||||||
|
case showAllMediaButton
|
||||||
|
}
|
||||||
|
|
||||||
|
class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVideoPlayerDelegate, PlayerProgressBarDelegate {
|
||||||
|
public let galleryItem: MediaGalleryViewModel.Item
|
||||||
|
public weak var delegate: MediaDetailViewControllerDelegate?
|
||||||
|
private var image: UIImage?
|
||||||
|
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
|
private var mediaViewBottomConstraint: NSLayoutConstraint?
|
||||||
|
private var mediaViewLeadingConstraint: NSLayoutConstraint?
|
||||||
|
private var mediaViewTopConstraint: NSLayoutConstraint?
|
||||||
|
private var mediaViewTrailingConstraint: NSLayoutConstraint?
|
||||||
|
|
||||||
|
private lazy var scrollView: UIScrollView = {
|
||||||
|
let result: UIScrollView = UIScrollView()
|
||||||
|
result.showsVerticalScrollIndicator = false
|
||||||
|
result.showsHorizontalScrollIndicator = false
|
||||||
|
result.contentInsetAdjustmentBehavior = .never
|
||||||
|
result.decelerationRate = .fast
|
||||||
|
result.delegate = self
|
||||||
|
|
||||||
|
return result
|
||||||
|
}()
|
||||||
|
|
||||||
|
public var mediaView: UIView = UIView()
|
||||||
|
private var playVideoButton: UIButton = UIButton()
|
||||||
|
private var videoProgressBar: PlayerProgressBar = PlayerProgressBar()
|
||||||
|
private var videoPlayer: OWSVideoPlayer?
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(
|
||||||
|
galleryItem: MediaGalleryViewModel.Item,
|
||||||
|
delegate: MediaDetailViewControllerDelegate? = nil
|
||||||
|
) {
|
||||||
|
self.galleryItem = galleryItem
|
||||||
|
self.delegate = delegate
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
// We cache the image data in case the attachment stream is deleted.
|
||||||
|
galleryItem.attachment.thumbnail(
|
||||||
|
size: .large,
|
||||||
|
success: { [weak self] image, _ in
|
||||||
|
// Only reload the content if the view has already loaded (if it
|
||||||
|
// hasn't then it'll load with the image immediately)
|
||||||
|
let updateUICallback = {
|
||||||
|
self?.image = image
|
||||||
|
|
||||||
|
if self?.isViewLoaded == true {
|
||||||
|
self?.updateContents()
|
||||||
|
self?.updateMinZoomScale()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard Thread.isMainThread else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
updateUICallback()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUICallback()
|
||||||
|
},
|
||||||
|
failure: {
|
||||||
|
SNLog("Could not load media.")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
self.stopAnyVideo()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
self.view.backgroundColor = Colors.navigationBarBackground
|
||||||
|
|
||||||
|
self.view.addSubview(scrollView)
|
||||||
|
scrollView.pin(to: self.view)
|
||||||
|
|
||||||
|
self.updateContents()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
self.resetMediaFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
|
||||||
|
self.updateMinZoomScale()
|
||||||
|
self.centerMediaViewConstraints()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Functions
|
||||||
|
|
||||||
|
private func updateMinZoomScale() {
|
||||||
|
let maybeImageSize: CGSize? = {
|
||||||
|
switch self.mediaView {
|
||||||
|
case let imageView as UIImageView: return (imageView.image?.size ?? .zero)
|
||||||
|
case let imageView as YYAnimatedImageView: return (imageView.image?.size ?? .zero)
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
guard let imageSize: CGSize = maybeImageSize else {
|
||||||
|
self.scrollView.minimumZoomScale = 1
|
||||||
|
self.scrollView.maximumZoomScale = 1
|
||||||
|
self.scrollView.zoomScale = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let viewSize: CGSize = self.scrollView.bounds.size
|
||||||
|
|
||||||
|
guard imageSize.width > 0 && imageSize.height > 0 else {
|
||||||
|
SNLog("Invalid image dimensions (\(imageSize.width), \(imageSize.height))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let scaleWidth: CGFloat = (viewSize.width / imageSize.width)
|
||||||
|
let scaleHeight: CGFloat = (viewSize.height / imageSize.height)
|
||||||
|
let minScale: CGFloat = min(scaleWidth, scaleHeight)
|
||||||
|
|
||||||
|
if minScale != self.scrollView.minimumZoomScale {
|
||||||
|
self.scrollView.minimumZoomScale = minScale
|
||||||
|
self.scrollView.maximumZoomScale = (minScale * 8)
|
||||||
|
self.scrollView.zoomScale = minScale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func zoomOut(animated: Bool) {
|
||||||
|
if self.scrollView.zoomScale != self.scrollView.minimumZoomScale {
|
||||||
|
self.scrollView.setZoomScale(self.scrollView.minimumZoomScale, animated: animated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Content
|
||||||
|
|
||||||
|
private func updateContents() {
|
||||||
|
self.mediaView.removeFromSuperview()
|
||||||
|
self.playVideoButton.removeFromSuperview()
|
||||||
|
self.videoProgressBar.removeFromSuperview()
|
||||||
|
self.scrollView.zoomScale = 1
|
||||||
|
|
||||||
|
if self.galleryItem.attachment.isAnimated {
|
||||||
|
if self.galleryItem.attachment.isValid, let originalFilePath: String = self.galleryItem.attachment.originalFilePath {
|
||||||
|
let animatedView: YYAnimatedImageView = YYAnimatedImageView()
|
||||||
|
animatedView.autoPlayAnimatedImage = false
|
||||||
|
animatedView.image = YYImage(contentsOfFile: originalFilePath)
|
||||||
|
self.mediaView = animatedView
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
self.mediaView = UIView()
|
||||||
|
self.mediaView.backgroundColor = Colors.unimportant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if self.image == nil {
|
||||||
|
// Still loading thumbnail.
|
||||||
|
self.mediaView = UIView()
|
||||||
|
self.mediaView.backgroundColor = Colors.unimportant
|
||||||
|
}
|
||||||
|
else if self.galleryItem.attachment.isVideo {
|
||||||
|
if self.galleryItem.attachment.isValid {
|
||||||
|
self.mediaView = self.buildVideoPlayerView()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
self.mediaView = UIView()
|
||||||
|
self.mediaView.backgroundColor = Colors.unimportant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Present the static image using standard UIImageView
|
||||||
|
self.mediaView = UIImageView(image: self.image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We add these gestures to mediaView rather than
|
||||||
|
// the root view so that interacting with the video player
|
||||||
|
// progres bar doesn't trigger any of these gestures.
|
||||||
|
self.addGestureRecognizers(to: self.mediaView)
|
||||||
|
self.scrollView.addSubview(self.mediaView)
|
||||||
|
|
||||||
|
self.mediaViewLeadingConstraint = self.mediaView.pin(.leading, to: .leading, of: self.scrollView)
|
||||||
|
self.mediaViewTopConstraint = self.mediaView.pin(.top, to: .top, of: self.scrollView)
|
||||||
|
self.mediaViewTrailingConstraint = self.mediaView.pin(.trailing, to: .trailing, of: self.scrollView)
|
||||||
|
self.mediaViewBottomConstraint = self.mediaView.pin(.bottom, to: .bottom, of: self.scrollView)
|
||||||
|
|
||||||
|
self.mediaView.contentMode = .scaleAspectFit
|
||||||
|
self.mediaView.isUserInteractionEnabled = true
|
||||||
|
self.mediaView.clipsToBounds = true
|
||||||
|
self.mediaView.layer.allowsEdgeAntialiasing = true
|
||||||
|
self.mediaView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
// Use trilinear filters for better scaling quality at
|
||||||
|
// 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) {
|
||||||
|
let doubleTap: UITapGestureRecognizer = UITapGestureRecognizer(
|
||||||
|
target: self,
|
||||||
|
action: #selector(didDoubleTapImage(_:))
|
||||||
|
)
|
||||||
|
doubleTap.numberOfTapsRequired = 2
|
||||||
|
view.addGestureRecognizer(doubleTap)
|
||||||
|
|
||||||
|
let singleTap: UITapGestureRecognizer = UITapGestureRecognizer(
|
||||||
|
target: self,
|
||||||
|
action: #selector(didSingleTapImage(_:))
|
||||||
|
)
|
||||||
|
singleTap.require(toFail: doubleTap)
|
||||||
|
view.addGestureRecognizer(singleTap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gesture Recognizers
|
||||||
|
|
||||||
|
@objc private func didSingleTapImage(_ gesture: UITapGestureRecognizer) {
|
||||||
|
self.delegate?.mediaDetailViewControllerDidTapMedia(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func didDoubleTapImage(_ gesture: UITapGestureRecognizer) {
|
||||||
|
guard self.scrollView.zoomScale == self.scrollView.minimumZoomScale else {
|
||||||
|
// If already zoomed in at all, zoom out all the way.
|
||||||
|
self.zoomOut(animated: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let doubleTapZoomScale: CGFloat = 2
|
||||||
|
let zoomWidth: CGFloat = (self.scrollView.bounds.width / doubleTapZoomScale)
|
||||||
|
let zoomHeight: CGFloat = (self.scrollView.bounds.height / doubleTapZoomScale)
|
||||||
|
|
||||||
|
// Center zoom rect around tapLocation
|
||||||
|
let tapLocation: CGPoint = gesture.location(in: self.scrollView)
|
||||||
|
let zoomX: CGFloat = max(0, tapLocation.x - zoomWidth / 2)
|
||||||
|
let zoomY: CGFloat = max(0, tapLocation.y - zoomHeight / 2)
|
||||||
|
let zoomRect: CGRect = CGRect(x: zoomX, y: zoomY, width: zoomWidth, height: zoomHeight)
|
||||||
|
let translatedRect: CGRect = self.mediaView.convert(zoomRect, to: self.scrollView)
|
||||||
|
|
||||||
|
self.scrollView.zoom(to: translatedRect, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc public func didPressPlayBarButton() {
|
||||||
|
self.playVideo()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc public func didPressPauseBarButton() {
|
||||||
|
self.pauseVideo()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UIScrollViewDelegate
|
||||||
|
|
||||||
|
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||||
|
return self.mediaView
|
||||||
|
}
|
||||||
|
|
||||||
|
private func centerMediaViewConstraints() {
|
||||||
|
let scrollViewSize: CGSize = self.scrollView.bounds.size
|
||||||
|
let imageViewSize: CGSize = self.mediaView.frame.size
|
||||||
|
|
||||||
|
// We want to modify the yOffset so the content remains centered on the screen (we can do this
|
||||||
|
// by subtracting half the parentViewController's y position)
|
||||||
|
//
|
||||||
|
// Note: Due to weird partial-pixel value rendering behaviours we need to round the inset either
|
||||||
|
// up or down depending on which direction the partial-pixel would end up rounded to make it
|
||||||
|
// align correctly
|
||||||
|
let halfHeightDiff: CGFloat = ((self.scrollView.bounds.size.height - self.mediaView.frame.size.height) / 2)
|
||||||
|
let shouldRoundUp: Bool = (round(halfHeightDiff) - halfHeightDiff > 0)
|
||||||
|
|
||||||
|
let yOffset: CGFloat = (
|
||||||
|
round((scrollViewSize.height - imageViewSize.height) / 2) -
|
||||||
|
(shouldRoundUp ?
|
||||||
|
ceil((self.parent?.view.frame.origin.y ?? 0) / 2) :
|
||||||
|
floor((self.parent?.view.frame.origin.y ?? 0) / 2)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.mediaViewTopConstraint?.constant = yOffset
|
||||||
|
self.mediaViewBottomConstraint?.constant = yOffset
|
||||||
|
|
||||||
|
let xOffset: CGFloat = max(0, (scrollViewSize.width - imageViewSize.width) / 2)
|
||||||
|
self.mediaViewLeadingConstraint?.constant = xOffset
|
||||||
|
self.mediaViewTrailingConstraint?.constant = xOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||||
|
self.centerMediaViewConstraints()
|
||||||
|
self.view.layoutIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetMediaFrame() {
|
||||||
|
// HACK: Setting the frame to itself *seems* like it should be a no-op, but
|
||||||
|
// it ensures the content is drawn at the right frame. In particular I was
|
||||||
|
// reproducibly seeing some images squished (they were EXIF rotated, maybe
|
||||||
|
// related). similar to this report:
|
||||||
|
// https://stackoverflow.com/questions/27961884/swift-uiimageview-stretched-aspect
|
||||||
|
self.view.layoutIfNeeded()
|
||||||
|
self.mediaView.frame = self.mediaView.frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MediaDetailViewControllerDelegate
|
||||||
|
|
||||||
|
protocol MediaDetailViewControllerDelegate: AnyObject {
|
||||||
|
func mediaDetailViewController(_ mediaDetailViewController: MediaDetailViewController, isPlayingVideo: Bool)
|
||||||
|
func mediaDetailViewControllerDidTapMedia(_ mediaDetailViewController: MediaDetailViewController)
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
import SessionUIKit
|
||||||
|
|
||||||
|
class MediaGalleryNavigationController: OWSNavigationController {
|
||||||
|
// HACK: Though we don't have an input accessory view, the VC we are presented above (ConversationVC) does.
|
||||||
|
// If the app is backgrounded and then foregrounded, when OWSWindowManager calls mainWindow.makeKeyAndVisible
|
||||||
|
// the ConversationVC's inputAccessoryView will appear *above* us unless we'd previously become first responder.
|
||||||
|
override public var canBecomeFirstResponder: Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
|
private lazy var backgroundView: UIView = {
|
||||||
|
let result: UIView = UIView()
|
||||||
|
result.backgroundColor = Colors.navigationBarBackground
|
||||||
|
|
||||||
|
return result
|
||||||
|
}()
|
||||||
|
|
||||||
|
// MARK: - View Lifecycle
|
||||||
|
|
||||||
|
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||||
|
return (isLightMode ? .default : .lightContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
guard let navigationBar = self.navigationBar as? OWSNavigationBar else {
|
||||||
|
owsFailDebug("navigationBar had unexpected class: \(self.navigationBar)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
view.backgroundColor = Colors.navigationBarBackground
|
||||||
|
|
||||||
|
navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default)
|
||||||
|
navigationBar.shadowImage = UIImage()
|
||||||
|
navigationBar.isTranslucent = false
|
||||||
|
navigationBar.barTintColor = Colors.navigationBarBackground
|
||||||
|
|
||||||
|
// Insert a view to ensure the nav bar colour goes to the top of the screen
|
||||||
|
relayoutBackgroundView()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
|
||||||
|
// If the user's device is already rotated, try to respect that by rotating to landscape now
|
||||||
|
UIViewController.attemptRotationToDeviceOrientation()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Orientation
|
||||||
|
|
||||||
|
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||||
|
return .allButUpsideDown
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Functions
|
||||||
|
|
||||||
|
private func relayoutBackgroundView() {
|
||||||
|
guard !backgroundView.isHidden else {
|
||||||
|
backgroundView.removeFromSuperview()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
view.insertSubview(backgroundView, belowSubview: navigationBar)
|
||||||
|
|
||||||
|
backgroundView.pin(.top, to: .top, of: view)
|
||||||
|
backgroundView.pin(.left, to: .left, of: navigationBar)
|
||||||
|
backgroundView.pin(.right, to: .right, of: navigationBar)
|
||||||
|
backgroundView.pin(.bottom, to: .bottom, of: navigationBar)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setNavigationBarHidden(_ hidden: Bool, animated: Bool) {
|
||||||
|
super.setNavigationBarHidden(hidden, animated: animated)
|
||||||
|
|
||||||
|
backgroundView.isHidden = hidden
|
||||||
|
relayoutBackgroundView()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,903 +0,0 @@
|
||||||
//
|
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public enum GalleryDirection {
|
|
||||||
case before, after, around
|
|
||||||
}
|
|
||||||
|
|
||||||
class MediaGalleryAlbum {
|
|
||||||
|
|
||||||
private var originalItems: [MediaGalleryItem]
|
|
||||||
var items: [MediaGalleryItem] {
|
|
||||||
get {
|
|
||||||
guard let mediaGalleryDataSource = self.mediaGalleryDataSource else {
|
|
||||||
owsFailDebug("mediaGalleryDataSource was unexpectedly nil")
|
|
||||||
return originalItems
|
|
||||||
}
|
|
||||||
|
|
||||||
return originalItems.filter { !mediaGalleryDataSource.deletedGalleryItems.contains($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
weak var mediaGalleryDataSource: MediaGalleryDataSource?
|
|
||||||
|
|
||||||
init(items: [MediaGalleryItem]) {
|
|
||||||
self.originalItems = items
|
|
||||||
}
|
|
||||||
|
|
||||||
func add(item: MediaGalleryItem) {
|
|
||||||
guard !originalItems.contains(item) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
originalItems.append(item)
|
|
||||||
originalItems.sort { (lhs, rhs) -> Bool in
|
|
||||||
return lhs.albumIndex < rhs.albumIndex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class MediaGalleryItem: Equatable, Hashable {
|
|
||||||
let message: TSMessage
|
|
||||||
let attachmentStream: TSAttachmentStream
|
|
||||||
let galleryDate: GalleryDate
|
|
||||||
let captionForDisplay: String?
|
|
||||||
let albumIndex: Int
|
|
||||||
var album: MediaGalleryAlbum?
|
|
||||||
let orderingKey: MediaGalleryItemOrderingKey
|
|
||||||
|
|
||||||
init(message: TSMessage, attachmentStream: TSAttachmentStream) {
|
|
||||||
self.message = message
|
|
||||||
self.attachmentStream = attachmentStream
|
|
||||||
self.captionForDisplay = attachmentStream.caption?.filterForDisplay
|
|
||||||
self.galleryDate = GalleryDate(message: message)
|
|
||||||
self.albumIndex = message.attachmentIds.index(of: attachmentStream.uniqueId!)
|
|
||||||
self.orderingKey = MediaGalleryItemOrderingKey(messageSortKey: message.sortId, attachmentSortKey: albumIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
var isVideo: Bool {
|
|
||||||
return attachmentStream.isVideo
|
|
||||||
}
|
|
||||||
|
|
||||||
var isAnimated: Bool {
|
|
||||||
return attachmentStream.isAnimated
|
|
||||||
}
|
|
||||||
|
|
||||||
var isImage: Bool {
|
|
||||||
return attachmentStream.isImage
|
|
||||||
}
|
|
||||||
|
|
||||||
var imageSize: CGSize {
|
|
||||||
return attachmentStream.imageSize()
|
|
||||||
}
|
|
||||||
|
|
||||||
public typealias AsyncThumbnailBlock = (UIImage) -> Void
|
|
||||||
func thumbnailImage(async:@escaping AsyncThumbnailBlock) -> UIImage? {
|
|
||||||
return attachmentStream.thumbnailImageSmall(success: async, failure: {})
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Equatable
|
|
||||||
|
|
||||||
public static func == (lhs: MediaGalleryItem, rhs: MediaGalleryItem) -> Bool {
|
|
||||||
return lhs.attachmentStream.uniqueId == rhs.attachmentStream.uniqueId
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Hashable
|
|
||||||
|
|
||||||
public var hashValue: Int {
|
|
||||||
return attachmentStream.uniqueId?.hashValue ?? attachmentStream.hashValue
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Sorting
|
|
||||||
|
|
||||||
struct MediaGalleryItemOrderingKey: Comparable {
|
|
||||||
let messageSortKey: UInt64
|
|
||||||
let attachmentSortKey: Int
|
|
||||||
|
|
||||||
// MARK: Comparable
|
|
||||||
|
|
||||||
static func < (lhs: MediaGalleryItem.MediaGalleryItemOrderingKey, rhs: MediaGalleryItem.MediaGalleryItemOrderingKey) -> Bool {
|
|
||||||
if lhs.messageSortKey < rhs.messageSortKey {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if lhs.messageSortKey == rhs.messageSortKey {
|
|
||||||
if lhs.attachmentSortKey < rhs.attachmentSortKey {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct GalleryDate: Hashable, Comparable, Equatable {
|
|
||||||
let year: Int
|
|
||||||
let month: Int
|
|
||||||
|
|
||||||
init(message: TSMessage) {
|
|
||||||
let date = message.dateForUI()
|
|
||||||
|
|
||||||
self.year = Calendar.current.component(.year, from: date)
|
|
||||||
self.month = Calendar.current.component(.month, from: date)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(year: Int, month: Int) {
|
|
||||||
assert(month >= 1 && month <= 12)
|
|
||||||
|
|
||||||
self.year = year
|
|
||||||
self.month = month
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isThisMonth: Bool {
|
|
||||||
let now = Date()
|
|
||||||
let year = Calendar.current.component(.year, from: now)
|
|
||||||
let month = Calendar.current.component(.month, from: now)
|
|
||||||
let thisMonth = GalleryDate(year: year, month: month)
|
|
||||||
|
|
||||||
return self == thisMonth
|
|
||||||
}
|
|
||||||
|
|
||||||
public var date: Date {
|
|
||||||
var components = DateComponents()
|
|
||||||
components.month = self.month
|
|
||||||
components.year = self.year
|
|
||||||
|
|
||||||
return Calendar.current.date(from: components)!
|
|
||||||
}
|
|
||||||
|
|
||||||
private var isThisYear: Bool {
|
|
||||||
let now = Date()
|
|
||||||
let thisYear = Calendar.current.component(.year, from: now)
|
|
||||||
|
|
||||||
return self.year == thisYear
|
|
||||||
}
|
|
||||||
|
|
||||||
static let thisYearFormatter: DateFormatter = {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "MMMM"
|
|
||||||
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let olderFormatter: DateFormatter = {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
|
|
||||||
// FIXME localize for RTL, or is there a built in way to do this?
|
|
||||||
formatter.dateFormat = "MMMM yyyy"
|
|
||||||
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
var localizedString: String {
|
|
||||||
if isThisMonth {
|
|
||||||
return NSLocalizedString("MEDIA_GALLERY_THIS_MONTH_HEADER", comment: "Section header in media gallery collection view")
|
|
||||||
} else if isThisYear {
|
|
||||||
return type(of: self).thisYearFormatter.string(from: self.date)
|
|
||||||
} else {
|
|
||||||
return type(of: self).olderFormatter.string(from: self.date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Hashable
|
|
||||||
|
|
||||||
public var hashValue: Int {
|
|
||||||
return month.hashValue ^ year.hashValue
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Comparable
|
|
||||||
|
|
||||||
public static func < (lhs: GalleryDate, rhs: GalleryDate) -> Bool {
|
|
||||||
if lhs.year != rhs.year {
|
|
||||||
return lhs.year < rhs.year
|
|
||||||
} else if lhs.month != rhs.month {
|
|
||||||
return lhs.month < rhs.month
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Equatable
|
|
||||||
|
|
||||||
public static func == (lhs: GalleryDate, rhs: GalleryDate) -> Bool {
|
|
||||||
return lhs.month == rhs.month && lhs.year == rhs.year
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol MediaGalleryDataSource: class {
|
|
||||||
var hasFetchedOldest: Bool { get }
|
|
||||||
var hasFetchedMostRecent: Bool { get }
|
|
||||||
|
|
||||||
var galleryItems: [MediaGalleryItem] { get }
|
|
||||||
var galleryItemCount: Int { get }
|
|
||||||
|
|
||||||
var sections: [GalleryDate: [MediaGalleryItem]] { get }
|
|
||||||
var sectionDates: [GalleryDate] { get }
|
|
||||||
|
|
||||||
var deletedAttachments: Set<TSAttachment> { get }
|
|
||||||
var deletedGalleryItems: Set<MediaGalleryItem> { get }
|
|
||||||
|
|
||||||
func ensureGalleryItemsLoaded(_ direction: GalleryDirection, item: MediaGalleryItem, amount: UInt, completion: ((IndexSet, [IndexPath]) -> Void)?)
|
|
||||||
|
|
||||||
func galleryItem(before currentItem: MediaGalleryItem) -> MediaGalleryItem?
|
|
||||||
func galleryItem(after currentItem: MediaGalleryItem) -> MediaGalleryItem?
|
|
||||||
|
|
||||||
func showAllMedia(focusedItem: MediaGalleryItem)
|
|
||||||
func dismissMediaDetailViewController(_ mediaDetailViewController: MediaPageViewController, animated isAnimated: Bool, completion: (() -> Void)?)
|
|
||||||
|
|
||||||
func delete(items: [MediaGalleryItem], initiatedBy: AnyObject)
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol MediaGalleryDataSourceDelegate: class {
|
|
||||||
func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject)
|
|
||||||
func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, deletedSections: IndexSet, deletedItems: [IndexPath])
|
|
||||||
}
|
|
||||||
|
|
||||||
class MediaGalleryNavigationController: OWSNavigationController {
|
|
||||||
|
|
||||||
var retainUntilDismissed: MediaGallery?
|
|
||||||
|
|
||||||
// HACK: Though we don't have an input accessory view, the VC we are presented above (ConversationVC) does.
|
|
||||||
// If the app is backgrounded and then foregrounded, when OWSWindowManager calls mainWindow.makeKeyAndVisible
|
|
||||||
// the ConversationVC's inputAccessoryView will appear *above* us unless we'd previously become first responder.
|
|
||||||
override public var canBecomeFirstResponder: Bool {
|
|
||||||
Logger.debug("")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: View Lifecycle
|
|
||||||
|
|
||||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
|
||||||
return isLightMode ? .default : .lightContent
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
guard let navigationBar = self.navigationBar as? OWSNavigationBar else {
|
|
||||||
owsFailDebug("navigationBar had unexpected class: \(self.navigationBar)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
view.backgroundColor = Colors.navigationBarBackground
|
|
||||||
|
|
||||||
navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default)
|
|
||||||
navigationBar.shadowImage = UIImage()
|
|
||||||
navigationBar.isTranslucent = false
|
|
||||||
navigationBar.barTintColor = Colors.navigationBarBackground
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
|
||||||
super.viewDidAppear(animated)
|
|
||||||
// If the user's device is already rotated, try to respect that by rotating to landscape now
|
|
||||||
UIViewController.attemptRotationToDeviceOrientation()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Orientation
|
|
||||||
|
|
||||||
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
|
||||||
return .allButUpsideDown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
class MediaGallery: NSObject, MediaGalleryDataSource, MediaTileViewControllerDelegate {
|
|
||||||
|
|
||||||
@objc
|
|
||||||
weak public var navigationController: MediaGalleryNavigationController!
|
|
||||||
|
|
||||||
var deletedAttachments: Set<TSAttachment> = Set()
|
|
||||||
var deletedGalleryItems: Set<MediaGalleryItem> = Set()
|
|
||||||
|
|
||||||
private var pageViewController: MediaPageViewController?
|
|
||||||
|
|
||||||
private var uiDatabaseConnection: YapDatabaseConnection {
|
|
||||||
return OWSPrimaryStorage.shared().uiDatabaseConnection
|
|
||||||
}
|
|
||||||
|
|
||||||
private let editingDatabaseConnection: YapDatabaseConnection
|
|
||||||
private let mediaGalleryFinder: OWSMediaGalleryFinder
|
|
||||||
|
|
||||||
private var initialDetailItem: MediaGalleryItem?
|
|
||||||
private let thread: TSThread
|
|
||||||
private let options: MediaGalleryOption
|
|
||||||
|
|
||||||
// we start with a small range size for quick loading.
|
|
||||||
private let fetchRangeSize: UInt = 10
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
Logger.debug("")
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
init(thread: TSThread, options: MediaGalleryOption = []) {
|
|
||||||
self.thread = thread
|
|
||||||
|
|
||||||
self.editingDatabaseConnection = OWSPrimaryStorage.shared().newDatabaseConnection()
|
|
||||||
|
|
||||||
self.options = options
|
|
||||||
self.mediaGalleryFinder = OWSMediaGalleryFinder(thread: thread)
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self,
|
|
||||||
selector: #selector(uiDatabaseDidUpdate),
|
|
||||||
name: .OWSUIDatabaseConnectionDidUpdate,
|
|
||||||
object: OWSPrimaryStorage.shared().dbNotificationObject)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Present/Dismiss
|
|
||||||
|
|
||||||
private var currentItem: MediaGalleryItem {
|
|
||||||
return self.pageViewController!.currentItem
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
public func presentDetailView(fromViewController: UIViewController, mediaAttachment: TSAttachment) {
|
|
||||||
var galleryItem: MediaGalleryItem?
|
|
||||||
uiDatabaseConnection.read { transaction in
|
|
||||||
galleryItem = self.buildGalleryItem(attachment: mediaAttachment, transaction: transaction)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let initialDetailItem = galleryItem else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
presentDetailView(fromViewController: fromViewController, initialDetailItem: initialDetailItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func presentDetailView(fromViewController: UIViewController, initialDetailItem: MediaGalleryItem) {
|
|
||||||
// For a speedy load, we only fetch a few items on either side of
|
|
||||||
// the initial message
|
|
||||||
ensureGalleryItemsLoaded(.around, item: initialDetailItem, amount: 10)
|
|
||||||
|
|
||||||
// We lazily load media into the gallery, but with large albums, we want to be sure
|
|
||||||
// we load all the media required to render the album's media rail.
|
|
||||||
ensureAlbumEntirelyLoaded(galleryItem: initialDetailItem)
|
|
||||||
|
|
||||||
self.initialDetailItem = initialDetailItem
|
|
||||||
|
|
||||||
let pageViewController = MediaPageViewController(initialItem: initialDetailItem, mediaGalleryDataSource: self, uiDatabaseConnection: self.uiDatabaseConnection, options: self.options)
|
|
||||||
self.addDataSourceDelegate(pageViewController)
|
|
||||||
|
|
||||||
self.pageViewController = pageViewController
|
|
||||||
|
|
||||||
let navController = MediaGalleryNavigationController()
|
|
||||||
self.navigationController = navController
|
|
||||||
navController.retainUntilDismissed = self
|
|
||||||
|
|
||||||
navigationController.setViewControllers([pageViewController], animated: false)
|
|
||||||
|
|
||||||
navigationController.modalPresentationStyle = .fullScreen
|
|
||||||
navigationController.modalTransitionStyle = .crossDissolve
|
|
||||||
|
|
||||||
fromViewController.present(navigationController, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're using a navigationController other than self to present the views
|
|
||||||
// e.g. the conversation settings view controller
|
|
||||||
var fromNavController: OWSNavigationController?
|
|
||||||
|
|
||||||
@objc
|
|
||||||
func pushTileView(fromNavController: OWSNavigationController) {
|
|
||||||
var mostRecentItem: MediaGalleryItem?
|
|
||||||
self.uiDatabaseConnection.read { transaction in
|
|
||||||
if let attachment = self.mediaGalleryFinder.mostRecentMediaAttachment(transaction: transaction) {
|
|
||||||
mostRecentItem = self.buildGalleryItem(attachment: attachment, transaction: transaction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let mostRecentItem = mostRecentItem {
|
|
||||||
mediaTileViewController.focusedItem = mostRecentItem
|
|
||||||
ensureGalleryItemsLoaded(.around, item: mostRecentItem, amount: 100)
|
|
||||||
}
|
|
||||||
self.fromNavController = fromNavController
|
|
||||||
fromNavController.pushViewController(mediaTileViewController, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func showAllMedia(focusedItem: MediaGalleryItem) {
|
|
||||||
// TODO fancy animation - zoom media item into it's tile in the all media grid
|
|
||||||
ensureGalleryItemsLoaded(.around, item: focusedItem, amount: 100)
|
|
||||||
|
|
||||||
if let fromNavController = self.fromNavController {
|
|
||||||
// If from conversation settings view, we've already pushed
|
|
||||||
fromNavController.popViewController(animated: true)
|
|
||||||
} else {
|
|
||||||
// If from conversation view
|
|
||||||
mediaTileViewController.focusedItem = focusedItem
|
|
||||||
navigationController.pushViewController(mediaTileViewController, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: MediaTileViewControllerDelegate
|
|
||||||
|
|
||||||
func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryItem) {
|
|
||||||
if self.fromNavController != nil {
|
|
||||||
// If we got to the gallery via conversation settings, present the detail view
|
|
||||||
// on top of the tile view
|
|
||||||
//
|
|
||||||
// == ViewController Schematic ==
|
|
||||||
//
|
|
||||||
// [DetailView] <--,
|
|
||||||
// [TileView] -----'
|
|
||||||
// [ConversationSettingsView]
|
|
||||||
// [ConversationView]
|
|
||||||
//
|
|
||||||
|
|
||||||
self.presentDetailView(fromViewController: mediaTileViewController, initialDetailItem: mediaGalleryItem)
|
|
||||||
} else {
|
|
||||||
// If we got to the gallery via the conversation view, pop the tile view
|
|
||||||
// to return to the detail view
|
|
||||||
//
|
|
||||||
// == ViewController Schematic ==
|
|
||||||
//
|
|
||||||
// [TileView] -----,
|
|
||||||
// [DetailView] <--'
|
|
||||||
// [ConversationView]
|
|
||||||
//
|
|
||||||
|
|
||||||
guard let pageViewController = self.pageViewController else {
|
|
||||||
owsFailDebug("pageViewController was unexpectedly nil")
|
|
||||||
self.navigationController.dismiss(animated: true)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pageViewController.setCurrentItem(mediaGalleryItem, direction: .forward, animated: false)
|
|
||||||
pageViewController.willBePresentedAgain()
|
|
||||||
|
|
||||||
// TODO fancy zoom animation
|
|
||||||
self.navigationController.popViewController(animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func dismissMediaDetailViewController(_ mediaPageViewController: MediaPageViewController, animated isAnimated: Bool, completion completionParam: (() -> Void)?) {
|
|
||||||
|
|
||||||
guard let presentingViewController = self.navigationController.presentingViewController else {
|
|
||||||
owsFailDebug("presentingController was unexpectedly nil")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let completion = {
|
|
||||||
completionParam?()
|
|
||||||
UIApplication.shared.isStatusBarHidden = false
|
|
||||||
presentingViewController.setNeedsStatusBarAppearanceUpdate()
|
|
||||||
}
|
|
||||||
|
|
||||||
navigationController.view.isUserInteractionEnabled = false
|
|
||||||
|
|
||||||
presentingViewController.dismiss(animated: true, completion: completion)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Database Notifications
|
|
||||||
|
|
||||||
@objc
|
|
||||||
func uiDatabaseDidUpdate(notification: Notification) {
|
|
||||||
guard let notifications = notification.userInfo?[OWSUIDatabaseConnectionNotificationsKey] as? [Notification] else {
|
|
||||||
owsFailDebug("notifications was unexpectedly nil")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard mediaGalleryFinder.hasMediaChanges(in: notifications, dbConnection: uiDatabaseConnection) else {
|
|
||||||
Logger.verbose("no changes for thread: \(thread)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let rowChanges = extractRowChanges(notifications: notifications)
|
|
||||||
assert(rowChanges.count > 0)
|
|
||||||
|
|
||||||
process(rowChanges: rowChanges)
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractRowChanges(notifications: [Notification]) -> [YapDatabaseViewRowChange] {
|
|
||||||
return notifications.flatMap { notification -> [YapDatabaseViewRowChange] in
|
|
||||||
guard let userInfo = notification.userInfo else {
|
|
||||||
owsFailDebug("userInfo was unexpectedly nil")
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let extensionChanges = userInfo["extensions"] as? [AnyHashable: Any] else {
|
|
||||||
owsFailDebug("extensionChanges was unexpectedly nil")
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let galleryData = extensionChanges[OWSMediaGalleryFinder.databaseExtensionName()] as? [AnyHashable: Any] else {
|
|
||||||
owsFailDebug("galleryData was unexpectedly nil")
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let galleryChanges = galleryData["changes"] as? [Any] else {
|
|
||||||
owsFailDebug("gallerlyChanges was unexpectedly nil")
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
return galleryChanges.compactMap { $0 as? YapDatabaseViewRowChange }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func process(rowChanges: [YapDatabaseViewRowChange]) {
|
|
||||||
let deleteChanges = rowChanges.filter { $0.type == .delete }
|
|
||||||
|
|
||||||
let deletedItems: [MediaGalleryItem] = deleteChanges.compactMap { (deleteChange: YapDatabaseViewRowChange) -> MediaGalleryItem? in
|
|
||||||
guard let deletedItem = self.galleryItems.first(where: { galleryItem in
|
|
||||||
galleryItem.attachmentStream.uniqueId == deleteChange.collectionKey.key
|
|
||||||
}) else {
|
|
||||||
Logger.debug("deletedItem was never loaded - no need to remove.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return deletedItem
|
|
||||||
}
|
|
||||||
|
|
||||||
self.delete(items: deletedItems, initiatedBy: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - MediaGalleryDataSource
|
|
||||||
|
|
||||||
lazy var mediaTileViewController: MediaTileViewController = {
|
|
||||||
let vc = MediaTileViewController(mediaGalleryDataSource: self, uiDatabaseConnection: self.uiDatabaseConnection)
|
|
||||||
vc.delegate = self
|
|
||||||
|
|
||||||
self.addDataSourceDelegate(vc)
|
|
||||||
|
|
||||||
return vc
|
|
||||||
}()
|
|
||||||
|
|
||||||
var galleryItems: [MediaGalleryItem] = []
|
|
||||||
var sections: [GalleryDate: [MediaGalleryItem]] = [:]
|
|
||||||
var sectionDates: [GalleryDate] = []
|
|
||||||
var hasFetchedOldest = false
|
|
||||||
var hasFetchedMostRecent = false
|
|
||||||
|
|
||||||
func buildGalleryItem(attachment: TSAttachment, transaction: YapDatabaseReadTransaction) -> MediaGalleryItem? {
|
|
||||||
guard let attachmentStream = attachment as? TSAttachmentStream else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let message = attachmentStream.fetchAlbumMessage(with: transaction) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let galleryItem = MediaGalleryItem(message: message, attachmentStream: attachmentStream)
|
|
||||||
galleryItem.album = getAlbum(item: galleryItem)
|
|
||||||
|
|
||||||
return galleryItem
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureAlbumEntirelyLoaded(galleryItem: MediaGalleryItem) {
|
|
||||||
ensureGalleryItemsLoaded(.before, item: galleryItem, amount: UInt(galleryItem.albumIndex))
|
|
||||||
|
|
||||||
let followingCount = galleryItem.message.attachmentIds.count - 1 - galleryItem.albumIndex
|
|
||||||
guard followingCount >= 0 else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ensureGalleryItemsLoaded(.after, item: galleryItem, amount: UInt(followingCount))
|
|
||||||
}
|
|
||||||
|
|
||||||
var galleryAlbums: [String: MediaGalleryAlbum] = [:]
|
|
||||||
func getAlbum(item: MediaGalleryItem) -> MediaGalleryAlbum? {
|
|
||||||
guard let albumMessageId = item.attachmentStream.albumMessageId else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let existingAlbum = galleryAlbums[albumMessageId] else {
|
|
||||||
let newAlbum = MediaGalleryAlbum(items: [item])
|
|
||||||
galleryAlbums[albumMessageId] = newAlbum
|
|
||||||
newAlbum.mediaGalleryDataSource = self
|
|
||||||
return newAlbum
|
|
||||||
}
|
|
||||||
|
|
||||||
existingAlbum.add(item: item)
|
|
||||||
return existingAlbum
|
|
||||||
}
|
|
||||||
|
|
||||||
// Range instead of indexSet since it's contiguous?
|
|
||||||
var fetchedIndexSet = IndexSet() {
|
|
||||||
didSet {
|
|
||||||
Logger.debug("\(oldValue) -> \(fetchedIndexSet)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum MediaGalleryError: Error {
|
|
||||||
case itemNoLongerExists
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureGalleryItemsLoaded(_ direction: GalleryDirection, item: MediaGalleryItem, amount: UInt, completion: ((IndexSet, [IndexPath]) -> Void)? = nil ) {
|
|
||||||
|
|
||||||
var galleryItems: [MediaGalleryItem] = self.galleryItems
|
|
||||||
var sections: [GalleryDate: [MediaGalleryItem]] = self.sections
|
|
||||||
var sectionDates: [GalleryDate] = self.sectionDates
|
|
||||||
|
|
||||||
var newGalleryItems: [MediaGalleryItem] = []
|
|
||||||
var newDates: [GalleryDate] = []
|
|
||||||
|
|
||||||
do {
|
|
||||||
try Bench(title: "fetching gallery items") {
|
|
||||||
try self.uiDatabaseConnection.read { transaction in
|
|
||||||
guard let index = self.mediaGalleryFinder.mediaIndex(attachment: item.attachmentStream, transaction: transaction) else {
|
|
||||||
throw MediaGalleryError.itemNoLongerExists
|
|
||||||
}
|
|
||||||
let initialIndex: Int = index.intValue
|
|
||||||
let mediaCount: Int = Int(self.mediaGalleryFinder.mediaCount(transaction: transaction))
|
|
||||||
|
|
||||||
let requestRange: Range<Int> = { () -> Range<Int> in
|
|
||||||
let range: Range<Int> = { () -> Range<Int> in
|
|
||||||
switch direction {
|
|
||||||
case .around:
|
|
||||||
// To keep it simple, this isn't exactly *amount* sized if `message` window overlaps the end or
|
|
||||||
// beginning of the view. Still, we have sufficient buffer to fetch more as the user swipes.
|
|
||||||
let start: Int = initialIndex - Int(amount) / 2
|
|
||||||
let end: Int = initialIndex + Int(amount) / 2 + 1
|
|
||||||
|
|
||||||
return start..<end
|
|
||||||
case .before:
|
|
||||||
let start: Int = initialIndex - Int(amount)
|
|
||||||
let end: Int = initialIndex
|
|
||||||
|
|
||||||
return start..<end
|
|
||||||
case .after:
|
|
||||||
let start: Int = initialIndex
|
|
||||||
let end: Int = initialIndex + Int(amount) + 1
|
|
||||||
|
|
||||||
return start..<end
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return range.clamped(to: 0..<mediaCount)
|
|
||||||
}()
|
|
||||||
|
|
||||||
let requestSet = IndexSet(integersIn: requestRange)
|
|
||||||
guard !self.fetchedIndexSet.contains(integersIn: requestSet) else {
|
|
||||||
Logger.debug("all requested messages have already been loaded.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let unfetchedSet = requestSet.subtracting(self.fetchedIndexSet)
|
|
||||||
|
|
||||||
// For perf we only want to fetch a substantially full batch...
|
|
||||||
let isSubstantialRequest = unfetchedSet.count > (requestSet.count / 2)
|
|
||||||
// ...but we always fulfill even small requests if we're getting just the tail end of a gallery.
|
|
||||||
let isFetchingEdgeOfGallery = (self.fetchedIndexSet.count - unfetchedSet.count) < requestSet.count
|
|
||||||
|
|
||||||
guard isSubstantialRequest || isFetchingEdgeOfGallery else {
|
|
||||||
Logger.debug("ignoring small fetch request: \(unfetchedSet.count)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.debug("fetching set: \(unfetchedSet)")
|
|
||||||
let nsRange: NSRange = NSRange(location: unfetchedSet.min()!, length: unfetchedSet.count)
|
|
||||||
self.mediaGalleryFinder.enumerateMediaAttachments(range: nsRange, transaction: transaction) { (attachment: TSAttachment) in
|
|
||||||
|
|
||||||
guard !self.deletedAttachments.contains(attachment) else {
|
|
||||||
Logger.debug("skipping \(attachment) which has been deleted.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let item: MediaGalleryItem = self.buildGalleryItem(attachment: attachment, transaction: transaction) else {
|
|
||||||
owsFailDebug("unexpectedly failed to buildGalleryItem")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let date = item.galleryDate
|
|
||||||
|
|
||||||
galleryItems.append(item)
|
|
||||||
if sections[date] != nil {
|
|
||||||
sections[date]!.append(item)
|
|
||||||
|
|
||||||
// so we can update collectionView
|
|
||||||
newGalleryItems.append(item)
|
|
||||||
} else {
|
|
||||||
sectionDates.append(date)
|
|
||||||
sections[date] = [item]
|
|
||||||
|
|
||||||
// so we can update collectionView
|
|
||||||
newDates.append(date)
|
|
||||||
newGalleryItems.append(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.fetchedIndexSet = self.fetchedIndexSet.union(unfetchedSet)
|
|
||||||
self.hasFetchedOldest = self.fetchedIndexSet.min() == 0
|
|
||||||
self.hasFetchedMostRecent = self.fetchedIndexSet.max() == mediaCount - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch MediaGalleryError.itemNoLongerExists {
|
|
||||||
Logger.debug("Ignoring reload, since item no longer exists.")
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
owsFailDebug("unexpected error: \(error)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO only sort if changed
|
|
||||||
var sortedSections: [GalleryDate: [MediaGalleryItem]] = [:]
|
|
||||||
|
|
||||||
Bench(title: "sorting gallery items") {
|
|
||||||
galleryItems.sort { lhs, rhs -> Bool in
|
|
||||||
return lhs.orderingKey < rhs.orderingKey
|
|
||||||
}
|
|
||||||
sectionDates.sort()
|
|
||||||
|
|
||||||
for (date, galleryItems) in sections {
|
|
||||||
sortedSections[date] = galleryItems.sorted { lhs, rhs -> Bool in
|
|
||||||
return lhs.orderingKey < rhs.orderingKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.galleryItems = galleryItems
|
|
||||||
self.sections = sortedSections
|
|
||||||
self.sectionDates = sectionDates
|
|
||||||
|
|
||||||
if let completionBlock = completion {
|
|
||||||
Bench(title: "calculating changes for collectionView") {
|
|
||||||
// FIXME can we avoid this index offset?
|
|
||||||
let dateIndices = newDates.map { sectionDates.firstIndex(of: $0)! + 1 }
|
|
||||||
let addedSections: IndexSet = IndexSet(dateIndices)
|
|
||||||
|
|
||||||
let addedItems: [IndexPath] = newGalleryItems.map { galleryItem in
|
|
||||||
let sectionIdx = sectionDates.firstIndex(of: galleryItem.galleryDate)!
|
|
||||||
let section = sections[galleryItem.galleryDate]!
|
|
||||||
let itemIdx = section.firstIndex(of: galleryItem)!
|
|
||||||
|
|
||||||
// FIXME can we avoid this index offset?
|
|
||||||
return IndexPath(item: itemIdx, section: sectionIdx + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
completionBlock(addedSections, addedItems)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var dataSourceDelegates: [Weak<MediaGalleryDataSourceDelegate>] = []
|
|
||||||
func addDataSourceDelegate(_ dataSourceDelegate: MediaGalleryDataSourceDelegate) {
|
|
||||||
dataSourceDelegates.append(Weak(value: dataSourceDelegate))
|
|
||||||
}
|
|
||||||
|
|
||||||
func delete(items: [MediaGalleryItem], initiatedBy: AnyObject) {
|
|
||||||
AssertIsOnMainThread()
|
|
||||||
|
|
||||||
Logger.info("with items: \(items.map { ($0.attachmentStream, $0.message.timestamp) })")
|
|
||||||
|
|
||||||
deletedGalleryItems.formUnion(items)
|
|
||||||
dataSourceDelegates.forEach { $0.value?.mediaGalleryDataSource(self, willDelete: items, initiatedBy: initiatedBy) }
|
|
||||||
|
|
||||||
for item in items {
|
|
||||||
self.deletedAttachments.insert(item.attachmentStream)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.editingDatabaseConnection.asyncReadWrite { transaction in
|
|
||||||
for item in items {
|
|
||||||
let message = item.message
|
|
||||||
let attachment = item.attachmentStream
|
|
||||||
message.removeAttachment(attachment, transaction: transaction)
|
|
||||||
if message.attachmentIds.count == 0 {
|
|
||||||
Logger.debug("removing message after removing last media attachment")
|
|
||||||
message.remove(with: transaction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var deletedSections: IndexSet = IndexSet()
|
|
||||||
var deletedIndexPaths: [IndexPath] = []
|
|
||||||
let originalSections = self.sections
|
|
||||||
let originalSectionDates = self.sectionDates
|
|
||||||
|
|
||||||
for item in items {
|
|
||||||
guard let itemIndex = galleryItems.firstIndex(of: item) else {
|
|
||||||
owsFailDebug("removing unknown item.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.galleryItems.remove(at: itemIndex)
|
|
||||||
|
|
||||||
guard let sectionIndex = sectionDates.firstIndex(where: { $0 == item.galleryDate }) else {
|
|
||||||
owsFailDebug("item with unknown date.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard var sectionItems = self.sections[item.galleryDate] else {
|
|
||||||
owsFailDebug("item with unknown section")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let sectionRowIndex = sectionItems.firstIndex(of: item) else {
|
|
||||||
owsFailDebug("item with unknown sectionRowIndex")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// We need to calculate the index of the deleted item with respect to it's original position.
|
|
||||||
guard let originalSectionIndex = originalSectionDates.firstIndex(where: { $0 == item.galleryDate }) else {
|
|
||||||
owsFailDebug("item with unknown date.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let originalSectionItems = originalSections[item.galleryDate] else {
|
|
||||||
owsFailDebug("item with unknown section")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let originalSectionRowIndex = originalSectionItems.firstIndex(of: item) else {
|
|
||||||
owsFailDebug("item with unknown sectionRowIndex")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if sectionItems == [item] {
|
|
||||||
// Last item in section. Delete section.
|
|
||||||
self.sections[item.galleryDate] = nil
|
|
||||||
self.sectionDates.remove(at: sectionIndex)
|
|
||||||
|
|
||||||
deletedSections.insert(originalSectionIndex + 1)
|
|
||||||
deletedIndexPaths.append(IndexPath(row: originalSectionRowIndex, section: originalSectionIndex + 1))
|
|
||||||
} else {
|
|
||||||
sectionItems.remove(at: sectionRowIndex)
|
|
||||||
self.sections[item.galleryDate] = sectionItems
|
|
||||||
|
|
||||||
deletedIndexPaths.append(IndexPath(row: originalSectionRowIndex, section: originalSectionIndex + 1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dataSourceDelegates.forEach { $0.value?.mediaGalleryDataSource(self, deletedSections: deletedSections, deletedItems: deletedIndexPaths) }
|
|
||||||
}
|
|
||||||
|
|
||||||
let kGallerySwipeLoadBatchSize: UInt = 5
|
|
||||||
|
|
||||||
internal func galleryItem(after currentItem: MediaGalleryItem) -> MediaGalleryItem? {
|
|
||||||
Logger.debug("")
|
|
||||||
|
|
||||||
self.ensureGalleryItemsLoaded(.after, item: currentItem, amount: kGallerySwipeLoadBatchSize)
|
|
||||||
|
|
||||||
guard let currentIndex = galleryItems.firstIndex(of: currentItem) else {
|
|
||||||
owsFailDebug("currentIndex was unexpectedly nil")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let index: Int = galleryItems.index(after: currentIndex)
|
|
||||||
guard let nextItem = galleryItems[safe: index] else {
|
|
||||||
// already at last item
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !deletedGalleryItems.contains(nextItem) else {
|
|
||||||
Logger.debug("nextItem was deleted - Recursing.")
|
|
||||||
return galleryItem(after: nextItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextItem
|
|
||||||
}
|
|
||||||
|
|
||||||
internal func galleryItem(before currentItem: MediaGalleryItem) -> MediaGalleryItem? {
|
|
||||||
Logger.debug("")
|
|
||||||
|
|
||||||
self.ensureGalleryItemsLoaded(.before, item: currentItem, amount: kGallerySwipeLoadBatchSize)
|
|
||||||
|
|
||||||
guard let currentIndex = galleryItems.firstIndex(of: currentItem) else {
|
|
||||||
owsFailDebug("currentIndex was unexpectedly nil")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let index: Int = galleryItems.index(before: currentIndex)
|
|
||||||
guard let previousItem = galleryItems[safe: index] else {
|
|
||||||
// already at first item
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !deletedGalleryItems.contains(previousItem) else {
|
|
||||||
Logger.debug("previousItem was deleted - Recursing.")
|
|
||||||
return galleryItem(before: previousItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
return previousItem
|
|
||||||
}
|
|
||||||
|
|
||||||
var galleryItemCount: Int {
|
|
||||||
var count: UInt = 0
|
|
||||||
self.uiDatabaseConnection.read { (transaction: YapDatabaseReadTransaction) in
|
|
||||||
count = self.mediaGalleryFinder.mediaCount(transaction: transaction)
|
|
||||||
}
|
|
||||||
return Int(count) - deletedAttachments.count
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,574 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import DifferenceKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
|
public class MediaGalleryViewModel {
|
||||||
|
public typealias SectionModel = ArraySection<Section, Item>
|
||||||
|
|
||||||
|
// MARK: - Section
|
||||||
|
|
||||||
|
public enum Section: Differentiable, Equatable, Comparable, Hashable {
|
||||||
|
case emptyGallery
|
||||||
|
case loadOlder
|
||||||
|
case galleryMonth(date: GalleryDate)
|
||||||
|
case loadNewer
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Variables
|
||||||
|
|
||||||
|
public let threadId: String
|
||||||
|
public let threadVariant: SessionThread.Variant
|
||||||
|
private var focusedAttachmentId: String?
|
||||||
|
public private(set) var focusedIndexPath: IndexPath?
|
||||||
|
|
||||||
|
/// This value is the current state of an album view
|
||||||
|
private var cachedInteractionIdBefore: Atomic<[Int64: Int64]> = Atomic([:])
|
||||||
|
private var cachedInteractionIdAfter: Atomic<[Int64: Int64]> = Atomic([:])
|
||||||
|
|
||||||
|
public var interactionIdBefore: [Int64: Int64] { cachedInteractionIdBefore.wrappedValue }
|
||||||
|
public var interactionIdAfter: [Int64: Int64] { cachedInteractionIdAfter.wrappedValue }
|
||||||
|
public private(set) var albumData: [Int64: [Item]] = [:]
|
||||||
|
public private(set) var pagedDataObserver: PagedDatabaseObserver<Attachment, Item>?
|
||||||
|
|
||||||
|
/// This value is the current state of a gallery view
|
||||||
|
private var unobservedGalleryDataChanges: [SectionModel]?
|
||||||
|
public private(set) var galleryData: [SectionModel] = []
|
||||||
|
public var onGalleryChange: (([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 unobservedGalleryDataChanges: [SectionModel] = self.unobservedGalleryDataChanges {
|
||||||
|
onGalleryChange?(unobservedGalleryDataChanges)
|
||||||
|
self.unobservedGalleryDataChanges = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(
|
||||||
|
threadId: String,
|
||||||
|
threadVariant: SessionThread.Variant,
|
||||||
|
isPagedData: Bool,
|
||||||
|
pageSize: Int = 1,
|
||||||
|
focusedAttachmentId: String? = nil,
|
||||||
|
performInitialQuerySync: Bool = false
|
||||||
|
) {
|
||||||
|
self.threadId = threadId
|
||||||
|
self.threadVariant = threadVariant
|
||||||
|
self.focusedAttachmentId = focusedAttachmentId
|
||||||
|
self.pagedDataObserver = nil
|
||||||
|
|
||||||
|
guard isPagedData else { return }
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
self.pagedDataObserver = PagedDatabaseObserver(
|
||||||
|
pagedTable: Attachment.self,
|
||||||
|
pageSize: pageSize,
|
||||||
|
idColumn: .id,
|
||||||
|
observedChanges: [
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: Attachment.self,
|
||||||
|
columns: [.isValid]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
joinSQL: Item.joinSQL,
|
||||||
|
filterSQL: Item.filterSQL(threadId: threadId),
|
||||||
|
orderSQL: Item.galleryOrderSQL,
|
||||||
|
dataQuery: Item.baseQuery(orderSQL: Item.galleryOrderSQL),
|
||||||
|
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||||
|
guard let updatedGalleryData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have the 'onGalleryChange' callback then trigger it, otherwise just store the changes
|
||||||
|
// to be sent to the callback if we ever start observing again (when we have the callback it needs
|
||||||
|
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
|
||||||
|
// correct order)
|
||||||
|
guard let onGalleryChange: (([SectionModel]) -> ()) = self?.onGalleryChange else {
|
||||||
|
self?.unobservedGalleryDataChanges = updatedGalleryData
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onGalleryChange(updatedGalleryData)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run the initial query on a backgorund thread so we don't block the push transition
|
||||||
|
let loadInitialData: () -> () = { [weak self] in
|
||||||
|
// If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query
|
||||||
|
// from a `0` offset)
|
||||||
|
guard let initialFocusedId: String = focusedAttachmentId else {
|
||||||
|
self?.pagedDataObserver?.load(.pageBefore)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have a custom transition when going from an attachment detail screen to the tile gallery
|
||||||
|
// so in that case we want to perform the initial query synchronously so that we have the content
|
||||||
|
// to do the transition (we don't clear the 'unobservedGalleryDataChanges' after setting it as
|
||||||
|
// we don't want to mess with the initial view controller behaviour)
|
||||||
|
guard !performInitialQuerySync else {
|
||||||
|
loadInitialData()
|
||||||
|
updateGalleryData(self.unobservedGalleryDataChanges ?? [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .default).async {
|
||||||
|
loadInitialData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data
|
||||||
|
|
||||||
|
public struct GalleryDate: Differentiable, Equatable, Comparable, Hashable {
|
||||||
|
private static let thisYearFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "MMMM"
|
||||||
|
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
let year: Int
|
||||||
|
let month: Int
|
||||||
|
|
||||||
|
private var date: Date? {
|
||||||
|
var components = DateComponents()
|
||||||
|
components.month = self.month
|
||||||
|
components.year = self.year
|
||||||
|
|
||||||
|
return Calendar.current.date(from: components)
|
||||||
|
}
|
||||||
|
|
||||||
|
var localizedString: String {
|
||||||
|
let isSameMonth: Bool = (self.month == Calendar.current.component(.month, from: Date()))
|
||||||
|
let isCurrentYear: Bool = (self.year == Calendar.current.component(.year, from: Date()))
|
||||||
|
let galleryDate: Date = (self.date ?? Date())
|
||||||
|
|
||||||
|
switch (isSameMonth, isCurrentYear) {
|
||||||
|
case (true, true): return "MEDIA_GALLERY_THIS_MONTH_HEADER".localized()
|
||||||
|
case (false, true): return GalleryDate.thisYearFormatter.string(from: galleryDate)
|
||||||
|
default: return GalleryDate.olderFormatter.string(from: galleryDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - --Initialization
|
||||||
|
|
||||||
|
init(messageDate: Date) {
|
||||||
|
self.year = Calendar.current.component(.year, from: messageDate)
|
||||||
|
self.month = Calendar.current.component(.month, from: messageDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - --Comparable
|
||||||
|
|
||||||
|
public static func < (lhs: GalleryDate, rhs: GalleryDate) -> Bool {
|
||||||
|
switch ((lhs.year != rhs.year), (lhs.month != rhs.month)) {
|
||||||
|
case (true, _): return lhs.year < rhs.year
|
||||||
|
case (_, true): return lhs.month < rhs.month
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 var id: String { attachment.id }
|
||||||
|
public var differenceIdentifier: String { attachment.id }
|
||||||
|
|
||||||
|
let interactionId: Int64
|
||||||
|
let interactionVariant: Interaction.Variant
|
||||||
|
let interactionAuthorId: String
|
||||||
|
let interactionTimestampMs: Int64
|
||||||
|
|
||||||
|
public var rowId: Int64
|
||||||
|
let attachmentAlbumIndex: Int
|
||||||
|
let attachment: Attachment
|
||||||
|
|
||||||
|
var galleryDate: GalleryDate {
|
||||||
|
GalleryDate(
|
||||||
|
messageDate: Date(timeIntervalSince1970: (Double(interactionTimestampMs) / 1000))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isVideo: Bool { attachment.isVideo }
|
||||||
|
var isAnimated: Bool { attachment.isAnimated }
|
||||||
|
var isImage: Bool { attachment.isImage }
|
||||||
|
|
||||||
|
var imageSize: CGSize {
|
||||||
|
guard let width: UInt = attachment.width, let height: UInt = attachment.height else {
|
||||||
|
return .zero
|
||||||
|
}
|
||||||
|
|
||||||
|
return CGSize(width: Int(width), height: Int(height))
|
||||||
|
}
|
||||||
|
|
||||||
|
var captionForDisplay: String? { attachment.caption?.filterForDisplay }
|
||||||
|
|
||||||
|
// MARK: - Query
|
||||||
|
|
||||||
|
fileprivate static let joinSQL: SQL = {
|
||||||
|
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||||
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
|
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||||
|
|
||||||
|
return """
|
||||||
|
JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id])
|
||||||
|
JOIN \(Interaction.self) ON \(interaction[.id]) = \(interactionAttachment[.interactionId])
|
||||||
|
"""
|
||||||
|
}()
|
||||||
|
|
||||||
|
fileprivate static func filterSQL(threadId: String) -> SQL {
|
||||||
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
|
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||||
|
|
||||||
|
return SQL("""
|
||||||
|
\(attachment[.isVisualMedia]) = true AND
|
||||||
|
\(attachment[.isValid]) = true AND
|
||||||
|
\(interaction[.threadId]) = \(threadId)
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate static let galleryOrderSQL: SQL = {
|
||||||
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
|
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||||
|
|
||||||
|
/// **Note:** This **MUST** match the desired sort behaviour for the screen otherwise paging will be
|
||||||
|
/// very broken
|
||||||
|
return SQL("\(interaction[.timestampMs].desc), \(interactionAttachment[.albumIndex])")
|
||||||
|
}()
|
||||||
|
|
||||||
|
fileprivate static let galleryReverseOrderSQL: SQL = {
|
||||||
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
|
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||||
|
|
||||||
|
/// **Note:** This **MUST** match the desired sort behaviour for the screen otherwise paging will be
|
||||||
|
/// very broken
|
||||||
|
return SQL("\(interaction[.timestampMs]), \(interactionAttachment[.albumIndex].desc)")
|
||||||
|
}()
|
||||||
|
|
||||||
|
fileprivate static func baseQuery(orderSQL: SQL, customFilters: SQL? = nil) -> (([Int64]) -> AdaptedFetchRequest<SQLRequest<Item>>) {
|
||||||
|
return { rowIds -> AdaptedFetchRequest<SQLRequest<Item>> in
|
||||||
|
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||||
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
|
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||||
|
|
||||||
|
let numColumnsBeforeLinkedRecords: Int = 6
|
||||||
|
let finalFilterSQL: SQL = {
|
||||||
|
guard let customFilters: SQL = customFilters else {
|
||||||
|
return """
|
||||||
|
WHERE \(attachment.alias[Column.rowID]) IN \(rowIds)
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
return """
|
||||||
|
WHERE (
|
||||||
|
\(customFilters)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
}()
|
||||||
|
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),
|
||||||
|
|
||||||
|
\(attachment.alias[Column.rowID]) AS \(Item.rowIdKey),
|
||||||
|
\(interactionAttachment[.albumIndex]) AS \(Item.attachmentAlbumIndexKey),
|
||||||
|
\(Item.attachmentKey).*
|
||||||
|
FROM \(Attachment.self)
|
||||||
|
\(joinSQL)
|
||||||
|
\(finalFilterSQL)
|
||||||
|
ORDER BY \(orderSQL)
|
||||||
|
"""
|
||||||
|
|
||||||
|
return request.adapted { db in
|
||||||
|
let adapters = try splittingRowAdapters(columnCounts: [
|
||||||
|
numColumnsBeforeLinkedRecords,
|
||||||
|
Attachment.numberOfSelectedColumns(db)
|
||||||
|
])
|
||||||
|
|
||||||
|
return ScopeAdapter([
|
||||||
|
Item.attachmentString: adapters[1]
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate static func baseQuery(orderSQL: SQL, customFilters: SQL) -> AdaptedFetchRequest<SQLRequest<Item>> {
|
||||||
|
return Item.baseQuery(orderSQL: orderSQL, customFilters: customFilters)([])
|
||||||
|
}
|
||||||
|
|
||||||
|
func thumbnailImage(async: @escaping (UIImage) -> ()) {
|
||||||
|
attachment.thumbnail(size: .small, success: { image, _ in async(image) }, failure: {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Album
|
||||||
|
|
||||||
|
/// 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:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static
|
||||||
|
/// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
|
||||||
|
///
|
||||||
|
/// **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
|
||||||
|
public typealias AlbumObservation = ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<[Item]>>>
|
||||||
|
public lazy var observableAlbumData: AlbumObservation = buildAlbumObservation(for: nil)
|
||||||
|
|
||||||
|
private func buildAlbumObservation(for interactionId: Int64?) -> AlbumObservation {
|
||||||
|
return ValueObservation
|
||||||
|
.trackingConstantRegion { db -> [Item] in
|
||||||
|
guard let interactionId: Int64 = interactionId else { return [] }
|
||||||
|
|
||||||
|
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||||
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
|
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||||
|
|
||||||
|
return try Item
|
||||||
|
.baseQuery(
|
||||||
|
orderSQL: SQL(interactionAttachment[.albumIndex]),
|
||||||
|
customFilters: SQL("""
|
||||||
|
\(attachment[.isValid]) = true AND
|
||||||
|
\(interaction[.id]) = \(interactionId)
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
.fetchAll(db)
|
||||||
|
}
|
||||||
|
.removeDuplicates()
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult public func loadAndCacheAlbumData(for interactionId: Int64) -> [Item] {
|
||||||
|
typealias AlbumInfo = (albumData: [Item], interactionIdBefore: Int64?, interactionIdAfter: Int64?)
|
||||||
|
|
||||||
|
// Note: It's possible we already have cached album data for this interaction
|
||||||
|
// but to avoid displaying stale data we re-fetch from the database anyway
|
||||||
|
let maybeAlbumInfo: AlbumInfo? = Storage.shared.read { db -> AlbumInfo in
|
||||||
|
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||||
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
|
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||||
|
|
||||||
|
let newAlbumData: [Item] = try Item
|
||||||
|
.baseQuery(
|
||||||
|
orderSQL: SQL(interactionAttachment[.albumIndex]),
|
||||||
|
customFilters: SQL("""
|
||||||
|
\(attachment[.isValid]) = true AND
|
||||||
|
\(interaction[.id]) = \(interactionId)
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
.fetchAll(db)
|
||||||
|
|
||||||
|
guard let albumTimestampMs: Int64 = newAlbumData.first?.interactionTimestampMs else {
|
||||||
|
return (newAlbumData, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
let itemBefore: Item? = try Item
|
||||||
|
.baseQuery(
|
||||||
|
orderSQL: Item.galleryReverseOrderSQL,
|
||||||
|
customFilters: SQL("\(interaction[.timestampMs]) > \(albumTimestampMs)")
|
||||||
|
)
|
||||||
|
.fetchOne(db)
|
||||||
|
let itemAfter: Item? = try Item
|
||||||
|
.baseQuery(
|
||||||
|
orderSQL: Item.galleryOrderSQL,
|
||||||
|
customFilters: SQL("\(interaction[.timestampMs]) < \(albumTimestampMs)")
|
||||||
|
)
|
||||||
|
.fetchOne(db)
|
||||||
|
|
||||||
|
return (newAlbumData, itemBefore?.interactionId, itemAfter?.interactionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let newAlbumInfo: AlbumInfo = maybeAlbumInfo else { return [] }
|
||||||
|
|
||||||
|
// Cache the album info for the new interactionId
|
||||||
|
self.updateAlbumData(newAlbumInfo.albumData, for: interactionId)
|
||||||
|
self.cachedInteractionIdBefore.mutate { $0[interactionId] = newAlbumInfo.interactionIdBefore }
|
||||||
|
self.cachedInteractionIdAfter.mutate { $0[interactionId] = newAlbumInfo.interactionIdAfter }
|
||||||
|
|
||||||
|
return newAlbumInfo.albumData
|
||||||
|
}
|
||||||
|
|
||||||
|
public func replaceAlbumObservation(toObservationFor interactionId: Int64) {
|
||||||
|
self.observableAlbumData = self.buildAlbumObservation(for: interactionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateAlbumData(_ updatedData: [Item], for interactionId: Int64) {
|
||||||
|
self.albumData[interactionId] = updatedData
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gallery
|
||||||
|
|
||||||
|
private func process(data: [Item], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
||||||
|
let galleryData: [SectionModel] = data
|
||||||
|
.grouped(by: \.galleryDate)
|
||||||
|
.mapValues { sectionItems -> [Item] in
|
||||||
|
sectionItems
|
||||||
|
.sorted { lhs, rhs -> Bool in
|
||||||
|
if lhs.interactionTimestampMs == rhs.interactionTimestampMs {
|
||||||
|
// Start of album first
|
||||||
|
return (lhs.attachmentAlbumIndex < rhs.attachmentAlbumIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newer interactions first
|
||||||
|
return (lhs.interactionTimestampMs > rhs.interactionTimestampMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map { galleryDate, items in
|
||||||
|
SectionModel(model: .galleryMonth(date: galleryDate), elements: items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove and re-add the custom sections as needed
|
||||||
|
return [
|
||||||
|
(data.isEmpty ? [SectionModel(section: .emptyGallery)] : []),
|
||||||
|
(!data.isEmpty && pageInfo.pageOffset > 0 ? [SectionModel(section: .loadNewer)] : []),
|
||||||
|
galleryData,
|
||||||
|
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
|
||||||
|
[SectionModel(section: .loadOlder)] :
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
.flatMap { $0 }
|
||||||
|
.sorted { lhs, rhs -> Bool in (lhs.model > rhs.model) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateGalleryData(_ updatedData: [SectionModel]) {
|
||||||
|
self.galleryData = updatedData
|
||||||
|
|
||||||
|
// If we have a focused attachment id then we need to make sure the 'focusedIndexPath'
|
||||||
|
// is updated to be accurate
|
||||||
|
if let focusedAttachmentId: String = focusedAttachmentId {
|
||||||
|
self.focusedIndexPath = nil
|
||||||
|
|
||||||
|
for (section, sectionData) in updatedData.enumerated() {
|
||||||
|
for (index, item) in sectionData.elements.enumerated() {
|
||||||
|
if item.attachment.id == focusedAttachmentId {
|
||||||
|
self.focusedIndexPath = IndexPath(item: index, section: section)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.focusedIndexPath != nil { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateFocusedItem(attachmentId: String, indexPath: IndexPath) {
|
||||||
|
// Note: We need to set both of these as the 'focusedIndexPath' is usually
|
||||||
|
// derived and if the data changes it will be regenerated using the
|
||||||
|
// 'focusedAttachmentId' value
|
||||||
|
self.focusedAttachmentId = attachmentId
|
||||||
|
self.focusedIndexPath = indexPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Creation Functions
|
||||||
|
|
||||||
|
public static func createDetailViewController(
|
||||||
|
for threadId: String,
|
||||||
|
threadVariant: SessionThread.Variant,
|
||||||
|
interactionId: Int64,
|
||||||
|
selectedAttachmentId: String,
|
||||||
|
options: [MediaGalleryOption]
|
||||||
|
) -> UIViewController? {
|
||||||
|
// Load the data for the album immediately (needed before pushing to the screen so
|
||||||
|
// transitions work nicely)
|
||||||
|
let viewModel: MediaGalleryViewModel = MediaGalleryViewModel(
|
||||||
|
threadId: threadId,
|
||||||
|
threadVariant: threadVariant,
|
||||||
|
isPagedData: false
|
||||||
|
)
|
||||||
|
viewModel.loadAndCacheAlbumData(for: interactionId)
|
||||||
|
viewModel.replaceAlbumObservation(toObservationFor: interactionId)
|
||||||
|
|
||||||
|
guard
|
||||||
|
!viewModel.albumData.isEmpty,
|
||||||
|
let initialItem: Item = viewModel.albumData[interactionId]?.first(where: { item -> Bool in
|
||||||
|
item.attachment.id == selectedAttachmentId
|
||||||
|
})
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let pageViewController: MediaPageViewController = MediaPageViewController(
|
||||||
|
viewModel: viewModel,
|
||||||
|
initialItem: initialItem,
|
||||||
|
options: options
|
||||||
|
)
|
||||||
|
let navController: MediaGalleryNavigationController = MediaGalleryNavigationController()
|
||||||
|
navController.viewControllers = [pageViewController]
|
||||||
|
navController.modalPresentationStyle = .fullScreen
|
||||||
|
navController.transitioningDelegate = pageViewController
|
||||||
|
|
||||||
|
return navController
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func createTileViewController(
|
||||||
|
threadId: String,
|
||||||
|
threadVariant: SessionThread.Variant,
|
||||||
|
focusedAttachmentId: String?,
|
||||||
|
performInitialQuerySync: Bool = false
|
||||||
|
) -> MediaTileViewController {
|
||||||
|
let viewModel: MediaGalleryViewModel = MediaGalleryViewModel(
|
||||||
|
threadId: threadId,
|
||||||
|
threadVariant: threadVariant,
|
||||||
|
isPagedData: true,
|
||||||
|
pageSize: MediaTileViewController.itemPageSize,
|
||||||
|
focusedAttachmentId: focusedAttachmentId,
|
||||||
|
performInitialQuerySync: performInitialQuerySync
|
||||||
|
)
|
||||||
|
|
||||||
|
return MediaTileViewController(
|
||||||
|
viewModel: viewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: OWSNavigationController) {
|
||||||
|
fromNavController.pushViewController(
|
||||||
|
MediaGalleryViewModel.createTileViewController(
|
||||||
|
threadId: threadId,
|
||||||
|
threadVariant: {
|
||||||
|
if isClosedGroup { return .closedGroup }
|
||||||
|
if isOpenGroup { return .openGroup }
|
||||||
|
|
||||||
|
return .contact
|
||||||
|
}(),
|
||||||
|
focusedAttachmentId: nil
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -41,18 +41,13 @@ class PhotoCapture: NSObject {
|
||||||
self.session = AVCaptureSession()
|
self.session = AVCaptureSession()
|
||||||
self.captureOutput = CaptureOutput()
|
self.captureOutput = CaptureOutput()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Dependencies
|
|
||||||
var audioSession: OWSAudioSession {
|
|
||||||
return Environment.shared.audioSession
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: -
|
// MARK: -
|
||||||
var audioDeviceInput: AVCaptureDeviceInput?
|
var audioDeviceInput: AVCaptureDeviceInput?
|
||||||
func startAudioCapture() throws {
|
func startAudioCapture() throws {
|
||||||
assertIsOnSessionQueue()
|
assertIsOnSessionQueue()
|
||||||
|
|
||||||
guard audioSession.startAudioActivity(recordingAudioActivity) else {
|
guard Environment.shared?.audioSession.startAudioActivity(recordingAudioActivity) == true else {
|
||||||
throw PhotoCaptureError.assertionError(description: "unable to capture audio activity")
|
throw PhotoCaptureError.assertionError(description: "unable to capture audio activity")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,7 +78,7 @@ class PhotoCapture: NSObject {
|
||||||
}
|
}
|
||||||
session.removeInput(audioDeviceInput)
|
session.removeInput(audioDeviceInput)
|
||||||
self.audioDeviceInput = nil
|
self.audioDeviceInput = nil
|
||||||
audioSession.endAudioActivity(recordingAudioActivity)
|
Environment.shared?.audioSession.endAudioActivity(recordingAudioActivity)
|
||||||
}
|
}
|
||||||
|
|
||||||
func startCapture() -> Promise<Void> {
|
func startCapture() -> Promise<Void> {
|
||||||
|
@ -458,16 +453,10 @@ protocol ImageCaptureOutput: AnyObject {
|
||||||
|
|
||||||
class CaptureOutput {
|
class CaptureOutput {
|
||||||
|
|
||||||
let imageOutput: ImageCaptureOutput
|
let imageOutput: ImageCaptureOutput = PhotoCaptureOutputAdaptee()
|
||||||
let movieOutput: AVCaptureMovieFileOutput
|
let movieOutput: AVCaptureMovieFileOutput
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if #available(iOS 10.0, *) {
|
|
||||||
imageOutput = PhotoCaptureOutputAdaptee()
|
|
||||||
} else {
|
|
||||||
imageOutput = StillImageCaptureOutput()
|
|
||||||
}
|
|
||||||
|
|
||||||
movieOutput = AVCaptureMovieFileOutput()
|
movieOutput = AVCaptureMovieFileOutput()
|
||||||
// disable movie fragment writing since it's not supported on mp4
|
// disable movie fragment writing since it's not supported on mp4
|
||||||
// leaving it enabled causes all audio to be lost on videos longer
|
// leaving it enabled causes all audio to be lost on videos longer
|
||||||
|
@ -536,7 +525,6 @@ class CaptureOutput {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 10.0, *)
|
|
||||||
class PhotoCaptureOutputAdaptee: NSObject, ImageCaptureOutput {
|
class PhotoCaptureOutputAdaptee: NSObject, ImageCaptureOutput {
|
||||||
|
|
||||||
let photoOutput = AVCapturePhotoOutput()
|
let photoOutput = AVCapturePhotoOutput()
|
||||||
|
@ -591,7 +579,6 @@ class PhotoCaptureOutputAdaptee: NSObject, ImageCaptureOutput {
|
||||||
self.completion = completion
|
self.completion = completion
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 11.0, *)
|
|
||||||
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
|
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
|
||||||
var data = photo.fileDataRepresentation()!
|
var data = photo.fileDataRepresentation()!
|
||||||
// Call normalized here to fix the orientation
|
// Call normalized here to fix the orientation
|
||||||
|
|
|
@ -115,12 +115,7 @@ class PhotoCaptureViewController: OWSViewController {
|
||||||
|
|
||||||
init(imageName: String, block: @escaping () -> Void) {
|
init(imageName: String, block: @escaping () -> Void) {
|
||||||
self.button = OWSButton(imageName: imageName, tintColor: .ows_white, block: block)
|
self.button = OWSButton(imageName: imageName, tintColor: .ows_white, block: block)
|
||||||
if #available(iOS 10, *) {
|
button.autoPinToSquareAspectRatio()
|
||||||
button.autoPinToSquareAspectRatio()
|
|
||||||
} else {
|
|
||||||
button.sizeToFit()
|
|
||||||
}
|
|
||||||
|
|
||||||
button.layer.shadowOffset = CGSize.zero
|
button.layer.shadowOffset = CGSize.zero
|
||||||
button.layer.shadowOpacity = 0.35
|
button.layer.shadowOpacity = 0.35
|
||||||
button.layer.shadowRadius = 4
|
button.layer.shadowRadius = 4
|
||||||
|
@ -600,20 +595,6 @@ class RecordingTimerView: UIView {
|
||||||
return icon
|
return icon
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: - Overrides //
|
|
||||||
|
|
||||||
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
|
||||||
if #available(iOS 10, *) {
|
|
||||||
return super.sizeThatFits(size)
|
|
||||||
} else {
|
|
||||||
// iOS9 manual layout sizing required for items in the navigation bar
|
|
||||||
var baseSize = label.frame.size
|
|
||||||
baseSize.width = baseSize.width + stackViewSpacing + RecordingTimerView.iconWidth + layoutMargins.left + layoutMargins.right
|
|
||||||
baseSize.height = baseSize.height + layoutMargins.top + layoutMargins.bottom
|
|
||||||
return baseSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: -
|
// MARK: -
|
||||||
var recordingStartTime: TimeInterval?
|
var recordingStartTime: TimeInterval?
|
||||||
|
|
||||||
|
@ -662,10 +643,5 @@ class RecordingTimerView: UIView {
|
||||||
Logger.verbose("recordingDuration: \(recordingDuration)")
|
Logger.verbose("recordingDuration: \(recordingDuration)")
|
||||||
let durationDate = Date(timeIntervalSinceReferenceDate: recordingDuration)
|
let durationDate = Date(timeIntervalSinceReferenceDate: recordingDuration)
|
||||||
label.text = timeFormatter.string(from: durationDate)
|
label.text = timeFormatter.string(from: durationDate)
|
||||||
if #available(iOS 10, *) {
|
|
||||||
// do nothing
|
|
||||||
} else {
|
|
||||||
label.sizeToFit()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import Foundation
|
||||||
import Photos
|
import Photos
|
||||||
import PromiseKit
|
import PromiseKit
|
||||||
|
|
||||||
protocol PhotoCollectionPickerDelegate: class {
|
protocol PhotoCollectionPickerDelegate: AnyObject {
|
||||||
func photoCollectionPicker(_ photoCollectionPicker: PhotoCollectionPickerController, didPickCollection collection: PhotoCollection)
|
func photoCollectionPicker(_ photoCollectionPicker: PhotoCollectionPickerController, didPickCollection collection: PhotoCollection)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,7 +102,7 @@ class PhotoCollectionPickerController: OWSTableViewController, PhotoLibraryDeleg
|
||||||
|
|
||||||
let photoMediaSize = PhotoMediaSize(thumbnailSize: CGSize(width: kImageSize, height: kImageSize))
|
let photoMediaSize = PhotoMediaSize(thumbnailSize: CGSize(width: kImageSize, height: kImageSize))
|
||||||
if let assetItem = contents.lastAssetItem(photoMediaSize: photoMediaSize) {
|
if let assetItem = contents.lastAssetItem(photoMediaSize: photoMediaSize) {
|
||||||
imageView.image = assetItem.asyncThumbnail { [weak imageView] image in
|
assetItem.asyncThumbnail { [weak imageView] image in
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
guard let imageView = imageView else {
|
guard let imageView = imageView else {
|
||||||
|
|
|
@ -9,15 +9,13 @@ public enum PhotoGridItemType {
|
||||||
case photo, animated, video
|
case photo, animated, video
|
||||||
}
|
}
|
||||||
|
|
||||||
public protocol PhotoGridItem: class {
|
public protocol PhotoGridItem: AnyObject {
|
||||||
var type: PhotoGridItemType { get }
|
var type: PhotoGridItemType { get }
|
||||||
func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage?
|
|
||||||
|
func asyncThumbnail(completion: @escaping (UIImage?) -> Void)
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PhotoGridViewCell: UICollectionViewCell {
|
public class PhotoGridViewCell: UICollectionViewCell {
|
||||||
|
|
||||||
static let reuseIdentifier = "PhotoGridViewCell"
|
|
||||||
|
|
||||||
public let imageView: UIImageView
|
public let imageView: UIImageView
|
||||||
|
|
||||||
private let contentTypeBadgeView: UIImageView
|
private let contentTypeBadgeView: UIImageView
|
||||||
|
@ -119,28 +117,23 @@ public class PhotoGridViewCell: UICollectionViewCell {
|
||||||
public func configure(item: PhotoGridItem) {
|
public func configure(item: PhotoGridItem) {
|
||||||
self.item = item
|
self.item = item
|
||||||
|
|
||||||
self.image = item.asyncThumbnail { image in
|
item.asyncThumbnail { [weak self] image in
|
||||||
guard let currentItem = self.item else {
|
guard let currentItem = self?.item else { return }
|
||||||
return
|
guard currentItem === item else { return }
|
||||||
}
|
|
||||||
|
|
||||||
guard currentItem === item else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if image == nil {
|
if image == nil {
|
||||||
Logger.debug("image == nil")
|
Logger.debug("image == nil")
|
||||||
}
|
}
|
||||||
self.image = image
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.image = image
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch item.type {
|
switch item.type {
|
||||||
case .video:
|
case .video: self.contentTypeBadgeImage = PhotoGridViewCell.videoBadgeImage
|
||||||
self.contentTypeBadgeImage = PhotoGridViewCell.videoBadgeImage
|
case .animated: self.contentTypeBadgeImage = PhotoGridViewCell.animatedBadgeImage
|
||||||
case .animated:
|
case .photo: self.contentTypeBadgeImage = nil
|
||||||
self.contentTypeBadgeImage = PhotoGridViewCell.animatedBadgeImage
|
|
||||||
case .photo:
|
|
||||||
self.contentTypeBadgeImage = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ import Photos
|
||||||
import PromiseKit
|
import PromiseKit
|
||||||
import CoreServices
|
import CoreServices
|
||||||
|
|
||||||
protocol PhotoLibraryDelegate: class {
|
protocol PhotoLibraryDelegate: AnyObject {
|
||||||
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary)
|
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,16 +47,13 @@ class PhotoPickerAssetItem: PhotoGridItem {
|
||||||
return .photo
|
return .photo
|
||||||
}
|
}
|
||||||
|
|
||||||
func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? {
|
func asyncThumbnail(completion: @escaping (UIImage?) -> Void) {
|
||||||
var syncImageResult: UIImage?
|
|
||||||
var hasLoadedImage = false
|
var hasLoadedImage = false
|
||||||
|
|
||||||
// Surprisingly, iOS will opportunistically run the completion block sync if the image is
|
// Surprisingly, iOS will opportunistically run the completion block sync if the image is
|
||||||
// already available.
|
// already available.
|
||||||
photoCollectionContents.requestThumbnail(for: self.asset, thumbnailSize: photoMediaSize.thumbnailSize) { image, _ in
|
photoCollectionContents.requestThumbnail(for: self.asset, thumbnailSize: photoMediaSize.thumbnailSize) { image, _ in
|
||||||
DispatchMainThreadSafe({
|
DispatchMainThreadSafe({
|
||||||
syncImageResult = image
|
|
||||||
|
|
||||||
// Once we've _successfully_ completed (e.g. invoked the completion with
|
// Once we've _successfully_ completed (e.g. invoked the completion with
|
||||||
// a non-nil image), don't invoke the completion again with a nil argument.
|
// a non-nil image), don't invoke the completion again with a nil argument.
|
||||||
if !hasLoadedImage || image != nil {
|
if !hasLoadedImage || image != nil {
|
||||||
|
@ -68,7 +65,6 @@ class PhotoPickerAssetItem: PhotoGridItem {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return syncImageResult
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,27 +1,30 @@
|
||||||
//
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import UIKit
|
||||||
import Photos
|
import Photos
|
||||||
import PromiseKit
|
import PromiseKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
@objc
|
|
||||||
protocol SendMediaNavDelegate: AnyObject {
|
|
||||||
func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController)
|
|
||||||
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], messageText: String?)
|
|
||||||
|
|
||||||
func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String?
|
|
||||||
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
class SendMediaNavigationController: OWSNavigationController {
|
class SendMediaNavigationController: OWSNavigationController {
|
||||||
|
|
||||||
// This is a sensitive constant, if you change it make sure to check
|
// This is a sensitive constant, if you change it make sure to check
|
||||||
// on iPhone5, 6, 6+, X, layouts.
|
// on iPhone5, 6, 6+, X, layouts.
|
||||||
static let bottomButtonsCenterOffset: CGFloat = -50
|
static let bottomButtonsCenterOffset: CGFloat = -50
|
||||||
|
|
||||||
|
private let threadId: String
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(threadId: String) {
|
||||||
|
self.threadId = threadId
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder aDecoder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Overrides
|
// MARK: - Overrides
|
||||||
|
|
||||||
override var prefersStatusBarHidden: Bool { return true }
|
override var prefersStatusBarHidden: Bool { return true }
|
||||||
|
@ -56,21 +59,20 @@ class SendMediaNavigationController: OWSNavigationController {
|
||||||
|
|
||||||
// MARK: -
|
// MARK: -
|
||||||
|
|
||||||
@objc
|
|
||||||
public weak var sendMediaNavDelegate: SendMediaNavDelegate?
|
public weak var sendMediaNavDelegate: SendMediaNavDelegate?
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
public class func showingCameraFirst() -> SendMediaNavigationController {
|
public class func showingCameraFirst(threadId: String) -> SendMediaNavigationController {
|
||||||
let navController = SendMediaNavigationController()
|
let navController = SendMediaNavigationController(threadId: threadId)
|
||||||
navController.setViewControllers([navController.captureViewController], animated: false)
|
navController.viewControllers = [navController.captureViewController]
|
||||||
|
|
||||||
return navController
|
return navController
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
public class func showingMediaLibraryFirst() -> SendMediaNavigationController {
|
public class func showingMediaLibraryFirst(threadId: String) -> SendMediaNavigationController {
|
||||||
let navController = SendMediaNavigationController()
|
let navController = SendMediaNavigationController(threadId: threadId)
|
||||||
navController.setViewControllers([navController.mediaLibraryViewController], animated: false)
|
navController.viewControllers = [navController.mediaLibraryViewController]
|
||||||
|
|
||||||
return navController
|
return navController
|
||||||
}
|
}
|
||||||
|
@ -230,7 +232,11 @@ class SendMediaNavigationController: OWSNavigationController {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let approvalViewController = AttachmentApprovalViewController(mode: .sharedNavigation, attachments: self.attachments)
|
let approvalViewController = AttachmentApprovalViewController(
|
||||||
|
mode: .sharedNavigation,
|
||||||
|
threadId: self.threadId,
|
||||||
|
attachments: self.attachments
|
||||||
|
)
|
||||||
approvalViewController.approvalDelegate = self
|
approvalViewController.approvalDelegate = self
|
||||||
approvalViewController.messageText = sendMediaNavDelegate.sendMediaNavInitialMessageText(self)
|
approvalViewController.messageText = sendMediaNavDelegate.sendMediaNavInitialMessageText(self)
|
||||||
|
|
||||||
|
@ -276,8 +282,6 @@ extension SendMediaNavigationController: UINavigationControllerDelegate {
|
||||||
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
|
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
|
||||||
if viewController == captureViewController {
|
if viewController == captureViewController {
|
||||||
setNavBarBackgroundColor(to: .black)
|
setNavBarBackgroundColor(to: .black)
|
||||||
} else if viewController == mediaLibraryViewController {
|
|
||||||
setNavBarBackgroundColor(to: .white)
|
|
||||||
} else {
|
} else {
|
||||||
setNavBarBackgroundColor(to: Colors.navigationBarBackground)
|
setNavBarBackgroundColor(to: Colors.navigationBarBackground)
|
||||||
}
|
}
|
||||||
|
@ -305,8 +309,6 @@ extension SendMediaNavigationController: UINavigationControllerDelegate {
|
||||||
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
|
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
|
||||||
if viewController == captureViewController {
|
if viewController == captureViewController {
|
||||||
setNavBarBackgroundColor(to: .black)
|
setNavBarBackgroundColor(to: .black)
|
||||||
} else if viewController == mediaLibraryViewController {
|
|
||||||
setNavBarBackgroundColor(to: .white)
|
|
||||||
} else {
|
} else {
|
||||||
setNavBarBackgroundColor(to: Colors.navigationBarBackground)
|
setNavBarBackgroundColor(to: Colors.navigationBarBackground)
|
||||||
}
|
}
|
||||||
|
@ -441,8 +443,8 @@ extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegat
|
||||||
attachmentDraftCollection.remove(attachment: attachment)
|
attachmentDraftCollection.remove(attachment: attachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) {
|
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) {
|
||||||
sendMediaNavDelegate?.sendMediaNav(self, didApproveAttachments: attachments, messageText: messageText)
|
sendMediaNavDelegate?.sendMediaNav(self, didApproveAttachments: attachments, forThreadId: threadId, messageText: messageText)
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) {
|
func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) {
|
||||||
|
@ -680,3 +682,13 @@ private class DoneButton: UIView {
|
||||||
delegate?.doneButtonWasTapped(self)
|
delegate?.doneButtonWasTapped(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - SendMediaNavDelegate
|
||||||
|
|
||||||
|
protocol SendMediaNavDelegate: AnyObject {
|
||||||
|
func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController)
|
||||||
|
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?)
|
||||||
|
|
||||||
|
func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String?
|
||||||
|
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,244 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import PromiseKit
|
||||||
|
|
||||||
|
class MediaDismissAnimationController: NSObject {
|
||||||
|
private let mediaItem: Media
|
||||||
|
public let interactionController: MediaInteractiveDismiss?
|
||||||
|
|
||||||
|
var fromView: UIView?
|
||||||
|
var transitionView: UIView?
|
||||||
|
var fromTransitionalOverlayView: UIView?
|
||||||
|
var toTransitionalOverlayView: UIView?
|
||||||
|
var fromMediaFrame: CGRect?
|
||||||
|
var pendingCompletion: (() -> ())?
|
||||||
|
|
||||||
|
init(galleryItem: MediaGalleryViewModel.Item, interactionController: MediaInteractiveDismiss? = nil) {
|
||||||
|
self.mediaItem = .gallery(galleryItem)
|
||||||
|
self.interactionController = interactionController
|
||||||
|
}
|
||||||
|
|
||||||
|
init(image: UIImage, interactionController: MediaInteractiveDismiss? = nil) {
|
||||||
|
self.mediaItem = .image(image)
|
||||||
|
self.interactionController = interactionController
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning {
|
||||||
|
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||||
|
return 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||||
|
let containerView = transitionContext.containerView
|
||||||
|
let fromContextProvider: MediaPresentationContextProvider
|
||||||
|
let toContextProvider: MediaPresentationContextProvider
|
||||||
|
|
||||||
|
guard let fromVC: UIViewController = transitionContext.viewController(forKey: .from) else {
|
||||||
|
transitionContext.completeTransition(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let toVC: UIViewController = transitionContext.viewController(forKey: .to) else {
|
||||||
|
transitionContext.completeTransition(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch fromVC {
|
||||||
|
case let contextProvider as MediaPresentationContextProvider:
|
||||||
|
fromContextProvider = contextProvider
|
||||||
|
|
||||||
|
case let navController as UINavigationController:
|
||||||
|
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
|
||||||
|
transitionContext.completeTransition(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fromContextProvider = contextProvider
|
||||||
|
|
||||||
|
default:
|
||||||
|
transitionContext.completeTransition(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch toVC {
|
||||||
|
case let contextProvider as MediaPresentationContextProvider:
|
||||||
|
toVC.view.layoutIfNeeded()
|
||||||
|
toContextProvider = contextProvider
|
||||||
|
|
||||||
|
case let navController as UINavigationController:
|
||||||
|
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
|
||||||
|
transitionContext.completeTransition(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toVC.view.layoutIfNeeded()
|
||||||
|
toContextProvider = contextProvider
|
||||||
|
|
||||||
|
default:
|
||||||
|
transitionContext.completeTransition(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let fromMediaContext: MediaPresentationContext = fromContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView) else {
|
||||||
|
transitionContext.completeTransition(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let presentationImage: UIImage = mediaItem.image else {
|
||||||
|
transitionContext.completeTransition(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// fromView will be nil if doing a presentation, in which case we don't want to add the view -
|
||||||
|
// it will automatically be added to the view hierarchy, in front of the VC we're presenting from
|
||||||
|
if let fromView: UIView = transitionContext.view(forKey: .from) {
|
||||||
|
self.fromView = fromView
|
||||||
|
containerView.addSubview(fromView)
|
||||||
|
}
|
||||||
|
|
||||||
|
// toView will be nil if doing a modal dismiss, in which case we don't want to add the view -
|
||||||
|
// it's already in the view hierarchy, behind the VC we're dismissing.
|
||||||
|
if let toView: UIView = transitionContext.view(forKey: .to) {
|
||||||
|
containerView.insertSubview(toView, at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let toMediaContext: MediaPresentationContext? = toContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView)
|
||||||
|
let duration: CGFloat = transitionDuration(using: transitionContext)
|
||||||
|
|
||||||
|
fromMediaContext.mediaView.alpha = 0
|
||||||
|
toMediaContext?.mediaView.alpha = 0
|
||||||
|
|
||||||
|
let transitionView = UIImageView(image: presentationImage)
|
||||||
|
transitionView.frame = fromMediaContext.presentationFrame
|
||||||
|
transitionView.contentMode = MediaView.contentMode
|
||||||
|
transitionView.layer.masksToBounds = true
|
||||||
|
transitionView.layer.cornerRadius = fromMediaContext.cornerRadius
|
||||||
|
transitionView.layer.maskedCorners = (toMediaContext?.cornerMask ?? fromMediaContext.cornerMask)
|
||||||
|
containerView.addSubview(transitionView)
|
||||||
|
|
||||||
|
// Add any UI elements which should appear above the media view
|
||||||
|
self.fromTransitionalOverlayView = {
|
||||||
|
guard let (overlayView, overlayViewFrame) = fromContextProvider.snapshotOverlayView(in: containerView) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayView.frame = overlayViewFrame
|
||||||
|
containerView.addSubview(overlayView)
|
||||||
|
|
||||||
|
return overlayView
|
||||||
|
}()
|
||||||
|
self.toTransitionalOverlayView = { [weak self] in
|
||||||
|
guard let (overlayView, overlayViewFrame) = toContextProvider.snapshotOverlayView(in: containerView) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fade in the 'toTransitionalOverlayView' if it's bigger than the origin
|
||||||
|
// one (makes it look cleaner as you don't get the crossfade effect)
|
||||||
|
if (self?.fromTransitionalOverlayView?.frame.size.height ?? 0) > overlayViewFrame.height {
|
||||||
|
overlayView.alpha = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayView.frame = overlayViewFrame
|
||||||
|
|
||||||
|
if let fromTransitionalOverlayView = self?.fromTransitionalOverlayView {
|
||||||
|
containerView.insertSubview(overlayView, belowSubview: fromTransitionalOverlayView)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
containerView.addSubview(overlayView)
|
||||||
|
}
|
||||||
|
|
||||||
|
return overlayView
|
||||||
|
}()
|
||||||
|
|
||||||
|
self.transitionView = transitionView
|
||||||
|
self.fromMediaFrame = transitionView.frame
|
||||||
|
|
||||||
|
self.pendingCompletion = {
|
||||||
|
let destinationFromAlpha: CGFloat
|
||||||
|
let destinationFrame: CGRect
|
||||||
|
let destinationCornerRadius: CGFloat
|
||||||
|
|
||||||
|
if transitionContext.transitionWasCancelled {
|
||||||
|
destinationFromAlpha = 1
|
||||||
|
destinationFrame = fromMediaContext.presentationFrame
|
||||||
|
destinationCornerRadius = fromMediaContext.cornerRadius
|
||||||
|
}
|
||||||
|
else if let toMediaContext: MediaPresentationContext = toMediaContext {
|
||||||
|
destinationFromAlpha = 0
|
||||||
|
destinationFrame = toMediaContext.presentationFrame
|
||||||
|
destinationCornerRadius = toMediaContext.cornerRadius
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// `toMediaContext` can be nil if the target item is scrolled off of the
|
||||||
|
// contextProvider's screen, so we synthesize a context to dismiss the item
|
||||||
|
// off screen
|
||||||
|
destinationFromAlpha = 0
|
||||||
|
destinationFrame = fromMediaContext.presentationFrame
|
||||||
|
.offsetBy(dx: 0, dy: (containerView.bounds.height * 2))
|
||||||
|
destinationCornerRadius = fromMediaContext.cornerRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
UIView.animate(
|
||||||
|
withDuration: duration,
|
||||||
|
delay: 0,
|
||||||
|
options: [.beginFromCurrentState, .curveEaseInOut],
|
||||||
|
animations: { [weak self] in
|
||||||
|
self?.fromTransitionalOverlayView?.alpha = destinationFromAlpha
|
||||||
|
self?.fromView?.alpha = destinationFromAlpha
|
||||||
|
self?.toTransitionalOverlayView?.alpha = (1.0 - destinationFromAlpha)
|
||||||
|
transitionView.frame = destinationFrame
|
||||||
|
transitionView.layer.cornerRadius = destinationCornerRadius
|
||||||
|
},
|
||||||
|
completion: { [weak self] _ in
|
||||||
|
self?.fromView?.alpha = 1
|
||||||
|
fromMediaContext.mediaView.alpha = 1
|
||||||
|
toMediaContext?.mediaView.alpha = 1
|
||||||
|
transitionView.removeFromSuperview()
|
||||||
|
self?.fromTransitionalOverlayView?.removeFromSuperview()
|
||||||
|
self?.toTransitionalOverlayView?.removeFromSuperview()
|
||||||
|
|
||||||
|
if transitionContext.transitionWasCancelled {
|
||||||
|
// The "to" view will be nil if we're doing a modal dismiss, in which case
|
||||||
|
// we wouldn't want to remove the toView.
|
||||||
|
transitionContext.view(forKey: .to)?.removeFromSuperview()
|
||||||
|
|
||||||
|
// Note: We shouldn't need to do this but for some reason it's not
|
||||||
|
// automatically getting re-enabled so we manually enable it
|
||||||
|
transitionContext.view(forKey: .from)?.isUserInteractionEnabled = true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
transitionContext.view(forKey: .from)?.removeFromSuperview()
|
||||||
|
|
||||||
|
// Note: We shouldn't need to do this but for some reason it's not
|
||||||
|
// automatically getting re-enabled so we manually enable it
|
||||||
|
transitionContext.view(forKey: .to)?.isUserInteractionEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The interactive transition will call the 'pendingCompletion' when it completes so don't call it here
|
||||||
|
guard !transitionContext.isInteractive else { return }
|
||||||
|
|
||||||
|
self.pendingCompletion?()
|
||||||
|
self.pendingCompletion = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MediaDismissAnimationController: InteractiveDismissDelegate {
|
||||||
|
func interactiveDismissUpdate(_ interactiveDismiss: UIPercentDrivenInteractiveTransition, didChangeTouchOffset offset: CGPoint) {
|
||||||
|
guard let transitionView: UIView = transitionView else { return } // Transition hasn't started yet
|
||||||
|
guard let fromMediaFrame: CGRect = fromMediaFrame else { return }
|
||||||
|
|
||||||
|
fromView?.alpha = (1.0 - interactiveDismiss.percentComplete)
|
||||||
|
transitionView.center = fromMediaFrame.offsetBy(dx: offset.x, dy: offset.y).center
|
||||||
|
}
|
||||||
|
|
||||||
|
func interactiveDismissDidFinish(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) {
|
||||||
|
self.pendingCompletion?()
|
||||||
|
self.pendingCompletion = nil
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue