Merge branch 'feature/database-refactor' into emoji-reacts
# Conflicts: # Session.xcodeproj/project.pbxproj # Session/Conversations/Context Menu/ContextMenuVC+Action.swift # Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift # Session/Conversations/Context Menu/ContextMenuVC.swift # Session/Conversations/ConversationVC+Interaction.swift # Session/Conversations/ConversationVC.swift # Session/Conversations/ConversationViewItem.h # Session/Conversations/ConversationViewItem.m # Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift # Session/Conversations/Message Cells/MessageCell.swift # Session/Conversations/Message Cells/VisibleMessageCell.swift # Session/Conversations/Views & Modals/BodyTextView.swift # Session/Meta/Translations/en.lproj/Localizable.strings # Session/Shared/UserCell.swift # SessionMessagingKit/Jobs/MessageSendJob.swift # SessionMessagingKit/Messages/Signal/TSMessage.h # SessionMessagingKit/Messages/Signal/TSMessage.m # SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift # SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift # SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift # SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.h # SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift # SessionMessagingKit/Utilities/General.swift # SessionNotificationServiceExtension/NSENotificationPresenter.swift # SignalUtilitiesKit/Utilities/DisplayableText.swift # SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift # SignalUtilitiesKit/Utilities/Notification+Loki.swift
This commit is contained in:
commit
c25f378c54
44
Podfile
44
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'
|
||||||
|
@ -19,13 +24,13 @@ abstract_target 'GlobalDependencies' do
|
||||||
pod 'PureLayout', '~> 3.1.8'
|
pod 'PureLayout', '~> 3.1.8'
|
||||||
pod 'NVActivityIndicatorView'
|
pod 'NVActivityIndicatorView'
|
||||||
pod 'YYImage', git: 'https://github.com/signalapp/YYImage'
|
pod 'YYImage', 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
|
||||||
|
@ -47,16 +52,32 @@ abstract_target 'GlobalDependencies' do
|
||||||
pod 'SAMKeychain'
|
pod 'SAMKeychain'
|
||||||
pod 'SwiftProtobuf', '~> 1.5.0'
|
pod 'SwiftProtobuf', '~> 1.5.0'
|
||||||
pod 'YYImage', git: 'https://github.com/signalapp/YYImage'
|
pod 'YYImage', 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'
|
||||||
|
|
||||||
|
target 'SessionUtilitiesKitTests' do
|
||||||
|
inherit! :complete
|
||||||
|
|
||||||
|
pod 'Quick'
|
||||||
|
pod 'Nimble'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -69,6 +90,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 +107,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
|
||||||
|
|
58
Podfile.lock
58
Podfile.lock
|
@ -21,9 +21,15 @@ 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.24.1):
|
||||||
|
- SQLCipher (>= 3.4.0)
|
||||||
|
- 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 +44,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):
|
||||||
|
@ -124,16 +131,20 @@ 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`)
|
||||||
|
@ -145,14 +156,17 @@ SPEC REPOS:
|
||||||
- AFNetworking
|
- AFNetworking
|
||||||
- CocoaLumberjack
|
- CocoaLumberjack
|
||||||
- CryptoSwift
|
- CryptoSwift
|
||||||
|
- DifferenceKit
|
||||||
|
- GRDB.swift
|
||||||
|
- 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 +174,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 +190,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 +210,19 @@ SPEC CHECKSUMS:
|
||||||
CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646
|
CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646
|
||||||
CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17
|
CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17
|
||||||
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
|
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
|
||||||
Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b
|
DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805
|
||||||
|
GRDB.swift: b3180ce2135fc06a453297889b746b1478c4d8c7
|
||||||
|
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 +230,6 @@ SPEC CHECKSUMS:
|
||||||
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
|
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
|
||||||
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
||||||
|
|
||||||
PODFILE CHECKSUM: a3d89a6cc8735285fd51348ca05cea71f2c28872
|
PODFILE CHECKSUM: 6ab902a81a379cc2c0a9a92c334c78d413190338
|
||||||
|
|
||||||
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,111 @@ 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 { 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 +254,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 +339,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 +386,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,37 @@ 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 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 +94,59 @@ 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 {
|
} else {
|
||||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
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 +160,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,101 +1,191 @@
|
||||||
|
// 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 isEmojiAction: Bool
|
||||||
|
let isEmojiPlus: Bool
|
||||||
|
let isDismissAction: Bool
|
||||||
let work: () -> Void
|
let work: () -> Void
|
||||||
|
|
||||||
static func reply(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
// MARK: - Initialization
|
||||||
let title = NSLocalizedString("context_menu_reply", comment: "")
|
|
||||||
return Action(icon: UIImage(named: "ic_reply")!, title: title) { delegate?.reply(viewItem) }
|
init(
|
||||||
}
|
icon: UIImage? = nil,
|
||||||
|
title: String = "",
|
||||||
static func copy(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
isEmojiAction: Bool = false,
|
||||||
let title = NSLocalizedString("copy", comment: "")
|
isEmojiPlus: Bool = false,
|
||||||
return Action(icon: UIImage(named: "ic_copy")!, title: title) { delegate?.copy(viewItem) }
|
isDismissAction: Bool = false,
|
||||||
}
|
work: @escaping () -> Void
|
||||||
|
) {
|
||||||
static func copySessionID(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
self.icon = icon
|
||||||
let title = NSLocalizedString("vc_conversation_settings_copy_session_id_button_title", comment: "")
|
self.title = title
|
||||||
return Action(icon: UIImage(named: "ic_copy")!, title: title) { delegate?.copySessionID(viewItem) }
|
self.isEmojiAction = isEmojiAction
|
||||||
}
|
self.isEmojiPlus = isEmojiPlus
|
||||||
|
self.isDismissAction = isDismissAction
|
||||||
static func delete(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
self.work = work
|
||||||
let title = NSLocalizedString("TXT_DELETE_TITLE", comment: "")
|
|
||||||
return Action(icon: UIImage(named: "ic_trash")!, title: title) { delegate?.delete(viewItem) }
|
|
||||||
}
|
|
||||||
|
|
||||||
static func save(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
|
||||||
let title = NSLocalizedString("context_menu_save", comment: "")
|
|
||||||
return Action(icon: UIImage(named: "ic_download")!, title: title) { delegate?.save(viewItem) }
|
|
||||||
}
|
|
||||||
|
|
||||||
static func ban(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
|
||||||
let title = NSLocalizedString("context_menu_ban_user", comment: "")
|
|
||||||
return Action(icon: UIImage(named: "ic_block")!, title: title) { delegate?.ban(viewItem) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func banAndDeleteAllMessages(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
// MARK: - Actions
|
||||||
let title = NSLocalizedString("context_menu_ban_and_delete_all", comment: "")
|
|
||||||
return Action(icon: UIImage(named: "ic_block")!, title: title) { delegate?.banAndDeleteAllMessages(viewItem) }
|
static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
|
return Action(
|
||||||
|
icon: UIImage(named: "ic_reply"),
|
||||||
|
title: "context_menu_reply".localized()
|
||||||
|
) { delegate?.reply(cellViewModel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
|
return Action(
|
||||||
|
icon: UIImage(named: "ic_copy"),
|
||||||
|
title: "copy".localized()
|
||||||
|
) { delegate?.copy(cellViewModel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
|
return Action(
|
||||||
|
icon: UIImage(named: "ic_copy"),
|
||||||
|
title: "vc_conversation_settings_copy_session_id_button_title".localized()
|
||||||
|
) { delegate?.copySessionID(cellViewModel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
|
return Action(
|
||||||
|
icon: UIImage(named: "ic_trash"),
|
||||||
|
title: "TXT_DELETE_TITLE".localized()
|
||||||
|
) { delegate?.delete(cellViewModel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
|
return Action(
|
||||||
|
icon: UIImage(named: "ic_download"),
|
||||||
|
title: "context_menu_save".localized()
|
||||||
|
) { delegate?.save(cellViewModel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
|
return Action(
|
||||||
|
icon: UIImage(named: "ic_block"),
|
||||||
|
title: "context_menu_ban_user".localized()
|
||||||
|
) { delegate?.ban(cellViewModel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
|
return Action(
|
||||||
|
icon: UIImage(named: "ic_block"),
|
||||||
|
title: "context_menu_ban_and_delete_all".localized()
|
||||||
|
) { delegate?.banAndDeleteAllMessages(cellViewModel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func react(_ cellViewModel: MessageViewModel, _ emoji: EmojiWithSkinTones, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
|
return Action(
|
||||||
|
title: emoji.rawValue,
|
||||||
|
isEmojiAction: true
|
||||||
|
) { delegate?.react(cellViewModel, with: emoji) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func emojiPlusButton(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
|
return Action(
|
||||||
|
isEmojiPlus: true
|
||||||
|
) { delegate?.showFullEmojiKeyboard(cellViewModel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dismiss(_ delegate: ContextMenuActionDelegate?) -> Action {
|
||||||
|
return Action(
|
||||||
|
isDismissAction: true
|
||||||
|
) { delegate?.contextMenuDismissed() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func actions(for viewItem: ConversationViewItem, delegate: ContextMenuActionDelegate?) -> [Action] {
|
static func actions(
|
||||||
func isReplyingAllowed() -> Bool {
|
for cellViewModel: MessageViewModel,
|
||||||
guard let message = viewItem.interaction as? TSOutgoingMessage else { return true }
|
recentEmojis: [EmojiWithSkinTones],
|
||||||
switch message.messageState {
|
currentUserIsOpenGroupModerator: Bool,
|
||||||
case .failed, .sending: return false
|
delegate: ContextMenuActionDelegate?
|
||||||
default: return true
|
) -> [Action]? {
|
||||||
}
|
// No context items for info messages
|
||||||
}
|
guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else {
|
||||||
switch viewItem.messageCellType {
|
return nil
|
||||||
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),
|
||||||
|
]
|
||||||
|
.appending(contentsOf: recentEmojis.map { Action.react(cellViewModel, $0, delegate) })
|
||||||
|
.appending(Action.emojiPlusButton(cellViewModel, delegate))
|
||||||
|
.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 react(_ viewItem: ConversationViewItem, with emoji: EmojiWithSkinTones)
|
func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones)
|
||||||
func showFullEmojiKeyboard(_ viewItem: ConversationViewItem)
|
func showFullEmojiKeyboard(_ 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,33 +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))!.withRenderingMode(.alwaysTemplate))
|
let iconImageView: UIImageView = UIImageView(
|
||||||
iconImageView.tintColor = Colors.text
|
image: action.icon?
|
||||||
let iconImageViewSize = ActionView.iconImageViewSize
|
.resizedImage(to: CGSize(width: iconSize, height: iconSize))?
|
||||||
iconImageView.set(.width, to: iconImageViewSize)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
iconImageView.set(.height, to: iconImageViewSize)
|
)
|
||||||
|
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,20 +1,26 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
extension ContextMenuVC {
|
import UIKit
|
||||||
|
import SessionUIKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
|
extension ContextMenuVC {
|
||||||
final class EmojiReactsView: UIView {
|
final class EmojiReactsView: UIView {
|
||||||
private let emoji: EmojiWithSkinTones
|
private let action: Action
|
||||||
private let dismiss: () -> Void
|
private let dismiss: () -> Void
|
||||||
private let work: () -> Void
|
|
||||||
|
|
||||||
// MARK: Settings
|
// MARK: - Settings
|
||||||
|
|
||||||
private static let size: CGFloat = 40
|
private static let size: CGFloat = 40
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
init(for emoji: EmojiWithSkinTones, dismiss: @escaping () -> Void, work: @escaping () -> Void) {
|
|
||||||
self.emoji = emoji
|
init(for action: Action, dismiss: @escaping () -> Void) {
|
||||||
|
self.action = action
|
||||||
self.dismiss = dismiss
|
self.dismiss = dismiss
|
||||||
self.work = work
|
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,36 +34,42 @@ extension ContextMenuVC {
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy() {
|
||||||
let emojiLabel = UILabel()
|
let emojiLabel = UILabel()
|
||||||
emojiLabel.text = self.emoji.rawValue
|
emojiLabel.text = self.action.title
|
||||||
emojiLabel.font = .systemFont(ofSize: Values.veryLargeFontSize)
|
emojiLabel.font = .systemFont(ofSize: Values.veryLargeFontSize)
|
||||||
emojiLabel.set(.height, to: ContextMenuVC.EmojiReactsView.size)
|
emojiLabel.set(.height, to: ContextMenuVC.EmojiReactsView.size)
|
||||||
addSubview(emojiLabel)
|
addSubview(emojiLabel)
|
||||||
emojiLabel.pin(to: self)
|
emojiLabel.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() {
|
||||||
work()
|
action.work()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class EmojiPlusButton: UIView {
|
final class EmojiPlusButton: UIView {
|
||||||
|
private let action: Action?
|
||||||
private let dismiss: () -> Void
|
private let dismiss: () -> Void
|
||||||
private let work: () -> Void
|
|
||||||
|
|
||||||
// MARK: Settings
|
// MARK: - Settings
|
||||||
|
|
||||||
public static let size: CGFloat = 28
|
public static let size: CGFloat = 28
|
||||||
private let iconSize: CGFloat = 14
|
private let iconSize: CGFloat = 14
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
init(dismiss: @escaping () -> Void, work: @escaping () -> Void) {
|
|
||||||
|
init(action: Action?, dismiss: @escaping () -> Void) {
|
||||||
|
self.action = action
|
||||||
self.dismiss = dismiss
|
self.dismiss = dismiss
|
||||||
self.work = work
|
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -78,22 +90,24 @@ extension ContextMenuVC {
|
||||||
iconImageView.contentMode = .scaleAspectFit
|
iconImageView.contentMode = .scaleAspectFit
|
||||||
addSubview(iconImageView)
|
addSubview(iconImageView)
|
||||||
iconImageView.center(in: self)
|
iconImageView.center(in: self)
|
||||||
|
|
||||||
// Background
|
// Background
|
||||||
isUserInteractionEnabled = true
|
isUserInteractionEnabled = true
|
||||||
backgroundColor = Colors.sessionEmojiPlusButtonBackground
|
backgroundColor = Colors.sessionEmojiPlusButtonBackground
|
||||||
|
|
||||||
// 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() {
|
||||||
dismiss()
|
dismiss()
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: { [weak self] in
|
|
||||||
self?.work()
|
|
||||||
})
|
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: { [weak self] in
|
||||||
|
self?.action?.work()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,22 @@
|
||||||
import CoreGraphics
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import SessionUIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
final class ContextMenuVC : UIViewController {
|
final class ContextMenuVC: UIViewController {
|
||||||
private let snapshot: UIView
|
private static let actionViewHeight: CGFloat = 40
|
||||||
private let viewItem: ConversationViewItem
|
private static let menuCornerRadius: CGFloat = 8
|
||||||
private let frame: CGRect
|
|
||||||
private let dismiss: () -> Void
|
|
||||||
private weak var delegate: ContextMenuActionDelegate?
|
|
||||||
|
|
||||||
private var recentEmoji: [EmojiWithSkinTones] = []
|
private let snapshot: UIView
|
||||||
|
private let frame: CGRect
|
||||||
// MARK: UI Components
|
private let cellViewModel: MessageViewModel
|
||||||
private lazy var blurView = UIVisualEffectView(effect: nil)
|
private let actions: [Action]
|
||||||
|
private let dismiss: () -> Void
|
||||||
|
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
|
private lazy var blurView: UIVisualEffectView = UIVisualEffectView(effect: nil)
|
||||||
|
|
||||||
private lazy var emojiBar: UIView = {
|
private lazy var emojiBar: UIView = {
|
||||||
let result = UIView()
|
let result = UIView()
|
||||||
|
@ -20,51 +25,61 @@ final class ContextMenuVC : UIViewController {
|
||||||
result.layer.shadowOpacity = 0.4
|
result.layer.shadowOpacity = 0.4
|
||||||
result.layer.shadowRadius = 4
|
result.layer.shadowRadius = 4
|
||||||
result.set(.height, to: ContextMenuVC.actionViewHeight)
|
result.set(.height, to: ContextMenuVC.actionViewHeight)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var emojiPlusButton: EmojiPlusButton = {
|
private lazy var emojiPlusButton: EmojiPlusButton = {
|
||||||
let result = EmojiPlusButton(dismiss: snDismiss) { self.delegate?.showFullEmojiKeyboard(self.viewItem) }
|
let result = EmojiPlusButton(
|
||||||
|
action: self.actions.first(where: { $0.isEmojiPlus }),
|
||||||
|
dismiss: snDismiss
|
||||||
|
)
|
||||||
result.set(.width, to: EmojiPlusButton.size)
|
result.set(.width, to: EmojiPlusButton.size)
|
||||||
result.set(.height, to: EmojiPlusButton.size)
|
result.set(.height, to: EmojiPlusButton.size)
|
||||||
result.layer.cornerRadius = EmojiPlusButton.size / 2
|
result.layer.cornerRadius = EmojiPlusButton.size / 2
|
||||||
result.layer.masksToBounds = true
|
result.layer.masksToBounds = true
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
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)
|
||||||
Storage.read { transaction in
|
|
||||||
self.recentEmoji = Array(Storage.shared.getRecentEmoji(withDefaultEmoji: true, transaction: transaction)[...5])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(nibName: String?, bundle: Bundle?) {
|
override init(nibName: String?, bundle: Bundle?) {
|
||||||
|
@ -74,29 +89,37 @@ 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)
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emoji reacts
|
// Emoji reacts
|
||||||
let emojiBarBackgroundView = UIView()
|
let emojiBarBackgroundView = UIView()
|
||||||
emojiBarBackgroundView.backgroundColor = Colors.receivedMessageBackground
|
emojiBarBackgroundView.backgroundColor = Colors.receivedMessageBackground
|
||||||
|
@ -109,12 +132,11 @@ final class ContextMenuVC : UIViewController {
|
||||||
emojiPlusButton.pin(.right, to: .right, of: emojiBar, withInset: -Values.smallSpacing)
|
emojiPlusButton.pin(.right, to: .right, of: emojiBar, withInset: -Values.smallSpacing)
|
||||||
emojiPlusButton.center(.vertical, in: emojiBar)
|
emojiPlusButton.center(.vertical, in: emojiBar)
|
||||||
|
|
||||||
let emojiLabels = recentEmoji.map { emoji -> EmojiReactsView in
|
let emojiBarStackView = UIStackView(
|
||||||
EmojiReactsView(for: emoji, dismiss: snDismiss) {
|
arrangedSubviews: actions
|
||||||
self.delegate?.react(self.viewItem, with: emoji)
|
.filter { $0.isEmojiAction }
|
||||||
}
|
.map { action -> EmojiReactsView in EmojiReactsView(for: action, dismiss: snDismiss) }
|
||||||
}
|
)
|
||||||
let emojiBarStackView = UIStackView(arrangedSubviews: emojiLabels)
|
|
||||||
emojiBarStackView.axis = .horizontal
|
emojiBarStackView.axis = .horizontal
|
||||||
emojiBarStackView.spacing = Values.smallSpacing
|
emojiBarStackView.spacing = Values.smallSpacing
|
||||||
emojiBarStackView.layoutMargins = UIEdgeInsets(top: 0, left: Values.smallSpacing, bottom: 0, right: Values.smallSpacing)
|
emojiBarStackView.layoutMargins = UIEdgeInsets(top: 0, left: Values.smallSpacing, bottom: 0, right: Values.smallSpacing)
|
||||||
|
@ -123,7 +145,10 @@ final class ContextMenuVC : UIViewController {
|
||||||
emojiBarStackView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: emojiBar)
|
emojiBarStackView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: emojiBar)
|
||||||
emojiBarStackView.pin(.right, to: .left, of: emojiPlusButton)
|
emojiBarStackView.pin(.right, to: .left, of: emojiPlusButton)
|
||||||
|
|
||||||
|
// Hide the emoji bar if we have no emoji actions
|
||||||
|
emojiBar.isHidden = emojiBarStackView.arrangedSubviews.isEmpty
|
||||||
view.addSubview(emojiBar)
|
view.addSubview(emojiBar)
|
||||||
|
|
||||||
// Menu
|
// Menu
|
||||||
let menuBackgroundView = UIView()
|
let menuBackgroundView = UIView()
|
||||||
menuBackgroundView.backgroundColor = Colors.receivedMessageBackground
|
menuBackgroundView.backgroundColor = Colors.receivedMessageBackground
|
||||||
|
@ -131,31 +156,41 @@ 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.isEmojiAction && !$0.isEmojiPlus && !$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)
|
||||||
|
|
||||||
// Constrains
|
// Constrains
|
||||||
let menuHeight = CGFloat(actionViews.count) * ContextMenuVC.actionViewHeight
|
let menuHeight: CGFloat = CGFloat(menuStackView.arrangedSubviews.count) * ContextMenuVC.actionViewHeight
|
||||||
let spacing = Values.smallSpacing
|
let spacing: CGFloat = Values.smallSpacing
|
||||||
let frame = calculateFrame(menuHeight: menuHeight, spacing: spacing)
|
let targetFrame: CGRect = calculateFrame(menuHeight: menuHeight, spacing: spacing)
|
||||||
snapshot.pin(.left, to: .left, of: view, withInset: frame.origin.x)
|
|
||||||
snapshot.pin(.top, to: .top, of: view, withInset: frame.origin.y)
|
snapshot.pin(.left, to: .left, of: view, withInset: targetFrame.origin.x)
|
||||||
|
snapshot.pin(.top, to: .top, of: view, withInset: targetFrame.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)
|
||||||
emojiBar.pin(.bottom, to: .top, of: snapshot, withInset: -spacing)
|
emojiBar.pin(.bottom, to: .top, of: snapshot, withInset: -spacing)
|
||||||
menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing)
|
menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing)
|
||||||
switch viewItem.interaction.interactionType() {
|
|
||||||
case .outgoingMessage:
|
switch cellViewModel.variant {
|
||||||
menuView.pin(.right, to: .right, of: snapshot)
|
case .standardOutgoing:
|
||||||
emojiBar.pin(.right, to: .right, of: snapshot)
|
menuView.pin(.right, to: .right, of: snapshot)
|
||||||
case .incomingMessage:
|
emojiBar.pin(.right, to: .right, of: snapshot)
|
||||||
menuView.pin(.left, to: .left, of: snapshot)
|
|
||||||
emojiBar.pin(.left, to: .left, of: snapshot)
|
case .standardIncoming:
|
||||||
default: break // Should never occur
|
menuView.pin(.left, to: .left, of: snapshot)
|
||||||
|
emojiBar.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)
|
||||||
|
@ -163,6 +198,7 @@ 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
|
||||||
|
@ -170,45 +206,65 @@ final class ContextMenuVC : UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func calculateFrame(menuHeight: CGFloat, spacing: CGFloat) -> CGRect {
|
func calculateFrame(menuHeight: CGFloat, spacing: CGFloat) -> CGRect {
|
||||||
var finalFrame = frame
|
var finalFrame: CGRect = frame
|
||||||
let ratio = frame.width / frame.height
|
let ratio: CGFloat = (frame.width / frame.height)
|
||||||
|
|
||||||
|
// FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement)
|
||||||
let topMargin = max(UIApplication.shared.keyWindow!.safeAreaInsets.top, Values.mediumSpacing)
|
let topMargin = max(UIApplication.shared.keyWindow!.safeAreaInsets.top, Values.mediumSpacing)
|
||||||
let bottomMargin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing)
|
let bottomMargin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing)
|
||||||
let diffY = finalFrame.height + menuHeight + Self.actionViewHeight + 2 * spacing + topMargin + bottomMargin - UIScreen.main.bounds.height
|
let diffY = finalFrame.height + menuHeight + Self.actionViewHeight + 2 * spacing + topMargin + bottomMargin - UIScreen.main.bounds.height
|
||||||
|
|
||||||
if diffY > 0 {
|
if diffY > 0 {
|
||||||
finalFrame.size.height -= diffY
|
finalFrame.size.height -= diffY
|
||||||
let newWidth = ratio * finalFrame.size.height
|
let newWidth = ratio * finalFrame.size.height
|
||||||
if viewItem.interaction.interactionType() == .outgoingMessage {
|
if cellViewModel.variant == .standardOutgoing {
|
||||||
finalFrame.origin.x += finalFrame.size.width - newWidth
|
finalFrame.origin.x += finalFrame.size.width - newWidth
|
||||||
}
|
}
|
||||||
finalFrame.size.width = newWidth
|
finalFrame.size.width = newWidth
|
||||||
finalFrame.origin.y = UIScreen.main.bounds.height - finalFrame.size.height - menuHeight - bottomMargin - spacing
|
finalFrame.origin.y = UIScreen.main.bounds.height - finalFrame.size.height - menuHeight - bottomMargin - spacing
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
finalFrame.origin.y = (UIScreen.main.bounds.height - finalFrame.size.height) / 2
|
finalFrame.origin.y = (UIScreen.main.bounds.height - finalFrame.size.height) / 2
|
||||||
}
|
}
|
||||||
|
|
||||||
return finalFrame
|
return finalFrame
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
|
||||||
emojiBar.layer.shadowPath = UIBezierPath(roundedRect: emojiBar.bounds, cornerRadius: ContextMenuVC.actionViewHeight / 2).cgPath
|
menuView.layer.shadowPath = UIBezierPath(
|
||||||
|
roundedRect: menuView.bounds,
|
||||||
|
cornerRadius: ContextMenuVC.menuCornerRadius
|
||||||
|
).cgPath
|
||||||
|
emojiBar.layer.shadowPath = UIBezierPath(
|
||||||
|
roundedRect: emojiBar.bounds,
|
||||||
|
cornerRadius: (ContextMenuVC.actionViewHeight / 2)
|
||||||
|
).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?.emojiBar.alpha = 0
|
||||||
self.delegate?.contextMenuDismissed()
|
self?.snapshot.alpha = 0
|
||||||
})
|
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,167 +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;
|
|
||||||
|
|
||||||
@property (nonatomic) BOOL reactionShouldExpanded;
|
|
||||||
|
|
||||||
// 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,719 @@
|
||||||
|
// 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)
|
||||||
|
let recentReactionEmoji: [String] = try Emoji.getRecent(db, withDefaultEmoji: true)
|
||||||
|
let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
||||||
|
.conversationQuery(threadId: threadId, userPublicKey: userPublicKey)
|
||||||
|
.fetchOne(db)
|
||||||
|
|
||||||
|
return threadViewModel
|
||||||
|
.map { $0.with(recentReactionEmoji: recentReactionEmoji) }
|
||||||
|
}
|
||||||
|
.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 reactionExpandedInteractionIds: Set<Int64> = []
|
||||||
|
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.ReactionInfo, MessageViewModel>(
|
||||||
|
trackedAgainst: Reaction.self,
|
||||||
|
observedChanges: [
|
||||||
|
PagedData.ObservedChanges(
|
||||||
|
table: Reaction.self,
|
||||||
|
columns: [.count]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
dataQuery: MessageViewModel.ReactionInfo.baseQuery,
|
||||||
|
joinToPagedType: MessageViewModel.ReactionInfo.joinToViewModelQuerySQL,
|
||||||
|
associateData: MessageViewModel.ReactionInfo.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
|
||||||
|
}
|
||||||
|
|
||||||
|
public func expandReactions(for interactionId: Int64) {
|
||||||
|
reactionExpandedInteractionIds.insert(interactionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func collapseReactions(for interactionId: Int64) {
|
||||||
|
reactionExpandedInteractionIds.remove(interactionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
Storage.shared.writeAsync { db in
|
||||||
|
try SessionThread
|
||||||
|
.filter(id: self.threadId)
|
||||||
|
.updateAll(db, SessionThread.Columns.messageDraft.set(to: draft))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func markAllAsRead() {
|
||||||
|
guard 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,10 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
protocol EmojiPickerCollectionViewDelegate: AnyObject {
|
protocol EmojiPickerCollectionViewDelegate: AnyObject {
|
||||||
func emojiPicker(_ emojiPicker: EmojiPickerCollectionView, didSelectEmoji emoji: EmojiWithSkinTones)
|
func emojiPicker(_ emojiPicker: EmojiPickerCollectionView?, didSelectEmoji emoji: EmojiWithSkinTones)
|
||||||
func emojiPickerWillBeginDragging(_ emojiPicker: EmojiPickerCollectionView)
|
func emojiPickerWillBeginDragging(_ emojiPicker: EmojiPickerCollectionView)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,6 +42,8 @@ class EmojiPickerCollectionView: UICollectionView {
|
||||||
}
|
}
|
||||||
|
|
||||||
lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissSkinTonePicker))
|
lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissSkinTonePicker))
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
layout = UICollectionViewFlowLayout()
|
layout = UICollectionViewFlowLayout()
|
||||||
|
@ -66,24 +72,31 @@ class EmojiPickerCollectionView: UICollectionView {
|
||||||
addGestureRecognizer(tapGestureRecognizer)
|
addGestureRecognizer(tapGestureRecognizer)
|
||||||
tapGestureRecognizer.delegate = self
|
tapGestureRecognizer.delegate = self
|
||||||
|
|
||||||
Storage.read { transaction in
|
// Fetch the emoji data from the database
|
||||||
self.recentEmoji = Storage.shared.getRecentEmoji(withDefaultEmoji: false, transaction: transaction)
|
let maybeEmojiData: (recent: [EmojiWithSkinTones], allGrouped: [Emoji.Category: [EmojiWithSkinTones]])? = Storage.shared.read { db in
|
||||||
|
|
||||||
// Some emoji have two different code points but identical appearances. Let's remove them!
|
// Some emoji have two different code points but identical appearances. Let's remove them!
|
||||||
// If we normalize to a different emoji than the one currently in our array, we want to drop
|
// If we normalize to a different emoji than the one currently in our array, we want to drop
|
||||||
// the non-normalized variant if the normalized variant already exists. Otherwise, map to the
|
// the non-normalized variant if the normalized variant already exists. Otherwise, map to the
|
||||||
// normalized variant.
|
// normalized variant.
|
||||||
for (idx, emoji) in self.recentEmoji.enumerated().reversed() {
|
let recentEmoji: [EmojiWithSkinTones] = try Emoji.getRecent(db, withDefaultEmoji: false)
|
||||||
if !emoji.isNormalized {
|
.compactMap { EmojiWithSkinTones(rawValue: $0) }
|
||||||
if self.recentEmoji.contains(emoji.normalized) {
|
.reduce(into: [EmojiWithSkinTones]()) { result, emoji in
|
||||||
self.recentEmoji.remove(at: idx)
|
guard !emoji.isNormalized else {
|
||||||
} else {
|
result.append(emoji)
|
||||||
self.recentEmoji[idx] = emoji.normalized
|
return
|
||||||
}
|
}
|
||||||
|
guard !result.contains(emoji.normalized) else { return }
|
||||||
|
|
||||||
|
result.append(emoji.normalized)
|
||||||
}
|
}
|
||||||
}
|
let allSendableEmojiByCategory: [Emoji.Category: [EmojiWithSkinTones]] = Emoji.allSendableEmojiByCategoryWithPreferredSkinTones(db)
|
||||||
|
|
||||||
self.allSendableEmojiByCategory = Emoji.allSendableEmojiByCategoryWithPreferredSkinTones(transaction: transaction)
|
return (recentEmoji, allSendableEmojiByCategory)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let emojiData: (recent: [EmojiWithSkinTones], allGrouped: [Emoji.Category: [EmojiWithSkinTones]]) = maybeEmojiData {
|
||||||
|
self.recentEmoji = emojiData.recent
|
||||||
|
self.allSendableEmojiByCategory = emojiData.allGrouped
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,7 +105,9 @@ class EmojiPickerCollectionView: UICollectionView {
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is not an exact calculation, but is simple and works for our purposes.
|
// This is not an exact calculation, but is simple and works for our purposes.
|
||||||
var numberOfColumns: Int { Int((self.width()) / (EmojiPickerCollectionView.emojiWidth + EmojiPickerCollectionView.minimumSpacing)) }
|
var numberOfColumns: Int {
|
||||||
|
Int((self.width()) / (EmojiPickerCollectionView.emojiWidth + EmojiPickerCollectionView.minimumSpacing))
|
||||||
|
}
|
||||||
|
|
||||||
// At max, we show 3 rows of recent emoji
|
// At max, we show 3 rows of recent emoji
|
||||||
private var maxRecentEmoji: Int { numberOfColumns * 3 }
|
private var maxRecentEmoji: Int { numberOfColumns * 3 }
|
||||||
|
@ -170,19 +185,19 @@ class EmojiPickerCollectionView: UICollectionView {
|
||||||
|
|
||||||
currentSkinTonePicker?.dismiss()
|
currentSkinTonePicker?.dismiss()
|
||||||
currentSkinTonePicker = EmojiSkinTonePicker.present(referenceView: cell, emoji: emoji) { [weak self] emoji in
|
currentSkinTonePicker = EmojiSkinTonePicker.present(referenceView: cell, emoji: emoji) { [weak self] emoji in
|
||||||
guard let self = self else { return }
|
if let emoji: EmojiWithSkinTones = emoji {
|
||||||
|
Storage.shared.writeAsync { db in
|
||||||
if let emoji = emoji {
|
emoji.baseEmoji.setPreferredSkinTones(
|
||||||
Storage.write { transaction in
|
db,
|
||||||
Storage.shared.recordRecentEmoji(emoji, transaction: transaction)
|
preferredSkinTonePermutation: emoji.skinTones
|
||||||
emoji.baseEmoji.setPreferredSkinTones(emoji.skinTones, transaction: transaction)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.pickerDelegate?.emojiPicker(self, didSelectEmoji: emoji)
|
self?.pickerDelegate?.emojiPicker(self, didSelectEmoji: emoji)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.currentSkinTonePicker?.dismiss()
|
self?.currentSkinTonePicker?.dismiss()
|
||||||
self.currentSkinTonePicker = nil
|
self?.currentSkinTonePicker = nil
|
||||||
}
|
}
|
||||||
case .changed:
|
case .changed:
|
||||||
currentSkinTonePicker?.didChangeLongPress(sender)
|
currentSkinTonePicker?.didChangeLongPress(sender)
|
||||||
|
@ -215,11 +230,7 @@ extension EmojiPickerCollectionView: UICollectionViewDelegate {
|
||||||
guard let emoji = emojiForIndexPath(indexPath) else {
|
guard let emoji = emojiForIndexPath(indexPath) else {
|
||||||
return owsFailDebug("Missing emoji for indexPath \(indexPath)")
|
return owsFailDebug("Missing emoji for indexPath \(indexPath)")
|
||||||
}
|
}
|
||||||
|
|
||||||
Storage.write { transaction in
|
|
||||||
Storage.shared.recordRecentEmoji(emoji, transaction: transaction)
|
|
||||||
}
|
|
||||||
|
|
||||||
pickerDelegate?.emojiPicker(self, didSelectEmoji: emoji)
|
pickerDelegate?.emojiPicker(self, didSelectEmoji: emoji)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -114,7 +114,7 @@ extension EmojiPickerSheet: EmojiPickerCollectionViewDelegate {
|
||||||
searchBar.resignFirstResponder()
|
searchBar.resignFirstResponder()
|
||||||
}
|
}
|
||||||
|
|
||||||
func emojiPicker(_ emojiPicker: EmojiPickerCollectionView, didSelectEmoji emoji: EmojiWithSkinTones) {
|
func emojiPicker(_ emojiPicker: EmojiPickerCollectionView?, didSelectEmoji emoji: EmojiWithSkinTones) {
|
||||||
completionHandler(emoji)
|
completionHandler(emoji)
|
||||||
dismiss(animated: true, completion: dismissHandler)
|
dismiss(animated: true, completion: dismissHandler)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(_ gestureRecognizer: UITapGestureRecognizer) {
|
func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||||
// 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,54 +428,57 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func tapableLabel(_ label: TappableLabel, didTapUrl url: String, atRange range: NSRange) {
|
func tapableLabel(_ label: TappableLabel, didTapUrl url: String, atRange range: NSRange) {
|
||||||
// Do nothing
|
// Do nothing
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,72 @@ 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)
|
showExpandedReactions: Bool,
|
||||||
case .missed, .permissionDenied: icon = UIImage(named: "CallMissed")?.withTint(Colors.destructive)
|
lastSearchText: String?
|
||||||
default: icon = nil
|
) {
|
||||||
}
|
guard
|
||||||
iconImageView.image = icon
|
cellViewModel.variant == .infoCall,
|
||||||
iconImageViewWidthConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0
|
let infoMessageData: Data = (cellViewModel.rawBody ?? "").data(using: .utf8),
|
||||||
iconImageViewHeightConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0
|
let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode(
|
||||||
|
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)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
|
.resizedImage(to: CGSize(width: CallMessageView.iconSize, height: CallMessageView.iconSize))
|
||||||
|
)
|
||||||
|
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))
|
.withRenderingMode(.alwaysTemplate)
|
||||||
|
.resizedImage(to: CGSize(
|
||||||
|
width: DeletedMessageView.iconSize,
|
||||||
|
height: DeletedMessageView.iconSize
|
||||||
|
))
|
||||||
|
|
||||||
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,98 +1,106 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
import NVActivityIndicatorView
|
import NVActivityIndicatorView
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
final class LinkPreviewView : UIView {
|
final class LinkPreviewView: UIView {
|
||||||
private let viewItem: ConversationViewItem?
|
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 bodyTappableLabelContainer: 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: TappableLabel?
|
var bodyTappableLabel: TappableLabel?
|
||||||
|
|
||||||
// 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)
|
||||||
|
@ -100,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, bodyTappableLabelContainer ])
|
||||||
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: TappableLabelDelegate? = 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() }
|
bodyTappableLabelContainer.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 {
|
||||||
self.bodyTextView = bodyTextView
|
let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel(
|
||||||
bodyTextViewContainer.addSubview(bodyTextView)
|
for: cellViewModel,
|
||||||
bodyTextView.pin(to: bodyTextViewContainer, withInset: 12)
|
with: maxWidth,
|
||||||
|
textColor: (bodyLabelTextColor ?? sentLinkPreviewTextColor),
|
||||||
|
searchText: lastSearchText,
|
||||||
|
delegate: delegate
|
||||||
|
)
|
||||||
|
|
||||||
|
self.bodyTappableLabel = bodyTappableLabel
|
||||||
|
bodyTappableLabelContainer.addSubview(bodyTappableLabel)
|
||||||
|
bodyTappableLabel.pin(to: bodyTappableLabelContainer, 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 : TappableLabelDelegate {
|
|
||||||
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)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
|
.resizedImage(
|
||||||
|
to: CGSize(
|
||||||
|
width: MediaPlaceholderView.iconSize,
|
||||||
|
height: MediaPlaceholderView.iconSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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,28 +125,30 @@ 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
|
||||||
|
}
|
||||||
|
guard attachment.state != .uploaded 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)
|
||||||
|
@ -187,36 +156,34 @@ public class MediaView: UIView {
|
||||||
loadBlock = { [weak self] in
|
loadBlock = { [weak self] in
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
guard let strongSelf = self else {
|
guard let strongSelf = self else { return }
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if animatedImageView.image != nil {
|
if animatedImageView.image != nil {
|
||||||
owsFailDebug("Unexpectedly already loaded.")
|
owsFailDebug("Unexpectedly already loaded.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
strongSelf.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
|
strongSelf.tryToLoadMedia(
|
||||||
guard attachmentStream.isValidImage else {
|
loadMediaBlock: { applyMediaBlock in
|
||||||
Logger.warn("Ignoring invalid attachment.")
|
guard attachment.isValid else { return }
|
||||||
return nil
|
guard let filePath: String = attachment.originalFilePath else {
|
||||||
}
|
owsFailDebug("Attachment stream missing original file path.")
|
||||||
guard let filePath = attachmentStream.originalFilePath else {
|
return
|
||||||
owsFailDebug("Attachment stream missing original file path.")
|
}
|
||||||
return nil
|
|
||||||
}
|
applyMediaBlock(YYImage(contentsOfFile: filePath))
|
||||||
let animatedImage = YYImage(contentsOfFile: filePath)
|
},
|
||||||
return animatedImage
|
applyMediaBlock: { media in
|
||||||
},
|
AssertIsOnMainThread()
|
||||||
applyMediaBlock: { (media) in
|
|
||||||
AssertIsOnMainThread()
|
guard let image: YYImage = media as? YYImage else {
|
||||||
|
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||||
guard let image = media as? YYImage else {
|
return
|
||||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
}
|
||||||
return
|
// FIXME: Animated images flicker when reloading the cells (even though they are in the cache)
|
||||||
}
|
animatedImageView.image = image
|
||||||
animatedImageView.image = image
|
},
|
||||||
},
|
cacheKey: attachment.id
|
||||||
cacheKey: cacheKey)
|
)
|
||||||
}
|
}
|
||||||
unloadBlock = {
|
unloadBlock = {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
@ -225,23 +192,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 +214,28 @@ 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 }
|
||||||
return nil
|
|
||||||
}
|
attachment.thumbnail(
|
||||||
return attachmentStream.thumbnailImageLarge(success: { (image) in
|
size: .large,
|
||||||
|
success: { image, _ in applyMediaBlock(image) },
|
||||||
|
failure: { Logger.error("Could not load thumbnail") }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
applyMediaBlock: { media in
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
|
guard let image: UIImage = media as? UIImage else {
|
||||||
|
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||||
|
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 +244,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 +275,28 @@ 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 }
|
||||||
return nil
|
|
||||||
}
|
attachment.thumbnail(
|
||||||
return attachmentStream.thumbnailImageMedium(success: { (image) in
|
size: .medium,
|
||||||
|
success: { image, _ in applyMediaBlock(image) },
|
||||||
|
failure: { Logger.error("Could not load thumbnail") }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
applyMediaBlock: { media in
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
|
guard let image: UIImage = media as? UIImage else {
|
||||||
|
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||||
|
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 +305,105 @@ 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)
|
|
||||||
let icon: UIImage
|
let icon: UIImage
|
||||||
|
|
||||||
switch error {
|
switch error {
|
||||||
case .failed:
|
case .failed:
|
||||||
guard let asset = UIImage(named: "media_retry") else {
|
guard let asset = UIImage(named: "media_retry") else {
|
||||||
owsFailDebug("Missing image")
|
owsFailDebug("Missing image")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
icon = asset
|
icon = asset
|
||||||
case .invalid:
|
|
||||||
guard let asset = UIImage(named: "media_invalid") else {
|
case .invalid:
|
||||||
owsFailDebug("Missing image")
|
guard let asset = UIImage(named: "media_invalid") else {
|
||||||
return
|
owsFailDebug("Missing image")
|
||||||
}
|
return
|
||||||
icon = asset
|
}
|
||||||
case .missing:
|
icon = asset
|
||||||
return
|
|
||||||
|
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 +424,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)?
|
||||||
|
.withRenderingMode(.alwaysTemplate)
|
||||||
|
.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||||
|
)
|
||||||
|
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,126 @@ 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)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
imageView.contentMode = (thumbnail != nil) ? .scaleAspectFill : .center
|
.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||||
|
)
|
||||||
|
|
||||||
|
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: {}
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 +244,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,5 +1,19 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class ReactionContainerView : UIView {
|
import UIKit
|
||||||
|
import SessionUIKit
|
||||||
|
|
||||||
|
final class ReactionContainerView: UIView {
|
||||||
|
var showingAllReactions = false
|
||||||
|
private var isOutgoingMessage = false
|
||||||
|
private var showNumbers = true
|
||||||
|
private var maxEmojisPerLine = isIPhone6OrSmaller ? 5 : 6
|
||||||
|
|
||||||
|
var reactions: [ReactionViewModel] = []
|
||||||
|
var reactionViews: [ReactionButton] = []
|
||||||
|
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
private lazy var mainStackView: UIStackView = {
|
private lazy var mainStackView: UIStackView = {
|
||||||
let result = UIStackView(arrangedSubviews: [ reactionContainerView ])
|
let result = UIStackView(arrangedSubviews: [ reactionContainerView ])
|
||||||
result.axis = .vertical
|
result.axis = .vertical
|
||||||
|
@ -16,14 +30,8 @@ final class ReactionContainerView : UIView {
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var showingAllReactions = false
|
|
||||||
private var isOutgoingMessage = false
|
|
||||||
private var showNumbers = true
|
|
||||||
private var maxEmojisPerLine = isIPhone6OrSmaller ? 5 : 6
|
|
||||||
|
|
||||||
var reactions: [ReactionViewModel] = []
|
|
||||||
var reactionViews: [ReactionButton] = []
|
|
||||||
var expandButton: ExpandingReactionButton?
|
var expandButton: ExpandingReactionButton?
|
||||||
|
|
||||||
var collapseButton: UIStackView = {
|
var collapseButton: UIStackView = {
|
||||||
let arrow = UIImageView(image: UIImage(named: "ic_chevron_up")?.resizedImage(to: CGSize(width: 15, height: 13))?.withRenderingMode(.alwaysTemplate))
|
let arrow = UIImageView(image: UIImage(named: "ic_chevron_up")?.resizedImage(to: CGSize(width: 15, height: 13))?.withRenderingMode(.alwaysTemplate))
|
||||||
arrow.tintColor = Colors.text
|
arrow.tintColor = Colors.text
|
||||||
|
@ -39,7 +47,8 @@ final class ReactionContainerView : UIView {
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
|
@ -55,6 +64,7 @@ final class ReactionContainerView : UIView {
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy() {
|
||||||
addSubview(mainStackView)
|
addSubview(mainStackView)
|
||||||
|
|
||||||
mainStackView.pin(to: self)
|
mainStackView.pin(to: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,10 +72,13 @@ final class ReactionContainerView : UIView {
|
||||||
self.reactions = reactions
|
self.reactions = reactions
|
||||||
self.isOutgoingMessage = isOutgoingMessage
|
self.isOutgoingMessage = isOutgoingMessage
|
||||||
self.showNumbers = showNumbers
|
self.showNumbers = showNumbers
|
||||||
|
|
||||||
prepareForUpdate()
|
prepareForUpdate()
|
||||||
|
|
||||||
if showingAllReactions {
|
if showingAllReactions {
|
||||||
updateAllReactions()
|
updateAllReactions()
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
updateCollapsedReactions(reactions)
|
updateCollapsedReactions(reactions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -75,10 +88,12 @@ final class ReactionContainerView : UIView {
|
||||||
stackView.axis = .horizontal
|
stackView.axis = .horizontal
|
||||||
stackView.spacing = Values.smallSpacing
|
stackView.spacing = Values.smallSpacing
|
||||||
stackView.alignment = .center
|
stackView.alignment = .center
|
||||||
|
|
||||||
if isOutgoingMessage {
|
if isOutgoingMessage {
|
||||||
stackView.semanticContentAttribute = .forceRightToLeft
|
stackView.semanticContentAttribute = .forceRightToLeft
|
||||||
reactionContainerView.semanticContentAttribute = .forceRightToLeft
|
reactionContainerView.semanticContentAttribute = .forceRightToLeft
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
stackView.semanticContentAttribute = .unspecified
|
stackView.semanticContentAttribute = .unspecified
|
||||||
reactionContainerView.semanticContentAttribute = .unspecified
|
reactionContainerView.semanticContentAttribute = .unspecified
|
||||||
}
|
}
|
||||||
|
@ -88,8 +103,10 @@ final class ReactionContainerView : UIView {
|
||||||
|
|
||||||
if reactions.count > maxEmojisPerLine {
|
if reactions.count > maxEmojisPerLine {
|
||||||
displayedReactions = Array(reactions[0...(maxEmojisPerLine - 3)])
|
displayedReactions = Array(reactions[0...(maxEmojisPerLine - 3)])
|
||||||
expandButtonReactions = Array(reactions[(maxEmojisPerLine - 2)...maxEmojisPerLine]).map{ $0.emoji }
|
expandButtonReactions = Array(reactions[(maxEmojisPerLine - 2)...maxEmojisPerLine])
|
||||||
} else {
|
.map { $0.emoji }
|
||||||
|
}
|
||||||
|
else {
|
||||||
displayedReactions = reactions
|
displayedReactions = reactions
|
||||||
expandButtonReactions = []
|
expandButtonReactions = []
|
||||||
}
|
}
|
||||||
|
@ -99,29 +116,39 @@ final class ReactionContainerView : UIView {
|
||||||
stackView.addArrangedSubview(reactionView)
|
stackView.addArrangedSubview(reactionView)
|
||||||
reactionViews.append(reactionView)
|
reactionViews.append(reactionView)
|
||||||
}
|
}
|
||||||
|
|
||||||
if expandButtonReactions.count > 0 {
|
if expandButtonReactions.count > 0 {
|
||||||
expandButton = ExpandingReactionButton(emojis: expandButtonReactions)
|
let expandButton: ExpandingReactionButton = ExpandingReactionButton(emojis: expandButtonReactions)
|
||||||
stackView.addArrangedSubview(expandButton!)
|
stackView.addArrangedSubview(expandButton)
|
||||||
} else {
|
|
||||||
|
self.expandButton = expandButton
|
||||||
|
}
|
||||||
|
else {
|
||||||
expandButton = nil
|
expandButton = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
reactionContainerView.addArrangedSubview(stackView)
|
reactionContainerView.addArrangedSubview(stackView)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateAllReactions() {
|
private func updateAllReactions() {
|
||||||
var reactions = self.reactions
|
var reactions = self.reactions
|
||||||
var numberOfLines = 0
|
var numberOfLines = 0
|
||||||
|
|
||||||
while reactions.count > 0 {
|
while reactions.count > 0 {
|
||||||
var line: [ReactionViewModel] = []
|
var line: [ReactionViewModel] = []
|
||||||
|
|
||||||
while reactions.count > 0 && line.count < maxEmojisPerLine {
|
while reactions.count > 0 && line.count < maxEmojisPerLine {
|
||||||
line.append(reactions.removeFirst())
|
line.append(reactions.removeFirst())
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCollapsedReactions(line)
|
updateCollapsedReactions(line)
|
||||||
numberOfLines += 1
|
numberOfLines += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if numberOfLines > 1 {
|
if numberOfLines > 1 {
|
||||||
mainStackView.addArrangedSubview(collapseButton)
|
mainStackView.addArrangedSubview(collapseButton)
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
showingAllReactions = false
|
showingAllReactions = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -131,6 +158,7 @@ final class ReactionContainerView : UIView {
|
||||||
reactionContainerView.removeArrangedSubview(subview)
|
reactionContainerView.removeArrangedSubview(subview)
|
||||||
subview.removeFromSuperview()
|
subview.removeFromSuperview()
|
||||||
}
|
}
|
||||||
|
|
||||||
mainStackView.removeArrangedSubview(collapseButton)
|
mainStackView.removeArrangedSubview(collapseButton)
|
||||||
collapseButton.removeFromSuperview()
|
collapseButton.removeFromSuperview()
|
||||||
reactionViews = []
|
reactionViews = []
|
||||||
|
@ -138,12 +166,14 @@ final class ReactionContainerView : UIView {
|
||||||
|
|
||||||
public func showAllEmojis() {
|
public func showAllEmojis() {
|
||||||
guard !showingAllReactions else { return }
|
guard !showingAllReactions else { return }
|
||||||
|
|
||||||
showingAllReactions = true
|
showingAllReactions = true
|
||||||
update(reactions, isOutgoingMessage: isOutgoingMessage, showNumbers: showNumbers)
|
update(reactions, isOutgoingMessage: isOutgoingMessage, showNumbers: showNumbers)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func showLessEmojis() {
|
public func showLessEmojis() {
|
||||||
guard showingAllReactions else { return }
|
guard showingAllReactions else { return }
|
||||||
|
|
||||||
showingAllReactions = false
|
showingAllReactions = false
|
||||||
update(reactions, isOutgoingMessage: isOutgoingMessage, showNumbers: showNumbers)
|
update(reactions, isOutgoingMessage: isOutgoingMessage, showNumbers: showNumbers)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,32 +1,32 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import SessionUIKit
|
||||||
|
|
||||||
public struct ReactionViewModel: Hashable {
|
public struct ReactionViewModel: Hashable {
|
||||||
let emoji: EmojiWithSkinTones
|
let emoji: EmojiWithSkinTones
|
||||||
let number: Int
|
let number: Int
|
||||||
let showBorder: Bool
|
let showBorder: Bool
|
||||||
|
|
||||||
init(emoji: EmojiWithSkinTones, value: Int, showBorder: Bool) {
|
|
||||||
self.emoji = emoji
|
|
||||||
self.number = value
|
|
||||||
self.showBorder = showBorder
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final class ReactionButton: UIView {
|
final class ReactionButton: UIView {
|
||||||
let viewModel: ReactionViewModel
|
let viewModel: ReactionViewModel
|
||||||
let showNumber: Bool
|
let showNumber: Bool
|
||||||
|
|
||||||
// MARK: Settings
|
// MARK: - Settings
|
||||||
|
|
||||||
private var height: CGFloat = 22
|
private var height: CGFloat = 22
|
||||||
private var fontSize: CGFloat = Values.verySmallFontSize
|
private var fontSize: CGFloat = Values.verySmallFontSize
|
||||||
|
|
||||||
private var spacing: CGFloat = Values.verySmallSpacing
|
private var spacing: CGFloat = Values.verySmallSpacing
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
init(viewModel: ReactionViewModel, showNumber: Bool = true) {
|
init(viewModel: ReactionViewModel, showNumber: Bool = true) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.showNumber = showNumber
|
self.showNumber = showNumber
|
||||||
|
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,14 +73,18 @@ final class ReactionButton: UIView {
|
||||||
final class ExpandingReactionButton: UIView {
|
final class ExpandingReactionButton: UIView {
|
||||||
private let emojis: [EmojiWithSkinTones]
|
private let emojis: [EmojiWithSkinTones]
|
||||||
|
|
||||||
// MARK: Settings
|
// MARK: - Settings
|
||||||
|
|
||||||
private let size: CGFloat = 22
|
private let size: CGFloat = 22
|
||||||
private let margin: CGFloat = 15
|
private let margin: CGFloat = 15
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
init(emojis: [EmojiWithSkinTones]) {
|
init(emojis: [EmojiWithSkinTones]) {
|
||||||
self.emojis = emojis
|
self.emojis = emojis
|
||||||
|
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,6 +98,7 @@ final class ExpandingReactionButton: UIView {
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy() {
|
||||||
var rightMargin: CGFloat = 0
|
var rightMargin: CGFloat = 0
|
||||||
|
|
||||||
for emoji in self.emojis.reversed() {
|
for emoji in self.emojis.reversed() {
|
||||||
let container = UIView()
|
let container = UIView()
|
||||||
container.set(.width, to: size)
|
container.set(.width, to: size)
|
||||||
|
@ -101,7 +106,8 @@ final class ExpandingReactionButton: UIView {
|
||||||
container.backgroundColor = Colors.receivedMessageBackground
|
container.backgroundColor = Colors.receivedMessageBackground
|
||||||
container.layer.cornerRadius = size / 2
|
container.layer.cornerRadius = size / 2
|
||||||
container.layer.borderWidth = 1
|
container.layer.borderWidth = 1
|
||||||
container.layer.borderColor = isDarkMode ? UIColor.black.cgColor : UIColor.white.cgColor
|
// FIXME: This is going to have issues when swapping between light/dark mode
|
||||||
|
container.layer.borderColor = (isDarkMode ? UIColor.black.cgColor : UIColor.white.cgColor)
|
||||||
|
|
||||||
let emojiLabel = UILabel()
|
let emojiLabel = UILabel()
|
||||||
emojiLabel.text = emoji.rawValue
|
emojiLabel.text = emoji.rawValue
|
||||||
|
|
|
@ -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,93 @@
|
||||||
|
// 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(
|
||||||
override func update() {
|
with cellViewModel: MessageViewModel,
|
||||||
guard let message = viewItem?.interaction as? TSInfoMessage else { return }
|
mediaCache: NSCache<NSString, AnyObject>,
|
||||||
let icon: UIImage?
|
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||||
switch message.messageType {
|
showExpandedReactions: Bool,
|
||||||
case .disappearingMessagesUpdate:
|
lastSearchText: String?
|
||||||
var configuration: OWSDisappearingMessagesConfiguration?
|
) {
|
||||||
Storage.read { transaction in
|
guard cellViewModel.variant.isInfoMessage else { return }
|
||||||
configuration = message.thread(with: transaction).disappearingMessagesConfiguration(with: transaction)
|
|
||||||
|
self.viewModel = cellViewModel
|
||||||
|
|
||||||
|
let icon: UIImage? = {
|
||||||
|
switch cellViewModel.variant {
|
||||||
|
case .infoDisappearingMessagesUpdate:
|
||||||
|
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,79 +9,87 @@ 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(
|
||||||
func update() {
|
with cellViewModel: MessageViewModel,
|
||||||
|
mediaCache: NSCache<NSString, AnyObject>,
|
||||||
|
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||||
|
showExpandedReactions: Bool,
|
||||||
|
lastSearchText: String?
|
||||||
|
) {
|
||||||
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 : ReactionDelegate {
|
// MARK: - MessageCellDelegate
|
||||||
var lastSearchedText: String? { get }
|
|
||||||
|
protocol MessageCellDelegate: ReactionDelegate {
|
||||||
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)
|
func showReactionList(_ cellViewModel: MessageViewModel, selectedReaction: EmojiWithSkinTones?)
|
||||||
func showReactionList(_ viewItem: ConversationViewItem, selectedReaction: EmojiWithSkinTones?)
|
func needsLayout(for cellViewModel: MessageViewModel, expandingReactions: Bool)
|
||||||
func needsLayout(for viewItem: ConversationViewItem, expandingReactions: Bool)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,85 +1,100 @@
|
||||||
|
// 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?,
|
||||||
|
showExpandedReactions: Bool,
|
||||||
|
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,49 +0,0 @@
|
||||||
|
|
||||||
// Requirements:
|
|
||||||
// • Links should show up properly and be tappable.
|
|
||||||
// • Text should * not * be selectable.
|
|
||||||
// • The long press interaction that shows the context menu should still work.
|
|
||||||
|
|
||||||
final class BodyTextView : UITextView {
|
|
||||||
private let snDelegate: BodyTextViewDelegate
|
|
||||||
|
|
||||||
override var selectedTextRange: UITextRange? {
|
|
||||||
get { return nil }
|
|
||||||
set { }
|
|
||||||
}
|
|
||||||
|
|
||||||
init(snDelegate: BodyTextViewDelegate) {
|
|
||||||
self.snDelegate = snDelegate
|
|
||||||
super.init(frame: CGRect.zero, textContainer: nil)
|
|
||||||
setUpGestureRecognizers()
|
|
||||||
}
|
|
||||||
|
|
||||||
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
|
||||||
preconditionFailure("Use init(snDelegate:) instead.")
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
preconditionFailure("Use init(snDelegate:) instead.")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setUpGestureRecognizers() {
|
|
||||||
let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
|
|
||||||
addGestureRecognizer(longPressGestureRecognizer)
|
|
||||||
let doubleTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap))
|
|
||||||
doubleTapGestureRecognizer.numberOfTapsRequired = 2
|
|
||||||
addGestureRecognizer(doubleTapGestureRecognizer)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) {
|
|
||||||
snDelegate.handleLongPress(gestureRecognizer)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func handleDoubleTap() {
|
|
||||||
// Do nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol BodyTextViewDelegate {
|
|
||||||
|
|
||||||
func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer)
|
|
||||||
}
|
|
|
@ -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,80 +1,113 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
final class ReactionListSheet : BaseVC {
|
import UIKit
|
||||||
private let thread: TSGroupThread
|
import DifferenceKit
|
||||||
private let viewItem: ConversationViewItem
|
import SessionUIKit
|
||||||
private var reactions: [ReactMessage] = []
|
import SessionMessagingKit
|
||||||
private var reactionMap: OrderedDictionary<EmojiWithSkinTones, [ReactMessage]> = OrderedDictionary()
|
import SignalUtilitiesKit
|
||||||
var selectedReaction: EmojiWithSkinTones?
|
|
||||||
var delegate: ReactionDelegate?
|
final class ReactionListSheet: BaseVC {
|
||||||
|
public struct ReactionSummary: Hashable, Differentiable {
|
||||||
|
let emoji: EmojiWithSkinTones
|
||||||
|
let number: Int
|
||||||
|
let isSelected: Bool
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
return "\(emoji.rawValue) · \(number)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Components
|
private let interactionId: Int64
|
||||||
|
private let onDismiss: (() -> ())?
|
||||||
|
private var messageViewModel: MessageViewModel = MessageViewModel()
|
||||||
|
private var reactionSummaries: [ReactionSummary] = []
|
||||||
|
private var selectedReactionUserList: [MessageViewModel.ReactionInfo] = []
|
||||||
|
private var lastSelectedReactionIndex: Int = 0
|
||||||
|
public var delegate: ReactionDelegate?
|
||||||
|
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
private lazy var contentView: UIView = {
|
private lazy var contentView: UIView = {
|
||||||
let result = UIView()
|
let result: UIView = UIView()
|
||||||
let line = UIView()
|
result.backgroundColor = Colors.modalBackground
|
||||||
line.set(.height, to: 0.5)
|
|
||||||
|
let line: UIView = UIView()
|
||||||
line.backgroundColor = Colors.border.withAlphaComponent(0.5)
|
line.backgroundColor = Colors.border.withAlphaComponent(0.5)
|
||||||
result.addSubview(line)
|
result.addSubview(line)
|
||||||
|
|
||||||
|
line.set(.height, to: 0.5)
|
||||||
line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top ], to: result)
|
line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top ], to: result)
|
||||||
result.backgroundColor = Colors.modalBackground
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var layout: UICollectionViewFlowLayout = {
|
private lazy var layout: UICollectionViewFlowLayout = {
|
||||||
let result = UICollectionViewFlowLayout()
|
let result: UICollectionViewFlowLayout = UICollectionViewFlowLayout()
|
||||||
result.scrollDirection = .horizontal
|
result.scrollDirection = .horizontal
|
||||||
result.minimumLineSpacing = Values.smallSpacing
|
result.sectionInset = UIEdgeInsets(
|
||||||
result.minimumInteritemSpacing = Values.smallSpacing
|
top: 0,
|
||||||
|
leading: Values.smallSpacing,
|
||||||
|
bottom: 0,
|
||||||
|
trailing: Values.smallSpacing
|
||||||
|
)
|
||||||
|
result.minimumLineSpacing = 0
|
||||||
|
result.minimumInteritemSpacing = 0
|
||||||
result.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
|
result.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var reactionContainer: UICollectionView = {
|
private lazy var reactionContainer: UICollectionView = {
|
||||||
let result = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
|
let result: UICollectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
|
||||||
result.register(Cell.self, forCellWithReuseIdentifier: Cell.identifier)
|
result.register(view: Cell.self)
|
||||||
result.set(.height, to: 48)
|
result.set(.height, to: 48)
|
||||||
result.backgroundColor = .clear
|
result.backgroundColor = .clear
|
||||||
result.isScrollEnabled = true
|
result.isScrollEnabled = true
|
||||||
result.showsHorizontalScrollIndicator = false
|
result.showsHorizontalScrollIndicator = false
|
||||||
result.dataSource = self
|
result.dataSource = self
|
||||||
result.delegate = self
|
result.delegate = self
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var detailInfoLabel: UILabel = {
|
private lazy var detailInfoLabel: UILabel = {
|
||||||
let result = UILabel()
|
let result: UILabel = UILabel()
|
||||||
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||||
result.textColor = Colors.grey.withAlphaComponent(0.8)
|
result.textColor = Colors.grey.withAlphaComponent(0.8)
|
||||||
result.set(.height, to: 32)
|
result.set(.height, to: 32)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var clearAllButton: Button = {
|
private lazy var clearAllButton: Button = {
|
||||||
let result = Button(style: .destructiveOutline, size: .small)
|
let result: Button = Button(style: .destructiveOutline, size: .small)
|
||||||
result.translatesAutoresizingMaskIntoConstraints = false
|
result.translatesAutoresizingMaskIntoConstraints = false
|
||||||
result.setTitle(NSLocalizedString("MESSAGE_REQUESTS_CLEAR_ALL", comment: ""), for: .normal)
|
result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal)
|
||||||
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
|
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
|
||||||
result.layer.borderWidth = 0
|
result.layer.borderWidth = 0
|
||||||
result.isHidden = true
|
result.isHidden = true
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var userListView: UITableView = {
|
private lazy var userListView: 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.register(view: UserCell.self)
|
||||||
result.separatorStyle = .none
|
result.separatorStyle = .none
|
||||||
result.backgroundColor = .clear
|
result.backgroundColor = .clear
|
||||||
result.showsVerticalScrollIndicator = false
|
result.showsVerticalScrollIndicator = false
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
init(for viewItem: ConversationViewItem, thread: TSGroupThread) {
|
init(for interactionId: Int64, onDismiss: (() -> ())? = nil) {
|
||||||
self.viewItem = viewItem
|
self.interactionId = interactionId
|
||||||
self.thread = thread
|
self.onDismiss = onDismiss
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,21 +121,20 @@ final class ReactionListSheet : BaseVC {
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
view.backgroundColor = .clear
|
view.backgroundColor = .clear
|
||||||
|
|
||||||
let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(close))
|
let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(close))
|
||||||
swipeGestureRecognizer.direction = .down
|
swipeGestureRecognizer.direction = .down
|
||||||
view.addGestureRecognizer(swipeGestureRecognizer)
|
view.addGestureRecognizer(swipeGestureRecognizer)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(update), name: .emojiReactsUpdated, object: nil)
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
update()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLayoutSubviews() {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewDidLayoutSubviews()
|
super.viewWillDisappear(animated)
|
||||||
if let index = reactionMap.orderedKeys.firstIndex(of: selectedReaction!) {
|
|
||||||
let indexPath = IndexPath(item: index, section: 0)
|
self.onDismiss?()
|
||||||
reactionContainer.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy() {
|
||||||
|
@ -117,6 +149,7 @@ final class ReactionListSheet : BaseVC {
|
||||||
contentView.addSubview(reactionContainer)
|
contentView.addSubview(reactionContainer)
|
||||||
reactionContainer.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: contentView)
|
reactionContainer.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: contentView)
|
||||||
reactionContainer.pin(.top, to: .top, of: contentView, withInset: Values.verySmallSpacing)
|
reactionContainer.pin(.top, to: .top, of: contentView, withInset: Values.verySmallSpacing)
|
||||||
|
|
||||||
// Seperator
|
// Seperator
|
||||||
let seperator = UIView()
|
let seperator = UIView()
|
||||||
seperator.backgroundColor = Colors.border.withAlphaComponent(0.1)
|
seperator.backgroundColor = Colors.border.withAlphaComponent(0.1)
|
||||||
|
@ -125,12 +158,14 @@ final class ReactionListSheet : BaseVC {
|
||||||
seperator.pin(.leading, to: .leading, of: contentView, withInset: Values.smallSpacing)
|
seperator.pin(.leading, to: .leading, of: contentView, withInset: Values.smallSpacing)
|
||||||
seperator.pin(.trailing, to: .trailing, of: contentView, withInset: -Values.smallSpacing)
|
seperator.pin(.trailing, to: .trailing, of: contentView, withInset: -Values.smallSpacing)
|
||||||
seperator.pin(.top, to: .bottom, of: reactionContainer, withInset: Values.verySmallSpacing)
|
seperator.pin(.top, to: .bottom, of: reactionContainer, withInset: Values.verySmallSpacing)
|
||||||
|
|
||||||
// Detail info & clear all
|
// Detail info & clear all
|
||||||
let stackView = UIStackView(arrangedSubviews: [ detailInfoLabel, clearAllButton ])
|
let stackView = UIStackView(arrangedSubviews: [ detailInfoLabel, clearAllButton ])
|
||||||
contentView.addSubview(stackView)
|
contentView.addSubview(stackView)
|
||||||
stackView.pin(.top, to: .bottom, of: seperator, withInset: Values.smallSpacing)
|
stackView.pin(.top, to: .bottom, of: seperator, withInset: Values.smallSpacing)
|
||||||
stackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing)
|
stackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing)
|
||||||
stackView.pin(.trailing, to: .trailing, of: contentView, withInset: -Values.mediumSpacing)
|
stackView.pin(.trailing, to: .trailing, of: contentView, withInset: -Values.mediumSpacing)
|
||||||
|
|
||||||
// Line
|
// Line
|
||||||
let line = UIView()
|
let line = UIView()
|
||||||
line.set(.height, to: 0.5)
|
line.set(.height, to: 0.5)
|
||||||
|
@ -138,65 +173,169 @@ final class ReactionListSheet : BaseVC {
|
||||||
contentView.addSubview(line)
|
contentView.addSubview(line)
|
||||||
line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: contentView)
|
line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: contentView)
|
||||||
line.pin(.top, to: .bottom, of: stackView, withInset: Values.smallSpacing)
|
line.pin(.top, to: .bottom, of: stackView, withInset: Values.smallSpacing)
|
||||||
|
|
||||||
// Reactor list
|
// Reactor list
|
||||||
contentView.addSubview(userListView)
|
contentView.addSubview(userListView)
|
||||||
userListView.pin([ UIView.HorizontalEdge.trailing, UIView.HorizontalEdge.leading, UIView.VerticalEdge.bottom ], to: contentView)
|
userListView.pin([ UIView.HorizontalEdge.trailing, UIView.HorizontalEdge.leading, UIView.VerticalEdge.bottom ], to: contentView)
|
||||||
userListView.pin(.top, to: .bottom, of: line, withInset: 0)
|
userListView.pin(.top, to: .bottom, of: line, withInset: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func populateData() {
|
// MARK: - Content
|
||||||
self.reactions = []
|
|
||||||
self.reactionMap = OrderedDictionary()
|
|
||||||
if let messageId = viewItem.interaction.uniqueId, let message = TSMessage.fetch(uniqueId: messageId) {
|
|
||||||
self.reactions = message.reactions as! [ReactMessage]
|
|
||||||
}
|
|
||||||
for reaction in reactions {
|
|
||||||
if let rawEmoji = reaction.emoji, let emoji = EmojiWithSkinTones(rawValue: rawEmoji) {
|
|
||||||
if !reactionMap.hasValue(forKey: emoji) { reactionMap.append(key: emoji, value: []) }
|
|
||||||
var value = reactionMap.value(forKey: emoji)!
|
|
||||||
if reaction.sender == getUserHexEncodedPublicKey() {
|
|
||||||
value.insert(reaction, at: 0)
|
|
||||||
} else {
|
|
||||||
value.append(reaction)
|
|
||||||
}
|
|
||||||
reactionMap.replace(key: emoji, value: value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (selectedReaction == nil || reactionMap.value(forKey: selectedReaction!) == nil) && reactionMap.orderedKeys.count > 0 {
|
|
||||||
selectedReaction = reactionMap.orderedKeys[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func reloadData() {
|
public func handleInteractionUpdates(
|
||||||
reactionContainer.reloadData()
|
_ allMessages: [MessageViewModel],
|
||||||
let seletedData = reactionMap.value(forKey: selectedReaction!)!
|
selectedReaction: EmojiWithSkinTones? = nil,
|
||||||
detailInfoLabel.text = "\(selectedReaction!.rawValue) · \(seletedData.count)"
|
updatedReactionIndex: Int? = nil,
|
||||||
if thread.isOpenGroup, let threadId = thread.uniqueId, let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadId) {
|
initialLoad: Bool = false
|
||||||
let isUserModerator = OpenGroupAPIV2.isUserModerator(getUserHexEncodedPublicKey(), for: openGroupV2.room, on: openGroupV2.server)
|
) {
|
||||||
clearAllButton.isHidden = !isUserModerator
|
guard let cellViewModel: MessageViewModel = allMessages.first(where: { $0.id == self.interactionId }) else {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
userListView.reloadData()
|
|
||||||
}
|
// If we have no more reactions (eg. the user removed the last one) then closed the list sheet
|
||||||
|
guard cellViewModel.reactionInfo?.isEmpty == false else {
|
||||||
@objc private func update() {
|
|
||||||
populateData()
|
|
||||||
if reactions.isEmpty {
|
|
||||||
close()
|
close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
reloadData()
|
|
||||||
|
// Generated the updated data
|
||||||
|
let updatedReactionInfo: OrderedDictionary<EmojiWithSkinTones, [MessageViewModel.ReactionInfo]> = (cellViewModel.reactionInfo ?? [])
|
||||||
|
.reduce(into: OrderedDictionary<EmojiWithSkinTones, [MessageViewModel.ReactionInfo]>()) {
|
||||||
|
result, reactionInfo in
|
||||||
|
guard let emoji: EmojiWithSkinTones = EmojiWithSkinTones(rawValue: reactionInfo.reaction.emoji) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard var updatedValue: [MessageViewModel.ReactionInfo] = result.value(forKey: emoji) else {
|
||||||
|
result.append(key: emoji, value: [reactionInfo])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if reactionInfo.reaction.authorId == cellViewModel.currentUserPublicKey {
|
||||||
|
updatedValue.insert(reactionInfo, at: 0)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
updatedValue.append(reactionInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.replace(key: emoji, value: updatedValue)
|
||||||
|
}
|
||||||
|
let oldSelectedReactionIndex: Int = self.lastSelectedReactionIndex
|
||||||
|
let updatedSelectedReactionIndex: Int = updatedReactionIndex
|
||||||
|
.defaulting(
|
||||||
|
to: {
|
||||||
|
// If we explicitly provided a 'selectedReaction' value then try to use that
|
||||||
|
if selectedReaction != nil, let targetIndex: Int = updatedReactionInfo.orderedKeys.firstIndex(where: { $0 == selectedReaction }) {
|
||||||
|
return targetIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise try to maintain the index of the currently selected index
|
||||||
|
guard
|
||||||
|
!self.reactionSummaries.isEmpty,
|
||||||
|
let emoji: EmojiWithSkinTones = self.reactionSummaries[safe: oldSelectedReactionIndex]?.emoji,
|
||||||
|
let targetIndex: Int = updatedReactionInfo.orderedKeys.firstIndex(of: emoji)
|
||||||
|
else { return 0 }
|
||||||
|
|
||||||
|
return targetIndex
|
||||||
|
}()
|
||||||
|
)
|
||||||
|
let updatedSummaries: [ReactionSummary] = updatedReactionInfo
|
||||||
|
.orderedKeys
|
||||||
|
.enumerated()
|
||||||
|
.map { index, emoji in
|
||||||
|
ReactionSummary(
|
||||||
|
emoji: emoji,
|
||||||
|
number: updatedReactionInfo.value(forKey: emoji)
|
||||||
|
.defaulting(to: [])
|
||||||
|
.map { Int($0.reaction.count) }
|
||||||
|
.reduce(0, +),
|
||||||
|
isSelected: (index == updatedSelectedReactionIndex)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the general UI
|
||||||
|
|
||||||
|
self.detailInfoLabel.text = updatedSummaries[safe: updatedSelectedReactionIndex]?.description
|
||||||
|
self.clearAllButton.isHidden = !cellViewModel.isSenderOpenGroupModerator
|
||||||
|
|
||||||
|
// Update general properties
|
||||||
|
self.messageViewModel = cellViewModel
|
||||||
|
self.lastSelectedReactionIndex = updatedSelectedReactionIndex
|
||||||
|
|
||||||
|
// Ensure the first load or a load when returning from a child screen runs without animations (if
|
||||||
|
// we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition)
|
||||||
|
guard !initialLoad else {
|
||||||
|
self.reactionSummaries = updatedSummaries
|
||||||
|
self.selectedReactionUserList = updatedReactionInfo
|
||||||
|
.orderedKeys[safe: updatedSelectedReactionIndex]
|
||||||
|
.map { updatedReactionInfo.value(forKey: $0) }
|
||||||
|
.defaulting(to: [])
|
||||||
|
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
self.reactionContainer.reloadData()
|
||||||
|
self.userListView.reloadData()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the collection view content
|
||||||
|
let collectionViewChangeset: StagedChangeset<[ReactionSummary]> = StagedChangeset(
|
||||||
|
source: self.reactionSummaries,
|
||||||
|
target: updatedSummaries
|
||||||
|
)
|
||||||
|
|
||||||
|
// If there are changes then we want to reload both the collection and table views
|
||||||
|
self.reactionContainer.reload(
|
||||||
|
using: collectionViewChangeset,
|
||||||
|
interrupt: { $0.changeCount > 1 }
|
||||||
|
) { [weak self] updatedData in
|
||||||
|
self?.reactionSummaries = updatedData
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we changed the selected index then no need to reload the changes
|
||||||
|
guard
|
||||||
|
oldSelectedReactionIndex == updatedSelectedReactionIndex &&
|
||||||
|
self.reactionSummaries[safe: oldSelectedReactionIndex]?.emoji == updatedSummaries[safe: updatedSelectedReactionIndex]?.emoji
|
||||||
|
else {
|
||||||
|
self.selectedReactionUserList = updatedReactionInfo
|
||||||
|
.orderedKeys[safe: updatedSelectedReactionIndex]
|
||||||
|
.map { updatedReactionInfo.value(forKey: $0) }
|
||||||
|
.defaulting(to: [])
|
||||||
|
self.userListView.reloadData()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let tableChangeset: StagedChangeset<[MessageViewModel.ReactionInfo]> = StagedChangeset(
|
||||||
|
source: self.selectedReactionUserList,
|
||||||
|
target: updatedReactionInfo
|
||||||
|
.orderedKeys[safe: updatedSelectedReactionIndex]
|
||||||
|
.map { updatedReactionInfo.value(forKey: $0) }
|
||||||
|
.defaulting(to: [])
|
||||||
|
)
|
||||||
|
|
||||||
|
self.userListView.reload(
|
||||||
|
using: tableChangeset,
|
||||||
|
deleteSectionsAnimation: .none,
|
||||||
|
insertSectionsAnimation: .none,
|
||||||
|
reloadSectionsAnimation: .none,
|
||||||
|
deleteRowsAnimation: .none,
|
||||||
|
insertRowsAnimation: .none,
|
||||||
|
reloadRowsAnimation: .none,
|
||||||
|
interrupt: { $0.changeCount > 100 }
|
||||||
|
) { [weak self] updatedData in
|
||||||
|
self?.selectedReactionUserList = updatedData
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||||
let touch = touches.first!
|
guard let touch: UITouch = touches.first, contentView.frame.contains(touch.location(in: view)) else {
|
||||||
let location = touch.location(in: view)
|
|
||||||
if contentView.frame.contains(location) {
|
|
||||||
super.touchesBegan(touches, with: event)
|
|
||||||
} else {
|
|
||||||
close()
|
close()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
super.touchesBegan(touches, with: event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func close() {
|
@objc func close() {
|
||||||
|
@ -204,83 +343,90 @@ final class ReactionListSheet : BaseVC {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func clearAllTapped() {
|
@objc private func clearAllTapped() {
|
||||||
guard let reactMessages = reactionMap.value(forKey: selectedReaction!) else { return }
|
guard let selectedReaction: EmojiWithSkinTones = self.reactionSummaries.first(where: { $0.isSelected })?.emoji else { return }
|
||||||
delegate?.cancelAllReact(reactMessages: reactMessages)
|
|
||||||
|
delegate?.removeAllReactions(messageViewModel, for: selectedReaction.rawValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: UICollectionView
|
// MARK: - UICollectionView
|
||||||
|
|
||||||
extension ReactionListSheet: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
extension ReactionListSheet: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||||
// MARK: Layout
|
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
|
|
||||||
return UIEdgeInsets(top: 0, leading: Values.smallSpacing, bottom: 0, trailing: Values.smallSpacing)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Data Source
|
// MARK: Data Source
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||||
return reactionMap.orderedKeys.count
|
return self.reactionSummaries.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Cell.identifier, for: indexPath) as! Cell
|
let cell: Cell = collectionView.dequeue(type: Cell.self, for: indexPath)
|
||||||
let item = reactionMap.orderedItems[indexPath.item]
|
let summary: ReactionSummary = self.reactionSummaries[indexPath.item]
|
||||||
cell.data = (item.0.rawValue, item.1.count)
|
|
||||||
cell.isCurrentSelection = item.0 == selectedReaction!
|
cell.update(
|
||||||
|
with: summary.emoji.rawValue,
|
||||||
|
count: summary.number,
|
||||||
|
isCurrentSelection: summary.isSelected
|
||||||
|
)
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Interaction
|
// MARK: Interaction
|
||||||
|
|
||||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
selectedReaction = reactionMap.orderedKeys[indexPath.item]
|
self.handleInteractionUpdates([messageViewModel], updatedReactionIndex: indexPath.item)
|
||||||
reloadData()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: UITableView
|
// MARK: - UITableViewDelegate & UITableViewDataSource
|
||||||
|
|
||||||
extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
|
extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
|
||||||
// MARK: Table View Data Source
|
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
return reactionMap.value(forKey: selectedReaction!)?.count ?? 0
|
return self.selectedReactionUserList.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 = reactionMap.value(forKey: selectedReaction!)![indexPath.row].sender!
|
let cellViewModel: MessageViewModel.ReactionInfo = self.selectedReactionUserList[indexPath.row]
|
||||||
cell.publicKey = publicKey
|
cell.update(
|
||||||
cell.normalFont = true
|
with: cellViewModel.reaction.authorId,
|
||||||
if publicKey == getUserHexEncodedPublicKey() {
|
profile: cellViewModel.profile,
|
||||||
cell.accessory = .x
|
isZombie: false,
|
||||||
} else {
|
mediumFont: true,
|
||||||
cell.accessory = .none
|
accessory: (cellViewModel.reaction.authorId == self.messageViewModel.currentUserPublicKey ?
|
||||||
}
|
.x :
|
||||||
cell.update()
|
.none
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
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 reactMessage = reactionMap.value(forKey: selectedReaction!)?[indexPath.row], let publicKey = reactMessage.sender else { return }
|
|
||||||
if publicKey == getUserHexEncodedPublicKey() {
|
let cellViewModel: MessageViewModel.ReactionInfo = self.selectedReactionUserList[indexPath.row]
|
||||||
delegate?.cancelReact(viewItem, for: selectedReaction!)
|
|
||||||
}
|
guard
|
||||||
|
let selectedReaction: EmojiWithSkinTones = self.reactionSummaries
|
||||||
|
.first(where: { $0.isSelected })?
|
||||||
|
.emoji,
|
||||||
|
selectedReaction.rawValue == cellViewModel.reaction.emoji,
|
||||||
|
cellViewModel.reaction.authorId == self.messageViewModel.currentUserPublicKey
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
delegate?.removeReact(self.messageViewModel, for: selectedReaction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Cell
|
// MARK: - Cell
|
||||||
|
|
||||||
extension ReactionListSheet {
|
extension ReactionListSheet {
|
||||||
|
fileprivate final class Cell: UICollectionViewCell {
|
||||||
fileprivate final class Cell : UICollectionViewCell {
|
// MARK: - UI
|
||||||
var data: (String, Int)? { didSet { update() } }
|
|
||||||
var isCurrentSelection: Bool? { didSet { updateBorder() } }
|
|
||||||
|
|
||||||
static let identifier = "ReactionListSheetCell"
|
private static var contentViewHeight: CGFloat = 32
|
||||||
|
private static var contentViewCornerRadius: CGFloat { contentViewHeight / 2 }
|
||||||
|
|
||||||
private lazy var snContentView: UIView = {
|
private lazy var snContentView: UIView = {
|
||||||
let result = UIView()
|
let result = UIView()
|
||||||
|
@ -300,27 +446,31 @@ extension ReactionListSheet {
|
||||||
let result = UILabel()
|
let result = UILabel()
|
||||||
result.textColor = Colors.text
|
result.textColor = Colors.text
|
||||||
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private static var contentViewHeight: CGFloat = 32
|
// MARK: - Initialization
|
||||||
private static var contentViewCornerRadius: CGFloat { contentViewHeight / 2 }
|
|
||||||
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy() {
|
||||||
addSubview(snContentView)
|
addSubview(snContentView)
|
||||||
|
|
||||||
let stackView = UIStackView(arrangedSubviews: [ emojiLabel, numberLabel ])
|
let stackView = UIStackView(arrangedSubviews: [ emojiLabel, numberLabel ])
|
||||||
stackView.axis = .horizontal
|
stackView.axis = .horizontal
|
||||||
stackView.alignment = .center
|
stackView.alignment = .center
|
||||||
|
|
||||||
let spacing = Values.smallSpacing + 2
|
let spacing = Values.smallSpacing + 2
|
||||||
stackView.spacing = spacing
|
stackView.spacing = spacing
|
||||||
stackView.layoutMargins = UIEdgeInsets(top: 0, left: spacing, bottom: 0, right: spacing)
|
stackView.layoutMargins = UIEdgeInsets(top: 0, left: spacing, bottom: 0, right: spacing)
|
||||||
|
@ -330,29 +480,30 @@ extension ReactionListSheet {
|
||||||
snContentView.pin(to: self)
|
snContentView.pin(to: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func update() {
|
// MARK: - Content
|
||||||
guard let data = data else { return }
|
|
||||||
emojiLabel.text = data.0
|
|
||||||
numberLabel.text = data.1 < 1000 ? "\(data.1)" : String(format: "%.1f", Float(data.1) / 1000) + "k"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateBorder() {
|
fileprivate func update(
|
||||||
if isCurrentSelection == true {
|
with emoji: String,
|
||||||
snContentView.addBorder(with: Colors.accent)
|
count: Int,
|
||||||
} else {
|
isCurrentSelection: Bool
|
||||||
snContentView.addBorder(with: .clear)
|
) {
|
||||||
}
|
snContentView.addBorder(
|
||||||
|
with: (isCurrentSelection == true ? Colors.accent : .clear)
|
||||||
|
)
|
||||||
|
|
||||||
|
emojiLabel.text = emoji
|
||||||
|
numberLabel.text = (count < 1000 ?
|
||||||
|
"\(count)" :
|
||||||
|
String(format: "%.1fk", Float(count) / 1000)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Delegate
|
// MARK: - Delegate
|
||||||
|
|
||||||
protocol ReactionDelegate : AnyObject {
|
protocol ReactionDelegate: AnyObject {
|
||||||
|
func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones)
|
||||||
func quickReact(_ viewItem: ConversationViewItem, with emoji: EmojiWithSkinTones)
|
func removeReact(_ cellViewModel: MessageViewModel, for emoji: EmojiWithSkinTones)
|
||||||
func cancelReact(_ viewItem: ConversationViewItem, for emoji: EmojiWithSkinTones)
|
func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String)
|
||||||
func cancelAllReact(reactMessages: [ReactMessage])
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
@ -150,10 +146,11 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
||||||
}.catch { error in
|
}.catch { error in
|
||||||
modalActivityIndicator.dismiss {
|
modalActivityIndicator.dismiss {
|
||||||
var messageOrNil: String?
|
var messageOrNil: String?
|
||||||
if let error = error as? SnodeAPI.Error {
|
if let error = error as? SnodeAPIError {
|
||||||
switch error {
|
switch error {
|
||||||
case .decryptionFailed, .hashingFailed, .validationFailed: messageOrNil = error.errorDescription
|
case .decryptionFailed, .hashingFailed, .validationFailed:
|
||||||
default: break
|
messageOrNil = error.errorDescription
|
||||||
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let message = messageOrNil ?? "Please check the Session ID or ONS name and try again"
|
let message = messageOrNil ?? "Please check the Session ID or ONS name and try again"
|
||||||
|
@ -166,10 +163,16 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,11 +1,14 @@
|
||||||
//
|
|
||||||
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
|
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
|
||||||
//
|
|
||||||
|
|
||||||
public struct EmojiWithSkinTones: Hashable {
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import DifferenceKit
|
||||||
|
import SessionMessagingKit
|
||||||
|
|
||||||
|
public struct EmojiWithSkinTones: Hashable, Equatable, ContentEquatable, ContentIdentifiable {
|
||||||
let baseEmoji: Emoji
|
let baseEmoji: Emoji
|
||||||
let skinTones: [Emoji.SkinTone]?
|
let skinTones: [Emoji.SkinTone]?
|
||||||
|
|
||||||
init(baseEmoji: Emoji, skinTones: [Emoji.SkinTone]? = nil) {
|
init(baseEmoji: Emoji, skinTones: [Emoji.SkinTone]? = nil) {
|
||||||
self.baseEmoji = baseEmoji
|
self.baseEmoji = baseEmoji
|
||||||
|
|
||||||
|
@ -40,28 +43,63 @@ public struct EmojiWithSkinTones: Hashable {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Emoji {
|
extension Emoji {
|
||||||
private static let emojiWithPreferredSkinToneCollection = "Emoji+PreferredSkinTonePermutation"
|
static func getRecent(_ db: Database, withDefaultEmoji: Bool) throws -> [String] {
|
||||||
|
let recentReactionEmoji: [String] = (db[.recentReactionEmoji]?
|
||||||
static func allSendableEmojiByCategoryWithPreferredSkinTones(transaction: YapDatabaseReadTransaction) -> [Category: [EmojiWithSkinTones]] {
|
.components(separatedBy: ","))
|
||||||
return Category.allCases.reduce(into: [Category: [EmojiWithSkinTones]]()) { result, category in
|
.defaulting(to: [])
|
||||||
result[category] = category.normalizedEmoji.filter { $0.available }.map { $0.withPreferredSkinTones(transaction: transaction) }
|
|
||||||
}
|
// No need to continue if we don't want the default emoji to pad out the list
|
||||||
|
guard withDefaultEmoji else { return recentReactionEmoji }
|
||||||
|
|
||||||
|
// Add in our default emoji if desired
|
||||||
|
let defaultEmoji = ["🙈", "🙉", "🙊", "😈", "🥸", "🐀"]
|
||||||
|
.filter { !recentReactionEmoji.contains($0) }
|
||||||
|
|
||||||
|
return Array(recentReactionEmoji
|
||||||
|
.appending(contentsOf: defaultEmoji)
|
||||||
|
.prefix(6))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func addRecent(_ db: Database, emoji: String) {
|
||||||
|
// Add/move the emoji to the start of the most recent list
|
||||||
|
db[.recentReactionEmoji] = (db[.recentReactionEmoji]?
|
||||||
|
.components(separatedBy: ","))
|
||||||
|
.defaulting(to: [])
|
||||||
|
.filter { $0 != emoji }
|
||||||
|
.inserting(emoji, at: 0)
|
||||||
|
.prefix(6)
|
||||||
|
.joined(separator: ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
func withPreferredSkinTones(transaction: YapDatabaseReadTransaction) -> EmojiWithSkinTones {
|
static func allSendableEmojiByCategoryWithPreferredSkinTones(_ db: Database) -> [Category: [EmojiWithSkinTones]] {
|
||||||
guard let rawSkinTones = transaction.object(forKey: rawValue, inCollection: Self.emojiWithPreferredSkinToneCollection) as? [String] else {
|
return Category.allCases
|
||||||
|
.reduce(into: [Category: [EmojiWithSkinTones]]()) { result, category in
|
||||||
|
result[category] = category.normalizedEmoji
|
||||||
|
.filter { $0.available }
|
||||||
|
.map { $0.withPreferredSkinTones(db) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func withPreferredSkinTones(_ db: Database) -> EmojiWithSkinTones {
|
||||||
|
guard let rawSkinTones: String = db[.emojiPreferredSkinTones(emoji: rawValue)] else {
|
||||||
return EmojiWithSkinTones(baseEmoji: self, skinTones: nil)
|
return EmojiWithSkinTones(baseEmoji: self, skinTones: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
return EmojiWithSkinTones(baseEmoji: self, skinTones: rawSkinTones.compactMap { SkinTone(rawValue: $0) })
|
return EmojiWithSkinTones(
|
||||||
|
baseEmoji: self,
|
||||||
|
skinTones: rawSkinTones
|
||||||
|
.split(separator: ",")
|
||||||
|
.compactMap { SkinTone(rawValue: String($0)) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setPreferredSkinTones(_ preferredSkinTonePermutation: [SkinTone]?, transaction: YapDatabaseReadWriteTransaction) {
|
func setPreferredSkinTones(_ db: Database, preferredSkinTonePermutation: [SkinTone]?) {
|
||||||
if let preferredSkinTonePermutation = preferredSkinTonePermutation {
|
db[.emojiPreferredSkinTones(emoji: rawValue)] = preferredSkinTonePermutation
|
||||||
transaction.setObject(preferredSkinTonePermutation.map { $0.rawValue }, forKey: rawValue, inCollection: Self.emojiWithPreferredSkinToneCollection)
|
.map { preferredSkinTonePermutation in
|
||||||
} else {
|
preferredSkinTonePermutation
|
||||||
transaction.removeObject(forKey: rawValue, inCollection: Self.emojiWithPreferredSkinToneCollection)
|
.map { $0.rawValue }
|
||||||
}
|
.joined(separator: ",")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init?(_ string: String) {
|
init?(_ string: String) {
|
||||||
|
|
|
@ -1,36 +0,0 @@
|
||||||
extension Storage {
|
|
||||||
|
|
||||||
private static let emojiPickerCollection = "EmojiPickerCollection"
|
|
||||||
private static let recentEmojiKey = "recentEmoji"
|
|
||||||
|
|
||||||
func getRecentEmoji(withDefaultEmoji: Bool, transaction: YapDatabaseReadTransaction) -> [EmojiWithSkinTones] {
|
|
||||||
var rawRecentEmoji = transaction.object(forKey: Self.recentEmojiKey, inCollection: Self.emojiPickerCollection) as? [String] ?? []
|
|
||||||
let defaultEmoji = ["🙈", "🙉", "🙊", "😈", "🥸", "🐀"].filter{ !rawRecentEmoji.contains($0) }
|
|
||||||
|
|
||||||
if rawRecentEmoji.count < 6 && withDefaultEmoji {
|
|
||||||
rawRecentEmoji.append(contentsOf: defaultEmoji[..<(defaultEmoji.count - rawRecentEmoji.count + 1)])
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawRecentEmoji.compactMap { EmojiWithSkinTones(rawValue: $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordRecentEmoji(_ emoji: EmojiWithSkinTones, transaction: YapDatabaseReadWriteTransaction) {
|
|
||||||
let recentEmoji = getRecentEmoji(withDefaultEmoji: false, transaction: transaction)
|
|
||||||
guard recentEmoji.first != emoji else { return }
|
|
||||||
guard emoji.isNormalized else {
|
|
||||||
recordRecentEmoji(emoji.normalized, transaction: transaction)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var newRecentEmoji = recentEmoji
|
|
||||||
|
|
||||||
// Remove any existing entries for this emoji
|
|
||||||
newRecentEmoji.removeAll { emoji == $0 }
|
|
||||||
// Insert the selected emoji at the start of the list
|
|
||||||
newRecentEmoji.insert(emoji, at: 0)
|
|
||||||
// Truncate the recent emoji list to a maximum of 50 stored
|
|
||||||
newRecentEmoji = Array(newRecentEmoji[0..<min(50, newRecentEmoji.count)])
|
|
||||||
|
|
||||||
transaction.setObject(newRecentEmoji.map { $0.rawValue }, forKey: Self.recentEmojiKey, inCollection: Self.emojiPickerCollection)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,278 @@ 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() {
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
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,436 @@
|
||||||
|
// 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() {
|
||||||
|
guard let image: UIImage = image else {
|
||||||
|
self.scrollView.minimumZoomScale = 1
|
||||||
|
self.scrollView.maximumZoomScale = 1
|
||||||
|
self.scrollView.zoomScale = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let viewSize: CGSize = self.scrollView.bounds.size
|
||||||
|
|
||||||
|
guard image.size.width > 0 && image.size.height > 0 else {
|
||||||
|
SNLog("Invalid image dimensions (\(image.size.width), \(image.size.height))")
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scaleWidth: CGFloat = (viewSize.width / image.size.width)
|
||||||
|
let scaleHeight: CGFloat = (viewSize.height / image.size.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
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue