Merge pull request #612 from mpretty-cyro/feature/database-refactor
Database refactor
This commit is contained in:
commit
7ec48baffa
49
Podfile
49
Podfile
|
@ -1,4 +1,4 @@
|
|||
platform :ios, '12.0'
|
||||
platform :ios, '13.0'
|
||||
source 'https://github.com/CocoaPods/Specs.git'
|
||||
|
||||
use_frameworks!
|
||||
|
@ -8,7 +8,12 @@ inhibit_all_warnings!
|
|||
abstract_target 'GlobalDependencies' do
|
||||
pod 'PromiseKit'
|
||||
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 'WebRTC-lib'
|
||||
pod 'SocketRocket', '~> 0.5.1'
|
||||
|
@ -18,14 +23,14 @@ abstract_target 'GlobalDependencies' do
|
|||
pod 'Reachability'
|
||||
pod 'PureLayout', '~> 3.1.8'
|
||||
pod 'NVActivityIndicatorView'
|
||||
pod 'YYImage', git: 'https://github.com/signalapp/YYImage'
|
||||
pod 'Mantle', git: 'https://github.com/signalapp/Mantle', branch: 'signal-master'
|
||||
pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
|
||||
pod 'ZXingObjC'
|
||||
pod 'DifferenceKit'
|
||||
end
|
||||
|
||||
# Dependencies to be included only in all extensions/frameworks
|
||||
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'
|
||||
|
||||
target 'SessionNotificationServiceExtension'
|
||||
|
@ -35,10 +40,10 @@ abstract_target 'GlobalDependencies' do
|
|||
abstract_target 'ExtendedDependencies' do
|
||||
pod 'AFNetworking'
|
||||
pod 'PureLayout', '~> 3.1.8'
|
||||
pod 'Mantle', git: 'https://github.com/signalapp/Mantle', branch: 'signal-master'
|
||||
|
||||
target 'SessionShareExtension' do
|
||||
pod 'NVActivityIndicatorView'
|
||||
pod 'DifferenceKit'
|
||||
end
|
||||
|
||||
target 'SignalUtilitiesKit' do
|
||||
|
@ -46,17 +51,34 @@ abstract_target 'GlobalDependencies' do
|
|||
pod 'Reachability'
|
||||
pod 'SAMKeychain'
|
||||
pod 'SwiftProtobuf', '~> 1.5.0'
|
||||
pod 'YYImage', git: 'https://github.com/signalapp/YYImage'
|
||||
pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
|
||||
pod 'DifferenceKit'
|
||||
end
|
||||
|
||||
target 'SessionMessagingKit' do
|
||||
pod 'Reachability'
|
||||
pod 'SAMKeychain'
|
||||
pod 'SwiftProtobuf', '~> 1.5.0'
|
||||
pod 'DifferenceKit'
|
||||
|
||||
target 'SessionMessagingKitTests' do
|
||||
inherit! :complete
|
||||
|
||||
pod 'Quick'
|
||||
pod 'Nimble'
|
||||
end
|
||||
end
|
||||
|
||||
target 'SessionUtilitiesKit' do
|
||||
pod 'SAMKeychain'
|
||||
pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
|
||||
|
||||
target 'SessionUtilitiesKitTests' do
|
||||
inherit! :complete
|
||||
|
||||
pod 'Quick'
|
||||
pod 'Nimble'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -69,6 +91,7 @@ target 'SessionUIKit'
|
|||
post_install do |installer|
|
||||
enable_whole_module_optimization_for_crypto_swift(installer)
|
||||
set_minimum_deployment_target(installer)
|
||||
enable_fts5_support(installer)
|
||||
end
|
||||
|
||||
def enable_whole_module_optimization_for_crypto_swift(installer)
|
||||
|
@ -85,7 +108,17 @@ end
|
|||
def set_minimum_deployment_target(installer)
|
||||
installer.pods_project.targets.each do |target|
|
||||
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
|
||||
|
|
76
Podfile.lock
76
Podfile.lock
|
@ -21,9 +21,24 @@ PODS:
|
|||
- Curve25519Kit (2.1.0):
|
||||
- CocoaLumberjack
|
||||
- SignalCoreKit
|
||||
- Mantle (2.1.0):
|
||||
- Mantle/extobjc (= 2.1.0)
|
||||
- Mantle/extobjc (2.1.0)
|
||||
- DifferenceKit (1.2.0):
|
||||
- DifferenceKit/Core (= 1.2.0)
|
||||
- DifferenceKit/UIKitExtension (= 1.2.0)
|
||||
- DifferenceKit/Core (1.2.0)
|
||||
- DifferenceKit/UIKitExtension (1.2.0):
|
||||
- DifferenceKit/Core
|
||||
- GRDB.swift/SQLCipher (5.26.0):
|
||||
- SQLCipher (>= 3.4.0)
|
||||
- libwebp (1.2.1):
|
||||
- libwebp/demux (= 1.2.1)
|
||||
- libwebp/mux (= 1.2.1)
|
||||
- libwebp/webp (= 1.2.1)
|
||||
- libwebp/demux (1.2.1):
|
||||
- libwebp/webp
|
||||
- libwebp/mux (1.2.1):
|
||||
- libwebp/demux
|
||||
- libwebp/webp (1.2.1)
|
||||
- Nimble (10.0.0)
|
||||
- NVActivityIndicatorView (5.1.1):
|
||||
- NVActivityIndicatorView/Base (= 5.1.1)
|
||||
- NVActivityIndicatorView/Base (5.1.1)
|
||||
|
@ -38,6 +53,7 @@ PODS:
|
|||
- PromiseKit/UIKit (6.15.3):
|
||||
- PromiseKit/CorePromise
|
||||
- PureLayout (3.1.9)
|
||||
- Quick (5.0.1)
|
||||
- Reachability (3.2)
|
||||
- SAMKeychain (1.5.3)
|
||||
- SignalCoreKit (1.0.0):
|
||||
|
@ -114,9 +130,10 @@ PODS:
|
|||
- YapDatabase/SQLCipher/Core
|
||||
- YapDatabase/SQLCipher/Extensions/View (3.1.1):
|
||||
- YapDatabase/SQLCipher/Core
|
||||
- YYImage (1.0.4):
|
||||
- YYImage/Core (= 1.0.4)
|
||||
- YYImage/Core (1.0.4)
|
||||
- YYImage/libwebp (1.0.4):
|
||||
- libwebp
|
||||
- YYImage/Core
|
||||
- ZXingObjC (3.6.5):
|
||||
- ZXingObjC/All (= 3.6.5)
|
||||
- ZXingObjC/All (3.6.5)
|
||||
|
@ -124,20 +141,24 @@ PODS:
|
|||
DEPENDENCIES:
|
||||
- AFNetworking
|
||||
- CryptoSwift
|
||||
- Curve25519Kit (from `https://github.com/signalapp/Curve25519Kit.git`)
|
||||
- Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`)
|
||||
- Curve25519Kit (from `https://github.com/oxen-io/session-ios-curve-25519-kit.git`, branch `session-version`)
|
||||
- DifferenceKit
|
||||
- GRDB.swift/SQLCipher
|
||||
- Nimble
|
||||
- NVActivityIndicatorView
|
||||
- PromiseKit
|
||||
- PureLayout (~> 3.1.8)
|
||||
- Quick
|
||||
- Reachability
|
||||
- SAMKeychain
|
||||
- SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`)
|
||||
- SocketRocket (~> 0.5.1)
|
||||
- Sodium (~> 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)
|
||||
- WebRTC-lib
|
||||
- YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`)
|
||||
- YYImage (from `https://github.com/signalapp/YYImage`)
|
||||
- YYImage/libwebp (from `https://github.com/signalapp/YYImage`)
|
||||
- ZXingObjC
|
||||
|
||||
SPEC REPOS:
|
||||
|
@ -145,14 +166,18 @@ SPEC REPOS:
|
|||
- AFNetworking
|
||||
- CocoaLumberjack
|
||||
- CryptoSwift
|
||||
- DifferenceKit
|
||||
- GRDB.swift
|
||||
- libwebp
|
||||
- Nimble
|
||||
- NVActivityIndicatorView
|
||||
- OpenSSL-Universal
|
||||
- PromiseKit
|
||||
- PureLayout
|
||||
- Quick
|
||||
- Reachability
|
||||
- SAMKeychain
|
||||
- SocketRocket
|
||||
- Sodium
|
||||
- SQLCipher
|
||||
- SwiftProtobuf
|
||||
- WebRTC-lib
|
||||
|
@ -160,13 +185,14 @@ SPEC REPOS:
|
|||
|
||||
EXTERNAL SOURCES:
|
||||
Curve25519Kit:
|
||||
:git: https://github.com/signalapp/Curve25519Kit.git
|
||||
Mantle:
|
||||
:branch: signal-master
|
||||
:git: https://github.com/signalapp/Mantle
|
||||
:branch: session-version
|
||||
:git: https://github.com/oxen-io/session-ios-curve-25519-kit.git
|
||||
SignalCoreKit:
|
||||
:branch: session-version
|
||||
: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:
|
||||
:branch: signal-release
|
||||
:git: https://github.com/oxen-io/session-ios-yap-database.git
|
||||
|
@ -175,14 +201,14 @@ EXTERNAL SOURCES:
|
|||
|
||||
CHECKOUT OPTIONS:
|
||||
Curve25519Kit:
|
||||
:commit: 4fc1c10e98fff2534b5379a9bb587430fdb8e577
|
||||
:git: https://github.com/signalapp/Curve25519Kit.git
|
||||
Mantle:
|
||||
:commit: e7e46253bb01ce39525d90aa69ed9e85e758bfc4
|
||||
:git: https://github.com/signalapp/Mantle
|
||||
:commit: b79c2ace600bfd3784e9c33cf1f254b121312edc
|
||||
:git: https://github.com/oxen-io/session-ios-curve-25519-kit.git
|
||||
SignalCoreKit:
|
||||
:commit: 4590c2737a2b5dc0ef4ace9f9019b581caccc1de
|
||||
: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:
|
||||
:commit: d84069e25e12a16ab4422e5258127a04b70489ad
|
||||
:git: https://github.com/oxen-io/session-ios-yap-database.git
|
||||
|
@ -195,16 +221,20 @@ SPEC CHECKSUMS:
|
|||
CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646
|
||||
CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17
|
||||
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
|
||||
Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b
|
||||
DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805
|
||||
GRDB.swift: 1395cb3556df6b16ed69dfc74c3886abc75d2825
|
||||
libwebp: 98a37e597e40bfdb4c911fc98f2c53d0b12d05fc
|
||||
Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84
|
||||
NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667
|
||||
OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2
|
||||
PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5
|
||||
PureLayout: 5fb5e5429519627d60d079ccb1eaa7265ce7cf88
|
||||
Quick: 749aa754fd1e7d984f2000fe051e18a3a9809179
|
||||
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d
|
||||
SocketRocket: d57c7159b83c3c6655745cd15302aa24b6bae531
|
||||
Sodium: 23d11554ecd556196d313cf6130d406dfe7ac6da
|
||||
Sodium: a7d42cb46e789d2630fa552d35870b416ed055ae
|
||||
SQLCipher: 98dc22f27c0b1790d39e710d440f22a466ebdb59
|
||||
SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2
|
||||
WebRTC-lib: 508fe02efa0c1a3a8867082a77d24c9be5d29aeb
|
||||
|
@ -212,6 +242,6 @@ SPEC CHECKSUMS:
|
|||
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
|
||||
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
||||
|
||||
PODFILE CHECKSUM: a3d89a6cc8735285fd51348ca05cea71f2c28872
|
||||
PODFILE CHECKSUM: f0857369c4831b2e5c1946345e76e493f3286805
|
||||
|
||||
COCOAPODS: 1.11.2
|
||||
COCOAPODS: 1.11.3
|
||||
|
|
|
@ -0,0 +1,251 @@
|
|||
#!/usr/bin/xcrun --sdk macosx swift
|
||||
|
||||
//
|
||||
// ListLocalizableStrings.swift
|
||||
// Archa
|
||||
//
|
||||
// Created by Morgan Pretty on 18/5/20.
|
||||
// Copyright © 2020 Archa. All rights reserved.
|
||||
//
|
||||
// This script is based on https://github.com/ginowu7/CleanSwiftLocalizableExample the main difference
|
||||
// is canges to the localized usage regex
|
||||
|
||||
import Foundation
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let currentPath = (
|
||||
ProcessInfo.processInfo.environment["PROJECT_DIR"] ?? fileManager.currentDirectoryPath
|
||||
)
|
||||
|
||||
/// List of files in currentPath - recursive
|
||||
var pathFiles: [String] = {
|
||||
guard let enumerator = fileManager.enumerator(atPath: currentPath), let files = enumerator.allObjects as? [String] else {
|
||||
fatalError("Could not locate files in path directory: \(currentPath)")
|
||||
}
|
||||
|
||||
return files
|
||||
}()
|
||||
|
||||
|
||||
/// List of localizable files - not including Localizable files in the Pods
|
||||
var localizableFiles: [String] = {
|
||||
return pathFiles
|
||||
.filter {
|
||||
$0.hasSuffix("Localizable.strings") &&
|
||||
!$0.contains(".app/") && // Exclude Built Localizable.strings files
|
||||
!$0.contains("Pods") // Exclude Pods
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
/// List of executable files
|
||||
var executableFiles: [String] = {
|
||||
return pathFiles.filter {
|
||||
!$0.localizedCaseInsensitiveContains("test") && // Exclude test files
|
||||
!$0.contains(".app/") && // Exclude Built Localizable.strings files
|
||||
!$0.contains("Pods") && // Exclude Pods
|
||||
(
|
||||
NSString(string: $0).pathExtension == "swift" ||
|
||||
NSString(string: $0).pathExtension == "m"
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
/// Reads contents in path
|
||||
///
|
||||
/// - Parameter path: path of file
|
||||
/// - Returns: content in file
|
||||
func contents(atPath path: String) -> String {
|
||||
print("Path: \(path)")
|
||||
guard let data = fileManager.contents(atPath: path), let content = String(data: data, encoding: .utf8) else {
|
||||
fatalError("Could not read from path: \(path)")
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/// Returns a list of strings that match regex pattern from content
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - pattern: regex pattern
|
||||
/// - content: content to match
|
||||
/// - Returns: list of results
|
||||
func regexFor(_ pattern: String, content: String) -> [String] {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
|
||||
fatalError("Regex not formatted correctly: \(pattern)")
|
||||
}
|
||||
|
||||
let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: content.utf16.count))
|
||||
|
||||
return matches.map {
|
||||
guard let range = Range($0.range(at: 0), in: content) else {
|
||||
fatalError("Incorrect range match")
|
||||
}
|
||||
|
||||
return String(content[range])
|
||||
}
|
||||
}
|
||||
|
||||
func create() -> [LocalizationStringsFile] {
|
||||
return localizableFiles.map(LocalizationStringsFile.init(path:))
|
||||
}
|
||||
|
||||
///
|
||||
///
|
||||
/// - Returns: A list of LocalizationCodeFile - contains path of file and all keys in it
|
||||
func localizedStringsInCode() -> [LocalizationCodeFile] {
|
||||
return executableFiles.compactMap {
|
||||
let content = contents(atPath: $0)
|
||||
// Note: Need to exclude escaped quotation marks from strings
|
||||
let matchesOld = regexFor("(?<=NSLocalizedString\\()\\s*\"(?!.*?%d)(.*?)\"", content: content)
|
||||
let matchesNew = regexFor("\"(?!.*?%d)([^(\\\")]*?)\"(?=\\s*)(?=\\.localized)", content: content)
|
||||
let allMatches = (matchesOld + matchesNew)
|
||||
|
||||
return allMatches.isEmpty ? nil : LocalizationCodeFile(path: $0, keys: Set(allMatches))
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws error if ALL localizable files does not have matching keys
|
||||
///
|
||||
/// - Parameter files: list of localizable files to validate
|
||||
func validateMatchKeys(_ files: [LocalizationStringsFile]) {
|
||||
print("------------ Validating keys match in all localizable files ------------")
|
||||
|
||||
guard let base = files.first, files.count > 1 else { return }
|
||||
|
||||
let files = Array(files.dropFirst())
|
||||
|
||||
files.forEach {
|
||||
guard let extraKey = Set(base.keys).symmetricDifference($0.keys).first else { return }
|
||||
let incorrectFile = $0.keys.contains(extraKey) ? $0 : base
|
||||
printPretty("error: Found extra key: \(extraKey) in file: \(incorrectFile.path)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws error if localizable files are missing keys
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - codeFiles: Array of LocalizationCodeFile
|
||||
/// - localizationFiles: Array of LocalizableStringFiles
|
||||
func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
|
||||
print("------------ Checking for missing keys -----------")
|
||||
|
||||
guard let baseFile = localizationFiles.first else {
|
||||
fatalError("Could not locate base localization file")
|
||||
}
|
||||
|
||||
let baseKeys = Set(baseFile.keys)
|
||||
|
||||
codeFiles.forEach {
|
||||
let extraKeys = $0.keys.subtracting(baseKeys)
|
||||
if !extraKeys.isEmpty {
|
||||
printPretty("error: Found keys in code missing in strings file: \(extraKeys) from \($0.path)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws warning if keys exist in localizable file but are not being used
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - codeFiles: Array of LocalizationCodeFile
|
||||
/// - localizationFiles: Array of LocalizableStringFiles
|
||||
func validateDeadKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
|
||||
print("------------ Checking for any dead keys in localizable file -----------")
|
||||
|
||||
guard let baseFile = localizationFiles.first else {
|
||||
fatalError("Could not locate base localization file")
|
||||
}
|
||||
|
||||
let baseKeys: Set<String> = Set(baseFile.keys)
|
||||
let allCodeFileKeys: [String] = codeFiles.flatMap { $0.keys }
|
||||
let deadKeys: [String] = Array(baseKeys.subtracting(allCodeFileKeys))
|
||||
.sorted()
|
||||
.map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) }
|
||||
|
||||
if !deadKeys.isEmpty {
|
||||
printPretty("warning: \(deadKeys) - Suggest cleaning dead keys")
|
||||
}
|
||||
}
|
||||
|
||||
protocol Pathable {
|
||||
var path: String { get }
|
||||
}
|
||||
|
||||
struct LocalizationStringsFile: Pathable {
|
||||
let path: String
|
||||
let kv: [String: String]
|
||||
|
||||
var keys: [String] {
|
||||
return Array(kv.keys)
|
||||
}
|
||||
|
||||
init(path: String) {
|
||||
self.path = path
|
||||
self.kv = ContentParser.parse(path)
|
||||
}
|
||||
|
||||
/// Writes back to localizable file with sorted keys and removed whitespaces and new lines
|
||||
func cleanWrite() {
|
||||
print("------------ Sort and remove whitespaces: \(path) ------------")
|
||||
let content = kv.keys.sorted().map { "\($0) = \(kv[$0]!);" }.joined(separator: "\n")
|
||||
try! content.write(toFile: path, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct LocalizationCodeFile: Pathable {
|
||||
let path: String
|
||||
let keys: Set<String>
|
||||
}
|
||||
|
||||
struct ContentParser {
|
||||
|
||||
/// Parses contents of a file to localizable keys and values - Throws error if localizable file have duplicated keys
|
||||
///
|
||||
/// - Parameter path: Localizable file paths
|
||||
/// - Returns: localizable key and value for content at path
|
||||
static func parse(_ path: String) -> [String: String] {
|
||||
print("------------ Checking for duplicate keys: \(path) ------------")
|
||||
|
||||
let content = contents(atPath: path)
|
||||
let trimmed = content
|
||||
.replacingOccurrences(of: "\n+", with: "", options: .regularExpression, range: nil)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let keys = regexFor("\"([^\"]*?)\"(?= =)", content: trimmed)
|
||||
let values = regexFor("(?<== )\"(.*?)\"(?=;)", content: trimmed)
|
||||
|
||||
if keys.count != values.count {
|
||||
fatalError("Error parsing contents: Make sure all keys and values are in correct format (this could be due to extra spaces between keys and values)")
|
||||
}
|
||||
|
||||
return zip(keys, values).reduce(into: [String: String]()) { results, keyValue in
|
||||
if results[keyValue.0] != nil {
|
||||
printPretty("error: Found duplicate key: \(keyValue.0) in file: \(path)")
|
||||
abort()
|
||||
}
|
||||
results[keyValue.0] = keyValue.1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printPretty(_ string: String) {
|
||||
print(string.replacingOccurrences(of: "\\", with: ""))
|
||||
}
|
||||
|
||||
let stringFiles = create()
|
||||
|
||||
if !stringFiles.isEmpty {
|
||||
print("------------ Found \(stringFiles.count) file(s) ------------")
|
||||
|
||||
stringFiles.forEach { print($0.path) }
|
||||
validateMatchKeys(stringFiles)
|
||||
|
||||
// Note: Uncomment the below file to clean out all comments from the localizable file (we don't want this because comments make it readable...)
|
||||
// stringFiles.forEach { $0.cleanWrite() }
|
||||
|
||||
let codeFiles = localizedStringsInCode()
|
||||
validateMissingKeys(codeFiles, localizationFiles: stringFiles)
|
||||
validateDeadKeys(codeFiles, localizationFiles: stringFiles)
|
||||
}
|
||||
|
||||
print("------------ SUCCESS ------------")
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1020"
|
||||
LastUpgradeVersion = "1320"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
@ -20,27 +20,15 @@
|
|||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</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>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "NO">
|
||||
shouldUseLaunchSchemeArgsEnv = "NO"
|
||||
codeCoverageEnabled = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
|
@ -57,173 +45,87 @@
|
|||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</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>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D221A0A9169C9E5F00537ABF"
|
||||
BuildableName = "SignalTests.xctest"
|
||||
BlueprintName = "SignalTests"
|
||||
BlueprintIdentifier = "FDC4388D27B9FFC700C60D73"
|
||||
BuildableName = "SessionMessagingKitTests.xctest"
|
||||
BlueprintName = "SessionMessagingKitTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B772E882F193AA2F25932C514BBF0805"
|
||||
BuildableName = "SignalServiceKit-Unit-Tests.xctest"
|
||||
BlueprintName = "SignalServiceKit-Unit-Tests"
|
||||
ReferencedContainer = "container:Pods/Pods.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">
|
||||
BlueprintIdentifier = "FD83B9AE27CF200A005E1583"
|
||||
BuildableName = "SessionUtilitiesKitTests.xctest"
|
||||
BlueprintName = "SessionUtilitiesKitTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</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"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1140"
|
||||
LastUpgradeVersion = "1320"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
|
@ -43,6 +43,16 @@
|
|||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FDC4388027B9FF1E00C60D73"
|
||||
BuildableName = "SessionTests.xctest"
|
||||
BlueprintName = "SessionTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
|
@ -73,6 +83,7 @@
|
|||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1020"
|
||||
LastUpgradeVersion = "1320"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
|
@ -52,6 +52,16 @@
|
|||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FDC4388027B9FF1E00C60D73"
|
||||
BuildableName = "SessionTests.xctest"
|
||||
BlueprintName = "SessionTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
|
@ -83,6 +93,7 @@
|
|||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
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"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1210"
|
||||
LastUpgradeVersion = "1320"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
|
|
@ -1,37 +1,33 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import WebRTC
|
||||
import SessionMessagingKit
|
||||
import PromiseKit
|
||||
import CallKit
|
||||
import GRDB
|
||||
import WebRTC
|
||||
import PromiseKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
|
||||
public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
||||
|
||||
public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
||||
@objc static let isEnabled = true
|
||||
|
||||
// MARK: Metadata Properties
|
||||
let uuid: String
|
||||
let callID: UUID // This is for CallKit
|
||||
let sessionID: String
|
||||
let mode: Mode
|
||||
// MARK: - Metadata Properties
|
||||
public let uuid: String
|
||||
public let callId: UUID // This is for CallKit
|
||||
let sessionId: String
|
||||
let mode: CallMode
|
||||
var audioMode: AudioMode
|
||||
let webRTCSession: WebRTCSession
|
||||
public let webRTCSession: WebRTCSession
|
||||
let isOutgoing: Bool
|
||||
var remoteSDP: RTCSessionDescription? = nil
|
||||
var callMessageID: String?
|
||||
var callInteractionId: Int64?
|
||||
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 = {
|
||||
return RTCCameraVideoCapturer(delegate: webRTCSession.localVideoSource)
|
||||
}()
|
||||
|
@ -61,21 +57,8 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: Mode
|
||||
enum Mode {
|
||||
case offer
|
||||
case answer
|
||||
}
|
||||
// MARK: - Audio I/O mode
|
||||
|
||||
// MARK: End call mode
|
||||
enum EndCallMode {
|
||||
case local
|
||||
case remote
|
||||
case unanswered
|
||||
case answeredElsewhere
|
||||
}
|
||||
|
||||
// MARK: Audio I/O mode
|
||||
enum AudioMode {
|
||||
case earpiece
|
||||
case speaker
|
||||
|
@ -83,7 +66,8 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
|||
case bluetooth
|
||||
}
|
||||
|
||||
// MARK: Call State Properties
|
||||
// MARK: - Call State Properties
|
||||
|
||||
var connectingDate: Date? {
|
||||
didSet {
|
||||
stateDidChange?()
|
||||
|
@ -112,7 +96,8 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: State Change Callbacks
|
||||
// MARK: - State Change Callbacks
|
||||
|
||||
var stateDidChange: (() -> Void)?
|
||||
var hasStartedConnectingDidChange: (() -> Void)?
|
||||
var hasConnectedDidChange: (() -> Void)?
|
||||
|
@ -121,8 +106,9 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
|||
var hasStartedReconnecting: (() -> Void)?
|
||||
var hasReconnected: (() -> Void)?
|
||||
|
||||
// MARK: Derived Properties
|
||||
var hasStartedConnecting: Bool {
|
||||
// MARK: - Derived Properties
|
||||
|
||||
public var hasStartedConnecting: Bool {
|
||||
get { return connectingDate != nil }
|
||||
set { connectingDate = newValue ? Date() : nil }
|
||||
}
|
||||
|
@ -153,73 +139,114 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
|||
|
||||
var reconnectTimer: Timer? = nil
|
||||
|
||||
// MARK: Initialization
|
||||
init(for sessionID: String, uuid: String, mode: Mode, outgoing: Bool = false) {
|
||||
self.sessionID = sessionID
|
||||
// MARK: - Initialization
|
||||
|
||||
init(_ db: Database, for sessionId: String, uuid: String, mode: CallMode, outgoing: Bool = false) {
|
||||
self.sessionId = sessionId
|
||||
self.uuid = uuid
|
||||
self.callID = UUID()
|
||||
self.callId = UUID()
|
||||
self.mode = mode
|
||||
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.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
|
||||
super.init()
|
||||
self.webRTCSession.delegate = self
|
||||
|
||||
if AppEnvironment.shared.callManager.currentCall == nil {
|
||||
AppEnvironment.shared.callManager.currentCall = self
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
SNLog("[Calls] A call is ongoing.")
|
||||
}
|
||||
}
|
||||
|
||||
func reportIncomingCallIfNeeded(completion: @escaping (Error?) -> Void) {
|
||||
guard case .answer = mode else { return }
|
||||
guard case .answer = mode else {
|
||||
SessionCallManager.reportFakeCall(info: "Call not in answer mode")
|
||||
return
|
||||
}
|
||||
|
||||
setupTimeoutTimer()
|
||||
AppEnvironment.shared.callManager.reportIncomingCall(self, callerName: contactName) { error in
|
||||
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.")
|
||||
remoteSDP = sdp
|
||||
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
|
||||
func startSessionCall() {
|
||||
guard case .offer = mode else { return }
|
||||
guard let thread = TSContactThread.fetch(uniqueId: TSContactThread.threadID(fromContactSessionID: sessionID)) else { return }
|
||||
// MARK: - Actions
|
||||
|
||||
let message = CallMessage()
|
||||
message.sender = getUserHexEncodedPublicKey()
|
||||
message.sentTimestamp = NSDate.millisecondTimestamp()
|
||||
message.uuid = self.uuid
|
||||
message.kind = .preOffer
|
||||
let infoMessage = TSInfoMessage.from(message, associatedWith: thread)
|
||||
infoMessage.save()
|
||||
self.callMessageID = infoMessage.uniqueId
|
||||
public func startSessionCall(_ db: Database) {
|
||||
let sessionId: String = self.sessionId
|
||||
let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .outgoing)
|
||||
|
||||
var promise: Promise<Void>!
|
||||
Storage.write(with: { transaction in
|
||||
promise = self.webRTCSession.sendPreOffer(message, in: thread, using: transaction)
|
||||
}, completion: { [weak self] in
|
||||
let _ = promise.done {
|
||||
Storage.shared.write { transaction in
|
||||
self?.webRTCSession.sendOffer(to: self!.sessionID, using: transaction as! YapDatabaseReadWriteTransaction).retainUntilComplete()
|
||||
guard
|
||||
case .offer = mode,
|
||||
let messageInfoData: Data = try? JSONEncoder().encode(messageInfo),
|
||||
let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId)
|
||||
else { return }
|
||||
|
||||
let timestampMs: Int64 = Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
let message: CallMessage = CallMessage(
|
||||
uuid: self.uuid,
|
||||
kind: .preOffer,
|
||||
sdps: [],
|
||||
sentTimestampMs: UInt64(timestampMs)
|
||||
)
|
||||
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()
|
||||
}
|
||||
})
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
func answerSessionCall() {
|
||||
guard case .answer = mode else { return }
|
||||
|
||||
hasStartedConnecting = true
|
||||
|
||||
if let sdp = remoteSDP {
|
||||
webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally
|
||||
webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -230,47 +257,79 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
|||
|
||||
func endSessionCall() {
|
||||
guard !hasEnded else { return }
|
||||
|
||||
let sessionId: String = self.sessionId
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// MARK: Update call message
|
||||
func updateCallMessage(mode: EndCallMode) {
|
||||
guard let callMessageID = callMessageID else { return }
|
||||
Storage.write { transaction in
|
||||
let infoMessage = TSInfoMessage.fetch(uniqueId: callMessageID, transaction: transaction)
|
||||
if let messageToUpdate = infoMessage {
|
||||
var shouldMarkAsRead = false
|
||||
if self.duration > 0 {
|
||||
shouldMarkAsRead = true
|
||||
} else if self.hasStartedConnecting {
|
||||
shouldMarkAsRead = true
|
||||
} 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)
|
||||
}
|
||||
// MARK: - Call Message Handling
|
||||
|
||||
public func updateCallMessage(mode: EndCallMode) {
|
||||
guard let callInteractionId: Int64 = callInteractionId else { return }
|
||||
|
||||
let duration: TimeInterval = self.duration
|
||||
let hasStartedConnecting: Bool = self.hasStartedConnecting
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
guard let interaction: Interaction = try? Interaction.fetchOne(db, id: callInteractionId) else {
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
webRTCSession.attachRemoteRenderer(renderer)
|
||||
}
|
||||
|
@ -283,14 +342,17 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
|||
webRTCSession.attachLocalRenderer(renderer)
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
// MARK: - Delegate
|
||||
|
||||
public func webRTCIsConnected() {
|
||||
self.invalidateTimeoutTimer()
|
||||
self.reconnectTimer?.invalidate()
|
||||
|
||||
guard !self.hasConnected else {
|
||||
hasReconnected?()
|
||||
return
|
||||
}
|
||||
|
||||
self.hasConnected = true
|
||||
self.answerCallAction?.fulfill()
|
||||
}
|
||||
|
@ -327,23 +389,32 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
|||
|
||||
private func tryToReconnect() {
|
||||
reconnectTimer?.invalidate()
|
||||
if SSKEnvironment.shared.reachabilityManager.isReachable {
|
||||
Storage.write { transaction in
|
||||
self.webRTCSession.sendOffer(to: self.sessionID, using: transaction, isRestartingICEConnection: true).retainUntilComplete()
|
||||
}
|
||||
} else {
|
||||
|
||||
guard Environment.shared?.reachabilityManager.isReachable == true else {
|
||||
reconnectTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 5, repeats: false) { _ in
|
||||
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() {
|
||||
invalidateTimeoutTimer()
|
||||
let timeInterval: TimeInterval = hasConnected ? 60 : 30
|
||||
|
||||
let timeInterval: TimeInterval = (hasConnected ? 60 : 30)
|
||||
|
||||
timeOutTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: timeInterval, repeats: false) { _ in
|
||||
self.didTimeout = true
|
||||
|
||||
AppEnvironment.shared.callManager.endCall(self) { error in
|
||||
self.timeOutTimer = nil
|
||||
}
|
||||
|
|
|
@ -1,24 +1,37 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
|
||||
extension SessionCallManager {
|
||||
@discardableResult
|
||||
public func startCallAction() -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
call.startSessionCall()
|
||||
guard let call: CurrentCallProtocol = self.currentCall else { return false }
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
call.startSessionCall(db)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
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 {
|
||||
call.answerSessionCall()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
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 {
|
||||
callVC.conversationVC = conversationVC
|
||||
conversationVC.inputAccessoryView?.isHidden = true
|
||||
conversationVC.inputAccessoryView?.alpha = 0
|
||||
}
|
||||
|
||||
presentingVC.present(callVC, animated: true) {
|
||||
call.answerSessionCall()
|
||||
}
|
||||
|
@ -28,20 +41,26 @@ extension SessionCallManager {
|
|||
|
||||
@discardableResult
|
||||
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()
|
||||
|
||||
if call.didTimeout {
|
||||
reportCurrentCallEnded(reason: .unanswered)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
reportCurrentCallEnded(reason: nil)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
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
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import CallKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
|
@ -5,10 +8,12 @@ extension SessionCallManager {
|
|||
public func startCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||
guard case .offer = call.mode else { return }
|
||||
guard !call.hasConnected else { return }
|
||||
|
||||
reportOutgoingCall(call)
|
||||
|
||||
if callController != nil {
|
||||
let handle = CXHandle(type: .generic, value: call.sessionID)
|
||||
let startCallAction = CXStartCallAction(call: call.callID, handle: handle)
|
||||
let handle = CXHandle(type: .generic, value: call.sessionId)
|
||||
let startCallAction = CXStartCallAction(call: call.callId, handle: handle)
|
||||
|
||||
startCallAction.isVideo = false
|
||||
|
||||
|
@ -16,7 +21,8 @@ extension SessionCallManager {
|
|||
transaction.addAction(startCallAction)
|
||||
|
||||
requestTransaction(transaction, completion: completion)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
startCallAction()
|
||||
completion?(nil)
|
||||
}
|
||||
|
@ -24,12 +30,13 @@ extension SessionCallManager {
|
|||
|
||||
public func answerCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||
if callController != nil {
|
||||
let answerCallAction = CXAnswerCallAction(call: call.callID)
|
||||
let answerCallAction = CXAnswerCallAction(call: call.callId)
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(answerCallAction)
|
||||
|
||||
requestTransaction(transaction, completion: completion)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
answerCallAction()
|
||||
completion?(nil)
|
||||
}
|
||||
|
@ -37,12 +44,13 @@ extension SessionCallManager {
|
|||
|
||||
public func endCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||
if callController != nil {
|
||||
let endCallAction = CXEndCallAction(call: call.callID)
|
||||
let endCallAction = CXEndCallAction(call: call.callId)
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(endCallAction)
|
||||
|
||||
requestTransaction(transaction, completion: completion)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
endCallAction()
|
||||
completion?(nil)
|
||||
}
|
||||
|
@ -51,7 +59,7 @@ extension SessionCallManager {
|
|||
// Not currently in use
|
||||
public func setOnHoldStatus(for call: SessionCall) {
|
||||
if callController != nil {
|
||||
let setHeldCallAction = CXSetHeldCallAction(call: call.callID, onHold: true)
|
||||
let setHeldCallAction = CXSetHeldCallAction(call: call.callId, onHold: true)
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(setHeldCallAction)
|
||||
|
||||
|
@ -63,9 +71,11 @@ extension SessionCallManager {
|
|||
callController?.request(transaction) { error in
|
||||
if let error = error {
|
||||
SNLog("Error requesting transaction: \(error)")
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
SNLog("Requested transaction successfully")
|
||||
}
|
||||
|
||||
completion?(error)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import CallKit
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension SessionCallManager: CXProviderDelegate {
|
||||
public func providerDidReset(_ provider: CXProvider) {
|
||||
AssertIsOnMainThread()
|
||||
currentCall?.endSessionCall()
|
||||
(currentCall as? SessionCall)?.endSessionCall()
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||
AssertIsOnMainThread()
|
||||
if startCallAction() {
|
||||
action.fulfill()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
@ -18,14 +24,18 @@ extension SessionCallManager: CXProviderDelegate {
|
|||
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||
AssertIsOnMainThread()
|
||||
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 answerCallAction() {
|
||||
action.fulfill()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
action.fail()
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
call.answerSessionCallInBackground(action: action)
|
||||
}
|
||||
}
|
||||
|
@ -33,9 +43,11 @@ extension SessionCallManager: CXProviderDelegate {
|
|||
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
||||
print("[CallKit] Perform CXEndCallAction")
|
||||
AssertIsOnMainThread()
|
||||
|
||||
if endCallAction() {
|
||||
action.fulfill()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
@ -43,9 +55,11 @@ extension SessionCallManager: CXProviderDelegate {
|
|||
public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
||||
print("[CallKit] Perform CXSetMutedCallAction, isMuted: \(action.isMuted)")
|
||||
AssertIsOnMainThread()
|
||||
|
||||
if setMutedCallAction(isMuted: action.isMuted) {
|
||||
action.fulfill()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
@ -61,7 +75,8 @@ extension SessionCallManager: CXProviderDelegate {
|
|||
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
||||
print("[CallKit] Audio session did activate.")
|
||||
AssertIsOnMainThread()
|
||||
guard let call = self.currentCall else { return }
|
||||
guard let call: SessionCall = (self.currentCall as? SessionCall) else { return }
|
||||
|
||||
call.webRTCSession.audioSessionDidActivate(audioSession)
|
||||
if call.isOutgoing && !call.hasConnected { CallRingTonePlayer.shared.startPlayingRingTone() }
|
||||
}
|
||||
|
@ -69,7 +84,8 @@ extension SessionCallManager: CXProviderDelegate {
|
|||
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
||||
print("[CallKit] Audio session did deactivate.")
|
||||
AssertIsOnMainThread()
|
||||
guard let call = self.currentCall else { return }
|
||||
guard let call: SessionCall = (self.currentCall as? SessionCall) else { return }
|
||||
|
||||
call.webRTCSession.audioSessionDidDeactivate(audioSession)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import CallKit
|
||||
import GRDB
|
||||
import SessionMessagingKit
|
||||
|
||||
public final class SessionCallManager: NSObject {
|
||||
public final class SessionCallManager: NSObject, CallManagerProtocol {
|
||||
let provider: CXProvider?
|
||||
let callController: CXCallController?
|
||||
var currentCall: SessionCall? = nil {
|
||||
|
||||
public var currentCall: CurrentCallProtocol? = nil {
|
||||
willSet {
|
||||
if (newValue != nil) {
|
||||
DispatchQueue.main.async {
|
||||
|
@ -19,13 +24,14 @@ public final class SessionCallManager: NSObject {
|
|||
}
|
||||
|
||||
private static var _sharedProvider: CXProvider?
|
||||
class func sharedProvider(useSystemCallLog: Bool) -> CXProvider {
|
||||
static func sharedProvider(useSystemCallLog: Bool) -> CXProvider {
|
||||
let configuration = buildProviderConfiguration(useSystemCallLog: useSystemCallLog)
|
||||
|
||||
if let sharedProvider = self._sharedProvider {
|
||||
sharedProvider.configuration = configuration
|
||||
return sharedProvider
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
SwiftSingletons.register(self)
|
||||
let provider = CXProvider(configuration: configuration)
|
||||
_sharedProvider = provider
|
||||
|
@ -33,9 +39,8 @@ public final class SessionCallManager: NSObject {
|
|||
}
|
||||
}
|
||||
|
||||
class func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration {
|
||||
let localizedName = NSLocalizedString("APPLICATION_NAME", comment: "Name of application")
|
||||
let providerConfiguration = CXProviderConfiguration(localizedName: localizedName)
|
||||
static func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration {
|
||||
let providerConfiguration = CXProviderConfiguration(localizedName: "Session")
|
||||
providerConfiguration.supportsVideo = true
|
||||
providerConfiguration.maximumCallGroups = 1
|
||||
providerConfiguration.maximumCallsPerCallGroup = 1
|
||||
|
@ -47,30 +52,47 @@ public final class SessionCallManager: NSObject {
|
|||
return providerConfiguration
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(useSystemCallLog: Bool = false) {
|
||||
AssertIsOnMainThread()
|
||||
if SSKPreferences.isCallKitSupported {
|
||||
self.provider = type(of: self).sharedProvider(useSystemCallLog: useSystemCallLog)
|
||||
if Preferences.isCallKitSupported {
|
||||
self.provider = SessionCallManager.sharedProvider(useSystemCallLog: useSystemCallLog)
|
||||
self.callController = CXCallController()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
self.provider = nil
|
||||
self.callController = nil
|
||||
}
|
||||
|
||||
super.init()
|
||||
|
||||
// We cannot assert singleton here, because this class gets rebuilt when the user changes relevant call settings
|
||||
self.provider?.setDelegate(self, queue: nil)
|
||||
}
|
||||
|
||||
// MARK: Report calls
|
||||
// MARK: - Report calls
|
||||
|
||||
public static func reportFakeCall(info: String) {
|
||||
SessionCallManager.sharedProvider(useSystemCallLog: false)
|
||||
.reportNewIncomingCall(
|
||||
with: UUID(),
|
||||
update: CXCallUpdate()
|
||||
) { _ in
|
||||
SNLog("[Calls] Reported fake incoming call to CallKit due to: \(info)")
|
||||
}
|
||||
}
|
||||
|
||||
public func reportOutgoingCall(_ call: SessionCall) {
|
||||
AssertIsOnMainThread()
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
||||
UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing")
|
||||
|
||||
call.stateDidChange = {
|
||||
if call.hasStartedConnecting {
|
||||
self.provider?.reportOutgoingCall(with: call.callID, startedConnectingAt: call.connectingDate)
|
||||
self.provider?.reportOutgoingCall(with: call.callId, startedConnectingAt: call.connectingDate)
|
||||
}
|
||||
|
||||
if call.hasConnected {
|
||||
self.provider?.reportOutgoingCall(with: call.callID, connectedAt: call.connectedDate)
|
||||
self.provider?.reportOutgoingCall(with: call.callId, connectedAt: call.connectedDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -82,47 +104,61 @@ public final class SessionCallManager: NSObject {
|
|||
// Construct a CXCallUpdate describing the incoming call, including the caller.
|
||||
let update = CXCallUpdate()
|
||||
update.localizedCallerName = callerName
|
||||
update.remoteHandle = CXHandle(type: .generic, value: call.callID.uuidString)
|
||||
update.remoteHandle = CXHandle(type: .generic, value: call.callId.uuidString)
|
||||
update.hasVideo = false
|
||||
|
||||
disableUnsupportedFeatures(callUpdate: update)
|
||||
|
||||
// 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 {
|
||||
self.reportCurrentCallEnded(reason: .failed)
|
||||
completion(error)
|
||||
return
|
||||
}
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
||||
UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing")
|
||||
completion(nil)
|
||||
}
|
||||
} else {
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
||||
}
|
||||
else {
|
||||
SessionCallManager.reportFakeCall(info: "No CXProvider instance")
|
||||
UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing")
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
|
||||
public func reportCurrentCallEnded(reason: CXCallEndedReason?) {
|
||||
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)
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
self.reportCurrentCallEnded(reason: reason)
|
||||
}
|
||||
} 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.webRTCSession.dropConnection()
|
||||
self.currentCall = 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) {
|
||||
// Call Holding is failing to restart audio when "swapping" calls on the CallKit screen
|
||||
// until user returns to in-app call screen.
|
||||
|
@ -136,17 +172,67 @@ public final class SessionCallManager: NSObject {
|
|||
callUpdate.supportsDTMF = false
|
||||
}
|
||||
|
||||
public func handleIncomingCallOfferInBusyState(offerMessage: CallMessage, using transaction: YapDatabaseReadWriteTransaction) {
|
||||
guard let caller = offerMessage.sender, let thread = TSContactThread.fetch(for: caller, using: transaction) else { return }
|
||||
let message = CallMessage()
|
||||
message.uuid = offerMessage.uuid
|
||||
message.kind = .endCall
|
||||
SNLog("[Calls] Sending end call message because there is an ongoing call.")
|
||||
MessageSender.sendNonDurably(message, in: thread, using: transaction).retainUntilComplete()
|
||||
let infoMessage = TSInfoMessage.from(offerMessage, associatedWith: thread)
|
||||
infoMessage.updateCallInfoMessage(.missed, using: transaction)
|
||||
// MARK: - UI
|
||||
|
||||
public func showCallUIForCall(caller: String, uuid: String, mode: CallMode, interactionId: Int64?) {
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
self.showCallUIForCall(caller: caller, uuid: uuid, mode: mode, interactionId: interactionId)
|
||||
}
|
||||
return
|
||||
}
|
||||
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 MediaPlayer
|
||||
|
||||
final class CallVC : UIViewController, VideoPreviewDelegate {
|
||||
final class CallVC: UIViewController, VideoPreviewDelegate {
|
||||
let call: SessionCall
|
||||
var latestKnownAudioOutputDeviceName: String?
|
||||
var durationTimer: Timer?
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import UIKit
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
@objc
|
||||
final class CallMissedTipsModal : Modal {
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
final class CallMissedTipsModal: Modal {
|
||||
private let caller: String
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(caller: String) {
|
||||
self.caller = caller
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
@ -26,27 +28,37 @@ final class CallMissedTipsModal : Modal {
|
|||
let tipsIconImageView = UIImageView(image: UIImage(named: "Tips")?.withTint(Colors.text))
|
||||
tipsIconImageView.set(.width, to: 19)
|
||||
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
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
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
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = String(format: NSLocalizedString("modal_call_missed_tips_explanation", comment: ""), caller)
|
||||
messageLabel.text = message
|
||||
messageLabel.text = String(format: "modal_call_missed_tips_explanation".localized(), caller)
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .natural
|
||||
|
||||
// Cancel Button
|
||||
cancelButton.setTitle(NSLocalizedString("OK", comment: ""), for: .normal)
|
||||
cancelButton.setTitle("BUTTON_OK".localized(), for: .normal)
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ tipsIconImageView, titleLabel, messageLabel, cancelButton ])
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ tipsIconContainerView, titleLabel, messageLabel, cancelButton ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.alignment = .center
|
||||
mainStackView.alignment = .fill
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
contentView.addSubview(mainStackView)
|
||||
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 WebRTC
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
||||
|
@ -82,8 +85,12 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
|||
self.layer.cornerRadius = Values.largeSpacing
|
||||
self.layer.masksToBounds = true
|
||||
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
|
||||
let stackView = UIStackView(arrangedSubviews: [profilePictureView, displayNameLabel, hangUpButton, answerButton])
|
||||
stackView.axis = .horizontal
|
||||
|
|
|
@ -1,63 +1,77 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc(SNEditClosedGroupVC)
|
||||
final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate {
|
||||
private let thread: TSGroupThread
|
||||
private var name = ""
|
||||
private var zombies: Set<String> = []
|
||||
private var membersAndZombies: [String] = [] { didSet { handleMembersChanged() } }
|
||||
final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate {
|
||||
private struct GroupMemberDisplayInfo: FetchableRecord, Decodable {
|
||||
let profileId: String
|
||||
let role: GroupMember.Role
|
||||
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 tableViewHeightConstraint: NSLayoutConstraint!
|
||||
|
||||
private lazy var groupPublicKey: String = {
|
||||
let groupID = thread.groupModel.groupId
|
||||
return LKGroupUtilities.getDecodedGroupID(groupID)
|
||||
}()
|
||||
// MARK: - Components
|
||||
|
||||
// MARK: Components
|
||||
private lazy var groupNameLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
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
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
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.addTarget(self, action: #selector(addMembers), for: UIControl.Event.touchUpInside)
|
||||
result.contentEdgeInsets = UIEdgeInsets(top: 0, leading: Values.mediumSpacing, bottom: 0, trailing: Values.mediumSpacing)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
@objc private lazy var tableView: UITableView = {
|
||||
let result = UITableView()
|
||||
let result: UITableView = UITableView()
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
result.register(UserCell.self, forCellReuseIdentifier: "UserCell")
|
||||
result.separatorStyle = .none
|
||||
result.backgroundColor = .clear
|
||||
result.isScrollEnabled = false
|
||||
result.register(view: UserCell.self)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc(initWithThreadID:)
|
||||
init(with threadID: String) {
|
||||
var thread: TSGroupThread!
|
||||
Storage.read { transaction in
|
||||
thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction)!
|
||||
}
|
||||
self.thread = thread
|
||||
// MARK: - Lifecycle
|
||||
|
||||
@objc(initWithThreadId:)
|
||||
init(with threadId: String) {
|
||||
self.threadId = threadId
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
@ -67,27 +81,62 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setUpGradientBackground()
|
||||
setUpNavBarStyle()
|
||||
setNavBarTitle("Edit Group")
|
||||
|
||||
let backButton = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil)
|
||||
backButton.tintColor = Colors.text
|
||||
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()
|
||||
// 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()
|
||||
name = thread.groupModel.groupName!
|
||||
handleMembersChanged()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
// Group name container
|
||||
groupNameLabel.text = thread.groupModel.groupName
|
||||
groupNameLabel.text = name
|
||||
|
||||
let groupNameContainer = UIView()
|
||||
groupNameContainer.addSubview(groupNameLabel)
|
||||
groupNameLabel.pin(to: groupNameContainer)
|
||||
|
@ -95,6 +144,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
groupNameTextField.pin(to: groupNameContainer)
|
||||
groupNameContainer.set(.height, to: 40)
|
||||
groupNameTextField.alpha = 0
|
||||
|
||||
// Top container
|
||||
let topContainer = UIView()
|
||||
topContainer.addSubview(groupNameContainer)
|
||||
|
@ -102,19 +152,21 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
topContainer.set(.height, to: 40)
|
||||
let topContainerTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(showEditGroupNameUI))
|
||||
topContainer.addGestureRecognizer(topContainerTapGestureRecognizer)
|
||||
|
||||
// Members label
|
||||
let membersLabel = UILabel()
|
||||
membersLabel.textColor = Colors.text
|
||||
membersLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
membersLabel.text = "Members"
|
||||
|
||||
// Add members button
|
||||
let hasContactsToAdd = !Set(ContactUtilities.getAllContacts()).subtracting(self.membersAndZombies).isEmpty
|
||||
if (!hasContactsToAdd) {
|
||||
if !self.hasContactsToAdd {
|
||||
addMembersButton.isUserInteractionEnabled = false
|
||||
let disabledColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
addMembersButton.layer.borderColor = disabledColor.cgColor
|
||||
addMembersButton.setTitleColor(disabledColor, for: UIControl.State.normal)
|
||||
}
|
||||
|
||||
// Middle stack view
|
||||
let middleStackView = UIStackView(arrangedSubviews: [ membersLabel, addMembersButton ])
|
||||
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.isLayoutMarginsRelativeArrangement = true
|
||||
middleStackView.set(.height, to: Values.largeButtonHeight + Values.smallSpacing * 2)
|
||||
|
||||
// Table view
|
||||
tableViewHeightConstraint = tableView.set(.height, to: 0)
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [
|
||||
UIView.vSpacer(Values.veryLargeSpacing),
|
||||
|
@ -137,6 +191,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
mainStackView.axis = .vertical
|
||||
mainStackView.alignment = .fill
|
||||
mainStackView.set(.width, to: UIScreen.main.bounds.width)
|
||||
|
||||
// Scroll view
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
|
@ -152,41 +207,49 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell
|
||||
let publicKey = membersAndZombies[indexPath.row]
|
||||
cell.publicKey = publicKey
|
||||
cell.isZombie = zombies.contains(publicKey)
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
let isCurrentUserAdmin = thread.groupModel.groupAdminIds.contains(userPublicKey)
|
||||
cell.accessory = !isCurrentUserAdmin ? .lock : .none
|
||||
cell.update()
|
||||
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
|
||||
cell.update(
|
||||
with: membersAndZombies[indexPath.row].profileId,
|
||||
profile: membersAndZombies[indexPath.row].profile,
|
||||
isZombie: (membersAndZombies[indexPath.row].role == .zombie),
|
||||
accessory: (adminIds.contains(userPublicKey) ?
|
||||
.none :
|
||||
.lock
|
||||
)
|
||||
)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
return thread.groupModel.groupAdminIds.contains(userPublicKey)
|
||||
return adminIds.contains(userPublicKey)
|
||||
}
|
||||
|
||||
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
|
||||
guard let self = self, let index = self.membersAndZombies.firstIndex(of: publicKey) else { return }
|
||||
self.membersAndZombies.remove(at: index)
|
||||
self?.adminIds.remove(profileId)
|
||||
self?.membersAndZombies.remove(at: indexPath.row)
|
||||
self?.handleMembersChanged()
|
||||
}
|
||||
removeAction.backgroundColor = Colors.destructive
|
||||
|
||||
return [ removeAction ]
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
// MARK: - Updating
|
||||
|
||||
private func updateNavigationBarButtons() {
|
||||
if isEditingGroupName {
|
||||
let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(handleCancelGroupNameEditingButtonTapped))
|
||||
cancelButton.tintColor = Colors.text
|
||||
navigationItem.leftBarButtonItem = cancelButton
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
navigationItem.leftBarButtonItem = nil
|
||||
}
|
||||
|
||||
let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(handleDoneButtonTapped))
|
||||
doneButton.tintColor = Colors.text
|
||||
navigationItem.rightBarButtonItem = doneButton
|
||||
|
@ -199,18 +262,22 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
|
||||
private func handleIsEditingGroupNameChanged() {
|
||||
updateNavigationBarButtons()
|
||||
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.groupNameLabel.alpha = self.isEditingGroupName ? 0 : 1
|
||||
self.groupNameTextField.alpha = self.isEditingGroupName ? 1 : 0
|
||||
}
|
||||
|
||||
if isEditingGroupName {
|
||||
groupNameTextField.becomeFirstResponder()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
groupNameTextField.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func showEditGroupNameUI() {
|
||||
isEditingGroupName = true
|
||||
}
|
||||
|
@ -222,93 +289,163 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
@objc private func handleDoneButtonTapped() {
|
||||
if isEditingGroupName {
|
||||
updateGroupName()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
commitChanges()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateGroupName() {
|
||||
let name = groupNameTextField.text!.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
guard !name.isEmpty else {
|
||||
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_missing_error", comment: ""))
|
||||
let updatedName: String = groupNameTextField.text
|
||||
.defaulting(to: "")
|
||||
.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 {
|
||||
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_too_long_error", comment: ""))
|
||||
guard updatedName.count < 64 else {
|
||||
return showError(title: "vc_create_closed_group_group_name_too_long_error".localized())
|
||||
}
|
||||
|
||||
isEditingGroupName = false
|
||||
self.name = name
|
||||
groupNameLabel.text = name
|
||||
groupNameLabel.text = updatedName
|
||||
self.name = updatedName
|
||||
}
|
||||
|
||||
@objc private func addMembers() {
|
||||
let title = "Add Members"
|
||||
let userSelectionVC = UserSelectionVC(with: title, excluding: Set(membersAndZombies)) { [weak self] selectedUsers in
|
||||
guard let self = self else { return }
|
||||
var members = self.membersAndZombies
|
||||
members.append(contentsOf: selectedUsers)
|
||||
func getDisplayName(for publicKey: String) -> String {
|
||||
return Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey
|
||||
|
||||
let userSelectionVC: UserSelectionVC = UserSelectionVC(
|
||||
with: title,
|
||||
excluding: membersAndZombies
|
||||
.map { $0.profileId }
|
||||
.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
|
||||
self.addMembersButton.isUserInteractionEnabled = hasContactsToAdd
|
||||
let color = hasContactsToAdd ? Colors.accent : Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
self.addMembersButton.layer.borderColor = color.cgColor
|
||||
self.addMembersButton.setTitleColor(color, for: UIControl.State.normal)
|
||||
|
||||
let color = (self?.hasContactsToAdd == true ?
|
||||
Colors.accent :
|
||||
Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
)
|
||||
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() {
|
||||
let popToConversationVC: (EditClosedGroupVC) -> Void = { editVC in
|
||||
if let conversationVC = editVC.navigationController!.viewControllers.first(where: { $0 is ConversationVC }) {
|
||||
editVC.navigationController!.popToViewController(conversationVC, animated: true)
|
||||
} else {
|
||||
editVC.navigationController!.popViewController(animated: true)
|
||||
let popToConversationVC: ((EditClosedGroupVC?) -> ()) = { editVC in
|
||||
guard
|
||||
let viewControllers: [UIViewController] = editVC?.navigationController?.viewControllers,
|
||||
let conversationVC: ConversationVC = viewControllers.first(where: { $0 is ConversationVC }) as? ConversationVC
|
||||
else {
|
||||
editVC?.navigationController?.popViewController(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
editVC?.navigationController?.popToViewController(conversationVC, animated: true)
|
||||
}
|
||||
let storage = SNMessagingKitConfiguration.shared.storage
|
||||
let members = Set(self.membersAndZombies)
|
||||
let name = self.name
|
||||
let zombies = storage.getZombieMembers(for: groupPublicKey)
|
||||
guard members != Set(thread.groupModel.groupMemberIds + zombies) || name != thread.groupModel.groupName else {
|
||||
|
||||
let threadId: String = self.threadId
|
||||
let updatedName: String = self.name
|
||||
let userPublicKey: String = self.userPublicKey
|
||||
let updatedMemberIds: Set<String> = self.membersAndZombies
|
||||
.map { $0.profileId }
|
||||
.asSet()
|
||||
|
||||
guard updatedMemberIds != self.originalMembersAndZombieIds || updatedName != self.originalName else {
|
||||
return popToConversationVC(self)
|
||||
}
|
||||
if !members.contains(getUserHexEncodedPublicKey()) {
|
||||
guard Set(thread.groupModel.groupMemberIds).subtracting([ getUserHexEncodedPublicKey() ]) == members else {
|
||||
return showError(title: "Couldn't Update Group", message: "Can't leave while adding or removing other members.")
|
||||
|
||||
if !updatedMemberIds.contains(userPublicKey) {
|
||||
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 {
|
||||
return showError(title: NSLocalizedString("vc_create_closed_group_too_many_group_members_error", comment: ""))
|
||||
guard updatedMemberIds.count <= 100 else {
|
||||
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
|
||||
Storage.write(with: { transaction in
|
||||
if !members.contains(getUserHexEncodedPublicKey()) {
|
||||
promise = MessageSender.leave(groupPublicKey, using: transaction)
|
||||
} else {
|
||||
promise = MessageSender.update(groupPublicKey, with: members, name: name, transaction: transaction)
|
||||
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
|
||||
Storage.shared
|
||||
.writeAsync { db in
|
||||
if !updatedMemberIds.contains(userPublicKey) {
|
||||
return try MessageSender.leave(db, groupPublicKey: threadId)
|
||||
}
|
||||
|
||||
return try MessageSender.update(
|
||||
db,
|
||||
groupPublicKey: threadId,
|
||||
with: updatedMemberIds,
|
||||
name: updatedName
|
||||
)
|
||||
}
|
||||
}, completion: {
|
||||
let _ = promise.done(on: DispatchQueue.main) {
|
||||
guard let self = self else { return }
|
||||
MentionsManager.populateUserPublicKeyCacheIfNeeded(for: self.thread.uniqueId!)
|
||||
self.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
.done(on: DispatchQueue.main) { [weak self] in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
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?.showError(title: "Couldn't Update Group", message: error.localizedDescription)
|
||||
}
|
||||
})
|
||||
.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Convenience
|
||||
// MARK: - Convenience
|
||||
|
||||
private func showError(title: String, message: String = "") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
private protocol TableViewTouchDelegate {
|
||||
|
||||
func tableViewWasTouched(_ tableView: TableView)
|
||||
}
|
||||
|
||||
private final class TableView : UITableView {
|
||||
private final class TableView: UITableView {
|
||||
var touchDelegate: TableViewTouchDelegate?
|
||||
|
||||
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 {
|
||||
private let contacts = ContactUtilities.getAllContacts()
|
||||
final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate {
|
||||
private let contactProfiles: [Profile] = Profile.fetchAllContactProfiles(excludeCurrentUser: true)
|
||||
private var selectedContacts: Set<String> = []
|
||||
|
||||
// MARK: Components
|
||||
private lazy var nameTextField = TextField(placeholder: NSLocalizedString("vc_create_closed_group_text_field_hint", comment: ""))
|
||||
// MARK: - Components
|
||||
|
||||
private lazy var nameTextField = TextField(placeholder: "vc_create_closed_group_text_field_hint".localized())
|
||||
|
||||
private lazy var tableView: TableView = {
|
||||
let result = TableView()
|
||||
let result: TableView = TableView()
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
result.touchDelegate = self
|
||||
result.register(UserCell.self, forCellReuseIdentifier: "UserCell")
|
||||
result.separatorStyle = .none
|
||||
result.backgroundColor = .clear
|
||||
result.isScrollEnabled = false
|
||||
result.register(view: UserCell.self)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setUpGradientBackground()
|
||||
setUpNavBarStyle()
|
||||
|
||||
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
|
||||
let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close))
|
||||
closeButton.tintColor = Colors.text
|
||||
navigationItem.leftBarButtonItem = closeButton
|
||||
|
||||
let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(createClosedGroup))
|
||||
doneButton.tintColor = Colors.text
|
||||
navigationItem.rightBarButtonItem = doneButton
|
||||
|
||||
// Set up content
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
if !contacts.isEmpty {
|
||||
let mainStackView = UIStackView()
|
||||
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()
|
||||
guard !contactProfiles.isEmpty else {
|
||||
let explanationLabel: UILabel = UILabel()
|
||||
explanationLabel.textColor = Colors.text
|
||||
explanationLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
explanationLabel.numberOfLines = 0
|
||||
explanationLabel.lineBreakMode = .byWordWrapping
|
||||
explanationLabel.textAlignment = .center
|
||||
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.addTarget(self, action: #selector(createNewDM), for: UIControl.Event.touchUpInside)
|
||||
createNewPrivateChatButton.set(.width, to: 196)
|
||||
let stackView = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ])
|
||||
|
||||
let stackView: UIStackView = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ])
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = Values.mediumSpacing
|
||||
stackView.alignment = .center
|
||||
view.addSubview(stackView)
|
||||
stackView.center(.horizontal, in: view)
|
||||
|
||||
let verticalCenteringConstraint = stackView.center(.vertical, in: view)
|
||||
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 {
|
||||
return contacts.count
|
||||
return contactProfiles.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell
|
||||
let publicKey = contacts[indexPath.row]
|
||||
cell.publicKey = publicKey
|
||||
let isSelected = selectedContacts.contains(publicKey)
|
||||
cell.accessory = .tick(isSelected: isSelected)
|
||||
cell.update()
|
||||
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
|
||||
cell.update(
|
||||
with: contactProfiles[indexPath.row].id,
|
||||
profile: contactProfiles[indexPath.row],
|
||||
isZombie: false,
|
||||
accessory: .tick(isSelected: selectedContacts.contains(contactProfiles[indexPath.row].id))
|
||||
)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||
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) {
|
||||
let publicKey = contacts[indexPath.row]
|
||||
if !selectedContacts.contains(publicKey) { selectedContacts.insert(publicKey) } else { selectedContacts.remove(publicKey) }
|
||||
guard let cell = tableView.cellForRow(at: indexPath) as? UserCell else { return }
|
||||
let isSelected = selectedContacts.contains(publicKey)
|
||||
cell.accessory = .tick(isSelected: isSelected)
|
||||
cell.update()
|
||||
if !selectedContacts.contains(contactProfiles[indexPath.row].id) {
|
||||
selectedContacts.insert(contactProfiles[indexPath.row].id)
|
||||
}
|
||||
else {
|
||||
selectedContacts.remove(contactProfiles[indexPath.row].id)
|
||||
}
|
||||
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
tableView.reloadRows(at: [indexPath], with: .none)
|
||||
}
|
||||
|
||||
@objc private func close() {
|
||||
|
@ -169,28 +196,34 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
let selectedContacts = self.selectedContacts
|
||||
let message: String? = (selectedContacts.count > 20) ? "Please wait while the group is created..." : nil
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in
|
||||
var promise: Promise<TSGroupThread>!
|
||||
Storage.writeSync { transaction in
|
||||
promise = MessageSender.createClosedGroup(name: name, members: selectedContacts, transaction: transaction)
|
||||
}
|
||||
let _ = promise.done(on: DispatchQueue.main) { thread in
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
||||
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false)
|
||||
}
|
||||
promise.catch(on: DispatchQueue.main) { _ in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
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)
|
||||
}
|
||||
Storage.shared
|
||||
.writeAsync { db in
|
||||
try MessageSender.createClosedGroup(db, name: name, members: selectedContacts)
|
||||
}
|
||||
.done(on: DispatchQueue.main) { thread in
|
||||
Storage.shared.writeAsync { db in
|
||||
try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
|
||||
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
SessionApp.presentConversation(for: thread.id, action: .compose, animated: false)
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { [weak self] _ in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
|
||||
let 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() {
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
SignalApp.shared().homeViewController!.createNewDM()
|
||||
|
||||
SessionApp.homeViewController.wrappedValue?.createNewDM()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,99 +1,156 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
extension ContextMenuVC {
|
||||
|
||||
struct Action {
|
||||
let icon: UIImage
|
||||
let icon: UIImage?
|
||||
let title: String
|
||||
let isDismissAction: Bool
|
||||
let work: () -> Void
|
||||
|
||||
static func reply(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
let title = NSLocalizedString("context_menu_reply", comment: "")
|
||||
return Action(icon: UIImage(named: "ic_reply")!, title: title) { delegate?.reply(viewItem) }
|
||||
static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_reply"),
|
||||
title: "context_menu_reply".localized(),
|
||||
isDismissAction: false
|
||||
) { delegate?.reply(cellViewModel) }
|
||||
}
|
||||
|
||||
static func copy(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
let title = NSLocalizedString("copy", comment: "")
|
||||
return Action(icon: UIImage(named: "ic_copy")!, title: title) { delegate?.copy(viewItem) }
|
||||
static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_copy"),
|
||||
title: "copy".localized(),
|
||||
isDismissAction: false
|
||||
) { delegate?.copy(cellViewModel) }
|
||||
}
|
||||
|
||||
static func copySessionID(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
let title = NSLocalizedString("vc_conversation_settings_copy_session_id_button_title", comment: "")
|
||||
return Action(icon: UIImage(named: "ic_copy")!, title: title) { delegate?.copySessionID(viewItem) }
|
||||
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(),
|
||||
isDismissAction: false
|
||||
) { delegate?.copySessionID(cellViewModel) }
|
||||
}
|
||||
|
||||
static func delete(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
let title = NSLocalizedString("TXT_DELETE_TITLE", comment: "")
|
||||
return Action(icon: UIImage(named: "ic_trash")!, title: title) { delegate?.delete(viewItem) }
|
||||
static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_trash"),
|
||||
title: "TXT_DELETE_TITLE".localized(),
|
||||
isDismissAction: false
|
||||
) { delegate?.delete(cellViewModel) }
|
||||
}
|
||||
|
||||
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 save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_download"),
|
||||
title: "context_menu_save".localized(),
|
||||
isDismissAction: false
|
||||
) { delegate?.save(cellViewModel) }
|
||||
}
|
||||
|
||||
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 ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_block"),
|
||||
title: "context_menu_ban_user".localized(),
|
||||
isDismissAction: false
|
||||
) { delegate?.ban(cellViewModel) }
|
||||
}
|
||||
|
||||
static func banAndDeleteAllMessages(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
let title = NSLocalizedString("context_menu_ban_and_delete_all", comment: "")
|
||||
return Action(icon: UIImage(named: "ic_block")!, title: title) { delegate?.banAndDeleteAllMessages(viewItem) }
|
||||
static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_block"),
|
||||
title: "context_menu_ban_and_delete_all".localized(),
|
||||
isDismissAction: false
|
||||
) { delegate?.banAndDeleteAllMessages(cellViewModel) }
|
||||
}
|
||||
|
||||
static func dismiss(_ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: nil,
|
||||
title: "",
|
||||
isDismissAction: true
|
||||
) { delegate?.contextMenuDismissed() }
|
||||
}
|
||||
}
|
||||
|
||||
static func actions(for viewItem: ConversationViewItem, delegate: ContextMenuActionDelegate?) -> [Action] {
|
||||
func isReplyingAllowed() -> Bool {
|
||||
guard let message = viewItem.interaction as? TSOutgoingMessage else { return true }
|
||||
switch message.messageState {
|
||||
case .failed, .sending: return false
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
switch viewItem.messageCellType {
|
||||
case .textOnlyMessage:
|
||||
var result: [Action] = []
|
||||
if isReplyingAllowed() { result.append(Action.reply(viewItem, delegate)) }
|
||||
result.append(Action.copy(viewItem, delegate))
|
||||
let isGroup = viewItem.isGroupThread
|
||||
if let message = viewItem.interaction as? TSIncomingMessage, isGroup, !message.isOpenGroupMessage {
|
||||
result.append(Action.copySessionID(viewItem, delegate))
|
||||
}
|
||||
if !isGroup || viewItem.userCanDeleteGroupMessage { result.append(Action.delete(viewItem, delegate)) }
|
||||
if isGroup && viewItem.interaction is TSIncomingMessage && viewItem.userHasModerationPermission {
|
||||
result.append(Action.ban(viewItem, delegate))
|
||||
result.append(Action.banAndDeleteAllMessages(viewItem, delegate))
|
||||
}
|
||||
return result
|
||||
case .mediaMessage, .audio, .genericAttachment:
|
||||
var result: [Action] = []
|
||||
if isReplyingAllowed() { result.append(Action.reply(viewItem, delegate)) }
|
||||
if viewItem.canCopyMedia() { result.append(Action.copy(viewItem, delegate)) }
|
||||
if viewItem.canSaveMedia() { result.append(Action.save(viewItem, delegate)) }
|
||||
let isGroup = viewItem.isGroupThread
|
||||
if let message = viewItem.interaction as? TSIncomingMessage, isGroup, !message.isOpenGroupMessage {
|
||||
result.append(Action.copySessionID(viewItem, delegate))
|
||||
}
|
||||
if !isGroup || viewItem.userCanDeleteGroupMessage { result.append(Action.delete(viewItem, delegate)) }
|
||||
if isGroup && viewItem.interaction is TSIncomingMessage && viewItem.userHasModerationPermission {
|
||||
result.append(Action.ban(viewItem, delegate))
|
||||
result.append(Action.banAndDeleteAllMessages(viewItem, delegate))
|
||||
}
|
||||
return result
|
||||
default: return []
|
||||
static func actions(for cellViewModel: MessageViewModel, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? {
|
||||
// No context items for info messages
|
||||
guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let canReply: Bool = (
|
||||
cellViewModel.variant != .standardOutgoing || (
|
||||
cellViewModel.state != .failed &&
|
||||
cellViewModel.state != .sending
|
||||
)
|
||||
)
|
||||
let canCopy: Bool = (
|
||||
cellViewModel.cellType == .textOnlyMessage || (
|
||||
(
|
||||
cellViewModel.cellType == .genericAttachment ||
|
||||
cellViewModel.cellType == .mediaMessage
|
||||
) &&
|
||||
(cellViewModel.attachments ?? []).count == 1 &&
|
||||
(cellViewModel.attachments ?? []).first?.isVisualMedia == true &&
|
||||
(cellViewModel.attachments ?? []).first?.isValid == true && (
|
||||
(cellViewModel.attachments ?? []).first?.state == .downloaded ||
|
||||
(cellViewModel.attachments ?? []).first?.state == .uploaded
|
||||
)
|
||||
)
|
||||
)
|
||||
let canSave: Bool = (
|
||||
cellViewModel.cellType == .mediaMessage &&
|
||||
(cellViewModel.attachments ?? [])
|
||||
.filter { attachment in
|
||||
attachment.isValid &&
|
||||
attachment.isVisualMedia && (
|
||||
attachment.state == .downloaded ||
|
||||
attachment.state == .uploaded
|
||||
)
|
||||
}.isEmpty == false
|
||||
)
|
||||
let canCopySessionId: Bool = (
|
||||
cellViewModel.variant == .standardIncoming &&
|
||||
cellViewModel.threadVariant != .openGroup
|
||||
)
|
||||
let canDelete: Bool = (
|
||||
cellViewModel.threadVariant != .openGroup ||
|
||||
currentUserIsOpenGroupModerator
|
||||
)
|
||||
let canBan: Bool = (
|
||||
cellViewModel.threadVariant == .openGroup &&
|
||||
currentUserIsOpenGroupModerator
|
||||
)
|
||||
|
||||
let generatedActions: [Action] = [
|
||||
(canReply ? Action.reply(cellViewModel, delegate) : nil),
|
||||
(canCopy ? Action.copy(cellViewModel, delegate) : nil),
|
||||
(canSave ? Action.save(cellViewModel, delegate) : nil),
|
||||
(canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil),
|
||||
(canDelete ? Action.delete(cellViewModel, delegate) : nil),
|
||||
(canBan ? Action.ban(cellViewModel, delegate) : nil),
|
||||
(canBan ? Action.banAndDeleteAllMessages(cellViewModel, delegate) : nil)
|
||||
]
|
||||
.compactMap { $0 }
|
||||
|
||||
guard !generatedActions.isEmpty else { return [] }
|
||||
|
||||
return generatedActions.appending(Action.dismiss(delegate))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
protocol ContextMenuActionDelegate : AnyObject {
|
||||
// MARK: - Delegate
|
||||
|
||||
func reply(_ viewItem: ConversationViewItem)
|
||||
func copy(_ viewItem: ConversationViewItem)
|
||||
func copySessionID(_ viewItem: ConversationViewItem)
|
||||
func delete(_ viewItem: ConversationViewItem)
|
||||
func save(_ viewItem: ConversationViewItem)
|
||||
func ban(_ viewItem: ConversationViewItem)
|
||||
func banAndDeleteAllMessages(_ viewItem: ConversationViewItem)
|
||||
protocol ContextMenuActionDelegate {
|
||||
func reply(_ cellViewModel: MessageViewModel)
|
||||
func copy(_ cellViewModel: MessageViewModel)
|
||||
func copySessionID(_ cellViewModel: MessageViewModel)
|
||||
func delete(_ cellViewModel: MessageViewModel)
|
||||
func save(_ cellViewModel: MessageViewModel)
|
||||
func ban(_ cellViewModel: MessageViewModel)
|
||||
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel)
|
||||
func contextMenuDismissed()
|
||||
}
|
||||
|
|
|
@ -1,19 +1,25 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension ContextMenuVC {
|
||||
|
||||
final class ActionView : UIView {
|
||||
private let action: Action
|
||||
private let dismiss: () -> Void
|
||||
|
||||
// MARK: Settings
|
||||
final class ActionView: UIView {
|
||||
private static let iconSize: CGFloat = 16
|
||||
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) {
|
||||
self.action = action
|
||||
self.dismiss = dismiss
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
@ -28,32 +34,46 @@ extension ContextMenuVC {
|
|||
private func setUpViewHierarchy() {
|
||||
// Icon
|
||||
let iconSize = ActionView.iconSize
|
||||
let iconImageView = UIImageView(image: action.icon.resizedImage(to: CGSize(width: iconSize, height: iconSize))!.withTint(Colors.text))
|
||||
let iconImageViewSize = ActionView.iconImageViewSize
|
||||
iconImageView.set(.width, to: iconImageViewSize)
|
||||
iconImageView.set(.height, to: iconImageViewSize)
|
||||
let iconImageView: UIImageView = UIImageView(
|
||||
image: action.icon?
|
||||
.resizedImage(to: CGSize(width: iconSize, height: iconSize))?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
iconImageView.set(.width, to: ActionView.iconImageViewSize)
|
||||
iconImageView.set(.height, to: ActionView.iconImageViewSize)
|
||||
iconImageView.contentMode = .center
|
||||
iconImageView.tintColor = Colors.text
|
||||
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = action.title
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ iconImageView, titleLabel ])
|
||||
let stackView: UIStackView = UIStackView(arrangedSubviews: [ iconImageView, titleLabel ])
|
||||
stackView.axis = .horizontal
|
||||
stackView.spacing = Values.smallSpacing
|
||||
stackView.alignment = .center
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
|
||||
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)
|
||||
stackView.pin(to: self)
|
||||
|
||||
// Tap gesture recognizer
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func handleTap() {
|
||||
action.work()
|
||||
dismiss()
|
||||
|
|
|
@ -1,43 +1,60 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class ContextMenuVC: UIViewController {
|
||||
private static let actionViewHeight: CGFloat = 40
|
||||
private static let menuCornerRadius: CGFloat = 8
|
||||
|
||||
final class ContextMenuVC : UIViewController {
|
||||
private let snapshot: UIView
|
||||
private let viewItem: ConversationViewItem
|
||||
private let frame: CGRect
|
||||
private let cellViewModel: MessageViewModel
|
||||
private let actions: [Action]
|
||||
private let dismiss: () -> Void
|
||||
private weak var delegate: ContextMenuActionDelegate?
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var blurView = UIVisualEffectView(effect: nil)
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var blurView: UIVisualEffectView = UIVisualEffectView(effect: nil)
|
||||
|
||||
private lazy var menuView: UIView = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.layer.shadowColor = UIColor.black.cgColor
|
||||
result.layer.shadowOffset = CGSize.zero
|
||||
result.layer.shadowOpacity = 0.4
|
||||
result.layer.shadowRadius = 4
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var timestampLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let date = viewItem.interaction.dateForUI()
|
||||
result.text = DateUtil.formatDate(forDisplay: date)
|
||||
let result: UILabel = UILabel()
|
||||
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
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
private static let actionViewHeight: CGFloat = 40
|
||||
private static let menuCornerRadius: CGFloat = 8
|
||||
// MARK: - Initialization
|
||||
|
||||
// MARK: Lifecycle
|
||||
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.viewItem = viewItem
|
||||
self.frame = frame
|
||||
self.delegate = delegate
|
||||
self.cellViewModel = cellViewModel
|
||||
self.actions = actions
|
||||
self.dismiss = dismiss
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
@ -49,32 +66,41 @@ final class ContextMenuVC : UIViewController {
|
|||
preconditionFailure("Use init(coder:) instead.")
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Background color
|
||||
view.backgroundColor = .clear
|
||||
|
||||
// Blur
|
||||
view.addSubview(blurView)
|
||||
blurView.pin(to: view)
|
||||
|
||||
// Snapshot
|
||||
snapshot.layer.shadowColor = UIColor.black.cgColor
|
||||
snapshot.layer.shadowOffset = CGSize.zero
|
||||
snapshot.layer.shadowOpacity = 0.4
|
||||
snapshot.layer.shadowRadius = 4
|
||||
view.addSubview(snapshot)
|
||||
|
||||
snapshot.pin(.left, to: .left, of: view, withInset: frame.origin.x)
|
||||
snapshot.pin(.top, to: .top, of: view, withInset: frame.origin.y)
|
||||
snapshot.set(.width, to: frame.width)
|
||||
snapshot.set(.height, to: frame.height)
|
||||
|
||||
// Timestamp
|
||||
view.addSubview(timestampLabel)
|
||||
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)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
timestampLabel.pin(.left, to: .right, of: snapshot, withInset: Values.smallSpacing)
|
||||
}
|
||||
|
||||
// Menu
|
||||
let menuBackgroundView = UIView()
|
||||
menuBackgroundView.backgroundColor = Colors.receivedMessageBackground
|
||||
|
@ -82,25 +108,35 @@ final class ContextMenuVC : UIViewController {
|
|||
menuBackgroundView.layer.masksToBounds = true
|
||||
menuView.addSubview(menuBackgroundView)
|
||||
menuBackgroundView.pin(to: menuView)
|
||||
let actionViews = ContextMenuVC.actions(for: viewItem, delegate: delegate).map { ActionView(for: $0, dismiss: snDismiss) }
|
||||
let menuStackView = UIStackView(arrangedSubviews: actionViews)
|
||||
|
||||
let menuStackView = UIStackView(
|
||||
arrangedSubviews: actions
|
||||
.filter { !$0.isDismissAction }
|
||||
.map { action -> ActionView in ActionView(for: action, dismiss: snDismiss) }
|
||||
)
|
||||
menuStackView.axis = .vertical
|
||||
menuView.addSubview(menuStackView)
|
||||
menuStackView.pin(to: menuView)
|
||||
view.addSubview(menuView)
|
||||
let menuHeight = CGFloat(actionViews.count) * ContextMenuVC.actionViewHeight
|
||||
|
||||
let menuHeight = (CGFloat(actions.count) * ContextMenuVC.actionViewHeight)
|
||||
let spacing = Values.smallSpacing
|
||||
// FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement)
|
||||
let margin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing)
|
||||
|
||||
if frame.maxY + spacing + menuHeight > UIScreen.main.bounds.height - margin {
|
||||
menuView.pin(.bottom, to: .top, of: snapshot, withInset: -spacing)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing)
|
||||
}
|
||||
switch viewItem.interaction.interactionType() {
|
||||
case .outgoingMessage: menuView.pin(.right, to: .right, of: snapshot)
|
||||
case .incomingMessage: menuView.pin(.left, to: .left, of: snapshot)
|
||||
default: break // Should never occur
|
||||
|
||||
switch cellViewModel.variant {
|
||||
case .standardOutgoing: menuView.pin(.right, to: .right, of: snapshot)
|
||||
case .standardIncoming: menuView.pin(.left, to: .left, of: snapshot)
|
||||
default: break // Should never occur
|
||||
}
|
||||
|
||||
// Tap gesture
|
||||
let mainTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
view.addGestureRecognizer(mainTapGestureRecognizer)
|
||||
|
@ -108,31 +144,43 @@ final class ContextMenuVC : UIViewController {
|
|||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.blurView.effect = UIBlurEffect(style: .regular)
|
||||
self.menuView.alpha = 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
// MARK: - Layout
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
menuView.layer.shadowPath = UIBezierPath(roundedRect: menuView.bounds, cornerRadius: ContextMenuVC.menuCornerRadius).cgPath
|
||||
|
||||
menuView.layer.shadowPath = UIBezierPath(
|
||||
roundedRect: menuView.bounds,
|
||||
cornerRadius: ContextMenuVC.menuCornerRadius
|
||||
).cgPath
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func handleTap() {
|
||||
snDismiss()
|
||||
}
|
||||
|
||||
func snDismiss() {
|
||||
UIView.animate(withDuration: 0.25, animations: {
|
||||
self.blurView.effect = nil
|
||||
self.menuView.alpha = 0
|
||||
self.timestampLabel.alpha = 0
|
||||
}, completion: { _ in
|
||||
self.dismiss()
|
||||
self.delegate?.contextMenuDismissed()
|
||||
})
|
||||
UIView.animate(
|
||||
withDuration: 0.25,
|
||||
animations: { [weak self] in
|
||||
self?.blurView.effect = nil
|
||||
self?.menuView.alpha = 0
|
||||
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()
|
||||
}
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
override init(windowScene: UIWindowScene) {
|
||||
super.init(windowScene: windowScene)
|
||||
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 (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc
|
||||
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate {
|
||||
public class ConversationSearchController: NSObject {
|
||||
public static let minimumSearchTextLength: UInt = 2
|
||||
|
||||
@objc
|
||||
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
|
||||
private let threadId: String
|
||||
public weak var delegate: ConversationSearchControllerDelegate?
|
||||
|
||||
let thread: TSThread
|
||||
|
||||
@objc
|
||||
public let uiSearchController: UISearchController = UISearchController(searchResultsController: nil)
|
||||
public let resultsBar: SearchResultsBar = SearchResultsBar()
|
||||
|
||||
private var lastSearchText: String?
|
||||
|
||||
// MARK: Initializer
|
||||
|
||||
@objc
|
||||
required public init(thread: TSThread) {
|
||||
self.thread = thread
|
||||
public init(threadId: String) {
|
||||
self.threadId = threadId
|
||||
|
||||
super.init()
|
||||
|
||||
resultsBar.resultsBarDelegate = self
|
||||
uiSearchController.delegate = self
|
||||
uiSearchController.searchResultsUpdater = self
|
||||
self.resultsBar.resultsBarDelegate = self
|
||||
self.uiSearchController.delegate = self
|
||||
self.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
|
||||
self.uiSearchController.hidesNavigationBarDuringPresentation = false
|
||||
self.uiSearchController.searchBar.inputAccessoryView = resultsBar
|
||||
}
|
||||
}
|
||||
|
||||
extension ConversationSearchController : UISearchControllerDelegate {
|
||||
// MARK: - UISearchControllerDelegate
|
||||
|
||||
extension ConversationSearchController: UISearchControllerDelegate {
|
||||
public func didPresentSearchController(_ searchController: UISearchController) {
|
||||
Logger.verbose("")
|
||||
delegate?.didPresentSearchController?(searchController)
|
||||
}
|
||||
|
||||
public func didDismissSearchController(_ searchController: UISearchController) {
|
||||
Logger.verbose("")
|
||||
delegate?.didDismissSearchController?(searchController)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConversationSearchController : UISearchResultsUpdating {
|
||||
|
||||
var dbSearcher: FullTextSearcher {
|
||||
return FullTextSearcher.shared
|
||||
}
|
||||
// MARK: - UISearchResultsUpdating
|
||||
|
||||
extension ConversationSearchController: UISearchResultsUpdating {
|
||||
public func updateSearchResults(for searchController: UISearchController) {
|
||||
Logger.verbose("searchBar.text: \( searchController.searchBar.text ?? "<blank>")")
|
||||
|
||||
guard let rawSearchText = searchController.searchBar.text?.stripped else {
|
||||
self.resultsBar.updateResults(resultSet: nil)
|
||||
self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil)
|
||||
return
|
||||
}
|
||||
let searchText = FullTextSearchFinder.normalize(text: rawSearchText)
|
||||
lastSearchText = searchText
|
||||
|
||||
guard searchText.count >= ConversationSearchController.kMinimumSearchTextLength else {
|
||||
lastSearchText = nil
|
||||
self.resultsBar.updateResults(resultSet: nil)
|
||||
self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil)
|
||||
guard
|
||||
let searchText: String = searchController.searchBar.text?.stripped,
|
||||
searchText.count >= ConversationSearchController.minimumSearchTextLength
|
||||
else {
|
||||
self.resultsBar.updateResults(results: nil)
|
||||
self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil, searchText: nil)
|
||||
return
|
||||
}
|
||||
|
||||
var resultSet: ConversationScreenSearchResultSet?
|
||||
self.dbReadConnection.asyncRead({ [weak self] transaction in
|
||||
guard let self = self else {
|
||||
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)
|
||||
})
|
||||
let threadId: String = self.threadId
|
||||
let results: [Int64] = Storage.shared.read { db -> [Int64] in
|
||||
try Interaction.idsForTermWithin(
|
||||
threadId: threadId,
|
||||
pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText)
|
||||
)
|
||||
.fetchAll(db)
|
||||
}
|
||||
.defaulting(to: [])
|
||||
|
||||
self.resultsBar.updateResults(results: results)
|
||||
self.delegate?.conversationSearchController(self, didUpdateSearchResults: results, searchText: searchText)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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, didSelectMessageId: searchResult.messageId)
|
||||
self.delegate?.conversationSearchController(self, didSelectInteractionId: interactionId)
|
||||
}
|
||||
}
|
||||
|
||||
protocol SearchResultsBarDelegate : AnyObject {
|
||||
|
||||
func searchResultsBar(_ searchResultsBar: SearchResultsBar,
|
||||
setCurrentIndex currentIndex: Int,
|
||||
resultSet: ConversationScreenSearchResultSet)
|
||||
protocol SearchResultsBarDelegate: AnyObject {
|
||||
func searchResultsBar(
|
||||
_ searchResultsBar: SearchResultsBar,
|
||||
setCurrentIndex currentIndex: Int,
|
||||
results: [Int64]
|
||||
)
|
||||
}
|
||||
|
||||
public final class SearchResultsBar : UIView {
|
||||
private var resultSet: ConversationScreenSearchResultSet?
|
||||
public final class SearchResultsBar: UIView {
|
||||
private var results: [Int64]?
|
||||
var currentIndex: Int?
|
||||
weak var resultsBarDelegate: SearchResultsBarDelegate?
|
||||
|
||||
|
@ -145,7 +102,6 @@ public final class SearchResultsBar : UIView {
|
|||
|
||||
private lazy var label: UILabel = {
|
||||
let result = UILabel()
|
||||
result.text = "Test"
|
||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.text
|
||||
return result
|
||||
|
@ -169,6 +125,14 @@ public final class SearchResultsBar : UIView {
|
|||
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) {
|
||||
super.init(frame: frame)
|
||||
setUpViewHierarchy()
|
||||
|
@ -181,6 +145,7 @@ public final class SearchResultsBar : UIView {
|
|||
|
||||
private func setUpViewHierarchy() {
|
||||
autoresizingMask = .flexibleHeight
|
||||
|
||||
// Background & blur
|
||||
let backgroundView = UIView()
|
||||
backgroundView.backgroundColor = isLightMode ? .white : .black
|
||||
|
@ -190,18 +155,22 @@ public final class SearchResultsBar : UIView {
|
|||
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
addSubview(blurView)
|
||||
blurView.pin(to: self)
|
||||
|
||||
// Separator
|
||||
let separator = UIView()
|
||||
separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
|
||||
separator.set(.height, to: 1 / UIScreen.main.scale)
|
||||
addSubview(separator)
|
||||
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
|
||||
|
||||
// Spacers
|
||||
let spacer1 = UIView.hStretchingSpacer()
|
||||
let spacer2 = UIView.hStretchingSpacer()
|
||||
|
||||
// Button containers
|
||||
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))
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ upButtonContainer, downButtonContainer, spacer1, label, spacer2 ])
|
||||
mainStackView.axis = .horizontal
|
||||
|
@ -209,110 +178,116 @@ public final class SearchResultsBar : UIView {
|
|||
mainStackView.isLayoutMarginsRelativeArrangement = true
|
||||
mainStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.largeSpacing, bottom: Values.smallSpacing, trailing: Values.largeSpacing)
|
||||
addSubview(mainStackView)
|
||||
|
||||
mainStackView.pin(.top, to: .bottom, of: separator)
|
||||
mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self)
|
||||
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
|
||||
label.center(.horizontal, in: self)
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
@objc
|
||||
public func handleUpButtonTapped() {
|
||||
Logger.debug("")
|
||||
guard let resultSet = resultSet else {
|
||||
owsFailDebug("resultSet was unexpectedly nil")
|
||||
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
|
||||
}
|
||||
guard let results: [Int64] = results else { return }
|
||||
guard let currentIndex: Int = currentIndex else { return }
|
||||
guard currentIndex + 1 < results.count else { return }
|
||||
|
||||
let newIndex = currentIndex + 1
|
||||
self.currentIndex = newIndex
|
||||
updateBarItems()
|
||||
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, resultSet: resultSet)
|
||||
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, results: results)
|
||||
}
|
||||
|
||||
@objc
|
||||
public func handleDownButtonTapped() {
|
||||
Logger.debug("")
|
||||
guard let resultSet = resultSet else {
|
||||
owsFailDebug("resultSet was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
guard let currentIndex = currentIndex else {
|
||||
owsFailDebug("currentIndex was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
guard currentIndex > 0 else {
|
||||
owsFailDebug("showMoreRecent button should be disabled")
|
||||
return
|
||||
}
|
||||
guard let results: [Int64] = results else { return }
|
||||
guard let currentIndex: Int = currentIndex, currentIndex > 0 else { return }
|
||||
|
||||
let newIndex = currentIndex - 1
|
||||
self.currentIndex = newIndex
|
||||
updateBarItems()
|
||||
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, resultSet: resultSet)
|
||||
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, results: results)
|
||||
}
|
||||
|
||||
func updateResults(resultSet: ConversationScreenSearchResultSet?) {
|
||||
if let resultSet = resultSet {
|
||||
if resultSet.messages.count > 0 {
|
||||
currentIndex = min(currentIndex ?? 0, resultSet.messages.count - 1)
|
||||
} else {
|
||||
currentIndex = nil
|
||||
}
|
||||
} else {
|
||||
currentIndex = nil
|
||||
}
|
||||
func updateResults(results: [Int64]?) {
|
||||
currentIndex = {
|
||||
guard let results: [Int64] = results, !results.isEmpty else { return nil }
|
||||
|
||||
self.resultSet = resultSet
|
||||
if let currentIndex: Int = currentIndex {
|
||||
return max(0, min(currentIndex, results.count - 1))
|
||||
}
|
||||
|
||||
return 0
|
||||
}()
|
||||
|
||||
self.results = results
|
||||
|
||||
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() {
|
||||
guard let resultSet = resultSet else {
|
||||
guard let results: [Int64] = results else {
|
||||
label.text = ""
|
||||
downButton.isEnabled = false
|
||||
upButton.isEnabled = false
|
||||
return
|
||||
}
|
||||
|
||||
switch resultSet.messages.count {
|
||||
case 0:
|
||||
label.text = NSLocalizedString("CONVERSATION_SEARCH_NO_RESULTS", comment: "keyboard toolbar label when no messages match the search string")
|
||||
case 1:
|
||||
label.text = NSLocalizedString("CONVERSATION_SEARCH_ONE_RESULT", comment: "keyboard toolbar label when exactly 1 message matches the search string")
|
||||
default:
|
||||
let format = NSLocalizedString("CONVERSATION_SEARCH_RESULTS_FORMAT",
|
||||
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}}")
|
||||
switch results.count {
|
||||
case 0:
|
||||
// Keyboard toolbar label when no messages match the search string
|
||||
label.text = "CONVERSATION_SEARCH_NO_RESULTS".localized()
|
||||
|
||||
guard let currentIndex = currentIndex else {
|
||||
owsFailDebug("currentIndex was unexpectedly nil")
|
||||
return
|
||||
case 1:
|
||||
// Keyboard toolbar label when exactly 1 message matches the search string
|
||||
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: Int = currentIndex else { 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
|
||||
upButton.isEnabled = currentIndex + 1 < resultSet.messages.count
|
||||
} else {
|
||||
upButton.isEnabled = (currentIndex + 1 < results.count)
|
||||
}
|
||||
else {
|
||||
downButton.isEnabled = false
|
||||
upButton.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
public func startLoading() {
|
||||
loadingIndicator.startAnimating()
|
||||
}
|
||||
|
||||
public func stopLoading() {
|
||||
loadingIndicator.stopAnimating()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ConversationSearchControllerDelegate
|
||||
|
||||
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate {
|
||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?)
|
||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId: Int64)
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -1,8 +0,0 @@
|
|||
@import Foundation;
|
||||
|
||||
typedef NS_ENUM(NSUInteger, ConversationViewAction) {
|
||||
ConversationViewActionNone,
|
||||
ConversationViewActionCompose,
|
||||
ConversationViewActionAudioCall,
|
||||
ConversationViewActionVideoCall,
|
||||
};
|
|
@ -1,165 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <SessionMessagingKit/OWSAudioPlayer.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
extern NSString *const SNAudioDidFinishPlayingNotification;
|
||||
|
||||
typedef NS_ENUM(NSInteger, OWSMessageCellType) {
|
||||
OWSMessageCellType_Unknown,
|
||||
OWSMessageCellType_TextOnlyMessage,
|
||||
OWSMessageCellType_Audio,
|
||||
OWSMessageCellType_GenericAttachment,
|
||||
OWSMessageCellType_MediaMessage,
|
||||
OWSMessageCellType_OversizeTextDownloading,
|
||||
OWSMessageCellType_DeletedMessage
|
||||
};
|
||||
|
||||
NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType);
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@class ContactShareViewModel;
|
||||
@class ConversationViewCell;
|
||||
@class DisplayableText;
|
||||
@class SNVoiceMessageView;
|
||||
@class OWSLinkPreview;
|
||||
@class OWSQuotedReplyModel;
|
||||
@class OWSUnreadIndicator;
|
||||
@class TSAttachment;
|
||||
@class TSAttachmentPointer;
|
||||
@class TSAttachmentStream;
|
||||
@class TSInteraction;
|
||||
@class TSThread;
|
||||
@class YapDatabaseReadTransaction;
|
||||
|
||||
@interface ConversationMediaAlbumItem : NSObject
|
||||
|
||||
@property (nonatomic, readonly) TSAttachment *attachment;
|
||||
|
||||
// This property will only be set if the attachment is downloaded.
|
||||
@property (nonatomic, readonly, nullable) TSAttachmentStream *attachmentStream;
|
||||
|
||||
// This property will be non-zero if the attachment is valid.
|
||||
@property (nonatomic, readonly) CGSize mediaSize;
|
||||
|
||||
@property (nonatomic, readonly, nullable) NSString *caption;
|
||||
|
||||
@property (nonatomic, readonly) BOOL isFailedDownload;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@protocol ConversationViewItem <NSObject, OWSAudioPlayerDelegate>
|
||||
|
||||
@property (nonatomic, readonly) TSInteraction *interaction;
|
||||
|
||||
@property (nonatomic, readonly, nullable) OWSQuotedReplyModel *quotedReply;
|
||||
|
||||
@property (nonatomic, readonly) BOOL isGroupThread;
|
||||
@property (nonatomic, readonly) BOOL userCanDeleteGroupMessage;
|
||||
@property (nonatomic, readonly) BOOL userHasModerationPermission;
|
||||
|
||||
@property (nonatomic, readonly) BOOL hasBodyText;
|
||||
|
||||
@property (nonatomic, readonly) BOOL isQuotedReply;
|
||||
@property (nonatomic, readonly) BOOL hasQuotedAttachment;
|
||||
@property (nonatomic, readonly) BOOL hasQuotedText;
|
||||
@property (nonatomic, readonly) BOOL hasCellHeader;
|
||||
|
||||
@property (nonatomic, readonly) BOOL isExpiringMessage;
|
||||
|
||||
@property (nonatomic) BOOL shouldShowDate;
|
||||
@property (nonatomic) BOOL shouldShowSenderProfilePicture;
|
||||
@property (nonatomic, nullable) NSAttributedString *senderName;
|
||||
@property (nonatomic) BOOL shouldHideFooter;
|
||||
@property (nonatomic) BOOL isFirstInCluster;
|
||||
@property (nonatomic) BOOL isOnlyMessageInCluster;
|
||||
@property (nonatomic) BOOL isLastInCluster;
|
||||
@property (nonatomic) BOOL wasPreviousItemInfoMessage;
|
||||
|
||||
@property (nonatomic, nullable) OWSUnreadIndicator *unreadIndicator;
|
||||
|
||||
- (void)replaceInteraction:(TSInteraction *)interaction transaction:(YapDatabaseReadTransaction *)transaction;
|
||||
|
||||
- (void)clearCachedLayoutState;
|
||||
|
||||
@property (nonatomic, readonly) BOOL hasCachedLayoutState;
|
||||
|
||||
#pragma mark - Audio Playback
|
||||
|
||||
@property (nonatomic, weak) SNVoiceMessageView *lastAudioMessageView;
|
||||
|
||||
@property (nonatomic, readonly) CGFloat audioDurationSeconds;
|
||||
@property (nonatomic, readonly) CGFloat audioProgressSeconds;
|
||||
|
||||
#pragma mark - View State Caching
|
||||
|
||||
// These methods only apply to text & attachment messages.
|
||||
@property (nonatomic, readonly) OWSMessageCellType messageCellType;
|
||||
@property (nonatomic, readonly, nullable) DisplayableText *displayableBodyText;
|
||||
@property (nonatomic, readonly, nullable) TSAttachmentStream *attachmentStream;
|
||||
@property (nonatomic, readonly, nullable) TSAttachmentPointer *attachmentPointer;
|
||||
@property (nonatomic, readonly, nullable) NSArray<ConversationMediaAlbumItem *> *mediaAlbumItems;
|
||||
|
||||
@property (nonatomic, readonly, nullable) DisplayableText *displayableQuotedText;
|
||||
@property (nonatomic, readonly, nullable) NSString *quotedAttachmentMimetype;
|
||||
@property (nonatomic, readonly, nullable) NSString *quotedRecipientId;
|
||||
|
||||
// We don't want to try to load the media for this item (if any)
|
||||
// if a load has previously failed.
|
||||
@property (nonatomic) BOOL didCellMediaFailToLoad;
|
||||
|
||||
@property (nonatomic, readonly, nullable) ContactShareViewModel *contactShare;
|
||||
|
||||
@property (nonatomic, readonly, nullable) OWSLinkPreview *linkPreview;
|
||||
@property (nonatomic, readonly, nullable) TSAttachment *linkPreviewAttachment;
|
||||
|
||||
@property (nonatomic, readonly, nullable) NSString *systemMessageText;
|
||||
|
||||
// NOTE: This property is only set for incoming messages.
|
||||
@property (nonatomic, readonly, nullable) NSString *authorConversationColorName;
|
||||
|
||||
#pragma mark - MessageActions
|
||||
|
||||
@property (nonatomic, readonly) BOOL hasBodyTextActionContent;
|
||||
@property (nonatomic, readonly) BOOL hasMediaActionContent;
|
||||
|
||||
- (void)copyMediaAction;
|
||||
- (void)copyTextAction;
|
||||
- (void)saveMediaAction;
|
||||
- (void)deleteLocallyAction;
|
||||
- (void)deleteRemotelyAction;
|
||||
|
||||
- (void)deleteAction; // Remove this after the unsend request is enabled
|
||||
|
||||
- (BOOL)canCopyMedia;
|
||||
- (BOOL)canSaveMedia;
|
||||
|
||||
// For view items that correspond to interactions, this is the interaction's unique id.
|
||||
// For other view views (like the typing indicator), this is a unique, stable string.
|
||||
- (NSString *)itemId;
|
||||
|
||||
- (nullable TSAttachmentStream *)firstValidAlbumAttachment;
|
||||
|
||||
- (BOOL)mediaAlbumHasFailedAttachment;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface ConversationInteractionViewItem
|
||||
: NSObject <ConversationViewItem, OWSAudioPlayerDelegate>
|
||||
|
||||
- (instancetype)init NS_UNAVAILABLE;
|
||||
- (instancetype)initWithInteraction:(TSInteraction *)interaction
|
||||
isGroupThread:(BOOL)isGroupThread
|
||||
transaction:(YapDatabaseReadTransaction *)transaction;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
File diff suppressed because it is too large
Load Diff
|
@ -1,142 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class ConversationStyle;
|
||||
@class ConversationViewModel;
|
||||
@class OWSQuotedReplyModel;
|
||||
@class TSOutgoingMessage;
|
||||
@class TSThread;
|
||||
@class ThreadDynamicInteractions;
|
||||
|
||||
@protocol ConversationViewItem;
|
||||
|
||||
typedef NS_ENUM(NSUInteger, ConversationUpdateType) {
|
||||
// No view items in the load window were effected.
|
||||
ConversationUpdateType_Minor,
|
||||
// A subset of view items in the load window were effected;
|
||||
// the view should be updated using the update items.
|
||||
ConversationUpdateType_Diff,
|
||||
// Complicated or unexpected changes occurred in the load window;
|
||||
// the view should be reloaded.
|
||||
ConversationUpdateType_Reload,
|
||||
};
|
||||
|
||||
#pragma mark -
|
||||
|
||||
typedef NS_ENUM(NSUInteger, ConversationUpdateItemType) {
|
||||
ConversationUpdateItemType_Insert,
|
||||
ConversationUpdateItemType_Delete,
|
||||
ConversationUpdateItemType_Update,
|
||||
};
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface ConversationViewState : NSObject
|
||||
|
||||
@property (nonatomic, readonly) NSArray<id<ConversationViewItem>> *viewItems;
|
||||
@property (nonatomic, readonly) NSDictionary<NSString *, NSNumber *> *interactionIndexMap;
|
||||
// We have to track interactionIds separately. We can't just use interactionIndexMap.allKeys,
|
||||
// as that won't preserve ordering.
|
||||
@property (nonatomic, readonly) NSArray<NSString *> *interactionIds;
|
||||
@property (nonatomic, readonly, nullable) NSNumber *unreadIndicatorIndex;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface ConversationUpdateItem : NSObject
|
||||
|
||||
@property (nonatomic, readonly) ConversationUpdateItemType updateItemType;
|
||||
// Only applies in the "delete" and "update" cases.
|
||||
@property (nonatomic, readonly) NSUInteger oldIndex;
|
||||
// Only applies in the "insert" and "update" cases.
|
||||
@property (nonatomic, readonly) NSUInteger newIndex;
|
||||
// Only applies in the "insert" and "update" cases.
|
||||
@property (nonatomic, readonly, nullable) id<ConversationViewItem> viewItem;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface ConversationUpdate : NSObject
|
||||
|
||||
@property (nonatomic, readonly) ConversationUpdateType conversationUpdateType;
|
||||
// Only applies in the "diff" case.
|
||||
@property (nonatomic, readonly, nullable) NSArray<ConversationUpdateItem *> *updateItems;
|
||||
//// Only applies in the "diff" case.
|
||||
@property (nonatomic, readonly) BOOL shouldAnimateUpdates;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@protocol ConversationViewModelDelegate <NSObject>
|
||||
|
||||
- (void)conversationViewModelWillUpdate;
|
||||
- (void)conversationViewModelDidUpdate:(ConversationUpdate *)conversationUpdate;
|
||||
|
||||
- (void)conversationViewModelWillLoadMoreItems;
|
||||
- (void)conversationViewModelDidLoadMoreItems;
|
||||
- (void)conversationViewModelDidLoadPrevPage;
|
||||
- (void)conversationViewModelRangeDidChange;
|
||||
|
||||
// Called after the view model recovers from a severe error
|
||||
// to prod the view to reset its scroll state, etc.
|
||||
- (void)conversationViewModelDidReset;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
// Always load up to n messages when user arrives.
|
||||
//
|
||||
// The smaller this number is, the faster the conversation can display.
|
||||
// To test, shrink you accessibility font as much as possible, then count how many 1-line system info messages (our
|
||||
// shortest cells) can fit on screen at a time on an iPhoneX
|
||||
//
|
||||
// PERF: we could do less messages on shorter (older, slower) devices
|
||||
// PERF: we could cache the cell height, since some messages will be much taller.
|
||||
static const int kYapDatabasePageSize = 250;
|
||||
|
||||
// Never show more than n messages in conversation view when user arrives.
|
||||
static const int kConversationInitialMaxRangeSize = 250;
|
||||
|
||||
// Never show more than n messages in conversation view at a time.
|
||||
static const int kYapDatabaseRangeMaxLength = 250000;
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface ConversationViewModel : NSObject
|
||||
|
||||
@property (nonatomic, readonly) ConversationViewState *viewState;
|
||||
@property (nonatomic, nullable) NSString *focusMessageIdOnOpen;
|
||||
@property (nonatomic, readonly, nullable) ThreadDynamicInteractions *dynamicInteractions;
|
||||
|
||||
- (instancetype)init NS_UNAVAILABLE;
|
||||
- (instancetype)initWithThread:(TSThread *)thread
|
||||
focusMessageIdOnOpen:(nullable NSString *)focusMessageIdOnOpen
|
||||
delegate:(id<ConversationViewModelDelegate>)delegate NS_DESIGNATED_INITIALIZER;
|
||||
|
||||
- (void)ensureDynamicInteractionsAndUpdateIfNecessary;
|
||||
|
||||
- (void)loadAnotherPageOfMessages;
|
||||
|
||||
- (void)viewDidResetContentAndLayout;
|
||||
|
||||
- (void)viewDidLoad;
|
||||
|
||||
- (BOOL)canLoadMoreItems;
|
||||
|
||||
- (nullable NSIndexPath *)ensureLoadWindowContainsQuotedReply:(OWSQuotedReplyModel *)quotedReply;
|
||||
- (nullable NSIndexPath *)ensureLoadWindowContainsInteractionId:(NSString *)interactionId;
|
||||
|
||||
- (void)appendUnsavedOutgoingTextMessage:(TSOutgoingMessage *)outgoingMessage;
|
||||
|
||||
- (BOOL)reloadViewItems;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,714 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||
public typealias SectionModel = ArraySection<Section, MessageViewModel>
|
||||
|
||||
// MARK: - Action
|
||||
|
||||
public enum Action {
|
||||
case none
|
||||
case compose
|
||||
case audioCall
|
||||
case videoCall
|
||||
}
|
||||
|
||||
// MARK: - Section
|
||||
|
||||
public enum Section: Differentiable, Equatable, Comparable, Hashable {
|
||||
case loadOlder
|
||||
case messages
|
||||
case loadNewer
|
||||
}
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
public static let pageSize: Int = 50
|
||||
|
||||
private var threadId: String
|
||||
public let initialThreadVariant: SessionThread.Variant
|
||||
public var sentMessageBeforeUpdate: Bool = false
|
||||
public var lastSearchedText: String?
|
||||
public let focusedInteractionId: Int64? // Note: This is used for global search
|
||||
|
||||
public lazy var blockedBannerMessage: String = {
|
||||
switch self.threadData.threadVariant {
|
||||
case .contact:
|
||||
let name: String = Profile.displayName(
|
||||
id: self.threadData.threadId,
|
||||
threadVariant: self.threadData.threadVariant
|
||||
)
|
||||
|
||||
return "\(name) is blocked. Unblock them?"
|
||||
|
||||
default: return "Thread is blocked. Unblock it?"
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64?) {
|
||||
// If we have a specified 'focusedInteractionId' then use that, otherwise retrieve the oldest
|
||||
// unread interaction and start focused around that one
|
||||
let targetInteractionId: Int64? = {
|
||||
if let focusedInteractionId: Int64 = focusedInteractionId { return focusedInteractionId }
|
||||
|
||||
return Storage.shared.read { db in
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
|
||||
return try Interaction
|
||||
.select(.id)
|
||||
.filter(interaction[.wasRead] == false)
|
||||
.filter(interaction[.threadId] == threadId)
|
||||
.order(interaction[.timestampMs].asc)
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchOne(db)
|
||||
}
|
||||
}()
|
||||
|
||||
self.threadId = threadId
|
||||
self.initialThreadVariant = threadVariant
|
||||
self.focusedInteractionId = targetInteractionId
|
||||
self.pagedDataObserver = nil
|
||||
|
||||
// Note: Since this references self we need to finish initializing before setting it, we
|
||||
// also want to skip the initial query and trigger it async so that the push animation
|
||||
// doesn't stutter (it should load basically immediately but without this there is a
|
||||
// distinct stutter)
|
||||
self.pagedDataObserver = self.setupPagedObserver(
|
||||
for: threadId,
|
||||
userPublicKey: getUserHexEncodedPublicKey()
|
||||
)
|
||||
|
||||
// Run the initial query on a background thread so we don't block the push transition
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
// If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query
|
||||
// from a `0` offset)
|
||||
guard let initialFocusedId: Int64 = targetInteractionId else {
|
||||
self?.pagedDataObserver?.load(.pageBefore)
|
||||
return
|
||||
}
|
||||
|
||||
self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Thread Data
|
||||
|
||||
/// This value is the current state of the view
|
||||
public private(set) lazy var threadData: SessionThreadViewModel = SessionThreadViewModel(
|
||||
threadId: self.threadId,
|
||||
threadVariant: self.initialThreadVariant,
|
||||
currentUserIsClosedGroupMember: (self.initialThreadVariant != .closedGroup ?
|
||||
nil :
|
||||
Storage.shared.read { db in
|
||||
try GroupMember
|
||||
.filter(GroupMember.Columns.groupId == self.threadId)
|
||||
.filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db))
|
||||
.filter(GroupMember.Columns.role == GroupMember.Role.standard)
|
||||
.isNotEmpty(db)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
||||
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||
///
|
||||
/// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static
|
||||
/// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
|
||||
///
|
||||
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
|
||||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
||||
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||
public lazy var observableThreadData: ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> = setupObservableThreadData(for: self.threadId)
|
||||
|
||||
private func setupObservableThreadData(for threadId: String) -> ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> {
|
||||
return ValueObservation
|
||||
.trackingConstantRegion { db -> SessionThreadViewModel? in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
return try SessionThreadViewModel
|
||||
.conversationQuery(threadId: threadId, userPublicKey: userPublicKey)
|
||||
.fetchOne(db)
|
||||
}
|
||||
.removeDuplicates()
|
||||
}
|
||||
|
||||
public func updateThreadData(_ updatedData: SessionThreadViewModel) {
|
||||
self.threadData = updatedData
|
||||
}
|
||||
|
||||
// MARK: - Interaction Data
|
||||
|
||||
public private(set) var unobservedInteractionDataChanges: [SectionModel]?
|
||||
public private(set) var interactionData: [SectionModel] = []
|
||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<Interaction, MessageViewModel>?
|
||||
|
||||
public var onInteractionChange: (([SectionModel]) -> ())? {
|
||||
didSet {
|
||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||
// data was changed while we weren't observing
|
||||
if let unobservedInteractionDataChanges: [SectionModel] = self.unobservedInteractionDataChanges {
|
||||
onInteractionChange?(unobservedInteractionDataChanges)
|
||||
self.unobservedInteractionDataChanges = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupPagedObserver(for threadId: String, userPublicKey: String) -> PagedDatabaseObserver<Interaction, MessageViewModel> {
|
||||
return PagedDatabaseObserver(
|
||||
pagedTable: Interaction.self,
|
||||
pageSize: ConversationViewModel.pageSize,
|
||||
idColumn: .id,
|
||||
observedChanges: [
|
||||
PagedData.ObservedChanges(
|
||||
table: Interaction.self,
|
||||
columns: Interaction.Columns
|
||||
.allCases
|
||||
.filter { $0 != .wasRead }
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: Contact.self,
|
||||
columns: [.isTrusted],
|
||||
joinToPagedType: {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: Profile.self,
|
||||
columns: [.profilePictureFileName],
|
||||
joinToPagedType: {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
|
||||
}()
|
||||
)
|
||||
],
|
||||
filterSQL: MessageViewModel.filterSQL(threadId: threadId),
|
||||
groupSQL: MessageViewModel.groupSQL,
|
||||
orderSQL: MessageViewModel.orderSQL,
|
||||
dataQuery: MessageViewModel.baseQuery(
|
||||
userPublicKey: userPublicKey,
|
||||
orderSQL: MessageViewModel.orderSQL,
|
||||
groupSQL: MessageViewModel.groupSQL
|
||||
),
|
||||
associatedRecords: [
|
||||
AssociatedRecord<MessageViewModel.AttachmentInteractionInfo, MessageViewModel>(
|
||||
trackedAgainst: Attachment.self,
|
||||
observedChanges: [
|
||||
PagedData.ObservedChanges(
|
||||
table: Attachment.self,
|
||||
columns: [.state]
|
||||
)
|
||||
],
|
||||
dataQuery: MessageViewModel.AttachmentInteractionInfo.baseQuery,
|
||||
joinToPagedType: MessageViewModel.AttachmentInteractionInfo.joinToViewModelQuerySQL,
|
||||
associateData: MessageViewModel.AttachmentInteractionInfo.createAssociateDataClosure()
|
||||
),
|
||||
AssociatedRecord<MessageViewModel.TypingIndicatorInfo, MessageViewModel>(
|
||||
trackedAgainst: ThreadTypingIndicator.self,
|
||||
observedChanges: [
|
||||
PagedData.ObservedChanges(
|
||||
table: ThreadTypingIndicator.self,
|
||||
events: [.insert, .delete],
|
||||
columns: []
|
||||
)
|
||||
],
|
||||
dataQuery: MessageViewModel.TypingIndicatorInfo.baseQuery,
|
||||
joinToPagedType: MessageViewModel.TypingIndicatorInfo.joinToViewModelQuerySQL,
|
||||
associateData: MessageViewModel.TypingIndicatorInfo.createAssociateDataClosure()
|
||||
)
|
||||
],
|
||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
guard let updatedInteractionData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
|
||||
return
|
||||
}
|
||||
|
||||
// If we have the 'onInteractionChanged' callback then trigger it, otherwise just store the changes
|
||||
// to be sent to the callback if we ever start observing again (when we have the callback it needs
|
||||
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
|
||||
// correct order)
|
||||
guard let onInteractionChange: (([SectionModel]) -> ()) = self?.onInteractionChange else {
|
||||
self?.unobservedInteractionDataChanges = updatedInteractionData
|
||||
return
|
||||
}
|
||||
|
||||
onInteractionChange(updatedInteractionData)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
||||
let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true })
|
||||
let sortedData: [MessageViewModel] = data
|
||||
.filter { $0.isTypingIndicator != true }
|
||||
.sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs }
|
||||
|
||||
// We load messages from newest to oldest so having a pageOffset larger than zero means
|
||||
// there are newer pages to load
|
||||
return [
|
||||
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
|
||||
[SectionModel(section: .loadOlder)] :
|
||||
[]
|
||||
),
|
||||
[
|
||||
SectionModel(
|
||||
section: .messages,
|
||||
elements: sortedData
|
||||
.enumerated()
|
||||
.map { index, cellViewModel -> MessageViewModel in
|
||||
cellViewModel.withClusteringChanges(
|
||||
prevModel: (index > 0 ? sortedData[index - 1] : nil),
|
||||
nextModel: (index < (sortedData.count - 1) ? sortedData[index + 1] : nil),
|
||||
isLast: (
|
||||
// The database query sorts by timestampMs descending so the "last"
|
||||
// interaction will actually have a 'pageOffset' of '0' even though
|
||||
// it's the last element in the 'sortedData' array
|
||||
index == (sortedData.count - 1) &&
|
||||
pageInfo.pageOffset == 0
|
||||
),
|
||||
currentUserBlindedPublicKey: threadData.currentUserBlindedPublicKey
|
||||
)
|
||||
}
|
||||
.appending(typingIndicator)
|
||||
)
|
||||
],
|
||||
(!data.isEmpty && pageInfo.pageOffset > 0 ?
|
||||
[SectionModel(section: .loadNewer)] :
|
||||
[]
|
||||
)
|
||||
].flatMap { $0 }
|
||||
}
|
||||
|
||||
public func updateInteractionData(_ updatedData: [SectionModel]) {
|
||||
self.interactionData = updatedData
|
||||
}
|
||||
|
||||
// MARK: - Mentions
|
||||
|
||||
public struct MentionInfo: FetchableRecord, Decodable {
|
||||
fileprivate static let threadVariantKey = CodingKeys.threadVariant.stringValue
|
||||
fileprivate static let openGroupServerKey = CodingKeys.openGroupServer.stringValue
|
||||
fileprivate static let openGroupRoomTokenKey = CodingKeys.openGroupRoomToken.stringValue
|
||||
|
||||
let profile: Profile
|
||||
let threadVariant: SessionThread.Variant
|
||||
let openGroupServer: String?
|
||||
let openGroupRoomToken: String?
|
||||
}
|
||||
|
||||
public func mentions(for query: String = "") -> [MentionInfo] {
|
||||
let threadData: SessionThreadViewModel = self.threadData
|
||||
|
||||
let results: [MentionInfo] = Storage.shared
|
||||
.read { db -> [MentionInfo] in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
switch threadData.threadVariant {
|
||||
case .contact:
|
||||
guard userPublicKey != threadData.threadId else { return [] }
|
||||
|
||||
return [Profile.fetchOrCreate(db, id: threadData.threadId)]
|
||||
.map { profile in
|
||||
MentionInfo(
|
||||
profile: profile,
|
||||
threadVariant: threadData.threadVariant,
|
||||
openGroupServer: nil,
|
||||
openGroupRoomToken: nil
|
||||
)
|
||||
}
|
||||
.filter {
|
||||
query.count < 2 ||
|
||||
$0.profile.displayName(for: $0.threadVariant).contains(query)
|
||||
}
|
||||
|
||||
case .closedGroup:
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
|
||||
return try GroupMember
|
||||
.select(
|
||||
profile.allColumns(),
|
||||
SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey)
|
||||
)
|
||||
.filter(GroupMember.Columns.groupId == threadData.threadId)
|
||||
.filter(GroupMember.Columns.profileId != userPublicKey)
|
||||
.filter(GroupMember.Columns.role == GroupMember.Role.standard)
|
||||
.joining(
|
||||
required: GroupMember.profile
|
||||
.aliased(profile)
|
||||
// Note: LIKE is case-insensitive in SQLite
|
||||
.filter(
|
||||
query.count < 2 || (
|
||||
profile[.nickname] != nil &&
|
||||
profile[.nickname].like("%\(query)%")
|
||||
) || (
|
||||
profile[.nickname] == nil &&
|
||||
profile[.name].like("%\(query)%")
|
||||
)
|
||||
)
|
||||
)
|
||||
.asRequest(of: MentionInfo.self)
|
||||
.fetchAll(db)
|
||||
|
||||
case .openGroup:
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
|
||||
return try Interaction
|
||||
.select(
|
||||
profile.allColumns(),
|
||||
SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey),
|
||||
SQL("\(threadData.openGroupServer)").forKey(MentionInfo.openGroupServerKey),
|
||||
SQL("\(threadData.openGroupRoomToken)").forKey(MentionInfo.openGroupRoomTokenKey)
|
||||
)
|
||||
.distinct()
|
||||
.group(Interaction.Columns.authorId)
|
||||
.filter(Interaction.Columns.threadId == threadData.threadId)
|
||||
.filter(Interaction.Columns.authorId != userPublicKey)
|
||||
.joining(
|
||||
required: Interaction.profile
|
||||
.aliased(profile)
|
||||
// Note: LIKE is case-insensitive in SQLite
|
||||
.filter(
|
||||
query.count < 2 || (
|
||||
profile[.nickname] != nil &&
|
||||
profile[.nickname].like("%\(query)%")
|
||||
) || (
|
||||
profile[.nickname] == nil &&
|
||||
profile[.name].like("%\(query)%")
|
||||
)
|
||||
)
|
||||
)
|
||||
.order(Interaction.Columns.timestampMs.desc)
|
||||
.limit(20)
|
||||
.asRequest(of: MentionInfo.self)
|
||||
.fetchAll(db)
|
||||
}
|
||||
}
|
||||
.defaulting(to: [])
|
||||
|
||||
guard query.count >= 2 else {
|
||||
return results.sorted { lhs, rhs -> Bool in
|
||||
lhs.profile.displayName(for: lhs.threadVariant) < rhs.profile.displayName(for: rhs.threadVariant)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.sorted { lhs, rhs -> Bool in
|
||||
let maybeLhsRange = lhs.profile.displayName(for: lhs.threadVariant).lowercased().range(of: query.lowercased())
|
||||
let maybeRhsRange = rhs.profile.displayName(for: rhs.threadVariant).lowercased().range(of: query.lowercased())
|
||||
|
||||
guard let lhsRange: Range<String.Index> = maybeLhsRange, let rhsRange: Range<String.Index> = maybeRhsRange else {
|
||||
return true
|
||||
}
|
||||
|
||||
return (lhsRange.lowerBound < rhsRange.lowerBound)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public func updateDraft(to draft: String) {
|
||||
let threadId: String = self.threadId
|
||||
let currentDraft: String = Storage.shared
|
||||
.read { db in
|
||||
try SessionThread
|
||||
.select(.messageDraft)
|
||||
.filter(id: threadId)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db)
|
||||
}
|
||||
.defaulting(to: "")
|
||||
|
||||
// Only write the updated draft to the database if it's changed (avoid unnecessary writes)
|
||||
guard draft != currentDraft else { return }
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
try SessionThread
|
||||
.filter(id: threadId)
|
||||
.updateAll(db, SessionThread.Columns.messageDraft.set(to: draft))
|
||||
}
|
||||
}
|
||||
|
||||
public func markAllAsRead() {
|
||||
// Don't bother marking anything as read if there are no unread interactions (we can rely
|
||||
// on the 'threadData.threadUnreadCount' to always be accurate)
|
||||
guard
|
||||
(self.threadData.threadUnreadCount ?? 0) > 0,
|
||||
let lastInteractionId: Int64 = self.threadData.interactionId
|
||||
else { return }
|
||||
|
||||
let threadId: String = self.threadData.threadId
|
||||
let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false)
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
try Interaction.markAsRead(
|
||||
db,
|
||||
interactionId: lastInteractionId,
|
||||
threadId: threadId,
|
||||
includingOlder: true,
|
||||
trySendReadReceipt: trySendReadReceipt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func swapToThread(updatedThreadId: String) {
|
||||
let oldestMessageId: Int64? = self.interactionData
|
||||
.filter { $0.model == .messages }
|
||||
.first?
|
||||
.elements
|
||||
.first?
|
||||
.id
|
||||
|
||||
self.threadId = updatedThreadId
|
||||
self.observableThreadData = self.setupObservableThreadData(for: updatedThreadId)
|
||||
self.pagedDataObserver = self.setupPagedObserver(
|
||||
for: updatedThreadId,
|
||||
userPublicKey: getUserHexEncodedPublicKey()
|
||||
)
|
||||
|
||||
// Try load everything up to the initial visible message, fallback to just the initial page of messages
|
||||
// if we don't have one
|
||||
switch oldestMessageId {
|
||||
case .some(let id): self.pagedDataObserver?.load(.untilInclusive(id: id, padding: 0))
|
||||
case .none: self.pagedDataObserver?.load(.pageBefore)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio Playback
|
||||
|
||||
public struct PlaybackInfo {
|
||||
let state: AudioPlaybackState
|
||||
let progress: TimeInterval
|
||||
let playbackRate: Double
|
||||
let oldPlaybackRate: Double
|
||||
let updateCallback: (PlaybackInfo?, Error?) -> ()
|
||||
|
||||
public func with(
|
||||
state: AudioPlaybackState? = nil,
|
||||
progress: TimeInterval? = nil,
|
||||
playbackRate: Double? = nil,
|
||||
updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil
|
||||
) -> PlaybackInfo {
|
||||
return PlaybackInfo(
|
||||
state: (state ?? self.state),
|
||||
progress: (progress ?? self.progress),
|
||||
playbackRate: (playbackRate ?? self.playbackRate),
|
||||
oldPlaybackRate: self.playbackRate,
|
||||
updateCallback: (updateCallback ?? self.updateCallback)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var audioPlayer: Atomic<OWSAudioPlayer?> = Atomic(nil)
|
||||
private var currentPlayingInteraction: Atomic<Int64?> = Atomic(nil)
|
||||
private var playbackInfo: Atomic<[Int64: PlaybackInfo]> = Atomic([:])
|
||||
|
||||
public func playbackInfo(for viewModel: MessageViewModel, updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil) -> PlaybackInfo? {
|
||||
// Use the existing info if it already exists (update it's callback if provided as that means
|
||||
// the cell was reloaded)
|
||||
if let currentPlaybackInfo: PlaybackInfo = playbackInfo.wrappedValue[viewModel.id] {
|
||||
let updatedPlaybackInfo: PlaybackInfo = currentPlaybackInfo
|
||||
.with(updateCallback: updateCallback)
|
||||
|
||||
playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
|
||||
|
||||
return updatedPlaybackInfo
|
||||
}
|
||||
|
||||
// Validate the item is a valid audio item
|
||||
guard
|
||||
let updateCallback: ((PlaybackInfo?, Error?) -> ()) = updateCallback,
|
||||
let attachment: Attachment = viewModel.attachments?.first,
|
||||
attachment.isAudio,
|
||||
attachment.isValid,
|
||||
let originalFilePath: String = attachment.originalFilePath,
|
||||
FileManager.default.fileExists(atPath: originalFilePath)
|
||||
else { return nil }
|
||||
|
||||
// Create the info with the update callback
|
||||
let newPlaybackInfo: PlaybackInfo = PlaybackInfo(
|
||||
state: .stopped,
|
||||
progress: 0,
|
||||
playbackRate: 1,
|
||||
oldPlaybackRate: 1,
|
||||
updateCallback: updateCallback
|
||||
)
|
||||
|
||||
// Cache the info
|
||||
playbackInfo.mutate { $0[viewModel.id] = newPlaybackInfo }
|
||||
|
||||
return newPlaybackInfo
|
||||
}
|
||||
|
||||
public func playOrPauseAudio(for viewModel: MessageViewModel) {
|
||||
guard
|
||||
let attachment: Attachment = viewModel.attachments?.first,
|
||||
let originalFilePath: String = attachment.originalFilePath,
|
||||
FileManager.default.fileExists(atPath: originalFilePath)
|
||||
else { return }
|
||||
|
||||
// If the user interacted with the currently playing item
|
||||
guard currentPlayingInteraction.wrappedValue != viewModel.id else {
|
||||
let currentPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[viewModel.id]
|
||||
let updatedPlaybackInfo: PlaybackInfo? = currentPlaybackInfo?
|
||||
.with(
|
||||
state: (currentPlaybackInfo?.state != .playing ? .playing : .paused),
|
||||
playbackRate: 1
|
||||
)
|
||||
|
||||
audioPlayer.wrappedValue?.playbackRate = 1
|
||||
|
||||
switch currentPlaybackInfo?.state {
|
||||
case .playing: audioPlayer.wrappedValue?.pause()
|
||||
default: audioPlayer.wrappedValue?.play()
|
||||
}
|
||||
|
||||
// Update the state and then update the UI with the updated state
|
||||
playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
|
||||
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// First stop any existing audio
|
||||
audioPlayer.wrappedValue?.stop()
|
||||
|
||||
// Then setup the state for the new audio
|
||||
currentPlayingInteraction.mutate { $0 = viewModel.id }
|
||||
|
||||
audioPlayer.mutate { [weak self] player in
|
||||
// Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer
|
||||
// gets deallocated it triggers state changes which cause UI bugs when auto-playing
|
||||
player?.delegate = nil
|
||||
player = nil
|
||||
|
||||
let audioPlayer: OWSAudioPlayer = OWSAudioPlayer(
|
||||
mediaUrl: URL(fileURLWithPath: originalFilePath),
|
||||
audioBehavior: .audioMessagePlayback,
|
||||
delegate: self
|
||||
)
|
||||
audioPlayer.play()
|
||||
audioPlayer.setCurrentTime(playbackInfo.wrappedValue[viewModel.id]?.progress ?? 0)
|
||||
player = audioPlayer
|
||||
}
|
||||
}
|
||||
|
||||
public func speedUpAudio(for viewModel: MessageViewModel) {
|
||||
// If we aren't playing the specified item then just start playing it
|
||||
guard viewModel.id == currentPlayingInteraction.wrappedValue else {
|
||||
playOrPauseAudio(for: viewModel)
|
||||
return
|
||||
}
|
||||
|
||||
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[viewModel.id]?
|
||||
.with(playbackRate: 1.5)
|
||||
|
||||
// Speed up the audio player
|
||||
audioPlayer.wrappedValue?.playbackRate = 1.5
|
||||
|
||||
playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
|
||||
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
||||
}
|
||||
|
||||
public func stopAudio() {
|
||||
audioPlayer.wrappedValue?.stop()
|
||||
|
||||
currentPlayingInteraction.mutate { $0 = nil }
|
||||
audioPlayer.mutate {
|
||||
// Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer
|
||||
// gets deallocated it triggers state changes which cause UI bugs when auto-playing
|
||||
$0?.delegate = nil
|
||||
$0 = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OWSAudioPlayerDelegate
|
||||
|
||||
public func audioPlaybackState() -> AudioPlaybackState {
|
||||
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return .stopped }
|
||||
|
||||
return (playbackInfo.wrappedValue[interactionId]?.state ?? .stopped)
|
||||
}
|
||||
|
||||
public func setAudioPlaybackState(_ state: AudioPlaybackState) {
|
||||
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
|
||||
|
||||
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
|
||||
.with(state: state)
|
||||
|
||||
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
|
||||
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
||||
}
|
||||
|
||||
public func setAudioProgress(_ progress: CGFloat, duration: CGFloat) {
|
||||
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
|
||||
|
||||
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
|
||||
.with(progress: TimeInterval(progress))
|
||||
|
||||
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
|
||||
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
||||
}
|
||||
|
||||
public func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully: Bool) {
|
||||
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
|
||||
guard successfully else { return }
|
||||
|
||||
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
|
||||
.with(
|
||||
state: .stopped,
|
||||
progress: 0,
|
||||
playbackRate: 1
|
||||
)
|
||||
|
||||
// Safe the changes and send one final update to the UI
|
||||
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
|
||||
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
||||
|
||||
// Clear out the currently playing record
|
||||
currentPlayingInteraction.mutate { $0 = nil }
|
||||
audioPlayer.mutate {
|
||||
// Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer
|
||||
// gets deallocated it triggers state changes which cause UI bugs when auto-playing
|
||||
$0?.delegate = nil
|
||||
$0 = nil
|
||||
}
|
||||
|
||||
// If the next interaction is another voice message then autoplay it
|
||||
guard
|
||||
let messageSection: SectionModel = self.interactionData
|
||||
.first(where: { $0.model == .messages }),
|
||||
let currentIndex: Int = messageSection.elements
|
||||
.firstIndex(where: { $0.id == interactionId }),
|
||||
currentIndex < (messageSection.elements.count - 1),
|
||||
messageSection.elements[currentIndex + 1].cellType == .audio
|
||||
else { return }
|
||||
|
||||
let nextItem: MessageViewModel = messageSection.elements[currentIndex + 1]
|
||||
playOrPauseAudio(for: nextItem)
|
||||
}
|
||||
|
||||
public func showInvalidAudioFileAlert() {
|
||||
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
|
||||
|
||||
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
|
||||
.with(
|
||||
state: .stopped,
|
||||
progress: 0,
|
||||
playbackRate: 1
|
||||
)
|
||||
|
||||
currentPlayingInteraction.mutate { $0 = nil }
|
||||
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
|
||||
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, AttachmentError.invalidData)
|
||||
}
|
||||
}
|
|
@ -143,7 +143,7 @@ final class ExpandingAttachmentsButton : UIView, InputViewButtonDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
// MARK: - Delegate
|
||||
|
||||
protocol ExpandingAttachmentsButtonDelegate: AnyObject {
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ public final class InputTextView : UITextView, UITextViewDelegate {
|
|||
private let maxWidth: CGFloat
|
||||
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
|
||||
private lazy var placeholderLabel: UILabel = {
|
||||
|
@ -79,21 +79,26 @@ public final class InputTextView : UITextView, UITextViewDelegate {
|
|||
|
||||
private func handleTextChanged() {
|
||||
defer { snDelegate?.inputTextViewDidChangeContent(self) }
|
||||
placeholderLabel.isHidden = !text.isEmpty
|
||||
|
||||
placeholderLabel.isHidden = !(text ?? "").isEmpty
|
||||
|
||||
let height = frame.height
|
||||
let size = sizeThatFits(CGSize(width: maxWidth, height: .greatestFiniteMagnitude))
|
||||
|
||||
// `textView.contentSize` isn't accurate when restoring a multiline draft, so we set it here manually
|
||||
self.contentSize = size
|
||||
let newHeight = size.height.clamp(minHeight, maxHeight)
|
||||
|
||||
guard newHeight != height else { return }
|
||||
|
||||
heightConstraint.constant = newHeight
|
||||
snDelegate?.inputTextViewDidChangeSize(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
protocol InputTextViewDelegate : AnyObject {
|
||||
// MARK: - InputTextViewDelegate
|
||||
|
||||
protocol InputTextViewDelegate: AnyObject {
|
||||
func inputTextViewDidChangeSize(_ inputTextView: InputTextView)
|
||||
func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
|
||||
func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage)
|
||||
|
|
|
@ -1,30 +1,44 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, QuoteViewDelegate, LinkPreviewViewDelegate, MentionSelectionViewDelegate {
|
||||
enum MessageTypes {
|
||||
case all
|
||||
case textOnly
|
||||
case none
|
||||
}
|
||||
final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate {
|
||||
// MARK: - Variables
|
||||
|
||||
private static let linkPreviewViewInset: CGFloat = 6
|
||||
|
||||
private let threadVariant: SessionThread.Variant
|
||||
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 lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0)
|
||||
|
||||
private lazy var linkPreviewView: LinkPreviewView = {
|
||||
let maxWidth = self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset
|
||||
return LinkPreviewView(for: nil, maxWidth: maxWidth, delegate: self)
|
||||
let maxWidth: CGFloat = (self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset)
|
||||
|
||||
return LinkPreviewView(maxWidth: maxWidth) { [weak self] in
|
||||
self?.linkPreviewInfo = nil
|
||||
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
}
|
||||
}()
|
||||
|
||||
var text: String {
|
||||
get { inputTextView.text }
|
||||
get { inputTextView.text ?? "" }
|
||||
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 {
|
||||
setEnabledMessageTypes(enabledMessageTypes, message: nil)
|
||||
}
|
||||
|
@ -33,7 +47,7 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
override var intrinsicContentSize: CGSize { CGSize.zero }
|
||||
var lastSearchedText: String? { nil }
|
||||
|
||||
// MARK: UI Components
|
||||
// MARK: - UI
|
||||
|
||||
private var bottomStackView: UIStackView?
|
||||
private lazy var attachmentsButton = ExpandingAttachmentsButton(delegate: delegate)
|
||||
|
@ -45,7 +59,6 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
return result
|
||||
}()
|
||||
|
||||
|
||||
private lazy var sendButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self)
|
||||
result.isHidden = true
|
||||
|
@ -55,22 +68,25 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton)
|
||||
|
||||
private lazy var mentionsView: MentionSelectionView = {
|
||||
let result = MentionSelectionView()
|
||||
let result: MentionSelectionView = MentionSelectionView()
|
||||
result.delegate = self
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var mentionsViewContainer: UIView = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
let backgroundView = UIView()
|
||||
backgroundView.backgroundColor = isLightMode ? .white : .black
|
||||
backgroundView.backgroundColor = (isLightMode ? .white : .black)
|
||||
backgroundView.alpha = Values.lowOpacity
|
||||
result.addSubview(backgroundView)
|
||||
backgroundView.pin(to: result)
|
||||
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
|
||||
let blurView: UIVisualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
result.addSubview(blurView)
|
||||
blurView.pin(to: result)
|
||||
result.alpha = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -97,13 +113,14 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
|
||||
private lazy var additionalContentContainer = UIView()
|
||||
|
||||
// MARK: Settings
|
||||
private static let linkPreviewViewInset: CGFloat = 6
|
||||
// MARK: - Initialization
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(delegate: InputViewDelegate) {
|
||||
init(threadVariant: SessionThread.Variant, delegate: InputViewDelegate) {
|
||||
self.threadVariant = threadVariant
|
||||
self.delegate = delegate
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
@ -117,31 +134,37 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
|
||||
private func setUpViewHierarchy() {
|
||||
autoresizingMask = .flexibleHeight
|
||||
|
||||
// Background & blur
|
||||
let backgroundView = UIView()
|
||||
backgroundView.backgroundColor = isLightMode ? .white : .black
|
||||
backgroundView.alpha = Values.lowOpacity
|
||||
addSubview(backgroundView)
|
||||
backgroundView.pin(to: self)
|
||||
|
||||
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
addSubview(blurView)
|
||||
blurView.pin(to: self)
|
||||
|
||||
// Separator
|
||||
let separator = UIView()
|
||||
separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
|
||||
separator.set(.height, to: 1 / UIScreen.main.scale)
|
||||
addSubview(separator)
|
||||
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
|
||||
|
||||
// Bottom stack view
|
||||
let bottomStackView = UIStackView(arrangedSubviews: [ attachmentsButton, inputTextView, container(for: sendButton) ])
|
||||
bottomStackView.axis = .horizontal
|
||||
bottomStackView.spacing = Values.smallSpacing
|
||||
bottomStackView.alignment = .center
|
||||
self.bottomStackView = bottomStackView
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ additionalContentContainer, bottomStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.isLayoutMarginsRelativeArrangement = true
|
||||
|
||||
let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2
|
||||
mainStackView.layoutMargins = UIEdgeInsets(top: 2, leading: Values.mediumSpacing - adjustment, bottom: 2, trailing: Values.mediumSpacing - adjustment)
|
||||
addSubview(mainStackView)
|
||||
|
@ -163,12 +186,14 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
mentionsViewContainer.addSubview(mentionsView)
|
||||
mentionsView.pin(to: mentionsViewContainer)
|
||||
mentionsViewHeightConstraint.isActive = true
|
||||
|
||||
// Voice message button
|
||||
addSubview(voiceMessageButtonContainer)
|
||||
voiceMessageButtonContainer.center(in: sendButton)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
// MARK: - Updating
|
||||
|
||||
func inputTextViewDidChangeSize(_ inputTextView: InputTextView) {
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
@ -192,11 +217,27 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
private func handleQuoteDraftChanged() {
|
||||
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
linkPreviewInfo = nil
|
||||
|
||||
guard let quoteDraftInfo = quoteDraftInfo else { return }
|
||||
let direction: QuoteView.Direction = quoteDraftInfo.isOutgoing ? .outgoing : .incoming
|
||||
|
||||
let hInset: CGFloat = 6 // Slight visual adjustment
|
||||
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)
|
||||
quoteView.pin(.left, to: .left, of: additionalContentContainer, withInset: hInset)
|
||||
quoteView.pin(.top, to: .top, of: additionalContentContainer, withInset: 12)
|
||||
|
@ -211,52 +252,66 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
// Suggest that the user enable link previews if they haven't already and we haven't
|
||||
// told them about link previews yet
|
||||
let text = inputTextView.text!
|
||||
let userDefaults = UserDefaults.standard
|
||||
if !OWSLinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && !SSKPreferences.areLinkPreviewsEnabled
|
||||
&& !userDefaults[.hasSeenLinkPreviewSuggestion] {
|
||||
let areLinkPreviewsEnabled: Bool = Storage.shared[.areLinkPreviewsEnabled]
|
||||
|
||||
if
|
||||
!LinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty &&
|
||||
!areLinkPreviewsEnabled &&
|
||||
!UserDefaults.standard[.hasSeenLinkPreviewSuggestion]
|
||||
{
|
||||
delegate?.showLinkPreviewSuggestionModal()
|
||||
userDefaults[.hasSeenLinkPreviewSuggestion] = true
|
||||
UserDefaults.standard[.hasSeenLinkPreviewSuggestion] = true
|
||||
return
|
||||
}
|
||||
// Check that link previews are enabled
|
||||
guard SSKPreferences.areLinkPreviewsEnabled else { return }
|
||||
guard areLinkPreviewsEnabled else { return }
|
||||
|
||||
// Proceed
|
||||
autoGenerateLinkPreview()
|
||||
}
|
||||
|
||||
func autoGenerateLinkPreview() {
|
||||
// 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
|
||||
}
|
||||
|
||||
// Guard against obsolete updates
|
||||
guard linkPreviewURL != self.linkPreviewInfo?.url else { return }
|
||||
|
||||
// Clear content container
|
||||
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
quoteDraftInfo = nil
|
||||
|
||||
// Set the state to loading
|
||||
linkPreviewInfo = (url: linkPreviewURL, draft: nil)
|
||||
linkPreviewView.linkPreviewState = LinkPreviewLoading()
|
||||
linkPreviewView.update(with: LinkPreview.LoadingState(), isOutgoing: false)
|
||||
|
||||
// Add the link preview view
|
||||
additionalContentContainer.addSubview(linkPreviewView)
|
||||
linkPreviewView.pin(.left, to: .left, of: additionalContentContainer, withInset: InputView.linkPreviewViewInset)
|
||||
linkPreviewView.pin(.top, to: .top, of: additionalContentContainer, withInset: 10)
|
||||
linkPreviewView.pin(.right, to: .right, of: additionalContentContainer)
|
||||
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()
|
||||
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: MessageTypes, message: String?) {
|
||||
func setEnabledMessageTypes(_ messageTypes: MessageInputTypes, message: String?) {
|
||||
guard enabledMessageTypes != messageTypes else { return }
|
||||
|
||||
enabledMessageTypes = messageTypes
|
||||
|
@ -279,32 +334,37 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
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
|
||||
let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton,
|
||||
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
|
||||
} else {
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
|
||||
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
||||
let buttonContainers = [ attachmentsButton.gifButtonContainer, attachmentsButton.documentButtonContainer,
|
||||
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 {
|
||||
// Needed so that the user can tap the buttons when the expanding attachments button is expanded
|
||||
return true
|
||||
} else if mentionsViewContainer.frame.contains(point) {
|
||||
}
|
||||
|
||||
if mentionsViewContainer.frame.contains(point) {
|
||||
// Needed so that the user can tap mentions
|
||||
return true
|
||||
} else {
|
||||
return super.point(inside: point, with: event)
|
||||
}
|
||||
|
||||
return super.point(inside: point, with: event)
|
||||
}
|
||||
|
||||
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) {
|
||||
|
@ -329,21 +389,16 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
voiceMessageRecordingView.handleLongPressEnded(at: location)
|
||||
}
|
||||
|
||||
func handleQuoteViewCancelButtonTapped() {
|
||||
delegate?.handleQuoteViewCancelButtonTapped()
|
||||
}
|
||||
|
||||
override func resignFirstResponder() -> Bool {
|
||||
inputTextView.resignFirstResponder()
|
||||
}
|
||||
|
||||
func handleLongPress() {
|
||||
// Not relevant in this case
|
||||
func inputTextViewBecomeFirstResponder() {
|
||||
inputTextView.becomeFirstResponder()
|
||||
}
|
||||
|
||||
func handleLinkPreviewCanceled() {
|
||||
linkPreviewInfo = nil
|
||||
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
func handleLongPress() {
|
||||
// Not relevant in this case
|
||||
}
|
||||
|
||||
@objc private func showVoiceMessageUI() {
|
||||
|
@ -373,50 +428,53 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
}
|
||||
|
||||
func hideMentionsUI() {
|
||||
UIView.animate(withDuration: 0.25, animations: {
|
||||
self.mentionsViewContainer.alpha = 0
|
||||
}, completion: { _ in
|
||||
self.mentionsViewHeightConstraint.constant = 0
|
||||
self.mentionsView.tableView.contentOffset = CGPoint.zero
|
||||
})
|
||||
UIView.animate(
|
||||
withDuration: 0.25,
|
||||
animations: { [weak self] in
|
||||
self?.mentionsViewContainer.alpha = 0
|
||||
},
|
||||
completion: { [weak self] _ in
|
||||
self?.mentionsViewHeightConstraint.constant = 0
|
||||
self?.mentionsView.contentOffset = CGPoint.zero
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func showMentionsUI(for candidates: [Mention], in thread: TSThread) {
|
||||
if let openGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) {
|
||||
mentionsView.openGroupServer = openGroupV2.server
|
||||
mentionsView.openGroupRoom = openGroupV2.room
|
||||
}
|
||||
func showMentionsUI(for candidates: [ConversationViewModel.MentionInfo]) {
|
||||
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
|
||||
layoutIfNeeded()
|
||||
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.mentionsViewContainer.alpha = 1
|
||||
}
|
||||
}
|
||||
|
||||
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) {
|
||||
delegate?.handleMentionSelected(mention, from: view)
|
||||
func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView) {
|
||||
delegate?.handleMentionSelected(mentionInfo, from: view)
|
||||
}
|
||||
|
||||
// MARK: Convenience
|
||||
// MARK: - Convenience
|
||||
|
||||
private func container(for button: InputViewButton) -> UIView {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.addSubview(button)
|
||||
result.set(.width, to: InputViewButton.expandedSize)
|
||||
result.set(.height, to: InputViewButton.expandedSize)
|
||||
button.center(in: result)
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
protocol InputViewDelegate : AnyObject, ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate {
|
||||
// MARK: - Delegate
|
||||
|
||||
protocol InputViewDelegate: ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate {
|
||||
func showLinkPreviewSuggestionModal()
|
||||
func handleSendButtonTapped()
|
||||
func handleQuoteViewCancelButtonTapped()
|
||||
func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
|
||||
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView)
|
||||
func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView)
|
||||
func didPasteImageFromPasteboard(_ image: UIImage)
|
||||
}
|
||||
|
|
|
@ -59,8 +59,8 @@ final class InputViewButton : UIView {
|
|||
isUserInteractionEnabled = true
|
||||
widthConstraint.isActive = true
|
||||
heightConstraint.isActive = true
|
||||
let tint = isSendButton ? UIColor.black : Colors.text
|
||||
let iconImageView = UIImageView(image: icon.withTint(tint))
|
||||
let iconImageView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
|
||||
iconImageView.tintColor = (isSendButton ? UIColor.black : Colors.text)
|
||||
iconImageView.contentMode = .scaleAspectFit
|
||||
let iconSize = InputViewButton.iconSize
|
||||
iconImageView.set(.width, to: iconSize)
|
||||
|
@ -141,9 +141,9 @@ final class InputViewButton : UIView {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
protocol InputViewButtonDelegate : class {
|
||||
// MARK: - Delegate
|
||||
|
||||
protocol InputViewButtonDelegate: AnyObject {
|
||||
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton)
|
||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton)
|
||||
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch)
|
||||
|
@ -151,7 +151,6 @@ protocol InputViewButtonDelegate : class {
|
|||
}
|
||||
|
||||
extension InputViewButtonDelegate {
|
||||
|
||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) { }
|
||||
func handleInputViewButtonLongPressMoved(_ 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 {
|
||||
var candidates: [Mention] = [] {
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDelegate {
|
||||
var candidates: [ConversationViewModel.MentionInfo] = [] {
|
||||
didSet {
|
||||
tableView.isScrollEnabled = (candidates.count > 4)
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
var openGroupServer: String?
|
||||
var openGroupChannel: UInt64?
|
||||
var openGroupRoom: String?
|
||||
|
||||
weak var delegate: MentionSelectionViewDelegate?
|
||||
|
||||
// MARK: Components
|
||||
lazy var tableView: UITableView = { // TODO: Make this private
|
||||
let result = UITableView()
|
||||
var contentOffset: CGPoint {
|
||||
get { tableView.contentOffset }
|
||||
set { tableView.contentOffset = newValue }
|
||||
}
|
||||
|
||||
// MARK: - Components
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let result: UITableView = UITableView()
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
result.register(Cell.self, forCellReuseIdentifier: "Cell")
|
||||
result.separatorStyle = .none
|
||||
result.backgroundColor = .clear
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.register(view: Cell.self)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Initialization
|
||||
// MARK: - Initialization
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
@ -38,43 +52,54 @@ final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDel
|
|||
// Table view
|
||||
addSubview(tableView)
|
||||
tableView.pin(to: self)
|
||||
|
||||
// Top separator
|
||||
let topSeparator = UIView()
|
||||
let topSeparator: UIView = UIView()
|
||||
topSeparator.backgroundColor = Colors.separator
|
||||
topSeparator.set(.height, to: Values.separatorThickness)
|
||||
addSubview(topSeparator)
|
||||
topSeparator.pin(.leading, to: .leading, of: self)
|
||||
topSeparator.pin(.top, to: .top, of: self)
|
||||
topSeparator.pin(.trailing, to: .trailing, of: self)
|
||||
|
||||
// Bottom separator
|
||||
let bottomSeparator = UIView()
|
||||
let bottomSeparator: UIView = UIView()
|
||||
bottomSeparator.backgroundColor = Colors.separator
|
||||
bottomSeparator.set(.height, to: Values.separatorThickness)
|
||||
addSubview(bottomSeparator)
|
||||
|
||||
bottomSeparator.pin(.leading, to: .leading, of: self)
|
||||
bottomSeparator.pin(.trailing, to: .trailing, of: self)
|
||||
bottomSeparator.pin(.bottom, to: .bottom, of: self)
|
||||
}
|
||||
|
||||
// MARK: Data
|
||||
// MARK: - Data
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return candidates.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! Cell
|
||||
let mentionCandidate = candidates[indexPath.row]
|
||||
cell.mentionCandidate = mentionCandidate
|
||||
cell.openGroupServer = openGroupServer
|
||||
cell.openGroupChannel = openGroupChannel
|
||||
cell.openGroupRoom = openGroupRoom
|
||||
cell.separator.isHidden = (indexPath.row == (candidates.count - 1))
|
||||
let cell: Cell = tableView.dequeue(type: Cell.self, for: indexPath)
|
||||
cell.update(
|
||||
with: candidates[indexPath.row].profile,
|
||||
threadVariant: candidates[indexPath.row].threadVariant,
|
||||
isUserModeratorOrAdmin: OpenGroupManager.isUserModeratorOrAdmin(
|
||||
candidates[indexPath.row].profile.id,
|
||||
for: candidates[indexPath.row].openGroupRoomToken,
|
||||
on: candidates[indexPath.row].openGroupServer
|
||||
),
|
||||
isLast: (indexPath.row == (candidates.count - 1))
|
||||
)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
let mentionCandidate = candidates[indexPath.row]
|
||||
|
||||
delegate?.handleMentionSelected(mentionCandidate, from: self)
|
||||
}
|
||||
}
|
||||
|
@ -82,56 +107,59 @@ final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDel
|
|||
// MARK: - Cell
|
||||
|
||||
private extension MentionSelectionView {
|
||||
final class Cell: UITableViewCell {
|
||||
// MARK: - UI
|
||||
|
||||
final class Cell : UITableViewCell {
|
||||
var mentionCandidate = Mention(publicKey: "", displayName: "") { didSet { update() } }
|
||||
var openGroupServer: String?
|
||||
var openGroupChannel: UInt64?
|
||||
var openGroupRoom: String?
|
||||
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView()
|
||||
|
||||
// MARK: Components
|
||||
private lazy var profilePictureView = ProfilePictureView()
|
||||
|
||||
private lazy var moderatorIconImageView = UIImageView(image: #imageLiteral(resourceName: "Crown"))
|
||||
private lazy var moderatorIconImageView: UIImageView = UIImageView(image: #imageLiteral(resourceName: "Crown"))
|
||||
|
||||
private lazy var displayNameLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
lazy var separator: UIView = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = Colors.separator
|
||||
result.set(.height, to: Values.separatorThickness)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Initialization
|
||||
// MARK: - Initialization
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
// Cell background color
|
||||
backgroundColor = .clear
|
||||
|
||||
// Highlight color
|
||||
let selectedBackgroundView = UIView()
|
||||
selectedBackgroundView.backgroundColor = .clear
|
||||
self.selectedBackgroundView = selectedBackgroundView
|
||||
|
||||
// Profile picture image view
|
||||
let profilePictureViewSize = Values.smallProfilePictureSize
|
||||
profilePictureView.set(.width, to: profilePictureViewSize)
|
||||
profilePictureView.set(.height, to: profilePictureViewSize)
|
||||
profilePictureView.size = profilePictureViewSize
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel ])
|
||||
mainStackView.axis = .horizontal
|
||||
|
@ -144,12 +172,14 @@ private extension MentionSelectionView {
|
|||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.mediumSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.smallSpacing)
|
||||
mainStackView.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing)
|
||||
|
||||
// Moderator icon image view
|
||||
moderatorIconImageView.set(.width, to: 20)
|
||||
moderatorIconImageView.set(.height, to: 20)
|
||||
contentView.addSubview(moderatorIconImageView)
|
||||
moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1)
|
||||
moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5)
|
||||
|
||||
// Separator
|
||||
addSubview(separator)
|
||||
separator.pin(.leading, to: .leading, of: self)
|
||||
|
@ -157,24 +187,28 @@ private extension MentionSelectionView {
|
|||
separator.pin(.bottom, to: .bottom, of: self)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
private func update() {
|
||||
displayNameLabel.text = mentionCandidate.displayName
|
||||
profilePictureView.publicKey = mentionCandidate.publicKey
|
||||
profilePictureView.update()
|
||||
if let server = openGroupServer, let room = openGroupRoom {
|
||||
let isUserModerator = OpenGroupAPIV2.isUserModerator(mentionCandidate.publicKey, for: room, on: server)
|
||||
moderatorIconImageView.isHidden = !isUserModerator
|
||||
} else {
|
||||
moderatorIconImageView.isHidden = true
|
||||
}
|
||||
// MARK: - Updating
|
||||
|
||||
fileprivate func update(
|
||||
with profile: Profile,
|
||||
threadVariant: SessionThread.Variant,
|
||||
isUserModeratorOrAdmin: Bool,
|
||||
isLast: Bool
|
||||
) {
|
||||
displayNameLabel.text = profile.displayName(for: threadVariant)
|
||||
profilePictureView.update(
|
||||
publicKey: profile.id,
|
||||
profile: profile,
|
||||
threadVariant: threadVariant
|
||||
)
|
||||
moderatorIconImageView.isHidden = !isUserModeratorOrAdmin
|
||||
separator.isHidden = isLast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delegate
|
||||
|
||||
protocol MentionSelectionViewDelegate : class {
|
||||
|
||||
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView)
|
||||
protocol MentionSelectionViewDelegate: AnyObject {
|
||||
func handleMentionSelected(_ mention: ConversationViewModel.MentionInfo, from view: MentionSelectionView)
|
||||
}
|
||||
|
|
|
@ -396,9 +396,9 @@ extension VoiceMessageRecordingView {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
protocol VoiceMessageRecordingViewDelegate : class {
|
||||
// MARK: - Delegate
|
||||
|
||||
protocol VoiceMessageRecordingViewDelegate: AnyObject {
|
||||
func startVoiceMessageRecording()
|
||||
func endVoiceMessageRecording()
|
||||
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 SessionUIKit
|
||||
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 iconImageViewHeightConstraint = iconImageView.set(.height, to: 0)
|
||||
|
||||
private lazy var infoImageViewWidthConstraint = infoImageView.set(.width, to: 0)
|
||||
private lazy var infoImageViewHeightConstraint = infoImageView.set(.height, to: 0)
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var iconImageView = UIImageView()
|
||||
// MARK: - UI
|
||||
|
||||
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 = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var label: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.numberOfLines = 0
|
||||
result.lineBreakMode = .byWordWrapping
|
||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var container: UIView = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.set(.height, to: 50)
|
||||
result.layer.cornerRadius = 18
|
||||
result.backgroundColor = Colors.callMessageBackground
|
||||
result.addSubview(label)
|
||||
|
||||
label.autoCenterInSuperview()
|
||||
result.addSubview(iconImageView)
|
||||
|
||||
iconImageView.autoVCenterInSuperview()
|
||||
iconImageView.pin(.left, to: .left, of: result, withInset: CallMessageCell.inset)
|
||||
result.addSubview(infoImageView)
|
||||
|
||||
infoImageView.autoVCenterInSuperview()
|
||||
infoImageView.pin(.right, to: .right, of: result, withInset: -CallMessageCell.inset)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var stackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ timestampLabel, container ])
|
||||
let result: UIStackView = UIStackView(arrangedSubviews: [ timestampLabel, container ])
|
||||
result.axis = .vertical
|
||||
result.alignment = .center
|
||||
result.spacing = Values.smallSpacing
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
private static let iconSize: CGFloat = 16
|
||||
private static let inset = Values.mediumSpacing
|
||||
private static let margin = UIScreen.main.bounds.width * 0.1
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override class var identifier: String { "CallMessageCell" }
|
||||
|
||||
// MARK: Lifecycle
|
||||
override func setUpViewHierarchy() {
|
||||
super.setUpViewHierarchy()
|
||||
|
||||
iconImageViewWidthConstraint.isActive = true
|
||||
iconImageViewHeightConstraint.isActive = true
|
||||
addSubview(stackView)
|
||||
|
||||
container.autoPinWidthToSuperview()
|
||||
stackView.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin)
|
||||
stackView.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset)
|
||||
|
@ -81,39 +96,71 @@ final class CallMessageCell : MessageCell {
|
|||
addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
override func update() {
|
||||
guard let message = viewItem?.interaction as? TSInfoMessage, message.messageType == .call else { return }
|
||||
let icon: UIImage?
|
||||
switch message.callState {
|
||||
case .outgoing: icon = UIImage(named: "CallOutgoing")?.withTint(Colors.text)
|
||||
case .incoming: icon = UIImage(named: "CallIncoming")?.withTint(Colors.text)
|
||||
case .missed, .permissionDenied: icon = UIImage(named: "CallMissed")?.withTint(Colors.destructive)
|
||||
default: icon = nil
|
||||
}
|
||||
iconImageView.image = icon
|
||||
iconImageViewWidthConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0
|
||||
iconImageViewHeightConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0
|
||||
// MARK: - Updating
|
||||
|
||||
let shouldShowInfoIcon = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled
|
||||
infoImageViewWidthConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0
|
||||
infoImageViewHeightConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0
|
||||
override func update(
|
||||
with cellViewModel: MessageViewModel,
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||
lastSearchText: String?
|
||||
) {
|
||||
guard
|
||||
cellViewModel.variant == .infoCall,
|
||||
let infoMessageData: Data = (cellViewModel.rawBody ?? "").data(using: .utf8),
|
||||
let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode(
|
||||
CallMessage.MessageInfo.self,
|
||||
from: infoMessageData
|
||||
)
|
||||
else { return }
|
||||
|
||||
Storage.read { transaction in
|
||||
self.label.text = message.previewText(with: transaction)
|
||||
}
|
||||
self.viewModel = cellViewModel
|
||||
|
||||
let date = message.dateForUI()
|
||||
let description = DateUtil.formatDate(forDisplay: date)
|
||||
timestampLabel.text = description
|
||||
iconImageView.image = {
|
||||
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 shouldShowInfoIcon: Bool = (
|
||||
messageInfo.state == .permissionDenied &&
|
||||
!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) {
|
||||
guard let viewItem = viewItem, let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call else { return }
|
||||
let shouldBeTappable = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled
|
||||
if shouldBeTappable {
|
||||
delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer)
|
||||
}
|
||||
}
|
||||
guard
|
||||
let cellViewModel: MessageViewModel = self.viewModel,
|
||||
cellViewModel.variant == .infoCall,
|
||||
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 {
|
||||
private let viewItem: ConversationViewItem
|
||||
private let textColor: UIColor
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
// MARK: Settings
|
||||
final class CallMessageView: UIView {
|
||||
private static let iconSize: CGFloat = 24
|
||||
private static let iconImageViewSize: CGFloat = 40
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
||||
self.viewItem = viewItem
|
||||
self.textColor = textColor
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(cellViewModel: MessageViewModel, textColor: UIColor) {
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
|
||||
setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -23,22 +24,27 @@ final class CallMessageView : UIView {
|
|||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() }
|
||||
private func setUpViewHierarchy(cellViewModel: MessageViewModel, textColor: UIColor) {
|
||||
// Image view
|
||||
let iconSize = CallMessageView.iconSize
|
||||
let icon = UIImage(named: "Phone")?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
let imageView = UIImageView(image: icon)
|
||||
let imageView: UIImageView = UIImageView(
|
||||
image: UIImage(named: "Phone")?
|
||||
.resizedImage(to: CGSize(width: CallMessageView.iconSize, height: CallMessageView.iconSize))?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
imageView.tintColor = textColor
|
||||
imageView.contentMode = .center
|
||||
|
||||
let iconImageViewSize = CallMessageView.iconImageViewSize
|
||||
imageView.set(.width, to: iconImageViewSize)
|
||||
imageView.set(.height, to: iconImageViewSize)
|
||||
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = message.body
|
||||
titleLabel.text = cellViewModel.body
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||
stackView.axis = .horizontal
|
||||
|
|
|
@ -1,43 +1,51 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class DeletedMessageView : UIView {
|
||||
private let viewItem: ConversationViewItem
|
||||
private let textColor: UIColor
|
||||
import UIKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
// MARK: Settings
|
||||
final class DeletedMessageView: UIView {
|
||||
private static let iconSize: CGFloat = 18
|
||||
private static let iconImageViewSize: CGFloat = 30
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
||||
self.viewItem = viewItem
|
||||
self.textColor = textColor
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(textColor: UIColor) {
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
|
||||
setUpViewHierarchy(textColor: textColor)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
preconditionFailure("Use init(textColor:) instead.")
|
||||
}
|
||||
|
||||
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
|
||||
let iconSize = DeletedMessageView.iconSize
|
||||
let icon = UIImage(named: "ic_trash")?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
let icon = UIImage(named: "ic_trash")?
|
||||
.resizedImage(to: CGSize(
|
||||
width: DeletedMessageView.iconSize,
|
||||
height: DeletedMessageView.iconSize
|
||||
))?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
|
||||
let imageView = UIImageView(image: icon)
|
||||
imageView.tintColor = textColor
|
||||
imageView.contentMode = .center
|
||||
let iconImageViewSize = DeletedMessageView.iconImageViewSize
|
||||
imageView.set(.width, to: iconImageViewSize)
|
||||
imageView.set(.height, to: iconImageViewSize)
|
||||
imageView.set(.width, to: DeletedMessageView.iconImageViewSize)
|
||||
imageView.set(.height, to: DeletedMessageView.iconImageViewSize)
|
||||
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = NSLocalizedString("message_deleted", comment: "")
|
||||
titleLabel.text = "message_deleted".localized()
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||
stackView.axis = .horizontal
|
||||
|
@ -45,7 +53,8 @@ final class DeletedMessageView : UIView {
|
|||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 6)
|
||||
addSubview(stackView)
|
||||
|
||||
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 {
|
||||
private let viewItem: ConversationViewItem
|
||||
private let textColor: UIColor
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
// MARK: Settings
|
||||
final class DocumentView: UIView {
|
||||
private static let iconImageViewSize: CGSize = CGSize(width: 31, height: 40)
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
||||
self.viewItem = viewItem
|
||||
self.textColor = textColor
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(attachment: Attachment, textColor: UIColor) {
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
|
||||
setUpViewHierarchy(attachment: attachment, textColor: textColor)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -22,30 +23,34 @@ final class DocumentView : UIView {
|
|||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
guard let attachment = viewItem.attachmentStream ?? viewItem.attachmentPointer else { return }
|
||||
private func setUpViewHierarchy(attachment: Attachment, textColor: UIColor) {
|
||||
// Image view
|
||||
let icon = UIImage(named: "File")?.withTint(textColor)
|
||||
let imageView = UIImageView(image: icon)
|
||||
let imageView = UIImageView(image: UIImage(named: "File")?.withRenderingMode(.alwaysTemplate))
|
||||
imageView.tintColor = textColor
|
||||
imageView.contentMode = .center
|
||||
|
||||
let iconImageViewSize = DocumentView.iconImageViewSize
|
||||
imageView.set(.width, to: iconImageViewSize.width)
|
||||
imageView.set(.height, to: iconImageViewSize.height)
|
||||
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = attachment.sourceFilename ?? "File"
|
||||
titleLabel.text = (attachment.sourceFilename ?? "File")
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.smallFontSize, weight: .light)
|
||||
|
||||
// Size label
|
||||
let sizeLabel = UILabel()
|
||||
sizeLabel.lineBreakMode = .byTruncatingTail
|
||||
sizeLabel.text = OWSFormat.formatFileSize(UInt(attachment.byteCount))
|
||||
sizeLabel.textColor = textColor
|
||||
sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
|
||||
// Label stack view
|
||||
let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, sizeLabel ])
|
||||
labelStackView.axis = .vertical
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, labelStackView ])
|
||||
stackView.axis = .horizontal
|
||||
|
|
|
@ -1,220 +1,138 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
extension CGPoint {
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
public func offsetBy(dx: CGFloat) -> CGPoint {
|
||||
return CGPoint(x: x + dx, y: y)
|
||||
}
|
||||
|
||||
public func offsetBy(dy: CGFloat) -> CGPoint {
|
||||
return CGPoint(x: x, y: y + dy)
|
||||
}
|
||||
protocol LinkPreviewState {
|
||||
var isLoaded: Bool { get }
|
||||
var urlString: String? { get }
|
||||
var title: String? { get }
|
||||
var imageState: LinkPreview.ImageState { get }
|
||||
var image: UIImage? { get }
|
||||
}
|
||||
|
||||
// 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 extension LinkPreview {
|
||||
enum ImageState: Int {
|
||||
case none
|
||||
case loading
|
||||
case loaded
|
||||
case invalid
|
||||
}
|
||||
|
||||
public func isLoaded() -> Bool {
|
||||
return false
|
||||
// MARK: LoadingState
|
||||
|
||||
struct LoadingState: LinkPreviewState {
|
||||
var isLoaded: Bool { false }
|
||||
var urlString: String? { nil }
|
||||
var title: String? { nil }
|
||||
var imageState: LinkPreview.ImageState { .none }
|
||||
var image: UIImage? { nil }
|
||||
}
|
||||
|
||||
public func urlString() -> String? {
|
||||
return nil
|
||||
}
|
||||
// MARK: DraftState
|
||||
|
||||
public func displayDomain() -> String? {
|
||||
return nil
|
||||
}
|
||||
struct DraftState: LinkPreviewState {
|
||||
var isLoaded: Bool { true }
|
||||
var urlString: String? { linkPreviewDraft.urlString }
|
||||
|
||||
public func title() -> String? {
|
||||
return nil
|
||||
}
|
||||
var title: String? {
|
||||
guard let value = linkPreviewDraft.title, value.count > 0 else { 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 value
|
||||
}
|
||||
return displayDomain
|
||||
}
|
||||
|
||||
public func title() -> String? {
|
||||
guard let value = linkPreviewDraft.title,
|
||||
value.count > 0 else {
|
||||
var imageState: LinkPreview.ImageState {
|
||||
if linkPreviewDraft.jpegImageData != nil { return .loaded }
|
||||
|
||||
return .none
|
||||
}
|
||||
|
||||
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 value
|
||||
}
|
||||
}
|
||||
|
||||
public func imageState() -> LinkPreviewImageState {
|
||||
if linkPreviewDraft.jpegImageData != nil {
|
||||
return .loaded
|
||||
} else {
|
||||
return .none
|
||||
return image
|
||||
}
|
||||
|
||||
// MARK: - Type Specific
|
||||
|
||||
private let linkPreviewDraft: LinkPreviewDraft
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(linkPreviewDraft: LinkPreviewDraft) {
|
||||
self.linkPreviewDraft = linkPreviewDraft
|
||||
}
|
||||
}
|
||||
|
||||
public func image() -> UIImage? {
|
||||
guard let jpegImageData = linkPreviewDraft.jpegImageData else {
|
||||
return nil
|
||||
// MARK: SentState
|
||||
|
||||
struct SentState: LinkPreviewState {
|
||||
var isLoaded: Bool { true }
|
||||
var urlString: String? { linkPreview.url }
|
||||
|
||||
var title: String? {
|
||||
guard let value = linkPreview.title, value.count > 0 else { return nil }
|
||||
|
||||
return value
|
||||
}
|
||||
guard let image = UIImage(data: jpegImageData) else {
|
||||
owsFailDebug("Could not load image: \(jpegImageData.count)")
|
||||
return nil
|
||||
|
||||
var imageState: LinkPreview.ImageState {
|
||||
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: -
|
||||
|
||||
@objc
|
||||
public class LinkPreviewSent: NSObject, LinkPreviewState {
|
||||
private let linkPreview: OWSLinkPreview
|
||||
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 {
|
||||
var image: UIImage? {
|
||||
// Note: We don't check if the image is valid here because that can be confirmed
|
||||
// in 'imageState' and it's a little inefficient
|
||||
guard imageAttachment?.isImage == true else { return nil }
|
||||
guard let imageData: Data = try? imageAttachment?.readDataFromFile() else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
guard let image = UIImage(data: imageData) else {
|
||||
owsFailDebug("Could not load image: \(imageAttachment?.localRelativeFilePath ?? "unknown")")
|
||||
return nil
|
||||
}
|
||||
|
||||
public func imageState() -> LinkPreviewImageState {
|
||||
guard linkPreview.imageAttachmentId != nil else {
|
||||
return .none
|
||||
return image
|
||||
}
|
||||
guard let imageAttachment = imageAttachment else {
|
||||
owsFailDebug("Missing imageAttachment.")
|
||||
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? {
|
||||
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
|
||||
return nil
|
||||
// MARK: - Type Specific
|
||||
|
||||
private let linkPreview: LinkPreview
|
||||
private let imageAttachment: Attachment?
|
||||
|
||||
public var imageSize: CGSize {
|
||||
guard let width: UInt = imageAttachment?.width, let height: UInt = imageAttachment?.height else {
|
||||
return CGSize.zero
|
||||
}
|
||||
|
||||
return CGSize(width: CGFloat(width), height: CGFloat(height))
|
||||
}
|
||||
guard attachmentStream.isImage,
|
||||
attachmentStream.isValidImage else {
|
||||
return nil
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(linkPreview: LinkPreview, imageAttachment: Attachment?) {
|
||||
self.linkPreview = linkPreview
|
||||
self.imageAttachment = imageAttachment
|
||||
}
|
||||
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,76 +1,83 @@
|
|||
import NVActivityIndicatorView
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import NVActivityIndicatorView
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class LinkPreviewView: UIView {
|
||||
private static let loaderSize: CGFloat = 24
|
||||
private static let cancelButtonSize: CGFloat = 45
|
||||
|
||||
final class LinkPreviewView : UIView {
|
||||
private let viewItem: ConversationViewItem?
|
||||
private let maxWidth: CGFloat
|
||||
private let delegate: LinkPreviewViewDelegate
|
||||
var linkPreviewState: LinkPreviewState? { didSet { update() } }
|
||||
private let onCancel: (() -> ())?
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var imageViewContainerWidthConstraint = imageView.set(.width, 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
|
||||
|
||||
private lazy var imageView: UIImageView = {
|
||||
let result = UIImageView()
|
||||
let result: UIImageView = UIImageView()
|
||||
result.contentMode = .scaleAspectFill
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var imageViewContainer: UIView = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.clipsToBounds = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
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)
|
||||
}()
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.numberOfLines = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var bodyTextViewContainer = UIView()
|
||||
private lazy var bodyTextViewContainer: UIView = UIView()
|
||||
|
||||
private lazy var hStackViewContainer = UIView()
|
||||
private lazy var hStackViewContainer: UIView = UIView()
|
||||
|
||||
private lazy var hStackView = UIStackView()
|
||||
private lazy var hStackView: UIStackView = UIStackView()
|
||||
|
||||
private lazy var cancelButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let tint: UIColor = isLightMode ? .black : .white
|
||||
result.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal)
|
||||
// FIXME: This will have issues with theme transitions
|
||||
let result: UIButton = UIButton(type: .custom)
|
||||
result.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: UIControl.State.normal)
|
||||
result.tintColor = (isLightMode ? .black : .white)
|
||||
|
||||
let cancelButtonSize = LinkPreviewView.cancelButtonSize
|
||||
result.set(.width, to: cancelButtonSize)
|
||||
result.set(.height, to: cancelButtonSize)
|
||||
result.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
var bodyTextView: UITextView?
|
||||
|
||||
// MARK: Settings
|
||||
private static let loaderSize: CGFloat = 24
|
||||
private static let cancelButtonSize: CGFloat = 45
|
||||
// MARK: - Initialization
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(for viewItem: ConversationViewItem?, maxWidth: CGFloat, delegate: LinkPreviewViewDelegate) {
|
||||
self.viewItem = viewItem
|
||||
init(maxWidth: CGFloat, onCancel: (() -> ())? = nil) {
|
||||
self.maxWidth = maxWidth
|
||||
self.delegate = delegate
|
||||
self.onCancel = onCancel
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
@ -88,10 +95,12 @@ final class LinkPreviewView : UIView {
|
|||
imageViewContainerHeightConstraint.isActive = true
|
||||
imageViewContainer.addSubview(imageView)
|
||||
imageView.pin(to: imageViewContainer)
|
||||
|
||||
// Title label
|
||||
let titleLabelContainer = UIView()
|
||||
titleLabelContainer.addSubview(titleLabel)
|
||||
titleLabel.pin(to: titleLabelContainer, withInset: Values.smallSpacing)
|
||||
|
||||
// Horizontal stack view
|
||||
hStackView.addArrangedSubview(imageViewContainer)
|
||||
hStackView.addArrangedSubview(titleLabelContainer)
|
||||
|
@ -99,72 +108,106 @@ final class LinkPreviewView : UIView {
|
|||
hStackView.alignment = .center
|
||||
hStackViewContainer.addSubview(hStackView)
|
||||
hStackView.pin(to: hStackViewContainer)
|
||||
|
||||
// Vertical stack view
|
||||
let vStackView = UIStackView(arrangedSubviews: [ hStackViewContainer, bodyTextViewContainer ])
|
||||
vStackView.axis = .vertical
|
||||
addSubview(vStackView)
|
||||
vStackView.pin(to: self)
|
||||
|
||||
// Loader
|
||||
addSubview(loader)
|
||||
|
||||
let loaderSize = LinkPreviewView.loaderSize
|
||||
loader.set(.width, to: loaderSize)
|
||||
loader.set(.height, to: loaderSize)
|
||||
loader.center(in: self)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
private func update() {
|
||||
// MARK: - Updating
|
||||
|
||||
public func update(
|
||||
with state: LinkPreviewState,
|
||||
isOutgoing: Bool,
|
||||
delegate: (UITextViewDelegate & BodyTextViewDelegate)? = nil,
|
||||
cellViewModel: MessageViewModel? = nil,
|
||||
bodyLabelTextColor: UIColor? = nil,
|
||||
lastSearchText: String? = nil
|
||||
) {
|
||||
cancelButton.removeFromSuperview()
|
||||
guard let linkPreviewState = linkPreviewState else { return }
|
||||
var image = linkPreviewState.image()
|
||||
if image == nil && (linkPreviewState is LinkPreviewDraft || linkPreviewState is LinkPreviewSent) {
|
||||
|
||||
var image: UIImage? = state.image
|
||||
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 view
|
||||
let imageViewContainerSize: CGFloat = (linkPreviewState is LinkPreviewSent) ? 100 : 80
|
||||
let imageViewContainerSize: CGFloat = (state is LinkPreview.SentState ? 100 : 80)
|
||||
imageViewContainerWidthConstraint.constant = imageViewContainerSize
|
||||
imageViewContainerHeightConstraint.constant = imageViewContainerSize
|
||||
imageViewContainer.layer.cornerRadius = (linkPreviewState is LinkPreviewSent) ? 0 : 8
|
||||
if linkPreviewState is LinkPreviewLoading {
|
||||
imageViewContainer.layer.cornerRadius = (state is LinkPreview.SentState ? 0 : 8)
|
||||
|
||||
if state is LinkPreview.LoadingState {
|
||||
imageViewContainer.backgroundColor = .clear
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
imageViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06)
|
||||
}
|
||||
|
||||
imageView.image = image
|
||||
imageView.contentMode = (linkPreviewState.image() == nil) ? .center : .scaleAspectFill
|
||||
imageView.contentMode = (stateHasImage ? .scaleAspectFill : .center)
|
||||
|
||||
// Loader
|
||||
loader.alpha = (image != nil) ? 0 : 1
|
||||
loader.alpha = (image != nil ? 0 : 1)
|
||||
if image != nil { loader.stopAnimating() } else { loader.startAnimating() }
|
||||
|
||||
// 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.text = linkPreviewState.title()
|
||||
titleLabel.text = state.title
|
||||
|
||||
// Horizontal stack view
|
||||
switch linkPreviewState {
|
||||
case is LinkPreviewSent: hStackViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06)
|
||||
default: hStackViewContainer.backgroundColor = nil
|
||||
switch state {
|
||||
case is LinkPreview.SentState:
|
||||
// FIXME: This will have issues with theme transitions
|
||||
hStackViewContainer.backgroundColor = (isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06))
|
||||
|
||||
default:
|
||||
hStackViewContainer.backgroundColor = nil
|
||||
}
|
||||
|
||||
// Body text view
|
||||
bodyTextViewContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
if let viewItem = viewItem {
|
||||
let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: sentLinkPreviewTextColor, delegate: delegate)
|
||||
|
||||
if let cellViewModel: MessageViewModel = cellViewModel {
|
||||
let bodyTextView = VisibleMessageCell.getBodyTextView(
|
||||
for: cellViewModel,
|
||||
with: maxWidth,
|
||||
textColor: (bodyLabelTextColor ?? sentLinkPreviewTextColor),
|
||||
searchText: lastSearchText,
|
||||
delegate: delegate
|
||||
)
|
||||
|
||||
self.bodyTextView = bodyTextView
|
||||
bodyTextViewContainer.addSubview(bodyTextView)
|
||||
bodyTextView.pin(to: bodyTextViewContainer, withInset: 12)
|
||||
}
|
||||
if linkPreviewState is LinkPreviewDraft {
|
||||
|
||||
if state is LinkPreview.DraftState {
|
||||
hStackView.addArrangedSubview(cancelButton)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func cancel() {
|
||||
delegate.handleLinkPreviewCanceled()
|
||||
onCancel?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
protocol LinkPreviewViewDelegate : UITextViewDelegate & BodyTextViewDelegate {
|
||||
var lastSearchedText: String? { get }
|
||||
|
||||
func handleLinkPreviewCanceled()
|
||||
}
|
||||
|
|
|
@ -1,17 +1,11 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
@objc(OWSMediaAlbumView)
|
||||
public class MediaAlbumView: UIStackView {
|
||||
private let items: [ConversationMediaAlbumItem]
|
||||
|
||||
@objc
|
||||
private let items: [Attachment]
|
||||
public let itemViews: [MediaView]
|
||||
|
||||
@objc
|
||||
public var moreItemsView: MediaView?
|
||||
|
||||
private static let kSpacingPts: CGFloat = 2
|
||||
|
@ -22,19 +16,22 @@ public class MediaAlbumView: UIStackView {
|
|||
notImplemented()
|
||||
}
|
||||
|
||||
@objc
|
||||
public required init(mediaCache: NSCache<NSString, AnyObject>,
|
||||
items: [ConversationMediaAlbumItem],
|
||||
isOutgoing: Bool,
|
||||
maxMessageWidth: CGFloat) {
|
||||
public required init(
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
items: [Attachment],
|
||||
isOutgoing: Bool,
|
||||
maxMessageWidth: CGFloat
|
||||
) {
|
||||
self.items = items
|
||||
self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items).map {
|
||||
let result = MediaView(mediaCache: mediaCache,
|
||||
attachment: $0.attachment,
|
||||
isOutgoing: isOutgoing,
|
||||
maxMessageWidth: maxMessageWidth)
|
||||
return result
|
||||
}
|
||||
self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items)
|
||||
.map {
|
||||
MediaView(
|
||||
mediaCache: mediaCache,
|
||||
attachment: $0,
|
||||
isOutgoing: isOutgoing,
|
||||
maxMessageWidth: maxMessageWidth
|
||||
)
|
||||
}
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
|
@ -46,110 +43,137 @@ public class MediaAlbumView: UIStackView {
|
|||
|
||||
private func createContents(maxMessageWidth: CGFloat) {
|
||||
switch itemViews.count {
|
||||
case 0:
|
||||
owsFailDebug("No item views.")
|
||||
return
|
||||
case 1:
|
||||
// X
|
||||
guard let itemView = itemViews.first else {
|
||||
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
|
||||
case 0: return owsFailDebug("No item views.")
|
||||
|
||||
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")
|
||||
case 1:
|
||||
// X
|
||||
guard let itemView = itemViews.first else {
|
||||
owsFailDebug("Missing item view.")
|
||||
return
|
||||
}
|
||||
addSubview(itemView)
|
||||
itemView.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
moreItemsView = lastView
|
||||
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
|
||||
|
||||
let tintView = UIView()
|
||||
tintView.backgroundColor = UIColor(white: 0, alpha: 0.4)
|
||||
lastView.addSubview(tintView)
|
||||
tintView.autoPinEdgesToSuperviewEdges()
|
||||
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
|
||||
|
||||
let moreCount = max(1, items.count - MediaAlbumView.kMaxItems)
|
||||
let moreCountText = OWSFormat.formatInt(Int32(moreCount))
|
||||
let moreText = String(format: NSLocalizedString("MEDIA_GALLERY_MORE_ITEMS_FORMAT",
|
||||
comment: "Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}."), 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()
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
|
@ -181,43 +205,47 @@ public class MediaAlbumView: UIStackView {
|
|||
}
|
||||
}
|
||||
|
||||
private func autoSet(viewSize: CGFloat,
|
||||
ofViews views: [MediaView]) {
|
||||
private func autoSet(
|
||||
viewSize: CGFloat,
|
||||
ofViews views: [MediaView]
|
||||
) {
|
||||
for itemView in views {
|
||||
itemView.autoSetDimensions(to: CGSize(width: viewSize, height: viewSize))
|
||||
}
|
||||
}
|
||||
|
||||
private func newRow(rowViews: [MediaView],
|
||||
axis: NSLayoutConstraint.Axis,
|
||||
viewSize: CGFloat) -> UIStackView {
|
||||
private func newRow(
|
||||
rowViews: [MediaView],
|
||||
axis: NSLayoutConstraint.Axis,
|
||||
viewSize: CGFloat
|
||||
) -> UIStackView {
|
||||
autoSet(viewSize: viewSize, ofViews: rowViews)
|
||||
return newRow(rowViews: rowViews, axis: axis)
|
||||
}
|
||||
|
||||
private func newRow(rowViews: [MediaView],
|
||||
axis: NSLayoutConstraint.Axis) -> UIStackView {
|
||||
private func newRow(
|
||||
rowViews: [MediaView],
|
||||
axis: NSLayoutConstraint.Axis
|
||||
) -> UIStackView {
|
||||
let stackView = UIStackView(arrangedSubviews: rowViews)
|
||||
stackView.axis = axis
|
||||
stackView.spacing = MediaAlbumView.kSpacingPts
|
||||
return stackView
|
||||
}
|
||||
|
||||
@objc
|
||||
public func loadMedia() {
|
||||
for itemView in itemViews {
|
||||
itemView.loadMedia()
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public func unloadMedia() {
|
||||
for itemView in itemViews {
|
||||
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
|
||||
// items which are still downloading and invalid
|
||||
// items.
|
||||
|
@ -228,43 +256,47 @@ public class MediaAlbumView: UIStackView {
|
|||
return validItems
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func layoutSize(forMaxMessageWidth maxMessageWidth: CGFloat,
|
||||
items: [ConversationMediaAlbumItem]) -> CGSize {
|
||||
public class func layoutSize(
|
||||
forMaxMessageWidth maxMessageWidth: CGFloat,
|
||||
items: [Attachment]
|
||||
) -> CGSize {
|
||||
let itemCount = itemsToDisplay(forItems: items).count
|
||||
|
||||
switch itemCount {
|
||||
case 0, 1, 4:
|
||||
// X
|
||||
//
|
||||
// or
|
||||
//
|
||||
// XX
|
||||
// XX
|
||||
// Square
|
||||
return CGSize(width: maxMessageWidth, height: maxMessageWidth)
|
||||
case 2:
|
||||
// X X
|
||||
// side-by-side.
|
||||
let imageSize = (maxMessageWidth - kSpacingPts) / 2
|
||||
return CGSize(width: maxMessageWidth, height: imageSize)
|
||||
case 3:
|
||||
// x
|
||||
// X x
|
||||
// Big on left, 2 small on right.
|
||||
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
||||
let bigImageSize = smallImageSize * 2 + kSpacingPts
|
||||
return CGSize(width: maxMessageWidth, height: bigImageSize)
|
||||
default:
|
||||
// X X
|
||||
// xxx
|
||||
// 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)
|
||||
case 0, 1, 4:
|
||||
// X
|
||||
//
|
||||
// or
|
||||
//
|
||||
// XX
|
||||
// XX
|
||||
// Square
|
||||
return CGSize(width: maxMessageWidth, height: maxMessageWidth)
|
||||
|
||||
case 2:
|
||||
// X X
|
||||
// side-by-side.
|
||||
let imageSize = (maxMessageWidth - kSpacingPts) / 2
|
||||
return CGSize(width: maxMessageWidth, height: imageSize)
|
||||
|
||||
case 3:
|
||||
// x
|
||||
// X x
|
||||
// Big on left, 2 small on right.
|
||||
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
||||
let bigImageSize = smallImageSize * 2 + kSpacingPts
|
||||
return CGSize(width: maxMessageWidth, height: bigImageSize)
|
||||
|
||||
default:
|
||||
// X X
|
||||
// xxx
|
||||
// 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? {
|
||||
var bestMediaView: MediaView?
|
||||
var bestDistance: CGFloat = 0
|
||||
|
@ -280,7 +312,6 @@ public class MediaAlbumView: UIStackView {
|
|||
return bestMediaView
|
||||
}
|
||||
|
||||
@objc
|
||||
public func isMoreItemsView(mediaView: MediaView) -> Bool {
|
||||
return moreItemsView == mediaView
|
||||
}
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class MediaPlaceholderView : UIView {
|
||||
private let viewItem: ConversationViewItem
|
||||
private let textColor: UIColor
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
// MARK: Settings
|
||||
final class MediaPlaceholderView: UIView {
|
||||
private static let iconSize: CGFloat = 24
|
||||
private static let iconImageViewSize: CGFloat = 40
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
||||
self.viewItem = viewItem
|
||||
self.textColor = textColor
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(cellViewModel: MessageViewModel, textColor: UIColor) {
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
|
||||
setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -23,32 +23,47 @@ final class MediaPlaceholderView : UIView {
|
|||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
private func setUpViewHierarchy(
|
||||
cellViewModel: MessageViewModel,
|
||||
textColor: UIColor
|
||||
) {
|
||||
let (iconName, attachmentDescription): (String, String) = {
|
||||
guard let message = viewItem.interaction as? TSIncomingMessage else { return ("actionsheet_document_black", "file") } // Should never occur
|
||||
var attachments: [TSAttachment] = []
|
||||
Storage.read { transaction in
|
||||
attachments = message.attachments(with: transaction)
|
||||
guard
|
||||
cellViewModel.variant == .standardIncoming,
|
||||
let attachment: Attachment = cellViewModel.attachments?.first
|
||||
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 MIMETypeUtil.isImage(contentType) || MIMETypeUtil.isVideo(contentType) { return ("actionsheet_camera_roll_black", "media") }
|
||||
|
||||
if attachment.isAudio { return ("attachment_audio", "audio") }
|
||||
if attachment.isImage || attachment.isVideo { return ("actionsheet_camera_roll_black", "media") }
|
||||
|
||||
return ("actionsheet_document_black", "file")
|
||||
}()
|
||||
|
||||
// Image view
|
||||
let iconSize = MediaPlaceholderView.iconSize
|
||||
let icon = UIImage(named: iconName)?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
let imageView = UIImageView(image: icon)
|
||||
let imageView = UIImageView(
|
||||
image: UIImage(named: iconName)?
|
||||
.resizedImage(
|
||||
to: CGSize(
|
||||
width: MediaPlaceholderView.iconSize,
|
||||
height: MediaPlaceholderView.iconSize
|
||||
)
|
||||
)?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
imageView.tintColor = textColor
|
||||
imageView.contentMode = .center
|
||||
let iconImageViewSize = MediaPlaceholderView.iconImageViewSize
|
||||
imageView.set(.width, to: iconImageViewSize)
|
||||
imageView.set(.height, to: iconImageViewSize)
|
||||
imageView.set(.width, to: MediaPlaceholderView.iconImageViewSize)
|
||||
imageView.set(.height, to: MediaPlaceholderView.iconImageViewSize)
|
||||
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = "Tap to download \(attachmentDescription)"
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||
stackView.axis = .horizontal
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import YYImage
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
@objc(OWSMediaView)
|
||||
public class MediaView: UIView {
|
||||
static let contentMode: UIView.ContentMode = .scaleAspectFill
|
||||
|
||||
private enum MediaError {
|
||||
case missing
|
||||
|
@ -17,8 +17,7 @@ public class MediaView: UIView {
|
|||
// MARK: -
|
||||
|
||||
private let mediaCache: NSCache<NSString, AnyObject>
|
||||
@objc
|
||||
public let attachment: TSAttachment
|
||||
public let attachment: Attachment
|
||||
private let isOutgoing: Bool
|
||||
private let maxMessageWidth: CGFloat
|
||||
private var loadBlock: (() -> Void)?
|
||||
|
@ -42,50 +41,16 @@ public class MediaView: UIView {
|
|||
case failed
|
||||
}
|
||||
|
||||
// Thread-safe access to load state.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
private let loadState: Atomic<LoadState> = Atomic(.unloaded)
|
||||
|
||||
// MARK: - Initializers
|
||||
|
||||
@objc
|
||||
public required init(mediaCache: NSCache<NSString, AnyObject>,
|
||||
attachment: TSAttachment,
|
||||
isOutgoing: Bool,
|
||||
maxMessageWidth: CGFloat) {
|
||||
public required init(
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
attachment: Attachment,
|
||||
isOutgoing: Bool,
|
||||
maxMessageWidth: CGFloat
|
||||
) {
|
||||
self.mediaCache = mediaCache
|
||||
self.attachment = attachment
|
||||
self.isOutgoing = isOutgoing
|
||||
|
@ -105,9 +70,7 @@ public class MediaView: UIView {
|
|||
}
|
||||
|
||||
deinit {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
loadState = .unloaded
|
||||
loadState.mutate { $0 = .unloaded }
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
@ -115,41 +78,45 @@ public class MediaView: UIView {
|
|||
private func createContents() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let attachmentStream = attachment as? TSAttachmentStream else {
|
||||
guard attachment.state != .pendingDownload && attachment.state != .downloading else {
|
||||
addDownloadProgressIfNecessary()
|
||||
return
|
||||
}
|
||||
guard !isFailedDownload else {
|
||||
guard attachment.state != .failedDownload else {
|
||||
configure(forError: .failed)
|
||||
return
|
||||
}
|
||||
if attachmentStream.isAnimated {
|
||||
configureForAnimatedImage(attachmentStream: attachmentStream)
|
||||
} else if attachmentStream.isImage {
|
||||
configureForStillImage(attachmentStream: attachmentStream)
|
||||
} else if attachmentStream.isVideo {
|
||||
configureForVideo(attachmentStream: attachmentStream)
|
||||
} else {
|
||||
guard attachment.isValid else {
|
||||
configure(forError: .invalid)
|
||||
return
|
||||
}
|
||||
|
||||
if attachment.isAnimated {
|
||||
configureForAnimatedImage(attachment: attachment)
|
||||
}
|
||||
else if attachment.isImage {
|
||||
configureForStillImage(attachment: attachment)
|
||||
}
|
||||
else if attachment.isVideo {
|
||||
configureForVideo(attachment: attachment)
|
||||
}
|
||||
else {
|
||||
owsFailDebug("Attachment has unexpected type.")
|
||||
configure(forError: .invalid)
|
||||
}
|
||||
}
|
||||
|
||||
private func addDownloadProgressIfNecessary() {
|
||||
guard !isFailedDownload else {
|
||||
guard attachment.state != .failedDownload else {
|
||||
configure(forError: .failed)
|
||||
return
|
||||
}
|
||||
guard let attachmentPointer = attachment as? TSAttachmentPointer else {
|
||||
owsFailDebug("Attachment has unexpected type.")
|
||||
configure(forError: .invalid)
|
||||
return
|
||||
}
|
||||
guard attachmentPointer.pointerType == .incoming else {
|
||||
guard attachment.state != .uploading && attachment.state != .uploaded else {
|
||||
// TODO: Show "restoring" indicator and possibly progress.
|
||||
configure(forError: .missing)
|
||||
return
|
||||
}
|
||||
|
||||
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
||||
let loader = MediaLoaderView()
|
||||
addSubview(loader)
|
||||
|
@ -158,28 +125,33 @@ public class MediaView: UIView {
|
|||
|
||||
private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool {
|
||||
guard isOutgoing else { return false }
|
||||
guard let attachmentStream = attachment as? TSAttachmentStream else { return false }
|
||||
guard !attachmentStream.isUploaded else { return false }
|
||||
guard attachment.state != .failedUpload else {
|
||||
configure(forError: .failed)
|
||||
return false
|
||||
}
|
||||
|
||||
// If this message was uploaded on a different device it'll now be seen as 'downloaded' (but
|
||||
// will still be outgoing - we don't want to show a loading indicator in this case)
|
||||
guard attachment.state != .uploaded && attachment.state != .downloaded else { return false }
|
||||
|
||||
let loader = MediaLoaderView()
|
||||
addSubview(loader)
|
||||
loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func configureForAnimatedImage(attachmentStream: TSAttachmentStream) {
|
||||
guard let cacheKey = attachmentStream.uniqueId else {
|
||||
owsFailDebug("Attachment stream missing unique ID.")
|
||||
return
|
||||
}
|
||||
let animatedImageView = YYAnimatedImageView()
|
||||
private func configureForAnimatedImage(attachment: Attachment) {
|
||||
let animatedImageView: YYAnimatedImageView = YYAnimatedImageView()
|
||||
// We need to specify a contentMode since the size of the image
|
||||
// might not match the aspect ratio of the view.
|
||||
animatedImageView.contentMode = .scaleAspectFill
|
||||
animatedImageView.contentMode = MediaView.contentMode
|
||||
// Use trilinear filters for better scaling quality at
|
||||
// some performance cost.
|
||||
animatedImageView.layer.minificationFilter = .trilinear
|
||||
animatedImageView.layer.magnificationFilter = .trilinear
|
||||
animatedImageView.backgroundColor = Colors.unimportant
|
||||
animatedImageView.isHidden = !attachment.isValid
|
||||
addSubview(animatedImageView)
|
||||
animatedImageView.autoPinEdgesToSuperviewEdges()
|
||||
_ = addUploadProgressIfNecessary(animatedImageView)
|
||||
|
@ -187,36 +159,37 @@ public class MediaView: UIView {
|
|||
loadBlock = { [weak self] in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
|
||||
if animatedImageView.image != nil {
|
||||
owsFailDebug("Unexpectedly already loaded.")
|
||||
return
|
||||
}
|
||||
strongSelf.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
|
||||
guard attachmentStream.isValidImage else {
|
||||
Logger.warn("Ignoring invalid attachment.")
|
||||
return nil
|
||||
}
|
||||
guard let filePath = attachmentStream.originalFilePath else {
|
||||
owsFailDebug("Attachment stream missing original file path.")
|
||||
return nil
|
||||
}
|
||||
let animatedImage = YYImage(contentsOfFile: filePath)
|
||||
return animatedImage
|
||||
},
|
||||
applyMediaBlock: { (media) in
|
||||
AssertIsOnMainThread()
|
||||
self?.tryToLoadMedia(
|
||||
loadMediaBlock: { applyMediaBlock in
|
||||
guard attachment.isValid else {
|
||||
self?.configure(forError: .invalid)
|
||||
return
|
||||
}
|
||||
guard let filePath: String = attachment.originalFilePath else {
|
||||
owsFailDebug("Attachment stream missing original file path.")
|
||||
self?.configure(forError: .invalid)
|
||||
return
|
||||
}
|
||||
|
||||
guard let image = media as? YYImage else {
|
||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||
return
|
||||
}
|
||||
animatedImageView.image = image
|
||||
},
|
||||
cacheKey: cacheKey)
|
||||
applyMediaBlock(YYImage(contentsOfFile: filePath))
|
||||
},
|
||||
applyMediaBlock: { media in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let image: YYImage = media as? YYImage else {
|
||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||
self?.configure(forError: .invalid)
|
||||
return
|
||||
}
|
||||
// FIXME: Animated images flicker when reloading the cells (even though they are in the cache)
|
||||
animatedImageView.image = image
|
||||
},
|
||||
cacheKey: attachment.id
|
||||
)
|
||||
}
|
||||
unloadBlock = {
|
||||
AssertIsOnMainThread()
|
||||
|
@ -225,23 +198,21 @@ public class MediaView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
private func configureForStillImage(attachmentStream: TSAttachmentStream) {
|
||||
guard let cacheKey = attachmentStream.uniqueId else {
|
||||
owsFailDebug("Attachment stream missing unique ID.")
|
||||
return
|
||||
}
|
||||
private func configureForStillImage(attachment: Attachment) {
|
||||
let stillImageView = UIImageView()
|
||||
// We need to specify a contentMode since the size of the image
|
||||
// might not match the aspect ratio of the view.
|
||||
stillImageView.contentMode = .scaleAspectFill
|
||||
stillImageView.contentMode = MediaView.contentMode
|
||||
// Use trilinear filters for better scaling quality at
|
||||
// some performance cost.
|
||||
stillImageView.layer.minificationFilter = .trilinear
|
||||
stillImageView.layer.magnificationFilter = .trilinear
|
||||
stillImageView.backgroundColor = Colors.unimportant
|
||||
stillImageView.isHidden = !attachment.isValid
|
||||
addSubview(stillImageView)
|
||||
stillImageView.autoPinEdgesToSuperviewEdges()
|
||||
_ = addUploadProgressIfNecessary(stillImageView)
|
||||
|
||||
loadBlock = { [weak self] in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
|
@ -249,29 +220,35 @@ public class MediaView: UIView {
|
|||
owsFailDebug("Unexpectedly already loaded.")
|
||||
return
|
||||
}
|
||||
self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
|
||||
guard attachmentStream.isValidImage else {
|
||||
Logger.warn("Ignoring invalid attachment.")
|
||||
return nil
|
||||
}
|
||||
return attachmentStream.thumbnailImageLarge(success: { (image) in
|
||||
self?.tryToLoadMedia(
|
||||
loadMediaBlock: { applyMediaBlock in
|
||||
guard attachment.isValid else {
|
||||
self?.configure(forError: .invalid)
|
||||
return
|
||||
}
|
||||
|
||||
attachment.thumbnail(
|
||||
size: .large,
|
||||
success: { image, _ in applyMediaBlock(image) },
|
||||
failure: {
|
||||
Logger.error("Could not load thumbnail")
|
||||
self?.configure(forError: .invalid)
|
||||
}
|
||||
)
|
||||
},
|
||||
applyMediaBlock: { media in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
stillImageView.image = image
|
||||
}, failure: {
|
||||
Logger.error("Could not load thumbnail")
|
||||
})
|
||||
},
|
||||
applyMediaBlock: { (media) in
|
||||
AssertIsOnMainThread()
|
||||
guard let image: UIImage = media as? UIImage else {
|
||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||
self?.configure(forError: .invalid)
|
||||
return
|
||||
}
|
||||
|
||||
guard let image = media as? UIImage else {
|
||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||
return
|
||||
}
|
||||
stillImageView.image = image
|
||||
},
|
||||
cacheKey: cacheKey)
|
||||
stillImageView.image = image
|
||||
},
|
||||
cacheKey: attachment.id
|
||||
)
|
||||
}
|
||||
unloadBlock = {
|
||||
AssertIsOnMainThread()
|
||||
|
@ -280,20 +257,17 @@ public class MediaView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
private func configureForVideo(attachmentStream: TSAttachmentStream) {
|
||||
guard let cacheKey = attachmentStream.uniqueId else {
|
||||
owsFailDebug("Attachment stream missing unique ID.")
|
||||
return
|
||||
}
|
||||
private func configureForVideo(attachment: Attachment) {
|
||||
let stillImageView = UIImageView()
|
||||
// We need to specify a contentMode since the size of the image
|
||||
// might not match the aspect ratio of the view.
|
||||
stillImageView.contentMode = .scaleAspectFill
|
||||
stillImageView.contentMode = MediaView.contentMode
|
||||
// Use trilinear filters for better scaling quality at
|
||||
// some performance cost.
|
||||
stillImageView.layer.minificationFilter = .trilinear
|
||||
stillImageView.layer.magnificationFilter = .trilinear
|
||||
stillImageView.backgroundColor = Colors.unimportant
|
||||
stillImageView.isHidden = !attachment.isValid
|
||||
|
||||
addSubview(stillImageView)
|
||||
stillImageView.autoPinEdgesToSuperviewEdges()
|
||||
|
@ -314,29 +288,35 @@ public class MediaView: UIView {
|
|||
owsFailDebug("Unexpectedly already loaded.")
|
||||
return
|
||||
}
|
||||
self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
|
||||
guard attachmentStream.isValidVideo else {
|
||||
Logger.warn("Ignoring invalid attachment.")
|
||||
return nil
|
||||
}
|
||||
return attachmentStream.thumbnailImageMedium(success: { (image) in
|
||||
self?.tryToLoadMedia(
|
||||
loadMediaBlock: { applyMediaBlock in
|
||||
guard attachment.isValid else {
|
||||
self?.configure(forError: .invalid)
|
||||
return
|
||||
}
|
||||
|
||||
attachment.thumbnail(
|
||||
size: .medium,
|
||||
success: { image, _ in applyMediaBlock(image) },
|
||||
failure: {
|
||||
Logger.error("Could not load thumbnail")
|
||||
self?.configure(forError: .invalid)
|
||||
}
|
||||
)
|
||||
},
|
||||
applyMediaBlock: { media in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
stillImageView.image = image
|
||||
}, failure: {
|
||||
Logger.error("Could not load thumbnail")
|
||||
})
|
||||
},
|
||||
applyMediaBlock: { (media) in
|
||||
AssertIsOnMainThread()
|
||||
guard let image: UIImage = media as? UIImage else {
|
||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||
self?.configure(forError: .invalid)
|
||||
return
|
||||
}
|
||||
|
||||
guard let image = media as? UIImage else {
|
||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||
return
|
||||
}
|
||||
stillImageView.image = image
|
||||
},
|
||||
cacheKey: cacheKey)
|
||||
stillImageView.image = image
|
||||
},
|
||||
cacheKey: attachment.id
|
||||
)
|
||||
}
|
||||
unloadBlock = {
|
||||
AssertIsOnMainThread()
|
||||
|
@ -345,57 +325,69 @@ 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) {
|
||||
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
||||
let icon: UIImage
|
||||
switch error {
|
||||
case .failed:
|
||||
guard let asset = UIImage(named: "media_retry") else {
|
||||
owsFailDebug("Missing image")
|
||||
return
|
||||
// When there is a failure in the 'loadMediaBlock' closure this can be called
|
||||
// on a background thread - rather than dispatching in every 'loadMediaBlock'
|
||||
// usage we just do so here
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
self.configure(forError: error)
|
||||
}
|
||||
icon = asset
|
||||
case .invalid:
|
||||
guard let asset = UIImage(named: "media_invalid") else {
|
||||
owsFailDebug("Missing image")
|
||||
return
|
||||
}
|
||||
icon = asset
|
||||
case .missing:
|
||||
return
|
||||
}
|
||||
|
||||
let icon: UIImage
|
||||
|
||||
switch error {
|
||||
case .failed:
|
||||
guard let asset = UIImage(named: "media_retry") else {
|
||||
owsFailDebug("Missing image")
|
||||
return
|
||||
}
|
||||
icon = asset
|
||||
|
||||
case .invalid:
|
||||
guard let asset = UIImage(named: "media_invalid") else {
|
||||
owsFailDebug("Missing image")
|
||||
return
|
||||
}
|
||||
icon = asset
|
||||
|
||||
case .missing: return
|
||||
}
|
||||
|
||||
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
||||
|
||||
// For failed ougoing messages add an overlay to make the icon more visible
|
||||
if isOutgoing {
|
||||
let attachmentOverlayView: UIView = UIView()
|
||||
attachmentOverlayView.backgroundColor = Colors.navigationBarBackground
|
||||
.withAlphaComponent(Values.lowOpacity)
|
||||
addSubview(attachmentOverlayView)
|
||||
attachmentOverlayView.pin(to: self)
|
||||
}
|
||||
|
||||
let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
|
||||
iconView.tintColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
iconView.tintColor = Colors.text
|
||||
.withAlphaComponent(Values.mediumOpacity)
|
||||
addSubview(iconView)
|
||||
iconView.autoCenterInSuperview()
|
||||
}
|
||||
|
||||
private func tryToLoadMedia(loadMediaBlock: @escaping () -> AnyObject?,
|
||||
applyMediaBlock: @escaping (AnyObject) -> Void,
|
||||
cacheKey: String) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
private func tryToLoadMedia(
|
||||
loadMediaBlock: @escaping (@escaping (AnyObject?) -> Void) -> Void,
|
||||
applyMediaBlock: @escaping (AnyObject) -> Void,
|
||||
cacheKey: String
|
||||
) {
|
||||
// It's critical that we update loadState once
|
||||
// our load attempt is complete.
|
||||
let loadCompletion: (AnyObject?) -> Void = { [weak self] (possibleMedia) in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
guard strongSelf.loadState == .loading else {
|
||||
let loadCompletion: (AnyObject?) -> Void = { [weak self] possibleMedia in
|
||||
guard self?.loadState.wrappedValue == .loading else {
|
||||
Logger.verbose("Skipping obsolete load.")
|
||||
return
|
||||
}
|
||||
guard let media = possibleMedia else {
|
||||
strongSelf.loadState = .failed
|
||||
guard let media: AnyObject = possibleMedia else {
|
||||
self?.loadState.mutate { $0 = .failed }
|
||||
// TODO:
|
||||
// [self showAttachmentErrorViewWithMediaView:mediaView];
|
||||
return
|
||||
|
@ -403,41 +395,44 @@ public class MediaView: UIView {
|
|||
|
||||
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)")
|
||||
return
|
||||
}
|
||||
|
||||
let mediaCache = self.mediaCache
|
||||
if let media = mediaCache.object(forKey: cacheKey as NSString) {
|
||||
if let media: AnyObject = self.mediaCache.object(forKey: cacheKey as NSString) {
|
||||
Logger.verbose("media cache hit")
|
||||
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
loadCompletion(media)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
loadCompletion(media)
|
||||
return
|
||||
}
|
||||
|
||||
Logger.verbose("media cache miss")
|
||||
|
||||
let threadSafeLoadState = self.threadSafeLoadState
|
||||
MediaView.loadQueue.async {
|
||||
guard threadSafeLoadState.get() == .loading else {
|
||||
MediaView.loadQueue.async { [weak self] in
|
||||
guard self?.loadState.wrappedValue == .loading else {
|
||||
Logger.verbose("Skipping obsolete load.")
|
||||
return
|
||||
}
|
||||
|
||||
guard let media = loadMediaBlock() else {
|
||||
Logger.error("Failed to load media.")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
loadCompletion(nil)
|
||||
loadMediaBlock { media in
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
loadCompletion(media)
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
mediaCache.setObject(media, forKey: cacheKey as NSString)
|
||||
|
||||
loadCompletion(media)
|
||||
}
|
||||
|
@ -459,32 +454,18 @@ public class MediaView: UIView {
|
|||
// "skip rate" of obsolete loads.
|
||||
private static let loadQueue = ReverseDispatchQueue(label: "org.signal.asyncMediaLoadQueue")
|
||||
|
||||
@objc
|
||||
public func loadMedia() {
|
||||
AssertIsOnMainThread()
|
||||
switch loadState.wrappedValue {
|
||||
case .unloaded:
|
||||
loadState.mutate { $0 = .loading }
|
||||
loadBlock?()
|
||||
|
||||
switch loadState {
|
||||
case .unloaded:
|
||||
loadState = .loading
|
||||
|
||||
guard let loadBlock = loadBlock else {
|
||||
return
|
||||
}
|
||||
loadBlock()
|
||||
case .loading, .loaded, .failed:
|
||||
break
|
||||
case .loading, .loaded, .failed: break
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public func unloadMedia() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
loadState = .unloaded
|
||||
|
||||
guard let unloadBlock = unloadBlock else {
|
||||
return
|
||||
}
|
||||
unloadBlock()
|
||||
loadState.mutate { $0 = .unloaded }
|
||||
unloadBlock?()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,30 +1,24 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class OpenGroupInvitationView : UIView {
|
||||
private let name: String
|
||||
private let rawURL: String
|
||||
private let textColor: UIColor
|
||||
private let isOutgoing: Bool
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
private lazy var url: String = {
|
||||
if let range = rawURL.range(of: "?public_key=") {
|
||||
return String(rawURL[..<range.lowerBound])
|
||||
} else {
|
||||
return rawURL
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
final class OpenGroupInvitationView: UIView {
|
||||
private static let iconSize: CGFloat = 24
|
||||
private static let iconImageViewSize: CGFloat = 48
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
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)
|
||||
setUpViewHierarchy()
|
||||
|
||||
setUpViewHierarchy(
|
||||
name: name,
|
||||
rawUrl: url,
|
||||
textColor: textColor,
|
||||
isOutgoing: isOutgoing
|
||||
)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -35,41 +29,56 @@ final class OpenGroupInvitationView : UIView {
|
|||
preconditionFailure("Use init(name:url:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
private func setUpViewHierarchy(name: String, rawUrl: String, textColor: UIColor, isOutgoing: Bool) {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = name
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
|
||||
// Subtitle
|
||||
let subtitleLabel = UILabel()
|
||||
subtitleLabel.lineBreakMode = .byTruncatingTail
|
||||
subtitleLabel.text = NSLocalizedString("view_open_group_invitation_description", comment: "")
|
||||
subtitleLabel.textColor = textColor
|
||||
subtitleLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
|
||||
// URL
|
||||
let urlLabel = UILabel()
|
||||
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.numberOfLines = 0
|
||||
urlLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
|
||||
// Label stack
|
||||
let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, UIView.vSpacer(2), subtitleLabel, UIView.vSpacer(4), urlLabel ])
|
||||
labelStackView.axis = .vertical
|
||||
|
||||
// Icon
|
||||
let iconSize = OpenGroupInvitationView.iconSize
|
||||
let iconName = isOutgoing ? "Globe" : "Plus"
|
||||
let icon = UIImage(named: iconName)?.withTint(.white)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
let iconName = (isOutgoing ? "Globe" : "Plus")
|
||||
let iconImageViewSize = OpenGroupInvitationView.iconImageViewSize
|
||||
let iconImageView = UIImageView(image: icon)
|
||||
let iconImageView = UIImageView(
|
||||
image: UIImage(named: iconName)?
|
||||
.resizedImage(to: CGSize(width: iconSize, height: iconSize))?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
iconImageView.tintColor = .white
|
||||
iconImageView.contentMode = .center
|
||||
iconImageView.layer.cornerRadius = iconImageViewSize / 2
|
||||
iconImageView.layer.masksToBounds = true
|
||||
iconImageView.backgroundColor = Colors.accent
|
||||
iconImageView.set(.width, to: iconImageViewSize)
|
||||
iconImageView.set(.height, to: iconImageViewSize)
|
||||
|
||||
// Main stack
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ iconImageView, labelStackView ])
|
||||
mainStackView.axis = .horizontal
|
||||
|
|
|
@ -1,100 +1,57 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class QuoteView : UIView {
|
||||
private let mode: Mode
|
||||
private let thread: TSThread
|
||||
private let direction: Direction
|
||||
private let hInset: CGFloat
|
||||
private let maxWidth: CGFloat
|
||||
private let delegate: QuoteViewDelegate?
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
private var maxBodyLabelHeight: CGFloat {
|
||||
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
|
||||
final class QuoteView: UIView {
|
||||
static let thumbnailSize: CGFloat = 48
|
||||
static let iconSize: CGFloat = 24
|
||||
static let labelStackViewSpacing: CGFloat = 2
|
||||
static let labelStackViewVMargin: CGFloat = 4
|
||||
static let cancelButtonSize: CGFloat = 33
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(for viewItem: ConversationViewItem, in thread: TSThread?, direction: Direction, hInset: CGFloat, maxWidth: CGFloat) {
|
||||
self.mode = .regular(viewItem)
|
||||
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 Mode {
|
||||
case regular
|
||||
case draft
|
||||
}
|
||||
enum Direction { case incoming, outgoing }
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
private let onCancel: (() -> ())?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(
|
||||
for mode: Mode,
|
||||
authorId: String,
|
||||
quotedText: String?,
|
||||
threadVariant: SessionThread.Variant,
|
||||
currentUserPublicKey: String?,
|
||||
currentUserBlindedPublicKey: String?,
|
||||
direction: Direction,
|
||||
attachment: Attachment?,
|
||||
hInset: CGFloat,
|
||||
maxWidth: CGFloat,
|
||||
onCancel: (() -> ())? = nil
|
||||
) {
|
||||
self.onCancel = onCancel
|
||||
|
||||
init(for model: OWSQuotedReplyModel, direction: Direction, hInset: CGFloat, maxWidth: CGFloat, delegate: QuoteViewDelegate) {
|
||||
self.mode = .draft(model)
|
||||
self.thread = TSThread.fetch(uniqueId: model.threadId)!
|
||||
self.maxWidth = maxWidth
|
||||
self.direction = direction
|
||||
self.hInset = hInset
|
||||
self.delegate = delegate
|
||||
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) {
|
||||
|
@ -105,14 +62,24 @@ final class QuoteView : UIView {
|
|||
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
|
||||
// if you don't need to. If you do then test:
|
||||
// • Quoted text 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
|
||||
// • All of the above in both dark mode and light mode
|
||||
let hasAttachments = !attachments.isEmpty
|
||||
let thumbnailSize = QuoteView.thumbnailSize
|
||||
let iconSize = QuoteView.iconSize
|
||||
let labelStackViewSpacing = QuoteView.labelStackViewSpacing
|
||||
|
@ -120,18 +87,23 @@ final class QuoteView : UIView {
|
|||
let smallSpacing = Values.smallSpacing
|
||||
let cancelButtonSize = QuoteView.cancelButtonSize
|
||||
var availableWidth: CGFloat
|
||||
|
||||
// Subtract smallSpacing twice; once for the spacing in between the stack view elements and
|
||||
// once for the trailing margin.
|
||||
if !hasAttachments {
|
||||
if attachment == nil {
|
||||
availableWidth = maxWidth - 2 * hInset - Values.accentLineThickness - 2 * smallSpacing
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
availableWidth = maxWidth - 2 * hInset - thumbnailSize - 2 * smallSpacing
|
||||
}
|
||||
|
||||
if case .draft = mode {
|
||||
availableWidth -= cancelButtonSize
|
||||
}
|
||||
|
||||
let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude)
|
||||
var body = self.body
|
||||
var body: String? = quotedText
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [])
|
||||
mainStackView.axis = .horizontal
|
||||
|
@ -139,49 +111,129 @@ final class QuoteView : UIView {
|
|||
mainStackView.isLayoutMarginsRelativeArrangement = true
|
||||
mainStackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: smallSpacing)
|
||||
mainStackView.alignment = .center
|
||||
|
||||
// Content view
|
||||
let contentView = UIView()
|
||||
addSubview(contentView)
|
||||
contentView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self)
|
||||
contentView.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor).isActive = true
|
||||
|
||||
// Line view
|
||||
let lineColor: 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()
|
||||
lineView.backgroundColor = lineColor
|
||||
lineView.set(.width, to: Values.accentLineThickness)
|
||||
if !hasAttachments {
|
||||
mainStackView.addArrangedSubview(lineView)
|
||||
} else {
|
||||
let isAudio = MIMETypeUtil.isAudio(attachments.first!.contentType ?? "")
|
||||
let fallbackImageName = isAudio ? "attachment_audio" : "actionsheet_document_black"
|
||||
let fallbackImage = UIImage(named: fallbackImageName)?.withTint(.white)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
let imageView = UIImageView(image: thumbnail ?? fallbackImage)
|
||||
imageView.contentMode = (thumbnail != nil) ? .scaleAspectFill : .center
|
||||
|
||||
if let attachment: Attachment = attachment {
|
||||
let isAudio: Bool = MIMETypeUtil.isAudio(attachment.contentType)
|
||||
let fallbackImageName: String = (isAudio ? "attachment_audio" : "actionsheet_document_black")
|
||||
let imageView: UIImageView = UIImageView(
|
||||
image: UIImage(named: fallbackImageName)?
|
||||
.resizedImage(to: CGSize(width: iconSize, height: iconSize))?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
|
||||
imageView.tintColor = .white
|
||||
imageView.contentMode = .center
|
||||
imageView.backgroundColor = lineColor
|
||||
imageView.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
|
||||
imageView.layer.masksToBounds = true
|
||||
imageView.set(.width, to: thumbnailSize)
|
||||
imageView.set(.height, to: thumbnailSize)
|
||||
mainStackView.addArrangedSubview(imageView)
|
||||
|
||||
if (body ?? "").isEmpty {
|
||||
body = (thumbnail != nil) ? "Image" : (isAudio ? "Audio" : "Document")
|
||||
body = (attachment.isImage ?
|
||||
"Image" :
|
||||
(isAudio ? "Audio" : "Document")
|
||||
)
|
||||
}
|
||||
|
||||
// Generate the thumbnail if needed
|
||||
if attachment.isVisualMedia {
|
||||
attachment.thumbnail(
|
||||
size: .small,
|
||||
success: { image, _ in
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
imageView.image = image
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
imageView.image = image
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
},
|
||||
failure: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
else {
|
||||
mainStackView.addArrangedSubview(lineView)
|
||||
}
|
||||
|
||||
// Body label
|
||||
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()
|
||||
bodyLabel.numberOfLines = 0
|
||||
bodyLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
let isOutgoing = (direction == .outgoing)
|
||||
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
|
||||
|
||||
let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace)
|
||||
|
||||
// Label stack view
|
||||
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()
|
||||
authorLabel.lineBreakMode = .byTruncatingTail
|
||||
let context: Contact.Context = groupThread.isOpenGroup ? .openGroup : .regular
|
||||
authorLabel.text = Storage.shared.getContact(with: authorID)?.displayName(for: context) ?? authorID
|
||||
authorLabel.text = (isCurrentUser ?
|
||||
"MEDIA_GALLERY_SENDER_NAME_YOU".localized() :
|
||||
Profile.displayName(
|
||||
id: authorId,
|
||||
threadVariant: threadVariant
|
||||
)
|
||||
)
|
||||
authorLabel.textColor = textColor
|
||||
authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace)
|
||||
|
@ -195,51 +247,56 @@ final class QuoteView : UIView {
|
|||
labelStackView.isLayoutMarginsRelativeArrangement = true
|
||||
labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0)
|
||||
mainStackView.addArrangedSubview(labelStackView)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
mainStackView.addArrangedSubview(bodyLabel)
|
||||
}
|
||||
|
||||
// Cancel button
|
||||
let cancelButton = UIButton(type: .custom)
|
||||
let tint: UIColor = isLightMode ? .black : .white
|
||||
cancelButton.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal)
|
||||
cancelButton.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: UIControl.State.normal)
|
||||
cancelButton.tintColor = (isLightMode ? .black : .white)
|
||||
cancelButton.set(.width, to: cancelButtonSize)
|
||||
cancelButton.set(.height, to: cancelButtonSize)
|
||||
cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
|
||||
|
||||
// Constraints
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(to: contentView)
|
||||
if !thread.isGroupThread() {
|
||||
|
||||
if threadVariant != .openGroup && threadVariant != .closedGroup {
|
||||
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
|
||||
if hasAttachments {
|
||||
|
||||
if attachment != nil {
|
||||
contentViewHeight = thumbnailSize + 8 // Add a small amount of spacing above and below the thumbnail
|
||||
bodyLabel.set(.height, to: 18) // Experimentally determined
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
if let authorLabelHeight = authorLabelHeight { // Group thread
|
||||
contentViewHeight = bodyLabelHeight + (authorLabelHeight + labelStackViewSpacing) + 2 * labelStackViewVMargin
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
contentViewHeight = bodyLabelHeight + 2 * smallSpacing
|
||||
}
|
||||
}
|
||||
|
||||
contentView.set(.height, to: contentViewHeight)
|
||||
lineView.set(.height, to: contentViewHeight - 8) // Add a small amount of spacing above and below the line
|
||||
if case .draft = mode {
|
||||
|
||||
if mode == .draft {
|
||||
addSubview(cancelButton)
|
||||
cancelButton.center(.vertical, in: self)
|
||||
cancelButton.pin(.right, to: .right, of: self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func cancel() {
|
||||
delegate?.handleQuoteViewCancelButtonTapped()
|
||||
onCancel?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
protocol QuoteViewDelegate {
|
||||
|
||||
func handleQuoteViewCancelButtonTapped()
|
||||
}
|
||||
|
|
|
@ -1,78 +1,84 @@
|
|||
import NVActivityIndicatorView
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
@objc(SNVoiceMessageView)
|
||||
public final class VoiceMessageView : UIView {
|
||||
private let viewItem: ConversationViewItem
|
||||
private var isShowingSpeedUpLabel = false
|
||||
@objc var progress: Int = 0 { didSet { handleProgressChanged() } }
|
||||
@objc var isPlaying = false { didSet { handleIsPlayingChanged() } }
|
||||
import UIKit
|
||||
import NVActivityIndicatorView
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
public final class VoiceMessageView: UIView {
|
||||
private static let width: CGFloat = 160
|
||||
private static let toggleContainerSize: CGFloat = 20
|
||||
private static let inset = Values.smallSpacing
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
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 = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = UIColor.black.withAlphaComponent(0.2)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
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(.height, to: 8)
|
||||
result.contentMode = .scaleAspectFit
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
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(.height, to: VoiceMessageView.toggleContainerSize + 2)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var countdownLabelContainer: UIView = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = .white
|
||||
result.layer.masksToBounds = true
|
||||
result.set(.height, to: VoiceMessageView.toggleContainerSize)
|
||||
result.set(.width, to: 44)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var countdownLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = .black
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.text = "0:00"
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var speedUpLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = .black
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.alpha = 0
|
||||
result.text = "1.5x"
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
private static let width: CGFloat = 160
|
||||
private static let toggleContainerSize: CGFloat = 20
|
||||
private static let inset = Values.smallSpacing
|
||||
// MARK: - Lifecycle
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem) {
|
||||
self.viewItem = viewItem
|
||||
self.progress = Int(viewItem.audioProgressSeconds)
|
||||
init() {
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
handleProgressChanged()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -86,27 +92,33 @@ public final class VoiceMessageView : UIView {
|
|||
private func setUpViewHierarchy() {
|
||||
let toggleContainerSize = VoiceMessageView.toggleContainerSize
|
||||
let inset = VoiceMessageView.inset
|
||||
|
||||
// Width & height
|
||||
set(.width, to: VoiceMessageView.width)
|
||||
|
||||
// Toggle
|
||||
let toggleContainer = UIView()
|
||||
let toggleContainer: UIView = UIView()
|
||||
toggleContainer.backgroundColor = .white
|
||||
toggleContainer.set(.width, to: toggleContainerSize)
|
||||
toggleContainer.set(.height, to: toggleContainerSize)
|
||||
toggleContainer.addSubview(toggleImageView)
|
||||
toggleImageView.center(in: toggleContainer)
|
||||
toggleContainer.layer.cornerRadius = toggleContainerSize / 2
|
||||
toggleContainer.layer.cornerRadius = (toggleContainerSize / 2)
|
||||
toggleContainer.layer.masksToBounds = true
|
||||
|
||||
// Line
|
||||
let lineView = UIView()
|
||||
lineView.backgroundColor = .white
|
||||
lineView.set(.height, to: 1)
|
||||
|
||||
// Countdown label
|
||||
countdownLabelContainer.addSubview(countdownLabel)
|
||||
countdownLabel.center(in: countdownLabelContainer)
|
||||
|
||||
// Speed up label
|
||||
countdownLabelContainer.addSubview(speedUpLabel)
|
||||
speedUpLabel.center(in: countdownLabelContainer)
|
||||
|
||||
// Constraints
|
||||
addSubview(progressView)
|
||||
progressView.pin(.left, to: .left, of: self)
|
||||
|
@ -114,60 +126,73 @@ public final class VoiceMessageView : UIView {
|
|||
progressViewRightConstraint.isActive = true
|
||||
progressView.pin(.bottom, to: .bottom, of: self)
|
||||
addSubview(toggleContainer)
|
||||
|
||||
toggleContainer.pin(.left, to: .left, of: self, withInset: inset)
|
||||
toggleContainer.pin(.top, to: .top, of: self, withInset: inset)
|
||||
toggleContainer.pin(.bottom, to: .bottom, of: self, withInset: -inset)
|
||||
addSubview(lineView)
|
||||
|
||||
lineView.pin(.left, to: .right, of: toggleContainer)
|
||||
lineView.center(.vertical, in: self)
|
||||
addSubview(countdownLabelContainer)
|
||||
|
||||
countdownLabelContainer.pin(.left, to: .right, of: lineView)
|
||||
countdownLabelContainer.pin(.right, to: .right, of: self, withInset: -inset)
|
||||
countdownLabelContainer.center(.vertical, in: self)
|
||||
|
||||
addSubview(loader)
|
||||
loader.center(in: toggleContainer)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
countdownLabelContainer.layer.cornerRadius = countdownLabelContainer.bounds.height / 2
|
||||
|
||||
countdownLabelContainer.layer.cornerRadius = (countdownLabelContainer.bounds.height / 2)
|
||||
}
|
||||
|
||||
private func handleIsPlayingChanged() {
|
||||
toggleImageView.image = isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play")
|
||||
if !isPlaying { progress = 0 }
|
||||
}
|
||||
// MARK: - Updating
|
||||
|
||||
private func handleProgressChanged() {
|
||||
let isDownloaded = (attachment?.isDownloaded == true)
|
||||
loader.isHidden = isDownloaded
|
||||
if isDownloaded { loader.stopAnimating() } else if !loader.isAnimating { loader.startAnimating() }
|
||||
guard isDownloaded else { return }
|
||||
countdownLabel.text = OWSFormat.formatDurationSeconds(duration - progress)
|
||||
guard viewItem.audioProgressSeconds > 0 && viewItem.audioDurationSeconds > 0 else {
|
||||
return progressViewRightConstraint.constant = -VoiceMessageView.width
|
||||
}
|
||||
let fraction = viewItem.audioProgressSeconds / viewItem.audioDurationSeconds
|
||||
progressViewRightConstraint.constant = -(VoiceMessageView.width * (1 - fraction))
|
||||
}
|
||||
public func update(
|
||||
with attachment: Attachment,
|
||||
isPlaying: Bool,
|
||||
progress: TimeInterval,
|
||||
playbackRate: Double,
|
||||
oldPlaybackRate: Double
|
||||
) {
|
||||
switch attachment.state {
|
||||
case .downloaded, .uploaded:
|
||||
loader.isHidden = true
|
||||
loader.stopAnimating()
|
||||
|
||||
func showSpeedUpLabel() {
|
||||
guard !isShowingSpeedUpLabel else { return }
|
||||
isShowingSpeedUpLabel = true
|
||||
UIView.animate(withDuration: 0.25) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.countdownLabel.alpha = 0
|
||||
self.speedUpLabel.alpha = 1
|
||||
}
|
||||
Timer.scheduledTimer(withTimeInterval: 1.25, repeats: false) { [weak self] _ in
|
||||
UIView.animate(withDuration: 0.25, animations: {
|
||||
guard let self = self else { return }
|
||||
self.countdownLabel.alpha = 1
|
||||
self.speedUpLabel.alpha = 0
|
||||
}, completion: { _ in
|
||||
self?.isShowingSpeedUpLabel = false
|
||||
})
|
||||
toggleImageView.image = (isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play"))
|
||||
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 {
|
||||
return progressViewRightConstraint.constant = -VoiceMessageView.width
|
||||
}
|
||||
|
||||
let fraction: Double = (progress / duration)
|
||||
progressViewRightConstraint.constant = -(VoiceMessageView.width * (1 - fraction))
|
||||
|
||||
// If the playback rate changed then show the 'speedUpLabel' briefly
|
||||
guard playbackRate > oldPlaybackRate else { return }
|
||||
|
||||
UIView.animate(withDuration: 0.25) { [weak self] in
|
||||
self?.countdownLabel.alpha = 0
|
||||
self?.speedUpLabel.alpha = 1
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1250)) {
|
||||
UIView.animate(withDuration: 0.25) { [weak self] in
|
||||
self?.countdownLabel.alpha = 1
|
||||
self?.speedUpLabel.alpha = 0
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
if !loader.isAnimating {
|
||||
loader.startAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,72 +1,87 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class InfoMessageCell: MessageCell {
|
||||
private static let iconSize: CGFloat = 16
|
||||
private static let inset = Values.mediumSpacing
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
final class InfoMessageCell : MessageCell {
|
||||
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: InfoMessageCell.iconSize)
|
||||
private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: InfoMessageCell.iconSize)
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var iconImageView = UIImageView()
|
||||
private lazy var iconImageView: UIImageView = UIImageView()
|
||||
|
||||
private lazy var label: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.numberOfLines = 0
|
||||
result.lineBreakMode = .byWordWrapping
|
||||
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var stackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ iconImageView, label ])
|
||||
let result: UIStackView = UIStackView(arrangedSubviews: [ iconImageView, label ])
|
||||
result.axis = .vertical
|
||||
result.alignment = .center
|
||||
result.spacing = Values.smallSpacing
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
private static let iconSize: CGFloat = 16
|
||||
private static let inset = Values.mediumSpacing
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override class var identifier: String { "InfoMessageCell" }
|
||||
|
||||
// MARK: Lifecycle
|
||||
override func setUpViewHierarchy() {
|
||||
super.setUpViewHierarchy()
|
||||
|
||||
iconImageViewWidthConstraint.isActive = true
|
||||
iconImageViewHeightConstraint.isActive = true
|
||||
addSubview(stackView)
|
||||
|
||||
stackView.pin(.left, to: .left, 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(.bottom, to: .bottom, of: self, withInset: -InfoMessageCell.inset)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
override func update() {
|
||||
guard let message = viewItem?.interaction as? TSInfoMessage else { return }
|
||||
let icon: UIImage?
|
||||
switch message.messageType {
|
||||
case .disappearingMessagesUpdate:
|
||||
var configuration: OWSDisappearingMessagesConfiguration?
|
||||
Storage.read { transaction in
|
||||
configuration = message.thread(with: transaction).disappearingMessagesConfiguration(with: transaction)
|
||||
// MARK: - Updating
|
||||
|
||||
override func update(with cellViewModel: MessageViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
|
||||
guard cellViewModel.variant.isInfoMessage else { return }
|
||||
|
||||
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 {
|
||||
iconImageView.image = icon.withTint(Colors.text)
|
||||
iconImageView.image = icon.withRenderingMode(.alwaysTemplate)
|
||||
iconImageView.tintColor = Colors.text
|
||||
}
|
||||
|
||||
iconImageViewWidthConstraint.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 SessionMessagingKit
|
||||
|
||||
|
@ -7,37 +9,29 @@ public enum SwipeState {
|
|||
case cancelled
|
||||
}
|
||||
|
||||
class MessageCell : UITableViewCell {
|
||||
public class MessageCell: UITableViewCell {
|
||||
weak var delegate: MessageCellDelegate?
|
||||
var thread: TSThread? {
|
||||
didSet {
|
||||
if viewItem != nil { update() }
|
||||
}
|
||||
}
|
||||
var viewItem: ConversationViewItem? {
|
||||
didSet {
|
||||
if thread != nil { update() }
|
||||
}
|
||||
}
|
||||
var viewModel: MessageViewModel?
|
||||
|
||||
// MARK: Settings
|
||||
class var identifier: String { preconditionFailure("Must be overridden by subclasses.") }
|
||||
// MARK: - Lifecycle
|
||||
|
||||
// MARK: Lifecycle
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
setUpViewHierarchy()
|
||||
setUpGestureRecognizers()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
setUpViewHierarchy()
|
||||
setUpGestureRecognizers()
|
||||
}
|
||||
|
||||
func setUpViewHierarchy() {
|
||||
backgroundColor = .clear
|
||||
|
||||
let selectedBackgroundView = UIView()
|
||||
selectedBackgroundView.backgroundColor = .clear
|
||||
self.selectedBackgroundView = selectedBackgroundView
|
||||
|
@ -47,37 +41,47 @@ class MessageCell : UITableViewCell {
|
|||
// To be overridden by subclasses
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
func update() {
|
||||
// MARK: - Updating
|
||||
|
||||
func update(with cellViewModel: MessageViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
|
||||
preconditionFailure("Must be overridden by subclasses.")
|
||||
}
|
||||
|
||||
// MARK: Convenience
|
||||
static func getCellType(for viewItem: ConversationViewItem) -> MessageCell.Type {
|
||||
switch viewItem.interaction {
|
||||
case is TSIncomingMessage: fallthrough
|
||||
case is TSOutgoingMessage: return VisibleMessageCell.self
|
||||
case is TSInfoMessage:
|
||||
if let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call {
|
||||
/// 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
|
||||
/// like playing inline audio/video)
|
||||
func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||
preconditionFailure("Must be overridden by subclasses.")
|
||||
}
|
||||
|
||||
// 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 InfoMessageCell.self
|
||||
case is TypingIndicatorInteraction: return TypingIndicatorCell.self
|
||||
default: preconditionFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol MessageCellDelegate : AnyObject {
|
||||
var lastSearchedText: String? { get }
|
||||
// MARK: - MessageCellDelegate
|
||||
|
||||
func getMediaCache() -> NSCache<NSString, AnyObject>
|
||||
func handleViewItemLongPressed(_ viewItem: ConversationViewItem)
|
||||
func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer)
|
||||
func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem)
|
||||
func handleViewItemSwiped(_ viewItem: ConversationViewItem, state: SwipeState)
|
||||
func showFullText(_ viewItem: ConversationViewItem)
|
||||
func openURL(_ url: URL)
|
||||
func handleReplyButtonTapped(for viewItem: ConversationViewItem)
|
||||
func showUserDetails(for sessionID: String)
|
||||
protocol MessageCellDelegate: AnyObject {
|
||||
func handleItemLongPressed(_ cellViewModel: MessageViewModel)
|
||||
func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer)
|
||||
func handleItemDoubleTapped(_ cellViewModel: MessageViewModel)
|
||||
func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState)
|
||||
func openUrl(_ urlString: String)
|
||||
func handleReplyButtonTapped(for cellViewModel: MessageViewModel)
|
||||
func showUserDetails(for profile: Profile)
|
||||
func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?)
|
||||
}
|
||||
|
|
|
@ -1,85 +1,94 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
// Assumptions
|
||||
// • We'll never encounter an outgoing typing indicator.
|
||||
// • Typing indicators are only sent in contact threads.
|
||||
final class TypingIndicatorCell: MessageCell {
|
||||
// MARK: - UI
|
||||
|
||||
final class TypingIndicatorCell : MessageCell {
|
||||
|
||||
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 = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
|
||||
result.backgroundColor = Colors.receivedMessageBackground
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let bubbleViewMaskLayer = CAShapeLayer()
|
||||
private let bubbleViewMaskLayer: CAShapeLayer = CAShapeLayer()
|
||||
|
||||
private lazy var typingIndicatorView = TypingIndicatorView()
|
||||
private lazy var typingIndicatorView: TypingIndicatorView = TypingIndicatorView()
|
||||
|
||||
// MARK: Settings
|
||||
override class var identifier: String { "TypingIndicatorCell" }
|
||||
// MARK: - Lifecycle
|
||||
|
||||
// MARK: Direction & Position
|
||||
enum Position { case top, middle, bottom }
|
||||
|
||||
// MARK: Lifecycle
|
||||
override func setUpViewHierarchy() {
|
||||
super.setUpViewHierarchy()
|
||||
|
||||
// Bubble view
|
||||
addSubview(bubbleView)
|
||||
bubbleView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing)
|
||||
bubbleView.pin(.top, to: .top, of: self, withInset: 1)
|
||||
|
||||
// Typing indicator view
|
||||
bubbleView.addSubview(typingIndicatorView)
|
||||
typingIndicatorView.pin(to: bubbleView, withInset: 12)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
override func update() {
|
||||
guard let viewItem = viewItem, viewItem.interaction is TypingIndicatorInteraction else { return }
|
||||
// MARK: - Updating
|
||||
|
||||
override func update(with cellViewModel: MessageViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
|
||||
guard cellViewModel.cellType == .typingIndicator else { return }
|
||||
|
||||
self.viewModel = cellViewModel
|
||||
|
||||
// Bubble view
|
||||
updateBubbleViewCorners()
|
||||
|
||||
// Typing indicator view
|
||||
typingIndicatorView.startAnimation()
|
||||
}
|
||||
|
||||
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
updateBubbleViewCorners()
|
||||
}
|
||||
|
||||
private func updateBubbleViewCorners() {
|
||||
let maskPath = UIBezierPath(roundedRect: bubbleView.bounds, byRoundingCorners: getCornersToRound(),
|
||||
cornerRadii: CGSize(width: VisibleMessageCell.largeCornerRadius, height: VisibleMessageCell.largeCornerRadius))
|
||||
let maskPath = UIBezierPath(
|
||||
roundedRect: bubbleView.bounds,
|
||||
byRoundingCorners: getCornersToRound(),
|
||||
cornerRadii: CGSize(
|
||||
width: VisibleMessageCell.largeCornerRadius,
|
||||
height: VisibleMessageCell.largeCornerRadius)
|
||||
)
|
||||
|
||||
bubbleViewMaskLayer.path = maskPath.cgPath
|
||||
bubbleView.layer.mask = bubbleViewMaskLayer
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
typingIndicatorView.stopAnimation()
|
||||
}
|
||||
|
||||
// MARK: Convenience
|
||||
// MARK: - Convenience
|
||||
|
||||
private func getCornersToRound() -> UIRectCorner {
|
||||
guard !isOnlyMessageInCluster else { return .allCorners }
|
||||
let result: UIRectCorner
|
||||
switch positionInCluster {
|
||||
case .top: result = [ .topLeft, .topRight, .bottomRight ]
|
||||
case .middle: result = [ .topRight, .bottomRight ]
|
||||
case .bottom: result = [ .topRight, .bottomRight, .bottomLeft ]
|
||||
case nil: result = .allCorners
|
||||
guard viewModel?.isOnlyMessageInCluster == false else { return .allCorners }
|
||||
|
||||
switch viewModel?.positionInCluster {
|
||||
case .top: return [ .topLeft, .topRight, .bottomRight ]
|
||||
case .middle: return [ .topRight, .bottomRight ]
|
||||
case .bottom: return [ .topRight, .bottomRight, .bottomLeft ]
|
||||
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;
|
||||
|
||||
- (void)configureWithThread:(TSThread *)thread uiDatabaseConnection:(YapDatabaseConnection *)uiDatabaseConnection;
|
||||
- (void)configureWithThreadId:(NSString *)threadId threadName:(NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf;
|
||||
|
||||
@end
|
||||
|
||||
|
|
|
@ -9,17 +9,8 @@
|
|||
#import "UIView+OWS.h"
|
||||
#import <Curve25519Kit/Curve25519.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/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 PromiseKit;
|
||||
|
@ -30,12 +21,18 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
@interface OWSConversationSettingsViewController () <OWSSheetViewControllerDelegate>
|
||||
|
||||
@property (nonatomic) TSThread *thread;
|
||||
@property (nonatomic) YapDatabaseConnection *uiDatabaseConnection;
|
||||
@property (nonatomic, readonly) YapDatabaseConnection *editingDatabaseConnection;
|
||||
@property (nonatomic) NSString *threadId;
|
||||
@property (nonatomic) NSString *threadName;
|
||||
@property (nonatomic) BOOL isNoteToSelf;
|
||||
@property (nonatomic) BOOL isClosedGroup;
|
||||
@property (nonatomic) BOOL isOpenGroup;
|
||||
@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) UILabel *disappearingMessagesDurationLabel;
|
||||
@property (nonatomic) UILabel *displayNameLabel;
|
||||
|
@ -56,8 +53,6 @@ CGFloat kIconViewLength = 24;
|
|||
return self;
|
||||
}
|
||||
|
||||
[self commonInit];
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
|
@ -68,8 +63,6 @@ CGFloat kIconViewLength = 24;
|
|||
return self;
|
||||
}
|
||||
|
||||
[self commonInit];
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
|
@ -80,95 +73,24 @@ CGFloat kIconViewLength = 24;
|
|||
return self;
|
||||
}
|
||||
|
||||
[self commonInit];
|
||||
|
||||
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
|
||||
|
||||
- (void)observeNotifications
|
||||
{
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(identityStateDidChange:)
|
||||
name:kNSNotificationName_IdentityStateDidChange
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(otherUsersProfileDidChange:)
|
||||
name:kNSNotificationName_OtherUsersProfileDidChange
|
||||
object:nil];
|
||||
}
|
||||
- (void)configureWithThreadId:(NSString *)threadId threadName:(NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf {
|
||||
self.threadId = threadId;
|
||||
self.threadName = threadName;
|
||||
self.isClosedGroup = isClosedGroup;
|
||||
self.isOpenGroup = isOpenGroup;
|
||||
self.isNoteToSelf = isNoteToSelf;
|
||||
|
||||
- (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];
|
||||
if (!isClosedGroup && !isOpenGroup) {
|
||||
self.threadName = [SMKProfile displayNameWithId:threadId customFallback:@"Anonymous"];
|
||||
}
|
||||
return threadName;
|
||||
}
|
||||
|
||||
- (BOOL)isGroupThread
|
||||
{
|
||||
return [self.thread isKindOfClass:[TSGroupThread class]];
|
||||
}
|
||||
|
||||
- (BOOL)isOpenGroup
|
||||
{
|
||||
if ([self isGroupThread]) {
|
||||
TSGroupThread *thread = (TSGroupThread *)self.thread;
|
||||
return thread.isOpenGroup;
|
||||
else {
|
||||
self.threadName = threadName;
|
||||
}
|
||||
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
|
||||
|
@ -227,7 +149,7 @@ CGFloat kIconViewLength = 24;
|
|||
[self.displayNameContainer addSubview:self.displayNameTextField];
|
||||
[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)];
|
||||
[self.displayNameContainer addGestureRecognizer:tapGestureRecognizer];
|
||||
}
|
||||
|
@ -238,20 +160,16 @@ CGFloat kIconViewLength = 24;
|
|||
_disappearingMessagesDurationLabel = [UILabel new];
|
||||
SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _disappearingMessagesDurationLabel);
|
||||
|
||||
self.disappearingMessagesDurations = [OWSDisappearingMessagesConfiguration validDurationsSeconds];
|
||||
|
||||
self.disappearingMessagesConfiguration =
|
||||
[OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:self.thread.uniqueId];
|
||||
|
||||
if (!self.disappearingMessagesConfiguration) {
|
||||
self.disappearingMessagesConfiguration =
|
||||
[[OWSDisappearingMessagesConfiguration alloc] initDefaultWithThreadId:self.thread.uniqueId];
|
||||
}
|
||||
self.disappearingMessagesDurations = [SMKDisappearingMessagesConfiguration validDurationsSeconds];
|
||||
self.isDisappearingMessagesEnabled = [SMKDisappearingMessagesConfiguration isEnabledFor: self.threadId];
|
||||
self.disappearingMessagesDurationIndex = [SMKDisappearingMessagesConfiguration durationIndexFor: self.threadId];
|
||||
self.originalIsDisappearingMessagesEnabled = self.isDisappearingMessagesEnabled;
|
||||
self.originalDisappearingMessagesDurationIndex = self.disappearingMessagesDurationIndex;
|
||||
|
||||
[self updateTableContents];
|
||||
|
||||
NSString *title;
|
||||
if ([self.thread isKindOfClass:[TSContactThread class]]) {
|
||||
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||
title = NSLocalizedString(@"Settings", @"");
|
||||
} else {
|
||||
title = NSLocalizedString(@"Group Settings", @"");
|
||||
|
@ -259,7 +177,7 @@ CGFloat kIconViewLength = 24;
|
|||
[LKViewControllerUtilities setUpDefaultSessionStyleForVC:self withTitle:title customBackButton:YES];
|
||||
self.tableView.backgroundColor = UIColor.clearColor;
|
||||
|
||||
if ([self.thread isKindOfClass:TSContactThread.class]) {
|
||||
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||
[self updateNavBarButtons];
|
||||
}
|
||||
}
|
||||
|
@ -269,8 +187,6 @@ CGFloat kIconViewLength = 24;
|
|||
OWSTableContents *contents = [OWSTableContents new];
|
||||
contents.title = NSLocalizedString(@"CONVERSATION_SETTINGS", @"title for conversation settings screen");
|
||||
|
||||
BOOL isNoteToSelf = self.thread.isNoteToSelf;
|
||||
|
||||
__weak OWSConversationSettingsViewController *weakSelf = self;
|
||||
|
||||
OWSTableSection *section = [OWSTableSection new];
|
||||
|
@ -279,7 +195,7 @@ CGFloat kIconViewLength = 24;
|
|||
section.customHeaderHeight = @(UITableViewAutomaticDimension);
|
||||
|
||||
// Copy Session ID
|
||||
if ([self.thread isKindOfClass:TSContactThread.class]) {
|
||||
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
return [weakSelf
|
||||
disclosureCellWithName:NSLocalizedString(@"vc_conversation_settings_copy_session_id_button_title", "")
|
||||
|
@ -327,7 +243,7 @@ CGFloat kIconViewLength = 24;
|
|||
}]];
|
||||
|
||||
// Disappearing messages
|
||||
if (![self isOpenGroup] && !self.thread.isBlocked) {
|
||||
if (![self isOpenGroup] && ![SMKContact isBlockedFor:self.threadId]) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
UITableViewCell *cell = [OWSTableItem newCell];
|
||||
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
||||
|
@ -337,7 +253,7 @@ CGFloat kIconViewLength = 24;
|
|||
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
|
||||
NSString *iconName
|
||||
= (strongSelf.disappearingMessagesConfiguration.isEnabled ? @"ic_timer" : @"ic_timer_disabled");
|
||||
= (strongSelf.isDisappearingMessagesEnabled ? @"ic_timer" : @"ic_timer_disabled");
|
||||
UIImageView *iconView = [strongSelf viewForIconWithName:iconName];
|
||||
|
||||
UILabel *rowLabel = [UILabel new];
|
||||
|
@ -348,7 +264,7 @@ CGFloat kIconViewLength = 24;
|
|||
rowLabel.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||
|
||||
UISwitch *switchView = [UISwitch new];
|
||||
switchView.on = strongSelf.disappearingMessagesConfiguration.isEnabled;
|
||||
switchView.on = strongSelf.isDisappearingMessagesEnabled;
|
||||
[switchView addTarget:strongSelf action:@selector(disappearingMessagesSwitchValueDidChange:)
|
||||
forControlEvents:UIControlEventValueChanged];
|
||||
|
||||
|
@ -361,11 +277,10 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
UILabel *subtitleLabel = [UILabel new];
|
||||
NSString *displayName;
|
||||
if (self.thread.isGroupThread) {
|
||||
if (self.isClosedGroup || self.isOpenGroup) {
|
||||
displayName = @"the group";
|
||||
} else {
|
||||
TSContactThread *thread = (TSContactThread *)self.thread;
|
||||
displayName = [[LKStorage.shared getContactWithSessionID:thread.contactSessionID] displayNameFor:SNContactContextRegular] ?: @"anonymous";
|
||||
displayName = [SMKProfile displayNameWithId:self.threadId customFallback:@"anonymous"];
|
||||
}
|
||||
subtitleLabel.text = [NSString stringWithFormat:NSLocalizedString(@"When enabled, messages between you and %@ will disappear after they have been seen.", ""), displayName];
|
||||
subtitleLabel.textColor = LKColors.text;
|
||||
|
@ -385,7 +300,7 @@ CGFloat kIconViewLength = 24;
|
|||
return cell;
|
||||
} customRowHeight:UITableViewAutomaticDimension actionBlock:nil]];
|
||||
|
||||
if (self.disappearingMessagesConfiguration.isEnabled) {
|
||||
if (self.isDisappearingMessagesEnabled) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
UITableViewCell *cell = [OWSTableItem newCell];
|
||||
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
||||
|
@ -415,7 +330,7 @@ CGFloat kIconViewLength = 24;
|
|||
slider.minimumValue = 0;
|
||||
slider.tintColor = LKColors.accent;
|
||||
slider.continuous = NO;
|
||||
slider.value = strongSelf.disappearingMessagesConfiguration.durationIndex;
|
||||
slider.value = strongSelf.disappearingMessagesDurationIndex;
|
||||
[slider addTarget:strongSelf action:@selector(durationSliderDidChange:)
|
||||
forControlEvents:UIControlEventValueChanged];
|
||||
[cell.contentView addSubview:slider];
|
||||
|
@ -438,11 +353,10 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
// Closed group settings
|
||||
__block BOOL isUserMember = NO;
|
||||
if (self.isGroupThread) {
|
||||
NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey];
|
||||
isUserMember = [(TSGroupThread *)self.thread isUserMemberInGroup:userPublicKey];
|
||||
if (self.isClosedGroup || self.isOpenGroup) {
|
||||
isUserMember = [SMKGroupMember isCurrentUserMemberOf:self.threadId];
|
||||
}
|
||||
if (self.isGroupThread && self.isClosedGroup && isUserMember) {
|
||||
if (self.isClosedGroup && isUserMember) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
UITableViewCell *cell =
|
||||
[weakSelf disclosureCellWithName:NSLocalizedString(@"EDIT_GROUP_ACTION", @"table cell label in conversation settings")
|
||||
|
@ -466,7 +380,7 @@ CGFloat kIconViewLength = 24;
|
|||
}]];
|
||||
}
|
||||
|
||||
if (!isNoteToSelf) {
|
||||
if (!self.isNoteToSelf) {
|
||||
// Notification sound
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
UITableViewCell *cell =
|
||||
|
@ -493,8 +407,8 @@ CGFloat kIconViewLength = 24;
|
|||
[cell.contentView addSubview:contentRow];
|
||||
[contentRow autoPinEdgesToSuperviewMargins];
|
||||
|
||||
OWSSound sound = [OWSSounds notificationSoundForThread:strongSelf.thread];
|
||||
cell.detailTextLabel.text = [OWSSounds displayNameForSound:sound];
|
||||
NSInteger sound = [SMKSound notificationSoundFor:strongSelf.threadId];
|
||||
cell.detailTextLabel.text = [SMKSound displayNameFor:sound];
|
||||
|
||||
cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(
|
||||
OWSConversationSettingsViewController, @"notifications");
|
||||
|
@ -504,11 +418,11 @@ CGFloat kIconViewLength = 24;
|
|||
customRowHeight:UITableViewAutomaticDimension
|
||||
actionBlock:^{
|
||||
OWSSoundSettingsViewController *vc = [OWSSoundSettingsViewController new];
|
||||
vc.thread = weakSelf.thread;
|
||||
vc.threadId = weakSelf.threadId;
|
||||
[weakSelf.navigationController pushViewController:vc animated:YES];
|
||||
}]];
|
||||
|
||||
if (self.isGroupThread) {
|
||||
if (self.isClosedGroup || self.isOpenGroup) {
|
||||
// Notification Settings
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
UITableViewCell *cell = [OWSTableItem newCell];
|
||||
|
@ -527,7 +441,7 @@ CGFloat kIconViewLength = 24;
|
|||
rowLabel.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||
|
||||
UISwitch *switchView = [UISwitch new];
|
||||
switchView.on = ((TSGroupThread *)strongSelf.thread).isOnlyNotifyingForMentions;
|
||||
switchView.on = [SMKThread isOnlyNotifyingForMentions:strongSelf.threadId];
|
||||
[switchView addTarget:strongSelf action:@selector(notifyForMentionsOnlySwitchValueDidChange:)
|
||||
forControlEvents:UIControlEventValueChanged];
|
||||
|
||||
|
@ -570,7 +484,7 @@ CGFloat kIconViewLength = 24;
|
|||
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
|
||||
UISwitch *muteConversationSwitch = [UISwitch new];
|
||||
NSDate *mutedUntilDate = strongSelf.thread.mutedUntilDate;
|
||||
NSDate *mutedUntilDate = [SMKThread mutedUntilDateFor:strongSelf.threadId];
|
||||
NSDate *now = [NSDate date];
|
||||
muteConversationSwitch.on = (mutedUntilDate != nil && [mutedUntilDate timeIntervalSinceDate:now] > 0);
|
||||
[muteConversationSwitch addTarget:strongSelf action:@selector(handleMuteSwitchToggled:)
|
||||
|
@ -582,7 +496,7 @@ CGFloat kIconViewLength = 24;
|
|||
}
|
||||
|
||||
// Block contact
|
||||
if (!isNoteToSelf && [self.thread isKindOfClass:TSContactThread.class]) {
|
||||
if (!self.isNoteToSelf && !self.isClosedGroup && !self.isOpenGroup) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
||||
if (!strongSelf) { return [UITableViewCell new]; }
|
||||
|
@ -594,7 +508,7 @@ CGFloat kIconViewLength = 24;
|
|||
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
|
||||
UISwitch *blockConversationSwitch = [UISwitch new];
|
||||
blockConversationSwitch.on = strongSelf.thread.isBlocked;
|
||||
blockConversationSwitch.on = [SMKContact isBlockedFor:strongSelf.threadId];
|
||||
[blockConversationSwitch addTarget:strongSelf action:@selector(blockConversationSwitchDidChange:)
|
||||
forControlEvents:UIControlEventValueChanged];
|
||||
cell.accessoryView = blockConversationSwitch;
|
||||
|
@ -683,7 +597,7 @@ CGFloat kIconViewLength = 24;
|
|||
[profilePictureView addGestureRecognizer:profilePictureTapGestureRecognizer];
|
||||
|
||||
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)];
|
||||
[self.displayNameContainer addGestureRecognizer:tapGestureRecognizer];
|
||||
}
|
||||
|
@ -698,18 +612,18 @@ CGFloat kIconViewLength = 24;
|
|||
stackView.layoutMargins = UIEdgeInsetsMake(LKValues.mediumSpacing, horizontalSpacing, LKValues.mediumSpacing, horizontalSpacing);
|
||||
[stackView setLayoutMarginsRelativeArrangement:YES];
|
||||
|
||||
if (!self.isGroupThread) {
|
||||
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||
SRCopyableLabel *subtitleView = [SRCopyableLabel new];
|
||||
subtitleView.textColor = LKColors.text;
|
||||
subtitleView.font = [LKFonts spaceMonoOfSize:LKValues.smallFontSize];
|
||||
subtitleView.lineBreakMode = NSLineBreakByCharWrapping;
|
||||
subtitleView.numberOfLines = 2;
|
||||
subtitleView.text = ((TSContactThread *)self.thread).contactSessionID;
|
||||
subtitleView.text = self.threadId;
|
||||
subtitleView.textAlignment = NSTextAlignmentCenter;
|
||||
[stackView addArrangedSubview:subtitleView];
|
||||
}
|
||||
|
||||
[profilePictureView updateForThread:self.thread];
|
||||
[profilePictureView updateForThreadId:self.threadId];
|
||||
|
||||
return stackView;
|
||||
}
|
||||
|
@ -749,43 +663,36 @@ CGFloat kIconViewLength = 24;
|
|||
{
|
||||
[super viewWillDisappear:animated];
|
||||
|
||||
if (self.disappearingMessagesConfiguration.isNewRecord && !self.disappearingMessagesConfiguration.isEnabled) {
|
||||
// don't save defaults, else we'll unintentionally save the configuration and notify the contact.
|
||||
// Do nothing if the values haven't changed (or if it's disabled and only the 'durationIndex'
|
||||
// has changed as the 'durationIndex' value defaults to 1 hour when disabled)
|
||||
if (
|
||||
self.isDisappearingMessagesEnabled == self.originalIsDisappearingMessagesEnabled && (
|
||||
!self.originalIsDisappearingMessagesEnabled ||
|
||||
self.disappearingMessagesDurationIndex == self.originalDisappearingMessagesDurationIndex
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.disappearingMessagesConfiguration.dictionaryValueDidChange) {
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) {
|
||||
[self.disappearingMessagesConfiguration saveWithTransaction:transaction];
|
||||
OWSDisappearingConfigurationUpdateInfoMessage *infoMessage = [[OWSDisappearingConfigurationUpdateInfoMessage alloc]
|
||||
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];
|
||||
}];
|
||||
}
|
||||
[SMKDisappearingMessagesConfiguration
|
||||
update:self.threadId
|
||||
isEnabled: self.isDisappearingMessagesEnabled
|
||||
durationIndex: self.disappearingMessagesDurationIndex
|
||||
];
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (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];
|
||||
}
|
||||
|
||||
- (void)didTapLeaveGroup
|
||||
{
|
||||
NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey];
|
||||
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.";
|
||||
} else {
|
||||
message = NSLocalizedString(@"CONFIRM_LEAVE_GROUP_DESCRIPTION", @"Alert body");
|
||||
|
@ -811,9 +718,8 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
- (BOOL)hasLeftGroup
|
||||
{
|
||||
if (self.isGroupThread) {
|
||||
TSGroupThread *groupThread = (TSGroupThread *)self.thread;
|
||||
return !groupThread.isCurrentUserMemberInGroup;
|
||||
if (self.isClosedGroup) {
|
||||
return ![SMKGroupMember isCurrentUserMemberOf:self.threadId];
|
||||
}
|
||||
|
||||
return NO;
|
||||
|
@ -821,13 +727,8 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
- (void)leaveGroup
|
||||
{
|
||||
TSGroupThread *gThread = (TSGroupThread *)self.thread;
|
||||
|
||||
if (gThread.isClosedGroup) {
|
||||
NSString *groupPublicKey = [LKGroupUtilities getDecodedGroupID:gThread.groupModel.groupId];
|
||||
[LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) {
|
||||
[[SNMessageSender leaveClosedGroupWithPublicKey:groupPublicKey using:transaction] retainUntilComplete];
|
||||
}];
|
||||
if (self.isClosedGroup) {
|
||||
[[SMKMessageSender leaveClosedGroupWithPublicKey:self.threadId] retainUntilComplete];
|
||||
}
|
||||
|
||||
[self.navigationController popViewControllerAnimated:YES];
|
||||
|
@ -846,13 +747,9 @@ CGFloat kIconViewLength = 24;
|
|||
{
|
||||
UISwitch *uiSwitch = (UISwitch *)sender;
|
||||
if (uiSwitch.isOn) {
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
[self.thread updateWithMutedUntilDate:[NSDate distantFuture] transaction:transaction];
|
||||
}];
|
||||
[SMKThread updateWithMutedUntilDateTo:[NSDate distantFuture] forThreadId:self.threadId];
|
||||
} else {
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
[self.thread updateWithMutedUntilDate:nil transaction:transaction];
|
||||
}];
|
||||
[SMKThread updateWithMutedUntilDateTo:nil forThreadId:self.threadId];
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -861,13 +758,12 @@ CGFloat kIconViewLength = 24;
|
|||
if (![sender isKindOfClass:[UISwitch class]]) {
|
||||
OWSFailDebug(@"Unexpected sender for block user switch: %@", sender);
|
||||
}
|
||||
if (![self.thread isKindOfClass:[TSContactThread class]]) {
|
||||
OWSFailDebug(@"unexpected thread type: %@", self.thread.class);
|
||||
if (self.isClosedGroup || self.isOpenGroup) {
|
||||
OWSFailDebug(@"unexpected group thread");
|
||||
}
|
||||
UISwitch *blockConversationSwitch = (UISwitch *)sender;
|
||||
TSContactThread *contactThread = (TSContactThread *)self.thread;
|
||||
|
||||
BOOL isCurrentlyBlocked = contactThread.isBlocked;
|
||||
BOOL isCurrentlyBlocked = [SMKContact isBlockedFor:self.threadId];
|
||||
|
||||
__weak OWSConversationSettingsViewController *weakSelf = self;
|
||||
if (blockConversationSwitch.isOn) {
|
||||
|
@ -875,7 +771,7 @@ CGFloat kIconViewLength = 24;
|
|||
if (isCurrentlyBlocked) {
|
||||
return;
|
||||
}
|
||||
[BlockListUIUtils showBlockThreadActionSheet:contactThread
|
||||
[BlockListUIUtils showBlockThreadActionSheet:self.threadId
|
||||
from:self
|
||||
completionBlock:^(BOOL isBlocked) {
|
||||
// Update switch state if user cancels action.
|
||||
|
@ -883,7 +779,7 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
// If we successfully blocked then force a config sync
|
||||
if (isBlocked) {
|
||||
[SNMessageSender forceSyncConfigurationNow];
|
||||
[SMKMessageSender forceSyncConfigurationNow];
|
||||
}
|
||||
|
||||
[weakSelf updateTableContents];
|
||||
|
@ -894,7 +790,7 @@ CGFloat kIconViewLength = 24;
|
|||
if (!isCurrentlyBlocked) {
|
||||
return;
|
||||
}
|
||||
[BlockListUIUtils showUnblockThreadActionSheet:contactThread
|
||||
[BlockListUIUtils showUnblockThreadActionSheet:self.threadId
|
||||
from:self
|
||||
completionBlock:^(BOOL isBlocked) {
|
||||
// Update switch state if user cancels action.
|
||||
|
@ -902,7 +798,7 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
// If we successfully unblocked then force a config sync
|
||||
if (!isBlocked) {
|
||||
[SNMessageSender forceSyncConfigurationNow];
|
||||
[SMKMessageSender forceSyncConfigurationNow];
|
||||
}
|
||||
|
||||
[weakSelf updateTableContents];
|
||||
|
@ -912,7 +808,7 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
- (void)toggleDisappearingMessages:(BOOL)flag
|
||||
{
|
||||
self.disappearingMessagesConfiguration.enabled = flag;
|
||||
self.isDisappearingMessagesEnabled = flag;
|
||||
|
||||
[self updateTableContents];
|
||||
}
|
||||
|
@ -920,21 +816,23 @@ CGFloat kIconViewLength = 24;
|
|||
- (void)durationSliderDidChange:(UISlider *)slider
|
||||
{
|
||||
// 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];
|
||||
NSNumber *numberOfSeconds = self.disappearingMessagesDurations[index];
|
||||
self.disappearingMessagesConfiguration.durationSeconds = [numberOfSeconds unsignedIntValue];
|
||||
self.disappearingMessagesDurationIndex = index;
|
||||
|
||||
[self updateDisappearingMessagesDurationLabel];
|
||||
}
|
||||
|
||||
- (void)updateDisappearingMessagesDurationLabel
|
||||
{
|
||||
if (self.disappearingMessagesConfiguration.isEnabled) {
|
||||
if (self.isDisappearingMessagesEnabled) {
|
||||
NSString *keepForFormat = @"Disappear after %@";
|
||||
self.disappearingMessagesDurationLabel.text =
|
||||
[NSString stringWithFormat:keepForFormat, self.disappearingMessagesConfiguration.durationString];
|
||||
} else {
|
||||
self.disappearingMessagesDurationLabel.text = [NSString
|
||||
stringWithFormat:keepForFormat,
|
||||
[SMKDisappearingMessagesConfiguration durationStringFor: self.disappearingMessagesDurationIndex]
|
||||
];
|
||||
}
|
||||
else {
|
||||
self.disappearingMessagesDurationLabel.text
|
||||
= NSLocalizedString(@"KEEP_MESSAGES_FOREVER", @"Slider label when disappearing messages is off");
|
||||
}
|
||||
|
@ -945,30 +843,16 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
- (void)copySessionID
|
||||
{
|
||||
UIPasteboard.generalPasteboard.string = ((TSContactThread *)self.thread).contactSessionID;
|
||||
UIPasteboard.generalPasteboard.string = self.threadId;
|
||||
}
|
||||
|
||||
- (void)inviteUsersToOpenGroup
|
||||
{
|
||||
NSString *threadID = self.thread.uniqueId;
|
||||
SNOpenGroupV2 *openGroup = [LKStorage.shared getV2OpenGroupForThreadID:threadID];
|
||||
NSString *url = [NSString stringWithFormat:@"%@/%@?public_key=%@", openGroup.server, openGroup.room, openGroup.publicKey];
|
||||
NSString *threadId = self.threadId;
|
||||
SNUserSelectionVC *userSelectionVC = [[SNUserSelectionVC alloc] initWithTitle:NSLocalizedString(@"vc_conversation_settings_invite_button_title", @"")
|
||||
excluding:[NSSet new]
|
||||
completion:^(NSSet<NSString *> *selectedUsers) {
|
||||
for (NSString *user in selectedUsers) {
|
||||
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];
|
||||
}];
|
||||
}
|
||||
[SMKOpenGroup inviteUsers:selectedUsers toOpenGroupFor:threadId];
|
||||
}];
|
||||
[self.navigationController pushViewController:userSelectionVC animated:YES];
|
||||
}
|
||||
|
@ -977,13 +861,8 @@ CGFloat kIconViewLength = 24;
|
|||
{
|
||||
OWSLogDebug(@"");
|
||||
|
||||
MediaGallery *mediaGallery = [[MediaGallery alloc] initWithThread:self.thread
|
||||
options:MediaGalleryOptionSliderEnabled];
|
||||
|
||||
self.mediaGallery = mediaGallery;
|
||||
|
||||
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
|
||||
|
@ -995,9 +874,8 @@ CGFloat kIconViewLength = 24;
|
|||
{
|
||||
UISwitch *uiSwitch = (UISwitch *)sender;
|
||||
BOOL isEnabled = uiSwitch.isOn;
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
[(TSGroupThread *)self.thread setIsOnlyNotifyingForMentions:isEnabled withTransaction:transaction];
|
||||
}];
|
||||
|
||||
[SMKThread setIsOnlyNotifyingForMentions:self.threadId to:isEnabled];
|
||||
}
|
||||
|
||||
- (void)hideEditNameUI
|
||||
|
@ -1029,18 +907,10 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
- (void)saveName
|
||||
{
|
||||
if (![self.thread isKindOfClass:TSContactThread.class]) { return; }
|
||||
NSString *sessionID = ((TSContactThread *)self.thread).contactSessionID;
|
||||
SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID];
|
||||
if (contact == nil) {
|
||||
contact = [[SNContact alloc] initWithSessionID:sessionID];
|
||||
}
|
||||
if (self.isClosedGroup || self.isOpenGroup) { return; }
|
||||
|
||||
NSString *text = [self.displayNameTextField.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
||||
contact.nickname = text.length > 0 ? text : nil;
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
[LKStorage.shared setContact:contact usingTransaction:transaction];
|
||||
}];
|
||||
self.displayNameLabel.text = text.length > 0 ? text : contact.name;
|
||||
self.displayNameLabel.text = [SMKProfile displayNameAfterSavingNickname:text forProfileId:self.threadId];
|
||||
[self hideEditNameUI];
|
||||
}
|
||||
|
||||
|
@ -1069,23 +939,16 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
#pragma mark - Notifications
|
||||
|
||||
- (void)identityStateDidChange:(NSNotification *)notification
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
[self updateTableContents];
|
||||
}
|
||||
|
||||
// FIXME: When this screen gets refactored, make sure to observe changes for relevant profile image updates
|
||||
- (void)otherUsersProfileDidChange:(NSNotification *)notification
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
NSString *recipientId = notification.userInfo[kNSNotificationKey_ProfileRecipientId];
|
||||
NSString *recipientId = @"";//notification.userInfo[NSNotification.profileRecipientIdKey];
|
||||
OWSAssertDebug(recipientId.length > 0);
|
||||
|
||||
if (recipientId.length > 0 && [self.thread isKindOfClass:[TSContactThread class]] &&
|
||||
[((TSContactThread *)self.thread).contactSessionID isEqualToString:recipientId]) {
|
||||
[self updateTableContents];
|
||||
if (recipientId.length > 0 && !self.isClosedGroup && !self.isOpenGroup && self.threadId == recipientId) {
|
||||
DispatchMainThreadSafe(^{
|
||||
[self updateTableContents];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,11 +9,8 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
|
||||
@protocol OWSConversationSettingsViewDelegate <NSObject>
|
||||
|
||||
- (void)groupWasUpdated:(TSGroupModel *)groupModel;
|
||||
- (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController;
|
||||
|
||||
- (void)popAllConversationSettingsViewsWithCompletion:(void (^_Nullable)(void))completionBlock;
|
||||
|
||||
@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.
|
||||
@objc(SNProfilePictureVC)
|
||||
final class ProfilePictureVC : BaseVC {
|
||||
final class ProfilePictureVC: BaseVC {
|
||||
private let image: UIImage
|
||||
private let snTitle: String
|
||||
|
||||
@objc init(image: UIImage, title: String) {
|
||||
self.image = image
|
||||
self.snTitle = title
|
||||
|
||||
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
|
||||
|
||||
final class BlockedModal: Modal {
|
||||
|
@ -19,7 +25,7 @@ final class BlockedModal: Modal {
|
|||
|
||||
override func populateContentView() {
|
||||
// Name
|
||||
let name = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey
|
||||
let name = Profile.displayName(id: publicKey)
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
|
@ -67,23 +73,20 @@ final class BlockedModal: Modal {
|
|||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func unblock() {
|
||||
let publicKey: String = self.publicKey
|
||||
|
||||
Storage.shared.write(
|
||||
with: { transaction in
|
||||
guard let transaction = transaction as? YapDatabaseReadWriteTransaction, let contact: Contact = Storage.shared.getContact(with: publicKey, using: transaction) else {
|
||||
return
|
||||
}
|
||||
Storage.shared.writeAsync { db in
|
||||
try Contact
|
||||
.filter(id: publicKey)
|
||||
.updateAll(db, Contact.Columns.isBlocked.set(to: false))
|
||||
|
||||
contact.isBlocked = false
|
||||
Storage.shared.setContact(contact, using: transaction as Any)
|
||||
},
|
||||
completion: {
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
)
|
||||
try MessageSender
|
||||
.syncConfiguration(db, forceSyncNow: true)
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
|
|
@ -1,20 +1,37 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
// 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.
|
||||
// • Links should show up properly and be tappable
|
||||
// • Text should * not * be selectable (this is handled via the 'textViewDidChangeSelection(_:)'
|
||||
// delegate method)
|
||||
// • The long press interaction that shows the context menu should still work
|
||||
final class BodyTextView: UITextView {
|
||||
private let snDelegate: BodyTextViewDelegate?
|
||||
private let highlightedMentionBackgroundView: HighlightMentionBackgroundView = HighlightMentionBackgroundView()
|
||||
|
||||
final class BodyTextView : UITextView {
|
||||
private let snDelegate: BodyTextViewDelegate
|
||||
override var attributedText: NSAttributedString! {
|
||||
didSet {
|
||||
guard attributedText != nil else { return }
|
||||
|
||||
override var selectedTextRange: UITextRange? {
|
||||
get { return nil }
|
||||
set { }
|
||||
highlightedMentionBackgroundView.maxPadding = highlightedMentionBackgroundView
|
||||
.calculateMaxPadding(for: attributedText)
|
||||
highlightedMentionBackgroundView.frame = self.bounds.insetBy(
|
||||
dx: -highlightedMentionBackgroundView.maxPadding,
|
||||
dy: -highlightedMentionBackgroundView.maxPadding
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init(snDelegate: BodyTextViewDelegate) {
|
||||
init(snDelegate: BodyTextViewDelegate?) {
|
||||
self.snDelegate = snDelegate
|
||||
|
||||
super.init(frame: CGRect.zero, textContainer: nil)
|
||||
|
||||
self.clipsToBounds = false // Needed for the 'HighlightMentionBackgroundView'
|
||||
addSubview(highlightedMentionBackgroundView)
|
||||
|
||||
setUpGestureRecognizers()
|
||||
}
|
||||
|
||||
|
@ -35,12 +52,21 @@ final class BodyTextView : UITextView {
|
|||
}
|
||||
|
||||
@objc private func handleLongPress() {
|
||||
snDelegate.handleLongPress()
|
||||
snDelegate?.handleLongPress()
|
||||
}
|
||||
|
||||
@objc private func handleDoubleTap() {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
highlightedMentionBackgroundView.frame = self.bounds.insetBy(
|
||||
dx: -highlightedMentionBackgroundView.maxPadding,
|
||||
dy: -highlightedMentionBackgroundView.maxPadding
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
protocol BodyTextViewDelegate {
|
||||
|
|
|
@ -1,13 +1,21 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
@objc
|
||||
final class CallModal : Modal {
|
||||
final class CallModal: Modal {
|
||||
private let onCallEnabled: () -> Void
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
@objc
|
||||
init(onCallEnabled: @escaping () -> Void) {
|
||||
self.onCallEnabled = onCallEnabled
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
@ -27,15 +35,16 @@ final class CallModal : Modal {
|
|||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_call_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = NSLocalizedString("modal_call_explanation", comment: "")
|
||||
messageLabel.text = message
|
||||
messageLabel.text = "modal_call_explanation".localized()
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
|
||||
// Enable button
|
||||
let enableButton = UIButton()
|
||||
enableButton.set(.height, to: Values.mediumButtonHeight)
|
||||
|
@ -45,25 +54,29 @@ final class CallModal : Modal {
|
|||
enableButton.setTitleColor(Colors.text, 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)
|
||||
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
contentView.addSubview(mainStackView)
|
||||
|
||||
mainStackView.pin(.leading, to: .leading, 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(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func enable() {
|
||||
SSKPreferences.areCallsEnabled = true
|
||||
Storage.shared.writeAsync { db in db[.areCallsEnabled] = true }
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
onCallEnabled()
|
||||
}
|
||||
|
|
|
@ -1,26 +1,35 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class ConversationTitleView : UIView {
|
||||
private let thread: TSThread
|
||||
weak var delegate: ConversationTitleViewDelegate?
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class ConversationTitleView: UIView {
|
||||
private static let leftInset: CGFloat = 8
|
||||
private static let leftInsetWithCallButton: CGFloat = 54
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
return UIView.layoutFittingExpandedSize
|
||||
}
|
||||
|
||||
// MARK: UI Components
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var subtitleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .systemFont(ofSize: 13)
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -29,114 +38,119 @@ final class ConversationTitleView : UIView {
|
|||
result.axis = .vertical
|
||||
result.alignment = .center
|
||||
result.isLayoutMarginsRelativeArrangement = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(thread: TSThread) {
|
||||
self.thread = thread
|
||||
super.init(frame: CGRect.zero)
|
||||
initialize()
|
||||
}
|
||||
// MARK: - Initialization
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(thread:) instead.")
|
||||
}
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
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))
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
let notificationCenter = NotificationCenter.default
|
||||
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()
|
||||
stackView.pin(to: self)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
@objc private func update() {
|
||||
titleLabel.text = getTitle()
|
||||
let subtitle = getSubtitle()
|
||||
subtitleLabel.attributedText = subtitle
|
||||
let titleFontSize = (subtitle != nil) ? Values.mediumFontSize : Values.veryLargeFontSize
|
||||
titleLabel.font = .boldSystemFont(ofSize: titleFontSize)
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init() instead.")
|
||||
}
|
||||
|
||||
// MARK: General
|
||||
private func getTitle() -> String {
|
||||
if let thread = thread as? TSGroupThread {
|
||||
return thread.groupModel.groupName!
|
||||
}
|
||||
else if thread.isNoteToSelf() {
|
||||
return "Note to Self"
|
||||
}
|
||||
else {
|
||||
let sessionID = (thread as! TSContactThread).contactSessionID()
|
||||
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))"
|
||||
result = (displayName == sessionID ? middleTruncatedHexKey : displayName)
|
||||
// MARK: - Content
|
||||
|
||||
public func initialSetup(with threadVariant: SessionThread.Variant) {
|
||||
self.update(
|
||||
with: " ",
|
||||
isNoteToSelf: false,
|
||||
threadVariant: threadVariant,
|
||||
mutedUntilTimestamp: nil,
|
||||
onlyNotifyForMentions: false,
|
||||
userCount: (threadVariant != .contact ? 0 : nil)
|
||||
)
|
||||
}
|
||||
|
||||
public func update(
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private func getSubtitle() -> NSAttributedString? {
|
||||
let result = NSMutableAttributedString()
|
||||
if thread.isMuted {
|
||||
result.append(NSAttributedString(string: "\u{e067} ", attributes: [ .font : UIFont.ows_elegantIconsFont(10), .foregroundColor : Colors.text ]))
|
||||
result.append(NSAttributedString(string: "Muted"))
|
||||
return result
|
||||
} else if let thread = self.thread as? TSGroupThread {
|
||||
if thread.isOnlyNotifyingForMentions {
|
||||
// Generate the subtitle
|
||||
let subtitle: NSAttributedString? = {
|
||||
guard Date().timeIntervalSince1970 > (mutedUntilTimestamp ?? 0) else {
|
||||
return NSAttributedString(
|
||||
string: "\u{e067} ",
|
||||
attributes: [
|
||||
.font: UIFont.ows_elegantIconsFont(10),
|
||||
.foregroundColor: Colors.text
|
||||
]
|
||||
)
|
||||
.appending(string: "Muted")
|
||||
}
|
||||
guard !onlyNotifyForMentions else {
|
||||
// FIXME: This is going to have issues when swapping between light/dark mode
|
||||
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.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize)
|
||||
let imageAsString = NSAttributedString(attachment: imageAttachment)
|
||||
result.append(imageAsString)
|
||||
result.append(NSAttributedString(string: " " + NSLocalizedString("view_conversation_title_notify_for_mentions_only", comment: "")))
|
||||
return result
|
||||
} else {
|
||||
var userCount: UInt64?
|
||||
switch thread.groupModel.groupType {
|
||||
case .closedGroup: userCount = UInt64(thread.groupModel.groupMemberIds.count)
|
||||
case .openGroup:
|
||||
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")
|
||||
}
|
||||
imageAttachment.bounds = CGRect(
|
||||
x: 0,
|
||||
y: -2,
|
||||
width: Values.smallFontSize,
|
||||
height: Values.smallFontSize
|
||||
)
|
||||
|
||||
return NSAttributedString(attachment: imageAttachment)
|
||||
.appending(string: " ")
|
||||
.appending(string: "view_conversation_title_notify_for_mentions_only".localized())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
guard let userCount: Int = userCount else { return nil }
|
||||
|
||||
// MARK: Interaction
|
||||
@objc private func handleTap() {
|
||||
delegate?.handleTitleViewTapped()
|
||||
return NSAttributedString(string: "\(userCount) member\(userCount == 1 ? "" : "s")")
|
||||
}()
|
||||
|
||||
self.titleLabel.text = name
|
||||
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,10 +1,19 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class DownloadAttachmentModal : Modal {
|
||||
private let viewItem: ConversationViewItem
|
||||
import UIKit
|
||||
import GRDB
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class DownloadAttachmentModal: Modal {
|
||||
private let profile: Profile?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(profile: Profile?) {
|
||||
self.profile = profile
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem) {
|
||||
self.viewItem = viewItem
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
@ -17,26 +26,33 @@ final class DownloadAttachmentModal : Modal {
|
|||
}
|
||||
|
||||
override func populateContentView() {
|
||||
guard let publicKey = (viewItem.interaction as? TSIncomingMessage)?.authorId else { return }
|
||||
guard let profile: Profile = profile else { return }
|
||||
|
||||
// Name
|
||||
let name = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey
|
||||
let name: String = profile.displayName()
|
||||
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = String(format: NSLocalizedString("modal_download_attachment_title", comment: ""), name)
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = String(format: NSLocalizedString("modal_download_attachment_explanation", comment: ""), name)
|
||||
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.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
|
||||
// Download button
|
||||
let downloadButton = UIButton()
|
||||
downloadButton.set(.height, to: Values.mediumButtonHeight)
|
||||
|
@ -45,15 +61,18 @@ final class DownloadAttachmentModal : Modal {
|
|||
downloadButton.setTitleColor(Colors.text, 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)
|
||||
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, downloadButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
|
@ -66,18 +85,36 @@ final class DownloadAttachmentModal : Modal {
|
|||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func trust() {
|
||||
guard let message = viewItem.interaction as? TSIncomingMessage else { return }
|
||||
let publicKey = message.authorId
|
||||
let contact = Storage.shared.getContact(with: publicKey) ?? Contact(sessionID: publicKey)
|
||||
contact.isTrusted = true
|
||||
Storage.write(with: { transaction in
|
||||
Storage.shared.setContact(contact, using: transaction)
|
||||
MessageInvalidator.invalidate(message, with: transaction)
|
||||
}, completion: {
|
||||
Storage.shared.resumeAttachmentDownloadJobsIfNeeded(for: message.uniqueThreadId)
|
||||
})
|
||||
guard let profileId: String = profile?.id else { return }
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
try Contact
|
||||
.filter(id: profileId)
|
||||
.updateAll(db, Contact.Columns.isTrusted.set(to: true))
|
||||
|
||||
// Start downloading any pending attachments for this contact (UI will automatically be
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 url: String
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(name: String, url: String) {
|
||||
self.name = name
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(name: String?, url: String) {
|
||||
self.name = (name ?? "Open Group")
|
||||
self.url = url
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
@ -25,6 +33,7 @@ final class JoinOpenGroupModal : Modal {
|
|||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = "Join \(name)?"
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
|
@ -36,6 +45,7 @@ final class JoinOpenGroupModal : Modal {
|
|||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
|
||||
// Join button
|
||||
let joinButton = UIButton()
|
||||
joinButton.set(.height, to: Values.mediumButtonHeight)
|
||||
|
@ -45,15 +55,18 @@ final class JoinOpenGroupModal : Modal {
|
|||
joinButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
joinButton.setTitle("Join", for: UIControl.State.normal)
|
||||
joinButton.addTarget(self, action: #selector(joinOpenGroup), for: UIControl.Event.touchUpInside)
|
||||
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, joinButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
|
@ -66,24 +79,39 @@ final class JoinOpenGroupModal : Modal {
|
|||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@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)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
||||
return presentingViewController!.presentAlert(alert)
|
||||
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||
|
||||
return presentingViewController.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
presentingViewController!.dismiss(animated: true, completion: nil)
|
||||
Storage.shared.write { [presentingViewController = self.presentingViewController!] transaction in
|
||||
OpenGroupManagerV2.shared.add(room: room, server: server, publicKey: publicKey, using: transaction)
|
||||
|
||||
presentingViewController.dismiss(animated: true, completion: nil)
|
||||
|
||||
Storage.shared
|
||||
.writeAsync { db in
|
||||
OpenGroupManager.shared.add(
|
||||
db,
|
||||
roomToken: room,
|
||||
server: server,
|
||||
publicKey: publicKey,
|
||||
isConfigMessage: false
|
||||
)
|
||||
}
|
||||
.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
|
||||
let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
||||
presentingViewController.presentAlert(alert)
|
||||
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||
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
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(onLinkPreviewsEnabled: @escaping () -> Void) {
|
||||
self.onLinkPreviewsEnabled = onLinkPreviewsEnabled
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
@ -18,22 +25,23 @@ final class LinkPreviewModal : Modal {
|
|||
|
||||
override func populateContentView() {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
let titleLabel: UILabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_link_previews_title", comment: "")
|
||||
titleLabel.text = "modal_link_previews_title".localized()
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
let messageLabel: UILabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = NSLocalizedString("modal_link_previews_explanation", comment: "")
|
||||
messageLabel.text = message
|
||||
messageLabel.text = "modal_link_previews_explanation".localized()
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
|
||||
// Enable button
|
||||
let enableButton = UIButton()
|
||||
let enableButton: UIButton = UIButton()
|
||||
enableButton.set(.height, to: Values.mediumButtonHeight)
|
||||
enableButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
enableButton.backgroundColor = Colors.buttonBackground
|
||||
|
@ -41,18 +49,22 @@ final class LinkPreviewModal : Modal {
|
|||
enableButton.setTitleColor(Colors.text, 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)
|
||||
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
|
||||
let buttonStackView: UIStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
|
@ -62,9 +74,13 @@ final class LinkPreviewModal : Modal {
|
|||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func enable() {
|
||||
SSKPreferences.areLinkPreviewsEnabled = true
|
||||
Storage.shared.writeAsync { db in
|
||||
db[.areLinkPreviewsEnabled] = true
|
||||
}
|
||||
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
onLinkPreviewsEnabled()
|
||||
}
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
|
||||
final class MessagesTableView : UITableView {
|
||||
override init(frame: CGRect, style: UITableView.Style) {
|
||||
super.init(frame: frame, style: style)
|
||||
initialize()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
initialize()
|
||||
}
|
||||
|
||||
private func initialize() {
|
||||
register(VisibleMessageCell.self, forCellReuseIdentifier: VisibleMessageCell.identifier)
|
||||
register(InfoMessageCell.self, forCellReuseIdentifier: InfoMessageCell.identifier)
|
||||
register(TypingIndicatorCell.self, forCellReuseIdentifier: TypingIndicatorCell.identifier)
|
||||
register(CallMessageCell.self, forCellReuseIdentifier: CallMessageCell.identifier)
|
||||
separatorStyle = .none
|
||||
backgroundColor = .clear
|
||||
showsVerticalScrollIndicator = false
|
||||
contentInsetAdjustmentBehavior = .never
|
||||
keyboardDismissMode = .interactive
|
||||
}
|
||||
}
|
|
@ -1,12 +1,17 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class ScrollToBottomButton : UIView {
|
||||
import UIKit
|
||||
|
||||
final class ScrollToBottomButton: UIView {
|
||||
private weak var delegate: ScrollToBottomButtonDelegate?
|
||||
|
||||
// MARK: Settings
|
||||
// MARK: - Settings
|
||||
|
||||
private static let size: CGFloat = 40
|
||||
private static let iconSize: CGFloat = 16
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(delegate: ScrollToBottomButtonDelegate) {
|
||||
self.delegate = delegate
|
||||
super.init(frame: CGRect.zero)
|
||||
|
@ -55,13 +60,15 @@ final class ScrollToBottomButton : UIView {
|
|||
addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func handleTap() {
|
||||
delegate?.handleScrollToBottomButtonTapped()
|
||||
}
|
||||
}
|
||||
|
||||
protocol ScrollToBottomButtonDelegate : class {
|
||||
// MARK: - ScrollToBottomButtonDelegate
|
||||
|
||||
protocol ScrollToBottomButtonDelegate: AnyObject {
|
||||
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
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
@ -23,6 +28,7 @@ final class URLModal : Modal {
|
|||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_open_url_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
|
@ -34,6 +40,7 @@ final class URLModal : Modal {
|
|||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
|
||||
// Open button
|
||||
let openButton = UIButton()
|
||||
openButton.set(.height, to: Values.mediumButtonHeight)
|
||||
|
@ -42,16 +49,19 @@ final class URLModal : Modal {
|
|||
openButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
openButton.setTitleColor(Colors.text, 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
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, openButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
|
@ -64,9 +74,11 @@ final class URLModal : Modal {
|
|||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc private func openURL() {
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func openUrl() {
|
||||
let url = self.url
|
||||
|
||||
presentingViewController?.dismiss(animated: true, completion: {
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
})
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class UserDetailsSheet : Sheet {
|
||||
private let sessionID: String
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class UserDetailsSheet: Sheet {
|
||||
private let profile: Profile
|
||||
|
||||
init(for profile: Profile) {
|
||||
self.profile = profile
|
||||
|
||||
init(for sessionID: String) {
|
||||
self.sessionID = sessionID
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
@ -22,16 +27,21 @@ final class UserDetailsSheet : Sheet {
|
|||
profilePictureView.size = size
|
||||
profilePictureView.set(.width, to: size)
|
||||
profilePictureView.set(.height, to: size)
|
||||
profilePictureView.publicKey = sessionID
|
||||
profilePictureView.update()
|
||||
profilePictureView.update(
|
||||
publicKey: profile.id,
|
||||
profile: profile,
|
||||
threadVariant: .contact
|
||||
)
|
||||
|
||||
// Display name label
|
||||
let displayNameLabel = UILabel()
|
||||
let displayName = Storage.shared.getContact(with: sessionID)?.displayName(for: .regular) ?? sessionID
|
||||
let displayName = profile.displayName()
|
||||
displayNameLabel.text = displayName
|
||||
displayNameLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
displayNameLabel.textColor = Colors.text
|
||||
displayNameLabel.numberOfLines = 1
|
||||
displayNameLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
// Session ID label
|
||||
let sessionIDLabel = UILabel()
|
||||
sessionIDLabel.textColor = Colors.text
|
||||
|
@ -39,7 +49,8 @@ final class UserDetailsSheet : Sheet {
|
|||
sessionIDLabel.numberOfLines = 0
|
||||
sessionIDLabel.lineBreakMode = .byCharWrapping
|
||||
sessionIDLabel.accessibilityLabel = "Session ID label"
|
||||
sessionIDLabel.text = sessionID
|
||||
sessionIDLabel.text = profile.id
|
||||
|
||||
// Session ID label container
|
||||
let sessionIDLabelContainer = UIView()
|
||||
sessionIDLabelContainer.addSubview(sessionIDLabel)
|
||||
|
@ -47,23 +58,26 @@ final class UserDetailsSheet : Sheet {
|
|||
sessionIDLabelContainer.layer.cornerRadius = TextField.cornerRadius
|
||||
sessionIDLabelContainer.layer.borderWidth = 1
|
||||
sessionIDLabelContainer.layer.borderColor = isLightMode ? UIColor.black.cgColor : UIColor.white.cgColor
|
||||
|
||||
// Copy button
|
||||
let copyButton = Button(style: .prominentOutline, size: .medium)
|
||||
copyButton.setTitle(NSLocalizedString("copy", comment: ""), for: UIControl.State.normal)
|
||||
copyButton.addTarget(self, action: #selector(copySessionID), for: UIControl.Event.touchUpInside)
|
||||
copyButton.set(.width, to: 160)
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel, sessionIDLabelContainer, copyButton, UIView.vSpacer(Values.largeSpacing) ])
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = Values.largeSpacing
|
||||
stackView.alignment = .center
|
||||
|
||||
// Constraints
|
||||
contentView.addSubview(stackView)
|
||||
stackView.pin(to: contentView, withInset: Values.largeSpacing)
|
||||
}
|
||||
|
||||
@objc private func copySessionID() {
|
||||
UIPasteboard.general.string = sessionID
|
||||
UIPasteboard.general.string = profile.id
|
||||
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 {
|
||||
private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
||||
|
@ -71,12 +78,7 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
|||
// Set up tab bar
|
||||
view.addSubview(tabBar)
|
||||
tabBar.pin(.leading, to: .leading, of: view)
|
||||
let tabBarInset: CGFloat
|
||||
if #available(iOS 13, *) {
|
||||
tabBarInset = UIDevice.current.isIPad ? navigationBar.height() + 20 : navigationBar.height()
|
||||
} else {
|
||||
tabBarInset = 0
|
||||
}
|
||||
let tabBarInset: CGFloat = (UIDevice.current.isIPad ? navigationBar.height() + 20 : navigationBar.height())
|
||||
tabBar.pin(.top, to: .top, of: view, withInset: tabBarInset)
|
||||
view.pin(.trailing, to: .trailing, of: tabBar)
|
||||
// Set up page VC constraints
|
||||
|
@ -88,13 +90,7 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
|||
view.pin(.bottom, to: .bottom, of: pageVCView)
|
||||
let screen = UIScreen.main.bounds
|
||||
pageVCView.set(.width, to: screen.width)
|
||||
let height: CGFloat
|
||||
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
|
||||
}
|
||||
let height: CGFloat = (navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight)
|
||||
pageVCView.set(.height, to: height)
|
||||
enterPublicKeyVC.constrainHeight(to: height)
|
||||
scanQRCodePlaceholderVC.constrainHeight(to: height)
|
||||
|
@ -138,38 +134,57 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
|||
}
|
||||
|
||||
fileprivate func startNewDMIfPossible(with onsNameOrPublicKey: String) {
|
||||
if ECKeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) {
|
||||
let maybeSessionId: SessionId? = SessionId(from: onsNameOrPublicKey)
|
||||
|
||||
if ECKeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) && maybeSessionId?.prefix == .standard {
|
||||
startNewDM(with: onsNameOrPublicKey)
|
||||
} else {
|
||||
// This could be an ONS name
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
|
||||
SnodeAPI.getSessionID(for: onsNameOrPublicKey).done { sessionID in
|
||||
modalActivityIndicator.dismiss {
|
||||
self?.startNewDM(with: sessionID)
|
||||
}
|
||||
}.catch { error in
|
||||
modalActivityIndicator.dismiss {
|
||||
var messageOrNil: String?
|
||||
if let error = error as? SnodeAPI.Error {
|
||||
switch error {
|
||||
case .decryptionFailed, .hashingFailed, .validationFailed: messageOrNil = error.errorDescription
|
||||
return
|
||||
}
|
||||
|
||||
// This could be an ONS name
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
|
||||
SnodeAPI.getSessionID(for: onsNameOrPublicKey).done { sessionID in
|
||||
modalActivityIndicator.dismiss {
|
||||
self?.startNewDM(with: sessionID)
|
||||
}
|
||||
}.catch { error in
|
||||
modalActivityIndicator.dismiss {
|
||||
var messageOrNil: String?
|
||||
if let error = error as? SnodeAPIError {
|
||||
switch error {
|
||||
case .decryptionFailed, .hashingFailed, .validationFailed:
|
||||
messageOrNil = error.errorDescription
|
||||
default: break
|
||||
}
|
||||
}
|
||||
let message = messageOrNil ?? "Please check the Session ID or ONS name and try again"
|
||||
let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
||||
self?.presentAlert(alert)
|
||||
}
|
||||
let message: String = {
|
||||
if let messageOrNil: String = messageOrNil {
|
||||
return messageOrNil
|
||||
}
|
||||
|
||||
return (maybeSessionId?.prefix == .blinded ?
|
||||
"You can only send messages to Blinded IDs from within an Open Group" :
|
||||
"Please check the Session ID or ONS name and try again"
|
||||
)
|
||||
}()
|
||||
let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||
self?.presentAlert(alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startNewDM(with sessionID: String) {
|
||||
let thread = TSContactThread.getOrCreateThread(contactSessionID: sessionID)
|
||||
private func startNewDM(with sessionId: String) {
|
||||
let maybeThread: SessionThread? = Storage.shared.write { db in
|
||||
try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact)
|
||||
}
|
||||
|
||||
guard maybeThread != nil else { return }
|
||||
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false)
|
||||
|
||||
SessionApp.presentConversation(for: sessionId, action: .compose, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import PureLayout
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import NVActivityIndicatorView
|
||||
|
||||
class EmptySearchResultCell: UITableViewCell {
|
||||
static let reuseIdentifier = "EmptySearchResultCell"
|
||||
|
||||
private lazy var messageLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textAlignment = .center
|
||||
|
@ -24,6 +26,7 @@ class EmptySearchResultCell: UITableViewCell {
|
|||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
|
||||
contentView.addSubview(messageLabel)
|
||||
messageLabel.autoSetDimension(.height, toSize: 150)
|
||||
|
|
|
@ -1,11 +1,42 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc
|
||||
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 = "" {
|
||||
didSet {
|
||||
|
@ -14,26 +45,11 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
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 {
|
||||
case noResults
|
||||
case contacts
|
||||
case messages
|
||||
case recent
|
||||
}
|
||||
|
||||
// MARK: UI Components
|
||||
// MARK: - UI Components
|
||||
|
||||
internal lazy var searchBar: SearchBar = {
|
||||
let result = SearchBar()
|
||||
let result: SearchBar = SearchBar()
|
||||
result.tintColor = Colors.text
|
||||
result.delegate = self
|
||||
result.showsCancelButton = true
|
||||
|
@ -41,26 +57,23 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
}()
|
||||
|
||||
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.estimatedRowHeight = 60
|
||||
result.separatorStyle = .none
|
||||
result.keyboardDismissMode = .onDrag
|
||||
result.register(EmptySearchResultCell.self, forCellReuseIdentifier: EmptySearchResultCell.reuseIdentifier)
|
||||
result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier)
|
||||
result.register(view: EmptySearchResultCell.self)
|
||||
result.register(view: FullConversationCell.self)
|
||||
result.showsVerticalScrollIndicator = false
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Dependencies
|
||||
// MARK: - View Lifecycle
|
||||
|
||||
var dbReadConnection: YapDatabaseConnection {
|
||||
return OWSPrimaryStorage.shared().dbReadConnection
|
||||
}
|
||||
|
||||
// MARK: View Lifecycle
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setUpGradientBackground()
|
||||
|
||||
tableView.dataSource = self
|
||||
|
@ -89,7 +102,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
// 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
|
||||
// in home screen doing a weird scrolling when going back to home screen.
|
||||
let searchBarContainer = UIView()
|
||||
let searchBarContainer: UIView = UIView()
|
||||
searchBarContainer.layoutMargins = UIEdgeInsets.zero
|
||||
searchBar.sizeToFit()
|
||||
searchBar.layoutMargins = UIEdgeInsets.zero
|
||||
|
@ -103,14 +116,15 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
if UIDevice.current.isIPad {
|
||||
let ipadCancelButton = UIButton()
|
||||
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)
|
||||
searchBarContainer.addSubview(ipadCancelButton)
|
||||
ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer)
|
||||
ipadCancelButton.autoVCenterInSuperview()
|
||||
searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing)
|
||||
searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
searchBar.autoPinEdgesToSuperviewMargins()
|
||||
}
|
||||
}
|
||||
|
@ -119,21 +133,18 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
tableView.reloadData()
|
||||
}
|
||||
|
||||
// MARK: Update Search Results
|
||||
|
||||
var refreshTimer: Timer?
|
||||
// MARK: - Update Search Results
|
||||
|
||||
private func refreshSearchResults() {
|
||||
refreshTimer?.invalidate()
|
||||
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) {
|
||||
|
||||
let searchText = rawSearchText.stripped
|
||||
|
||||
guard searchText.count > 0 else {
|
||||
searchResultSet = defaultSearchResults
|
||||
lastSearchText = nil
|
||||
|
@ -144,37 +155,62 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
|
||||
lastSearchText = searchText
|
||||
|
||||
var searchResults: HomeScreenSearchResultSet?
|
||||
self.dbReadConnection.asyncRead({[weak self] transaction in
|
||||
guard let self = self else { return }
|
||||
self.isLoading = true
|
||||
// The max search result count is set according to the keyword length. This is just a workaround for performance issue.
|
||||
// The longer and more accurate the keyword is, the less search results should there be.
|
||||
searchResults = self.searcher.searchForHomeScreen(searchText: searchText, maxSearchResults: 500, transaction: transaction)
|
||||
}, completionBlock: { [weak self] in
|
||||
AssertIsOnMainThread()
|
||||
guard let self = self, let results = searchResults, self.lastSearchText == searchText else { return }
|
||||
self.searchResultSet = results
|
||||
self.isLoading = false
|
||||
self.reloadTableData()
|
||||
self.refreshTimer = nil
|
||||
})
|
||||
let result: Result<[SectionModel], Error>? = Storage.shared.read { db -> Result<[SectionModel], Error> in
|
||||
do {
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel
|
||||
.contactsAndGroupsQuery(
|
||||
userPublicKey: userPublicKey,
|
||||
pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText),
|
||||
searchTerm: searchText
|
||||
)
|
||||
.fetchAll(db)
|
||||
let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel
|
||||
.messagesQuery(
|
||||
userPublicKey: userPublicKey,
|
||||
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 clearRecentSearchResults() {
|
||||
recentSearchResults = []
|
||||
tableView.reloadSections([ SearchSection.recent.rawValue ], with: .top)
|
||||
Storage.shared.clearRecentSearchResults()
|
||||
}
|
||||
|
||||
@objc func cancel(_ sender: Any) {
|
||||
@objc func cancel() {
|
||||
self.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UISearchBarDelegate
|
||||
|
||||
extension GlobalSearchViewController: UISearchBarDelegate {
|
||||
public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
|
||||
self.updateSearchText()
|
||||
|
@ -201,51 +237,57 @@ extension GlobalSearchViewController: UISearchBarDelegate {
|
|||
}
|
||||
|
||||
// MARK: - UITableViewDelegate & UITableViewDataSource
|
||||
|
||||
extension GlobalSearchViewController {
|
||||
|
||||
// MARK: UITableViewDelegate
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
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)
|
||||
|
||||
let section: SectionModel = self.searchResultSet[indexPath.section]
|
||||
|
||||
switch section.model {
|
||||
case .noResults: break
|
||||
case .contactsAndGroups, .messages:
|
||||
show(
|
||||
threadId: section.elements[indexPath.row].threadId,
|
||||
threadVariant: section.elements[indexPath.row].threadVariant,
|
||||
focusedInteractionId: section.elements[indexPath.row].interactionId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if let presentedVC = self.presentedViewController {
|
||||
presentedVC.dismiss(animated: false, completion: nil)
|
||||
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)
|
||||
}
|
||||
let conversationVC = ConversationVC(thread: thread, focusedMessageID: highlightedMessageID)
|
||||
var viewControllers = self.navigationController?.viewControllers
|
||||
if isFromRecent, let index = viewControllers?.firstIndex(of: self) { viewControllers?.remove(at: index) }
|
||||
viewControllers?.append(conversationVC)
|
||||
self.navigationController?.setViewControllers(viewControllers!, animated: true)
|
||||
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
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
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? {
|
||||
|
@ -260,13 +302,12 @@ extension GlobalSearchViewController {
|
|||
guard nil != self.tableView(tableView, titleForHeaderInSection: section) else {
|
||||
return .leastNonzeroMagnitude
|
||||
}
|
||||
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
guard let searchSection = SearchSection(rawValue: section) else { return nil }
|
||||
|
||||
guard let title = self.tableView(tableView, titleForHeaderInSection: section) else {
|
||||
guard let title: String = self.tableView(tableView, titleForHeaderInSection: section) else {
|
||||
return UIView()
|
||||
}
|
||||
|
||||
|
@ -281,58 +322,16 @@ extension GlobalSearchViewController {
|
|||
container.addSubview(titleLabel)
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
case .noResults:
|
||||
return nil
|
||||
case .contacts:
|
||||
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
|
||||
switch section.model {
|
||||
case .noResults: return nil
|
||||
case .contactsAndGroups: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_CONTACTS".localized())
|
||||
case .messages: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_MESSAGES".localized())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -341,41 +340,23 @@ extension GlobalSearchViewController {
|
|||
}
|
||||
|
||||
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)
|
||||
cell.configure(isLoading: isLoading)
|
||||
return cell
|
||||
|
||||
switch searchSection {
|
||||
case .noResults:
|
||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: EmptySearchResultCell.reuseIdentifier) as? EmptySearchResultCell, indexPath.row == 0 else { return UITableViewCell() }
|
||||
cell.configure(isLoading: isLoading)
|
||||
return cell
|
||||
case .contacts:
|
||||
let sectionResults = searchResultSet.conversations
|
||||
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)
|
||||
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
|
||||
case .contactsAndGroups:
|
||||
let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
|
||||
cell.updateForContactAndGroupSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet)
|
||||
return cell
|
||||
|
||||
case .messages:
|
||||
let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
|
||||
cell.updateForMessageSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet)
|
||||
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,29 +1,37 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc
|
||||
class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
||||
private var threads: YapDatabaseViewMappings! = {
|
||||
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 static let loadingHeaderHeight: CGFloat = 20
|
||||
|
||||
private var messageRequestCount: UInt {
|
||||
threads.numberOfItems(inGroup: TSMessageRequestGroup)
|
||||
private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
|
||||
private var dataChangeObservable: DatabaseCancellable?
|
||||
private var hasLoadedInitialThreadData: Bool = false
|
||||
private var isLoadingMore: Bool = false
|
||||
private var isAutoLoadingNextPage: Bool = false
|
||||
private var viewHasAppeared: Bool = false
|
||||
|
||||
// MARK: - Intialization
|
||||
|
||||
init() {
|
||||
Storage.shared.addObserver(viewModel.pagedDataObserver)
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
private lazy var dbConnection: YapDatabaseConnection = {
|
||||
let result = OWSPrimaryStorage.shared().newDatabaseConnection()
|
||||
result.objectCacheLimit = 500
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init() instead.")
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
|
@ -32,8 +40,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.backgroundColor = .clear
|
||||
result.separatorStyle = .none
|
||||
result.register(MessageRequestsCell.self, forCellReuseIdentifier: MessageRequestsCell.reuseIdentifier)
|
||||
result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier)
|
||||
result.register(view: FullConversationCell.self)
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
|
||||
|
@ -41,6 +48,10 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
|
||||
result.showsVerticalScrollIndicator = false
|
||||
|
||||
if #available(iOS 15.0, *) {
|
||||
result.sectionHeaderTopPadding = 0
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -87,7 +98,11 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
override func 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
|
||||
// the dataSource has the correct data)
|
||||
|
@ -100,33 +115,44 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
// Notifications
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleYapDatabaseModifiedNotification(_:)),
|
||||
name: .YapDatabaseModified,
|
||||
object: OWSPrimaryStorage.shared().dbNotificationObject
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleProfileDidChangeNotification(_:)),
|
||||
name: NSNotification.Name(rawValue: kNSNotificationName_OtherUsersProfileDidChange),
|
||||
selector: #selector(applicationDidBecomeActive(_:)),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleBlockedContactsUpdatedNotification(_:)),
|
||||
name: .blockedContactsUpdated,
|
||||
object: nil
|
||||
selector: #selector(applicationDidResignActive(_:)),
|
||||
name: UIApplication.didEnterBackgroundNotification, object: nil
|
||||
)
|
||||
}
|
||||
|
||||
reload()
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
startObservingChanges()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
reload()
|
||||
|
||||
self.viewHasAppeared = true
|
||||
self.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
// Stop observing database changes
|
||||
dataChangeObservable?.cancel()
|
||||
}
|
||||
|
||||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||
startObservingChanges(didReturnFromBackground: true)
|
||||
}
|
||||
|
||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||
// Stop observing database changes
|
||||
dataChangeObservable?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
@ -158,111 +184,86 @@ 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
|
||||
|
||||
private func reload() {
|
||||
AssertIsOnMainThread()
|
||||
dbConnection.beginLongLivedReadTransaction() // Jump to the latest commit
|
||||
dbConnection.read { transaction in
|
||||
self.threads.update(with: transaction)
|
||||
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
||||
self.viewModel.onThreadChange = { [weak self] updatedThreadData in
|
||||
self?.handleThreadUpdates(updatedThreadData)
|
||||
}
|
||||
|
||||
// Note: When returning from the background we could have received notifications but the
|
||||
// PagedDatabaseObserver won't have them so we need to force a re-fetch of the current
|
||||
// data to ensure everything is up to date
|
||||
if didReturnFromBackground {
|
||||
self.viewModel.pagedDataObserver?.reload()
|
||||
}
|
||||
threadViewModelCache.removeAll()
|
||||
tableView.reloadData()
|
||||
clearAllButton.isHidden = (messageRequestCount == 0)
|
||||
emptyStateLabel.isHidden = (messageRequestCount != 0)
|
||||
}
|
||||
|
||||
@objc private func handleYapDatabaseModifiedNotification(_ yapDatabase: YapDatabase) {
|
||||
// NOTE: This code is very finicky and crashes easily. Modify with care.
|
||||
AssertIsOnMainThread()
|
||||
|
||||
// If we don't capture `threads` here, a race condition can occur where the
|
||||
// `thread.snapshotOfLastUpdate != firstSnapshot - 1` check below evaluates to
|
||||
// `false`, but `threads` then changes between that check and the
|
||||
// `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
|
||||
let notifications = dbConnection.beginLongLivedReadTransaction()
|
||||
|
||||
guard !notifications.isEmpty else { return }
|
||||
|
||||
let ext = dbConnection.ext(TSThreadDatabaseViewExtensionName) as! YapDatabaseViewConnection
|
||||
let hasChanges = ext.hasChanges(forGroup: TSMessageRequestGroup, in: notifications)
|
||||
|
||||
guard hasChanges else { return }
|
||||
|
||||
if let firstChangeSet = notifications[0].userInfo {
|
||||
let firstSnapshot = firstChangeSet[YapDatabaseSnapshotKey] as! UInt64
|
||||
|
||||
if threads.snapshotOfLastUpdate != firstSnapshot - 1 {
|
||||
return reload() // The code below will crash if we try to process multiple commits at once
|
||||
}
|
||||
private func handleThreadUpdates(_ updatedData: [MessageRequestsViewModel.SectionModel], initialLoad: Bool = false) {
|
||||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||
// in from a frame of CGRect.zero)
|
||||
guard hasLoadedInitialThreadData else {
|
||||
hasLoadedInitialThreadData = true
|
||||
UIView.performWithoutAnimation { handleThreadUpdates(updatedData, initialLoad: true) }
|
||||
return
|
||||
}
|
||||
|
||||
var sectionChanges = NSArray()
|
||||
var rowChanges = NSArray()
|
||||
ext.getSectionChanges(§ionChanges, rowChanges: &rowChanges, for: notifications, with: threads)
|
||||
// Show the empty state if there is no data
|
||||
clearAllButton.isHidden = updatedData.isEmpty
|
||||
emptyStateLabel.isHidden = !updatedData.isEmpty
|
||||
|
||||
guard sectionChanges.count > 0 || rowChanges.count > 0 else { return }
|
||||
|
||||
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 {
|
||||
case .move: tableView.moveRow(at: rowChange.indexPath!, to: rowChange.newIndexPath!)
|
||||
default: break
|
||||
}
|
||||
CATransaction.begin()
|
||||
CATransaction.setCompletionBlock { [weak self] in
|
||||
// Complete page loading
|
||||
self?.isLoadingMore = false
|
||||
self?.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
|
||||
tableView.endUpdates()
|
||||
clearAllButton.isHidden = (messageRequestCount == 0)
|
||||
emptyStateLabel.isHidden = (messageRequestCount != 0)
|
||||
// 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)
|
||||
}
|
||||
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
@objc private func handleProfileDidChangeNotification(_ notification: Notification) {
|
||||
tableView.reloadData() // TODO: Just reload the affected cell
|
||||
}
|
||||
private func autoLoadNextPageIfNeeded() {
|
||||
guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return }
|
||||
|
||||
@objc private func handleBlockedContactsUpdatedNotification(_ notification: Notification) {
|
||||
tableView.reloadData() // TODO: Just reload the affected cell
|
||||
self.isAutoLoadingNextPage = true
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in
|
||||
self?.isAutoLoadingNextPage = false
|
||||
|
||||
// Note: We sort the headers as we want to prioritise loading newer pages over older ones
|
||||
let sections: [(MessageRequestsViewModel.Section, CGRect)] = (self?.viewModel.threadData
|
||||
.enumerated()
|
||||
.map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) })
|
||||
.defaulting(to: [])
|
||||
let shouldLoadMore: Bool = sections
|
||||
.contains { section, headerRect in
|
||||
section == .loadMore &&
|
||||
headerRect != .zero &&
|
||||
(self?.tableView.bounds.contains(headerRect) == true)
|
||||
}
|
||||
|
||||
guard shouldLoadMore else { return }
|
||||
|
||||
self?.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(.pageAfter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc override internal func handleAppModeChangedNotification(_ notification: Notification) {
|
||||
|
@ -273,15 +274,96 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
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
|
||||
|
||||
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) {
|
||||
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)
|
||||
self.navigationController?.pushViewController(conversationVC, animated: true)
|
||||
switch section.model {
|
||||
case .threads:
|
||||
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||
let conversationVC: ConversationVC = ConversationVC(
|
||||
threadId: threadViewModel.threadId,
|
||||
threadVariant: threadViewModel.threadVariant
|
||||
)
|
||||
self.navigationController?.pushViewController(conversationVC, animated: true)
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
|
@ -289,146 +371,98 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
}
|
||||
|
||||
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
|
||||
self?.delete(thread)
|
||||
switch section.model {
|
||||
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
|
||||
|
||||
private func updateContactAndThread(thread: TSThread, with transaction: YapDatabaseReadWriteTransaction, onComplete: ((Bool) -> ())? = nil) {
|
||||
guard let contactThread: TSContactThread = thread as? TSContactThread else {
|
||||
onComplete?(false)
|
||||
@objc private func clearAllTapped() {
|
||||
guard viewModel.threadData.first(where: { $0.model == .threads })?.elements.isEmpty == false else {
|
||||
return
|
||||
}
|
||||
|
||||
var needsSync: Bool = false
|
||||
|
||||
// Update the contact
|
||||
let sessionId: String = contactThread.contactSessionID()
|
||||
|
||||
if let contact: Contact = Storage.shared.getContact(with: sessionId), (contact.isApproved || !contact.isBlocked) {
|
||||
contact.isApproved = false
|
||||
contact.isBlocked = true
|
||||
|
||||
Storage.shared.setContact(contact, using: transaction)
|
||||
needsSync = true
|
||||
}
|
||||
|
||||
// Delete all thread content
|
||||
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
|
||||
let threadIds: [String] = (viewModel.threadData
|
||||
.first { $0.model == .threads }?
|
||||
.elements
|
||||
.map { $0.threadId })
|
||||
.defaulting(to: [])
|
||||
let alertVC: UIAlertController = UIAlertController(
|
||||
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE".localized(),
|
||||
message: nil,
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
alertVC.addAction(UIAlertAction(
|
||||
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON".localized(),
|
||||
style: .destructive
|
||||
) { _ in
|
||||
// Clear the requests
|
||||
Storage.write(
|
||||
with: { [weak self] transaction in
|
||||
threads.forEach { thread in
|
||||
if let uniqueId: String = thread.uniqueId {
|
||||
Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction)
|
||||
}
|
||||
Storage.shared.write { db in
|
||||
_ = try SessionThread
|
||||
.filter(ids: threadIds)
|
||||
.deleteAll(db)
|
||||
|
||||
self?.updateContactAndThread(thread: thread, with: transaction) { threadNeedsSync in
|
||||
if threadNeedsSync {
|
||||
needsSync = true
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
try threadIds.forEach { threadId in
|
||||
_ = try Contact
|
||||
.fetchOrCreate(db, id: threadId)
|
||||
.with(
|
||||
isApproved: false,
|
||||
isBlocked: true
|
||||
)
|
||||
.saved(db)
|
||||
}
|
||||
)
|
||||
})
|
||||
alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .cancel, handler: nil))
|
||||
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)
|
||||
// Force a config sync
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
threadViewModelCache[uniqueId] = threadViewModel
|
||||
})
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
return threadViewModel
|
||||
}
|
||||
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.clipsToBounds = true
|
||||
result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity)
|
||||
result.layer.cornerRadius = (ConversationCell.unreadCountViewSize / 2)
|
||||
result.layer.cornerRadius = (FullConversationCell.unreadCountViewSize / 2)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -115,8 +115,8 @@ class MessageRequestsCell: UITableViewCell {
|
|||
|
||||
unreadCountView.leftAnchor.constraint(equalTo: titleLabel.rightAnchor, constant: (Values.smallSpacing / 2)),
|
||||
unreadCountView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
|
||||
unreadCountView.widthAnchor.constraint(equalToConstant: ConversationCell.unreadCountViewSize),
|
||||
unreadCountView.heightAnchor.constraint(equalToConstant: ConversationCell.unreadCountViewSize),
|
||||
unreadCountView.widthAnchor.constraint(equalToConstant: FullConversationCell.unreadCountViewSize),
|
||||
unreadCountView.heightAnchor.constraint(equalToConstant: FullConversationCell.unreadCountViewSize),
|
||||
|
||||
unreadCountLabel.topAnchor.constraint(equalTo: unreadCountView.topAnchor),
|
||||
unreadCountLabel.leftAnchor.constraint(equalTo: unreadCountView.leftAnchor),
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
protocol GifPickerLayoutDelegate: class {
|
||||
protocol GifPickerLayoutDelegate: AnyObject {
|
||||
func imageInfosForLayout() -> [GiphyImageInfo]
|
||||
}
|
||||
|
||||
|
|
|
@ -8,11 +8,6 @@ import SignalUtilitiesKit
|
|||
import PromiseKit
|
||||
import SessionUIKit
|
||||
|
||||
@objc
|
||||
protocol GifPickerViewControllerDelegate: class {
|
||||
func gifPickerDidSelect(attachment: SignalAttachment)
|
||||
}
|
||||
|
||||
class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate, GifPickerLayoutDelegate {
|
||||
|
||||
// MARK: Properties
|
||||
|
@ -31,11 +26,8 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
|
||||
var lastQuery: String = ""
|
||||
|
||||
@objc
|
||||
public weak var delegate: GifPickerViewControllerDelegate?
|
||||
|
||||
let thread: TSThread
|
||||
|
||||
let searchBar: SearchBar
|
||||
let layout: GifPickerLayout
|
||||
let collectionView: UICollectionView
|
||||
|
@ -51,17 +43,14 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
|
||||
var progressiveSearchTimer: Timer?
|
||||
|
||||
// MARK: Initializers
|
||||
// MARK: - Initialization
|
||||
|
||||
@available(*, unavailable, message:"use other constructor instead.")
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
notImplemented()
|
||||
}
|
||||
|
||||
@objc
|
||||
required init(thread: TSThread) {
|
||||
self.thread = thread
|
||||
|
||||
required init() {
|
||||
self.searchBar = SearchBar()
|
||||
self.layout = GifPickerLayout()
|
||||
self.collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: self.layout)
|
||||
|
@ -116,7 +105,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
|
||||
// Loki: Customize title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = NSLocalizedString("GIF", comment: "")
|
||||
titleLabel.text = "accessibility_gif_button".localized().uppercased()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
navigationItem.titleView = titleLabel
|
||||
|
@ -469,8 +458,8 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
progressiveSearchTimer = nil
|
||||
|
||||
guard let text = searchBar.text else {
|
||||
OWSAlerts.showErrorAlert(message: NSLocalizedString("GIF_PICKER_VIEW_MISSING_QUERY",
|
||||
comment: "Alert message shown when user tries to search for GIFs without entering any search terms."))
|
||||
// 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
|
||||
}
|
||||
|
||||
|
@ -556,3 +545,9 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
layout.invalidateLayout()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GifPickerViewControllerDelegate
|
||||
|
||||
protocol GifPickerViewControllerDelegate: AnyObject {
|
||||
func gifPickerDidSelect(attachment: SignalAttachment)
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
import Foundation
|
||||
import Photos
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
|
||||
protocol ImagePickerGridControllerDelegate: AnyObject {
|
||||
func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController)
|
||||
|
@ -47,6 +48,8 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.view.backgroundColor = Colors.navigationBarBackground
|
||||
|
||||
library.add(delegate: self)
|
||||
|
||||
guard let collectionView = collectionView else {
|
||||
|
@ -54,12 +57,11 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
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
|
||||
let bottomButtonInset = -1 * SendMediaNavigationController.bottomButtonsCenterOffset + SendMediaNavigationController.bottomButtonWidth / 2 + 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.
|
||||
// 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 cancelButton = UIBarButtonItem(image: cancelImage, style: .plain, target: self, action: #selector(didPressCancel))
|
||||
|
||||
cancelButton.tintColor = .black
|
||||
cancelButton.tintColor = Colors.text
|
||||
navigationItem.leftBarButtonItem = cancelButton
|
||||
|
||||
let titleView = TitleView()
|
||||
titleView.delegate = self
|
||||
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
|
||||
self.titleView = titleView
|
||||
|
||||
collectionView.backgroundColor = .white
|
||||
collectionView.backgroundColor = Colors.navigationBarBackground
|
||||
|
||||
let selectionPanGesture = DirectionalPanGestureRecognizer(direction: [.horizontal], target: self, action: #selector(didPanSelection))
|
||||
selectionPanGesture.delegate = self
|
||||
|
@ -200,16 +194,15 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
// Loki: Set navigation bar background color
|
||||
let navigationBar = navigationController!.navigationBar
|
||||
navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default)
|
||||
navigationBar.shadowImage = UIImage()
|
||||
navigationBar.isTranslucent = false
|
||||
navigationBar.barTintColor = .white
|
||||
(navigationBar as! OWSNavigationBar).respectsTheme = false
|
||||
navigationBar.backgroundColor = .white
|
||||
let backgroundImage = UIImage(color: .white)
|
||||
navigationBar.setBackgroundImage(backgroundImage, for: .default)
|
||||
let backgroundImage: UIImage = UIImage(color: Colors.navigationBarBackground)
|
||||
self.navigationItem.title = nil
|
||||
self.navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default)
|
||||
self.navigationController?.navigationBar.shadowImage = UIImage()
|
||||
self.navigationController?.navigationBar.isTranslucent = false
|
||||
self.navigationController?.navigationBar.barTintColor = Colors.navigationBarBackground
|
||||
(self.navigationController?.navigationBar as? OWSNavigationBar)?.respectsTheme = true
|
||||
self.navigationController?.navigationBar.backgroundColor = Colors.navigationBarBackground
|
||||
self.navigationController?.navigationBar.setBackgroundImage(backgroundImage, for: .default)
|
||||
|
||||
// Determine the size of the thumbnails to request
|
||||
let scale = UIScreen.main.scale
|
||||
|
@ -268,11 +261,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
// MARK:
|
||||
|
||||
var lastPageYOffset: CGFloat {
|
||||
var yOffset = collectionView.contentSize.height - collectionView.bounds.height + collectionView.adjustedContentInset.bottom
|
||||
if #available(iOS 11.0, *) {
|
||||
yOffset += view.safeAreaInsets.bottom
|
||||
}
|
||||
return yOffset
|
||||
return (collectionView.contentSize.height - collectionView.bounds.height + collectionView.adjustedContentInset.bottom + view.safeAreaInsets.bottom)
|
||||
}
|
||||
|
||||
func scrollToBottom(animated: Bool) {
|
||||
|
@ -343,10 +332,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
static let kInterItemSpacing: CGFloat = 2
|
||||
private class func buildLayout() -> UICollectionViewFlowLayout {
|
||||
let layout = UICollectionViewFlowLayout()
|
||||
|
||||
if #available(iOS 11, *) {
|
||||
layout.sectionInsetReference = .fromSafeArea
|
||||
}
|
||||
layout.sectionInsetReference = .fromSafeArea
|
||||
layout.minimumInteritemSpacing = kInterItemSpacing
|
||||
layout.minimumLineSpacing = kInterItemSpacing
|
||||
layout.sectionHeadersPinToVisibleBounds = true
|
||||
|
@ -355,13 +341,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
}
|
||||
|
||||
func updateLayout() {
|
||||
let containerWidth: CGFloat
|
||||
if #available(iOS 11.0, *) {
|
||||
containerWidth = self.view.safeAreaLayoutGuide.layoutFrame.size.width
|
||||
} else {
|
||||
containerWidth = self.view.frame.size.width
|
||||
}
|
||||
|
||||
let containerWidth: CGFloat = self.view.safeAreaLayoutGuide.layoutFrame.size.width
|
||||
let kItemsPerPortraitRow = 4
|
||||
let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
|
||||
let approxItemWidth = screenWidth / CGFloat(kItemsPerPortraitRow)
|
||||
|
@ -556,11 +536,9 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
return UICollectionViewCell(forAutoLayout: ())
|
||||
}
|
||||
|
||||
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PhotoGridViewCell.reuseIdentifier, for: indexPath) as? PhotoGridViewCell else {
|
||||
owsFail("cell was unexpectedly nil")
|
||||
}
|
||||
|
||||
let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath)
|
||||
cell.loadingColor = UIColor(white: 0.2, alpha: 1)
|
||||
|
||||
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
|
||||
cell.configure(item: assetItem)
|
||||
|
||||
|
@ -587,7 +565,7 @@ extension ImagePickerGridController: UIGestureRecognizerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
protocol TitleViewDelegate: class {
|
||||
protocol TitleViewDelegate: AnyObject {
|
||||
func titleViewWasTapped(_ titleView: TitleView)
|
||||
}
|
||||
|
||||
|
@ -615,10 +593,10 @@ class TitleView: UIView {
|
|||
addSubview(stackView)
|
||||
stackView.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
label.textColor = .black
|
||||
label.textColor = Colors.text
|
||||
label.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
|
||||
iconView.tintColor = .black
|
||||
iconView.tintColor = Colors.text
|
||||
iconView.image = UIImage(named: "navbar_disclosure_down")?.withRenderingMode(.alwaysTemplate)
|
||||
|
||||
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(titleTapped)))
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <SignalUtilitiesKit/OWSViewController.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@protocol ConversationViewItem;
|
||||
|
||||
@class GalleryItemBox;
|
||||
@class MediaDetailViewController;
|
||||
@class TSAttachment;
|
||||
|
||||
typedef NS_OPTIONS(NSInteger, MediaGalleryOption) {
|
||||
MediaGalleryOptionSliderEnabled = 1 << 0,
|
||||
MediaGalleryOptionShowAllMediaButton = 1 << 1
|
||||
};
|
||||
|
||||
@protocol MediaDetailViewControllerDelegate <NSObject>
|
||||
|
||||
- (void)mediaDetailViewController:(MediaDetailViewController *)mediaDetailViewController
|
||||
requestDeleteAttachment:(TSAttachment *)attachment;
|
||||
|
||||
- (void)mediaDetailViewController:(MediaDetailViewController *)mediaDetailViewController
|
||||
isPlayingVideo:(BOOL)isPlayingVideo;
|
||||
|
||||
- (void)mediaDetailViewControllerDidTapMedia:(MediaDetailViewController *)mediaDetailViewController;
|
||||
|
||||
@end
|
||||
|
||||
@interface MediaDetailViewController : OWSViewController
|
||||
|
||||
@property (nonatomic, weak) id<MediaDetailViewControllerDelegate> delegate;
|
||||
@property (nonatomic, readonly) GalleryItemBox *galleryItemBox;
|
||||
|
||||
// If viewItem is non-null, long press will show a menu controller.
|
||||
- (instancetype)initWithGalleryItemBox:(GalleryItemBox *)galleryItemBox
|
||||
viewItem:(nullable id<ConversationViewItem>)viewItem;
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)didPressPlayBarButton:(id)sender;
|
||||
- (void)didPressPauseBarButton:(id)sender;
|
||||
- (void)playVideo;
|
||||
|
||||
// Stops playback and rewinds
|
||||
- (void)stopAnyVideo;
|
||||
|
||||
- (void)setShouldHideToolbars:(BOOL)shouldHideToolbars;
|
||||
- (void)zoomOutAnimated:(BOOL)isAnimated;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -1,500 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "MediaDetailViewController.h"
|
||||
#import "ConversationViewItem.h"
|
||||
#import "Session-Swift.h"
|
||||
#import "TSAttachmentStream.h"
|
||||
#import "TSInteraction.h"
|
||||
#import "UIColor+OWS.h"
|
||||
#import "UIUtil.h"
|
||||
#import "UIView+OWS.h"
|
||||
#import <AVKit/AVKit.h>
|
||||
#import <MediaPlayer/MPMoviePlayerViewController.h>
|
||||
#import <MediaPlayer/MediaPlayer.h>
|
||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||
#import <SessionUtilitiesKit/NSData+Image.h>
|
||||
#import <SessionUIKit/SessionUIKit.h>
|
||||
#import <YYImage/YYImage.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface MediaDetailViewController () <UIScrollViewDelegate,
|
||||
UIGestureRecognizerDelegate,
|
||||
PlayerProgressBarDelegate,
|
||||
OWSVideoPlayerDelegate>
|
||||
|
||||
@property (nonatomic) UIScrollView *scrollView;
|
||||
@property (nonatomic) UIView *mediaView;
|
||||
@property (nonatomic) UIView *presentationView;
|
||||
@property (nonatomic) UIView *replacingView;
|
||||
@property (nonatomic) UIButton *shareButton;
|
||||
|
||||
@property (nonatomic) TSAttachmentStream *attachmentStream;
|
||||
@property (nonatomic, nullable) id<ConversationViewItem> viewItem;
|
||||
@property (nonatomic, nullable) UIImage *image;
|
||||
|
||||
@property (nonatomic, nullable) OWSVideoPlayer *videoPlayer;
|
||||
@property (nonatomic, nullable) UIButton *playVideoButton;
|
||||
@property (nonatomic, nullable) PlayerProgressBar *videoProgressBar;
|
||||
@property (nonatomic, nullable) UIBarButtonItem *videoPlayBarButton;
|
||||
@property (nonatomic, nullable) UIBarButtonItem *videoPauseBarButton;
|
||||
|
||||
@property (nonatomic, nullable) NSArray<NSLayoutConstraint *> *presentationViewConstraints;
|
||||
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewBottomConstraint;
|
||||
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewLeadingConstraint;
|
||||
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewTopConstraint;
|
||||
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewTrailingConstraint;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation MediaDetailViewController
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[self stopAnyVideo];
|
||||
}
|
||||
|
||||
- (instancetype)initWithGalleryItemBox:(GalleryItemBox *)galleryItemBox
|
||||
viewItem:(nullable id<ConversationViewItem>)viewItem
|
||||
{
|
||||
self = [super initWithNibName:nil bundle:nil];
|
||||
if (!self) {
|
||||
return self;
|
||||
}
|
||||
|
||||
_galleryItemBox = galleryItemBox;
|
||||
_viewItem = viewItem;
|
||||
|
||||
// We cache the image data in case the attachment stream is deleted.
|
||||
__weak MediaDetailViewController *weakSelf = self;
|
||||
_image = [galleryItemBox.attachmentStream
|
||||
thumbnailImageLargeWithSuccess:^(UIImage *image) {
|
||||
weakSelf.image = image;
|
||||
[weakSelf updateContents];
|
||||
[weakSelf updateMinZoomScale];
|
||||
}
|
||||
failure:^{
|
||||
OWSLogWarn(@"Could not load media.");
|
||||
}];
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (TSAttachmentStream *)attachmentStream
|
||||
{
|
||||
return self.galleryItemBox.attachmentStream;
|
||||
}
|
||||
|
||||
- (BOOL)isAnimated
|
||||
{
|
||||
return self.attachmentStream.isAnimated;
|
||||
}
|
||||
|
||||
- (BOOL)isVideo
|
||||
{
|
||||
return self.attachmentStream.isVideo;
|
||||
}
|
||||
|
||||
- (void)viewDidLoad
|
||||
{
|
||||
[super viewDidLoad];
|
||||
|
||||
self.view.backgroundColor = LKColors.navigationBarBackground;
|
||||
|
||||
[self updateContents];
|
||||
|
||||
// Loki: Set navigation bar background color
|
||||
UINavigationBar *navigationBar = self.navigationController.navigationBar;
|
||||
[navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault];
|
||||
navigationBar.shadowImage = [UIImage new];
|
||||
[navigationBar setTranslucent:NO];
|
||||
navigationBar.barTintColor = LKColors.navigationBarBackground;
|
||||
}
|
||||
|
||||
- (void)viewWillAppear:(BOOL)animated
|
||||
{
|
||||
[super viewWillAppear:animated];
|
||||
[self resetMediaFrame];
|
||||
}
|
||||
|
||||
- (void)viewDidLayoutSubviews
|
||||
{
|
||||
[super viewDidLayoutSubviews];
|
||||
|
||||
[self updateMinZoomScale];
|
||||
[self centerMediaViewConstraints];
|
||||
}
|
||||
|
||||
- (void)updateMinZoomScale
|
||||
{
|
||||
if (!self.image) {
|
||||
self.scrollView.minimumZoomScale = 1.f;
|
||||
self.scrollView.maximumZoomScale = 1.f;
|
||||
self.scrollView.zoomScale = 1.f;
|
||||
return;
|
||||
}
|
||||
|
||||
CGSize viewSize = self.scrollView.bounds.size;
|
||||
UIImage *image = self.image;
|
||||
OWSAssertDebug(image);
|
||||
|
||||
if (image.size.width == 0 || image.size.height == 0) {
|
||||
OWSFailDebug(@"Invalid image dimensions. %@", NSStringFromCGSize(image.size));
|
||||
return;
|
||||
}
|
||||
|
||||
CGFloat scaleWidth = viewSize.width / image.size.width;
|
||||
CGFloat scaleHeight = viewSize.height / image.size.height;
|
||||
CGFloat minScale = MIN(scaleWidth, scaleHeight);
|
||||
|
||||
if (minScale != self.scrollView.minimumZoomScale) {
|
||||
self.scrollView.minimumZoomScale = minScale;
|
||||
self.scrollView.maximumZoomScale = minScale * 8;
|
||||
self.scrollView.zoomScale = minScale;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)zoomOutAnimated:(BOOL)isAnimated
|
||||
{
|
||||
if (self.scrollView.zoomScale != self.scrollView.minimumZoomScale) {
|
||||
[self.scrollView setZoomScale:self.scrollView.minimumZoomScale animated:isAnimated];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Initializers
|
||||
|
||||
- (void)updateContents
|
||||
{
|
||||
[self.mediaView removeFromSuperview];
|
||||
[self.scrollView removeFromSuperview];
|
||||
[self.playVideoButton removeFromSuperview];
|
||||
[self.videoProgressBar removeFromSuperview];
|
||||
|
||||
UIScrollView *scrollView = [UIScrollView new];
|
||||
[self.view addSubview:scrollView];
|
||||
self.scrollView = scrollView;
|
||||
scrollView.delegate = self;
|
||||
|
||||
scrollView.showsVerticalScrollIndicator = NO;
|
||||
scrollView.showsHorizontalScrollIndicator = NO;
|
||||
scrollView.decelerationRate = UIScrollViewDecelerationRateFast;
|
||||
|
||||
if (@available(iOS 11.0, *)) {
|
||||
[scrollView contentInsetAdjustmentBehavior];
|
||||
} else {
|
||||
self.automaticallyAdjustsScrollViewInsets = NO;
|
||||
}
|
||||
|
||||
[scrollView ows_autoPinToSuperviewEdges];
|
||||
|
||||
if (self.isAnimated) {
|
||||
if (self.attachmentStream.isValidImage) {
|
||||
YYImage *animatedGif = [YYImage imageWithContentsOfFile:self.attachmentStream.originalFilePath];
|
||||
YYAnimatedImageView *animatedView = [YYAnimatedImageView new];
|
||||
animatedView.image = animatedGif;
|
||||
self.mediaView = animatedView;
|
||||
} else {
|
||||
self.mediaView = [UIView new];
|
||||
self.mediaView.backgroundColor = LKColors.unimportant;
|
||||
}
|
||||
} else if (!self.image) {
|
||||
// Still loading thumbnail.
|
||||
self.mediaView = [UIView new];
|
||||
self.mediaView.backgroundColor = LKColors.unimportant;
|
||||
} else if (self.isVideo) {
|
||||
if (self.attachmentStream.isValidVideo) {
|
||||
self.mediaView = [self buildVideoPlayerView];
|
||||
} else {
|
||||
self.mediaView = [UIView new];
|
||||
self.mediaView.backgroundColor = LKColors.unimportant;
|
||||
}
|
||||
} else {
|
||||
// Present the static image using standard UIImageView
|
||||
UIImageView *imageView = [[UIImageView alloc] initWithImage:self.image];
|
||||
self.mediaView = imageView;
|
||||
}
|
||||
|
||||
OWSAssertDebug(self.mediaView);
|
||||
|
||||
// We add these gestures to mediaView rather than
|
||||
// the root view so that interacting with the video player
|
||||
// progres bar doesn't trigger any of these gestures.
|
||||
[self addGestureRecognizersToView:self.mediaView];
|
||||
|
||||
[scrollView addSubview:self.mediaView];
|
||||
self.mediaViewLeadingConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeLeading];
|
||||
self.mediaViewTopConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTop];
|
||||
self.mediaViewTrailingConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTrailing];
|
||||
self.mediaViewBottomConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeBottom];
|
||||
|
||||
self.mediaView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
self.mediaView.userInteractionEnabled = YES;
|
||||
self.mediaView.clipsToBounds = YES;
|
||||
self.mediaView.layer.allowsEdgeAntialiasing = YES;
|
||||
self.mediaView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
|
||||
// Use trilinear filters for better scaling quality at
|
||||
// some performance cost.
|
||||
self.mediaView.layer.minificationFilter = kCAFilterTrilinear;
|
||||
self.mediaView.layer.magnificationFilter = kCAFilterTrilinear;
|
||||
|
||||
if (self.isVideo) {
|
||||
PlayerProgressBar *videoProgressBar = [PlayerProgressBar new];
|
||||
videoProgressBar.delegate = self;
|
||||
videoProgressBar.player = self.videoPlayer.avPlayer;
|
||||
|
||||
// We hide the progress bar until either:
|
||||
// 1. Video completes playing
|
||||
// 2. User taps the screen
|
||||
videoProgressBar.hidden = YES;
|
||||
|
||||
self.videoProgressBar = videoProgressBar;
|
||||
[self.view addSubview:videoProgressBar];
|
||||
[videoProgressBar autoPinWidthToSuperview];
|
||||
[videoProgressBar autoPinEdgeToSuperviewSafeArea:ALEdgeTop];
|
||||
CGFloat kVideoProgressBarHeight = 44;
|
||||
[videoProgressBar autoSetDimension:ALDimensionHeight toSize:kVideoProgressBarHeight];
|
||||
|
||||
UIButton *playVideoButton = [UIButton new];
|
||||
self.playVideoButton = playVideoButton;
|
||||
|
||||
[playVideoButton addTarget:self action:@selector(playVideo) forControlEvents:UIControlEventTouchUpInside];
|
||||
|
||||
UIImage *playImage = [UIImage imageNamed:@"CirclePlay"];
|
||||
[playVideoButton setBackgroundImage:playImage forState:UIControlStateNormal];
|
||||
playVideoButton.contentMode = UIViewContentModeScaleAspectFill;
|
||||
|
||||
[self.view addSubview:playVideoButton];
|
||||
|
||||
CGFloat playVideoButtonWidth = 72.f;
|
||||
[playVideoButton autoSetDimensionsToSize:CGSizeMake(playVideoButtonWidth, playVideoButtonWidth)];
|
||||
[playVideoButton autoCenterInSuperview];
|
||||
}
|
||||
}
|
||||
|
||||
- (UIView *)buildVideoPlayerView
|
||||
{
|
||||
NSURL *_Nullable attachmentUrl = self.attachmentStream.originalMediaURL;
|
||||
|
||||
NSFileManager *fileManager = [NSFileManager defaultManager];
|
||||
if (![fileManager fileExistsAtPath:[attachmentUrl path]]) {
|
||||
OWSFailDebug(@"Missing video file");
|
||||
}
|
||||
|
||||
OWSVideoPlayer *player = [[OWSVideoPlayer alloc] initWithUrl:attachmentUrl];
|
||||
[player seekToTime:kCMTimeZero];
|
||||
player.delegate = self;
|
||||
self.videoPlayer = player;
|
||||
|
||||
VideoPlayerView *playerView = [VideoPlayerView new];
|
||||
playerView.player = player.avPlayer;
|
||||
|
||||
[NSLayoutConstraint autoSetPriority:UILayoutPriorityDefaultLow
|
||||
forConstraints:^{
|
||||
[playerView autoSetDimensionsToSize:self.image.size];
|
||||
}];
|
||||
|
||||
return playerView;
|
||||
}
|
||||
|
||||
- (void)setShouldHideToolbars:(BOOL)shouldHideToolbars
|
||||
{
|
||||
self.videoProgressBar.hidden = shouldHideToolbars;
|
||||
}
|
||||
|
||||
- (void)addGestureRecognizersToView:(UIView *)view
|
||||
{
|
||||
UITapGestureRecognizer *doubleTap =
|
||||
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didDoubleTapImage:)];
|
||||
doubleTap.numberOfTapsRequired = 2;
|
||||
[view addGestureRecognizer:doubleTap];
|
||||
|
||||
UITapGestureRecognizer *singleTap =
|
||||
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didSingleTapImage:)];
|
||||
[singleTap requireGestureRecognizerToFail:doubleTap];
|
||||
[view addGestureRecognizer:singleTap];
|
||||
}
|
||||
|
||||
#pragma mark - Gesture Recognizers
|
||||
|
||||
- (void)didSingleTapImage:(UITapGestureRecognizer *)gesture
|
||||
{
|
||||
[self.delegate mediaDetailViewControllerDidTapMedia:self];
|
||||
}
|
||||
|
||||
- (void)didDoubleTapImage:(UITapGestureRecognizer *)gesture
|
||||
{
|
||||
OWSLogVerbose(@"did double tap image.");
|
||||
if (self.scrollView.zoomScale == self.scrollView.minimumZoomScale) {
|
||||
CGFloat kDoubleTapZoomScale = 2;
|
||||
|
||||
CGFloat zoomWidth = self.scrollView.width / kDoubleTapZoomScale;
|
||||
CGFloat zoomHeight = self.scrollView.height / kDoubleTapZoomScale;
|
||||
|
||||
// center zoom rect around tapLocation
|
||||
CGPoint tapLocation = [gesture locationInView:self.scrollView];
|
||||
CGFloat zoomX = MAX(0, tapLocation.x - zoomWidth / 2);
|
||||
CGFloat zoomY = MAX(0, tapLocation.y - zoomHeight / 2);
|
||||
|
||||
CGRect zoomRect = CGRectMake(zoomX, zoomY, zoomWidth, zoomHeight);
|
||||
|
||||
CGRect translatedRect = [self.mediaView convertRect:zoomRect fromView:self.scrollView];
|
||||
|
||||
[self.scrollView zoomToRect:translatedRect animated:YES];
|
||||
} else {
|
||||
// If already zoomed in at all, zoom out all the way.
|
||||
[self zoomOutAnimated:YES];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)didPressPlayBarButton:(id)sender
|
||||
{
|
||||
OWSAssertDebug(self.isVideo);
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
[self playVideo];
|
||||
}
|
||||
|
||||
- (void)didPressPauseBarButton:(id)sender
|
||||
{
|
||||
OWSAssertDebug(self.isVideo);
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
[self pauseVideo];
|
||||
}
|
||||
|
||||
#pragma mark - UIScrollViewDelegate
|
||||
|
||||
- (nullable UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
|
||||
{
|
||||
return self.mediaView;
|
||||
}
|
||||
|
||||
- (void)centerMediaViewConstraints
|
||||
{
|
||||
OWSAssertDebug(self.scrollView);
|
||||
|
||||
CGSize scrollViewSize = self.scrollView.bounds.size;
|
||||
CGSize imageViewSize = self.mediaView.frame.size;
|
||||
|
||||
CGFloat yOffset = MAX(0, (scrollViewSize.height - imageViewSize.height) / 2);
|
||||
self.mediaViewTopConstraint.constant = yOffset;
|
||||
self.mediaViewBottomConstraint.constant = yOffset;
|
||||
|
||||
CGFloat xOffset = MAX(0, (scrollViewSize.width - imageViewSize.width) / 2);
|
||||
self.mediaViewLeadingConstraint.constant = xOffset;
|
||||
self.mediaViewTrailingConstraint.constant = xOffset;
|
||||
}
|
||||
|
||||
- (void)scrollViewDidZoom:(UIScrollView *)scrollView
|
||||
{
|
||||
[self centerMediaViewConstraints];
|
||||
[self.view layoutIfNeeded];
|
||||
}
|
||||
|
||||
- (void)resetMediaFrame
|
||||
{
|
||||
// HACK: Setting the frame to itself *seems* like it should be a no-op, but
|
||||
// it ensures the content is drawn at the right frame. In particular I was
|
||||
// reproducibly seeing some images squished (they were EXIF rotated, maybe
|
||||
// related). similar to this report:
|
||||
// https://stackoverflow.com/questions/27961884/swift-uiimageview-stretched-aspect
|
||||
[self.view layoutIfNeeded];
|
||||
self.mediaView.frame = self.mediaView.frame;
|
||||
}
|
||||
|
||||
#pragma mark - Video Playback
|
||||
|
||||
- (void)playVideo
|
||||
{
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
|
||||
self.playVideoButton.hidden = YES;
|
||||
|
||||
[self.videoPlayer play];
|
||||
|
||||
[self.delegate mediaDetailViewController:self isPlayingVideo:YES];
|
||||
}
|
||||
|
||||
- (void)pauseVideo
|
||||
{
|
||||
OWSAssertDebug(self.isVideo);
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
|
||||
[self.videoPlayer pause];
|
||||
|
||||
[self.delegate mediaDetailViewController:self isPlayingVideo:NO];
|
||||
}
|
||||
|
||||
- (void)stopAnyVideo
|
||||
{
|
||||
if (self.isVideo) {
|
||||
[self stopVideo];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)stopVideo
|
||||
{
|
||||
OWSAssertDebug(self.isVideo);
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
|
||||
[self.videoPlayer stop];
|
||||
|
||||
self.playVideoButton.hidden = NO;
|
||||
|
||||
[self.delegate mediaDetailViewController:self isPlayingVideo:NO];
|
||||
}
|
||||
|
||||
#pragma mark - OWSVideoPlayer
|
||||
|
||||
- (void)videoPlayerDidPlayToCompletion:(OWSVideoPlayer *)videoPlayer
|
||||
{
|
||||
OWSAssertDebug(self.isVideo);
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
OWSLogVerbose(@"");
|
||||
|
||||
[self stopVideo];
|
||||
}
|
||||
|
||||
#pragma mark - PlayerProgressBarDelegate
|
||||
|
||||
- (void)playerProgressBarDidStartScrubbing:(PlayerProgressBar *)playerProgressBar
|
||||
{
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
[self.videoPlayer pause];
|
||||
}
|
||||
|
||||
- (void)playerProgressBar:(PlayerProgressBar *)playerProgressBar scrubbedToTime:(CMTime)time
|
||||
{
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
[self.videoPlayer seekToTime:time];
|
||||
}
|
||||
|
||||
- (void)playerProgressBar:(PlayerProgressBar *)playerProgressBar
|
||||
didFinishScrubbingAtTime:(CMTime)time
|
||||
shouldResumePlayback:(BOOL)shouldResumePlayback
|
||||
{
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
[self.videoPlayer seekToTime:time];
|
||||
|
||||
if (shouldResumePlayback) {
|
||||
[self.videoPlayer play];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Saving images to Camera Roll
|
||||
|
||||
- (void)image:(UIImage *)image didFinishSavingWithError:(NSError *)error contextInfo:(void *)contextInfo
|
||||
{
|
||||
if (error) {
|
||||
OWSLogWarn(@"There was a problem saving <%@> to camera roll.", error.localizedDescription);
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,444 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import YYImage
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
|
||||
public enum MediaGalleryOption {
|
||||
case sliderEnabled
|
||||
case showAllMediaButton
|
||||
}
|
||||
|
||||
class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVideoPlayerDelegate, PlayerProgressBarDelegate {
|
||||
public let galleryItem: MediaGalleryViewModel.Item
|
||||
public weak var delegate: MediaDetailViewControllerDelegate?
|
||||
private var image: UIImage?
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private var mediaViewBottomConstraint: NSLayoutConstraint?
|
||||
private var mediaViewLeadingConstraint: NSLayoutConstraint?
|
||||
private var mediaViewTopConstraint: NSLayoutConstraint?
|
||||
private var mediaViewTrailingConstraint: NSLayoutConstraint?
|
||||
|
||||
private lazy var scrollView: UIScrollView = {
|
||||
let result: UIScrollView = UIScrollView()
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.showsHorizontalScrollIndicator = false
|
||||
result.contentInsetAdjustmentBehavior = .never
|
||||
result.decelerationRate = .fast
|
||||
result.delegate = self
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
public var mediaView: UIView = UIView()
|
||||
private var playVideoButton: UIButton = UIButton()
|
||||
private var videoProgressBar: PlayerProgressBar = PlayerProgressBar()
|
||||
private var videoPlayer: OWSVideoPlayer?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
galleryItem: MediaGalleryViewModel.Item,
|
||||
delegate: MediaDetailViewControllerDelegate? = nil
|
||||
) {
|
||||
self.galleryItem = galleryItem
|
||||
self.delegate = delegate
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
// We cache the image data in case the attachment stream is deleted.
|
||||
galleryItem.attachment.thumbnail(
|
||||
size: .large,
|
||||
success: { [weak self] image, _ in
|
||||
// Only reload the content if the view has already loaded (if it
|
||||
// hasn't then it'll load with the image immediately)
|
||||
let updateUICallback = {
|
||||
self?.image = image
|
||||
|
||||
if self?.isViewLoaded == true {
|
||||
self?.updateContents()
|
||||
self?.updateMinZoomScale()
|
||||
}
|
||||
}
|
||||
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
updateUICallback()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
updateUICallback()
|
||||
},
|
||||
failure: {
|
||||
SNLog("Could not load media.")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.stopAnyVideo()
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.view.backgroundColor = Colors.navigationBarBackground
|
||||
|
||||
self.view.addSubview(scrollView)
|
||||
scrollView.pin(to: self.view)
|
||||
|
||||
self.updateContents()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
self.resetMediaFrame()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
if mediaView is YYAnimatedImageView {
|
||||
// Add a slight delay before starting the gif animation to prevent it from looking
|
||||
// buggy due to the custom transition
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { [weak self] in
|
||||
(self?.mediaView as? YYAnimatedImageView)?.startAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
self.updateMinZoomScale()
|
||||
self.centerMediaViewConstraints()
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
private func updateMinZoomScale() {
|
||||
let maybeImageSize: CGSize? = {
|
||||
switch self.mediaView {
|
||||
case let imageView as UIImageView: return (imageView.image?.size ?? .zero)
|
||||
case let imageView as YYAnimatedImageView: return (imageView.image?.size ?? .zero)
|
||||
default: return nil
|
||||
}
|
||||
}()
|
||||
|
||||
guard let imageSize: CGSize = maybeImageSize else {
|
||||
self.scrollView.minimumZoomScale = 1
|
||||
self.scrollView.maximumZoomScale = 1
|
||||
self.scrollView.zoomScale = 1
|
||||
return
|
||||
}
|
||||
|
||||
let viewSize: CGSize = self.scrollView.bounds.size
|
||||
|
||||
guard imageSize.width > 0 && imageSize.height > 0 else {
|
||||
SNLog("Invalid image dimensions (\(imageSize.width), \(imageSize.height))")
|
||||
return
|
||||
}
|
||||
|
||||
let scaleWidth: CGFloat = (viewSize.width / imageSize.width)
|
||||
let scaleHeight: CGFloat = (viewSize.height / imageSize.height)
|
||||
let minScale: CGFloat = min(scaleWidth, scaleHeight)
|
||||
|
||||
if minScale != self.scrollView.minimumZoomScale {
|
||||
self.scrollView.minimumZoomScale = minScale
|
||||
self.scrollView.maximumZoomScale = (minScale * 8)
|
||||
self.scrollView.zoomScale = minScale
|
||||
}
|
||||
}
|
||||
|
||||
public func zoomOut(animated: Bool) {
|
||||
if self.scrollView.zoomScale != self.scrollView.minimumZoomScale {
|
||||
self.scrollView.setZoomScale(self.scrollView.minimumZoomScale, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
private func updateContents() {
|
||||
self.mediaView.removeFromSuperview()
|
||||
self.playVideoButton.removeFromSuperview()
|
||||
self.videoProgressBar.removeFromSuperview()
|
||||
self.scrollView.zoomScale = 1
|
||||
|
||||
if self.galleryItem.attachment.isAnimated {
|
||||
if self.galleryItem.attachment.isValid, let originalFilePath: String = self.galleryItem.attachment.originalFilePath {
|
||||
let animatedView: YYAnimatedImageView = YYAnimatedImageView()
|
||||
animatedView.autoPlayAnimatedImage = false
|
||||
animatedView.image = YYImage(contentsOfFile: originalFilePath)
|
||||
self.mediaView = animatedView
|
||||
}
|
||||
else {
|
||||
self.mediaView = UIView()
|
||||
self.mediaView.backgroundColor = Colors.unimportant
|
||||
}
|
||||
}
|
||||
else if self.image == nil {
|
||||
// Still loading thumbnail.
|
||||
self.mediaView = UIView()
|
||||
self.mediaView.backgroundColor = Colors.unimportant
|
||||
}
|
||||
else if self.galleryItem.attachment.isVideo {
|
||||
if self.galleryItem.attachment.isValid {
|
||||
self.mediaView = self.buildVideoPlayerView()
|
||||
}
|
||||
else {
|
||||
self.mediaView = UIView()
|
||||
self.mediaView.backgroundColor = Colors.unimportant
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Present the static image using standard UIImageView
|
||||
self.mediaView = UIImageView(image: self.image)
|
||||
}
|
||||
|
||||
// We add these gestures to mediaView rather than
|
||||
// the root view so that interacting with the video player
|
||||
// progres bar doesn't trigger any of these gestures.
|
||||
self.addGestureRecognizers(to: self.mediaView)
|
||||
self.scrollView.addSubview(self.mediaView)
|
||||
|
||||
self.mediaViewLeadingConstraint = self.mediaView.pin(.leading, to: .leading, of: self.scrollView)
|
||||
self.mediaViewTopConstraint = self.mediaView.pin(.top, to: .top, of: self.scrollView)
|
||||
self.mediaViewTrailingConstraint = self.mediaView.pin(.trailing, to: .trailing, of: self.scrollView)
|
||||
self.mediaViewBottomConstraint = self.mediaView.pin(.bottom, to: .bottom, of: self.scrollView)
|
||||
|
||||
self.mediaView.contentMode = .scaleAspectFit
|
||||
self.mediaView.isUserInteractionEnabled = true
|
||||
self.mediaView.clipsToBounds = true
|
||||
self.mediaView.layer.allowsEdgeAntialiasing = true
|
||||
self.mediaView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// Use trilinear filters for better scaling quality at
|
||||
// some performance cost.
|
||||
self.mediaView.layer.minificationFilter = .trilinear
|
||||
self.mediaView.layer.magnificationFilter = .trilinear
|
||||
|
||||
if self.galleryItem.attachment.isVideo {
|
||||
self.videoProgressBar = PlayerProgressBar()
|
||||
self.videoProgressBar.delegate = self
|
||||
self.videoProgressBar.player = self.videoPlayer?.avPlayer
|
||||
|
||||
// We hide the progress bar until either:
|
||||
// 1. Video completes playing
|
||||
// 2. User taps the screen
|
||||
self.videoProgressBar.isHidden = false
|
||||
|
||||
self.view.addSubview(self.videoProgressBar)
|
||||
|
||||
self.videoProgressBar.autoPinWidthToSuperview()
|
||||
self.videoProgressBar.autoPinEdge(toSuperviewSafeArea: .top)
|
||||
self.videoProgressBar.autoSetDimension(.height, toSize: 44)
|
||||
|
||||
self.playVideoButton = UIButton()
|
||||
self.playVideoButton.contentMode = .scaleAspectFill
|
||||
self.playVideoButton.setBackgroundImage(UIImage(named: "CirclePlay"), for: .normal)
|
||||
self.playVideoButton.addTarget(self, action: #selector(playVideo), for: .touchUpInside)
|
||||
self.view.addSubview(self.playVideoButton)
|
||||
|
||||
self.playVideoButton.set(.width, to: 72)
|
||||
self.playVideoButton.set(.height, to: 72)
|
||||
self.playVideoButton.center(in: self.view)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildVideoPlayerView() -> UIView {
|
||||
guard
|
||||
let originalFilePath: String = self.galleryItem.attachment.originalFilePath,
|
||||
FileManager.default.fileExists(atPath: originalFilePath)
|
||||
else {
|
||||
owsFailDebug("Missing video file")
|
||||
return UIView()
|
||||
}
|
||||
|
||||
self.videoPlayer = OWSVideoPlayer(url: URL(fileURLWithPath: originalFilePath))
|
||||
self.videoPlayer?.seek(to: .zero)
|
||||
self.videoPlayer?.delegate = self
|
||||
|
||||
let imageSize: CGSize = (self.image?.size ?? .zero)
|
||||
let playerView: VideoPlayerView = VideoPlayerView()
|
||||
playerView.player = self.videoPlayer?.avPlayer
|
||||
|
||||
NSLayoutConstraint.autoSetPriority(.defaultLow) {
|
||||
playerView.autoSetDimensions(to: imageSize)
|
||||
}
|
||||
|
||||
return playerView
|
||||
}
|
||||
|
||||
public func setShouldHideToolbars(_ shouldHideToolbars: Bool) {
|
||||
self.videoProgressBar.isHidden = shouldHideToolbars
|
||||
}
|
||||
|
||||
private func addGestureRecognizers(to view: UIView) {
|
||||
let doubleTap: UITapGestureRecognizer = UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(didDoubleTapImage(_:))
|
||||
)
|
||||
doubleTap.numberOfTapsRequired = 2
|
||||
view.addGestureRecognizer(doubleTap)
|
||||
|
||||
let singleTap: UITapGestureRecognizer = UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(didSingleTapImage(_:))
|
||||
)
|
||||
singleTap.require(toFail: doubleTap)
|
||||
view.addGestureRecognizer(singleTap)
|
||||
}
|
||||
|
||||
// MARK: - Gesture Recognizers
|
||||
|
||||
@objc private func didSingleTapImage(_ gesture: UITapGestureRecognizer) {
|
||||
self.delegate?.mediaDetailViewControllerDidTapMedia(self)
|
||||
}
|
||||
|
||||
@objc private func didDoubleTapImage(_ gesture: UITapGestureRecognizer) {
|
||||
guard self.scrollView.zoomScale == self.scrollView.minimumZoomScale else {
|
||||
// If already zoomed in at all, zoom out all the way.
|
||||
self.zoomOut(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
let doubleTapZoomScale: CGFloat = 2
|
||||
let zoomWidth: CGFloat = (self.scrollView.bounds.width / doubleTapZoomScale)
|
||||
let zoomHeight: CGFloat = (self.scrollView.bounds.height / doubleTapZoomScale)
|
||||
|
||||
// Center zoom rect around tapLocation
|
||||
let tapLocation: CGPoint = gesture.location(in: self.scrollView)
|
||||
let zoomX: CGFloat = max(0, tapLocation.x - zoomWidth / 2)
|
||||
let zoomY: CGFloat = max(0, tapLocation.y - zoomHeight / 2)
|
||||
let zoomRect: CGRect = CGRect(x: zoomX, y: zoomY, width: zoomWidth, height: zoomHeight)
|
||||
let translatedRect: CGRect = self.mediaView.convert(zoomRect, to: self.scrollView)
|
||||
|
||||
self.scrollView.zoom(to: translatedRect, animated: true)
|
||||
}
|
||||
|
||||
@objc public func didPressPlayBarButton() {
|
||||
self.playVideo()
|
||||
}
|
||||
|
||||
@objc public func didPressPauseBarButton() {
|
||||
self.pauseVideo()
|
||||
}
|
||||
|
||||
// MARK: - UIScrollViewDelegate
|
||||
|
||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
return self.mediaView
|
||||
}
|
||||
|
||||
private func centerMediaViewConstraints() {
|
||||
let scrollViewSize: CGSize = self.scrollView.bounds.size
|
||||
let imageViewSize: CGSize = self.mediaView.frame.size
|
||||
|
||||
// We want to modify the yOffset so the content remains centered on the screen (we can do this
|
||||
// by subtracting half the parentViewController's y position)
|
||||
//
|
||||
// Note: Due to weird partial-pixel value rendering behaviours we need to round the inset either
|
||||
// up or down depending on which direction the partial-pixel would end up rounded to make it
|
||||
// align correctly
|
||||
let halfHeightDiff: CGFloat = ((self.scrollView.bounds.size.height - self.mediaView.frame.size.height) / 2)
|
||||
let shouldRoundUp: Bool = (round(halfHeightDiff) - halfHeightDiff > 0)
|
||||
|
||||
let yOffset: CGFloat = (
|
||||
round((scrollViewSize.height - imageViewSize.height) / 2) -
|
||||
(shouldRoundUp ?
|
||||
ceil((self.parent?.view.frame.origin.y ?? 0) / 2) :
|
||||
floor((self.parent?.view.frame.origin.y ?? 0) / 2)
|
||||
)
|
||||
)
|
||||
|
||||
self.mediaViewTopConstraint?.constant = yOffset
|
||||
self.mediaViewBottomConstraint?.constant = yOffset
|
||||
|
||||
let xOffset: CGFloat = max(0, (scrollViewSize.width - imageViewSize.width) / 2)
|
||||
self.mediaViewLeadingConstraint?.constant = xOffset
|
||||
self.mediaViewTrailingConstraint?.constant = xOffset
|
||||
}
|
||||
|
||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
self.centerMediaViewConstraints()
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
private func resetMediaFrame() {
|
||||
// HACK: Setting the frame to itself *seems* like it should be a no-op, but
|
||||
// it ensures the content is drawn at the right frame. In particular I was
|
||||
// reproducibly seeing some images squished (they were EXIF rotated, maybe
|
||||
// related). similar to this report:
|
||||
// https://stackoverflow.com/questions/27961884/swift-uiimageview-stretched-aspect
|
||||
self.view.layoutIfNeeded()
|
||||
self.mediaView.frame = self.mediaView.frame
|
||||
}
|
||||
|
||||
// MARK: - Video Playback
|
||||
|
||||
@objc public func playVideo() {
|
||||
self.playVideoButton.isHidden = true
|
||||
self.videoPlayer?.play()
|
||||
self.delegate?.mediaDetailViewController(self, isPlayingVideo: true)
|
||||
}
|
||||
|
||||
private func pauseVideo() {
|
||||
self.videoPlayer?.pause()
|
||||
self.delegate?.mediaDetailViewController(self, isPlayingVideo: false)
|
||||
}
|
||||
|
||||
public func stopAnyVideo() {
|
||||
guard self.galleryItem.attachment.isVideo else { return }
|
||||
|
||||
self.stopVideo()
|
||||
}
|
||||
|
||||
private func stopVideo() {
|
||||
self.videoPlayer?.stop()
|
||||
self.playVideoButton.isHidden = false
|
||||
self.delegate?.mediaDetailViewController(self, isPlayingVideo: false)
|
||||
}
|
||||
|
||||
// MARK: - OWSVideoPlayerDelegate
|
||||
|
||||
func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) {
|
||||
self.stopVideo()
|
||||
}
|
||||
|
||||
// MARK: - PlayerProgressBarDelegate
|
||||
|
||||
func playerProgressBarDidStartScrubbing(_ playerProgressBar: PlayerProgressBar) {
|
||||
self.videoPlayer?.pause()
|
||||
}
|
||||
|
||||
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, scrubbedToTime time: CMTime) {
|
||||
self.videoPlayer?.seek(to: time)
|
||||
}
|
||||
|
||||
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, didFinishScrubbingAtTime time: CMTime, shouldResumePlayback: Bool) {
|
||||
self.videoPlayer?.seek(to: time)
|
||||
|
||||
if shouldResumePlayback {
|
||||
self.videoPlayer?.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MediaDetailViewControllerDelegate
|
||||
|
||||
protocol MediaDetailViewControllerDelegate: AnyObject {
|
||||
func mediaDetailViewController(_ mediaDetailViewController: MediaDetailViewController, isPlayingVideo: Bool)
|
||||
func mediaDetailViewControllerDidTapMedia(_ mediaDetailViewController: MediaDetailViewController)
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUIKit
|
||||
|
||||
class MediaGalleryNavigationController: OWSNavigationController {
|
||||
// HACK: Though we don't have an input accessory view, the VC we are presented above (ConversationVC) does.
|
||||
// If the app is backgrounded and then foregrounded, when OWSWindowManager calls mainWindow.makeKeyAndVisible
|
||||
// the ConversationVC's inputAccessoryView will appear *above* us unless we'd previously become first responder.
|
||||
override public var canBecomeFirstResponder: Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var backgroundView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = Colors.navigationBarBackground
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - View Lifecycle
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return (isLightMode ? .default : .lightContent)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
guard let navigationBar = self.navigationBar as? OWSNavigationBar else {
|
||||
owsFailDebug("navigationBar had unexpected class: \(self.navigationBar)")
|
||||
return
|
||||
}
|
||||
|
||||
view.backgroundColor = Colors.navigationBarBackground
|
||||
|
||||
navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default)
|
||||
navigationBar.shadowImage = UIImage()
|
||||
navigationBar.isTranslucent = false
|
||||
navigationBar.barTintColor = Colors.navigationBarBackground
|
||||
|
||||
// Insert a view to ensure the nav bar colour goes to the top of the screen
|
||||
relayoutBackgroundView()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
// If the user's device is already rotated, try to respect that by rotating to landscape now
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
}
|
||||
|
||||
// MARK: - Orientation
|
||||
|
||||
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
return .allButUpsideDown
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
private func relayoutBackgroundView() {
|
||||
guard !backgroundView.isHidden else {
|
||||
backgroundView.removeFromSuperview()
|
||||
return
|
||||
}
|
||||
|
||||
view.insertSubview(backgroundView, belowSubview: navigationBar)
|
||||
|
||||
backgroundView.pin(.top, to: .top, of: view)
|
||||
backgroundView.pin(.left, to: .left, of: navigationBar)
|
||||
backgroundView.pin(.right, to: .right, of: navigationBar)
|
||||
backgroundView.pin(.bottom, to: .bottom, of: navigationBar)
|
||||
}
|
||||
|
||||
override func setNavigationBarHidden(_ hidden: Bool, animated: Bool) {
|
||||
super.setNavigationBarHidden(hidden, animated: animated)
|
||||
|
||||
backgroundView.isHidden = hidden
|
||||
relayoutBackgroundView()
|
||||
}
|
||||
}
|
|
@ -1,903 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum GalleryDirection {
|
||||
case before, after, around
|
||||
}
|
||||
|
||||
class MediaGalleryAlbum {
|
||||
|
||||
private var originalItems: [MediaGalleryItem]
|
||||
var items: [MediaGalleryItem] {
|
||||
get {
|
||||
guard let mediaGalleryDataSource = self.mediaGalleryDataSource else {
|
||||
owsFailDebug("mediaGalleryDataSource was unexpectedly nil")
|
||||
return originalItems
|
||||
}
|
||||
|
||||
return originalItems.filter { !mediaGalleryDataSource.deletedGalleryItems.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
weak var mediaGalleryDataSource: MediaGalleryDataSource?
|
||||
|
||||
init(items: [MediaGalleryItem]) {
|
||||
self.originalItems = items
|
||||
}
|
||||
|
||||
func add(item: MediaGalleryItem) {
|
||||
guard !originalItems.contains(item) else {
|
||||
return
|
||||
}
|
||||
|
||||
originalItems.append(item)
|
||||
originalItems.sort { (lhs, rhs) -> Bool in
|
||||
return lhs.albumIndex < rhs.albumIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class MediaGalleryItem: Equatable, Hashable {
|
||||
let message: TSMessage
|
||||
let attachmentStream: TSAttachmentStream
|
||||
let galleryDate: GalleryDate
|
||||
let captionForDisplay: String?
|
||||
let albumIndex: Int
|
||||
var album: MediaGalleryAlbum?
|
||||
let orderingKey: MediaGalleryItemOrderingKey
|
||||
|
||||
init(message: TSMessage, attachmentStream: TSAttachmentStream) {
|
||||
self.message = message
|
||||
self.attachmentStream = attachmentStream
|
||||
self.captionForDisplay = attachmentStream.caption?.filterForDisplay
|
||||
self.galleryDate = GalleryDate(message: message)
|
||||
self.albumIndex = message.attachmentIds.index(of: attachmentStream.uniqueId!)
|
||||
self.orderingKey = MediaGalleryItemOrderingKey(messageSortKey: message.sortId, attachmentSortKey: albumIndex)
|
||||
}
|
||||
|
||||
var isVideo: Bool {
|
||||
return attachmentStream.isVideo
|
||||
}
|
||||
|
||||
var isAnimated: Bool {
|
||||
return attachmentStream.isAnimated
|
||||
}
|
||||
|
||||
var isImage: Bool {
|
||||
return attachmentStream.isImage
|
||||
}
|
||||
|
||||
var imageSize: CGSize {
|
||||
return attachmentStream.imageSize()
|
||||
}
|
||||
|
||||
public typealias AsyncThumbnailBlock = (UIImage) -> Void
|
||||
func thumbnailImage(async:@escaping AsyncThumbnailBlock) -> UIImage? {
|
||||
return attachmentStream.thumbnailImageSmall(success: async, failure: {})
|
||||
}
|
||||
|
||||
// MARK: Equatable
|
||||
|
||||
public static func == (lhs: MediaGalleryItem, rhs: MediaGalleryItem) -> Bool {
|
||||
return lhs.attachmentStream.uniqueId == rhs.attachmentStream.uniqueId
|
||||
}
|
||||
|
||||
// MARK: Hashable
|
||||
|
||||
public var hashValue: Int {
|
||||
return attachmentStream.uniqueId?.hashValue ?? attachmentStream.hashValue
|
||||
}
|
||||
|
||||
// MARK: Sorting
|
||||
|
||||
struct MediaGalleryItemOrderingKey: Comparable {
|
||||
let messageSortKey: UInt64
|
||||
let attachmentSortKey: Int
|
||||
|
||||
// MARK: Comparable
|
||||
|
||||
static func < (lhs: MediaGalleryItem.MediaGalleryItemOrderingKey, rhs: MediaGalleryItem.MediaGalleryItemOrderingKey) -> Bool {
|
||||
if lhs.messageSortKey < rhs.messageSortKey {
|
||||
return true
|
||||
}
|
||||
|
||||
if lhs.messageSortKey == rhs.messageSortKey {
|
||||
if lhs.attachmentSortKey < rhs.attachmentSortKey {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct GalleryDate: Hashable, Comparable, Equatable {
|
||||
let year: Int
|
||||
let month: Int
|
||||
|
||||
init(message: TSMessage) {
|
||||
let date = message.dateForUI()
|
||||
|
||||
self.year = Calendar.current.component(.year, from: date)
|
||||
self.month = Calendar.current.component(.month, from: date)
|
||||
}
|
||||
|
||||
init(year: Int, month: Int) {
|
||||
assert(month >= 1 && month <= 12)
|
||||
|
||||
self.year = year
|
||||
self.month = month
|
||||
}
|
||||
|
||||
private var isThisMonth: Bool {
|
||||
let now = Date()
|
||||
let year = Calendar.current.component(.year, from: now)
|
||||
let month = Calendar.current.component(.month, from: now)
|
||||
let thisMonth = GalleryDate(year: year, month: month)
|
||||
|
||||
return self == thisMonth
|
||||
}
|
||||
|
||||
public var date: Date {
|
||||
var components = DateComponents()
|
||||
components.month = self.month
|
||||
components.year = self.year
|
||||
|
||||
return Calendar.current.date(from: components)!
|
||||
}
|
||||
|
||||
private var isThisYear: Bool {
|
||||
let now = Date()
|
||||
let thisYear = Calendar.current.component(.year, from: now)
|
||||
|
||||
return self.year == thisYear
|
||||
}
|
||||
|
||||
static let thisYearFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM"
|
||||
|
||||
return formatter
|
||||
}()
|
||||
|
||||
static let olderFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
|
||||
// FIXME localize for RTL, or is there a built in way to do this?
|
||||
formatter.dateFormat = "MMMM yyyy"
|
||||
|
||||
return formatter
|
||||
}()
|
||||
|
||||
var localizedString: String {
|
||||
if isThisMonth {
|
||||
return NSLocalizedString("MEDIA_GALLERY_THIS_MONTH_HEADER", comment: "Section header in media gallery collection view")
|
||||
} else if isThisYear {
|
||||
return type(of: self).thisYearFormatter.string(from: self.date)
|
||||
} else {
|
||||
return type(of: self).olderFormatter.string(from: self.date)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Hashable
|
||||
|
||||
public var hashValue: Int {
|
||||
return month.hashValue ^ year.hashValue
|
||||
}
|
||||
|
||||
// MARK: Comparable
|
||||
|
||||
public static func < (lhs: GalleryDate, rhs: GalleryDate) -> Bool {
|
||||
if lhs.year != rhs.year {
|
||||
return lhs.year < rhs.year
|
||||
} else if lhs.month != rhs.month {
|
||||
return lhs.month < rhs.month
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Equatable
|
||||
|
||||
public static func == (lhs: GalleryDate, rhs: GalleryDate) -> Bool {
|
||||
return lhs.month == rhs.month && lhs.year == rhs.year
|
||||
}
|
||||
}
|
||||
|
||||
protocol MediaGalleryDataSource: class {
|
||||
var hasFetchedOldest: Bool { get }
|
||||
var hasFetchedMostRecent: Bool { get }
|
||||
|
||||
var galleryItems: [MediaGalleryItem] { get }
|
||||
var galleryItemCount: Int { get }
|
||||
|
||||
var sections: [GalleryDate: [MediaGalleryItem]] { get }
|
||||
var sectionDates: [GalleryDate] { get }
|
||||
|
||||
var deletedAttachments: Set<TSAttachment> { get }
|
||||
var deletedGalleryItems: Set<MediaGalleryItem> { get }
|
||||
|
||||
func ensureGalleryItemsLoaded(_ direction: GalleryDirection, item: MediaGalleryItem, amount: UInt, completion: ((IndexSet, [IndexPath]) -> Void)?)
|
||||
|
||||
func galleryItem(before currentItem: MediaGalleryItem) -> MediaGalleryItem?
|
||||
func galleryItem(after currentItem: MediaGalleryItem) -> MediaGalleryItem?
|
||||
|
||||
func showAllMedia(focusedItem: MediaGalleryItem)
|
||||
func dismissMediaDetailViewController(_ mediaDetailViewController: MediaPageViewController, animated isAnimated: Bool, completion: (() -> Void)?)
|
||||
|
||||
func delete(items: [MediaGalleryItem], initiatedBy: AnyObject)
|
||||
}
|
||||
|
||||
protocol MediaGalleryDataSourceDelegate: class {
|
||||
func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject)
|
||||
func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, deletedSections: IndexSet, deletedItems: [IndexPath])
|
||||
}
|
||||
|
||||
class MediaGalleryNavigationController: OWSNavigationController {
|
||||
|
||||
var retainUntilDismissed: MediaGallery?
|
||||
|
||||
// HACK: Though we don't have an input accessory view, the VC we are presented above (ConversationVC) does.
|
||||
// If the app is backgrounded and then foregrounded, when OWSWindowManager calls mainWindow.makeKeyAndVisible
|
||||
// the ConversationVC's inputAccessoryView will appear *above* us unless we'd previously become first responder.
|
||||
override public var canBecomeFirstResponder: Bool {
|
||||
Logger.debug("")
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: View Lifecycle
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return isLightMode ? .default : .lightContent
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
guard let navigationBar = self.navigationBar as? OWSNavigationBar else {
|
||||
owsFailDebug("navigationBar had unexpected class: \(self.navigationBar)")
|
||||
return
|
||||
}
|
||||
|
||||
view.backgroundColor = Colors.navigationBarBackground
|
||||
|
||||
navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default)
|
||||
navigationBar.shadowImage = UIImage()
|
||||
navigationBar.isTranslucent = false
|
||||
navigationBar.barTintColor = Colors.navigationBarBackground
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
// If the user's device is already rotated, try to respect that by rotating to landscape now
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
}
|
||||
|
||||
// MARK: Orientation
|
||||
|
||||
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
return .allButUpsideDown
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
class MediaGallery: NSObject, MediaGalleryDataSource, MediaTileViewControllerDelegate {
|
||||
|
||||
@objc
|
||||
weak public var navigationController: MediaGalleryNavigationController!
|
||||
|
||||
var deletedAttachments: Set<TSAttachment> = Set()
|
||||
var deletedGalleryItems: Set<MediaGalleryItem> = Set()
|
||||
|
||||
private var pageViewController: MediaPageViewController?
|
||||
|
||||
private var uiDatabaseConnection: YapDatabaseConnection {
|
||||
return OWSPrimaryStorage.shared().uiDatabaseConnection
|
||||
}
|
||||
|
||||
private let editingDatabaseConnection: YapDatabaseConnection
|
||||
private let mediaGalleryFinder: OWSMediaGalleryFinder
|
||||
|
||||
private var initialDetailItem: MediaGalleryItem?
|
||||
private let thread: TSThread
|
||||
private let options: MediaGalleryOption
|
||||
|
||||
// we start with a small range size for quick loading.
|
||||
private let fetchRangeSize: UInt = 10
|
||||
|
||||
deinit {
|
||||
Logger.debug("")
|
||||
}
|
||||
|
||||
@objc
|
||||
init(thread: TSThread, options: MediaGalleryOption = []) {
|
||||
self.thread = thread
|
||||
|
||||
self.editingDatabaseConnection = OWSPrimaryStorage.shared().newDatabaseConnection()
|
||||
|
||||
self.options = options
|
||||
self.mediaGalleryFinder = OWSMediaGalleryFinder(thread: thread)
|
||||
super.init()
|
||||
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(uiDatabaseDidUpdate),
|
||||
name: .OWSUIDatabaseConnectionDidUpdate,
|
||||
object: OWSPrimaryStorage.shared().dbNotificationObject)
|
||||
}
|
||||
|
||||
// MARK: Present/Dismiss
|
||||
|
||||
private var currentItem: MediaGalleryItem {
|
||||
return self.pageViewController!.currentItem
|
||||
}
|
||||
|
||||
@objc
|
||||
public func presentDetailView(fromViewController: UIViewController, mediaAttachment: TSAttachment) {
|
||||
var galleryItem: MediaGalleryItem?
|
||||
uiDatabaseConnection.read { transaction in
|
||||
galleryItem = self.buildGalleryItem(attachment: mediaAttachment, transaction: transaction)
|
||||
}
|
||||
|
||||
guard let initialDetailItem = galleryItem else {
|
||||
return
|
||||
}
|
||||
|
||||
presentDetailView(fromViewController: fromViewController, initialDetailItem: initialDetailItem)
|
||||
}
|
||||
|
||||
public func presentDetailView(fromViewController: UIViewController, initialDetailItem: MediaGalleryItem) {
|
||||
// For a speedy load, we only fetch a few items on either side of
|
||||
// the initial message
|
||||
ensureGalleryItemsLoaded(.around, item: initialDetailItem, amount: 10)
|
||||
|
||||
// We lazily load media into the gallery, but with large albums, we want to be sure
|
||||
// we load all the media required to render the album's media rail.
|
||||
ensureAlbumEntirelyLoaded(galleryItem: initialDetailItem)
|
||||
|
||||
self.initialDetailItem = initialDetailItem
|
||||
|
||||
let pageViewController = MediaPageViewController(initialItem: initialDetailItem, mediaGalleryDataSource: self, uiDatabaseConnection: self.uiDatabaseConnection, options: self.options)
|
||||
self.addDataSourceDelegate(pageViewController)
|
||||
|
||||
self.pageViewController = pageViewController
|
||||
|
||||
let navController = MediaGalleryNavigationController()
|
||||
self.navigationController = navController
|
||||
navController.retainUntilDismissed = self
|
||||
|
||||
navigationController.setViewControllers([pageViewController], animated: false)
|
||||
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
navigationController.modalTransitionStyle = .crossDissolve
|
||||
|
||||
fromViewController.present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// If we're using a navigationController other than self to present the views
|
||||
// e.g. the conversation settings view controller
|
||||
var fromNavController: OWSNavigationController?
|
||||
|
||||
@objc
|
||||
func pushTileView(fromNavController: OWSNavigationController) {
|
||||
var mostRecentItem: MediaGalleryItem?
|
||||
self.uiDatabaseConnection.read { transaction in
|
||||
if let attachment = self.mediaGalleryFinder.mostRecentMediaAttachment(transaction: transaction) {
|
||||
mostRecentItem = self.buildGalleryItem(attachment: attachment, transaction: transaction)
|
||||
}
|
||||
}
|
||||
|
||||
if let mostRecentItem = mostRecentItem {
|
||||
mediaTileViewController.focusedItem = mostRecentItem
|
||||
ensureGalleryItemsLoaded(.around, item: mostRecentItem, amount: 100)
|
||||
}
|
||||
self.fromNavController = fromNavController
|
||||
fromNavController.pushViewController(mediaTileViewController, animated: true)
|
||||
}
|
||||
|
||||
func showAllMedia(focusedItem: MediaGalleryItem) {
|
||||
// TODO fancy animation - zoom media item into it's tile in the all media grid
|
||||
ensureGalleryItemsLoaded(.around, item: focusedItem, amount: 100)
|
||||
|
||||
if let fromNavController = self.fromNavController {
|
||||
// If from conversation settings view, we've already pushed
|
||||
fromNavController.popViewController(animated: true)
|
||||
} else {
|
||||
// If from conversation view
|
||||
mediaTileViewController.focusedItem = focusedItem
|
||||
navigationController.pushViewController(mediaTileViewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: MediaTileViewControllerDelegate
|
||||
|
||||
func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryItem) {
|
||||
if self.fromNavController != nil {
|
||||
// If we got to the gallery via conversation settings, present the detail view
|
||||
// on top of the tile view
|
||||
//
|
||||
// == ViewController Schematic ==
|
||||
//
|
||||
// [DetailView] <--,
|
||||
// [TileView] -----'
|
||||
// [ConversationSettingsView]
|
||||
// [ConversationView]
|
||||
//
|
||||
|
||||
self.presentDetailView(fromViewController: mediaTileViewController, initialDetailItem: mediaGalleryItem)
|
||||
} else {
|
||||
// If we got to the gallery via the conversation view, pop the tile view
|
||||
// to return to the detail view
|
||||
//
|
||||
// == ViewController Schematic ==
|
||||
//
|
||||
// [TileView] -----,
|
||||
// [DetailView] <--'
|
||||
// [ConversationView]
|
||||
//
|
||||
|
||||
guard let pageViewController = self.pageViewController else {
|
||||
owsFailDebug("pageViewController was unexpectedly nil")
|
||||
self.navigationController.dismiss(animated: true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pageViewController.setCurrentItem(mediaGalleryItem, direction: .forward, animated: false)
|
||||
pageViewController.willBePresentedAgain()
|
||||
|
||||
// TODO fancy zoom animation
|
||||
self.navigationController.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
public func dismissMediaDetailViewController(_ mediaPageViewController: MediaPageViewController, animated isAnimated: Bool, completion completionParam: (() -> Void)?) {
|
||||
|
||||
guard let presentingViewController = self.navigationController.presentingViewController else {
|
||||
owsFailDebug("presentingController was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
let completion = {
|
||||
completionParam?()
|
||||
UIApplication.shared.isStatusBarHidden = false
|
||||
presentingViewController.setNeedsStatusBarAppearanceUpdate()
|
||||
}
|
||||
|
||||
navigationController.view.isUserInteractionEnabled = false
|
||||
|
||||
presentingViewController.dismiss(animated: true, completion: completion)
|
||||
}
|
||||
|
||||
// MARK: - Database Notifications
|
||||
|
||||
@objc
|
||||
func uiDatabaseDidUpdate(notification: Notification) {
|
||||
guard let notifications = notification.userInfo?[OWSUIDatabaseConnectionNotificationsKey] as? [Notification] else {
|
||||
owsFailDebug("notifications was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
guard mediaGalleryFinder.hasMediaChanges(in: notifications, dbConnection: uiDatabaseConnection) else {
|
||||
Logger.verbose("no changes for thread: \(thread)")
|
||||
return
|
||||
}
|
||||
|
||||
let rowChanges = extractRowChanges(notifications: notifications)
|
||||
assert(rowChanges.count > 0)
|
||||
|
||||
process(rowChanges: rowChanges)
|
||||
}
|
||||
|
||||
func extractRowChanges(notifications: [Notification]) -> [YapDatabaseViewRowChange] {
|
||||
return notifications.flatMap { notification -> [YapDatabaseViewRowChange] in
|
||||
guard let userInfo = notification.userInfo else {
|
||||
owsFailDebug("userInfo was unexpectedly nil")
|
||||
return []
|
||||
}
|
||||
|
||||
guard let extensionChanges = userInfo["extensions"] as? [AnyHashable: Any] else {
|
||||
owsFailDebug("extensionChanges was unexpectedly nil")
|
||||
return []
|
||||
}
|
||||
|
||||
guard let galleryData = extensionChanges[OWSMediaGalleryFinder.databaseExtensionName()] as? [AnyHashable: Any] else {
|
||||
owsFailDebug("galleryData was unexpectedly nil")
|
||||
return []
|
||||
}
|
||||
|
||||
guard let galleryChanges = galleryData["changes"] as? [Any] else {
|
||||
owsFailDebug("gallerlyChanges was unexpectedly nil")
|
||||
return []
|
||||
}
|
||||
|
||||
return galleryChanges.compactMap { $0 as? YapDatabaseViewRowChange }
|
||||
}
|
||||
}
|
||||
|
||||
func process(rowChanges: [YapDatabaseViewRowChange]) {
|
||||
let deleteChanges = rowChanges.filter { $0.type == .delete }
|
||||
|
||||
let deletedItems: [MediaGalleryItem] = deleteChanges.compactMap { (deleteChange: YapDatabaseViewRowChange) -> MediaGalleryItem? in
|
||||
guard let deletedItem = self.galleryItems.first(where: { galleryItem in
|
||||
galleryItem.attachmentStream.uniqueId == deleteChange.collectionKey.key
|
||||
}) else {
|
||||
Logger.debug("deletedItem was never loaded - no need to remove.")
|
||||
return nil
|
||||
}
|
||||
|
||||
return deletedItem
|
||||
}
|
||||
|
||||
self.delete(items: deletedItems, initiatedBy: self)
|
||||
}
|
||||
|
||||
// MARK: - MediaGalleryDataSource
|
||||
|
||||
lazy var mediaTileViewController: MediaTileViewController = {
|
||||
let vc = MediaTileViewController(mediaGalleryDataSource: self, uiDatabaseConnection: self.uiDatabaseConnection)
|
||||
vc.delegate = self
|
||||
|
||||
self.addDataSourceDelegate(vc)
|
||||
|
||||
return vc
|
||||
}()
|
||||
|
||||
var galleryItems: [MediaGalleryItem] = []
|
||||
var sections: [GalleryDate: [MediaGalleryItem]] = [:]
|
||||
var sectionDates: [GalleryDate] = []
|
||||
var hasFetchedOldest = false
|
||||
var hasFetchedMostRecent = false
|
||||
|
||||
func buildGalleryItem(attachment: TSAttachment, transaction: YapDatabaseReadTransaction) -> MediaGalleryItem? {
|
||||
guard let attachmentStream = attachment as? TSAttachmentStream else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let message = attachmentStream.fetchAlbumMessage(with: transaction) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let galleryItem = MediaGalleryItem(message: message, attachmentStream: attachmentStream)
|
||||
galleryItem.album = getAlbum(item: galleryItem)
|
||||
|
||||
return galleryItem
|
||||
}
|
||||
|
||||
func ensureAlbumEntirelyLoaded(galleryItem: MediaGalleryItem) {
|
||||
ensureGalleryItemsLoaded(.before, item: galleryItem, amount: UInt(galleryItem.albumIndex))
|
||||
|
||||
let followingCount = galleryItem.message.attachmentIds.count - 1 - galleryItem.albumIndex
|
||||
guard followingCount >= 0 else {
|
||||
return
|
||||
}
|
||||
ensureGalleryItemsLoaded(.after, item: galleryItem, amount: UInt(followingCount))
|
||||
}
|
||||
|
||||
var galleryAlbums: [String: MediaGalleryAlbum] = [:]
|
||||
func getAlbum(item: MediaGalleryItem) -> MediaGalleryAlbum? {
|
||||
guard let albumMessageId = item.attachmentStream.albumMessageId else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let existingAlbum = galleryAlbums[albumMessageId] else {
|
||||
let newAlbum = MediaGalleryAlbum(items: [item])
|
||||
galleryAlbums[albumMessageId] = newAlbum
|
||||
newAlbum.mediaGalleryDataSource = self
|
||||
return newAlbum
|
||||
}
|
||||
|
||||
existingAlbum.add(item: item)
|
||||
return existingAlbum
|
||||
}
|
||||
|
||||
// Range instead of indexSet since it's contiguous?
|
||||
var fetchedIndexSet = IndexSet() {
|
||||
didSet {
|
||||
Logger.debug("\(oldValue) -> \(fetchedIndexSet)")
|
||||
}
|
||||
}
|
||||
|
||||
enum MediaGalleryError: Error {
|
||||
case itemNoLongerExists
|
||||
}
|
||||
|
||||
func ensureGalleryItemsLoaded(_ direction: GalleryDirection, item: MediaGalleryItem, amount: UInt, completion: ((IndexSet, [IndexPath]) -> Void)? = nil ) {
|
||||
|
||||
var galleryItems: [MediaGalleryItem] = self.galleryItems
|
||||
var sections: [GalleryDate: [MediaGalleryItem]] = self.sections
|
||||
var sectionDates: [GalleryDate] = self.sectionDates
|
||||
|
||||
var newGalleryItems: [MediaGalleryItem] = []
|
||||
var newDates: [GalleryDate] = []
|
||||
|
||||
do {
|
||||
try Bench(title: "fetching gallery items") {
|
||||
try self.uiDatabaseConnection.read { transaction in
|
||||
guard let index = self.mediaGalleryFinder.mediaIndex(attachment: item.attachmentStream, transaction: transaction) else {
|
||||
throw MediaGalleryError.itemNoLongerExists
|
||||
}
|
||||
let initialIndex: Int = index.intValue
|
||||
let mediaCount: Int = Int(self.mediaGalleryFinder.mediaCount(transaction: transaction))
|
||||
|
||||
let requestRange: Range<Int> = { () -> Range<Int> in
|
||||
let range: Range<Int> = { () -> Range<Int> in
|
||||
switch direction {
|
||||
case .around:
|
||||
// To keep it simple, this isn't exactly *amount* sized if `message` window overlaps the end or
|
||||
// beginning of the view. Still, we have sufficient buffer to fetch more as the user swipes.
|
||||
let start: Int = initialIndex - Int(amount) / 2
|
||||
let end: Int = initialIndex + Int(amount) / 2 + 1
|
||||
|
||||
return start..<end
|
||||
case .before:
|
||||
let start: Int = initialIndex - Int(amount)
|
||||
let end: Int = initialIndex
|
||||
|
||||
return start..<end
|
||||
case .after:
|
||||
let start: Int = initialIndex
|
||||
let end: Int = initialIndex + Int(amount) + 1
|
||||
|
||||
return start..<end
|
||||
}
|
||||
}()
|
||||
|
||||
return range.clamped(to: 0..<mediaCount)
|
||||
}()
|
||||
|
||||
let requestSet = IndexSet(integersIn: requestRange)
|
||||
guard !self.fetchedIndexSet.contains(integersIn: requestSet) else {
|
||||
Logger.debug("all requested messages have already been loaded.")
|
||||
return
|
||||
}
|
||||
|
||||
let unfetchedSet = requestSet.subtracting(self.fetchedIndexSet)
|
||||
|
||||
// For perf we only want to fetch a substantially full batch...
|
||||
let isSubstantialRequest = unfetchedSet.count > (requestSet.count / 2)
|
||||
// ...but we always fulfill even small requests if we're getting just the tail end of a gallery.
|
||||
let isFetchingEdgeOfGallery = (self.fetchedIndexSet.count - unfetchedSet.count) < requestSet.count
|
||||
|
||||
guard isSubstantialRequest || isFetchingEdgeOfGallery else {
|
||||
Logger.debug("ignoring small fetch request: \(unfetchedSet.count)")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.debug("fetching set: \(unfetchedSet)")
|
||||
let nsRange: NSRange = NSRange(location: unfetchedSet.min()!, length: unfetchedSet.count)
|
||||
self.mediaGalleryFinder.enumerateMediaAttachments(range: nsRange, transaction: transaction) { (attachment: TSAttachment) in
|
||||
|
||||
guard !self.deletedAttachments.contains(attachment) else {
|
||||
Logger.debug("skipping \(attachment) which has been deleted.")
|
||||
return
|
||||
}
|
||||
|
||||
guard let item: MediaGalleryItem = self.buildGalleryItem(attachment: attachment, transaction: transaction) else {
|
||||
owsFailDebug("unexpectedly failed to buildGalleryItem")
|
||||
return
|
||||
}
|
||||
|
||||
let date = item.galleryDate
|
||||
|
||||
galleryItems.append(item)
|
||||
if sections[date] != nil {
|
||||
sections[date]!.append(item)
|
||||
|
||||
// so we can update collectionView
|
||||
newGalleryItems.append(item)
|
||||
} else {
|
||||
sectionDates.append(date)
|
||||
sections[date] = [item]
|
||||
|
||||
// so we can update collectionView
|
||||
newDates.append(date)
|
||||
newGalleryItems.append(item)
|
||||
}
|
||||
}
|
||||
|
||||
self.fetchedIndexSet = self.fetchedIndexSet.union(unfetchedSet)
|
||||
self.hasFetchedOldest = self.fetchedIndexSet.min() == 0
|
||||
self.hasFetchedMostRecent = self.fetchedIndexSet.max() == mediaCount - 1
|
||||
}
|
||||
}
|
||||
} catch MediaGalleryError.itemNoLongerExists {
|
||||
Logger.debug("Ignoring reload, since item no longer exists.")
|
||||
return
|
||||
} catch {
|
||||
owsFailDebug("unexpected error: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO only sort if changed
|
||||
var sortedSections: [GalleryDate: [MediaGalleryItem]] = [:]
|
||||
|
||||
Bench(title: "sorting gallery items") {
|
||||
galleryItems.sort { lhs, rhs -> Bool in
|
||||
return lhs.orderingKey < rhs.orderingKey
|
||||
}
|
||||
sectionDates.sort()
|
||||
|
||||
for (date, galleryItems) in sections {
|
||||
sortedSections[date] = galleryItems.sorted { lhs, rhs -> Bool in
|
||||
return lhs.orderingKey < rhs.orderingKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.galleryItems = galleryItems
|
||||
self.sections = sortedSections
|
||||
self.sectionDates = sectionDates
|
||||
|
||||
if let completionBlock = completion {
|
||||
Bench(title: "calculating changes for collectionView") {
|
||||
// FIXME can we avoid this index offset?
|
||||
let dateIndices = newDates.map { sectionDates.firstIndex(of: $0)! + 1 }
|
||||
let addedSections: IndexSet = IndexSet(dateIndices)
|
||||
|
||||
let addedItems: [IndexPath] = newGalleryItems.map { galleryItem in
|
||||
let sectionIdx = sectionDates.firstIndex(of: galleryItem.galleryDate)!
|
||||
let section = sections[galleryItem.galleryDate]!
|
||||
let itemIdx = section.firstIndex(of: galleryItem)!
|
||||
|
||||
// FIXME can we avoid this index offset?
|
||||
return IndexPath(item: itemIdx, section: sectionIdx + 1)
|
||||
}
|
||||
|
||||
completionBlock(addedSections, addedItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var dataSourceDelegates: [Weak<MediaGalleryDataSourceDelegate>] = []
|
||||
func addDataSourceDelegate(_ dataSourceDelegate: MediaGalleryDataSourceDelegate) {
|
||||
dataSourceDelegates.append(Weak(value: dataSourceDelegate))
|
||||
}
|
||||
|
||||
func delete(items: [MediaGalleryItem], initiatedBy: AnyObject) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
Logger.info("with items: \(items.map { ($0.attachmentStream, $0.message.timestamp) })")
|
||||
|
||||
deletedGalleryItems.formUnion(items)
|
||||
dataSourceDelegates.forEach { $0.value?.mediaGalleryDataSource(self, willDelete: items, initiatedBy: initiatedBy) }
|
||||
|
||||
for item in items {
|
||||
self.deletedAttachments.insert(item.attachmentStream)
|
||||
}
|
||||
|
||||
self.editingDatabaseConnection.asyncReadWrite { transaction in
|
||||
for item in items {
|
||||
let message = item.message
|
||||
let attachment = item.attachmentStream
|
||||
message.removeAttachment(attachment, transaction: transaction)
|
||||
if message.attachmentIds.count == 0 {
|
||||
Logger.debug("removing message after removing last media attachment")
|
||||
message.remove(with: transaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var deletedSections: IndexSet = IndexSet()
|
||||
var deletedIndexPaths: [IndexPath] = []
|
||||
let originalSections = self.sections
|
||||
let originalSectionDates = self.sectionDates
|
||||
|
||||
for item in items {
|
||||
guard let itemIndex = galleryItems.firstIndex(of: item) else {
|
||||
owsFailDebug("removing unknown item.")
|
||||
return
|
||||
}
|
||||
|
||||
self.galleryItems.remove(at: itemIndex)
|
||||
|
||||
guard let sectionIndex = sectionDates.firstIndex(where: { $0 == item.galleryDate }) else {
|
||||
owsFailDebug("item with unknown date.")
|
||||
return
|
||||
}
|
||||
|
||||
guard var sectionItems = self.sections[item.galleryDate] else {
|
||||
owsFailDebug("item with unknown section")
|
||||
return
|
||||
}
|
||||
|
||||
guard let sectionRowIndex = sectionItems.firstIndex(of: item) else {
|
||||
owsFailDebug("item with unknown sectionRowIndex")
|
||||
return
|
||||
}
|
||||
|
||||
// We need to calculate the index of the deleted item with respect to it's original position.
|
||||
guard let originalSectionIndex = originalSectionDates.firstIndex(where: { $0 == item.galleryDate }) else {
|
||||
owsFailDebug("item with unknown date.")
|
||||
return
|
||||
}
|
||||
|
||||
guard let originalSectionItems = originalSections[item.galleryDate] else {
|
||||
owsFailDebug("item with unknown section")
|
||||
return
|
||||
}
|
||||
|
||||
guard let originalSectionRowIndex = originalSectionItems.firstIndex(of: item) else {
|
||||
owsFailDebug("item with unknown sectionRowIndex")
|
||||
return
|
||||
}
|
||||
|
||||
if sectionItems == [item] {
|
||||
// Last item in section. Delete section.
|
||||
self.sections[item.galleryDate] = nil
|
||||
self.sectionDates.remove(at: sectionIndex)
|
||||
|
||||
deletedSections.insert(originalSectionIndex + 1)
|
||||
deletedIndexPaths.append(IndexPath(row: originalSectionRowIndex, section: originalSectionIndex + 1))
|
||||
} else {
|
||||
sectionItems.remove(at: sectionRowIndex)
|
||||
self.sections[item.galleryDate] = sectionItems
|
||||
|
||||
deletedIndexPaths.append(IndexPath(row: originalSectionRowIndex, section: originalSectionIndex + 1))
|
||||
}
|
||||
}
|
||||
|
||||
dataSourceDelegates.forEach { $0.value?.mediaGalleryDataSource(self, deletedSections: deletedSections, deletedItems: deletedIndexPaths) }
|
||||
}
|
||||
|
||||
let kGallerySwipeLoadBatchSize: UInt = 5
|
||||
|
||||
internal func galleryItem(after currentItem: MediaGalleryItem) -> MediaGalleryItem? {
|
||||
Logger.debug("")
|
||||
|
||||
self.ensureGalleryItemsLoaded(.after, item: currentItem, amount: kGallerySwipeLoadBatchSize)
|
||||
|
||||
guard let currentIndex = galleryItems.firstIndex(of: currentItem) else {
|
||||
owsFailDebug("currentIndex was unexpectedly nil")
|
||||
return nil
|
||||
}
|
||||
|
||||
let index: Int = galleryItems.index(after: currentIndex)
|
||||
guard let nextItem = galleryItems[safe: index] else {
|
||||
// already at last item
|
||||
return nil
|
||||
}
|
||||
|
||||
guard !deletedGalleryItems.contains(nextItem) else {
|
||||
Logger.debug("nextItem was deleted - Recursing.")
|
||||
return galleryItem(after: nextItem)
|
||||
}
|
||||
|
||||
return nextItem
|
||||
}
|
||||
|
||||
internal func galleryItem(before currentItem: MediaGalleryItem) -> MediaGalleryItem? {
|
||||
Logger.debug("")
|
||||
|
||||
self.ensureGalleryItemsLoaded(.before, item: currentItem, amount: kGallerySwipeLoadBatchSize)
|
||||
|
||||
guard let currentIndex = galleryItems.firstIndex(of: currentItem) else {
|
||||
owsFailDebug("currentIndex was unexpectedly nil")
|
||||
return nil
|
||||
}
|
||||
|
||||
let index: Int = galleryItems.index(before: currentIndex)
|
||||
guard let previousItem = galleryItems[safe: index] else {
|
||||
// already at first item
|
||||
return nil
|
||||
}
|
||||
|
||||
guard !deletedGalleryItems.contains(previousItem) else {
|
||||
Logger.debug("previousItem was deleted - Recursing.")
|
||||
return galleryItem(before: previousItem)
|
||||
}
|
||||
|
||||
return previousItem
|
||||
}
|
||||
|
||||
var galleryItemCount: Int {
|
||||
var count: UInt = 0
|
||||
self.uiDatabaseConnection.read { (transaction: YapDatabaseReadTransaction) in
|
||||
count = self.mediaGalleryFinder.mediaCount(transaction: transaction)
|
||||
}
|
||||
return Int(count) - deletedAttachments.count
|
||||
}
|
||||
}
|
|
@ -0,0 +1,574 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public class MediaGalleryViewModel {
|
||||
public typealias SectionModel = ArraySection<Section, Item>
|
||||
|
||||
// MARK: - Section
|
||||
|
||||
public enum Section: Differentiable, Equatable, Comparable, Hashable {
|
||||
case emptyGallery
|
||||
case loadOlder
|
||||
case galleryMonth(date: GalleryDate)
|
||||
case loadNewer
|
||||
}
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
public let threadId: String
|
||||
public let threadVariant: SessionThread.Variant
|
||||
private var focusedAttachmentId: String?
|
||||
public private(set) var focusedIndexPath: IndexPath?
|
||||
|
||||
/// This value is the current state of an album view
|
||||
private var cachedInteractionIdBefore: Atomic<[Int64: Int64]> = Atomic([:])
|
||||
private var cachedInteractionIdAfter: Atomic<[Int64: Int64]> = Atomic([:])
|
||||
|
||||
public var interactionIdBefore: [Int64: Int64] { cachedInteractionIdBefore.wrappedValue }
|
||||
public var interactionIdAfter: [Int64: Int64] { cachedInteractionIdAfter.wrappedValue }
|
||||
public private(set) var albumData: [Int64: [Item]] = [:]
|
||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<Attachment, Item>?
|
||||
|
||||
/// This value is the current state of a gallery view
|
||||
private var unobservedGalleryDataChanges: [SectionModel]?
|
||||
public private(set) var galleryData: [SectionModel] = []
|
||||
public var onGalleryChange: (([SectionModel]) -> ())? {
|
||||
didSet {
|
||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||
// data was changed while we weren't observing
|
||||
if let unobservedGalleryDataChanges: [SectionModel] = self.unobservedGalleryDataChanges {
|
||||
onGalleryChange?(unobservedGalleryDataChanges)
|
||||
self.unobservedGalleryDataChanges = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
threadId: String,
|
||||
threadVariant: SessionThread.Variant,
|
||||
isPagedData: Bool,
|
||||
pageSize: Int = 1,
|
||||
focusedAttachmentId: String? = nil,
|
||||
performInitialQuerySync: Bool = false
|
||||
) {
|
||||
self.threadId = threadId
|
||||
self.threadVariant = threadVariant
|
||||
self.focusedAttachmentId = focusedAttachmentId
|
||||
self.pagedDataObserver = nil
|
||||
|
||||
guard isPagedData else { return }
|
||||
|
||||
// Note: Since this references self we need to finish initializing before setting it, we
|
||||
// also want to skip the initial query and trigger it async so that the push animation
|
||||
// doesn't stutter (it should load basically immediately but without this there is a
|
||||
// distinct stutter)
|
||||
self.pagedDataObserver = PagedDatabaseObserver(
|
||||
pagedTable: Attachment.self,
|
||||
pageSize: pageSize,
|
||||
idColumn: .id,
|
||||
observedChanges: [
|
||||
PagedData.ObservedChanges(
|
||||
table: Attachment.self,
|
||||
columns: [.isValid]
|
||||
)
|
||||
],
|
||||
joinSQL: Item.joinSQL,
|
||||
filterSQL: Item.filterSQL(threadId: threadId),
|
||||
orderSQL: Item.galleryOrderSQL,
|
||||
dataQuery: Item.baseQuery(orderSQL: Item.galleryOrderSQL),
|
||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
guard let updatedGalleryData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
|
||||
return
|
||||
}
|
||||
|
||||
// If we have the 'onGalleryChange' callback then trigger it, otherwise just store the changes
|
||||
// to be sent to the callback if we ever start observing again (when we have the callback it needs
|
||||
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
|
||||
// correct order)
|
||||
guard let onGalleryChange: (([SectionModel]) -> ()) = self?.onGalleryChange else {
|
||||
self?.unobservedGalleryDataChanges = updatedGalleryData
|
||||
return
|
||||
}
|
||||
|
||||
onGalleryChange(updatedGalleryData)
|
||||
}
|
||||
)
|
||||
|
||||
// Run the initial query on a backgorund thread so we don't block the push transition
|
||||
let loadInitialData: () -> () = { [weak self] in
|
||||
// If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query
|
||||
// from a `0` offset)
|
||||
guard let initialFocusedId: String = focusedAttachmentId else {
|
||||
self?.pagedDataObserver?.load(.pageBefore)
|
||||
return
|
||||
}
|
||||
|
||||
self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId))
|
||||
}
|
||||
|
||||
// We have a custom transition when going from an attachment detail screen to the tile gallery
|
||||
// so in that case we want to perform the initial query synchronously so that we have the content
|
||||
// to do the transition (we don't clear the 'unobservedGalleryDataChanges' after setting it as
|
||||
// we don't want to mess with the initial view controller behaviour)
|
||||
guard !performInitialQuerySync else {
|
||||
loadInitialData()
|
||||
updateGalleryData(self.unobservedGalleryDataChanges ?? [])
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.global(qos: .default).async {
|
||||
loadInitialData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data
|
||||
|
||||
public struct GalleryDate: Differentiable, Equatable, Comparable, Hashable {
|
||||
private static let thisYearFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM"
|
||||
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let olderFormatter: DateFormatter = {
|
||||
// FIXME: localize for RTL, or is there a built in way to do this?
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM yyyy"
|
||||
|
||||
return formatter
|
||||
}()
|
||||
|
||||
let year: Int
|
||||
let month: Int
|
||||
|
||||
private var date: Date? {
|
||||
var components = DateComponents()
|
||||
components.month = self.month
|
||||
components.year = self.year
|
||||
|
||||
return Calendar.current.date(from: components)
|
||||
}
|
||||
|
||||
var localizedString: String {
|
||||
let isSameMonth: Bool = (self.month == Calendar.current.component(.month, from: Date()))
|
||||
let isCurrentYear: Bool = (self.year == Calendar.current.component(.year, from: Date()))
|
||||
let galleryDate: Date = (self.date ?? Date())
|
||||
|
||||
switch (isSameMonth, isCurrentYear) {
|
||||
case (true, true): return "MEDIA_GALLERY_THIS_MONTH_HEADER".localized()
|
||||
case (false, true): return GalleryDate.thisYearFormatter.string(from: galleryDate)
|
||||
default: return GalleryDate.olderFormatter.string(from: galleryDate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - --Initialization
|
||||
|
||||
init(messageDate: Date) {
|
||||
self.year = Calendar.current.component(.year, from: messageDate)
|
||||
self.month = Calendar.current.component(.month, from: messageDate)
|
||||
}
|
||||
|
||||
// MARK: - --Comparable
|
||||
|
||||
public static func < (lhs: GalleryDate, rhs: GalleryDate) -> Bool {
|
||||
switch ((lhs.year != rhs.year), (lhs.month != rhs.month)) {
|
||||
case (true, _): return lhs.year < rhs.year
|
||||
case (_, true): return lhs.month < rhs.month
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct Item: FetchableRecordWithRowId, Decodable, Identifiable, Differentiable, Equatable, Hashable {
|
||||
fileprivate static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue)
|
||||
fileprivate static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue)
|
||||
fileprivate static let interactionAuthorIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionAuthorId.stringValue)
|
||||
fileprivate static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue)
|
||||
fileprivate static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue)
|
||||
fileprivate static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue)
|
||||
fileprivate static let attachmentAlbumIndexKey: SQL = SQL(stringLiteral: CodingKeys.attachmentAlbumIndex.stringValue)
|
||||
|
||||
fileprivate static let attachmentString: String = CodingKeys.attachment.stringValue
|
||||
|
||||
public var id: String { attachment.id }
|
||||
public var differenceIdentifier: String { attachment.id }
|
||||
|
||||
let interactionId: Int64
|
||||
let interactionVariant: Interaction.Variant
|
||||
let interactionAuthorId: String
|
||||
let interactionTimestampMs: Int64
|
||||
|
||||
public var rowId: Int64
|
||||
let attachmentAlbumIndex: Int
|
||||
let attachment: Attachment
|
||||
|
||||
var galleryDate: GalleryDate {
|
||||
GalleryDate(
|
||||
messageDate: Date(timeIntervalSince1970: (Double(interactionTimestampMs) / 1000))
|
||||
)
|
||||
}
|
||||
|
||||
var isVideo: Bool { attachment.isVideo }
|
||||
var isAnimated: Bool { attachment.isAnimated }
|
||||
var isImage: Bool { attachment.isImage }
|
||||
|
||||
var imageSize: CGSize {
|
||||
guard let width: UInt = attachment.width, let height: UInt = attachment.height else {
|
||||
return .zero
|
||||
}
|
||||
|
||||
return CGSize(width: Int(width), height: Int(height))
|
||||
}
|
||||
|
||||
var captionForDisplay: String? { attachment.caption?.filterForDisplay }
|
||||
|
||||
// MARK: - Query
|
||||
|
||||
fileprivate static let joinSQL: SQL = {
|
||||
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||
|
||||
return """
|
||||
JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id])
|
||||
JOIN \(Interaction.self) ON \(interaction[.id]) = \(interactionAttachment[.interactionId])
|
||||
"""
|
||||
}()
|
||||
|
||||
fileprivate static func filterSQL(threadId: String) -> SQL {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||
|
||||
return SQL("""
|
||||
\(attachment[.isVisualMedia]) = true AND
|
||||
\(attachment[.isValid]) = true AND
|
||||
\(interaction[.threadId]) = \(threadId)
|
||||
""")
|
||||
}
|
||||
|
||||
fileprivate static let galleryOrderSQL: SQL = {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||
|
||||
/// **Note:** This **MUST** match the desired sort behaviour for the screen otherwise paging will be
|
||||
/// very broken
|
||||
return SQL("\(interaction[.timestampMs].desc), \(interactionAttachment[.albumIndex])")
|
||||
}()
|
||||
|
||||
fileprivate static let galleryReverseOrderSQL: SQL = {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||
|
||||
/// **Note:** This **MUST** match the desired sort behaviour for the screen otherwise paging will be
|
||||
/// very broken
|
||||
return SQL("\(interaction[.timestampMs]), \(interactionAttachment[.albumIndex].desc)")
|
||||
}()
|
||||
|
||||
fileprivate static func baseQuery(orderSQL: SQL, customFilters: SQL? = nil) -> (([Int64]) -> AdaptedFetchRequest<SQLRequest<Item>>) {
|
||||
return { rowIds -> AdaptedFetchRequest<SQLRequest<Item>> in
|
||||
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||
|
||||
let numColumnsBeforeLinkedRecords: Int = 6
|
||||
let finalFilterSQL: SQL = {
|
||||
guard let customFilters: SQL = customFilters else {
|
||||
return """
|
||||
WHERE \(attachment.alias[Column.rowID]) IN \(rowIds)
|
||||
"""
|
||||
}
|
||||
|
||||
return """
|
||||
WHERE (
|
||||
\(customFilters)
|
||||
)
|
||||
"""
|
||||
}()
|
||||
let request: SQLRequest<Item> = """
|
||||
SELECT
|
||||
\(interaction[.id]) AS \(Item.interactionIdKey),
|
||||
\(interaction[.variant]) AS \(Item.interactionVariantKey),
|
||||
\(interaction[.authorId]) AS \(Item.interactionAuthorIdKey),
|
||||
\(interaction[.timestampMs]) AS \(Item.interactionTimestampMsKey),
|
||||
|
||||
\(attachment.alias[Column.rowID]) AS \(Item.rowIdKey),
|
||||
\(interactionAttachment[.albumIndex]) AS \(Item.attachmentAlbumIndexKey),
|
||||
\(Item.attachmentKey).*
|
||||
FROM \(Attachment.self)
|
||||
\(joinSQL)
|
||||
\(finalFilterSQL)
|
||||
ORDER BY \(orderSQL)
|
||||
"""
|
||||
|
||||
return request.adapted { db in
|
||||
let adapters = try splittingRowAdapters(columnCounts: [
|
||||
numColumnsBeforeLinkedRecords,
|
||||
Attachment.numberOfSelectedColumns(db)
|
||||
])
|
||||
|
||||
return ScopeAdapter([
|
||||
Item.attachmentString: adapters[1]
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate static func baseQuery(orderSQL: SQL, customFilters: SQL) -> AdaptedFetchRequest<SQLRequest<Item>> {
|
||||
return Item.baseQuery(orderSQL: orderSQL, customFilters: customFilters)([])
|
||||
}
|
||||
|
||||
func thumbnailImage(async: @escaping (UIImage) -> ()) {
|
||||
attachment.thumbnail(size: .small, success: { image, _ in async(image) }, failure: {})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Album
|
||||
|
||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
||||
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||
///
|
||||
/// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static
|
||||
/// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
|
||||
///
|
||||
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
|
||||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
||||
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||
public typealias AlbumObservation = ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<[Item]>>>
|
||||
public lazy var observableAlbumData: AlbumObservation = buildAlbumObservation(for: nil)
|
||||
|
||||
private func buildAlbumObservation(for interactionId: Int64?) -> AlbumObservation {
|
||||
return ValueObservation
|
||||
.trackingConstantRegion { db -> [Item] in
|
||||
guard let interactionId: Int64 = interactionId else { return [] }
|
||||
|
||||
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||
|
||||
return try Item
|
||||
.baseQuery(
|
||||
orderSQL: SQL(interactionAttachment[.albumIndex]),
|
||||
customFilters: SQL("""
|
||||
\(attachment[.isValid]) = true AND
|
||||
\(interaction[.id]) = \(interactionId)
|
||||
""")
|
||||
)
|
||||
.fetchAll(db)
|
||||
}
|
||||
.removeDuplicates()
|
||||
}
|
||||
|
||||
@discardableResult public func loadAndCacheAlbumData(for interactionId: Int64) -> [Item] {
|
||||
typealias AlbumInfo = (albumData: [Item], interactionIdBefore: Int64?, interactionIdAfter: Int64?)
|
||||
|
||||
// Note: It's possible we already have cached album data for this interaction
|
||||
// but to avoid displaying stale data we re-fetch from the database anyway
|
||||
let maybeAlbumInfo: AlbumInfo? = Storage.shared.read { db -> AlbumInfo in
|
||||
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||
|
||||
let newAlbumData: [Item] = try Item
|
||||
.baseQuery(
|
||||
orderSQL: SQL(interactionAttachment[.albumIndex]),
|
||||
customFilters: SQL("""
|
||||
\(attachment[.isValid]) = true AND
|
||||
\(interaction[.id]) = \(interactionId)
|
||||
""")
|
||||
)
|
||||
.fetchAll(db)
|
||||
|
||||
guard let albumTimestampMs: Int64 = newAlbumData.first?.interactionTimestampMs else {
|
||||
return (newAlbumData, nil, nil)
|
||||
}
|
||||
|
||||
let itemBefore: Item? = try Item
|
||||
.baseQuery(
|
||||
orderSQL: Item.galleryReverseOrderSQL,
|
||||
customFilters: SQL("\(interaction[.timestampMs]) > \(albumTimestampMs)")
|
||||
)
|
||||
.fetchOne(db)
|
||||
let itemAfter: Item? = try Item
|
||||
.baseQuery(
|
||||
orderSQL: Item.galleryOrderSQL,
|
||||
customFilters: SQL("\(interaction[.timestampMs]) < \(albumTimestampMs)")
|
||||
)
|
||||
.fetchOne(db)
|
||||
|
||||
return (newAlbumData, itemBefore?.interactionId, itemAfter?.interactionId)
|
||||
}
|
||||
|
||||
guard let newAlbumInfo: AlbumInfo = maybeAlbumInfo else { return [] }
|
||||
|
||||
// Cache the album info for the new interactionId
|
||||
self.updateAlbumData(newAlbumInfo.albumData, for: interactionId)
|
||||
self.cachedInteractionIdBefore.mutate { $0[interactionId] = newAlbumInfo.interactionIdBefore }
|
||||
self.cachedInteractionIdAfter.mutate { $0[interactionId] = newAlbumInfo.interactionIdAfter }
|
||||
|
||||
return newAlbumInfo.albumData
|
||||
}
|
||||
|
||||
public func replaceAlbumObservation(toObservationFor interactionId: Int64) {
|
||||
self.observableAlbumData = self.buildAlbumObservation(for: interactionId)
|
||||
}
|
||||
|
||||
public func updateAlbumData(_ updatedData: [Item], for interactionId: Int64) {
|
||||
self.albumData[interactionId] = updatedData
|
||||
}
|
||||
|
||||
// MARK: - Gallery
|
||||
|
||||
private func process(data: [Item], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
||||
let galleryData: [SectionModel] = data
|
||||
.grouped(by: \.galleryDate)
|
||||
.mapValues { sectionItems -> [Item] in
|
||||
sectionItems
|
||||
.sorted { lhs, rhs -> Bool in
|
||||
if lhs.interactionTimestampMs == rhs.interactionTimestampMs {
|
||||
// Start of album first
|
||||
return (lhs.attachmentAlbumIndex < rhs.attachmentAlbumIndex)
|
||||
}
|
||||
|
||||
// Newer interactions first
|
||||
return (lhs.interactionTimestampMs > rhs.interactionTimestampMs)
|
||||
}
|
||||
}
|
||||
.map { galleryDate, items in
|
||||
SectionModel(model: .galleryMonth(date: galleryDate), elements: items)
|
||||
}
|
||||
|
||||
// Remove and re-add the custom sections as needed
|
||||
return [
|
||||
(data.isEmpty ? [SectionModel(section: .emptyGallery)] : []),
|
||||
(!data.isEmpty && pageInfo.pageOffset > 0 ? [SectionModel(section: .loadNewer)] : []),
|
||||
galleryData,
|
||||
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
|
||||
[SectionModel(section: .loadOlder)] :
|
||||
[]
|
||||
)
|
||||
]
|
||||
.flatMap { $0 }
|
||||
.sorted { lhs, rhs -> Bool in (lhs.model > rhs.model) }
|
||||
}
|
||||
|
||||
public func updateGalleryData(_ updatedData: [SectionModel]) {
|
||||
self.galleryData = updatedData
|
||||
|
||||
// If we have a focused attachment id then we need to make sure the 'focusedIndexPath'
|
||||
// is updated to be accurate
|
||||
if let focusedAttachmentId: String = focusedAttachmentId {
|
||||
self.focusedIndexPath = nil
|
||||
|
||||
for (section, sectionData) in updatedData.enumerated() {
|
||||
for (index, item) in sectionData.elements.enumerated() {
|
||||
if item.attachment.id == focusedAttachmentId {
|
||||
self.focusedIndexPath = IndexPath(item: index, section: section)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if self.focusedIndexPath != nil { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func updateFocusedItem(attachmentId: String, indexPath: IndexPath) {
|
||||
// Note: We need to set both of these as the 'focusedIndexPath' is usually
|
||||
// derived and if the data changes it will be regenerated using the
|
||||
// 'focusedAttachmentId' value
|
||||
self.focusedAttachmentId = attachmentId
|
||||
self.focusedIndexPath = indexPath
|
||||
}
|
||||
|
||||
// MARK: - Creation Functions
|
||||
|
||||
public static func createDetailViewController(
|
||||
for threadId: String,
|
||||
threadVariant: SessionThread.Variant,
|
||||
interactionId: Int64,
|
||||
selectedAttachmentId: String,
|
||||
options: [MediaGalleryOption]
|
||||
) -> UIViewController? {
|
||||
// Load the data for the album immediately (needed before pushing to the screen so
|
||||
// transitions work nicely)
|
||||
let viewModel: MediaGalleryViewModel = MediaGalleryViewModel(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
isPagedData: false
|
||||
)
|
||||
viewModel.loadAndCacheAlbumData(for: interactionId)
|
||||
viewModel.replaceAlbumObservation(toObservationFor: interactionId)
|
||||
|
||||
guard
|
||||
!viewModel.albumData.isEmpty,
|
||||
let initialItem: Item = viewModel.albumData[interactionId]?.first(where: { item -> Bool in
|
||||
item.attachment.id == selectedAttachmentId
|
||||
})
|
||||
else { return nil }
|
||||
|
||||
let pageViewController: MediaPageViewController = MediaPageViewController(
|
||||
viewModel: viewModel,
|
||||
initialItem: initialItem,
|
||||
options: options
|
||||
)
|
||||
let navController: MediaGalleryNavigationController = MediaGalleryNavigationController()
|
||||
navController.viewControllers = [pageViewController]
|
||||
navController.modalPresentationStyle = .fullScreen
|
||||
navController.transitioningDelegate = pageViewController
|
||||
|
||||
return navController
|
||||
}
|
||||
|
||||
public static func createTileViewController(
|
||||
threadId: String,
|
||||
threadVariant: SessionThread.Variant,
|
||||
focusedAttachmentId: String?,
|
||||
performInitialQuerySync: Bool = false
|
||||
) -> MediaTileViewController {
|
||||
let viewModel: MediaGalleryViewModel = MediaGalleryViewModel(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
isPagedData: true,
|
||||
pageSize: MediaTileViewController.itemPageSize,
|
||||
focusedAttachmentId: focusedAttachmentId,
|
||||
performInitialQuerySync: performInitialQuerySync
|
||||
)
|
||||
|
||||
return MediaTileViewController(
|
||||
viewModel: viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Objective-C Support
|
||||
|
||||
// FIXME: Remove when we can
|
||||
|
||||
@objc(SNMediaGallery)
|
||||
public class SNMediaGallery: NSObject {
|
||||
@objc(pushTileViewWithSliderEnabledForThreadId:isClosedGroup:isOpenGroup:fromNavController:)
|
||||
static func pushTileView(threadId: String, isClosedGroup: Bool, isOpenGroup: Bool, fromNavController: OWSNavigationController) {
|
||||
fromNavController.pushViewController(
|
||||
MediaGalleryViewModel.createTileViewController(
|
||||
threadId: threadId,
|
||||
threadVariant: {
|
||||
if isClosedGroup { return .closedGroup }
|
||||
if isOpenGroup { return .openGroup }
|
||||
|
||||
return .contact
|
||||
}(),
|
||||
focusedAttachmentId: nil
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -42,17 +42,12 @@ class PhotoCapture: NSObject {
|
|||
self.captureOutput = CaptureOutput()
|
||||
}
|
||||
|
||||
// MARK: - Dependencies
|
||||
var audioSession: OWSAudioSession {
|
||||
return Environment.shared.audioSession
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
var audioDeviceInput: AVCaptureDeviceInput?
|
||||
func startAudioCapture() throws {
|
||||
assertIsOnSessionQueue()
|
||||
|
||||
guard audioSession.startAudioActivity(recordingAudioActivity) else {
|
||||
guard Environment.shared?.audioSession.startAudioActivity(recordingAudioActivity) == true else {
|
||||
throw PhotoCaptureError.assertionError(description: "unable to capture audio activity")
|
||||
}
|
||||
|
||||
|
@ -83,7 +78,7 @@ class PhotoCapture: NSObject {
|
|||
}
|
||||
session.removeInput(audioDeviceInput)
|
||||
self.audioDeviceInput = nil
|
||||
audioSession.endAudioActivity(recordingAudioActivity)
|
||||
Environment.shared?.audioSession.endAudioActivity(recordingAudioActivity)
|
||||
}
|
||||
|
||||
func startCapture() -> Promise<Void> {
|
||||
|
@ -458,16 +453,10 @@ protocol ImageCaptureOutput: AnyObject {
|
|||
|
||||
class CaptureOutput {
|
||||
|
||||
let imageOutput: ImageCaptureOutput
|
||||
let imageOutput: ImageCaptureOutput = PhotoCaptureOutputAdaptee()
|
||||
let movieOutput: AVCaptureMovieFileOutput
|
||||
|
||||
init() {
|
||||
if #available(iOS 10.0, *) {
|
||||
imageOutput = PhotoCaptureOutputAdaptee()
|
||||
} else {
|
||||
imageOutput = StillImageCaptureOutput()
|
||||
}
|
||||
|
||||
movieOutput = AVCaptureMovieFileOutput()
|
||||
// disable movie fragment writing since it's not supported on mp4
|
||||
// leaving it enabled causes all audio to be lost on videos longer
|
||||
|
@ -536,7 +525,6 @@ class CaptureOutput {
|
|||
}
|
||||
}
|
||||
|
||||
@available(iOS 10.0, *)
|
||||
class PhotoCaptureOutputAdaptee: NSObject, ImageCaptureOutput {
|
||||
|
||||
let photoOutput = AVCapturePhotoOutput()
|
||||
|
@ -591,7 +579,6 @@ class PhotoCaptureOutputAdaptee: NSObject, ImageCaptureOutput {
|
|||
self.completion = completion
|
||||
}
|
||||
|
||||
@available(iOS 11.0, *)
|
||||
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
|
||||
var data = photo.fileDataRepresentation()!
|
||||
// Call normalized here to fix the orientation
|
||||
|
|
|
@ -115,12 +115,7 @@ class PhotoCaptureViewController: OWSViewController {
|
|||
|
||||
init(imageName: String, block: @escaping () -> Void) {
|
||||
self.button = OWSButton(imageName: imageName, tintColor: .ows_white, block: block)
|
||||
if #available(iOS 10, *) {
|
||||
button.autoPinToSquareAspectRatio()
|
||||
} else {
|
||||
button.sizeToFit()
|
||||
}
|
||||
|
||||
button.autoPinToSquareAspectRatio()
|
||||
button.layer.shadowOffset = CGSize.zero
|
||||
button.layer.shadowOpacity = 0.35
|
||||
button.layer.shadowRadius = 4
|
||||
|
@ -600,20 +595,6 @@ class RecordingTimerView: UIView {
|
|||
return icon
|
||||
}()
|
||||
|
||||
// MARK: - Overrides //
|
||||
|
||||
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
||||
if #available(iOS 10, *) {
|
||||
return super.sizeThatFits(size)
|
||||
} else {
|
||||
// iOS9 manual layout sizing required for items in the navigation bar
|
||||
var baseSize = label.frame.size
|
||||
baseSize.width = baseSize.width + stackViewSpacing + RecordingTimerView.iconWidth + layoutMargins.left + layoutMargins.right
|
||||
baseSize.height = baseSize.height + layoutMargins.top + layoutMargins.bottom
|
||||
return baseSize
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
var recordingStartTime: TimeInterval?
|
||||
|
||||
|
@ -662,10 +643,5 @@ class RecordingTimerView: UIView {
|
|||
Logger.verbose("recordingDuration: \(recordingDuration)")
|
||||
let durationDate = Date(timeIntervalSinceReferenceDate: recordingDuration)
|
||||
label.text = timeFormatter.string(from: durationDate)
|
||||
if #available(iOS 10, *) {
|
||||
// do nothing
|
||||
} else {
|
||||
label.sizeToFit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import Foundation
|
|||
import Photos
|
||||
import PromiseKit
|
||||
|
||||
protocol PhotoCollectionPickerDelegate: class {
|
||||
protocol PhotoCollectionPickerDelegate: AnyObject {
|
||||
func photoCollectionPicker(_ photoCollectionPicker: PhotoCollectionPickerController, didPickCollection collection: PhotoCollection)
|
||||
}
|
||||
|
||||
|
@ -102,7 +102,7 @@ class PhotoCollectionPickerController: OWSTableViewController, PhotoLibraryDeleg
|
|||
|
||||
let photoMediaSize = PhotoMediaSize(thumbnailSize: CGSize(width: kImageSize, height: kImageSize))
|
||||
if let assetItem = contents.lastAssetItem(photoMediaSize: photoMediaSize) {
|
||||
imageView.image = assetItem.asyncThumbnail { [weak imageView] image in
|
||||
assetItem.asyncThumbnail { [weak imageView] image in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let imageView = imageView else {
|
||||
|
|
|
@ -9,15 +9,13 @@ public enum PhotoGridItemType {
|
|||
case photo, animated, video
|
||||
}
|
||||
|
||||
public protocol PhotoGridItem: class {
|
||||
public protocol PhotoGridItem: AnyObject {
|
||||
var type: PhotoGridItemType { get }
|
||||
func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage?
|
||||
|
||||
func asyncThumbnail(completion: @escaping (UIImage?) -> Void)
|
||||
}
|
||||
|
||||
public class PhotoGridViewCell: UICollectionViewCell {
|
||||
|
||||
static let reuseIdentifier = "PhotoGridViewCell"
|
||||
|
||||
public let imageView: UIImageView
|
||||
|
||||
private let contentTypeBadgeView: UIImageView
|
||||
|
@ -119,28 +117,23 @@ public class PhotoGridViewCell: UICollectionViewCell {
|
|||
public func configure(item: PhotoGridItem) {
|
||||
self.item = item
|
||||
|
||||
self.image = item.asyncThumbnail { image in
|
||||
guard let currentItem = self.item else {
|
||||
return
|
||||
}
|
||||
|
||||
guard currentItem === item else {
|
||||
return
|
||||
}
|
||||
item.asyncThumbnail { [weak self] image in
|
||||
guard let currentItem = self?.item else { return }
|
||||
guard currentItem === item else { return }
|
||||
|
||||
if image == nil {
|
||||
Logger.debug("image == nil")
|
||||
}
|
||||
self.image = image
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self?.image = image
|
||||
}
|
||||
}
|
||||
|
||||
switch item.type {
|
||||
case .video:
|
||||
self.contentTypeBadgeImage = PhotoGridViewCell.videoBadgeImage
|
||||
case .animated:
|
||||
self.contentTypeBadgeImage = PhotoGridViewCell.animatedBadgeImage
|
||||
case .photo:
|
||||
self.contentTypeBadgeImage = nil
|
||||
case .video: self.contentTypeBadgeImage = PhotoGridViewCell.videoBadgeImage
|
||||
case .animated: self.contentTypeBadgeImage = PhotoGridViewCell.animatedBadgeImage
|
||||
case .photo: self.contentTypeBadgeImage = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ import Photos
|
|||
import PromiseKit
|
||||
import CoreServices
|
||||
|
||||
protocol PhotoLibraryDelegate: class {
|
||||
protocol PhotoLibraryDelegate: AnyObject {
|
||||
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary)
|
||||
}
|
||||
|
||||
|
@ -47,16 +47,13 @@ class PhotoPickerAssetItem: PhotoGridItem {
|
|||
return .photo
|
||||
}
|
||||
|
||||
func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? {
|
||||
var syncImageResult: UIImage?
|
||||
func asyncThumbnail(completion: @escaping (UIImage?) -> Void) {
|
||||
var hasLoadedImage = false
|
||||
|
||||
// Surprisingly, iOS will opportunistically run the completion block sync if the image is
|
||||
// already available.
|
||||
photoCollectionContents.requestThumbnail(for: self.asset, thumbnailSize: photoMediaSize.thumbnailSize) { image, _ in
|
||||
DispatchMainThreadSafe({
|
||||
syncImageResult = image
|
||||
|
||||
// Once we've _successfully_ completed (e.g. invoked the completion with
|
||||
// a non-nil image), don't invoke the completion again with a nil argument.
|
||||
if !hasLoadedImage || image != nil {
|
||||
|
@ -68,7 +65,6 @@ class PhotoPickerAssetItem: PhotoGridItem {
|
|||
}
|
||||
})
|
||||
}
|
||||
return syncImageResult
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,27 +1,30 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Photos
|
||||
import PromiseKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc
|
||||
protocol SendMediaNavDelegate: AnyObject {
|
||||
func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController)
|
||||
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], messageText: String?)
|
||||
|
||||
func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String?
|
||||
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?)
|
||||
}
|
||||
|
||||
@objc
|
||||
class SendMediaNavigationController: OWSNavigationController {
|
||||
|
||||
// This is a sensitive constant, if you change it make sure to check
|
||||
// on iPhone5, 6, 6+, X, layouts.
|
||||
static let bottomButtonsCenterOffset: CGFloat = -50
|
||||
|
||||
private let threadId: String
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(threadId: String) {
|
||||
self.threadId = threadId
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Overrides
|
||||
|
||||
override var prefersStatusBarHidden: Bool { return true }
|
||||
|
@ -56,21 +59,20 @@ class SendMediaNavigationController: OWSNavigationController {
|
|||
|
||||
// MARK: -
|
||||
|
||||
@objc
|
||||
public weak var sendMediaNavDelegate: SendMediaNavDelegate?
|
||||
|
||||
@objc
|
||||
public class func showingCameraFirst() -> SendMediaNavigationController {
|
||||
let navController = SendMediaNavigationController()
|
||||
navController.setViewControllers([navController.captureViewController], animated: false)
|
||||
public class func showingCameraFirst(threadId: String) -> SendMediaNavigationController {
|
||||
let navController = SendMediaNavigationController(threadId: threadId)
|
||||
navController.viewControllers = [navController.captureViewController]
|
||||
|
||||
return navController
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func showingMediaLibraryFirst() -> SendMediaNavigationController {
|
||||
let navController = SendMediaNavigationController()
|
||||
navController.setViewControllers([navController.mediaLibraryViewController], animated: false)
|
||||
public class func showingMediaLibraryFirst(threadId: String) -> SendMediaNavigationController {
|
||||
let navController = SendMediaNavigationController(threadId: threadId)
|
||||
navController.viewControllers = [navController.mediaLibraryViewController]
|
||||
|
||||
return navController
|
||||
}
|
||||
|
@ -230,7 +232,11 @@ class SendMediaNavigationController: OWSNavigationController {
|
|||
return
|
||||
}
|
||||
|
||||
let approvalViewController = AttachmentApprovalViewController(mode: .sharedNavigation, attachments: self.attachments)
|
||||
let approvalViewController = AttachmentApprovalViewController(
|
||||
mode: .sharedNavigation,
|
||||
threadId: self.threadId,
|
||||
attachments: self.attachments
|
||||
)
|
||||
approvalViewController.approvalDelegate = self
|
||||
approvalViewController.messageText = sendMediaNavDelegate.sendMediaNavInitialMessageText(self)
|
||||
|
||||
|
@ -276,8 +282,6 @@ extension SendMediaNavigationController: UINavigationControllerDelegate {
|
|||
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
|
||||
if viewController == captureViewController {
|
||||
setNavBarBackgroundColor(to: .black)
|
||||
} else if viewController == mediaLibraryViewController {
|
||||
setNavBarBackgroundColor(to: .white)
|
||||
} else {
|
||||
setNavBarBackgroundColor(to: Colors.navigationBarBackground)
|
||||
}
|
||||
|
@ -305,8 +309,6 @@ extension SendMediaNavigationController: UINavigationControllerDelegate {
|
|||
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
|
||||
if viewController == captureViewController {
|
||||
setNavBarBackgroundColor(to: .black)
|
||||
} else if viewController == mediaLibraryViewController {
|
||||
setNavBarBackgroundColor(to: .white)
|
||||
} else {
|
||||
setNavBarBackgroundColor(to: Colors.navigationBarBackground)
|
||||
}
|
||||
|
@ -441,8 +443,8 @@ extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegat
|
|||
attachmentDraftCollection.remove(attachment: attachment)
|
||||
}
|
||||
|
||||
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) {
|
||||
sendMediaNavDelegate?.sendMediaNav(self, didApproveAttachments: attachments, messageText: messageText)
|
||||
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) {
|
||||
sendMediaNavDelegate?.sendMediaNav(self, didApproveAttachments: attachments, forThreadId: threadId, messageText: messageText)
|
||||
}
|
||||
|
||||
func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) {
|
||||
|
@ -680,3 +682,13 @@ private class DoneButton: UIView {
|
|||
delegate?.doneButtonWasTapped(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SendMediaNavDelegate
|
||||
|
||||
protocol SendMediaNavDelegate: AnyObject {
|
||||
func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController)
|
||||
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?)
|
||||
|
||||
func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String?
|
||||
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,244 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import PromiseKit
|
||||
|
||||
class MediaDismissAnimationController: NSObject {
|
||||
private let mediaItem: Media
|
||||
public let interactionController: MediaInteractiveDismiss?
|
||||
|
||||
var fromView: UIView?
|
||||
var transitionView: UIView?
|
||||
var fromTransitionalOverlayView: UIView?
|
||||
var toTransitionalOverlayView: UIView?
|
||||
var fromMediaFrame: CGRect?
|
||||
var pendingCompletion: (() -> ())?
|
||||
|
||||
init(galleryItem: MediaGalleryViewModel.Item, interactionController: MediaInteractiveDismiss? = nil) {
|
||||
self.mediaItem = .gallery(galleryItem)
|
||||
self.interactionController = interactionController
|
||||
}
|
||||
|
||||
init(image: UIImage, interactionController: MediaInteractiveDismiss? = nil) {
|
||||
self.mediaItem = .image(image)
|
||||
self.interactionController = interactionController
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning {
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return 0.3
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
let containerView = transitionContext.containerView
|
||||
let fromContextProvider: MediaPresentationContextProvider
|
||||
let toContextProvider: MediaPresentationContextProvider
|
||||
|
||||
guard let fromVC: UIViewController = transitionContext.viewController(forKey: .from) else {
|
||||
transitionContext.completeTransition(false)
|
||||
return
|
||||
}
|
||||
guard let toVC: UIViewController = transitionContext.viewController(forKey: .to) else {
|
||||
transitionContext.completeTransition(false)
|
||||
return
|
||||
}
|
||||
|
||||
switch fromVC {
|
||||
case let contextProvider as MediaPresentationContextProvider:
|
||||
fromContextProvider = contextProvider
|
||||
|
||||
case let navController as UINavigationController:
|
||||
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
|
||||
transitionContext.completeTransition(false)
|
||||
return
|
||||
}
|
||||
|
||||
fromContextProvider = contextProvider
|
||||
|
||||
default:
|
||||
transitionContext.completeTransition(false)
|
||||
return
|
||||
}
|
||||
|
||||
switch toVC {
|
||||
case let contextProvider as MediaPresentationContextProvider:
|
||||
toVC.view.layoutIfNeeded()
|
||||
toContextProvider = contextProvider
|
||||
|
||||
case let navController as UINavigationController:
|
||||
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
|
||||
transitionContext.completeTransition(false)
|
||||
return
|
||||
}
|
||||
|
||||
toVC.view.layoutIfNeeded()
|
||||
toContextProvider = contextProvider
|
||||
|
||||
default:
|
||||
transitionContext.completeTransition(false)
|
||||
return
|
||||
}
|
||||
|
||||
guard let fromMediaContext: MediaPresentationContext = fromContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView) else {
|
||||
transitionContext.completeTransition(false)
|
||||
return
|
||||
}
|
||||
|
||||
guard let presentationImage: UIImage = mediaItem.image else {
|
||||
transitionContext.completeTransition(true)
|
||||
return
|
||||
}
|
||||
|
||||
// fromView will be nil if doing a presentation, in which case we don't want to add the view -
|
||||
// it will automatically be added to the view hierarchy, in front of the VC we're presenting from
|
||||
if let fromView: UIView = transitionContext.view(forKey: .from) {
|
||||
self.fromView = fromView
|
||||
containerView.addSubview(fromView)
|
||||
}
|
||||
|
||||
// toView will be nil if doing a modal dismiss, in which case we don't want to add the view -
|
||||
// it's already in the view hierarchy, behind the VC we're dismissing.
|
||||
if let toView: UIView = transitionContext.view(forKey: .to) {
|
||||
containerView.insertSubview(toView, at: 0)
|
||||
}
|
||||
|
||||
let toMediaContext: MediaPresentationContext? = toContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView)
|
||||
let duration: CGFloat = transitionDuration(using: transitionContext)
|
||||
|
||||
fromMediaContext.mediaView.alpha = 0
|
||||
toMediaContext?.mediaView.alpha = 0
|
||||
|
||||
let transitionView = UIImageView(image: presentationImage)
|
||||
transitionView.frame = fromMediaContext.presentationFrame
|
||||
transitionView.contentMode = MediaView.contentMode
|
||||
transitionView.layer.masksToBounds = true
|
||||
transitionView.layer.cornerRadius = fromMediaContext.cornerRadius
|
||||
transitionView.layer.maskedCorners = (toMediaContext?.cornerMask ?? fromMediaContext.cornerMask)
|
||||
containerView.addSubview(transitionView)
|
||||
|
||||
// Add any UI elements which should appear above the media view
|
||||
self.fromTransitionalOverlayView = {
|
||||
guard let (overlayView, overlayViewFrame) = fromContextProvider.snapshotOverlayView(in: containerView) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
overlayView.frame = overlayViewFrame
|
||||
containerView.addSubview(overlayView)
|
||||
|
||||
return overlayView
|
||||
}()
|
||||
self.toTransitionalOverlayView = { [weak self] in
|
||||
guard let (overlayView, overlayViewFrame) = toContextProvider.snapshotOverlayView(in: containerView) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only fade in the 'toTransitionalOverlayView' if it's bigger than the origin
|
||||
// one (makes it look cleaner as you don't get the crossfade effect)
|
||||
if (self?.fromTransitionalOverlayView?.frame.size.height ?? 0) > overlayViewFrame.height {
|
||||
overlayView.alpha = 0
|
||||
}
|
||||
|
||||
overlayView.frame = overlayViewFrame
|
||||
|
||||
if let fromTransitionalOverlayView = self?.fromTransitionalOverlayView {
|
||||
containerView.insertSubview(overlayView, belowSubview: fromTransitionalOverlayView)
|
||||
}
|
||||
else {
|
||||
containerView.addSubview(overlayView)
|
||||
}
|
||||
|
||||
return overlayView
|
||||
}()
|
||||
|
||||
self.transitionView = transitionView
|
||||
self.fromMediaFrame = transitionView.frame
|
||||
|
||||
self.pendingCompletion = {
|
||||
let destinationFromAlpha: CGFloat
|
||||
let destinationFrame: CGRect
|
||||
let destinationCornerRadius: CGFloat
|
||||
|
||||
if transitionContext.transitionWasCancelled {
|
||||
destinationFromAlpha = 1
|
||||
destinationFrame = fromMediaContext.presentationFrame
|
||||
destinationCornerRadius = fromMediaContext.cornerRadius
|
||||
}
|
||||
else if let toMediaContext: MediaPresentationContext = toMediaContext {
|
||||
destinationFromAlpha = 0
|
||||
destinationFrame = toMediaContext.presentationFrame
|
||||
destinationCornerRadius = toMediaContext.cornerRadius
|
||||
}
|
||||
else {
|
||||
// `toMediaContext` can be nil if the target item is scrolled off of the
|
||||
// contextProvider's screen, so we synthesize a context to dismiss the item
|
||||
// off screen
|
||||
destinationFromAlpha = 0
|
||||
destinationFrame = fromMediaContext.presentationFrame
|
||||
.offsetBy(dx: 0, dy: (containerView.bounds.height * 2))
|
||||
destinationCornerRadius = fromMediaContext.cornerRadius
|
||||
}
|
||||
|
||||
UIView.animate(
|
||||
withDuration: duration,
|
||||
delay: 0,
|
||||
options: [.beginFromCurrentState, .curveEaseInOut],
|
||||
animations: { [weak self] in
|
||||
self?.fromTransitionalOverlayView?.alpha = destinationFromAlpha
|
||||
self?.fromView?.alpha = destinationFromAlpha
|
||||
self?.toTransitionalOverlayView?.alpha = (1.0 - destinationFromAlpha)
|
||||
transitionView.frame = destinationFrame
|
||||
transitionView.layer.cornerRadius = destinationCornerRadius
|
||||
},
|
||||
completion: { [weak self] _ in
|
||||
self?.fromView?.alpha = 1
|
||||
fromMediaContext.mediaView.alpha = 1
|
||||
toMediaContext?.mediaView.alpha = 1
|
||||
transitionView.removeFromSuperview()
|
||||
self?.fromTransitionalOverlayView?.removeFromSuperview()
|
||||
self?.toTransitionalOverlayView?.removeFromSuperview()
|
||||
|
||||
if transitionContext.transitionWasCancelled {
|
||||
// The "to" view will be nil if we're doing a modal dismiss, in which case
|
||||
// we wouldn't want to remove the toView.
|
||||
transitionContext.view(forKey: .to)?.removeFromSuperview()
|
||||
|
||||
// Note: We shouldn't need to do this but for some reason it's not
|
||||
// automatically getting re-enabled so we manually enable it
|
||||
transitionContext.view(forKey: .from)?.isUserInteractionEnabled = true
|
||||
}
|
||||
else {
|
||||
transitionContext.view(forKey: .from)?.removeFromSuperview()
|
||||
|
||||
// Note: We shouldn't need to do this but for some reason it's not
|
||||
// automatically getting re-enabled so we manually enable it
|
||||
transitionContext.view(forKey: .to)?.isUserInteractionEnabled = true
|
||||
}
|
||||
|
||||
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// The interactive transition will call the 'pendingCompletion' when it completes so don't call it here
|
||||
guard !transitionContext.isInteractive else { return }
|
||||
|
||||
self.pendingCompletion?()
|
||||
self.pendingCompletion = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaDismissAnimationController: InteractiveDismissDelegate {
|
||||
func interactiveDismissUpdate(_ interactiveDismiss: UIPercentDrivenInteractiveTransition, didChangeTouchOffset offset: CGPoint) {
|
||||
guard let transitionView: UIView = transitionView else { return } // Transition hasn't started yet
|
||||
guard let fromMediaFrame: CGRect = fromMediaFrame else { return }
|
||||
|
||||
fromView?.alpha = (1.0 - interactiveDismiss.percentComplete)
|
||||
transitionView.center = fromMediaFrame.offsetBy(dx: offset.x, dy: offset.y).center
|
||||
}
|
||||
|
||||
func interactiveDismissDidFinish(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) {
|
||||
self.pendingCompletion?()
|
||||
self.pendingCompletion = nil
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue