Merge pull request #612 from mpretty-cyro/feature/database-refactor

Database refactor
This commit is contained in:
Morgan Pretty 2022-08-08 15:11:29 +10:00 committed by GitHub
commit 7ec48baffa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
729 changed files with 65027 additions and 47224 deletions

49
Podfile
View File

@ -1,4 +1,4 @@
platform :ios, '12.0' platform :ios, '13.0'
source 'https://github.com/CocoaPods/Specs.git' source 'https://github.com/CocoaPods/Specs.git'
use_frameworks! use_frameworks!
@ -8,7 +8,12 @@ inhibit_all_warnings!
abstract_target 'GlobalDependencies' do abstract_target 'GlobalDependencies' do
pod 'PromiseKit' pod 'PromiseKit'
pod 'CryptoSwift' pod 'CryptoSwift'
pod 'Sodium', '~> 0.9.1' # FIXME: If https://github.com/jedisct1/swift-sodium/pull/249 gets resolved then revert this back to the standard pod
pod 'Sodium', :git => 'https://github.com/oxen-io/session-ios-swift-sodium.git', branch: 'session-build'
pod 'GRDB.swift/SQLCipher'
pod 'SQLCipher', '~> 4.0'
# FIXME: We want to remove this once it's been long enough since the migration to GRDB
pod 'YapDatabase/SQLCipher', :git => 'https://github.com/oxen-io/session-ios-yap-database.git', branch: 'signal-release' pod 'YapDatabase/SQLCipher', :git => 'https://github.com/oxen-io/session-ios-yap-database.git', branch: 'signal-release'
pod 'WebRTC-lib' pod 'WebRTC-lib'
pod 'SocketRocket', '~> 0.5.1' pod 'SocketRocket', '~> 0.5.1'
@ -18,14 +23,14 @@ abstract_target 'GlobalDependencies' do
pod 'Reachability' pod 'Reachability'
pod 'PureLayout', '~> 3.1.8' pod 'PureLayout', '~> 3.1.8'
pod 'NVActivityIndicatorView' pod 'NVActivityIndicatorView'
pod 'YYImage', git: 'https://github.com/signalapp/YYImage' pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
pod 'Mantle', git: 'https://github.com/signalapp/Mantle', branch: 'signal-master'
pod 'ZXingObjC' pod 'ZXingObjC'
pod 'DifferenceKit'
end end
# Dependencies to be included only in all extensions/frameworks # Dependencies to be included only in all extensions/frameworks
abstract_target 'FrameworkAndExtensionDependencies' do abstract_target 'FrameworkAndExtensionDependencies' do
pod 'Curve25519Kit', git: 'https://github.com/signalapp/Curve25519Kit.git' pod 'Curve25519Kit', git: 'https://github.com/oxen-io/session-ios-curve-25519-kit.git', branch: 'session-version'
pod 'SignalCoreKit', git: 'https://github.com/oxen-io/session-ios-core-kit', branch: 'session-version' pod 'SignalCoreKit', git: 'https://github.com/oxen-io/session-ios-core-kit', branch: 'session-version'
target 'SessionNotificationServiceExtension' target 'SessionNotificationServiceExtension'
@ -35,10 +40,10 @@ abstract_target 'GlobalDependencies' do
abstract_target 'ExtendedDependencies' do abstract_target 'ExtendedDependencies' do
pod 'AFNetworking' pod 'AFNetworking'
pod 'PureLayout', '~> 3.1.8' pod 'PureLayout', '~> 3.1.8'
pod 'Mantle', git: 'https://github.com/signalapp/Mantle', branch: 'signal-master'
target 'SessionShareExtension' do target 'SessionShareExtension' do
pod 'NVActivityIndicatorView' pod 'NVActivityIndicatorView'
pod 'DifferenceKit'
end end
target 'SignalUtilitiesKit' do target 'SignalUtilitiesKit' do
@ -46,17 +51,34 @@ abstract_target 'GlobalDependencies' do
pod 'Reachability' pod 'Reachability'
pod 'SAMKeychain' pod 'SAMKeychain'
pod 'SwiftProtobuf', '~> 1.5.0' pod 'SwiftProtobuf', '~> 1.5.0'
pod 'YYImage', git: 'https://github.com/signalapp/YYImage' pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
pod 'DifferenceKit'
end end
target 'SessionMessagingKit' do target 'SessionMessagingKit' do
pod 'Reachability' pod 'Reachability'
pod 'SAMKeychain' pod 'SAMKeychain'
pod 'SwiftProtobuf', '~> 1.5.0' pod 'SwiftProtobuf', '~> 1.5.0'
pod 'DifferenceKit'
target 'SessionMessagingKitTests' do
inherit! :complete
pod 'Quick'
pod 'Nimble'
end
end end
target 'SessionUtilitiesKit' do target 'SessionUtilitiesKit' do
pod 'SAMKeychain' pod 'SAMKeychain'
pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
target 'SessionUtilitiesKitTests' do
inherit! :complete
pod 'Quick'
pod 'Nimble'
end
end end
end end
end end
@ -69,6 +91,7 @@ target 'SessionUIKit'
post_install do |installer| post_install do |installer|
enable_whole_module_optimization_for_crypto_swift(installer) enable_whole_module_optimization_for_crypto_swift(installer)
set_minimum_deployment_target(installer) set_minimum_deployment_target(installer)
enable_fts5_support(installer)
end end
def enable_whole_module_optimization_for_crypto_swift(installer) def enable_whole_module_optimization_for_crypto_swift(installer)
@ -85,7 +108,17 @@ end
def set_minimum_deployment_target(installer) def set_minimum_deployment_target(installer)
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
target.build_configurations.each do |build_configuration| target.build_configurations.each do |build_configuration|
build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
end
end
end
# This is to ensure we enable support for FastTextSearch5 (might not be enabled by default)
# For more info see https://github.com/groue/GRDB.swift/blob/master/Documentation/FullTextSearch.md#enabling-fts5-support
def enable_fts5_support(installer)
installer.pods_project.targets.select { |target| target.name == "GRDB.swift" }.each do |target|
target.build_configurations.each do |config|
config.build_settings['OTHER_SWIFT_FLAGS'] = "$(inherited) -D SQLITE_ENABLE_FTS5"
end end
end end
end end

View File

@ -21,9 +21,24 @@ PODS:
- Curve25519Kit (2.1.0): - Curve25519Kit (2.1.0):
- CocoaLumberjack - CocoaLumberjack
- SignalCoreKit - SignalCoreKit
- Mantle (2.1.0): - DifferenceKit (1.2.0):
- Mantle/extobjc (= 2.1.0) - DifferenceKit/Core (= 1.2.0)
- Mantle/extobjc (2.1.0) - DifferenceKit/UIKitExtension (= 1.2.0)
- DifferenceKit/Core (1.2.0)
- DifferenceKit/UIKitExtension (1.2.0):
- DifferenceKit/Core
- GRDB.swift/SQLCipher (5.26.0):
- SQLCipher (>= 3.4.0)
- libwebp (1.2.1):
- libwebp/demux (= 1.2.1)
- libwebp/mux (= 1.2.1)
- libwebp/webp (= 1.2.1)
- libwebp/demux (1.2.1):
- libwebp/webp
- libwebp/mux (1.2.1):
- libwebp/demux
- libwebp/webp (1.2.1)
- Nimble (10.0.0)
- NVActivityIndicatorView (5.1.1): - NVActivityIndicatorView (5.1.1):
- NVActivityIndicatorView/Base (= 5.1.1) - NVActivityIndicatorView/Base (= 5.1.1)
- NVActivityIndicatorView/Base (5.1.1) - NVActivityIndicatorView/Base (5.1.1)
@ -38,6 +53,7 @@ PODS:
- PromiseKit/UIKit (6.15.3): - PromiseKit/UIKit (6.15.3):
- PromiseKit/CorePromise - PromiseKit/CorePromise
- PureLayout (3.1.9) - PureLayout (3.1.9)
- Quick (5.0.1)
- Reachability (3.2) - Reachability (3.2)
- SAMKeychain (1.5.3) - SAMKeychain (1.5.3)
- SignalCoreKit (1.0.0): - SignalCoreKit (1.0.0):
@ -114,9 +130,10 @@ PODS:
- YapDatabase/SQLCipher/Core - YapDatabase/SQLCipher/Core
- YapDatabase/SQLCipher/Extensions/View (3.1.1): - YapDatabase/SQLCipher/Extensions/View (3.1.1):
- YapDatabase/SQLCipher/Core - YapDatabase/SQLCipher/Core
- YYImage (1.0.4):
- YYImage/Core (= 1.0.4)
- YYImage/Core (1.0.4) - YYImage/Core (1.0.4)
- YYImage/libwebp (1.0.4):
- libwebp
- YYImage/Core
- ZXingObjC (3.6.5): - ZXingObjC (3.6.5):
- ZXingObjC/All (= 3.6.5) - ZXingObjC/All (= 3.6.5)
- ZXingObjC/All (3.6.5) - ZXingObjC/All (3.6.5)
@ -124,20 +141,24 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- AFNetworking - AFNetworking
- CryptoSwift - CryptoSwift
- Curve25519Kit (from `https://github.com/signalapp/Curve25519Kit.git`) - Curve25519Kit (from `https://github.com/oxen-io/session-ios-curve-25519-kit.git`, branch `session-version`)
- Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`) - DifferenceKit
- GRDB.swift/SQLCipher
- Nimble
- NVActivityIndicatorView - NVActivityIndicatorView
- PromiseKit - PromiseKit
- PureLayout (~> 3.1.8) - PureLayout (~> 3.1.8)
- Quick
- Reachability - Reachability
- SAMKeychain - SAMKeychain
- SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`) - SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`)
- SocketRocket (~> 0.5.1) - SocketRocket (~> 0.5.1)
- Sodium (~> 0.9.1) - Sodium (from `https://github.com/oxen-io/session-ios-swift-sodium.git`, branch `session-build`)
- SQLCipher (~> 4.0)
- SwiftProtobuf (~> 1.5.0) - SwiftProtobuf (~> 1.5.0)
- WebRTC-lib - WebRTC-lib
- YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`) - YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`)
- YYImage (from `https://github.com/signalapp/YYImage`) - YYImage/libwebp (from `https://github.com/signalapp/YYImage`)
- ZXingObjC - ZXingObjC
SPEC REPOS: SPEC REPOS:
@ -145,14 +166,18 @@ SPEC REPOS:
- AFNetworking - AFNetworking
- CocoaLumberjack - CocoaLumberjack
- CryptoSwift - CryptoSwift
- DifferenceKit
- GRDB.swift
- libwebp
- Nimble
- NVActivityIndicatorView - NVActivityIndicatorView
- OpenSSL-Universal - OpenSSL-Universal
- PromiseKit - PromiseKit
- PureLayout - PureLayout
- Quick
- Reachability - Reachability
- SAMKeychain - SAMKeychain
- SocketRocket - SocketRocket
- Sodium
- SQLCipher - SQLCipher
- SwiftProtobuf - SwiftProtobuf
- WebRTC-lib - WebRTC-lib
@ -160,13 +185,14 @@ SPEC REPOS:
EXTERNAL SOURCES: EXTERNAL SOURCES:
Curve25519Kit: Curve25519Kit:
:git: https://github.com/signalapp/Curve25519Kit.git :branch: session-version
Mantle: :git: https://github.com/oxen-io/session-ios-curve-25519-kit.git
:branch: signal-master
:git: https://github.com/signalapp/Mantle
SignalCoreKit: SignalCoreKit:
:branch: session-version :branch: session-version
:git: https://github.com/oxen-io/session-ios-core-kit :git: https://github.com/oxen-io/session-ios-core-kit
Sodium:
:branch: session-build
:git: https://github.com/oxen-io/session-ios-swift-sodium.git
YapDatabase: YapDatabase:
:branch: signal-release :branch: signal-release
:git: https://github.com/oxen-io/session-ios-yap-database.git :git: https://github.com/oxen-io/session-ios-yap-database.git
@ -175,14 +201,14 @@ EXTERNAL SOURCES:
CHECKOUT OPTIONS: CHECKOUT OPTIONS:
Curve25519Kit: Curve25519Kit:
:commit: 4fc1c10e98fff2534b5379a9bb587430fdb8e577 :commit: b79c2ace600bfd3784e9c33cf1f254b121312edc
:git: https://github.com/signalapp/Curve25519Kit.git :git: https://github.com/oxen-io/session-ios-curve-25519-kit.git
Mantle:
:commit: e7e46253bb01ce39525d90aa69ed9e85e758bfc4
:git: https://github.com/signalapp/Mantle
SignalCoreKit: SignalCoreKit:
:commit: 4590c2737a2b5dc0ef4ace9f9019b581caccc1de :commit: 4590c2737a2b5dc0ef4ace9f9019b581caccc1de
:git: https://github.com/oxen-io/session-ios-core-kit :git: https://github.com/oxen-io/session-ios-core-kit
Sodium:
:commit: 4ecfe2ddfd75e7b396c57975b4163e5c8cf4d5cc
:git: https://github.com/oxen-io/session-ios-swift-sodium.git
YapDatabase: YapDatabase:
:commit: d84069e25e12a16ab4422e5258127a04b70489ad :commit: d84069e25e12a16ab4422e5258127a04b70489ad
:git: https://github.com/oxen-io/session-ios-yap-database.git :git: https://github.com/oxen-io/session-ios-yap-database.git
@ -195,16 +221,20 @@ SPEC CHECKSUMS:
CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646 CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646
CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17 CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805
GRDB.swift: 1395cb3556df6b16ed69dfc74c3886abc75d2825
libwebp: 98a37e597e40bfdb4c911fc98f2c53d0b12d05fc
Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84
NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667
OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2 OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2
PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5 PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5
PureLayout: 5fb5e5429519627d60d079ccb1eaa7265ce7cf88 PureLayout: 5fb5e5429519627d60d079ccb1eaa7265ce7cf88
Quick: 749aa754fd1e7d984f2000fe051e18a3a9809179
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96 Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d
SocketRocket: d57c7159b83c3c6655745cd15302aa24b6bae531 SocketRocket: d57c7159b83c3c6655745cd15302aa24b6bae531
Sodium: 23d11554ecd556196d313cf6130d406dfe7ac6da Sodium: a7d42cb46e789d2630fa552d35870b416ed055ae
SQLCipher: 98dc22f27c0b1790d39e710d440f22a466ebdb59 SQLCipher: 98dc22f27c0b1790d39e710d440f22a466ebdb59
SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2 SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2
WebRTC-lib: 508fe02efa0c1a3a8867082a77d24c9be5d29aeb WebRTC-lib: 508fe02efa0c1a3a8867082a77d24c9be5d29aeb
@ -212,6 +242,6 @@ SPEC CHECKSUMS:
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
PODFILE CHECKSUM: a3d89a6cc8735285fd51348ca05cea71f2c28872 PODFILE CHECKSUM: f0857369c4831b2e5c1946345e76e493f3286805
COCOAPODS: 1.11.2 COCOAPODS: 1.11.3

View File

@ -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

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1020" LastUpgradeVersion = "1320"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@ -20,27 +20,15 @@
ReferencedContainer = "container:Session.xcodeproj"> ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "11319FE11E0F163FEF714A606CCC265F"
BuildableName = "SignalServiceKit.framework"
BlueprintName = "SignalServiceKit"
ReferencedContainer = "container:Pods/Pods.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries> </BuildActionEntries>
</BuildAction> </BuildAction>
<TestAction <TestAction
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "NO"> shouldUseLaunchSchemeArgsEnv = "NO"
codeCoverageEnabled = "YES"
onlyGenerateCoverageForSpecifiedTargets = "YES">
<MacroExpansion> <MacroExpansion>
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
@ -57,173 +45,87 @@
isEnabled = "YES"> isEnabled = "YES">
</EnvironmentVariable> </EnvironmentVariable>
</EnvironmentVariables> </EnvironmentVariables>
<CodeCoverageTargets>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D221A088169C9E5E00537ABF"
BuildableName = "Session.app"
BlueprintName = "Session"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7BC01A3A241F40AB00BC7C55"
BuildableName = "SessionNotificationServiceExtension.appex"
BlueprintName = "SessionNotificationServiceExtension"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "453518671FC635DD00210559"
BuildableName = "SessionShareExtension.appex"
BlueprintName = "SessionShareExtension"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
BuildableName = "SessionMessagingKit.framework"
BlueprintName = "SessionMessagingKit"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C3C2A59E255385C100C340D1"
BuildableName = "SessionSnodeKit.framework"
BlueprintName = "SessionSnodeKit"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C331FF1A2558F9D300070591"
BuildableName = "SessionUIKit.framework"
BlueprintName = "SessionUIKit"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
BuildableName = "SessionUtilitiesKit.framework"
BlueprintName = "SessionUtilitiesKit"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C33FD9AA255A548A00E217F9"
BuildableName = "SignalUtilitiesKit.framework"
BlueprintName = "SignalUtilitiesKit"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</CodeCoverageTargets>
<Testables> <Testables>
<TestableReference <TestableReference
skipped = "NO"> skipped = "NO"
parallelizable = "YES"
testExecutionOrdering = "random">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "D221A0A9169C9E5F00537ABF" BlueprintIdentifier = "FDC4388D27B9FFC700C60D73"
BuildableName = "SignalTests.xctest" BuildableName = "SessionMessagingKitTests.xctest"
BlueprintName = "SignalTests" BlueprintName = "SessionMessagingKitTests"
ReferencedContainer = "container:Session.xcodeproj"> ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference> </BuildableReference>
</TestableReference> </TestableReference>
<TestableReference <TestableReference
skipped = "NO"> skipped = "NO"
parallelizable = "YES"
testExecutionOrdering = "random">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "B772E882F193AA2F25932C514BBF0805" BlueprintIdentifier = "FD83B9AE27CF200A005E1583"
BuildableName = "SignalServiceKit-Unit-Tests.xctest" BuildableName = "SessionUtilitiesKitTests.xctest"
BlueprintName = "SignalServiceKit-Unit-Tests" BlueprintName = "SessionUtilitiesKitTests"
ReferencedContainer = "container:Pods/Pods.xcodeproj"> ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
<SkippedTests>
<Test
Identifier = "ContactSortingTest">
</Test>
<Test
Identifier = "DeviceNamesTest">
</Test>
<Test
Identifier = "JobQueueTest">
</Test>
<Test
Identifier = "MessageSenderJobQueueTest">
</Test>
<Test
Identifier = "OWSAnalyticsTests">
</Test>
<Test
Identifier = "OWSDeviceProvisionerTest">
</Test>
<Test
Identifier = "OWSDisappearingMessageFinderTest">
</Test>
<Test
Identifier = "OWSDisappearingMessagesConfigurationTest">
</Test>
<Test
Identifier = "OWSDisappearingMessagesJobTest">
</Test>
<Test
Identifier = "OWSFingerprintTest">
</Test>
<Test
Identifier = "OWSIncomingMessageFinderTest">
</Test>
<Test
Identifier = "OWSLinkPreviewTest">
</Test>
<Test
Identifier = "OWSMessageManagerTest">
</Test>
<Test
Identifier = "OWSMessageSenderTest">
</Test>
<Test
Identifier = "OWSProvisioningCipherTest">
</Test>
<Test
Identifier = "OWSSignalAddressTest">
</Test>
<Test
Identifier = "OWSUDManagerTest">
</Test>
<Test
Identifier = "PhoneNumberTest">
</Test>
<Test
Identifier = "PhoneNumberUtilTest">
</Test>
<Test
Identifier = "SSKBaseTestObjC">
</Test>
<Test
Identifier = "SSKBaseTestSwift">
</Test>
<Test
Identifier = "SSKMessageSenderJobRecordTest">
</Test>
<Test
Identifier = "SignalRecipientTest">
</Test>
<Test
Identifier = "SignedPreKeyDeletionTests">
</Test>
<Test
Identifier = "TSContactThreadTest">
</Test>
<Test
Identifier = "TSGroupThreadTest">
</Test>
<Test
Identifier = "TSMessageStorageTests">
</Test>
<Test
Identifier = "TSMessageTest">
</Test>
<Test
Identifier = "TSOutgoingMessageTest">
</Test>
<Test
Identifier = "TSStorageIdentityKeyStoreTests">
</Test>
<Test
Identifier = "TSStoragePreKeyStoreTests">
</Test>
<Test
Identifier = "TSThreadTest">
</Test>
</SkippedTests>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5C9F6BA9ADC4724B2612C9F20FBE2076"
BuildableName = "SignalCoreKit-Unit-Tests.xctest"
BlueprintName = "SignalCoreKit-Unit-Tests"
ReferencedContainer = "container:Pods/Pods.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF2BCB29C9D47F15FB156F1EC64E5CC2"
BuildableName = "AxolotlKit-Unit-Tests.xctest"
BlueprintName = "AxolotlKit-Unit-Tests"
ReferencedContainer = "container:Pods/Pods.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "78DE33AED82B26B4B8D899CC403003AF"
BuildableName = "Curve25519Kit-Unit-Tests.xctest"
BlueprintName = "Curve25519Kit-Unit-Tests"
ReferencedContainer = "container:Pods/Pods.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AF7FC2C93AA68E33600807F168BD483A"
BuildableName = "HKDFKit-Unit-Tests.xctest"
BlueprintName = "HKDFKit-Unit-Tests"
ReferencedContainer = "container:Pods/Pods.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B086B0C72F8A5814FF48795531F21635"
BuildableName = "SignalMetadataKit-Unit-Tests.xctest"
BlueprintName = "SignalMetadataKit-Unit-Tests"
ReferencedContainer = "container:Pods/Pods.xcodeproj">
</BuildableReference> </BuildableReference>
</TestableReference> </TestableReference>
</Testables> </Testables>

View File

@ -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>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1140" LastUpgradeVersion = "1320"
wasCreatedForAppExtension = "YES" wasCreatedForAppExtension = "YES"
version = "2.0"> version = "2.0">
<BuildAction <BuildAction
@ -43,6 +43,16 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"> shouldUseLaunchSchemeArgsEnv = "YES">
<Testables> <Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FDC4388027B9FF1E00C60D73"
BuildableName = "SessionTests.xctest"
BlueprintName = "SessionTests"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables> </Testables>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
@ -73,6 +83,7 @@
savedToolIdentifier = "" savedToolIdentifier = ""
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2"> launchAutomaticallySubstyle = "2">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1020" LastUpgradeVersion = "1320"
wasCreatedForAppExtension = "YES" wasCreatedForAppExtension = "YES"
version = "2.0"> version = "2.0">
<BuildAction <BuildAction
@ -52,6 +52,16 @@
</BuildableReference> </BuildableReference>
</MacroExpansion> </MacroExpansion>
<Testables> <Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FDC4388027B9FF1E00C60D73"
BuildableName = "SessionTests.xctest"
BlueprintName = "SessionTests"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables> </Testables>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
@ -83,6 +93,7 @@
savedToolIdentifier = "" savedToolIdentifier = ""
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2"> launchAutomaticallySubstyle = "2">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">

View File

@ -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>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1210" LastUpgradeVersion = "1320"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View File

@ -1,37 +1,33 @@
import Foundation // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import WebRTC
import SessionMessagingKit
import PromiseKit
import CallKit
public final class SessionCall: NSObject, WebRTCSessionDelegate { import Foundation
import CallKit
import GRDB
import WebRTC
import PromiseKit
import SignalUtilitiesKit
import SessionMessagingKit
public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
@objc static let isEnabled = true @objc static let isEnabled = true
// MARK: Metadata Properties // MARK: - Metadata Properties
let uuid: String public let uuid: String
let callID: UUID // This is for CallKit public let callId: UUID // This is for CallKit
let sessionID: String let sessionId: String
let mode: Mode let mode: CallMode
var audioMode: AudioMode var audioMode: AudioMode
let webRTCSession: WebRTCSession public let webRTCSession: WebRTCSession
let isOutgoing: Bool let isOutgoing: Bool
var remoteSDP: RTCSessionDescription? = nil var remoteSDP: RTCSessionDescription? = nil
var callMessageID: String? var callInteractionId: Int64?
var answerCallAction: CXAnswerCallAction? = nil var answerCallAction: CXAnswerCallAction? = nil
var contactName: String {
let contact = Storage.shared.getContact(with: self.sessionID)
return contact?.displayName(for: Contact.Context.regular) ?? "\(self.sessionID.prefix(4))...\(self.sessionID.suffix(4))"
}
var profilePicture: UIImage {
if let result = OWSProfileManager.shared().profileAvatar(forRecipientId: sessionID) {
return result
} else {
return Identicon.generatePlaceholderIcon(seed: sessionID, text: contactName, size: 300)
}
}
// MARK: Control let contactName: String
let profilePicture: UIImage
// MARK: - Control
lazy public var videoCapturer: RTCVideoCapturer = { lazy public var videoCapturer: RTCVideoCapturer = {
return RTCCameraVideoCapturer(delegate: webRTCSession.localVideoSource) return RTCCameraVideoCapturer(delegate: webRTCSession.localVideoSource)
}() }()
@ -61,21 +57,8 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
} }
} }
// MARK: Mode // MARK: - Audio I/O mode
enum Mode {
case offer
case answer
}
// MARK: End call mode
enum EndCallMode {
case local
case remote
case unanswered
case answeredElsewhere
}
// MARK: Audio I/O mode
enum AudioMode { enum AudioMode {
case earpiece case earpiece
case speaker case speaker
@ -83,7 +66,8 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
case bluetooth case bluetooth
} }
// MARK: Call State Properties // MARK: - Call State Properties
var connectingDate: Date? { var connectingDate: Date? {
didSet { didSet {
stateDidChange?() stateDidChange?()
@ -112,7 +96,8 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
} }
} }
// MARK: State Change Callbacks // MARK: - State Change Callbacks
var stateDidChange: (() -> Void)? var stateDidChange: (() -> Void)?
var hasStartedConnectingDidChange: (() -> Void)? var hasStartedConnectingDidChange: (() -> Void)?
var hasConnectedDidChange: (() -> Void)? var hasConnectedDidChange: (() -> Void)?
@ -121,8 +106,9 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
var hasStartedReconnecting: (() -> Void)? var hasStartedReconnecting: (() -> Void)?
var hasReconnected: (() -> Void)? var hasReconnected: (() -> Void)?
// MARK: Derived Properties // MARK: - Derived Properties
var hasStartedConnecting: Bool {
public var hasStartedConnecting: Bool {
get { return connectingDate != nil } get { return connectingDate != nil }
set { connectingDate = newValue ? Date() : nil } set { connectingDate = newValue ? Date() : nil }
} }
@ -153,73 +139,114 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
var reconnectTimer: Timer? = nil var reconnectTimer: Timer? = nil
// MARK: Initialization // MARK: - Initialization
init(for sessionID: String, uuid: String, mode: Mode, outgoing: Bool = false) {
self.sessionID = sessionID init(_ db: Database, for sessionId: String, uuid: String, mode: CallMode, outgoing: Bool = false) {
self.sessionId = sessionId
self.uuid = uuid self.uuid = uuid
self.callID = UUID() self.callId = UUID()
self.mode = mode self.mode = mode
self.audioMode = .earpiece self.audioMode = .earpiece
self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionID, with: uuid) self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionId, with: uuid)
self.isOutgoing = outgoing self.isOutgoing = outgoing
self.contactName = Profile.displayName(db, id: sessionId, threadVariant: .contact)
self.profilePicture = ProfileManager.profileAvatar(db, id: sessionId)
.map { UIImage(data: $0) }
.defaulting(to: Identicon.generatePlaceholderIcon(seed: sessionId, text: self.contactName, size: 300))
WebRTCSession.current = self.webRTCSession WebRTCSession.current = self.webRTCSession
super.init()
self.webRTCSession.delegate = self self.webRTCSession.delegate = self
if AppEnvironment.shared.callManager.currentCall == nil { if AppEnvironment.shared.callManager.currentCall == nil {
AppEnvironment.shared.callManager.currentCall = self AppEnvironment.shared.callManager.currentCall = self
} else { }
else {
SNLog("[Calls] A call is ongoing.") SNLog("[Calls] A call is ongoing.")
} }
} }
func reportIncomingCallIfNeeded(completion: @escaping (Error?) -> Void) { func reportIncomingCallIfNeeded(completion: @escaping (Error?) -> Void) {
guard case .answer = mode else { return } guard case .answer = mode else {
SessionCallManager.reportFakeCall(info: "Call not in answer mode")
return
}
setupTimeoutTimer() setupTimeoutTimer()
AppEnvironment.shared.callManager.reportIncomingCall(self, callerName: contactName) { error in AppEnvironment.shared.callManager.reportIncomingCall(self, callerName: contactName) { error in
completion(error) completion(error)
} }
} }
func didReceiveRemoteSDP(sdp: RTCSessionDescription) { public func didReceiveRemoteSDP(sdp: RTCSessionDescription) {
guard Thread.isMainThread else {
DispatchQueue.main.async {
self.didReceiveRemoteSDP(sdp: sdp)
}
return
}
SNLog("[Calls] Did receive remote sdp.") SNLog("[Calls] Did receive remote sdp.")
remoteSDP = sdp remoteSDP = sdp
if hasStartedConnecting { if hasStartedConnecting {
webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally
} }
} }
// MARK: Actions // MARK: - Actions
func startSessionCall() {
guard case .offer = mode else { return } public func startSessionCall(_ db: Database) {
guard let thread = TSContactThread.fetch(uniqueId: TSContactThread.threadID(fromContactSessionID: sessionID)) else { return } let sessionId: String = self.sessionId
let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .outgoing)
let message = CallMessage() guard
message.sender = getUserHexEncodedPublicKey() case .offer = mode,
message.sentTimestamp = NSDate.millisecondTimestamp() let messageInfoData: Data = try? JSONEncoder().encode(messageInfo),
message.uuid = self.uuid let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId)
message.kind = .preOffer else { return }
let infoMessage = TSInfoMessage.from(message, associatedWith: thread)
infoMessage.save()
self.callMessageID = infoMessage.uniqueId
var promise: Promise<Void>! let timestampMs: Int64 = Int64(floor(Date().timeIntervalSince1970 * 1000))
Storage.write(with: { transaction in let message: CallMessage = CallMessage(
promise = self.webRTCSession.sendPreOffer(message, in: thread, using: transaction) uuid: self.uuid,
}, completion: { [weak self] in kind: .preOffer,
let _ = promise.done { sdps: [],
Storage.shared.write { transaction in sentTimestampMs: UInt64(timestampMs)
self?.webRTCSession.sendOffer(to: self!.sessionID, using: transaction as! YapDatabaseReadWriteTransaction).retainUntilComplete() )
let interaction: Interaction? = try? Interaction(
messageUuid: self.uuid,
threadId: sessionId,
authorId: getUserHexEncodedPublicKey(db),
variant: .infoCall,
body: String(data: messageInfoData, encoding: .utf8),
timestampMs: timestampMs
)
.inserted(db)
self.callInteractionId = interaction?.id
try? self.webRTCSession
.sendPreOffer(
db,
message: message,
interactionId: interaction?.id,
in: thread
)
.done { [weak self] _ in
Storage.shared.writeAsync { db in
self?.webRTCSession.sendOffer(db, to: sessionId)
} }
self?.setupTimeoutTimer() self?.setupTimeoutTimer()
} }
}) .retainUntilComplete()
} }
func answerSessionCall() { func answerSessionCall() {
guard case .answer = mode else { return } guard case .answer = mode else { return }
hasStartedConnecting = true hasStartedConnecting = true
if let sdp = remoteSDP { if let sdp = remoteSDP {
webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally
} }
} }
@ -230,47 +257,79 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
func endSessionCall() { func endSessionCall() {
guard !hasEnded else { return } guard !hasEnded else { return }
let sessionId: String = self.sessionId
webRTCSession.hangUp() webRTCSession.hangUp()
Storage.write { transaction in
self.webRTCSession.endCall(with: self.sessionID, using: transaction) Storage.shared.writeAsync { [weak self] db in
try self?.webRTCSession.endCall(db, with: sessionId)
} }
hasEnded = true hasEnded = true
} }
// MARK: Update call message // MARK: - Call Message Handling
func updateCallMessage(mode: EndCallMode) {
guard let callMessageID = callMessageID else { return } public func updateCallMessage(mode: EndCallMode) {
Storage.write { transaction in guard let callInteractionId: Int64 = callInteractionId else { return }
let infoMessage = TSInfoMessage.fetch(uniqueId: callMessageID, transaction: transaction)
if let messageToUpdate = infoMessage { let duration: TimeInterval = self.duration
var shouldMarkAsRead = false let hasStartedConnecting: Bool = self.hasStartedConnecting
if self.duration > 0 {
shouldMarkAsRead = true Storage.shared.writeAsync { db in
} else if self.hasStartedConnecting { guard let interaction: Interaction = try? Interaction.fetchOne(db, id: callInteractionId) else {
shouldMarkAsRead = true return
} else {
switch mode {
case .local:
shouldMarkAsRead = true
fallthrough
case .remote:
fallthrough
case .unanswered:
if messageToUpdate.callState == .incoming {
messageToUpdate.updateCallInfoMessage(.missed, using: transaction)
}
case .answeredElsewhere:
shouldMarkAsRead = true
}
}
if shouldMarkAsRead {
messageToUpdate.markAsRead(atTimestamp: NSDate.ows_millisecondTimeStamp(), trySendReadReceipt: false, transaction: transaction)
}
} }
let updateToMissedIfNeeded: () throws -> () = {
let missedCallInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .missed)
guard
let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8),
let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode(
CallMessage.MessageInfo.self,
from: infoMessageData
),
messageInfo.state == .incoming,
let missedCallInfoData: Data = try? JSONEncoder().encode(missedCallInfo)
else { return }
_ = try interaction
.with(body: String(data: missedCallInfoData, encoding: .utf8))
.saved(db)
}
let shouldMarkAsRead: Bool = try {
if duration > 0 { return true }
if hasStartedConnecting { return true }
switch mode {
case .local:
try updateToMissedIfNeeded()
return true
case .remote, .unanswered:
try updateToMissedIfNeeded()
return false
case .answeredElsewhere: return true
}
}()
guard shouldMarkAsRead else { return }
try Interaction.markAsRead(
db,
interactionId: interaction.id,
threadId: interaction.threadId,
includingOlder: false,
trySendReadReceipt: false
)
} }
} }
// MARK: Renderer // MARK: - Renderer
func attachRemoteVideoRenderer(_ renderer: RTCVideoRenderer) { func attachRemoteVideoRenderer(_ renderer: RTCVideoRenderer) {
webRTCSession.attachRemoteRenderer(renderer) webRTCSession.attachRemoteRenderer(renderer)
} }
@ -283,14 +342,17 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
webRTCSession.attachLocalRenderer(renderer) webRTCSession.attachLocalRenderer(renderer)
} }
// MARK: Delegate // MARK: - Delegate
public func webRTCIsConnected() { public func webRTCIsConnected() {
self.invalidateTimeoutTimer() self.invalidateTimeoutTimer()
self.reconnectTimer?.invalidate() self.reconnectTimer?.invalidate()
guard !self.hasConnected else { guard !self.hasConnected else {
hasReconnected?() hasReconnected?()
return return
} }
self.hasConnected = true self.hasConnected = true
self.answerCallAction?.fulfill() self.answerCallAction?.fulfill()
} }
@ -327,23 +389,32 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
private func tryToReconnect() { private func tryToReconnect() {
reconnectTimer?.invalidate() reconnectTimer?.invalidate()
if SSKEnvironment.shared.reachabilityManager.isReachable {
Storage.write { transaction in guard Environment.shared?.reachabilityManager.isReachable == true else {
self.webRTCSession.sendOffer(to: self.sessionID, using: transaction, isRestartingICEConnection: true).retainUntilComplete()
}
} else {
reconnectTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 5, repeats: false) { _ in reconnectTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 5, repeats: false) { _ in
self.tryToReconnect() self.tryToReconnect()
} }
return
} }
let sessionId: String = self.sessionId
let webRTCSession: WebRTCSession = self.webRTCSession
Storage.shared
.read { db in webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true) }
.retainUntilComplete()
} }
// MARK: Timeout // MARK: - Timeout
public func setupTimeoutTimer() { public func setupTimeoutTimer() {
invalidateTimeoutTimer() invalidateTimeoutTimer()
let timeInterval: TimeInterval = hasConnected ? 60 : 30
let timeInterval: TimeInterval = (hasConnected ? 60 : 30)
timeOutTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: timeInterval, repeats: false) { _ in timeOutTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: timeInterval, repeats: false) { _ in
self.didTimeout = true self.didTimeout = true
AppEnvironment.shared.callManager.endCall(self) { error in AppEnvironment.shared.callManager.endCall(self) { error in
self.timeOutTimer = nil self.timeOutTimer = nil
} }

View File

@ -1,24 +1,37 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import GRDB
extension SessionCallManager { extension SessionCallManager {
@discardableResult @discardableResult
public func startCallAction() -> Bool { public func startCallAction() -> Bool {
guard let call = self.currentCall else { return false } guard let call: CurrentCallProtocol = self.currentCall else { return false }
call.startSessionCall()
Storage.shared.writeAsync { db in
call.startSessionCall(db)
}
return true return true
} }
@discardableResult @discardableResult
public func answerCallAction() -> Bool { public func answerCallAction() -> Bool {
guard let call = self.currentCall else { return false } guard let call: SessionCall = (self.currentCall as? SessionCall) else { return false }
if let _ = CurrentAppContext().frontmostViewController() as? CallVC { if let _ = CurrentAppContext().frontmostViewController() as? CallVC {
call.answerSessionCall() call.answerSessionCall()
} else { }
else {
guard let presentingVC = CurrentAppContext().frontmostViewController() else { return false } // FIXME: Handle more gracefully guard let presentingVC = CurrentAppContext().frontmostViewController() else { return false } // FIXME: Handle more gracefully
let callVC = CallVC(for: self.currentCall!) let callVC = CallVC(for: call)
if let conversationVC = presentingVC as? ConversationVC { if let conversationVC = presentingVC as? ConversationVC {
callVC.conversationVC = conversationVC callVC.conversationVC = conversationVC
conversationVC.inputAccessoryView?.isHidden = true conversationVC.inputAccessoryView?.isHidden = true
conversationVC.inputAccessoryView?.alpha = 0 conversationVC.inputAccessoryView?.alpha = 0
} }
presentingVC.present(callVC, animated: true) { presentingVC.present(callVC, animated: true) {
call.answerSessionCall() call.answerSessionCall()
} }
@ -28,20 +41,26 @@ extension SessionCallManager {
@discardableResult @discardableResult
public func endCallAction() -> Bool { public func endCallAction() -> Bool {
guard let call = self.currentCall else { return false } guard let call: SessionCall = (self.currentCall as? SessionCall) else { return false }
call.endSessionCall() call.endSessionCall()
if call.didTimeout { if call.didTimeout {
reportCurrentCallEnded(reason: .unanswered) reportCurrentCallEnded(reason: .unanswered)
} else { }
else {
reportCurrentCallEnded(reason: nil) reportCurrentCallEnded(reason: nil)
} }
return true return true
} }
@discardableResult @discardableResult
public func setMutedCallAction(isMuted: Bool) -> Bool { public func setMutedCallAction(isMuted: Bool) -> Bool {
guard let call = self.currentCall else { return false } guard let call: SessionCall = (self.currentCall as? SessionCall) else { return false }
call.isMuted = isMuted call.isMuted = isMuted
return true return true
} }
} }

View File

@ -1,3 +1,6 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import CallKit import CallKit
import SessionUtilitiesKit import SessionUtilitiesKit
@ -5,10 +8,12 @@ extension SessionCallManager {
public func startCall(_ call: SessionCall, completion: ((Error?) -> Void)?) { public func startCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
guard case .offer = call.mode else { return } guard case .offer = call.mode else { return }
guard !call.hasConnected else { return } guard !call.hasConnected else { return }
reportOutgoingCall(call) reportOutgoingCall(call)
if callController != nil { if callController != nil {
let handle = CXHandle(type: .generic, value: call.sessionID) let handle = CXHandle(type: .generic, value: call.sessionId)
let startCallAction = CXStartCallAction(call: call.callID, handle: handle) let startCallAction = CXStartCallAction(call: call.callId, handle: handle)
startCallAction.isVideo = false startCallAction.isVideo = false
@ -16,7 +21,8 @@ extension SessionCallManager {
transaction.addAction(startCallAction) transaction.addAction(startCallAction)
requestTransaction(transaction, completion: completion) requestTransaction(transaction, completion: completion)
} else { }
else {
startCallAction() startCallAction()
completion?(nil) completion?(nil)
} }
@ -24,12 +30,13 @@ extension SessionCallManager {
public func answerCall(_ call: SessionCall, completion: ((Error?) -> Void)?) { public func answerCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
if callController != nil { if callController != nil {
let answerCallAction = CXAnswerCallAction(call: call.callID) let answerCallAction = CXAnswerCallAction(call: call.callId)
let transaction = CXTransaction() let transaction = CXTransaction()
transaction.addAction(answerCallAction) transaction.addAction(answerCallAction)
requestTransaction(transaction, completion: completion) requestTransaction(transaction, completion: completion)
} else { }
else {
answerCallAction() answerCallAction()
completion?(nil) completion?(nil)
} }
@ -37,12 +44,13 @@ extension SessionCallManager {
public func endCall(_ call: SessionCall, completion: ((Error?) -> Void)?) { public func endCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
if callController != nil { if callController != nil {
let endCallAction = CXEndCallAction(call: call.callID) let endCallAction = CXEndCallAction(call: call.callId)
let transaction = CXTransaction() let transaction = CXTransaction()
transaction.addAction(endCallAction) transaction.addAction(endCallAction)
requestTransaction(transaction, completion: completion) requestTransaction(transaction, completion: completion)
} else { }
else {
endCallAction() endCallAction()
completion?(nil) completion?(nil)
} }
@ -51,7 +59,7 @@ extension SessionCallManager {
// Not currently in use // Not currently in use
public func setOnHoldStatus(for call: SessionCall) { public func setOnHoldStatus(for call: SessionCall) {
if callController != nil { if callController != nil {
let setHeldCallAction = CXSetHeldCallAction(call: call.callID, onHold: true) let setHeldCallAction = CXSetHeldCallAction(call: call.callId, onHold: true)
let transaction = CXTransaction() let transaction = CXTransaction()
transaction.addAction(setHeldCallAction) transaction.addAction(setHeldCallAction)
@ -63,9 +71,11 @@ extension SessionCallManager {
callController?.request(transaction) { error in callController?.request(transaction) { error in
if let error = error { if let error = error {
SNLog("Error requesting transaction: \(error)") SNLog("Error requesting transaction: \(error)")
} else { }
else {
SNLog("Requested transaction successfully") SNLog("Requested transaction successfully")
} }
completion?(error) completion?(error)
} }
} }

View File

@ -1,16 +1,22 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import CallKit import CallKit
import SignalCoreKit
import SessionUtilitiesKit
extension SessionCallManager: CXProviderDelegate { extension SessionCallManager: CXProviderDelegate {
public func providerDidReset(_ provider: CXProvider) { public func providerDidReset(_ provider: CXProvider) {
AssertIsOnMainThread() AssertIsOnMainThread()
currentCall?.endSessionCall() (currentCall as? SessionCall)?.endSessionCall()
} }
public func provider(_ provider: CXProvider, perform action: CXStartCallAction) { public func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
AssertIsOnMainThread() AssertIsOnMainThread()
if startCallAction() { if startCallAction() {
action.fulfill() action.fulfill()
} else { }
else {
action.fail() action.fail()
} }
} }
@ -18,14 +24,18 @@ extension SessionCallManager: CXProviderDelegate {
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
AssertIsOnMainThread() AssertIsOnMainThread()
print("[CallKit] Perform CXAnswerCallAction") print("[CallKit] Perform CXAnswerCallAction")
guard let call = self.currentCall else { return action.fail() }
guard let call: SessionCall = (self.currentCall as? SessionCall) else { return action.fail() }
if CurrentAppContext().isMainAppAndActive { if CurrentAppContext().isMainAppAndActive {
if answerCallAction() { if answerCallAction() {
action.fulfill() action.fulfill()
} else { }
else {
action.fail() action.fail()
} }
} else { }
else {
call.answerSessionCallInBackground(action: action) call.answerSessionCallInBackground(action: action)
} }
} }
@ -33,9 +43,11 @@ extension SessionCallManager: CXProviderDelegate {
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) { public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
print("[CallKit] Perform CXEndCallAction") print("[CallKit] Perform CXEndCallAction")
AssertIsOnMainThread() AssertIsOnMainThread()
if endCallAction() { if endCallAction() {
action.fulfill() action.fulfill()
} else { }
else {
action.fail() action.fail()
} }
} }
@ -43,9 +55,11 @@ extension SessionCallManager: CXProviderDelegate {
public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
print("[CallKit] Perform CXSetMutedCallAction, isMuted: \(action.isMuted)") print("[CallKit] Perform CXSetMutedCallAction, isMuted: \(action.isMuted)")
AssertIsOnMainThread() AssertIsOnMainThread()
if setMutedCallAction(isMuted: action.isMuted) { if setMutedCallAction(isMuted: action.isMuted) {
action.fulfill() action.fulfill()
} else { }
else {
action.fail() action.fail()
} }
} }
@ -61,7 +75,8 @@ extension SessionCallManager: CXProviderDelegate {
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
print("[CallKit] Audio session did activate.") print("[CallKit] Audio session did activate.")
AssertIsOnMainThread() AssertIsOnMainThread()
guard let call = self.currentCall else { return } guard let call: SessionCall = (self.currentCall as? SessionCall) else { return }
call.webRTCSession.audioSessionDidActivate(audioSession) call.webRTCSession.audioSessionDidActivate(audioSession)
if call.isOutgoing && !call.hasConnected { CallRingTonePlayer.shared.startPlayingRingTone() } if call.isOutgoing && !call.hasConnected { CallRingTonePlayer.shared.startPlayingRingTone() }
} }
@ -69,7 +84,8 @@ extension SessionCallManager: CXProviderDelegate {
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
print("[CallKit] Audio session did deactivate.") print("[CallKit] Audio session did deactivate.")
AssertIsOnMainThread() AssertIsOnMainThread()
guard let call = self.currentCall else { return } guard let call: SessionCall = (self.currentCall as? SessionCall) else { return }
call.webRTCSession.audioSessionDidDeactivate(audioSession) call.webRTCSession.audioSessionDidDeactivate(audioSession)
} }
} }

View File

@ -1,10 +1,15 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import CallKit import CallKit
import GRDB
import SessionMessagingKit import SessionMessagingKit
public final class SessionCallManager: NSObject { public final class SessionCallManager: NSObject, CallManagerProtocol {
let provider: CXProvider? let provider: CXProvider?
let callController: CXCallController? let callController: CXCallController?
var currentCall: SessionCall? = nil {
public var currentCall: CurrentCallProtocol? = nil {
willSet { willSet {
if (newValue != nil) { if (newValue != nil) {
DispatchQueue.main.async { DispatchQueue.main.async {
@ -19,13 +24,14 @@ public final class SessionCallManager: NSObject {
} }
private static var _sharedProvider: CXProvider? private static var _sharedProvider: CXProvider?
class func sharedProvider(useSystemCallLog: Bool) -> CXProvider { static func sharedProvider(useSystemCallLog: Bool) -> CXProvider {
let configuration = buildProviderConfiguration(useSystemCallLog: useSystemCallLog) let configuration = buildProviderConfiguration(useSystemCallLog: useSystemCallLog)
if let sharedProvider = self._sharedProvider { if let sharedProvider = self._sharedProvider {
sharedProvider.configuration = configuration sharedProvider.configuration = configuration
return sharedProvider return sharedProvider
} else { }
else {
SwiftSingletons.register(self) SwiftSingletons.register(self)
let provider = CXProvider(configuration: configuration) let provider = CXProvider(configuration: configuration)
_sharedProvider = provider _sharedProvider = provider
@ -33,9 +39,8 @@ public final class SessionCallManager: NSObject {
} }
} }
class func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration { static func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration {
let localizedName = NSLocalizedString("APPLICATION_NAME", comment: "Name of application") let providerConfiguration = CXProviderConfiguration(localizedName: "Session")
let providerConfiguration = CXProviderConfiguration(localizedName: localizedName)
providerConfiguration.supportsVideo = true providerConfiguration.supportsVideo = true
providerConfiguration.maximumCallGroups = 1 providerConfiguration.maximumCallGroups = 1
providerConfiguration.maximumCallsPerCallGroup = 1 providerConfiguration.maximumCallsPerCallGroup = 1
@ -47,30 +52,47 @@ public final class SessionCallManager: NSObject {
return providerConfiguration return providerConfiguration
} }
// MARK: - Initialization
init(useSystemCallLog: Bool = false) { init(useSystemCallLog: Bool = false) {
AssertIsOnMainThread() if Preferences.isCallKitSupported {
if SSKPreferences.isCallKitSupported { self.provider = SessionCallManager.sharedProvider(useSystemCallLog: useSystemCallLog)
self.provider = type(of: self).sharedProvider(useSystemCallLog: useSystemCallLog)
self.callController = CXCallController() self.callController = CXCallController()
} else { }
else {
self.provider = nil self.provider = nil
self.callController = nil self.callController = nil
} }
super.init() super.init()
// We cannot assert singleton here, because this class gets rebuilt when the user changes relevant call settings // We cannot assert singleton here, because this class gets rebuilt when the user changes relevant call settings
self.provider?.setDelegate(self, queue: nil) self.provider?.setDelegate(self, queue: nil)
} }
// MARK: Report calls // MARK: - Report calls
public static func reportFakeCall(info: String) {
SessionCallManager.sharedProvider(useSystemCallLog: false)
.reportNewIncomingCall(
with: UUID(),
update: CXCallUpdate()
) { _ in
SNLog("[Calls] Reported fake incoming call to CallKit due to: \(info)")
}
}
public func reportOutgoingCall(_ call: SessionCall) { public func reportOutgoingCall(_ call: SessionCall) {
AssertIsOnMainThread() AssertIsOnMainThread()
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing") UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing")
call.stateDidChange = { call.stateDidChange = {
if call.hasStartedConnecting { if call.hasStartedConnecting {
self.provider?.reportOutgoingCall(with: call.callID, startedConnectingAt: call.connectingDate) self.provider?.reportOutgoingCall(with: call.callId, startedConnectingAt: call.connectingDate)
} }
if call.hasConnected { if call.hasConnected {
self.provider?.reportOutgoingCall(with: call.callID, connectedAt: call.connectedDate) self.provider?.reportOutgoingCall(with: call.callId, connectedAt: call.connectedDate)
} }
} }
} }
@ -82,47 +104,61 @@ public final class SessionCallManager: NSObject {
// Construct a CXCallUpdate describing the incoming call, including the caller. // Construct a CXCallUpdate describing the incoming call, including the caller.
let update = CXCallUpdate() let update = CXCallUpdate()
update.localizedCallerName = callerName update.localizedCallerName = callerName
update.remoteHandle = CXHandle(type: .generic, value: call.callID.uuidString) update.remoteHandle = CXHandle(type: .generic, value: call.callId.uuidString)
update.hasVideo = false update.hasVideo = false
disableUnsupportedFeatures(callUpdate: update) disableUnsupportedFeatures(callUpdate: update)
// Report the incoming call to the system // Report the incoming call to the system
provider.reportNewIncomingCall(with: call.callID, update: update) { error in provider.reportNewIncomingCall(with: call.callId, update: update) { error in
guard error == nil else { guard error == nil else {
self.reportCurrentCallEnded(reason: .failed) self.reportCurrentCallEnded(reason: .failed)
completion(error) completion(error)
return return
} }
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing") UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing")
completion(nil) completion(nil)
} }
} else { }
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing") else {
SessionCallManager.reportFakeCall(info: "No CXProvider instance")
UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing")
completion(nil) completion(nil)
} }
} }
public func reportCurrentCallEnded(reason: CXCallEndedReason?) { public func reportCurrentCallEnded(reason: CXCallEndedReason?) {
guard let call = currentCall else { return } guard Thread.isMainThread else {
if let reason = reason { DispatchQueue.main.async {
self.provider?.reportCall(with: call.callID, endedAt: nil, reason: reason) self.reportCurrentCallEnded(reason: reason)
switch (reason) {
case .answeredElsewhere: call.updateCallMessage(mode: .answeredElsewhere)
case .unanswered: call.updateCallMessage(mode: .unanswered)
case .declinedElsewhere: call.updateCallMessage(mode: .local)
default: call.updateCallMessage(mode: .remote)
} }
} else { return
}
guard let call = currentCall else { return }
if let reason = reason {
self.provider?.reportCall(with: call.callId, endedAt: nil, reason: reason)
switch (reason) {
case .answeredElsewhere: call.updateCallMessage(mode: .answeredElsewhere)
case .unanswered: call.updateCallMessage(mode: .unanswered)
case .declinedElsewhere: call.updateCallMessage(mode: .local)
default: call.updateCallMessage(mode: .remote)
}
}
else {
call.updateCallMessage(mode: .local) call.updateCallMessage(mode: .local)
} }
call.webRTCSession.dropConnection() call.webRTCSession.dropConnection()
self.currentCall = nil self.currentCall = nil
WebRTCSession.current = nil WebRTCSession.current = nil
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(false, forKey: "isCallOngoing") UserDefaults.sharedLokiProject?.set(false, forKey: "isCallOngoing")
} }
// MARK: Util // MARK: - Util
private func disableUnsupportedFeatures(callUpdate: CXCallUpdate) { private func disableUnsupportedFeatures(callUpdate: CXCallUpdate) {
// Call Holding is failing to restart audio when "swapping" calls on the CallKit screen // Call Holding is failing to restart audio when "swapping" calls on the CallKit screen
// until user returns to in-app call screen. // until user returns to in-app call screen.
@ -136,17 +172,67 @@ public final class SessionCallManager: NSObject {
callUpdate.supportsDTMF = false callUpdate.supportsDTMF = false
} }
public func handleIncomingCallOfferInBusyState(offerMessage: CallMessage, using transaction: YapDatabaseReadWriteTransaction) { // MARK: - UI
guard let caller = offerMessage.sender, let thread = TSContactThread.fetch(for: caller, using: transaction) else { return }
let message = CallMessage() public func showCallUIForCall(caller: String, uuid: String, mode: CallMode, interactionId: Int64?) {
message.uuid = offerMessage.uuid guard Thread.isMainThread else {
message.kind = .endCall DispatchQueue.main.async {
SNLog("[Calls] Sending end call message because there is an ongoing call.") self.showCallUIForCall(caller: caller, uuid: uuid, mode: mode, interactionId: interactionId)
MessageSender.sendNonDurably(message, in: thread, using: transaction).retainUntilComplete() }
let infoMessage = TSInfoMessage.from(offerMessage, associatedWith: thread) return
infoMessage.updateCallInfoMessage(.missed, using: transaction) }
guard let call: SessionCall = Storage.shared.read({ db in SessionCall(db, for: caller, uuid: uuid, mode: mode) }) else {
return
}
call.callInteractionId = interactionId
call.reportIncomingCallIfNeeded { error in
if let error = error {
SNLog("[Calls] Failed to report incoming call to CallKit due to error: \(error)")
return
}
guard CurrentAppContext().isMainAppAndActive else { return }
guard let presentingVC = CurrentAppContext().frontmostViewController() else {
preconditionFailure() // FIXME: Handle more gracefully
}
if let conversationVC: ConversationVC = presentingVC as? ConversationVC, conversationVC.viewModel.threadData.threadId == call.sessionId {
let callVC = CallVC(for: call)
callVC.conversationVC = conversationVC
conversationVC.inputAccessoryView?.isHidden = true
conversationVC.inputAccessoryView?.alpha = 0
presentingVC.present(callVC, animated: true, completion: nil)
}
else if !Preferences.isCallKitSupported {
let incomingCallBanner = IncomingCallBanner(for: call)
incomingCallBanner.show()
}
}
} }
public func handleAnswerMessage(_ message: CallMessage) {
guard Thread.isMainThread else {
DispatchQueue.main.async {
self.handleAnswerMessage(message)
}
return
}
(CurrentAppContext().frontmostViewController() as? CallVC)?.handleAnswerMessage(message)
}
public func dismissAllCallUI() {
guard Thread.isMainThread else {
DispatchQueue.main.async {
self.dismissAllCallUI()
}
return
}
IncomingCallBanner.current?.dismiss()
(CurrentAppContext().frontmostViewController() as? CallVC)?.handleEndCallMessage()
MiniCallView.current?.dismiss()
}
} }

View File

@ -5,7 +5,7 @@ import SessionUtilitiesKit
import UIKit import UIKit
import MediaPlayer import MediaPlayer
final class CallVC : UIViewController, VideoPreviewDelegate { final class CallVC: UIViewController, VideoPreviewDelegate {
let call: SessionCall let call: SessionCall
var latestKnownAudioOutputDeviceName: String? var latestKnownAudioOutputDeviceName: String?
var durationTimer: Timer? var durationTimer: Timer?

View File

@ -1,11 +1,13 @@
import UIKit // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
@objc import UIKit
final class CallMissedTipsModal : Modal { import SessionUIKit
final class CallMissedTipsModal: Modal {
private let caller: String private let caller: String
// MARK: Lifecycle // MARK: - Lifecycle
@objc
init(caller: String) { init(caller: String) {
self.caller = caller self.caller = caller
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@ -26,27 +28,37 @@ final class CallMissedTipsModal : Modal {
let tipsIconImageView = UIImageView(image: UIImage(named: "Tips")?.withTint(Colors.text)) let tipsIconImageView = UIImageView(image: UIImage(named: "Tips")?.withTint(Colors.text))
tipsIconImageView.set(.width, to: 19) tipsIconImageView.set(.width, to: 19)
tipsIconImageView.set(.height, to: 28) tipsIconImageView.set(.height, to: 28)
// Tips icon container view
let tipsIconContainerView = UIView()
tipsIconContainerView.addSubview(tipsIconImageView)
tipsIconImageView.pin(.top, to: .top, of: tipsIconContainerView)
tipsIconImageView.pin(.bottom, to: .bottom, of: tipsIconContainerView)
tipsIconImageView.center(in: tipsIconContainerView)
// Title // Title
let titleLabel = UILabel() let titleLabel = UILabel()
titleLabel.textColor = Colors.text titleLabel.textColor = Colors.text
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize) titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
titleLabel.text = NSLocalizedString("modal_call_missed_tips_title", comment: "") titleLabel.text = "modal_call_missed_tips_title".localized()
titleLabel.textAlignment = .center titleLabel.textAlignment = .center
// Message // Message
let messageLabel = UILabel() let messageLabel = UILabel()
messageLabel.textColor = Colors.text messageLabel.textColor = Colors.text
messageLabel.font = .systemFont(ofSize: Values.smallFontSize) messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
let message = String(format: NSLocalizedString("modal_call_missed_tips_explanation", comment: ""), caller) messageLabel.text = String(format: "modal_call_missed_tips_explanation".localized(), caller)
messageLabel.text = message
messageLabel.numberOfLines = 0 messageLabel.numberOfLines = 0
messageLabel.lineBreakMode = .byWordWrapping messageLabel.lineBreakMode = .byWordWrapping
messageLabel.textAlignment = .natural messageLabel.textAlignment = .natural
// Cancel Button // Cancel Button
cancelButton.setTitle(NSLocalizedString("OK", comment: ""), for: .normal) cancelButton.setTitle("BUTTON_OK".localized(), for: .normal)
// Main stack view // Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ tipsIconImageView, titleLabel, messageLabel, cancelButton ]) let mainStackView = UIStackView(arrangedSubviews: [ tipsIconContainerView, titleLabel, messageLabel, cancelButton ])
mainStackView.axis = .vertical mainStackView.axis = .vertical
mainStackView.alignment = .center mainStackView.alignment = .fill
mainStackView.spacing = Values.largeSpacing mainStackView.spacing = Values.largeSpacing
contentView.addSubview(mainStackView) contentView.addSubview(mainStackView)
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing) mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)

View File

@ -1,5 +1,8 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit import UIKit
import WebRTC import WebRTC
import SessionUIKit
import SessionMessagingKit import SessionMessagingKit
final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate { final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
@ -82,8 +85,12 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
self.layer.cornerRadius = Values.largeSpacing self.layer.cornerRadius = Values.largeSpacing
self.layer.masksToBounds = true self.layer.masksToBounds = true
self.set(.height, to: 100) self.set(.height, to: 100)
profilePictureView.publicKey = call.sessionID
profilePictureView.update() profilePictureView.update(
publicKey: call.sessionId,
profile: Profile.fetchOrCreate(id: call.sessionId),
threadVariant: .contact
)
displayNameLabel.text = call.contactName displayNameLabel.text = call.contactName
let stackView = UIStackView(arrangedSubviews: [profilePictureView, displayNameLabel, hangUpButton, answerButton]) let stackView = UIStackView(arrangedSubviews: [profilePictureView, displayNameLabel, hangUpButton, answerButton])
stackView.axis = .horizontal stackView.axis = .horizontal

View File

@ -1,63 +1,77 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import GRDB
import PromiseKit import PromiseKit
import SessionUIKit
import SessionMessagingKit import SessionMessagingKit
import SignalUtilitiesKit
@objc(SNEditClosedGroupVC) @objc(SNEditClosedGroupVC)
final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate { final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate {
private let thread: TSGroupThread private struct GroupMemberDisplayInfo: FetchableRecord, Decodable {
private var name = "" let profileId: String
private var zombies: Set<String> = [] let role: GroupMember.Role
private var membersAndZombies: [String] = [] { didSet { handleMembersChanged() } } let profile: Profile?
}
private let threadId: String
private var originalName: String = ""
private var originalMembersAndZombieIds: Set<String> = []
private var name: String = ""
private var hasContactsToAdd: Bool = false
private var userPublicKey: String = ""
private var membersAndZombies: [GroupMemberDisplayInfo] = []
private var adminIds: Set<String> = []
private var isEditingGroupName = false { didSet { handleIsEditingGroupNameChanged() } } private var isEditingGroupName = false { didSet { handleIsEditingGroupNameChanged() } }
private var tableViewHeightConstraint: NSLayoutConstraint! private var tableViewHeightConstraint: NSLayoutConstraint!
private lazy var groupPublicKey: String = { // MARK: - Components
let groupID = thread.groupModel.groupId
return LKGroupUtilities.getDecodedGroupID(groupID)
}()
// MARK: Components
private lazy var groupNameLabel: UILabel = { private lazy var groupNameLabel: UILabel = {
let result = UILabel() let result: UILabel = UILabel()
result.textColor = Colors.text result.textColor = Colors.text
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize) result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
result.lineBreakMode = .byTruncatingTail result.lineBreakMode = .byTruncatingTail
result.textAlignment = .center result.textAlignment = .center
return result return result
}() }()
private lazy var groupNameTextField: TextField = { private lazy var groupNameTextField: TextField = {
let result = TextField(placeholder: "Enter a group name", usesDefaultHeight: false) let result: TextField = TextField(placeholder: "Enter a group name", usesDefaultHeight: false)
result.textAlignment = .center result.textAlignment = .center
return result return result
}() }()
private lazy var addMembersButton: Button = { private lazy var addMembersButton: Button = {
let result = Button(style: .prominentOutline, size: .large) let result: Button = Button(style: .prominentOutline, size: .large)
result.setTitle("Add Members", for: UIControl.State.normal) result.setTitle("Add Members", for: UIControl.State.normal)
result.addTarget(self, action: #selector(addMembers), for: UIControl.Event.touchUpInside) result.addTarget(self, action: #selector(addMembers), for: UIControl.Event.touchUpInside)
result.contentEdgeInsets = UIEdgeInsets(top: 0, leading: Values.mediumSpacing, bottom: 0, trailing: Values.mediumSpacing) result.contentEdgeInsets = UIEdgeInsets(top: 0, leading: Values.mediumSpacing, bottom: 0, trailing: Values.mediumSpacing)
return result return result
}() }()
@objc private lazy var tableView: UITableView = { @objc private lazy var tableView: UITableView = {
let result = UITableView() let result: UITableView = UITableView()
result.dataSource = self result.dataSource = self
result.delegate = self result.delegate = self
result.register(UserCell.self, forCellReuseIdentifier: "UserCell")
result.separatorStyle = .none result.separatorStyle = .none
result.backgroundColor = .clear result.backgroundColor = .clear
result.isScrollEnabled = false result.isScrollEnabled = false
result.register(view: UserCell.self)
return result return result
}() }()
// MARK: Lifecycle // MARK: - Lifecycle
@objc(initWithThreadID:)
init(with threadID: String) { @objc(initWithThreadId:)
var thread: TSGroupThread! init(with threadId: String) {
Storage.read { transaction in self.threadId = threadId
thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction)!
}
self.thread = thread
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@ -67,27 +81,62 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
setUpGradientBackground() setUpGradientBackground()
setUpNavBarStyle() setUpNavBarStyle()
setNavBarTitle("Edit Group") setNavBarTitle("Edit Group")
let backButton = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil) let backButton = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil)
backButton.tintColor = Colors.text backButton.tintColor = Colors.text
navigationItem.backBarButtonItem = backButton navigationItem.backBarButtonItem = backButton
func getDisplayName(for publicKey: String) -> String {
return Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey let threadId: String = self.threadId
Storage.shared.read { [weak self] db in
self?.userPublicKey = getUserHexEncodedPublicKey(db)
self?.name = try ClosedGroup
.select(.name)
.filter(id: threadId)
.asRequest(of: String.self)
.fetchOne(db)
.defaulting(to: "Group")
self?.originalName = (self?.name ?? "")
let profileAlias: TypedTableAlias<Profile> = TypedTableAlias()
let allGroupMembers: [GroupMemberDisplayInfo] = try GroupMember
.filter(GroupMember.Columns.groupId == threadId)
.including(optional: GroupMember.profile.aliased(profileAlias))
.order(
(GroupMember.Columns.role == GroupMember.Role.zombie), // Non-zombies at the top
profileAlias[.nickname],
profileAlias[.name],
GroupMember.Columns.profileId
)
.asRequest(of: GroupMemberDisplayInfo.self)
.fetchAll(db)
self?.membersAndZombies = allGroupMembers
.filter { $0.role == .standard || $0.role == .zombie }
self?.adminIds = allGroupMembers
.filter { $0.role == .admin }
.map { $0.profileId }
.asSet()
let uniqueGroupMemberIds: Set<String> = allGroupMembers
.map { $0.profileId }
.asSet()
self?.originalMembersAndZombieIds = uniqueGroupMemberIds
self?.hasContactsToAdd = ((try Profile.fetchCount(db) - uniqueGroupMemberIds.count) > 0)
} }
setUpViewHierarchy() setUpViewHierarchy()
// Always show zombies at the bottom
zombies = Storage.shared.getZombieMembers(for: groupPublicKey)
membersAndZombies = GroupUtilities.getClosedGroupMembers(thread).sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
+ zombies.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
updateNavigationBarButtons() updateNavigationBarButtons()
name = thread.groupModel.groupName! handleMembersChanged()
} }
private func setUpViewHierarchy() { private func setUpViewHierarchy() {
// Group name container // Group name container
groupNameLabel.text = thread.groupModel.groupName groupNameLabel.text = name
let groupNameContainer = UIView() let groupNameContainer = UIView()
groupNameContainer.addSubview(groupNameLabel) groupNameContainer.addSubview(groupNameLabel)
groupNameLabel.pin(to: groupNameContainer) groupNameLabel.pin(to: groupNameContainer)
@ -95,6 +144,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
groupNameTextField.pin(to: groupNameContainer) groupNameTextField.pin(to: groupNameContainer)
groupNameContainer.set(.height, to: 40) groupNameContainer.set(.height, to: 40)
groupNameTextField.alpha = 0 groupNameTextField.alpha = 0
// Top container // Top container
let topContainer = UIView() let topContainer = UIView()
topContainer.addSubview(groupNameContainer) topContainer.addSubview(groupNameContainer)
@ -102,19 +152,21 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
topContainer.set(.height, to: 40) topContainer.set(.height, to: 40)
let topContainerTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(showEditGroupNameUI)) let topContainerTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(showEditGroupNameUI))
topContainer.addGestureRecognizer(topContainerTapGestureRecognizer) topContainer.addGestureRecognizer(topContainerTapGestureRecognizer)
// Members label // Members label
let membersLabel = UILabel() let membersLabel = UILabel()
membersLabel.textColor = Colors.text membersLabel.textColor = Colors.text
membersLabel.font = .systemFont(ofSize: Values.mediumFontSize) membersLabel.font = .systemFont(ofSize: Values.mediumFontSize)
membersLabel.text = "Members" membersLabel.text = "Members"
// Add members button // Add members button
let hasContactsToAdd = !Set(ContactUtilities.getAllContacts()).subtracting(self.membersAndZombies).isEmpty if !self.hasContactsToAdd {
if (!hasContactsToAdd) {
addMembersButton.isUserInteractionEnabled = false addMembersButton.isUserInteractionEnabled = false
let disabledColor = Colors.text.withAlphaComponent(Values.mediumOpacity) let disabledColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
addMembersButton.layer.borderColor = disabledColor.cgColor addMembersButton.layer.borderColor = disabledColor.cgColor
addMembersButton.setTitleColor(disabledColor, for: UIControl.State.normal) addMembersButton.setTitleColor(disabledColor, for: UIControl.State.normal)
} }
// Middle stack view // Middle stack view
let middleStackView = UIStackView(arrangedSubviews: [ membersLabel, addMembersButton ]) let middleStackView = UIStackView(arrangedSubviews: [ membersLabel, addMembersButton ])
middleStackView.axis = .horizontal middleStackView.axis = .horizontal
@ -122,8 +174,10 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
middleStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.mediumSpacing, bottom: Values.smallSpacing, trailing: Values.mediumSpacing) middleStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.mediumSpacing, bottom: Values.smallSpacing, trailing: Values.mediumSpacing)
middleStackView.isLayoutMarginsRelativeArrangement = true middleStackView.isLayoutMarginsRelativeArrangement = true
middleStackView.set(.height, to: Values.largeButtonHeight + Values.smallSpacing * 2) middleStackView.set(.height, to: Values.largeButtonHeight + Values.smallSpacing * 2)
// Table view // Table view
tableViewHeightConstraint = tableView.set(.height, to: 0) tableViewHeightConstraint = tableView.set(.height, to: 0)
// Main stack view // Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ let mainStackView = UIStackView(arrangedSubviews: [
UIView.vSpacer(Values.veryLargeSpacing), UIView.vSpacer(Values.veryLargeSpacing),
@ -137,6 +191,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
mainStackView.axis = .vertical mainStackView.axis = .vertical
mainStackView.alignment = .fill mainStackView.alignment = .fill
mainStackView.set(.width, to: UIScreen.main.bounds.width) mainStackView.set(.width, to: UIScreen.main.bounds.width)
// Scroll view // Scroll view
let scrollView = UIScrollView() let scrollView = UIScrollView()
scrollView.showsVerticalScrollIndicator = false scrollView.showsVerticalScrollIndicator = false
@ -152,41 +207,49 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
} }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
let publicKey = membersAndZombies[indexPath.row] cell.update(
cell.publicKey = publicKey with: membersAndZombies[indexPath.row].profileId,
cell.isZombie = zombies.contains(publicKey) profile: membersAndZombies[indexPath.row].profile,
let userPublicKey = getUserHexEncodedPublicKey() isZombie: (membersAndZombies[indexPath.row].role == .zombie),
let isCurrentUserAdmin = thread.groupModel.groupAdminIds.contains(userPublicKey) accessory: (adminIds.contains(userPublicKey) ?
cell.accessory = !isCurrentUserAdmin ? .lock : .none .none :
cell.update() .lock
)
)
return cell return cell
} }
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
let userPublicKey = getUserHexEncodedPublicKey() return adminIds.contains(userPublicKey)
return thread.groupModel.groupAdminIds.contains(userPublicKey)
} }
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
let publicKey = membersAndZombies[indexPath.row] let profileId: String = self.membersAndZombies[indexPath.row].profileId
let removeAction = UITableViewRowAction(style: .destructive, title: "Remove") { [weak self] _, _ in let removeAction = UITableViewRowAction(style: .destructive, title: "Remove") { [weak self] _, _ in
guard let self = self, let index = self.membersAndZombies.firstIndex(of: publicKey) else { return } self?.adminIds.remove(profileId)
self.membersAndZombies.remove(at: index) self?.membersAndZombies.remove(at: indexPath.row)
self?.handleMembersChanged()
} }
removeAction.backgroundColor = Colors.destructive removeAction.backgroundColor = Colors.destructive
return [ removeAction ] return [ removeAction ]
} }
// MARK: Updating // MARK: - Updating
private func updateNavigationBarButtons() { private func updateNavigationBarButtons() {
if isEditingGroupName { if isEditingGroupName {
let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(handleCancelGroupNameEditingButtonTapped)) let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(handleCancelGroupNameEditingButtonTapped))
cancelButton.tintColor = Colors.text cancelButton.tintColor = Colors.text
navigationItem.leftBarButtonItem = cancelButton navigationItem.leftBarButtonItem = cancelButton
} else { }
else {
navigationItem.leftBarButtonItem = nil navigationItem.leftBarButtonItem = nil
} }
let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(handleDoneButtonTapped)) let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(handleDoneButtonTapped))
doneButton.tintColor = Colors.text doneButton.tintColor = Colors.text
navigationItem.rightBarButtonItem = doneButton navigationItem.rightBarButtonItem = doneButton
@ -196,21 +259,25 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 67 tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 67
tableView.reloadData() tableView.reloadData()
} }
private func handleIsEditingGroupNameChanged() { private func handleIsEditingGroupNameChanged() {
updateNavigationBarButtons() updateNavigationBarButtons()
UIView.animate(withDuration: 0.25) { UIView.animate(withDuration: 0.25) {
self.groupNameLabel.alpha = self.isEditingGroupName ? 0 : 1 self.groupNameLabel.alpha = self.isEditingGroupName ? 0 : 1
self.groupNameTextField.alpha = self.isEditingGroupName ? 1 : 0 self.groupNameTextField.alpha = self.isEditingGroupName ? 1 : 0
} }
if isEditingGroupName { if isEditingGroupName {
groupNameTextField.becomeFirstResponder() groupNameTextField.becomeFirstResponder()
} else { }
else {
groupNameTextField.resignFirstResponder() groupNameTextField.resignFirstResponder()
} }
} }
// MARK: Interaction // MARK: - Interaction
@objc private func showEditGroupNameUI() { @objc private func showEditGroupNameUI() {
isEditingGroupName = true isEditingGroupName = true
} }
@ -222,93 +289,163 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
@objc private func handleDoneButtonTapped() { @objc private func handleDoneButtonTapped() {
if isEditingGroupName { if isEditingGroupName {
updateGroupName() updateGroupName()
} else { }
else {
commitChanges() commitChanges()
} }
} }
private func updateGroupName() { private func updateGroupName() {
let name = groupNameTextField.text!.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) let updatedName: String = groupNameTextField.text
guard !name.isEmpty else { .defaulting(to: "")
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_missing_error", comment: "")) .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
guard !updatedName.isEmpty else {
return showError(title: "vc_create_closed_group_group_name_missing_error".lowercased())
} }
guard name.count < 64 else { guard updatedName.count < 64 else {
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_too_long_error", comment: "")) return showError(title: "vc_create_closed_group_group_name_too_long_error".localized())
} }
isEditingGroupName = false isEditingGroupName = false
self.name = name groupNameLabel.text = updatedName
groupNameLabel.text = name self.name = updatedName
} }
@objc private func addMembers() { @objc private func addMembers() {
let title = "Add Members" let title = "Add Members"
let userSelectionVC = UserSelectionVC(with: title, excluding: Set(membersAndZombies)) { [weak self] selectedUsers in
guard let self = self else { return } let userSelectionVC: UserSelectionVC = UserSelectionVC(
var members = self.membersAndZombies with: title,
members.append(contentsOf: selectedUsers) excluding: membersAndZombies
func getDisplayName(for publicKey: String) -> String { .map { $0.profileId }
return Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey .asSet()
) { [weak self] selectedUserIds in
Storage.shared.read { [weak self] db in
let selectedGroupMembers: [GroupMemberDisplayInfo] = try Profile
.filter(selectedUserIds.contains(Profile.Columns.id))
.fetchAll(db)
.map { profile in
GroupMemberDisplayInfo(
profileId: profile.id,
role: .standard,
profile: profile
)
}
self?.membersAndZombies = (self?.membersAndZombies ?? [])
.appending(contentsOf: selectedGroupMembers)
.sorted(by: { lhs, rhs in
if lhs.role == .zombie && rhs.role != .zombie {
return false
}
else if lhs.role != .zombie && rhs.role == .zombie {
return true
}
let lhsDisplayName: String = Profile.displayName(
for: .contact,
id: lhs.profileId,
name: lhs.profile?.name,
nickname: lhs.profile?.nickname
)
let rhsDisplayName: String = Profile.displayName(
for: .contact,
id: rhs.profileId,
name: rhs.profile?.name,
nickname: rhs.profile?.nickname
)
return (lhsDisplayName < rhsDisplayName)
})
.filter { $0.role == .standard || $0.role == .zombie }
let uniqueGroupMemberIds: Set<String> = (self?.membersAndZombies ?? [])
.map { $0.profileId }
.asSet()
.inserting(contentsOf: self?.adminIds)
self?.hasContactsToAdd = ((try Profile.fetchCount(db) - uniqueGroupMemberIds.count) > 0)
} }
self.membersAndZombies = members.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
let hasContactsToAdd = !Set(ContactUtilities.getAllContacts()).subtracting(self.membersAndZombies).isEmpty let color = (self?.hasContactsToAdd == true ?
self.addMembersButton.isUserInteractionEnabled = hasContactsToAdd Colors.accent :
let color = hasContactsToAdd ? Colors.accent : Colors.text.withAlphaComponent(Values.mediumOpacity) Colors.text.withAlphaComponent(Values.mediumOpacity)
self.addMembersButton.layer.borderColor = color.cgColor )
self.addMembersButton.setTitleColor(color, for: UIControl.State.normal) self?.addMembersButton.isUserInteractionEnabled = (self?.hasContactsToAdd == true)
self?.addMembersButton.layer.borderColor = color.cgColor
self?.addMembersButton.setTitleColor(color, for: UIControl.State.normal)
self?.handleMembersChanged()
} }
navigationController!.pushViewController(userSelectionVC, animated: true, completion: nil)
navigationController?.pushViewController(userSelectionVC, animated: true, completion: nil)
} }
private func commitChanges() { private func commitChanges() {
let popToConversationVC: (EditClosedGroupVC) -> Void = { editVC in let popToConversationVC: ((EditClosedGroupVC?) -> ()) = { editVC in
if let conversationVC = editVC.navigationController!.viewControllers.first(where: { $0 is ConversationVC }) { guard
editVC.navigationController!.popToViewController(conversationVC, animated: true) let viewControllers: [UIViewController] = editVC?.navigationController?.viewControllers,
} else { let conversationVC: ConversationVC = viewControllers.first(where: { $0 is ConversationVC }) as? ConversationVC
editVC.navigationController!.popViewController(animated: true) else {
editVC?.navigationController?.popViewController(animated: true)
return
} }
editVC?.navigationController?.popToViewController(conversationVC, animated: true)
} }
let storage = SNMessagingKitConfiguration.shared.storage
let members = Set(self.membersAndZombies) let threadId: String = self.threadId
let name = self.name let updatedName: String = self.name
let zombies = storage.getZombieMembers(for: groupPublicKey) let userPublicKey: String = self.userPublicKey
guard members != Set(thread.groupModel.groupMemberIds + zombies) || name != thread.groupModel.groupName else { let updatedMemberIds: Set<String> = self.membersAndZombies
.map { $0.profileId }
.asSet()
guard updatedMemberIds != self.originalMembersAndZombieIds || updatedName != self.originalName else {
return popToConversationVC(self) return popToConversationVC(self)
} }
if !members.contains(getUserHexEncodedPublicKey()) {
guard Set(thread.groupModel.groupMemberIds).subtracting([ getUserHexEncodedPublicKey() ]) == members else { if !updatedMemberIds.contains(userPublicKey) {
return showError(title: "Couldn't Update Group", message: "Can't leave while adding or removing other members.") guard self.originalMembersAndZombieIds.removing(userPublicKey) == updatedMemberIds else {
return showError(
title: "Couldn't Update Group",
message: "Can't leave while adding or removing other members."
)
} }
} }
guard members.count <= 100 else { guard updatedMemberIds.count <= 100 else {
return showError(title: NSLocalizedString("vc_create_closed_group_too_many_group_members_error", comment: "")) return showError(title: "vc_create_closed_group_too_many_group_members_error".localized())
} }
var promise: Promise<Void>!
ModalActivityIndicatorViewController.present(fromViewController: navigationController!) { [groupPublicKey, weak self] _ in ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
Storage.write(with: { transaction in Storage.shared
if !members.contains(getUserHexEncodedPublicKey()) { .writeAsync { db in
promise = MessageSender.leave(groupPublicKey, using: transaction) if !updatedMemberIds.contains(userPublicKey) {
} else { return try MessageSender.leave(db, groupPublicKey: threadId)
promise = MessageSender.update(groupPublicKey, with: members, name: name, transaction: transaction) }
return try MessageSender.update(
db,
groupPublicKey: threadId,
with: updatedMemberIds,
name: updatedName
)
} }
}, completion: { .done(on: DispatchQueue.main) { [weak self] in
let _ = promise.done(on: DispatchQueue.main) { self?.dismiss(animated: true, completion: nil) // Dismiss the loader
guard let self = self else { return }
MentionsManager.populateUserPublicKeyCacheIfNeeded(for: self.thread.uniqueId!)
self.dismiss(animated: true, completion: nil) // Dismiss the loader
popToConversationVC(self) popToConversationVC(self)
} }
promise.catch(on: DispatchQueue.main) { error in .catch(on: DispatchQueue.main) { [weak self] error in
self?.dismiss(animated: true, completion: nil) // Dismiss the loader self?.dismiss(animated: true, completion: nil) // Dismiss the loader
self?.showError(title: "Couldn't Update Group", message: error.localizedDescription) self?.showError(title: "Couldn't Update Group", message: error.localizedDescription)
} }
}) .retainUntilComplete()
} }
} }
// MARK: Convenience // MARK: - Convenience
private func showError(title: String, message: String = "") { private func showError(title: String, message: String = "") {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
presentAlert(alert) presentAlert(alert)
} }
} }

View File

@ -1,11 +1,16 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import GRDB
import PromiseKit import PromiseKit
import SessionUIKit
import SessionMessagingKit
private protocol TableViewTouchDelegate { private protocol TableViewTouchDelegate {
func tableViewWasTouched(_ tableView: TableView) func tableViewWasTouched(_ tableView: TableView)
} }
private final class TableView : UITableView { private final class TableView: UITableView {
var touchDelegate: TableViewTouchDelegate? var touchDelegate: TableViewTouchDelegate?
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
@ -14,107 +19,127 @@ private final class TableView : UITableView {
} }
} }
final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate { final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate {
private let contacts = ContactUtilities.getAllContacts() private let contactProfiles: [Profile] = Profile.fetchAllContactProfiles(excludeCurrentUser: true)
private var selectedContacts: Set<String> = [] private var selectedContacts: Set<String> = []
// MARK: Components // MARK: - Components
private lazy var nameTextField = TextField(placeholder: NSLocalizedString("vc_create_closed_group_text_field_hint", comment: ""))
private lazy var nameTextField = TextField(placeholder: "vc_create_closed_group_text_field_hint".localized())
private lazy var tableView: TableView = { private lazy var tableView: TableView = {
let result = TableView() let result: TableView = TableView()
result.dataSource = self result.dataSource = self
result.delegate = self result.delegate = self
result.touchDelegate = self result.touchDelegate = self
result.register(UserCell.self, forCellReuseIdentifier: "UserCell")
result.separatorStyle = .none result.separatorStyle = .none
result.backgroundColor = .clear result.backgroundColor = .clear
result.isScrollEnabled = false result.isScrollEnabled = false
result.register(view: UserCell.self)
return result return result
}() }()
// MARK: Lifecycle // MARK: - Lifecycle
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
setUpGradientBackground() setUpGradientBackground()
setUpNavBarStyle() setUpNavBarStyle()
let customTitleFontSize = Values.largeFontSize let customTitleFontSize = Values.largeFontSize
setNavBarTitle(NSLocalizedString("vc_create_closed_group_title", comment: ""), customFontSize: customTitleFontSize) setNavBarTitle("vc_create_closed_group_title".localized(), customFontSize: customTitleFontSize)
// Set up navigation bar buttons // Set up navigation bar buttons
let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close)) let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close))
closeButton.tintColor = Colors.text closeButton.tintColor = Colors.text
navigationItem.leftBarButtonItem = closeButton navigationItem.leftBarButtonItem = closeButton
let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(createClosedGroup)) let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(createClosedGroup))
doneButton.tintColor = Colors.text doneButton.tintColor = Colors.text
navigationItem.rightBarButtonItem = doneButton navigationItem.rightBarButtonItem = doneButton
// Set up content // Set up content
setUpViewHierarchy() setUpViewHierarchy()
} }
private func setUpViewHierarchy() { private func setUpViewHierarchy() {
if !contacts.isEmpty { guard !contactProfiles.isEmpty else {
let mainStackView = UIStackView() let explanationLabel: UILabel = UILabel()
mainStackView.axis = .vertical
nameTextField.delegate = self
let nameTextFieldContainer = UIView()
nameTextFieldContainer.addSubview(nameTextField)
nameTextField.pin(.leading, to: .leading, of: nameTextFieldContainer, withInset: Values.largeSpacing)
nameTextField.pin(.top, to: .top, of: nameTextFieldContainer, withInset: Values.mediumSpacing)
nameTextFieldContainer.pin(.trailing, to: .trailing, of: nameTextField, withInset: Values.largeSpacing)
nameTextFieldContainer.pin(.bottom, to: .bottom, of: nameTextField, withInset: Values.largeSpacing)
mainStackView.addArrangedSubview(nameTextFieldContainer)
let separator = UIView()
separator.backgroundColor = Colors.separator
separator.set(.height, to: Values.separatorThickness)
mainStackView.addArrangedSubview(separator)
tableView.set(.height, to: CGFloat(contacts.count * 65)) // A cell is exactly 65 points high
tableView.set(.width, to: UIScreen.main.bounds.width)
mainStackView.addArrangedSubview(tableView)
let scrollView = UIScrollView(wrapping: mainStackView, withInsets: UIEdgeInsets.zero)
scrollView.showsVerticalScrollIndicator = false
scrollView.delegate = self
view.addSubview(scrollView)
scrollView.set(.width, to: UIScreen.main.bounds.width)
scrollView.pin(to: view)
} else {
let explanationLabel = UILabel()
explanationLabel.textColor = Colors.text explanationLabel.textColor = Colors.text
explanationLabel.font = .systemFont(ofSize: Values.smallFontSize) explanationLabel.font = .systemFont(ofSize: Values.smallFontSize)
explanationLabel.numberOfLines = 0 explanationLabel.numberOfLines = 0
explanationLabel.lineBreakMode = .byWordWrapping explanationLabel.lineBreakMode = .byWordWrapping
explanationLabel.textAlignment = .center explanationLabel.textAlignment = .center
explanationLabel.text = NSLocalizedString("vc_create_closed_group_empty_state_message", comment: "") explanationLabel.text = NSLocalizedString("vc_create_closed_group_empty_state_message", comment: "")
let createNewPrivateChatButton = Button(style: .prominentOutline, size: .large)
let createNewPrivateChatButton: Button = Button(style: .prominentOutline, size: .large)
createNewPrivateChatButton.setTitle(NSLocalizedString("vc_create_closed_group_empty_state_button_title", comment: ""), for: UIControl.State.normal) createNewPrivateChatButton.setTitle(NSLocalizedString("vc_create_closed_group_empty_state_button_title", comment: ""), for: UIControl.State.normal)
createNewPrivateChatButton.addTarget(self, action: #selector(createNewDM), for: UIControl.Event.touchUpInside) createNewPrivateChatButton.addTarget(self, action: #selector(createNewDM), for: UIControl.Event.touchUpInside)
createNewPrivateChatButton.set(.width, to: 196) createNewPrivateChatButton.set(.width, to: 196)
let stackView = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ])
let stackView: UIStackView = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ])
stackView.axis = .vertical stackView.axis = .vertical
stackView.spacing = Values.mediumSpacing stackView.spacing = Values.mediumSpacing
stackView.alignment = .center stackView.alignment = .center
view.addSubview(stackView) view.addSubview(stackView)
stackView.center(.horizontal, in: view) stackView.center(.horizontal, in: view)
let verticalCenteringConstraint = stackView.center(.vertical, in: view) let verticalCenteringConstraint = stackView.center(.vertical, in: view)
verticalCenteringConstraint.constant = -16 // Makes things appear centered visually verticalCenteringConstraint.constant = -16 // Makes things appear centered visually
return
} }
let mainStackView: UIStackView = UIStackView()
mainStackView.axis = .vertical
nameTextField.delegate = self
let nameTextFieldContainer: UIView = UIView()
nameTextFieldContainer.addSubview(nameTextField)
nameTextField.pin(.leading, to: .leading, of: nameTextFieldContainer, withInset: Values.largeSpacing)
nameTextField.pin(.top, to: .top, of: nameTextFieldContainer, withInset: Values.mediumSpacing)
nameTextFieldContainer.pin(.trailing, to: .trailing, of: nameTextField, withInset: Values.largeSpacing)
nameTextFieldContainer.pin(.bottom, to: .bottom, of: nameTextField, withInset: Values.largeSpacing)
mainStackView.addArrangedSubview(nameTextFieldContainer)
let separator: UIView = UIView()
separator.backgroundColor = Colors.separator
separator.set(.height, to: Values.separatorThickness)
mainStackView.addArrangedSubview(separator)
tableView.set(.height, to: CGFloat(contactProfiles.count * 65)) // A cell is exactly 65 points high
tableView.set(.width, to: UIScreen.main.bounds.width)
mainStackView.addArrangedSubview(tableView)
let scrollView: UIScrollView = UIScrollView(wrapping: mainStackView, withInsets: UIEdgeInsets.zero)
scrollView.showsVerticalScrollIndicator = false
scrollView.delegate = self
view.addSubview(scrollView)
scrollView.set(.width, to: UIScreen.main.bounds.width)
scrollView.pin(to: view)
} }
// MARK: Table View Data Source // MARK: - Table View Data Source
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return contacts.count return contactProfiles.count
} }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
let publicKey = contacts[indexPath.row] cell.update(
cell.publicKey = publicKey with: contactProfiles[indexPath.row].id,
let isSelected = selectedContacts.contains(publicKey) profile: contactProfiles[indexPath.row],
cell.accessory = .tick(isSelected: isSelected) isZombie: false,
cell.update() accessory: .tick(isSelected: selectedContacts.contains(contactProfiles[indexPath.row].id))
)
return cell return cell
} }
// MARK: Interaction // MARK: - Interaction
func textFieldDidEndEditing(_ textField: UITextField) { func textFieldDidEndEditing(_ textField: UITextField) {
crossfadeLabel.text = textField.text!.isEmpty ? NSLocalizedString("vc_create_closed_group_title", comment: "") : textField.text! crossfadeLabel.text = textField.text!.isEmpty ? NSLocalizedString("vc_create_closed_group_title", comment: "") : textField.text!
} }
@ -135,13 +160,15 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat
} }
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let publicKey = contacts[indexPath.row] if !selectedContacts.contains(contactProfiles[indexPath.row].id) {
if !selectedContacts.contains(publicKey) { selectedContacts.insert(publicKey) } else { selectedContacts.remove(publicKey) } selectedContacts.insert(contactProfiles[indexPath.row].id)
guard let cell = tableView.cellForRow(at: indexPath) as? UserCell else { return } }
let isSelected = selectedContacts.contains(publicKey) else {
cell.accessory = .tick(isSelected: isSelected) selectedContacts.remove(contactProfiles[indexPath.row].id)
cell.update() }
tableView.deselectRow(at: indexPath, animated: true) tableView.deselectRow(at: indexPath, animated: true)
tableView.reloadRows(at: [indexPath], with: .none)
} }
@objc private func close() { @objc private func close() {
@ -169,28 +196,34 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat
let selectedContacts = self.selectedContacts let selectedContacts = self.selectedContacts
let message: String? = (selectedContacts.count > 20) ? "Please wait while the group is created..." : nil let message: String? = (selectedContacts.count > 20) ? "Please wait while the group is created..." : nil
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in
var promise: Promise<TSGroupThread>! Storage.shared
Storage.writeSync { transaction in .writeAsync { db in
promise = MessageSender.createClosedGroup(name: name, members: selectedContacts, transaction: transaction) try MessageSender.createClosedGroup(db, name: name, members: selectedContacts)
} }
let _ = promise.done(on: DispatchQueue.main) { thread in .done(on: DispatchQueue.main) { thread in
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() Storage.shared.writeAsync { db in
self?.presentingViewController?.dismiss(animated: true, completion: nil) try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false) }
}
promise.catch(on: DispatchQueue.main) { _ in self?.presentingViewController?.dismiss(animated: true, completion: nil)
self?.dismiss(animated: true, completion: nil) // Dismiss the loader SessionApp.presentConversation(for: thread.id, action: .compose, animated: false)
let title = "Couldn't Create Group" }
let message = "Please check your internet connection and try again." .catch(on: DispatchQueue.main) { [weak self] _ in
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) self?.dismiss(animated: true, completion: nil) // Dismiss the loader
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
self?.presentAlert(alert) let title = "Couldn't Create Group"
} let message = "Please check your internet connection and try again."
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
self?.presentAlert(alert)
}
.retainUntilComplete()
} }
} }
@objc private func createNewDM() { @objc private func createNewDM() {
presentingViewController?.dismiss(animated: true, completion: nil) presentingViewController?.dismiss(animated: true, completion: nil)
SignalApp.shared().homeViewController!.createNewDM()
SessionApp.homeViewController.wrappedValue?.createNewDM()
} }
} }

View File

@ -1,99 +1,156 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionMessagingKit
extension ContextMenuVC { extension ContextMenuVC {
struct Action { struct Action {
let icon: UIImage let icon: UIImage?
let title: String let title: String
let isDismissAction: Bool
let work: () -> Void let work: () -> Void
static func reply(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action { static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
let title = NSLocalizedString("context_menu_reply", comment: "") return Action(
return Action(icon: UIImage(named: "ic_reply")!, title: title) { delegate?.reply(viewItem) } icon: UIImage(named: "ic_reply"),
title: "context_menu_reply".localized(),
isDismissAction: false
) { delegate?.reply(cellViewModel) }
} }
static func copy(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action { static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
let title = NSLocalizedString("copy", comment: "") return Action(
return Action(icon: UIImage(named: "ic_copy")!, title: title) { delegate?.copy(viewItem) } icon: UIImage(named: "ic_copy"),
title: "copy".localized(),
isDismissAction: false
) { delegate?.copy(cellViewModel) }
} }
static func copySessionID(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action { static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
let title = NSLocalizedString("vc_conversation_settings_copy_session_id_button_title", comment: "") return Action(
return Action(icon: UIImage(named: "ic_copy")!, title: title) { delegate?.copySessionID(viewItem) } icon: UIImage(named: "ic_copy"),
title: "vc_conversation_settings_copy_session_id_button_title".localized(),
isDismissAction: false
) { delegate?.copySessionID(cellViewModel) }
} }
static func delete(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action { static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
let title = NSLocalizedString("TXT_DELETE_TITLE", comment: "") return Action(
return Action(icon: UIImage(named: "ic_trash")!, title: title) { delegate?.delete(viewItem) } icon: UIImage(named: "ic_trash"),
title: "TXT_DELETE_TITLE".localized(),
isDismissAction: false
) { delegate?.delete(cellViewModel) }
} }
static func save(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action { static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
let title = NSLocalizedString("context_menu_save", comment: "") return Action(
return Action(icon: UIImage(named: "ic_download")!, title: title) { delegate?.save(viewItem) } icon: UIImage(named: "ic_download"),
title: "context_menu_save".localized(),
isDismissAction: false
) { delegate?.save(cellViewModel) }
} }
static func ban(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action { static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
let title = NSLocalizedString("context_menu_ban_user", comment: "") return Action(
return Action(icon: UIImage(named: "ic_block")!, title: title) { delegate?.ban(viewItem) } icon: UIImage(named: "ic_block"),
title: "context_menu_ban_user".localized(),
isDismissAction: false
) { delegate?.ban(cellViewModel) }
} }
static func banAndDeleteAllMessages(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action { static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
let title = NSLocalizedString("context_menu_ban_and_delete_all", comment: "") return Action(
return Action(icon: UIImage(named: "ic_block")!, title: title) { delegate?.banAndDeleteAllMessages(viewItem) } icon: UIImage(named: "ic_block"),
title: "context_menu_ban_and_delete_all".localized(),
isDismissAction: false
) { delegate?.banAndDeleteAllMessages(cellViewModel) }
}
static func dismiss(_ delegate: ContextMenuActionDelegate?) -> Action {
return Action(
icon: nil,
title: "",
isDismissAction: true
) { delegate?.contextMenuDismissed() }
} }
} }
static func actions(for viewItem: ConversationViewItem, delegate: ContextMenuActionDelegate?) -> [Action] { static func actions(for cellViewModel: MessageViewModel, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? {
func isReplyingAllowed() -> Bool { // No context items for info messages
guard let message = viewItem.interaction as? TSOutgoingMessage else { return true } guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else {
switch message.messageState { return nil
case .failed, .sending: return false
default: return true
}
}
switch viewItem.messageCellType {
case .textOnlyMessage:
var result: [Action] = []
if isReplyingAllowed() { result.append(Action.reply(viewItem, delegate)) }
result.append(Action.copy(viewItem, delegate))
let isGroup = viewItem.isGroupThread
if let message = viewItem.interaction as? TSIncomingMessage, isGroup, !message.isOpenGroupMessage {
result.append(Action.copySessionID(viewItem, delegate))
}
if !isGroup || viewItem.userCanDeleteGroupMessage { result.append(Action.delete(viewItem, delegate)) }
if isGroup && viewItem.interaction is TSIncomingMessage && viewItem.userHasModerationPermission {
result.append(Action.ban(viewItem, delegate))
result.append(Action.banAndDeleteAllMessages(viewItem, delegate))
}
return result
case .mediaMessage, .audio, .genericAttachment:
var result: [Action] = []
if isReplyingAllowed() { result.append(Action.reply(viewItem, delegate)) }
if viewItem.canCopyMedia() { result.append(Action.copy(viewItem, delegate)) }
if viewItem.canSaveMedia() { result.append(Action.save(viewItem, delegate)) }
let isGroup = viewItem.isGroupThread
if let message = viewItem.interaction as? TSIncomingMessage, isGroup, !message.isOpenGroupMessage {
result.append(Action.copySessionID(viewItem, delegate))
}
if !isGroup || viewItem.userCanDeleteGroupMessage { result.append(Action.delete(viewItem, delegate)) }
if isGroup && viewItem.interaction is TSIncomingMessage && viewItem.userHasModerationPermission {
result.append(Action.ban(viewItem, delegate))
result.append(Action.banAndDeleteAllMessages(viewItem, delegate))
}
return result
default: return []
} }
let canReply: Bool = (
cellViewModel.variant != .standardOutgoing || (
cellViewModel.state != .failed &&
cellViewModel.state != .sending
)
)
let canCopy: Bool = (
cellViewModel.cellType == .textOnlyMessage || (
(
cellViewModel.cellType == .genericAttachment ||
cellViewModel.cellType == .mediaMessage
) &&
(cellViewModel.attachments ?? []).count == 1 &&
(cellViewModel.attachments ?? []).first?.isVisualMedia == true &&
(cellViewModel.attachments ?? []).first?.isValid == true && (
(cellViewModel.attachments ?? []).first?.state == .downloaded ||
(cellViewModel.attachments ?? []).first?.state == .uploaded
)
)
)
let canSave: Bool = (
cellViewModel.cellType == .mediaMessage &&
(cellViewModel.attachments ?? [])
.filter { attachment in
attachment.isValid &&
attachment.isVisualMedia && (
attachment.state == .downloaded ||
attachment.state == .uploaded
)
}.isEmpty == false
)
let canCopySessionId: Bool = (
cellViewModel.variant == .standardIncoming &&
cellViewModel.threadVariant != .openGroup
)
let canDelete: Bool = (
cellViewModel.threadVariant != .openGroup ||
currentUserIsOpenGroupModerator
)
let canBan: Bool = (
cellViewModel.threadVariant == .openGroup &&
currentUserIsOpenGroupModerator
)
let generatedActions: [Action] = [
(canReply ? Action.reply(cellViewModel, delegate) : nil),
(canCopy ? Action.copy(cellViewModel, delegate) : nil),
(canSave ? Action.save(cellViewModel, delegate) : nil),
(canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil),
(canDelete ? Action.delete(cellViewModel, delegate) : nil),
(canBan ? Action.ban(cellViewModel, delegate) : nil),
(canBan ? Action.banAndDeleteAllMessages(cellViewModel, delegate) : nil)
]
.compactMap { $0 }
guard !generatedActions.isEmpty else { return [] }
return generatedActions.appending(Action.dismiss(delegate))
} }
} }
// MARK: Delegate // MARK: - Delegate
protocol ContextMenuActionDelegate : AnyObject {
protocol ContextMenuActionDelegate {
func reply(_ viewItem: ConversationViewItem) func reply(_ cellViewModel: MessageViewModel)
func copy(_ viewItem: ConversationViewItem) func copy(_ cellViewModel: MessageViewModel)
func copySessionID(_ viewItem: ConversationViewItem) func copySessionID(_ cellViewModel: MessageViewModel)
func delete(_ viewItem: ConversationViewItem) func delete(_ cellViewModel: MessageViewModel)
func save(_ viewItem: ConversationViewItem) func save(_ cellViewModel: MessageViewModel)
func ban(_ viewItem: ConversationViewItem) func ban(_ cellViewModel: MessageViewModel)
func banAndDeleteAllMessages(_ viewItem: ConversationViewItem) func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel)
func contextMenuDismissed() func contextMenuDismissed()
} }

View File

@ -1,19 +1,25 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
import SessionUtilitiesKit
extension ContextMenuVC { extension ContextMenuVC {
final class ActionView: UIView {
final class ActionView : UIView {
private let action: Action
private let dismiss: () -> Void
// MARK: Settings
private static let iconSize: CGFloat = 16 private static let iconSize: CGFloat = 16
private static let iconImageViewSize: CGFloat = 24 private static let iconImageViewSize: CGFloat = 24
// MARK: Lifecycle private let action: Action
private let dismiss: () -> Void
// MARK: - Lifecycle
init(for action: Action, dismiss: @escaping () -> Void) { init(for action: Action, dismiss: @escaping () -> Void) {
self.action = action self.action = action
self.dismiss = dismiss self.dismiss = dismiss
super.init(frame: CGRect.zero) super.init(frame: CGRect.zero)
setUpViewHierarchy() setUpViewHierarchy()
} }
@ -28,32 +34,46 @@ extension ContextMenuVC {
private func setUpViewHierarchy() { private func setUpViewHierarchy() {
// Icon // Icon
let iconSize = ActionView.iconSize let iconSize = ActionView.iconSize
let iconImageView = UIImageView(image: action.icon.resizedImage(to: CGSize(width: iconSize, height: iconSize))!.withTint(Colors.text)) let iconImageView: UIImageView = UIImageView(
let iconImageViewSize = ActionView.iconImageViewSize image: action.icon?
iconImageView.set(.width, to: iconImageViewSize) .resizedImage(to: CGSize(width: iconSize, height: iconSize))?
iconImageView.set(.height, to: iconImageViewSize) .withRenderingMode(.alwaysTemplate)
)
iconImageView.set(.width, to: ActionView.iconImageViewSize)
iconImageView.set(.height, to: ActionView.iconImageViewSize)
iconImageView.contentMode = .center iconImageView.contentMode = .center
iconImageView.tintColor = Colors.text
// Title // Title
let titleLabel = UILabel() let titleLabel = UILabel()
titleLabel.text = action.title titleLabel.text = action.title
titleLabel.textColor = Colors.text titleLabel.textColor = Colors.text
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize) titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
// Stack view // Stack view
let stackView = UIStackView(arrangedSubviews: [ iconImageView, titleLabel ]) let stackView: UIStackView = UIStackView(arrangedSubviews: [ iconImageView, titleLabel ])
stackView.axis = .horizontal stackView.axis = .horizontal
stackView.spacing = Values.smallSpacing stackView.spacing = Values.smallSpacing
stackView.alignment = .center stackView.alignment = .center
stackView.isLayoutMarginsRelativeArrangement = true stackView.isLayoutMarginsRelativeArrangement = true
let smallSpacing = Values.smallSpacing let smallSpacing = Values.smallSpacing
stackView.layoutMargins = UIEdgeInsets(top: smallSpacing, leading: smallSpacing, bottom: smallSpacing, trailing: Values.mediumSpacing) stackView.layoutMargins = UIEdgeInsets(
top: smallSpacing,
leading: smallSpacing,
bottom: smallSpacing,
trailing: Values.mediumSpacing
)
addSubview(stackView) addSubview(stackView)
stackView.pin(to: self) stackView.pin(to: self)
// Tap gesture recognizer // Tap gesture recognizer
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
addGestureRecognizer(tapGestureRecognizer) addGestureRecognizer(tapGestureRecognizer)
} }
// MARK: Interaction // MARK: - Interaction
@objc private func handleTap() { @objc private func handleTap() {
action.work() action.work()
dismiss() dismiss()

View File

@ -1,43 +1,60 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class ContextMenuVC : UIViewController { import UIKit
import SessionUIKit
import SessionMessagingKit
final class ContextMenuVC: UIViewController {
private static let actionViewHeight: CGFloat = 40
private static let menuCornerRadius: CGFloat = 8
private let snapshot: UIView private let snapshot: UIView
private let viewItem: ConversationViewItem
private let frame: CGRect private let frame: CGRect
private let cellViewModel: MessageViewModel
private let actions: [Action]
private let dismiss: () -> Void private let dismiss: () -> Void
private weak var delegate: ContextMenuActionDelegate?
// MARK: UI Components // MARK: - UI
private lazy var blurView = UIVisualEffectView(effect: nil)
private lazy var blurView: UIVisualEffectView = UIVisualEffectView(effect: nil)
private lazy var menuView: UIView = { private lazy var menuView: UIView = {
let result = UIView() let result: UIView = UIView()
result.layer.shadowColor = UIColor.black.cgColor result.layer.shadowColor = UIColor.black.cgColor
result.layer.shadowOffset = CGSize.zero result.layer.shadowOffset = CGSize.zero
result.layer.shadowOpacity = 0.4 result.layer.shadowOpacity = 0.4
result.layer.shadowRadius = 4 result.layer.shadowRadius = 4
return result return result
}() }()
private lazy var timestampLabel: UILabel = { private lazy var timestampLabel: UILabel = {
let result = UILabel() let result: UILabel = UILabel()
let date = viewItem.interaction.dateForUI()
result.text = DateUtil.formatDate(forDisplay: date)
result.font = .systemFont(ofSize: Values.verySmallFontSize) result.font = .systemFont(ofSize: Values.verySmallFontSize)
result.textColor = isLightMode ? .black : .white result.textColor = (isLightMode ? .black : .white)
if let dateForUI: Date = cellViewModel.dateForUI {
result.text = dateForUI.formattedForDisplay
}
return result return result
}() }()
// MARK: Settings
private static let actionViewHeight: CGFloat = 40
private static let menuCornerRadius: CGFloat = 8
// MARK: Lifecycle // MARK: - Initialization
init(snapshot: UIView, viewItem: ConversationViewItem, frame: CGRect, delegate: ContextMenuActionDelegate, dismiss: @escaping () -> Void) {
init(
snapshot: UIView,
frame: CGRect,
cellViewModel: MessageViewModel,
actions: [Action],
dismiss: @escaping () -> Void
) {
self.snapshot = snapshot self.snapshot = snapshot
self.viewItem = viewItem
self.frame = frame self.frame = frame
self.delegate = delegate self.cellViewModel = cellViewModel
self.actions = actions
self.dismiss = dismiss self.dismiss = dismiss
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@ -48,33 +65,42 @@ final class ContextMenuVC : UIViewController {
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
preconditionFailure("Use init(coder:) instead.") preconditionFailure("Use init(coder:) instead.")
} }
// MARK: - Lifecycle
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
// Background color // Background color
view.backgroundColor = .clear view.backgroundColor = .clear
// Blur // Blur
view.addSubview(blurView) view.addSubview(blurView)
blurView.pin(to: view) blurView.pin(to: view)
// Snapshot // Snapshot
snapshot.layer.shadowColor = UIColor.black.cgColor snapshot.layer.shadowColor = UIColor.black.cgColor
snapshot.layer.shadowOffset = CGSize.zero snapshot.layer.shadowOffset = CGSize.zero
snapshot.layer.shadowOpacity = 0.4 snapshot.layer.shadowOpacity = 0.4
snapshot.layer.shadowRadius = 4 snapshot.layer.shadowRadius = 4
view.addSubview(snapshot) view.addSubview(snapshot)
snapshot.pin(.left, to: .left, of: view, withInset: frame.origin.x) snapshot.pin(.left, to: .left, of: view, withInset: frame.origin.x)
snapshot.pin(.top, to: .top, of: view, withInset: frame.origin.y) snapshot.pin(.top, to: .top, of: view, withInset: frame.origin.y)
snapshot.set(.width, to: frame.width) snapshot.set(.width, to: frame.width)
snapshot.set(.height, to: frame.height) snapshot.set(.height, to: frame.height)
// Timestamp // Timestamp
view.addSubview(timestampLabel) view.addSubview(timestampLabel)
timestampLabel.center(.vertical, in: snapshot) timestampLabel.center(.vertical, in: snapshot)
let isOutgoing = (viewItem.interaction.interactionType() == .outgoingMessage)
if isOutgoing { if cellViewModel.variant == .standardOutgoing {
timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing) timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing)
} else { }
else {
timestampLabel.pin(.left, to: .right, of: snapshot, withInset: Values.smallSpacing) timestampLabel.pin(.left, to: .right, of: snapshot, withInset: Values.smallSpacing)
} }
// Menu // Menu
let menuBackgroundView = UIView() let menuBackgroundView = UIView()
menuBackgroundView.backgroundColor = Colors.receivedMessageBackground menuBackgroundView.backgroundColor = Colors.receivedMessageBackground
@ -82,25 +108,35 @@ final class ContextMenuVC : UIViewController {
menuBackgroundView.layer.masksToBounds = true menuBackgroundView.layer.masksToBounds = true
menuView.addSubview(menuBackgroundView) menuView.addSubview(menuBackgroundView)
menuBackgroundView.pin(to: menuView) menuBackgroundView.pin(to: menuView)
let actionViews = ContextMenuVC.actions(for: viewItem, delegate: delegate).map { ActionView(for: $0, dismiss: snDismiss) }
let menuStackView = UIStackView(arrangedSubviews: actionViews) let menuStackView = UIStackView(
arrangedSubviews: actions
.filter { !$0.isDismissAction }
.map { action -> ActionView in ActionView(for: action, dismiss: snDismiss) }
)
menuStackView.axis = .vertical menuStackView.axis = .vertical
menuView.addSubview(menuStackView) menuView.addSubview(menuStackView)
menuStackView.pin(to: menuView) menuStackView.pin(to: menuView)
view.addSubview(menuView) view.addSubview(menuView)
let menuHeight = CGFloat(actionViews.count) * ContextMenuVC.actionViewHeight
let menuHeight = (CGFloat(actions.count) * ContextMenuVC.actionViewHeight)
let spacing = Values.smallSpacing let spacing = Values.smallSpacing
// FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement)
let margin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing) let margin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing)
if frame.maxY + spacing + menuHeight > UIScreen.main.bounds.height - margin { if frame.maxY + spacing + menuHeight > UIScreen.main.bounds.height - margin {
menuView.pin(.bottom, to: .top, of: snapshot, withInset: -spacing) menuView.pin(.bottom, to: .top, of: snapshot, withInset: -spacing)
} else { }
else {
menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing) menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing)
} }
switch viewItem.interaction.interactionType() {
case .outgoingMessage: menuView.pin(.right, to: .right, of: snapshot) switch cellViewModel.variant {
case .incomingMessage: menuView.pin(.left, to: .left, of: snapshot) case .standardOutgoing: menuView.pin(.right, to: .right, of: snapshot)
default: break // Should never occur case .standardIncoming: menuView.pin(.left, to: .left, of: snapshot)
default: break // Should never occur
} }
// Tap gesture // Tap gesture
let mainTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) let mainTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
view.addGestureRecognizer(mainTapGestureRecognizer) view.addGestureRecognizer(mainTapGestureRecognizer)
@ -108,31 +144,43 @@ final class ContextMenuVC : UIViewController {
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
UIView.animate(withDuration: 0.25) { UIView.animate(withDuration: 0.25) {
self.blurView.effect = UIBlurEffect(style: .regular) self.blurView.effect = UIBlurEffect(style: .regular)
self.menuView.alpha = 1 self.menuView.alpha = 1
} }
} }
// MARK: Updating // MARK: - Layout
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
menuView.layer.shadowPath = UIBezierPath(roundedRect: menuView.bounds, cornerRadius: ContextMenuVC.menuCornerRadius).cgPath
menuView.layer.shadowPath = UIBezierPath(
roundedRect: menuView.bounds,
cornerRadius: ContextMenuVC.menuCornerRadius
).cgPath
} }
// MARK: Interaction // MARK: - Interaction
@objc private func handleTap() { @objc private func handleTap() {
snDismiss() snDismiss()
} }
func snDismiss() { func snDismiss() {
UIView.animate(withDuration: 0.25, animations: { UIView.animate(
self.blurView.effect = nil withDuration: 0.25,
self.menuView.alpha = 0 animations: { [weak self] in
self.timestampLabel.alpha = 0 self?.blurView.effect = nil
}, completion: { _ in self?.menuView.alpha = 0
self.dismiss() self?.snapshot.alpha = 0
self.delegate?.contextMenuDismissed() self?.timestampLabel.alpha = 0
}) },
completion: { [weak self] _ in
self?.dismiss()
self?.actions.first(where: { $0.isDismissAction })?.work()
}
)
} }
} }

View File

@ -11,7 +11,6 @@ final class ContextMenuWindow : UIWindow {
initialize() initialize()
} }
@available(iOS 13.0, *)
override init(windowScene: UIWindowScene) { override init(windowScene: UIWindowScene) {
super.init(windowScene: windowScene) super.init(windowScene: windowScene)
initialize() initialize()

View File

@ -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
}
}

View File

@ -1,143 +1,100 @@
// // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation import UIKit
import SignalUtilitiesKit
@objc public class ConversationSearchController: NSObject {
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate { public static let minimumSearchTextLength: UInt = 2
@objc private let threadId: String
func conversationSearchController(_ conversationSearchController: ConversationSearchController,
didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?)
@objc
func conversationSearchController(_ conversationSearchController: ConversationSearchController,
didSelectMessageId: String)
}
@objc
public class ConversationSearchController : NSObject {
@objc
public static let kMinimumSearchTextLength: UInt = 2
@objc
public let uiSearchController = UISearchController(searchResultsController: nil)
@objc
public weak var delegate: ConversationSearchControllerDelegate? public weak var delegate: ConversationSearchControllerDelegate?
public let uiSearchController: UISearchController = UISearchController(searchResultsController: nil)
let thread: TSThread
@objc
public let resultsBar: SearchResultsBar = SearchResultsBar() public let resultsBar: SearchResultsBar = SearchResultsBar()
private var lastSearchText: String? private var lastSearchText: String?
// MARK: Initializer // MARK: Initializer
@objc public init(threadId: String) {
required public init(thread: TSThread) { self.threadId = threadId
self.thread = thread
super.init() super.init()
self.resultsBar.resultsBarDelegate = self
self.uiSearchController.delegate = self
self.uiSearchController.searchResultsUpdater = self
resultsBar.resultsBarDelegate = self self.uiSearchController.hidesNavigationBarDuringPresentation = false
uiSearchController.delegate = self self.uiSearchController.searchBar.inputAccessoryView = resultsBar
uiSearchController.searchResultsUpdater = self
uiSearchController.hidesNavigationBarDuringPresentation = false
if #available(iOS 13, *) {
// Do nothing
} else {
uiSearchController.dimsBackgroundDuringPresentation = false
}
uiSearchController.searchBar.inputAccessoryView = resultsBar
}
// MARK: Dependencies
var dbReadConnection: YapDatabaseConnection {
return OWSPrimaryStorage.shared().dbReadConnection
} }
} }
extension ConversationSearchController : UISearchControllerDelegate { // MARK: - UISearchControllerDelegate
extension ConversationSearchController: UISearchControllerDelegate {
public func didPresentSearchController(_ searchController: UISearchController) { public func didPresentSearchController(_ searchController: UISearchController) {
Logger.verbose("")
delegate?.didPresentSearchController?(searchController) delegate?.didPresentSearchController?(searchController)
} }
public func didDismissSearchController(_ searchController: UISearchController) { public func didDismissSearchController(_ searchController: UISearchController) {
Logger.verbose("")
delegate?.didDismissSearchController?(searchController) delegate?.didDismissSearchController?(searchController)
} }
} }
extension ConversationSearchController : UISearchResultsUpdating { // MARK: - UISearchResultsUpdating
var dbSearcher: FullTextSearcher {
return FullTextSearcher.shared
}
extension ConversationSearchController: UISearchResultsUpdating {
public func updateSearchResults(for searchController: UISearchController) { public func updateSearchResults(for searchController: UISearchController) {
Logger.verbose("searchBar.text: \( searchController.searchBar.text ?? "<blank>")") Logger.verbose("searchBar.text: \( searchController.searchBar.text ?? "<blank>")")
guard let rawSearchText = searchController.searchBar.text?.stripped else { guard
self.resultsBar.updateResults(resultSet: nil) let searchText: String = searchController.searchBar.text?.stripped,
self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil) searchText.count >= ConversationSearchController.minimumSearchTextLength
else {
self.resultsBar.updateResults(results: nil)
self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil, searchText: nil)
return return
} }
let searchText = FullTextSearchFinder.normalize(text: rawSearchText)
lastSearchText = searchText let threadId: String = self.threadId
let results: [Int64] = Storage.shared.read { db -> [Int64] in
guard searchText.count >= ConversationSearchController.kMinimumSearchTextLength else { try Interaction.idsForTermWithin(
lastSearchText = nil threadId: threadId,
self.resultsBar.updateResults(resultSet: nil) pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText)
self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil) )
return .fetchAll(db)
} }
.defaulting(to: [])
var resultSet: ConversationScreenSearchResultSet?
self.dbReadConnection.asyncRead({ [weak self] transaction in self.resultsBar.updateResults(results: results)
guard let self = self else { self.delegate?.conversationSearchController(self, didUpdateSearchResults: results, searchText: searchText)
return
}
resultSet = self.dbSearcher.searchWithinConversation(thread: self.thread, searchText: searchText, transaction: transaction)
}, completionBlock: { [weak self] in
guard let self = self, searchText == self.lastSearchText else {
return
}
self.resultsBar.updateResults(resultSet: resultSet)
self.delegate?.conversationSearchController(self, didUpdateSearchResults: resultSet)
})
} }
} }
extension ConversationSearchController : SearchResultsBarDelegate { // MARK: - SearchResultsBarDelegate
func searchResultsBar(_ searchResultsBar: SearchResultsBar,
setCurrentIndex currentIndex: Int,
resultSet: ConversationScreenSearchResultSet) {
guard let searchResult = resultSet.messages[safe: currentIndex] else {
owsFailDebug("messageId was unexpectedly nil")
return
}
self.delegate?.conversationSearchController(self, didSelectMessageId: searchResult.messageId) extension ConversationSearchController: SearchResultsBarDelegate {
func searchResultsBar(
_ searchResultsBar: SearchResultsBar,
setCurrentIndex currentIndex: Int,
results: [Int64]
) {
guard let interactionId: Int64 = results[safe: currentIndex] else { return }
self.delegate?.conversationSearchController(self, didSelectInteractionId: interactionId)
} }
} }
protocol SearchResultsBarDelegate : AnyObject { protocol SearchResultsBarDelegate: AnyObject {
func searchResultsBar(
func searchResultsBar(_ searchResultsBar: SearchResultsBar, _ searchResultsBar: SearchResultsBar,
setCurrentIndex currentIndex: Int, setCurrentIndex currentIndex: Int,
resultSet: ConversationScreenSearchResultSet) results: [Int64]
)
} }
public final class SearchResultsBar : UIView { public final class SearchResultsBar: UIView {
private var resultSet: ConversationScreenSearchResultSet? private var results: [Int64]?
var currentIndex: Int? var currentIndex: Int?
weak var resultsBarDelegate: SearchResultsBarDelegate? weak var resultsBarDelegate: SearchResultsBarDelegate?
@ -145,7 +102,6 @@ public final class SearchResultsBar : UIView {
private lazy var label: UILabel = { private lazy var label: UILabel = {
let result = UILabel() let result = UILabel()
result.text = "Test"
result.font = .boldSystemFont(ofSize: Values.smallFontSize) result.font = .boldSystemFont(ofSize: Values.smallFontSize)
result.textColor = Colors.text result.textColor = Colors.text
return result return result
@ -169,6 +125,14 @@ public final class SearchResultsBar : UIView {
return result return result
}() }()
private lazy var loadingIndicator: UIActivityIndicatorView = {
let result = UIActivityIndicatorView(style: .medium)
result.tintColor = Colors.text
result.alpha = 0.5
result.hidesWhenStopped = true
return result
}()
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
setUpViewHierarchy() setUpViewHierarchy()
@ -181,6 +145,7 @@ public final class SearchResultsBar : UIView {
private func setUpViewHierarchy() { private func setUpViewHierarchy() {
autoresizingMask = .flexibleHeight autoresizingMask = .flexibleHeight
// Background & blur // Background & blur
let backgroundView = UIView() let backgroundView = UIView()
backgroundView.backgroundColor = isLightMode ? .white : .black backgroundView.backgroundColor = isLightMode ? .white : .black
@ -190,18 +155,22 @@ public final class SearchResultsBar : UIView {
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
addSubview(blurView) addSubview(blurView)
blurView.pin(to: self) blurView.pin(to: self)
// Separator // Separator
let separator = UIView() let separator = UIView()
separator.backgroundColor = Colors.text.withAlphaComponent(0.2) separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
separator.set(.height, to: 1 / UIScreen.main.scale) separator.set(.height, to: 1 / UIScreen.main.scale)
addSubview(separator) addSubview(separator)
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self) separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
// Spacers // Spacers
let spacer1 = UIView.hStretchingSpacer() let spacer1 = UIView.hStretchingSpacer()
let spacer2 = UIView.hStretchingSpacer() let spacer2 = UIView.hStretchingSpacer()
// Button containers // Button containers
let upButtonContainer = UIView(wrapping: upButton, withInsets: UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0)) let upButtonContainer = UIView(wrapping: upButton, withInsets: UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0))
let downButtonContainer = UIView(wrapping: downButton, withInsets: UIEdgeInsets(top: 0, left: 0, bottom: 2, right: 0)) let downButtonContainer = UIView(wrapping: downButton, withInsets: UIEdgeInsets(top: 0, left: 0, bottom: 2, right: 0))
// Main stack view // Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ upButtonContainer, downButtonContainer, spacer1, label, spacer2 ]) let mainStackView = UIStackView(arrangedSubviews: [ upButtonContainer, downButtonContainer, spacer1, label, spacer2 ])
mainStackView.axis = .horizontal mainStackView.axis = .horizontal
@ -209,110 +178,116 @@ public final class SearchResultsBar : UIView {
mainStackView.isLayoutMarginsRelativeArrangement = true mainStackView.isLayoutMarginsRelativeArrangement = true
mainStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.largeSpacing, bottom: Values.smallSpacing, trailing: Values.largeSpacing) mainStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.largeSpacing, bottom: Values.smallSpacing, trailing: Values.largeSpacing)
addSubview(mainStackView) addSubview(mainStackView)
mainStackView.pin(.top, to: .bottom, of: separator) mainStackView.pin(.top, to: .bottom, of: separator)
mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self) mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self)
mainStackView.pin(.bottom, to: .bottom, of: self, withInset: -2) mainStackView.pin(.bottom, to: .bottom, of: self, withInset: -2)
addSubview(loadingIndicator)
loadingIndicator.pin(.left, to: .right, of: label, withInset: 10)
loadingIndicator.centerYAnchor.constraint(equalTo: label.centerYAnchor).isActive = true
// Remaining constraints // Remaining constraints
label.center(.horizontal, in: self) label.center(.horizontal, in: self)
} }
// MARK: - Functions
@objc @objc
public func handleUpButtonTapped() { public func handleUpButtonTapped() {
Logger.debug("") guard let results: [Int64] = results else { return }
guard let resultSet = resultSet else { guard let currentIndex: Int = currentIndex else { return }
owsFailDebug("resultSet was unexpectedly nil") guard currentIndex + 1 < results.count else { return }
return
}
guard let currentIndex = currentIndex else {
owsFailDebug("currentIndex was unexpectedly nil")
return
}
guard currentIndex + 1 < resultSet.messages.count else {
owsFailDebug("showLessRecent button should be disabled")
return
}
let newIndex = currentIndex + 1 let newIndex = currentIndex + 1
self.currentIndex = newIndex self.currentIndex = newIndex
updateBarItems() updateBarItems()
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, resultSet: resultSet) resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, results: results)
} }
@objc @objc
public func handleDownButtonTapped() { public func handleDownButtonTapped() {
Logger.debug("") Logger.debug("")
guard let resultSet = resultSet else { guard let results: [Int64] = results else { return }
owsFailDebug("resultSet was unexpectedly nil") guard let currentIndex: Int = currentIndex, currentIndex > 0 else { return }
return
}
guard let currentIndex = currentIndex else {
owsFailDebug("currentIndex was unexpectedly nil")
return
}
guard currentIndex > 0 else {
owsFailDebug("showMoreRecent button should be disabled")
return
}
let newIndex = currentIndex - 1 let newIndex = currentIndex - 1
self.currentIndex = newIndex self.currentIndex = newIndex
updateBarItems() updateBarItems()
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, resultSet: resultSet) resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, results: results)
} }
func updateResults(resultSet: ConversationScreenSearchResultSet?) { func updateResults(results: [Int64]?) {
if let resultSet = resultSet { currentIndex = {
if resultSet.messages.count > 0 { guard let results: [Int64] = results, !results.isEmpty else { return nil }
currentIndex = min(currentIndex ?? 0, resultSet.messages.count - 1)
} else { if let currentIndex: Int = currentIndex {
currentIndex = nil return max(0, min(currentIndex, results.count - 1))
} }
} else {
currentIndex = nil return 0
} }()
self.resultSet = resultSet self.results = results
updateBarItems() updateBarItems()
if let currentIndex = currentIndex, let resultSet = resultSet {
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: currentIndex, resultSet: resultSet) if let currentIndex = currentIndex, let results = results {
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: currentIndex, results: results)
} }
} }
func updateBarItems() { func updateBarItems() {
guard let resultSet = resultSet else { guard let results: [Int64] = results else {
label.text = "" label.text = ""
downButton.isEnabled = false downButton.isEnabled = false
upButton.isEnabled = false upButton.isEnabled = false
return return
} }
switch resultSet.messages.count { switch results.count {
case 0: case 0:
label.text = NSLocalizedString("CONVERSATION_SEARCH_NO_RESULTS", comment: "keyboard toolbar label when no messages match the search string") // Keyboard toolbar label when no messages match the search string
case 1: label.text = "CONVERSATION_SEARCH_NO_RESULTS".localized()
label.text = NSLocalizedString("CONVERSATION_SEARCH_ONE_RESULT", comment: "keyboard toolbar label when exactly 1 message matches the search string")
default: case 1:
let format = NSLocalizedString("CONVERSATION_SEARCH_RESULTS_FORMAT", // Keyboard toolbar label when exactly 1 message matches the search string
comment: "keyboard toolbar label when more than 1 message matches the search string. Embeds {{number/position of the 'currently viewed' result}} and the {{total number of results}}") label.text = "CONVERSATION_SEARCH_ONE_RESULT".localized()
default:
// Keyboard toolbar label when more than 1 message matches the search string
//
// Embeds {{number/position of the 'currently viewed' result}} and
// the {{total number of results}}
let format = "CONVERSATION_SEARCH_RESULTS_FORMAT".localized()
guard let currentIndex = currentIndex else { guard let currentIndex: Int = currentIndex else { return }
owsFailDebug("currentIndex was unexpectedly nil")
return label.text = String(format: format, currentIndex + 1, results.count)
} }
label.text = String(format: format, currentIndex + 1, resultSet.messages.count)
}
if let currentIndex = currentIndex { if let currentIndex: Int = currentIndex {
downButton.isEnabled = currentIndex > 0 downButton.isEnabled = currentIndex > 0
upButton.isEnabled = currentIndex + 1 < resultSet.messages.count upButton.isEnabled = (currentIndex + 1 < results.count)
} else { }
else {
downButton.isEnabled = false downButton.isEnabled = false
upButton.isEnabled = false upButton.isEnabled = false
} }
} }
public func startLoading() {
loadingIndicator.startAnimating()
}
public func stopLoading() {
loadingIndicator.stopAnimating()
}
}
// MARK: - ConversationSearchControllerDelegate
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate {
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?)
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId: Int64)
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +0,0 @@
@import Foundation;
typedef NS_ENUM(NSUInteger, ConversationViewAction) {
ConversationViewActionNone,
ConversationViewActionCompose,
ConversationViewActionAudioCall,
ConversationViewActionVideoCall,
};

View File

@ -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

View File

@ -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

View File

@ -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)
}
}

View File

@ -143,7 +143,7 @@ final class ExpandingAttachmentsButton : UIView, InputViewButtonDelegate {
} }
} }
// MARK: Delegate // MARK: - Delegate
protocol ExpandingAttachmentsButtonDelegate: AnyObject { protocol ExpandingAttachmentsButtonDelegate: AnyObject {

View File

@ -4,7 +4,7 @@ public final class InputTextView : UITextView, UITextViewDelegate {
private let maxWidth: CGFloat private let maxWidth: CGFloat
private lazy var heightConstraint = self.set(.height, to: minHeight) private lazy var heightConstraint = self.set(.height, to: minHeight)
public override var text: String! { didSet { handleTextChanged() } } public override var text: String? { didSet { handleTextChanged() } }
// MARK: UI Components // MARK: UI Components
private lazy var placeholderLabel: UILabel = { private lazy var placeholderLabel: UILabel = {
@ -79,21 +79,26 @@ public final class InputTextView : UITextView, UITextViewDelegate {
private func handleTextChanged() { private func handleTextChanged() {
defer { snDelegate?.inputTextViewDidChangeContent(self) } defer { snDelegate?.inputTextViewDidChangeContent(self) }
placeholderLabel.isHidden = !text.isEmpty
placeholderLabel.isHidden = !(text ?? "").isEmpty
let height = frame.height let height = frame.height
let size = sizeThatFits(CGSize(width: maxWidth, height: .greatestFiniteMagnitude)) let size = sizeThatFits(CGSize(width: maxWidth, height: .greatestFiniteMagnitude))
// `textView.contentSize` isn't accurate when restoring a multiline draft, so we set it here manually // `textView.contentSize` isn't accurate when restoring a multiline draft, so we set it here manually
self.contentSize = size self.contentSize = size
let newHeight = size.height.clamp(minHeight, maxHeight) let newHeight = size.height.clamp(minHeight, maxHeight)
guard newHeight != height else { return } guard newHeight != height else { return }
heightConstraint.constant = newHeight heightConstraint.constant = newHeight
snDelegate?.inputTextViewDidChangeSize(self) snDelegate?.inputTextViewDidChangeSize(self)
} }
} }
// MARK: Delegate // MARK: - InputTextViewDelegate
protocol InputTextViewDelegate : AnyObject {
protocol InputTextViewDelegate: AnyObject {
func inputTextViewDidChangeSize(_ inputTextView: InputTextView) func inputTextViewDidChangeSize(_ inputTextView: InputTextView)
func inputTextViewDidChangeContent(_ inputTextView: InputTextView) func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage)

View File

@ -1,51 +1,64 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit import UIKit
import SessionUIKit import SessionUIKit
import SessionMessagingKit
final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, QuoteViewDelegate, LinkPreviewViewDelegate, MentionSelectionViewDelegate { final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate {
enum MessageTypes { // MARK: - Variables
case all
case textOnly
case none
}
private static let linkPreviewViewInset: CGFloat = 6
private let threadVariant: SessionThread.Variant
private weak var delegate: InputViewDelegate? private weak var delegate: InputViewDelegate?
var quoteDraftInfo: (model: OWSQuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } }
var linkPreviewInfo: (url: String, draft: OWSLinkPreviewDraft?)? var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } }
var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)?
private var voiceMessageRecordingView: VoiceMessageRecordingView? private var voiceMessageRecordingView: VoiceMessageRecordingView?
private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0) private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0)
private lazy var linkPreviewView: LinkPreviewView = { private lazy var linkPreviewView: LinkPreviewView = {
let maxWidth = self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset let maxWidth: CGFloat = (self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset)
return LinkPreviewView(for: nil, maxWidth: maxWidth, delegate: self)
return LinkPreviewView(maxWidth: maxWidth) { [weak self] in
self?.linkPreviewInfo = nil
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
}
}() }()
var text: String { var text: String {
get { inputTextView.text } get { inputTextView.text ?? "" }
set { inputTextView.text = newValue } set { inputTextView.text = newValue }
} }
var enabledMessageTypes: MessageTypes = .all { var selectedRange: NSRange {
get { inputTextView.selectedRange }
set { inputTextView.selectedRange = newValue }
}
var inputTextViewIsFirstResponder: Bool { inputTextView.isFirstResponder }
var enabledMessageTypes: MessageInputTypes = .all {
didSet { didSet {
setEnabledMessageTypes(enabledMessageTypes, message: nil) setEnabledMessageTypes(enabledMessageTypes, message: nil)
} }
} }
override var intrinsicContentSize: CGSize { CGSize.zero } override var intrinsicContentSize: CGSize { CGSize.zero }
var lastSearchedText: String? { nil } var lastSearchedText: String? { nil }
// MARK: UI Components // MARK: - UI
private var bottomStackView: UIStackView? private var bottomStackView: UIStackView?
private lazy var attachmentsButton = ExpandingAttachmentsButton(delegate: delegate) private lazy var attachmentsButton = ExpandingAttachmentsButton(delegate: delegate)
private lazy var voiceMessageButton: InputViewButton = { private lazy var voiceMessageButton: InputViewButton = {
let result = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self) let result = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self)
result.accessibilityLabel = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE", comment: "") result.accessibilityLabel = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE", comment: "")
result.accessibilityHint = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE", comment: "") result.accessibilityHint = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE", comment: "")
return result return result
}() }()
private lazy var sendButton: InputViewButton = { private lazy var sendButton: InputViewButton = {
let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self) let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self)
result.isHidden = true result.isHidden = true
@ -55,25 +68,28 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton) private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton)
private lazy var mentionsView: MentionSelectionView = { private lazy var mentionsView: MentionSelectionView = {
let result = MentionSelectionView() let result: MentionSelectionView = MentionSelectionView()
result.delegate = self result.delegate = self
return result return result
}() }()
private lazy var mentionsViewContainer: UIView = { private lazy var mentionsViewContainer: UIView = {
let result = UIView() let result: UIView = UIView()
let backgroundView = UIView() let backgroundView = UIView()
backgroundView.backgroundColor = isLightMode ? .white : .black backgroundView.backgroundColor = (isLightMode ? .white : .black)
backgroundView.alpha = Values.lowOpacity backgroundView.alpha = Values.lowOpacity
result.addSubview(backgroundView) result.addSubview(backgroundView)
backgroundView.pin(to: result) backgroundView.pin(to: result)
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
let blurView: UIVisualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
result.addSubview(blurView) result.addSubview(blurView)
blurView.pin(to: result) blurView.pin(to: result)
result.alpha = 0 result.alpha = 0
return result return result
}() }()
private lazy var inputTextView: InputTextView = { private lazy var inputTextView: InputTextView = {
// HACK: When restoring a draft the input text view won't have a frame yet, and therefore it won't // HACK: When restoring a draft the input text view won't have a frame yet, and therefore it won't
// be able to calculate what size it should be to accommodate the draft text. As a workaround, we // be able to calculate what size it should be to accommodate the draft text. As a workaround, we
@ -83,7 +99,7 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
let maxWidth = UIScreen.main.bounds.width - 2 * InputViewButton.expandedSize - 2 * Values.smallSpacing - 2 * (Values.mediumSpacing - adjustment) let maxWidth = UIScreen.main.bounds.width - 2 * InputViewButton.expandedSize - 2 * Values.smallSpacing - 2 * (Values.mediumSpacing - adjustment)
return InputTextView(delegate: self, maxWidth: maxWidth) return InputTextView(delegate: self, maxWidth: maxWidth)
}() }()
private lazy var disabledInputLabel: UILabel = { private lazy var disabledInputLabel: UILabel = {
let label: UILabel = UILabel() let label: UILabel = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false label.translatesAutoresizingMaskIntoConstraints = false
@ -91,71 +107,78 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
label.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) label.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
label.textAlignment = .center label.textAlignment = .center
label.alpha = 0 label.alpha = 0
return label return label
}() }()
private lazy var additionalContentContainer = UIView() private lazy var additionalContentContainer = UIView()
// MARK: Settings // MARK: - Initialization
private static let linkPreviewViewInset: CGFloat = 6
// MARK: Lifecycle init(threadVariant: SessionThread.Variant, delegate: InputViewDelegate) {
init(delegate: InputViewDelegate) { self.threadVariant = threadVariant
self.delegate = delegate self.delegate = delegate
super.init(frame: CGRect.zero) super.init(frame: CGRect.zero)
setUpViewHierarchy() setUpViewHierarchy()
} }
override init(frame: CGRect) { override init(frame: CGRect) {
preconditionFailure("Use init(delegate:) instead.") preconditionFailure("Use init(delegate:) instead.")
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
preconditionFailure("Use init(delegate:) instead.") preconditionFailure("Use init(delegate:) instead.")
} }
private func setUpViewHierarchy() { private func setUpViewHierarchy() {
autoresizingMask = .flexibleHeight autoresizingMask = .flexibleHeight
// Background & blur // Background & blur
let backgroundView = UIView() let backgroundView = UIView()
backgroundView.backgroundColor = isLightMode ? .white : .black backgroundView.backgroundColor = isLightMode ? .white : .black
backgroundView.alpha = Values.lowOpacity backgroundView.alpha = Values.lowOpacity
addSubview(backgroundView) addSubview(backgroundView)
backgroundView.pin(to: self) backgroundView.pin(to: self)
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
addSubview(blurView) addSubview(blurView)
blurView.pin(to: self) blurView.pin(to: self)
// Separator // Separator
let separator = UIView() let separator = UIView()
separator.backgroundColor = Colors.text.withAlphaComponent(0.2) separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
separator.set(.height, to: 1 / UIScreen.main.scale) separator.set(.height, to: 1 / UIScreen.main.scale)
addSubview(separator) addSubview(separator)
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self) separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
// Bottom stack view // Bottom stack view
let bottomStackView = UIStackView(arrangedSubviews: [ attachmentsButton, inputTextView, container(for: sendButton) ]) let bottomStackView = UIStackView(arrangedSubviews: [ attachmentsButton, inputTextView, container(for: sendButton) ])
bottomStackView.axis = .horizontal bottomStackView.axis = .horizontal
bottomStackView.spacing = Values.smallSpacing bottomStackView.spacing = Values.smallSpacing
bottomStackView.alignment = .center bottomStackView.alignment = .center
self.bottomStackView = bottomStackView self.bottomStackView = bottomStackView
// Main stack view // Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ additionalContentContainer, bottomStackView ]) let mainStackView = UIStackView(arrangedSubviews: [ additionalContentContainer, bottomStackView ])
mainStackView.axis = .vertical mainStackView.axis = .vertical
mainStackView.isLayoutMarginsRelativeArrangement = true mainStackView.isLayoutMarginsRelativeArrangement = true
let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2 let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2
mainStackView.layoutMargins = UIEdgeInsets(top: 2, leading: Values.mediumSpacing - adjustment, bottom: 2, trailing: Values.mediumSpacing - adjustment) mainStackView.layoutMargins = UIEdgeInsets(top: 2, leading: Values.mediumSpacing - adjustment, bottom: 2, trailing: Values.mediumSpacing - adjustment)
addSubview(mainStackView) addSubview(mainStackView)
mainStackView.pin(.top, to: .bottom, of: separator) mainStackView.pin(.top, to: .bottom, of: separator)
mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self) mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self)
mainStackView.pin(.bottom, to: .bottom, of: self) mainStackView.pin(.bottom, to: .bottom, of: self)
addSubview(disabledInputLabel) addSubview(disabledInputLabel)
disabledInputLabel.pin(.top, to: .top, of: mainStackView) disabledInputLabel.pin(.top, to: .top, of: mainStackView)
disabledInputLabel.pin(.left, to: .left, of: mainStackView) disabledInputLabel.pin(.left, to: .left, of: mainStackView)
disabledInputLabel.pin(.right, to: .right, of: mainStackView) disabledInputLabel.pin(.right, to: .right, of: mainStackView)
disabledInputLabel.set(.height, to: InputViewButton.expandedSize) disabledInputLabel.set(.height, to: InputViewButton.expandedSize)
// Mentions // Mentions
insertSubview(mentionsViewContainer, belowSubview: mainStackView) insertSubview(mentionsViewContainer, belowSubview: mainStackView)
mentionsViewContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self) mentionsViewContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self)
@ -163,12 +186,14 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
mentionsViewContainer.addSubview(mentionsView) mentionsViewContainer.addSubview(mentionsView)
mentionsView.pin(to: mentionsViewContainer) mentionsView.pin(to: mentionsViewContainer)
mentionsViewHeightConstraint.isActive = true mentionsViewHeightConstraint.isActive = true
// Voice message button // Voice message button
addSubview(voiceMessageButtonContainer) addSubview(voiceMessageButtonContainer)
voiceMessageButtonContainer.center(in: sendButton) voiceMessageButtonContainer.center(in: sendButton)
} }
// MARK: - Updating
// MARK: Updating
func inputTextViewDidChangeSize(_ inputTextView: InputTextView) { func inputTextViewDidChangeSize(_ inputTextView: InputTextView) {
invalidateIntrinsicContentSize() invalidateIntrinsicContentSize()
} }
@ -180,7 +205,7 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
autoGenerateLinkPreviewIfPossible() autoGenerateLinkPreviewIfPossible()
delegate?.inputTextViewDidChangeContent(inputTextView) delegate?.inputTextViewDidChangeContent(inputTextView)
} }
func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) { func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) {
delegate?.didPasteImageFromPasteboard(image) delegate?.didPasteImageFromPasteboard(image)
} }
@ -188,15 +213,31 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
// We want to show either a link preview or a quote draft, but never both at the same time. When trying to // We want to show either a link preview or a quote draft, but never both at the same time. When trying to
// generate a link preview, wait until we're sure that we'll be able to build a link preview from the given // generate a link preview, wait until we're sure that we'll be able to build a link preview from the given
// URL before removing the quote draft. // URL before removing the quote draft.
private func handleQuoteDraftChanged() { private func handleQuoteDraftChanged() {
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
linkPreviewInfo = nil linkPreviewInfo = nil
guard let quoteDraftInfo = quoteDraftInfo else { return } guard let quoteDraftInfo = quoteDraftInfo else { return }
let direction: QuoteView.Direction = quoteDraftInfo.isOutgoing ? .outgoing : .incoming
let hInset: CGFloat = 6 // Slight visual adjustment let hInset: CGFloat = 6 // Slight visual adjustment
let maxWidth = additionalContentContainer.bounds.width let maxWidth = additionalContentContainer.bounds.width
let quoteView = QuoteView(for: quoteDraftInfo.model, direction: direction, hInset: hInset, maxWidth: maxWidth, delegate: self)
let quoteView: QuoteView = QuoteView(
for: .draft,
authorId: quoteDraftInfo.model.authorId,
quotedText: quoteDraftInfo.model.body,
threadVariant: threadVariant,
currentUserPublicKey: nil,
currentUserBlindedPublicKey: nil,
direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming),
attachment: quoteDraftInfo.model.attachment,
hInset: hInset,
maxWidth: maxWidth
) { [weak self] in
self?.quoteDraftInfo = nil
}
additionalContentContainer.addSubview(quoteView) additionalContentContainer.addSubview(quoteView)
quoteView.pin(.left, to: .left, of: additionalContentContainer, withInset: hInset) quoteView.pin(.left, to: .left, of: additionalContentContainer, withInset: hInset)
quoteView.pin(.top, to: .top, of: additionalContentContainer, withInset: 12) quoteView.pin(.top, to: .top, of: additionalContentContainer, withInset: 12)
@ -207,64 +248,78 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
private func autoGenerateLinkPreviewIfPossible() { private func autoGenerateLinkPreviewIfPossible() {
// Don't allow link previews on 'none' or 'textOnly' input // Don't allow link previews on 'none' or 'textOnly' input
guard enabledMessageTypes == .all else { return } guard enabledMessageTypes == .all else { return }
// Suggest that the user enable link previews if they haven't already and we haven't // Suggest that the user enable link previews if they haven't already and we haven't
// told them about link previews yet // told them about link previews yet
let text = inputTextView.text! let text = inputTextView.text!
let userDefaults = UserDefaults.standard let areLinkPreviewsEnabled: Bool = Storage.shared[.areLinkPreviewsEnabled]
if !OWSLinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && !SSKPreferences.areLinkPreviewsEnabled
&& !userDefaults[.hasSeenLinkPreviewSuggestion] { if
!LinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty &&
!areLinkPreviewsEnabled &&
!UserDefaults.standard[.hasSeenLinkPreviewSuggestion]
{
delegate?.showLinkPreviewSuggestionModal() delegate?.showLinkPreviewSuggestionModal()
userDefaults[.hasSeenLinkPreviewSuggestion] = true UserDefaults.standard[.hasSeenLinkPreviewSuggestion] = true
return return
} }
// Check that link previews are enabled // Check that link previews are enabled
guard SSKPreferences.areLinkPreviewsEnabled else { return } guard areLinkPreviewsEnabled else { return }
// Proceed // Proceed
autoGenerateLinkPreview() autoGenerateLinkPreview()
} }
func autoGenerateLinkPreview() { func autoGenerateLinkPreview() {
// Check that a valid URL is present // Check that a valid URL is present
guard let linkPreviewURL = OWSLinkPreview.previewUrl(forRawBodyText: text, selectedRange: inputTextView.selectedRange) else { guard let linkPreviewURL = LinkPreview.previewUrl(for: text, selectedRange: inputTextView.selectedRange) else {
return return
} }
// Guard against obsolete updates // Guard against obsolete updates
guard linkPreviewURL != self.linkPreviewInfo?.url else { return } guard linkPreviewURL != self.linkPreviewInfo?.url else { return }
// Clear content container // Clear content container
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
quoteDraftInfo = nil quoteDraftInfo = nil
// Set the state to loading // Set the state to loading
linkPreviewInfo = (url: linkPreviewURL, draft: nil) linkPreviewInfo = (url: linkPreviewURL, draft: nil)
linkPreviewView.linkPreviewState = LinkPreviewLoading() linkPreviewView.update(with: LinkPreview.LoadingState(), isOutgoing: false)
// Add the link preview view // Add the link preview view
additionalContentContainer.addSubview(linkPreviewView) additionalContentContainer.addSubview(linkPreviewView)
linkPreviewView.pin(.left, to: .left, of: additionalContentContainer, withInset: InputView.linkPreviewViewInset) linkPreviewView.pin(.left, to: .left, of: additionalContentContainer, withInset: InputView.linkPreviewViewInset)
linkPreviewView.pin(.top, to: .top, of: additionalContentContainer, withInset: 10) linkPreviewView.pin(.top, to: .top, of: additionalContentContainer, withInset: 10)
linkPreviewView.pin(.right, to: .right, of: additionalContentContainer) linkPreviewView.pin(.right, to: .right, of: additionalContentContainer)
linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4) linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4)
// Build the link preview
OWSLinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL).done { [weak self] draft in
guard let self = self else { return }
guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
self.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
self.linkPreviewView.linkPreviewState = LinkPreviewDraft(linkPreviewDraft: draft)
}.catch { _ in
guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
self.linkPreviewInfo = nil
self.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
}.retainUntilComplete()
}
func setEnabledMessageTypes(_ messageTypes: MessageTypes, message: String?) {
guard enabledMessageTypes != messageTypes else { return }
// Build the link preview
LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL)
.done { [weak self] draft in
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
self?.linkPreviewView.update(with: LinkPreview.DraftState(linkPreviewDraft: draft), isOutgoing: false)
}
.catch { [weak self] _ in
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
self?.linkPreviewInfo = nil
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
}
.retainUntilComplete()
}
func setEnabledMessageTypes(_ messageTypes: MessageInputTypes, message: String?) {
guard enabledMessageTypes != messageTypes else { return }
enabledMessageTypes = messageTypes enabledMessageTypes = messageTypes
disabledInputLabel.text = (message ?? "") disabledInputLabel.text = (message ?? "")
attachmentsButton.isUserInteractionEnabled = (messageTypes == .all) attachmentsButton.isUserInteractionEnabled = (messageTypes == .all)
voiceMessageButton.isUserInteractionEnabled = (messageTypes == .all) voiceMessageButton.isUserInteractionEnabled = (messageTypes == .all)
UIView.animate(withDuration: 0.3) { [weak self] in UIView.animate(withDuration: 0.3) { [weak self] in
self?.bottomStackView?.alpha = (messageTypes != .none ? 1 : 0) self?.bottomStackView?.alpha = (messageTypes != .none ? 1 : 0)
self?.attachmentsButton.alpha = (messageTypes == .all ? self?.attachmentsButton.alpha = (messageTypes == .all ?
@ -278,35 +333,40 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
self?.disabledInputLabel.alpha = (messageTypes != .none ? 0 : 1) self?.disabledInputLabel.alpha = (messageTypes != .none ? 0 : 1)
} }
} }
// MARK: - Interaction
// MARK: Interaction
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// Needed so that the user can tap the buttons when the expanding attachments button is expanded // Needed so that the user can tap the buttons when the expanding attachments button is expanded
let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton, let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton,
attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ] attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ]
let buttonContainer = buttonContainers.first { $0.superview!.convert($0.frame, to: self).contains(point) }
if let buttonContainer = buttonContainer { if let buttonContainer: InputViewButton = buttonContainers.first(where: { $0.superview?.convert($0.frame, to: self).contains(point) == true }) {
return buttonContainer return buttonContainer
} else {
return super.hitTest(point, with: event)
} }
return super.hitTest(point, with: event)
} }
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let buttonContainers = [ attachmentsButton.gifButtonContainer, attachmentsButton.documentButtonContainer, let buttonContainers = [ attachmentsButton.gifButtonContainer, attachmentsButton.documentButtonContainer,
attachmentsButton.libraryButtonContainer, attachmentsButton.cameraButtonContainer, attachmentsButton.mainButtonContainer ] attachmentsButton.libraryButtonContainer, attachmentsButton.cameraButtonContainer, attachmentsButton.mainButtonContainer ]
let isPointInsideAttachmentsButton = buttonContainers.contains { $0.superview!.convert($0.frame, to: self).contains(point) } let isPointInsideAttachmentsButton = buttonContainers
.contains { $0.superview!.convert($0.frame, to: self).contains(point) }
if isPointInsideAttachmentsButton { if isPointInsideAttachmentsButton {
// Needed so that the user can tap the buttons when the expanding attachments button is expanded // Needed so that the user can tap the buttons when the expanding attachments button is expanded
return true return true
} else if mentionsViewContainer.frame.contains(point) { }
if mentionsViewContainer.frame.contains(point) {
// Needed so that the user can tap mentions // Needed so that the user can tap mentions
return true return true
} else {
return super.point(inside: point, with: event)
} }
return super.point(inside: point, with: event)
} }
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) { func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) {
if inputViewButton == sendButton { delegate?.handleSendButtonTapped() } if inputViewButton == sendButton { delegate?.handleSendButtonTapped() }
} }
@ -329,23 +389,18 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
voiceMessageRecordingView.handleLongPressEnded(at: location) voiceMessageRecordingView.handleLongPressEnded(at: location)
} }
func handleQuoteViewCancelButtonTapped() {
delegate?.handleQuoteViewCancelButtonTapped()
}
override func resignFirstResponder() -> Bool { override func resignFirstResponder() -> Bool {
inputTextView.resignFirstResponder() inputTextView.resignFirstResponder()
} }
func inputTextViewBecomeFirstResponder() {
inputTextView.becomeFirstResponder()
}
func handleLongPress() { func handleLongPress() {
// Not relevant in this case // Not relevant in this case
} }
func handleLinkPreviewCanceled() {
linkPreviewInfo = nil
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
}
@objc private func showVoiceMessageUI() { @objc private func showVoiceMessageUI() {
voiceMessageRecordingView?.removeFromSuperview() voiceMessageRecordingView?.removeFromSuperview()
let voiceMessageButtonFrame = voiceMessageButton.superview!.convert(voiceMessageButton.frame, to: self) let voiceMessageButtonFrame = voiceMessageButton.superview!.convert(voiceMessageButton.frame, to: self)
@ -373,50 +428,53 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
} }
func hideMentionsUI() { func hideMentionsUI() {
UIView.animate(withDuration: 0.25, animations: { UIView.animate(
self.mentionsViewContainer.alpha = 0 withDuration: 0.25,
}, completion: { _ in animations: { [weak self] in
self.mentionsViewHeightConstraint.constant = 0 self?.mentionsViewContainer.alpha = 0
self.mentionsView.tableView.contentOffset = CGPoint.zero },
}) completion: { [weak self] _ in
self?.mentionsViewHeightConstraint.constant = 0
self?.mentionsView.contentOffset = CGPoint.zero
}
)
} }
func showMentionsUI(for candidates: [Mention], in thread: TSThread) { func showMentionsUI(for candidates: [ConversationViewModel.MentionInfo]) {
if let openGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) {
mentionsView.openGroupServer = openGroupV2.server
mentionsView.openGroupRoom = openGroupV2.room
}
mentionsView.candidates = candidates mentionsView.candidates = candidates
let mentionCellHeight = Values.smallProfilePictureSize + 2 * Values.smallSpacing
let mentionCellHeight = (Values.smallProfilePictureSize + 2 * Values.smallSpacing)
mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight
layoutIfNeeded() layoutIfNeeded()
UIView.animate(withDuration: 0.25) { UIView.animate(withDuration: 0.25) {
self.mentionsViewContainer.alpha = 1 self.mentionsViewContainer.alpha = 1
} }
} }
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) { func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView) {
delegate?.handleMentionSelected(mention, from: view) delegate?.handleMentionSelected(mentionInfo, from: view)
} }
// MARK: Convenience // MARK: - Convenience
private func container(for button: InputViewButton) -> UIView { private func container(for button: InputViewButton) -> UIView {
let result = UIView() let result: UIView = UIView()
result.addSubview(button) result.addSubview(button)
result.set(.width, to: InputViewButton.expandedSize) result.set(.width, to: InputViewButton.expandedSize)
result.set(.height, to: InputViewButton.expandedSize) result.set(.height, to: InputViewButton.expandedSize)
button.center(in: result) button.center(in: result)
return result return result
} }
} }
// MARK: Delegate // MARK: - Delegate
protocol InputViewDelegate : AnyObject, ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate {
protocol InputViewDelegate: ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate {
func showLinkPreviewSuggestionModal() func showLinkPreviewSuggestionModal()
func handleSendButtonTapped() func handleSendButtonTapped()
func handleQuoteViewCancelButtonTapped()
func inputTextViewDidChangeContent(_ inputTextView: InputTextView) func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView)
func didPasteImageFromPasteboard(_ image: UIImage) func didPasteImageFromPasteboard(_ image: UIImage)
} }

View File

@ -59,8 +59,8 @@ final class InputViewButton : UIView {
isUserInteractionEnabled = true isUserInteractionEnabled = true
widthConstraint.isActive = true widthConstraint.isActive = true
heightConstraint.isActive = true heightConstraint.isActive = true
let tint = isSendButton ? UIColor.black : Colors.text let iconImageView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
let iconImageView = UIImageView(image: icon.withTint(tint)) iconImageView.tintColor = (isSendButton ? UIColor.black : Colors.text)
iconImageView.contentMode = .scaleAspectFit iconImageView.contentMode = .scaleAspectFit
let iconSize = InputViewButton.iconSize let iconSize = InputViewButton.iconSize
iconImageView.set(.width, to: iconSize) iconImageView.set(.width, to: iconSize)
@ -141,17 +141,16 @@ final class InputViewButton : UIView {
} }
} }
// MARK: Delegate // MARK: - Delegate
protocol InputViewButtonDelegate : class {
protocol InputViewButtonDelegate: AnyObject {
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) func handleInputViewButtonTapped(_ inputViewButton: InputViewButton)
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton)
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch) func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch)
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch) func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch)
} }
extension InputViewButtonDelegate { extension InputViewButtonDelegate {
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) { } func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) { }
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch) { } func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch) { }
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch) { } func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch) { }

View File

@ -1,36 +1,50 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDelegate { import UIKit
var candidates: [Mention] = [] { import SessionUIKit
import SessionUtilitiesKit
import SignalUtilitiesKit
final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDelegate {
var candidates: [ConversationViewModel.MentionInfo] = [] {
didSet { didSet {
tableView.isScrollEnabled = (candidates.count > 4) tableView.isScrollEnabled = (candidates.count > 4)
tableView.reloadData() tableView.reloadData()
} }
} }
var openGroupServer: String?
var openGroupChannel: UInt64?
var openGroupRoom: String?
weak var delegate: MentionSelectionViewDelegate? weak var delegate: MentionSelectionViewDelegate?
var contentOffset: CGPoint {
get { tableView.contentOffset }
set { tableView.contentOffset = newValue }
}
// MARK: Components // MARK: - Components
lazy var tableView: UITableView = { // TODO: Make this private
let result = UITableView() private lazy var tableView: UITableView = {
let result: UITableView = UITableView()
result.dataSource = self result.dataSource = self
result.delegate = self result.delegate = self
result.register(Cell.self, forCellReuseIdentifier: "Cell")
result.separatorStyle = .none result.separatorStyle = .none
result.backgroundColor = .clear result.backgroundColor = .clear
result.showsVerticalScrollIndicator = false result.showsVerticalScrollIndicator = false
result.register(view: Cell.self)
return result return result
}() }()
// MARK: Initialization // MARK: - Initialization
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
setUpViewHierarchy() setUpViewHierarchy()
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
super.init(coder: coder) super.init(coder: coder)
setUpViewHierarchy() setUpViewHierarchy()
} }
@ -38,43 +52,54 @@ final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDel
// Table view // Table view
addSubview(tableView) addSubview(tableView)
tableView.pin(to: self) tableView.pin(to: self)
// Top separator // Top separator
let topSeparator = UIView() let topSeparator: UIView = UIView()
topSeparator.backgroundColor = Colors.separator topSeparator.backgroundColor = Colors.separator
topSeparator.set(.height, to: Values.separatorThickness) topSeparator.set(.height, to: Values.separatorThickness)
addSubview(topSeparator) addSubview(topSeparator)
topSeparator.pin(.leading, to: .leading, of: self) topSeparator.pin(.leading, to: .leading, of: self)
topSeparator.pin(.top, to: .top, of: self) topSeparator.pin(.top, to: .top, of: self)
topSeparator.pin(.trailing, to: .trailing, of: self) topSeparator.pin(.trailing, to: .trailing, of: self)
// Bottom separator // Bottom separator
let bottomSeparator = UIView() let bottomSeparator: UIView = UIView()
bottomSeparator.backgroundColor = Colors.separator bottomSeparator.backgroundColor = Colors.separator
bottomSeparator.set(.height, to: Values.separatorThickness) bottomSeparator.set(.height, to: Values.separatorThickness)
addSubview(bottomSeparator) addSubview(bottomSeparator)
bottomSeparator.pin(.leading, to: .leading, of: self) bottomSeparator.pin(.leading, to: .leading, of: self)
bottomSeparator.pin(.trailing, to: .trailing, of: self) bottomSeparator.pin(.trailing, to: .trailing, of: self)
bottomSeparator.pin(.bottom, to: .bottom, of: self) bottomSeparator.pin(.bottom, to: .bottom, of: self)
} }
// MARK: Data // MARK: - Data
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return candidates.count return candidates.count
} }
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! Cell let cell: Cell = tableView.dequeue(type: Cell.self, for: indexPath)
let mentionCandidate = candidates[indexPath.row] cell.update(
cell.mentionCandidate = mentionCandidate with: candidates[indexPath.row].profile,
cell.openGroupServer = openGroupServer threadVariant: candidates[indexPath.row].threadVariant,
cell.openGroupChannel = openGroupChannel isUserModeratorOrAdmin: OpenGroupManager.isUserModeratorOrAdmin(
cell.openGroupRoom = openGroupRoom candidates[indexPath.row].profile.id,
cell.separator.isHidden = (indexPath.row == (candidates.count - 1)) for: candidates[indexPath.row].openGroupRoomToken,
on: candidates[indexPath.row].openGroupServer
),
isLast: (indexPath.row == (candidates.count - 1))
)
return cell return cell
} }
// MARK: Interaction // MARK: - Interaction
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let mentionCandidate = candidates[indexPath.row] let mentionCandidate = candidates[indexPath.row]
delegate?.handleMentionSelected(mentionCandidate, from: self) delegate?.handleMentionSelected(mentionCandidate, from: self)
} }
} }
@ -82,56 +107,59 @@ final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDel
// MARK: - Cell // MARK: - Cell
private extension MentionSelectionView { private extension MentionSelectionView {
final class Cell: UITableViewCell {
// MARK: - UI
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView()
final class Cell : UITableViewCell { private lazy var moderatorIconImageView: UIImageView = UIImageView(image: #imageLiteral(resourceName: "Crown"))
var mentionCandidate = Mention(publicKey: "", displayName: "") { didSet { update() } }
var openGroupServer: String?
var openGroupChannel: UInt64?
var openGroupRoom: String?
// MARK: Components
private lazy var profilePictureView = ProfilePictureView()
private lazy var moderatorIconImageView = UIImageView(image: #imageLiteral(resourceName: "Crown"))
private lazy var displayNameLabel: UILabel = { private lazy var displayNameLabel: UILabel = {
let result = UILabel() let result: UILabel = UILabel()
result.textColor = Colors.text result.textColor = Colors.text
result.font = .systemFont(ofSize: Values.smallFontSize) result.font = .systemFont(ofSize: Values.smallFontSize)
result.lineBreakMode = .byTruncatingTail result.lineBreakMode = .byTruncatingTail
return result return result
}() }()
lazy var separator: UIView = { lazy var separator: UIView = {
let result = UIView() let result: UIView = UIView()
result.backgroundColor = Colors.separator result.backgroundColor = Colors.separator
result.set(.height, to: Values.separatorThickness) result.set(.height, to: Values.separatorThickness)
return result return result
}() }()
// MARK: Initialization // MARK: - Initialization
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
setUpViewHierarchy() setUpViewHierarchy()
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
super.init(coder: coder) super.init(coder: coder)
setUpViewHierarchy() setUpViewHierarchy()
} }
private func setUpViewHierarchy() { private func setUpViewHierarchy() {
// Cell background color // Cell background color
backgroundColor = .clear backgroundColor = .clear
// Highlight color // Highlight color
let selectedBackgroundView = UIView() let selectedBackgroundView = UIView()
selectedBackgroundView.backgroundColor = .clear selectedBackgroundView.backgroundColor = .clear
self.selectedBackgroundView = selectedBackgroundView self.selectedBackgroundView = selectedBackgroundView
// Profile picture image view // Profile picture image view
let profilePictureViewSize = Values.smallProfilePictureSize let profilePictureViewSize = Values.smallProfilePictureSize
profilePictureView.set(.width, to: profilePictureViewSize) profilePictureView.set(.width, to: profilePictureViewSize)
profilePictureView.set(.height, to: profilePictureViewSize) profilePictureView.set(.height, to: profilePictureViewSize)
profilePictureView.size = profilePictureViewSize profilePictureView.size = profilePictureViewSize
// Main stack view // Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel ]) let mainStackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel ])
mainStackView.axis = .horizontal mainStackView.axis = .horizontal
@ -144,12 +172,14 @@ private extension MentionSelectionView {
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.mediumSpacing) contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.mediumSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.smallSpacing) contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.smallSpacing)
mainStackView.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing) mainStackView.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing)
// Moderator icon image view // Moderator icon image view
moderatorIconImageView.set(.width, to: 20) moderatorIconImageView.set(.width, to: 20)
moderatorIconImageView.set(.height, to: 20) moderatorIconImageView.set(.height, to: 20)
contentView.addSubview(moderatorIconImageView) contentView.addSubview(moderatorIconImageView)
moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1) moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1)
moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5) moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5)
// Separator // Separator
addSubview(separator) addSubview(separator)
separator.pin(.leading, to: .leading, of: self) separator.pin(.leading, to: .leading, of: self)
@ -157,24 +187,28 @@ private extension MentionSelectionView {
separator.pin(.bottom, to: .bottom, of: self) separator.pin(.bottom, to: .bottom, of: self)
} }
// MARK: Updating // MARK: - Updating
private func update() {
displayNameLabel.text = mentionCandidate.displayName fileprivate func update(
profilePictureView.publicKey = mentionCandidate.publicKey with profile: Profile,
profilePictureView.update() threadVariant: SessionThread.Variant,
if let server = openGroupServer, let room = openGroupRoom { isUserModeratorOrAdmin: Bool,
let isUserModerator = OpenGroupAPIV2.isUserModerator(mentionCandidate.publicKey, for: room, on: server) isLast: Bool
moderatorIconImageView.isHidden = !isUserModerator ) {
} else { displayNameLabel.text = profile.displayName(for: threadVariant)
moderatorIconImageView.isHidden = true profilePictureView.update(
} publicKey: profile.id,
profile: profile,
threadVariant: threadVariant
)
moderatorIconImageView.isHidden = !isUserModeratorOrAdmin
separator.isHidden = isLast
} }
} }
} }
// MARK: - Delegate // MARK: - Delegate
protocol MentionSelectionViewDelegate : class { protocol MentionSelectionViewDelegate: AnyObject {
func handleMentionSelected(_ mention: ConversationViewModel.MentionInfo, from view: MentionSelectionView)
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView)
} }

View File

@ -396,9 +396,9 @@ extension VoiceMessageRecordingView {
} }
} }
// MARK: Delegate // MARK: - Delegate
protocol VoiceMessageRecordingViewDelegate : class {
protocol VoiceMessageRecordingViewDelegate: AnyObject {
func startVoiceMessageRecording() func startVoiceMessageRecording()
func endVoiceMessageRecording() func endVoiceMessageRecording()
func cancelVoiceMessageRecording() func cancelVoiceMessageRecording()

View File

@ -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)
}
}

View File

@ -1,73 +1,88 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit import UIKit
import SessionUIKit
import SessionMessagingKit import SessionMessagingKit
final class CallMessageCell : MessageCell { final class CallMessageCell: MessageCell {
private static let iconSize: CGFloat = 16
private static let inset = Values.mediumSpacing
private static let margin = UIScreen.main.bounds.width * 0.1
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: 0) private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: 0)
private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: 0) private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: 0)
private lazy var infoImageViewWidthConstraint = infoImageView.set(.width, to: 0) private lazy var infoImageViewWidthConstraint = infoImageView.set(.width, to: 0)
private lazy var infoImageViewHeightConstraint = infoImageView.set(.height, to: 0) private lazy var infoImageViewHeightConstraint = infoImageView.set(.height, to: 0)
// MARK: UI Components // MARK: - UI
private lazy var iconImageView = UIImageView()
private lazy var infoImageView = UIImageView(image: UIImage(named: "ic_info")?.withTint(Colors.text)) private lazy var iconImageView: UIImageView = UIImageView()
private lazy var infoImageView: UIImageView = {
let result: UIImageView = UIImageView(image: UIImage(named: "ic_info")?.withRenderingMode(.alwaysTemplate))
result.tintColor = Colors.text
return result
}()
private lazy var timestampLabel: UILabel = { private lazy var timestampLabel: UILabel = {
let result = UILabel() let result: UILabel = UILabel()
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
result.textColor = Colors.text result.textColor = Colors.text
result.textAlignment = .center result.textAlignment = .center
return result return result
}() }()
private lazy var label: UILabel = { private lazy var label: UILabel = {
let result = UILabel() let result: UILabel = UILabel()
result.numberOfLines = 0 result.numberOfLines = 0
result.lineBreakMode = .byWordWrapping result.lineBreakMode = .byWordWrapping
result.font = .boldSystemFont(ofSize: Values.smallFontSize) result.font = .boldSystemFont(ofSize: Values.smallFontSize)
result.textColor = Colors.text result.textColor = Colors.text
result.textAlignment = .center result.textAlignment = .center
return result return result
}() }()
private lazy var container: UIView = { private lazy var container: UIView = {
let result = UIView() let result: UIView = UIView()
result.set(.height, to: 50) result.set(.height, to: 50)
result.layer.cornerRadius = 18 result.layer.cornerRadius = 18
result.backgroundColor = Colors.callMessageBackground result.backgroundColor = Colors.callMessageBackground
result.addSubview(label) result.addSubview(label)
label.autoCenterInSuperview() label.autoCenterInSuperview()
result.addSubview(iconImageView) result.addSubview(iconImageView)
iconImageView.autoVCenterInSuperview() iconImageView.autoVCenterInSuperview()
iconImageView.pin(.left, to: .left, of: result, withInset: CallMessageCell.inset) iconImageView.pin(.left, to: .left, of: result, withInset: CallMessageCell.inset)
result.addSubview(infoImageView) result.addSubview(infoImageView)
infoImageView.autoVCenterInSuperview() infoImageView.autoVCenterInSuperview()
infoImageView.pin(.right, to: .right, of: result, withInset: -CallMessageCell.inset) infoImageView.pin(.right, to: .right, of: result, withInset: -CallMessageCell.inset)
return result return result
}() }()
private lazy var stackView: UIStackView = { private lazy var stackView: UIStackView = {
let result = UIStackView(arrangedSubviews: [ timestampLabel, container ]) let result: UIStackView = UIStackView(arrangedSubviews: [ timestampLabel, container ])
result.axis = .vertical result.axis = .vertical
result.alignment = .center result.alignment = .center
result.spacing = Values.smallSpacing result.spacing = Values.smallSpacing
return result return result
}() }()
// MARK: Settings // MARK: - Lifecycle
private static let iconSize: CGFloat = 16
private static let inset = Values.mediumSpacing
private static let margin = UIScreen.main.bounds.width * 0.1
override class var identifier: String { "CallMessageCell" }
// MARK: Lifecycle
override func setUpViewHierarchy() { override func setUpViewHierarchy() {
super.setUpViewHierarchy() super.setUpViewHierarchy()
iconImageViewWidthConstraint.isActive = true iconImageViewWidthConstraint.isActive = true
iconImageViewHeightConstraint.isActive = true iconImageViewHeightConstraint.isActive = true
addSubview(stackView) addSubview(stackView)
container.autoPinWidthToSuperview() container.autoPinWidthToSuperview()
stackView.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin) stackView.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin)
stackView.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset) stackView.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset)
@ -81,39 +96,71 @@ final class CallMessageCell : MessageCell {
addGestureRecognizer(tapGestureRecognizer) addGestureRecognizer(tapGestureRecognizer)
} }
// MARK: Updating // MARK: - Updating
override func update() {
guard let message = viewItem?.interaction as? TSInfoMessage, message.messageType == .call else { return } override func update(
let icon: UIImage? with cellViewModel: MessageViewModel,
switch message.callState { mediaCache: NSCache<NSString, AnyObject>,
case .outgoing: icon = UIImage(named: "CallOutgoing")?.withTint(Colors.text) playbackInfo: ConversationViewModel.PlaybackInfo?,
case .incoming: icon = UIImage(named: "CallIncoming")?.withTint(Colors.text) lastSearchText: String?
case .missed, .permissionDenied: icon = UIImage(named: "CallMissed")?.withTint(Colors.destructive) ) {
default: icon = nil guard
} cellViewModel.variant == .infoCall,
iconImageView.image = icon let infoMessageData: Data = (cellViewModel.rawBody ?? "").data(using: .utf8),
iconImageViewWidthConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0 let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode(
iconImageViewHeightConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0 CallMessage.MessageInfo.self,
from: infoMessageData
)
else { return }
let shouldShowInfoIcon = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled self.viewModel = cellViewModel
infoImageViewWidthConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0
infoImageViewHeightConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0
Storage.read { transaction in iconImageView.image = {
self.label.text = message.previewText(with: transaction) switch messageInfo.state {
} case .outgoing: return UIImage(named: "CallOutgoing")?.withRenderingMode(.alwaysTemplate)
case .incoming: return UIImage(named: "CallIncoming")?.withRenderingMode(.alwaysTemplate)
case .missed, .permissionDenied: return UIImage(named: "CallMissed")?.withRenderingMode(.alwaysTemplate)
default: return nil
}
}()
iconImageView.tintColor = {
switch messageInfo.state {
case .outgoing, .incoming: return Colors.text
case .missed, .permissionDenied: return Colors.destructive
default: return nil
}
}()
iconImageViewWidthConstraint.constant = (iconImageView.image != nil ? CallMessageCell.iconSize : 0)
iconImageViewHeightConstraint.constant = (iconImageView.image != nil ? CallMessageCell.iconSize : 0)
let date = message.dateForUI() let shouldShowInfoIcon: Bool = (
let description = DateUtil.formatDate(forDisplay: date) messageInfo.state == .permissionDenied &&
timestampLabel.text = description !Storage.shared[.areCallsEnabled]
)
infoImageViewWidthConstraint.constant = (shouldShowInfoIcon ? CallMessageCell.iconSize : 0)
infoImageViewHeightConstraint.constant = (shouldShowInfoIcon ? CallMessageCell.iconSize : 0)
label.text = cellViewModel.body
timestampLabel.text = cellViewModel.dateForUI?.formattedForDisplay
}
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
} }
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
guard let viewItem = viewItem, let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call else { return } guard
let shouldBeTappable = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled let cellViewModel: MessageViewModel = self.viewModel,
if shouldBeTappable { cellViewModel.variant == .infoCall,
delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer) let infoMessageData: Data = (cellViewModel.rawBody ?? "").data(using: .utf8),
} let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode(
CallMessage.MessageInfo.self,
from: infoMessageData
)
else { return }
// Should only be tappable if the info icon is visible
guard messageInfo.state == .permissionDenied && !Storage.shared[.areCallsEnabled] else { return }
self.delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer)
} }
} }

View File

@ -1,18 +1,19 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class CallMessageView : UIView { import UIKit
private let viewItem: ConversationViewItem import SessionUIKit
private let textColor: UIColor import SessionMessagingKit
// MARK: Settings final class CallMessageView: UIView {
private static let iconSize: CGFloat = 24 private static let iconSize: CGFloat = 24
private static let iconImageViewSize: CGFloat = 40 private static let iconImageViewSize: CGFloat = 40
// MARK: Lifecycle // MARK: - Lifecycle
init(viewItem: ConversationViewItem, textColor: UIColor) {
self.viewItem = viewItem init(cellViewModel: MessageViewModel, textColor: UIColor) {
self.textColor = textColor
super.init(frame: CGRect.zero) super.init(frame: CGRect.zero)
setUpViewHierarchy()
setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor)
} }
override init(frame: CGRect) { override init(frame: CGRect) {
@ -23,22 +24,27 @@ final class CallMessageView : UIView {
preconditionFailure("Use init(viewItem:textColor:) instead.") preconditionFailure("Use init(viewItem:textColor:) instead.")
} }
private func setUpViewHierarchy() { private func setUpViewHierarchy(cellViewModel: MessageViewModel, textColor: UIColor) {
guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() }
// Image view // Image view
let iconSize = CallMessageView.iconSize let imageView: UIImageView = UIImageView(
let icon = UIImage(named: "Phone")?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize)) image: UIImage(named: "Phone")?
let imageView = UIImageView(image: icon) .resizedImage(to: CGSize(width: CallMessageView.iconSize, height: CallMessageView.iconSize))?
.withRenderingMode(.alwaysTemplate)
)
imageView.tintColor = textColor
imageView.contentMode = .center imageView.contentMode = .center
let iconImageViewSize = CallMessageView.iconImageViewSize let iconImageViewSize = CallMessageView.iconImageViewSize
imageView.set(.width, to: iconImageViewSize) imageView.set(.width, to: iconImageViewSize)
imageView.set(.height, to: iconImageViewSize) imageView.set(.height, to: iconImageViewSize)
// Body label // Body label
let titleLabel = UILabel() let titleLabel = UILabel()
titleLabel.lineBreakMode = .byTruncatingTail titleLabel.lineBreakMode = .byTruncatingTail
titleLabel.text = message.body titleLabel.text = cellViewModel.body
titleLabel.textColor = textColor titleLabel.textColor = textColor
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize) titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
// Stack view // Stack view
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ]) let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
stackView.axis = .horizontal stackView.axis = .horizontal

View File

@ -1,43 +1,51 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class DeletedMessageView : UIView { import UIKit
private let viewItem: ConversationViewItem import SignalUtilitiesKit
private let textColor: UIColor import SessionUtilitiesKit
// MARK: Settings final class DeletedMessageView: UIView {
private static let iconSize: CGFloat = 18 private static let iconSize: CGFloat = 18
private static let iconImageViewSize: CGFloat = 30 private static let iconImageViewSize: CGFloat = 30
// MARK: Lifecycle // MARK: - Lifecycle
init(viewItem: ConversationViewItem, textColor: UIColor) {
self.viewItem = viewItem init(textColor: UIColor) {
self.textColor = textColor
super.init(frame: CGRect.zero) super.init(frame: CGRect.zero)
setUpViewHierarchy()
setUpViewHierarchy(textColor: textColor)
} }
override init(frame: CGRect) { override init(frame: CGRect) {
preconditionFailure("Use init(viewItem:textColor:) instead.") preconditionFailure("Use init(textColor:) instead.")
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
preconditionFailure("Use init(viewItem:textColor:) instead.") preconditionFailure("Use init(textColor:) instead.")
} }
private func setUpViewHierarchy() { private func setUpViewHierarchy(textColor: UIColor) {
// Image view // Image view
let iconSize = DeletedMessageView.iconSize let icon = UIImage(named: "ic_trash")?
let icon = UIImage(named: "ic_trash")?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize)) .resizedImage(to: CGSize(
width: DeletedMessageView.iconSize,
height: DeletedMessageView.iconSize
))?
.withRenderingMode(.alwaysTemplate)
let imageView = UIImageView(image: icon) let imageView = UIImageView(image: icon)
imageView.tintColor = textColor
imageView.contentMode = .center imageView.contentMode = .center
let iconImageViewSize = DeletedMessageView.iconImageViewSize imageView.set(.width, to: DeletedMessageView.iconImageViewSize)
imageView.set(.width, to: iconImageViewSize) imageView.set(.height, to: DeletedMessageView.iconImageViewSize)
imageView.set(.height, to: iconImageViewSize)
// Body label // Body label
let titleLabel = UILabel() let titleLabel = UILabel()
titleLabel.lineBreakMode = .byTruncatingTail titleLabel.lineBreakMode = .byTruncatingTail
titleLabel.text = NSLocalizedString("message_deleted", comment: "") titleLabel.text = "message_deleted".localized()
titleLabel.textColor = textColor titleLabel.textColor = textColor
titleLabel.font = .systemFont(ofSize: Values.smallFontSize) titleLabel.font = .systemFont(ofSize: Values.smallFontSize)
// Stack view // Stack view
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ]) let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
stackView.axis = .horizontal stackView.axis = .horizontal
@ -45,7 +53,8 @@ final class DeletedMessageView : UIView {
stackView.isLayoutMarginsRelativeArrangement = true stackView.isLayoutMarginsRelativeArrangement = true
stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 6) stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 6)
addSubview(stackView) addSubview(stackView)
stackView.pin(to: self, withInset: Values.smallSpacing) stackView.pin(to: self, withInset: Values.smallSpacing)
stackView.set(.height, to: .height, of: imageView)
} }
} }

View File

@ -1,17 +1,18 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class DocumentView : UIView { import UIKit
private let viewItem: ConversationViewItem import SessionUIKit
private let textColor: UIColor import SessionMessagingKit
// MARK: Settings final class DocumentView: UIView {
private static let iconImageViewSize: CGSize = CGSize(width: 31, height: 40) private static let iconImageViewSize: CGSize = CGSize(width: 31, height: 40)
// MARK: Lifecycle // MARK: - Lifecycle
init(viewItem: ConversationViewItem, textColor: UIColor) {
self.viewItem = viewItem init(attachment: Attachment, textColor: UIColor) {
self.textColor = textColor
super.init(frame: CGRect.zero) super.init(frame: CGRect.zero)
setUpViewHierarchy()
setUpViewHierarchy(attachment: attachment, textColor: textColor)
} }
override init(frame: CGRect) { override init(frame: CGRect) {
@ -22,30 +23,34 @@ final class DocumentView : UIView {
preconditionFailure("Use init(viewItem:textColor:) instead.") preconditionFailure("Use init(viewItem:textColor:) instead.")
} }
private func setUpViewHierarchy() { private func setUpViewHierarchy(attachment: Attachment, textColor: UIColor) {
guard let attachment = viewItem.attachmentStream ?? viewItem.attachmentPointer else { return }
// Image view // Image view
let icon = UIImage(named: "File")?.withTint(textColor) let imageView = UIImageView(image: UIImage(named: "File")?.withRenderingMode(.alwaysTemplate))
let imageView = UIImageView(image: icon) imageView.tintColor = textColor
imageView.contentMode = .center imageView.contentMode = .center
let iconImageViewSize = DocumentView.iconImageViewSize let iconImageViewSize = DocumentView.iconImageViewSize
imageView.set(.width, to: iconImageViewSize.width) imageView.set(.width, to: iconImageViewSize.width)
imageView.set(.height, to: iconImageViewSize.height) imageView.set(.height, to: iconImageViewSize.height)
// Body label // Body label
let titleLabel = UILabel() let titleLabel = UILabel()
titleLabel.lineBreakMode = .byTruncatingTail titleLabel.lineBreakMode = .byTruncatingTail
titleLabel.text = attachment.sourceFilename ?? "File" titleLabel.text = (attachment.sourceFilename ?? "File")
titleLabel.textColor = textColor titleLabel.textColor = textColor
titleLabel.font = .systemFont(ofSize: Values.smallFontSize, weight: .light) titleLabel.font = .systemFont(ofSize: Values.smallFontSize, weight: .light)
// Size label // Size label
let sizeLabel = UILabel() let sizeLabel = UILabel()
sizeLabel.lineBreakMode = .byTruncatingTail sizeLabel.lineBreakMode = .byTruncatingTail
sizeLabel.text = OWSFormat.formatFileSize(UInt(attachment.byteCount)) sizeLabel.text = OWSFormat.formatFileSize(UInt(attachment.byteCount))
sizeLabel.textColor = textColor sizeLabel.textColor = textColor
sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize) sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
// Label stack view // Label stack view
let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, sizeLabel ]) let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, sizeLabel ])
labelStackView.axis = .vertical labelStackView.axis = .vertical
// Stack view // Stack view
let stackView = UIStackView(arrangedSubviews: [ imageView, labelStackView ]) let stackView = UIStackView(arrangedSubviews: [ imageView, labelStackView ])
stackView.axis = .horizontal stackView.axis = .horizontal

View File

@ -1,220 +1,138 @@
// // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
extension CGPoint { import UIKit
import SessionMessagingKit
protocol LinkPreviewState {
var isLoaded: Bool { get }
var urlString: String? { get }
var title: String? { get }
var imageState: LinkPreview.ImageState { get }
var image: UIImage? { get }
}
public extension LinkPreview {
enum ImageState: Int {
case none
case loading
case loaded
case invalid
}
public func offsetBy(dx: CGFloat) -> CGPoint { // MARK: LoadingState
return CGPoint(x: x + dx, y: y)
struct LoadingState: LinkPreviewState {
var isLoaded: Bool { false }
var urlString: String? { nil }
var title: String? { nil }
var imageState: LinkPreview.ImageState { .none }
var image: UIImage? { nil }
} }
// MARK: DraftState
struct DraftState: LinkPreviewState {
var isLoaded: Bool { true }
var urlString: String? { linkPreviewDraft.urlString }
public func offsetBy(dy: CGFloat) -> CGPoint { var title: String? {
return CGPoint(x: x, y: y + dy) guard let value = linkPreviewDraft.title, value.count > 0 else { return nil }
}
} return value
// MARK: -
@objc
public enum LinkPreviewImageState: Int {
case none
case loading
case loaded
case invalid
}
// MARK: -
@objc
public protocol LinkPreviewState {
func isLoaded() -> Bool
func urlString() -> String?
func displayDomain() -> String?
func title() -> String?
func imageState() -> LinkPreviewImageState
func image() -> UIImage?
}
// MARK: -
@objc
public class LinkPreviewLoading: NSObject, LinkPreviewState {
override init() {
}
public func isLoaded() -> Bool {
return false
}
public func urlString() -> String? {
return nil
}
public func displayDomain() -> String? {
return nil
}
public func title() -> String? {
return nil
}
public func imageState() -> LinkPreviewImageState {
return .none
}
public func image() -> UIImage? {
return nil
}
}
// MARK: -
@objc
public class LinkPreviewDraft: NSObject, LinkPreviewState {
private let linkPreviewDraft: OWSLinkPreviewDraft
@objc
public required init(linkPreviewDraft: OWSLinkPreviewDraft) {
self.linkPreviewDraft = linkPreviewDraft
}
public func isLoaded() -> Bool {
return true
}
public func urlString() -> String? {
return linkPreviewDraft.urlString
}
public func displayDomain() -> String? {
guard let displayDomain = linkPreviewDraft.displayDomain() else {
owsFailDebug("Missing display domain")
return nil
} }
return displayDomain
} var imageState: LinkPreview.ImageState {
if linkPreviewDraft.jpegImageData != nil { return .loaded }
public func title() -> String? {
guard let value = linkPreviewDraft.title, return .none
value.count > 0 else { }
var image: UIImage? {
guard let jpegImageData = linkPreviewDraft.jpegImageData else { return nil }
guard let image = UIImage(data: jpegImageData) else {
owsFailDebug("Could not load image: \(jpegImageData.count)")
return nil return nil
}
return image
} }
return value
} // MARK: - Type Specific
private let linkPreviewDraft: LinkPreviewDraft
// MARK: - Initialization
public func imageState() -> LinkPreviewImageState { init(linkPreviewDraft: LinkPreviewDraft) {
if linkPreviewDraft.jpegImageData != nil { self.linkPreviewDraft = linkPreviewDraft
return .loaded
} else {
return .none
} }
} }
// MARK: SentState
struct SentState: LinkPreviewState {
var isLoaded: Bool { true }
var urlString: String? { linkPreview.url }
public func image() -> UIImage? { var title: String? {
guard let jpegImageData = linkPreviewDraft.jpegImageData else { guard let value = linkPreview.title, value.count > 0 else { return nil }
return nil
return value
} }
guard let image = UIImage(data: jpegImageData) else {
owsFailDebug("Could not load image: \(jpegImageData.count)") var imageState: LinkPreview.ImageState {
return nil guard linkPreview.attachmentId != nil else { return .none }
guard let imageAttachment: Attachment = imageAttachment else {
owsFailDebug("Missing imageAttachment.")
return .none
}
switch imageAttachment.state {
case .downloaded, .uploaded:
guard imageAttachment.isImage && imageAttachment.isValid else {
return .invalid
}
return .loaded
case .pendingDownload, .downloading, .uploading: return .loading
case .failedDownload, .failedUpload, .invalid: return .invalid
}
} }
return image
}
}
// MARK: - var image: UIImage? {
// Note: We don't check if the image is valid here because that can be confirmed
@objc // in 'imageState' and it's a little inefficient
public class LinkPreviewSent: NSObject, LinkPreviewState { guard imageAttachment?.isImage == true else { return nil }
private let linkPreview: OWSLinkPreview guard let imageData: Data = try? imageAttachment?.readDataFromFile() else {
private let imageAttachment: TSAttachment?
@objc
public var imageSize: CGSize {
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
return CGSize.zero
}
return attachmentStream.imageSize()
}
@objc
public required init(linkPreview: OWSLinkPreview,
imageAttachment: TSAttachment?) {
self.linkPreview = linkPreview
self.imageAttachment = imageAttachment
}
public func isLoaded() -> Bool {
return true
}
public func urlString() -> String? {
guard let urlString = linkPreview.urlString else {
owsFailDebug("Missing url")
return nil
}
return urlString
}
public func displayDomain() -> String? {
guard let displayDomain = linkPreview.displayDomain() else {
Logger.error("Missing display domain")
return nil
}
return displayDomain
}
public func title() -> String? {
guard let value = linkPreview.title,
value.count > 0 else {
return nil return nil
}
guard let image = UIImage(data: imageData) else {
owsFailDebug("Could not load image: \(imageAttachment?.localRelativeFilePath ?? "unknown")")
return nil
}
return image
} }
return value
} // MARK: - Type Specific
private let linkPreview: LinkPreview
private let imageAttachment: Attachment?
public func imageState() -> LinkPreviewImageState { public var imageSize: CGSize {
guard linkPreview.imageAttachmentId != nil else { guard let width: UInt = imageAttachment?.width, let height: UInt = imageAttachment?.height else {
return .none return CGSize.zero
}
return CGSize(width: CGFloat(width), height: CGFloat(height))
} }
guard let imageAttachment = imageAttachment else {
owsFailDebug("Missing imageAttachment.") // MARK: - Initialization
return .none
}
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
return .loading
}
guard attachmentStream.isImage,
attachmentStream.isValidImage else {
return .invalid
}
return .loaded
}
public func image() -> UIImage? { init(linkPreview: LinkPreview, imageAttachment: Attachment?) {
guard let attachmentStream = imageAttachment as? TSAttachmentStream else { self.linkPreview = linkPreview
return nil self.imageAttachment = imageAttachment
} }
guard attachmentStream.isImage,
attachmentStream.isValidImage else {
return nil
}
guard let imageFilepath = attachmentStream.originalFilePath else {
owsFailDebug("Attachment is missing file path.")
return nil
}
guard let image = UIImage(contentsOfFile: imageFilepath) else {
owsFailDebug("Could not load image: \(imageFilepath)")
return nil
}
return image
} }
} }
// MARK: -
@objc
public protocol LinkPreviewViewDraftDelegate {
func linkPreviewCanCancel() -> Bool
func linkPreviewDidCancel()
}

View File

@ -1,97 +1,106 @@
import NVActivityIndicatorView // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class LinkPreviewView : UIView { import UIKit
private let viewItem: ConversationViewItem? import NVActivityIndicatorView
import SessionUIKit
import SessionMessagingKit
final class LinkPreviewView: UIView {
private static let loaderSize: CGFloat = 24
private static let cancelButtonSize: CGFloat = 45
private let maxWidth: CGFloat private let maxWidth: CGFloat
private let delegate: LinkPreviewViewDelegate private let onCancel: (() -> ())?
var linkPreviewState: LinkPreviewState? { didSet { update() } }
// MARK: - UI
private lazy var imageViewContainerWidthConstraint = imageView.set(.width, to: 100) private lazy var imageViewContainerWidthConstraint = imageView.set(.width, to: 100)
private lazy var imageViewContainerHeightConstraint = imageView.set(.height, to: 100) private lazy var imageViewContainerHeightConstraint = imageView.set(.height, to: 100)
private lazy var sentLinkPreviewTextColor: UIColor = {
let isOutgoing = (viewItem?.interaction.interactionType() == .outgoingMessage)
switch (isOutgoing, AppModeManager.shared.currentAppMode) {
case (false, .light): return .black
case (true, .light): return Colors.grey
default: return .white
}
}()
// MARK: UI Components // MARK: UI Components
private lazy var imageView: UIImageView = { private lazy var imageView: UIImageView = {
let result = UIImageView() let result: UIImageView = UIImageView()
result.contentMode = .scaleAspectFill result.contentMode = .scaleAspectFill
return result return result
}() }()
private lazy var imageViewContainer: UIView = { private lazy var imageViewContainer: UIView = {
let result = UIView() let result: UIView = UIView()
result.clipsToBounds = true result.clipsToBounds = true
return result return result
}() }()
private lazy var loader: NVActivityIndicatorView = { private lazy var loader: NVActivityIndicatorView = {
let color: UIColor = isLightMode ? .black : .white // FIXME: This will have issues with theme transitions
let color: UIColor = (isLightMode ? .black : .white)
return NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: color, padding: nil) return NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: color, padding: nil)
}() }()
private lazy var titleLabel: UILabel = { private lazy var titleLabel: UILabel = {
let result = UILabel() let result: UILabel = UILabel()
result.font = .boldSystemFont(ofSize: Values.smallFontSize) result.font = .boldSystemFont(ofSize: Values.smallFontSize)
result.numberOfLines = 0 result.numberOfLines = 0
return result return result
}() }()
private lazy var bodyTextViewContainer = UIView() private lazy var bodyTextViewContainer: UIView = UIView()
private lazy var hStackViewContainer = UIView() private lazy var hStackViewContainer: UIView = UIView()
private lazy var hStackView = UIStackView() private lazy var hStackView: UIStackView = UIStackView()
private lazy var cancelButton: UIButton = { private lazy var cancelButton: UIButton = {
let result = UIButton(type: .custom) // FIXME: This will have issues with theme transitions
let tint: UIColor = isLightMode ? .black : .white let result: UIButton = UIButton(type: .custom)
result.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal) result.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: UIControl.State.normal)
result.tintColor = (isLightMode ? .black : .white)
let cancelButtonSize = LinkPreviewView.cancelButtonSize let cancelButtonSize = LinkPreviewView.cancelButtonSize
result.set(.width, to: cancelButtonSize) result.set(.width, to: cancelButtonSize)
result.set(.height, to: cancelButtonSize) result.set(.height, to: cancelButtonSize)
result.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside) result.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
return result return result
}() }()
var bodyTextView: UITextView? var bodyTextView: UITextView?
// MARK: Settings // MARK: - Initialization
private static let loaderSize: CGFloat = 24
private static let cancelButtonSize: CGFloat = 45 init(maxWidth: CGFloat, onCancel: (() -> ())? = nil) {
// MARK: Lifecycle
init(for viewItem: ConversationViewItem?, maxWidth: CGFloat, delegate: LinkPreviewViewDelegate) {
self.viewItem = viewItem
self.maxWidth = maxWidth self.maxWidth = maxWidth
self.delegate = delegate self.onCancel = onCancel
super.init(frame: CGRect.zero) super.init(frame: CGRect.zero)
setUpViewHierarchy() setUpViewHierarchy()
} }
override init(frame: CGRect) { override init(frame: CGRect) {
preconditionFailure("Use init(for:maxWidth:delegate:) instead.") preconditionFailure("Use init(for:maxWidth:delegate:) instead.")
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
preconditionFailure("Use init(for:maxWidth:delegate:) instead.") preconditionFailure("Use init(for:maxWidth:delegate:) instead.")
} }
private func setUpViewHierarchy() { private func setUpViewHierarchy() {
// Image view // Image view
imageViewContainerWidthConstraint.isActive = true imageViewContainerWidthConstraint.isActive = true
imageViewContainerHeightConstraint.isActive = true imageViewContainerHeightConstraint.isActive = true
imageViewContainer.addSubview(imageView) imageViewContainer.addSubview(imageView)
imageView.pin(to: imageViewContainer) imageView.pin(to: imageViewContainer)
// Title label // Title label
let titleLabelContainer = UIView() let titleLabelContainer = UIView()
titleLabelContainer.addSubview(titleLabel) titleLabelContainer.addSubview(titleLabel)
titleLabel.pin(to: titleLabelContainer, withInset: Values.smallSpacing) titleLabel.pin(to: titleLabelContainer, withInset: Values.smallSpacing)
// Horizontal stack view // Horizontal stack view
hStackView.addArrangedSubview(imageViewContainer) hStackView.addArrangedSubview(imageViewContainer)
hStackView.addArrangedSubview(titleLabelContainer) hStackView.addArrangedSubview(titleLabelContainer)
@ -99,72 +108,106 @@ final class LinkPreviewView : UIView {
hStackView.alignment = .center hStackView.alignment = .center
hStackViewContainer.addSubview(hStackView) hStackViewContainer.addSubview(hStackView)
hStackView.pin(to: hStackViewContainer) hStackView.pin(to: hStackViewContainer)
// Vertical stack view // Vertical stack view
let vStackView = UIStackView(arrangedSubviews: [ hStackViewContainer, bodyTextViewContainer ]) let vStackView = UIStackView(arrangedSubviews: [ hStackViewContainer, bodyTextViewContainer ])
vStackView.axis = .vertical vStackView.axis = .vertical
addSubview(vStackView) addSubview(vStackView)
vStackView.pin(to: self) vStackView.pin(to: self)
// Loader // Loader
addSubview(loader) addSubview(loader)
let loaderSize = LinkPreviewView.loaderSize let loaderSize = LinkPreviewView.loaderSize
loader.set(.width, to: loaderSize) loader.set(.width, to: loaderSize)
loader.set(.height, to: loaderSize) loader.set(.height, to: loaderSize)
loader.center(in: self) loader.center(in: self)
} }
// MARK: Updating // MARK: - Updating
private func update() {
public func update(
with state: LinkPreviewState,
isOutgoing: Bool,
delegate: (UITextViewDelegate & BodyTextViewDelegate)? = nil,
cellViewModel: MessageViewModel? = nil,
bodyLabelTextColor: UIColor? = nil,
lastSearchText: String? = nil
) {
cancelButton.removeFromSuperview() cancelButton.removeFromSuperview()
guard let linkPreviewState = linkPreviewState else { return }
var image = linkPreviewState.image() var image: UIImage? = state.image
if image == nil && (linkPreviewState is LinkPreviewDraft || linkPreviewState is LinkPreviewSent) { let stateHasImage: Bool = (image != nil)
if image == nil && (state is LinkPreview.DraftState || state is LinkPreview.SentState) {
image = UIImage(named: "Link")?.withTint(isLightMode ? .black : .white) image = UIImage(named: "Link")?.withTint(isLightMode ? .black : .white)
} }
// Image view // Image view
let imageViewContainerSize: CGFloat = (linkPreviewState is LinkPreviewSent) ? 100 : 80 let imageViewContainerSize: CGFloat = (state is LinkPreview.SentState ? 100 : 80)
imageViewContainerWidthConstraint.constant = imageViewContainerSize imageViewContainerWidthConstraint.constant = imageViewContainerSize
imageViewContainerHeightConstraint.constant = imageViewContainerSize imageViewContainerHeightConstraint.constant = imageViewContainerSize
imageViewContainer.layer.cornerRadius = (linkPreviewState is LinkPreviewSent) ? 0 : 8 imageViewContainer.layer.cornerRadius = (state is LinkPreview.SentState ? 0 : 8)
if linkPreviewState is LinkPreviewLoading {
if state is LinkPreview.LoadingState {
imageViewContainer.backgroundColor = .clear imageViewContainer.backgroundColor = .clear
} else { }
else {
imageViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06) imageViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06)
} }
imageView.image = image imageView.image = image
imageView.contentMode = (linkPreviewState.image() == nil) ? .center : .scaleAspectFill imageView.contentMode = (stateHasImage ? .scaleAspectFill : .center)
// Loader // Loader
loader.alpha = (image != nil) ? 0 : 1 loader.alpha = (image != nil ? 0 : 1)
if image != nil { loader.stopAnimating() } else { loader.startAnimating() } if image != nil { loader.stopAnimating() } else { loader.startAnimating() }
// Title // Title
let sentLinkPreviewTextColor: UIColor = {
switch (isOutgoing, AppModeManager.shared.currentAppMode) {
case (false, .light): return .black
case (true, .light): return Colors.grey
default: return .white
}
}()
titleLabel.textColor = sentLinkPreviewTextColor titleLabel.textColor = sentLinkPreviewTextColor
titleLabel.text = linkPreviewState.title() titleLabel.text = state.title
// Horizontal stack view // Horizontal stack view
switch linkPreviewState { switch state {
case is LinkPreviewSent: hStackViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06) case is LinkPreview.SentState:
default: hStackViewContainer.backgroundColor = nil // FIXME: This will have issues with theme transitions
hStackViewContainer.backgroundColor = (isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06))
default:
hStackViewContainer.backgroundColor = nil
} }
// Body text view // Body text view
bodyTextViewContainer.subviews.forEach { $0.removeFromSuperview() } bodyTextViewContainer.subviews.forEach { $0.removeFromSuperview() }
if let viewItem = viewItem {
let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: sentLinkPreviewTextColor, delegate: delegate) if let cellViewModel: MessageViewModel = cellViewModel {
let bodyTextView = VisibleMessageCell.getBodyTextView(
for: cellViewModel,
with: maxWidth,
textColor: (bodyLabelTextColor ?? sentLinkPreviewTextColor),
searchText: lastSearchText,
delegate: delegate
)
self.bodyTextView = bodyTextView self.bodyTextView = bodyTextView
bodyTextViewContainer.addSubview(bodyTextView) bodyTextViewContainer.addSubview(bodyTextView)
bodyTextView.pin(to: bodyTextViewContainer, withInset: 12) bodyTextView.pin(to: bodyTextViewContainer, withInset: 12)
} }
if linkPreviewState is LinkPreviewDraft {
if state is LinkPreview.DraftState {
hStackView.addArrangedSubview(cancelButton) hStackView.addArrangedSubview(cancelButton)
} }
} }
// MARK: Interaction // MARK: - Interaction
@objc private func cancel() { @objc private func cancel() {
delegate.handleLinkPreviewCanceled() onCancel?()
} }
} }
// MARK: Delegate
protocol LinkPreviewViewDelegate : UITextViewDelegate & BodyTextViewDelegate {
var lastSearchedText: String? { get }
func handleLinkPreviewCanceled()
}

View File

@ -1,17 +1,11 @@
// // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation import UIKit
import SessionMessagingKit
@objc(OWSMediaAlbumView)
public class MediaAlbumView: UIStackView { public class MediaAlbumView: UIStackView {
private let items: [ConversationMediaAlbumItem] private let items: [Attachment]
@objc
public let itemViews: [MediaView] public let itemViews: [MediaView]
@objc
public var moreItemsView: MediaView? public var moreItemsView: MediaView?
private static let kSpacingPts: CGFloat = 2 private static let kSpacingPts: CGFloat = 2
@ -22,19 +16,22 @@ public class MediaAlbumView: UIStackView {
notImplemented() notImplemented()
} }
@objc public required init(
public required init(mediaCache: NSCache<NSString, AnyObject>, mediaCache: NSCache<NSString, AnyObject>,
items: [ConversationMediaAlbumItem], items: [Attachment],
isOutgoing: Bool, isOutgoing: Bool,
maxMessageWidth: CGFloat) { maxMessageWidth: CGFloat
) {
self.items = items self.items = items
self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items).map { self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items)
let result = MediaView(mediaCache: mediaCache, .map {
attachment: $0.attachment, MediaView(
isOutgoing: isOutgoing, mediaCache: mediaCache,
maxMessageWidth: maxMessageWidth) attachment: $0,
return result isOutgoing: isOutgoing,
} maxMessageWidth: maxMessageWidth
)
}
super.init(frame: .zero) super.init(frame: .zero)
@ -46,110 +43,137 @@ public class MediaAlbumView: UIStackView {
private func createContents(maxMessageWidth: CGFloat) { private func createContents(maxMessageWidth: CGFloat) {
switch itemViews.count { switch itemViews.count {
case 0: case 0: return owsFailDebug("No item views.")
owsFailDebug("No item views.")
return case 1:
case 1: // X
// X guard let itemView = itemViews.first else {
guard let itemView = itemViews.first else { owsFailDebug("Missing item view.")
owsFailDebug("Missing item view.")
return
}
addSubview(itemView)
itemView.autoPinEdgesToSuperviewEdges()
case 2:
// X X
// side-by-side.
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
autoSet(viewSize: imageSize, ofViews: itemViews)
for itemView in itemViews {
addArrangedSubview(itemView)
}
self.axis = .horizontal
self.spacing = MediaAlbumView.kSpacingPts
case 3:
// x
// X x
// Big on left, 2 small on right.
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
let bigImageSize = smallImageSize * 2 + MediaAlbumView.kSpacingPts
guard let leftItemView = itemViews.first else {
owsFailDebug("Missing view")
return
}
autoSet(viewSize: bigImageSize, ofViews: [leftItemView])
addArrangedSubview(leftItemView)
let rightViews = Array(itemViews[1..<3])
addArrangedSubview(newRow(rowViews: rightViews,
axis: .vertical,
viewSize: smallImageSize))
self.axis = .horizontal
self.spacing = MediaAlbumView.kSpacingPts
case 4:
// X X
// X X
// Square
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
let topViews = Array(itemViews[0..<2])
addArrangedSubview(newRow(rowViews: topViews,
axis: .horizontal,
viewSize: imageSize))
let bottomViews = Array(itemViews[2..<4])
addArrangedSubview(newRow(rowViews: bottomViews,
axis: .horizontal,
viewSize: imageSize))
self.axis = .vertical
self.spacing = MediaAlbumView.kSpacingPts
default:
// X X
// xxx
// 2 big on top, 3 small on bottom.
let bigImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
let topViews = Array(itemViews[0..<2])
addArrangedSubview(newRow(rowViews: topViews,
axis: .horizontal,
viewSize: bigImageSize))
let bottomViews = Array(itemViews[2..<5])
addArrangedSubview(newRow(rowViews: bottomViews,
axis: .horizontal,
viewSize: smallImageSize))
self.axis = .vertical
self.spacing = MediaAlbumView.kSpacingPts
if items.count > MediaAlbumView.kMaxItems {
guard let lastView = bottomViews.last else {
owsFailDebug("Missing lastView")
return return
} }
addSubview(itemView)
itemView.autoPinEdgesToSuperviewEdges()
case 2:
// X X
// side-by-side.
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
autoSet(viewSize: imageSize, ofViews: itemViews)
for itemView in itemViews {
addArrangedSubview(itemView)
}
self.axis = .horizontal
self.distribution = .fillEqually
self.spacing = MediaAlbumView.kSpacingPts
case 3:
// x
// X x
// Big on left, 2 small on right.
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
let bigImageSize = smallImageSize * 2 + MediaAlbumView.kSpacingPts
moreItemsView = lastView guard let leftItemView = itemViews.first else {
owsFailDebug("Missing view")
return
}
autoSet(viewSize: bigImageSize, ofViews: [leftItemView])
addArrangedSubview(leftItemView)
let tintView = UIView() let rightViews = Array(itemViews[1..<3])
tintView.backgroundColor = UIColor(white: 0, alpha: 0.4) addArrangedSubview(
lastView.addSubview(tintView) newRow(
tintView.autoPinEdgesToSuperviewEdges() rowViews: rightViews,
axis: .vertical,
viewSize: smallImageSize
)
)
self.axis = .horizontal
self.spacing = MediaAlbumView.kSpacingPts
case 4:
// X X
// X X
// Square
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
let moreCount = max(1, items.count - MediaAlbumView.kMaxItems) let topViews = Array(itemViews[0..<2])
let moreCountText = OWSFormat.formatInt(Int32(moreCount)) addArrangedSubview(
let moreText = String(format: NSLocalizedString("MEDIA_GALLERY_MORE_ITEMS_FORMAT", newRow(
comment: "Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}."), moreCountText) rowViews: topViews,
let moreLabel = UILabel() axis: .horizontal,
moreLabel.text = moreText viewSize: imageSize
moreLabel.textColor = UIColor.ows_white )
// We don't want to use dynamic text here. )
moreLabel.font = UIFont.systemFont(ofSize: 24)
lastView.addSubview(moreLabel) let bottomViews = Array(itemViews[2..<4])
moreLabel.autoCenterInSuperview() addArrangedSubview(
} newRow(
rowViews: bottomViews,
axis: .horizontal,
viewSize: imageSize
)
)
self.axis = .vertical
self.spacing = MediaAlbumView.kSpacingPts
default:
// X X
// xxx
// 2 big on top, 3 small on bottom.
let bigImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
let topViews = Array(itemViews[0..<2])
addArrangedSubview(
newRow(
rowViews: topViews,
axis: .horizontal,
viewSize: bigImageSize
)
)
let bottomViews = Array(itemViews[2..<5])
addArrangedSubview(
newRow(
rowViews: bottomViews,
axis: .horizontal,
viewSize: smallImageSize
)
)
self.axis = .vertical
self.spacing = MediaAlbumView.kSpacingPts
if items.count > MediaAlbumView.kMaxItems {
guard let lastView = bottomViews.last else {
owsFailDebug("Missing lastView")
return
}
moreItemsView = lastView
let tintView = UIView()
tintView.backgroundColor = UIColor(white: 0, alpha: 0.4)
lastView.addSubview(tintView)
tintView.autoPinEdgesToSuperviewEdges()
let moreCount = max(1, items.count - MediaAlbumView.kMaxItems)
let moreCountText = OWSFormat.formatInt(Int32(moreCount))
let moreText = String(
// Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}.
format: "MEDIA_GALLERY_MORE_ITEMS_FORMAT".localized(),
moreCountText
)
let moreLabel = UILabel()
moreLabel.text = moreText
moreLabel.textColor = UIColor.ows_white
// We don't want to use dynamic text here.
moreLabel.font = UIFont.systemFont(ofSize: 24)
lastView.addSubview(moreLabel)
moreLabel.autoCenterInSuperview()
}
} }
for itemView in itemViews { for itemView in itemViews {
@ -181,43 +205,47 @@ public class MediaAlbumView: UIStackView {
} }
} }
private func autoSet(viewSize: CGFloat, private func autoSet(
ofViews views: [MediaView]) { viewSize: CGFloat,
ofViews views: [MediaView]
) {
for itemView in views { for itemView in views {
itemView.autoSetDimensions(to: CGSize(width: viewSize, height: viewSize)) itemView.autoSetDimensions(to: CGSize(width: viewSize, height: viewSize))
} }
} }
private func newRow(rowViews: [MediaView], private func newRow(
axis: NSLayoutConstraint.Axis, rowViews: [MediaView],
viewSize: CGFloat) -> UIStackView { axis: NSLayoutConstraint.Axis,
viewSize: CGFloat
) -> UIStackView {
autoSet(viewSize: viewSize, ofViews: rowViews) autoSet(viewSize: viewSize, ofViews: rowViews)
return newRow(rowViews: rowViews, axis: axis) return newRow(rowViews: rowViews, axis: axis)
} }
private func newRow(rowViews: [MediaView], private func newRow(
axis: NSLayoutConstraint.Axis) -> UIStackView { rowViews: [MediaView],
axis: NSLayoutConstraint.Axis
) -> UIStackView {
let stackView = UIStackView(arrangedSubviews: rowViews) let stackView = UIStackView(arrangedSubviews: rowViews)
stackView.axis = axis stackView.axis = axis
stackView.spacing = MediaAlbumView.kSpacingPts stackView.spacing = MediaAlbumView.kSpacingPts
return stackView return stackView
} }
@objc
public func loadMedia() { public func loadMedia() {
for itemView in itemViews { for itemView in itemViews {
itemView.loadMedia() itemView.loadMedia()
} }
} }
@objc
public func unloadMedia() { public func unloadMedia() {
for itemView in itemViews { for itemView in itemViews {
itemView.unloadMedia() itemView.unloadMedia()
} }
} }
private class func itemsToDisplay(forItems items: [ConversationMediaAlbumItem]) -> [ConversationMediaAlbumItem] { private class func itemsToDisplay(forItems items: [Attachment]) -> [Attachment] {
// TODO: Unless design changes, we want to display // TODO: Unless design changes, we want to display
// items which are still downloading and invalid // items which are still downloading and invalid
// items. // items.
@ -228,43 +256,47 @@ public class MediaAlbumView: UIStackView {
return validItems return validItems
} }
@objc public class func layoutSize(
public class func layoutSize(forMaxMessageWidth maxMessageWidth: CGFloat, forMaxMessageWidth maxMessageWidth: CGFloat,
items: [ConversationMediaAlbumItem]) -> CGSize { items: [Attachment]
) -> CGSize {
let itemCount = itemsToDisplay(forItems: items).count let itemCount = itemsToDisplay(forItems: items).count
switch itemCount { switch itemCount {
case 0, 1, 4: case 0, 1, 4:
// X // X
// //
// or // or
// //
// XX // XX
// XX // XX
// Square // Square
return CGSize(width: maxMessageWidth, height: maxMessageWidth) return CGSize(width: maxMessageWidth, height: maxMessageWidth)
case 2:
// X X case 2:
// side-by-side. // X X
let imageSize = (maxMessageWidth - kSpacingPts) / 2 // side-by-side.
return CGSize(width: maxMessageWidth, height: imageSize) let imageSize = (maxMessageWidth - kSpacingPts) / 2
case 3: return CGSize(width: maxMessageWidth, height: imageSize)
// x
// X x case 3:
// Big on left, 2 small on right. // x
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3 // X x
let bigImageSize = smallImageSize * 2 + kSpacingPts // Big on left, 2 small on right.
return CGSize(width: maxMessageWidth, height: bigImageSize) let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
default: let bigImageSize = smallImageSize * 2 + kSpacingPts
// X X return CGSize(width: maxMessageWidth, height: bigImageSize)
// xxx
// 2 big on top, 3 small on bottom. default:
let bigImageSize = (maxMessageWidth - kSpacingPts) / 2 // X X
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3 // xxx
return CGSize(width: maxMessageWidth, height: bigImageSize + smallImageSize + kSpacingPts) // 2 big on top, 3 small on bottom.
let bigImageSize = (maxMessageWidth - kSpacingPts) / 2
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
return CGSize(width: maxMessageWidth, height: bigImageSize + smallImageSize + kSpacingPts)
} }
} }
@objc
public func mediaView(forLocation location: CGPoint) -> MediaView? { public func mediaView(forLocation location: CGPoint) -> MediaView? {
var bestMediaView: MediaView? var bestMediaView: MediaView?
var bestDistance: CGFloat = 0 var bestDistance: CGFloat = 0
@ -280,7 +312,6 @@ public class MediaAlbumView: UIStackView {
return bestMediaView return bestMediaView
} }
@objc
public func isMoreItemsView(mediaView: MediaView) -> Bool { public func isMoreItemsView(mediaView: MediaView) -> Bool {
return moreItemsView == mediaView return moreItemsView == mediaView
} }

View File

@ -1,18 +1,18 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class MediaPlaceholderView : UIView { import UIKit
private let viewItem: ConversationViewItem import SessionMessagingKit
private let textColor: UIColor
final class MediaPlaceholderView: UIView {
// MARK: Settings
private static let iconSize: CGFloat = 24 private static let iconSize: CGFloat = 24
private static let iconImageViewSize: CGFloat = 40 private static let iconImageViewSize: CGFloat = 40
// MARK: Lifecycle // MARK: - Lifecycle
init(viewItem: ConversationViewItem, textColor: UIColor) {
self.viewItem = viewItem init(cellViewModel: MessageViewModel, textColor: UIColor) {
self.textColor = textColor
super.init(frame: CGRect.zero) super.init(frame: CGRect.zero)
setUpViewHierarchy()
setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor)
} }
override init(frame: CGRect) { override init(frame: CGRect) {
@ -23,32 +23,47 @@ final class MediaPlaceholderView : UIView {
preconditionFailure("Use init(viewItem:textColor:) instead.") preconditionFailure("Use init(viewItem:textColor:) instead.")
} }
private func setUpViewHierarchy() { private func setUpViewHierarchy(
cellViewModel: MessageViewModel,
textColor: UIColor
) {
let (iconName, attachmentDescription): (String, String) = { let (iconName, attachmentDescription): (String, String) = {
guard let message = viewItem.interaction as? TSIncomingMessage else { return ("actionsheet_document_black", "file") } // Should never occur guard
var attachments: [TSAttachment] = [] cellViewModel.variant == .standardIncoming,
Storage.read { transaction in let attachment: Attachment = cellViewModel.attachments?.first
attachments = message.attachments(with: transaction) else {
return ("actionsheet_document_black", "file") // Should never occur
} }
guard let contentType = attachments.first?.contentType else { return ("actionsheet_document_black", "file") } // Should never occur
if MIMETypeUtil.isAudio(contentType) { return ("attachment_audio", "audio") } if attachment.isAudio { return ("attachment_audio", "audio") }
if MIMETypeUtil.isImage(contentType) || MIMETypeUtil.isVideo(contentType) { return ("actionsheet_camera_roll_black", "media") } if attachment.isImage || attachment.isVideo { return ("actionsheet_camera_roll_black", "media") }
return ("actionsheet_document_black", "file") return ("actionsheet_document_black", "file")
}() }()
// Image view // Image view
let iconSize = MediaPlaceholderView.iconSize let imageView = UIImageView(
let icon = UIImage(named: iconName)?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize)) image: UIImage(named: iconName)?
let imageView = UIImageView(image: icon) .resizedImage(
to: CGSize(
width: MediaPlaceholderView.iconSize,
height: MediaPlaceholderView.iconSize
)
)?
.withRenderingMode(.alwaysTemplate)
)
imageView.tintColor = textColor
imageView.contentMode = .center imageView.contentMode = .center
let iconImageViewSize = MediaPlaceholderView.iconImageViewSize imageView.set(.width, to: MediaPlaceholderView.iconImageViewSize)
imageView.set(.width, to: iconImageViewSize) imageView.set(.height, to: MediaPlaceholderView.iconImageViewSize)
imageView.set(.height, to: iconImageViewSize)
// Body label // Body label
let titleLabel = UILabel() let titleLabel = UILabel()
titleLabel.lineBreakMode = .byTruncatingTail titleLabel.lineBreakMode = .byTruncatingTail
titleLabel.text = "Tap to download \(attachmentDescription)" titleLabel.text = "Tap to download \(attachmentDescription)"
titleLabel.textColor = textColor titleLabel.textColor = textColor
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize) titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
// Stack view // Stack view
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ]) let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
stackView.axis = .horizontal stackView.axis = .horizontal

View File

@ -1,13 +1,13 @@
// // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation import UIKit
import YYImage
import SessionUIKit import SessionUIKit
import SessionMessagingKit
@objc(OWSMediaView)
public class MediaView: UIView { public class MediaView: UIView {
static let contentMode: UIView.ContentMode = .scaleAspectFill
private enum MediaError { private enum MediaError {
case missing case missing
case invalid case invalid
@ -17,8 +17,7 @@ public class MediaView: UIView {
// MARK: - // MARK: -
private let mediaCache: NSCache<NSString, AnyObject> private let mediaCache: NSCache<NSString, AnyObject>
@objc public let attachment: Attachment
public let attachment: TSAttachment
private let isOutgoing: Bool private let isOutgoing: Bool
private let maxMessageWidth: CGFloat private let maxMessageWidth: CGFloat
private var loadBlock: (() -> Void)? private var loadBlock: (() -> Void)?
@ -42,50 +41,16 @@ public class MediaView: UIView {
case failed case failed
} }
// Thread-safe access to load state. private let loadState: Atomic<LoadState> = Atomic(.unloaded)
//
// We use a "box" class so that we can capture a reference
// to this box (rather than self) and a) safely access
// if off the main thread b) not prevent deallocation of
// self.
private class ThreadSafeLoadState {
private var value: LoadState
required init(_ value: LoadState) {
self.value = value
}
func get() -> LoadState {
objc_sync_enter(self)
let valueCopy = value
objc_sync_exit(self)
return valueCopy
}
func set(_ newValue: LoadState) {
objc_sync_enter(self)
value = newValue
objc_sync_exit(self)
}
}
private let threadSafeLoadState = ThreadSafeLoadState(.unloaded)
// Convenience accessors.
private var loadState: LoadState {
get {
return threadSafeLoadState.get()
}
set {
threadSafeLoadState.set(newValue)
}
}
// MARK: - Initializers // MARK: - Initializers
@objc public required init(
public required init(mediaCache: NSCache<NSString, AnyObject>, mediaCache: NSCache<NSString, AnyObject>,
attachment: TSAttachment, attachment: Attachment,
isOutgoing: Bool, isOutgoing: Bool,
maxMessageWidth: CGFloat) { maxMessageWidth: CGFloat
) {
self.mediaCache = mediaCache self.mediaCache = mediaCache
self.attachment = attachment self.attachment = attachment
self.isOutgoing = isOutgoing self.isOutgoing = isOutgoing
@ -105,9 +70,7 @@ public class MediaView: UIView {
} }
deinit { deinit {
AssertIsOnMainThread() loadState.mutate { $0 = .unloaded }
loadState = .unloaded
} }
// MARK: - // MARK: -
@ -115,41 +78,45 @@ public class MediaView: UIView {
private func createContents() { private func createContents() {
AssertIsOnMainThread() AssertIsOnMainThread()
guard let attachmentStream = attachment as? TSAttachmentStream else { guard attachment.state != .pendingDownload && attachment.state != .downloading else {
addDownloadProgressIfNecessary() addDownloadProgressIfNecessary()
return return
} }
guard !isFailedDownload else { guard attachment.state != .failedDownload else {
configure(forError: .failed) configure(forError: .failed)
return return
} }
if attachmentStream.isAnimated { guard attachment.isValid else {
configureForAnimatedImage(attachmentStream: attachmentStream) configure(forError: .invalid)
} else if attachmentStream.isImage { return
configureForStillImage(attachmentStream: attachmentStream) }
} else if attachmentStream.isVideo {
configureForVideo(attachmentStream: attachmentStream) if attachment.isAnimated {
} else { configureForAnimatedImage(attachment: attachment)
}
else if attachment.isImage {
configureForStillImage(attachment: attachment)
}
else if attachment.isVideo {
configureForVideo(attachment: attachment)
}
else {
owsFailDebug("Attachment has unexpected type.") owsFailDebug("Attachment has unexpected type.")
configure(forError: .invalid) configure(forError: .invalid)
} }
} }
private func addDownloadProgressIfNecessary() { private func addDownloadProgressIfNecessary() {
guard !isFailedDownload else { guard attachment.state != .failedDownload else {
configure(forError: .failed) configure(forError: .failed)
return return
} }
guard let attachmentPointer = attachment as? TSAttachmentPointer else { guard attachment.state != .uploading && attachment.state != .uploaded else {
owsFailDebug("Attachment has unexpected type.")
configure(forError: .invalid)
return
}
guard attachmentPointer.pointerType == .incoming else {
// TODO: Show "restoring" indicator and possibly progress. // TODO: Show "restoring" indicator and possibly progress.
configure(forError: .missing) configure(forError: .missing)
return return
} }
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05) backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
let loader = MediaLoaderView() let loader = MediaLoaderView()
addSubview(loader) addSubview(loader)
@ -158,65 +125,71 @@ public class MediaView: UIView {
private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool { private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool {
guard isOutgoing else { return false } guard isOutgoing else { return false }
guard let attachmentStream = attachment as? TSAttachmentStream else { return false } guard attachment.state != .failedUpload else {
guard !attachmentStream.isUploaded else { return false } configure(forError: .failed)
return false
}
// If this message was uploaded on a different device it'll now be seen as 'downloaded' (but
// will still be outgoing - we don't want to show a loading indicator in this case)
guard attachment.state != .uploaded && attachment.state != .downloaded else { return false }
let loader = MediaLoaderView() let loader = MediaLoaderView()
addSubview(loader) addSubview(loader)
loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self) loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self)
return true return true
} }
private func configureForAnimatedImage(attachmentStream: TSAttachmentStream) { private func configureForAnimatedImage(attachment: Attachment) {
guard let cacheKey = attachmentStream.uniqueId else { let animatedImageView: YYAnimatedImageView = YYAnimatedImageView()
owsFailDebug("Attachment stream missing unique ID.")
return
}
let animatedImageView = YYAnimatedImageView()
// We need to specify a contentMode since the size of the image // We need to specify a contentMode since the size of the image
// might not match the aspect ratio of the view. // might not match the aspect ratio of the view.
animatedImageView.contentMode = .scaleAspectFill animatedImageView.contentMode = MediaView.contentMode
// Use trilinear filters for better scaling quality at // Use trilinear filters for better scaling quality at
// some performance cost. // some performance cost.
animatedImageView.layer.minificationFilter = .trilinear animatedImageView.layer.minificationFilter = .trilinear
animatedImageView.layer.magnificationFilter = .trilinear animatedImageView.layer.magnificationFilter = .trilinear
animatedImageView.backgroundColor = Colors.unimportant animatedImageView.backgroundColor = Colors.unimportant
animatedImageView.isHidden = !attachment.isValid
addSubview(animatedImageView) addSubview(animatedImageView)
animatedImageView.autoPinEdgesToSuperviewEdges() animatedImageView.autoPinEdgesToSuperviewEdges()
_ = addUploadProgressIfNecessary(animatedImageView) _ = addUploadProgressIfNecessary(animatedImageView)
loadBlock = { [weak self] in loadBlock = { [weak self] in
AssertIsOnMainThread() AssertIsOnMainThread()
guard let strongSelf = self else {
return
}
if animatedImageView.image != nil { if animatedImageView.image != nil {
owsFailDebug("Unexpectedly already loaded.") owsFailDebug("Unexpectedly already loaded.")
return return
} }
strongSelf.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in self?.tryToLoadMedia(
guard attachmentStream.isValidImage else { loadMediaBlock: { applyMediaBlock in
Logger.warn("Ignoring invalid attachment.") guard attachment.isValid else {
return nil self?.configure(forError: .invalid)
} return
guard let filePath = attachmentStream.originalFilePath else { }
owsFailDebug("Attachment stream missing original file path.") guard let filePath: String = attachment.originalFilePath else {
return nil owsFailDebug("Attachment stream missing original file path.")
} self?.configure(forError: .invalid)
let animatedImage = YYImage(contentsOfFile: filePath) return
return animatedImage }
},
applyMediaBlock: { (media) in applyMediaBlock(YYImage(contentsOfFile: filePath))
AssertIsOnMainThread() },
applyMediaBlock: { media in
guard let image = media as? YYImage else { AssertIsOnMainThread()
owsFailDebug("Media has unexpected type: \(type(of: media))")
return guard let image: YYImage = media as? YYImage else {
} owsFailDebug("Media has unexpected type: \(type(of: media))")
animatedImageView.image = image self?.configure(forError: .invalid)
}, return
cacheKey: cacheKey) }
// FIXME: Animated images flicker when reloading the cells (even though they are in the cache)
animatedImageView.image = image
},
cacheKey: attachment.id
)
} }
unloadBlock = { unloadBlock = {
AssertIsOnMainThread() AssertIsOnMainThread()
@ -225,23 +198,21 @@ public class MediaView: UIView {
} }
} }
private func configureForStillImage(attachmentStream: TSAttachmentStream) { private func configureForStillImage(attachment: Attachment) {
guard let cacheKey = attachmentStream.uniqueId else {
owsFailDebug("Attachment stream missing unique ID.")
return
}
let stillImageView = UIImageView() let stillImageView = UIImageView()
// We need to specify a contentMode since the size of the image // We need to specify a contentMode since the size of the image
// might not match the aspect ratio of the view. // might not match the aspect ratio of the view.
stillImageView.contentMode = .scaleAspectFill stillImageView.contentMode = MediaView.contentMode
// Use trilinear filters for better scaling quality at // Use trilinear filters for better scaling quality at
// some performance cost. // some performance cost.
stillImageView.layer.minificationFilter = .trilinear stillImageView.layer.minificationFilter = .trilinear
stillImageView.layer.magnificationFilter = .trilinear stillImageView.layer.magnificationFilter = .trilinear
stillImageView.backgroundColor = Colors.unimportant stillImageView.backgroundColor = Colors.unimportant
stillImageView.isHidden = !attachment.isValid
addSubview(stillImageView) addSubview(stillImageView)
stillImageView.autoPinEdgesToSuperviewEdges() stillImageView.autoPinEdgesToSuperviewEdges()
_ = addUploadProgressIfNecessary(stillImageView) _ = addUploadProgressIfNecessary(stillImageView)
loadBlock = { [weak self] in loadBlock = { [weak self] in
AssertIsOnMainThread() AssertIsOnMainThread()
@ -249,29 +220,35 @@ public class MediaView: UIView {
owsFailDebug("Unexpectedly already loaded.") owsFailDebug("Unexpectedly already loaded.")
return return
} }
self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in self?.tryToLoadMedia(
guard attachmentStream.isValidImage else { loadMediaBlock: { applyMediaBlock in
Logger.warn("Ignoring invalid attachment.") guard attachment.isValid else {
return nil self?.configure(forError: .invalid)
} return
return attachmentStream.thumbnailImageLarge(success: { (image) in }
attachment.thumbnail(
size: .large,
success: { image, _ in applyMediaBlock(image) },
failure: {
Logger.error("Could not load thumbnail")
self?.configure(forError: .invalid)
}
)
},
applyMediaBlock: { media in
AssertIsOnMainThread() AssertIsOnMainThread()
guard let image: UIImage = media as? UIImage else {
owsFailDebug("Media has unexpected type: \(type(of: media))")
self?.configure(forError: .invalid)
return
}
stillImageView.image = image stillImageView.image = image
}, failure: { },
Logger.error("Could not load thumbnail") cacheKey: attachment.id
}) )
},
applyMediaBlock: { (media) in
AssertIsOnMainThread()
guard let image = media as? UIImage else {
owsFailDebug("Media has unexpected type: \(type(of: media))")
return
}
stillImageView.image = image
},
cacheKey: cacheKey)
} }
unloadBlock = { unloadBlock = {
AssertIsOnMainThread() AssertIsOnMainThread()
@ -280,20 +257,17 @@ public class MediaView: UIView {
} }
} }
private func configureForVideo(attachmentStream: TSAttachmentStream) { private func configureForVideo(attachment: Attachment) {
guard let cacheKey = attachmentStream.uniqueId else {
owsFailDebug("Attachment stream missing unique ID.")
return
}
let stillImageView = UIImageView() let stillImageView = UIImageView()
// We need to specify a contentMode since the size of the image // We need to specify a contentMode since the size of the image
// might not match the aspect ratio of the view. // might not match the aspect ratio of the view.
stillImageView.contentMode = .scaleAspectFill stillImageView.contentMode = MediaView.contentMode
// Use trilinear filters for better scaling quality at // Use trilinear filters for better scaling quality at
// some performance cost. // some performance cost.
stillImageView.layer.minificationFilter = .trilinear stillImageView.layer.minificationFilter = .trilinear
stillImageView.layer.magnificationFilter = .trilinear stillImageView.layer.magnificationFilter = .trilinear
stillImageView.backgroundColor = Colors.unimportant stillImageView.backgroundColor = Colors.unimportant
stillImageView.isHidden = !attachment.isValid
addSubview(stillImageView) addSubview(stillImageView)
stillImageView.autoPinEdgesToSuperviewEdges() stillImageView.autoPinEdgesToSuperviewEdges()
@ -314,29 +288,35 @@ public class MediaView: UIView {
owsFailDebug("Unexpectedly already loaded.") owsFailDebug("Unexpectedly already loaded.")
return return
} }
self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in self?.tryToLoadMedia(
guard attachmentStream.isValidVideo else { loadMediaBlock: { applyMediaBlock in
Logger.warn("Ignoring invalid attachment.") guard attachment.isValid else {
return nil self?.configure(forError: .invalid)
} return
return attachmentStream.thumbnailImageMedium(success: { (image) in }
attachment.thumbnail(
size: .medium,
success: { image, _ in applyMediaBlock(image) },
failure: {
Logger.error("Could not load thumbnail")
self?.configure(forError: .invalid)
}
)
},
applyMediaBlock: { media in
AssertIsOnMainThread() AssertIsOnMainThread()
guard let image: UIImage = media as? UIImage else {
owsFailDebug("Media has unexpected type: \(type(of: media))")
self?.configure(forError: .invalid)
return
}
stillImageView.image = image stillImageView.image = image
}, failure: { },
Logger.error("Could not load thumbnail") cacheKey: attachment.id
}) )
},
applyMediaBlock: { (media) in
AssertIsOnMainThread()
guard let image = media as? UIImage else {
owsFailDebug("Media has unexpected type: \(type(of: media))")
return
}
stillImageView.image = image
},
cacheKey: cacheKey)
} }
unloadBlock = { unloadBlock = {
AssertIsOnMainThread() AssertIsOnMainThread()
@ -345,100 +325,115 @@ public class MediaView: UIView {
} }
} }
private var isFailedDownload: Bool {
guard let attachmentPointer = attachment as? TSAttachmentPointer else {
return false
}
return attachmentPointer.state == .failed
}
private func configure(forError error: MediaError) { private func configure(forError error: MediaError) {
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05) // When there is a failure in the 'loadMediaBlock' closure this can be called
let icon: UIImage // on a background thread - rather than dispatching in every 'loadMediaBlock'
switch error { // usage we just do so here
case .failed: guard Thread.isMainThread else {
guard let asset = UIImage(named: "media_retry") else { DispatchQueue.main.async {
owsFailDebug("Missing image") self.configure(forError: error)
return
} }
icon = asset
case .invalid:
guard let asset = UIImage(named: "media_invalid") else {
owsFailDebug("Missing image")
return
}
icon = asset
case .missing:
return return
} }
let icon: UIImage
switch error {
case .failed:
guard let asset = UIImage(named: "media_retry") else {
owsFailDebug("Missing image")
return
}
icon = asset
case .invalid:
guard let asset = UIImage(named: "media_invalid") else {
owsFailDebug("Missing image")
return
}
icon = asset
case .missing: return
}
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
// For failed ougoing messages add an overlay to make the icon more visible
if isOutgoing {
let attachmentOverlayView: UIView = UIView()
attachmentOverlayView.backgroundColor = Colors.navigationBarBackground
.withAlphaComponent(Values.lowOpacity)
addSubview(attachmentOverlayView)
attachmentOverlayView.pin(to: self)
}
let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate)) let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
iconView.tintColor = Colors.text.withAlphaComponent(Values.mediumOpacity) iconView.tintColor = Colors.text
.withAlphaComponent(Values.mediumOpacity)
addSubview(iconView) addSubview(iconView)
iconView.autoCenterInSuperview() iconView.autoCenterInSuperview()
} }
private func tryToLoadMedia(loadMediaBlock: @escaping () -> AnyObject?, private func tryToLoadMedia(
applyMediaBlock: @escaping (AnyObject) -> Void, loadMediaBlock: @escaping (@escaping (AnyObject?) -> Void) -> Void,
cacheKey: String) { applyMediaBlock: @escaping (AnyObject) -> Void,
AssertIsOnMainThread() cacheKey: String
) {
// It's critical that we update loadState once // It's critical that we update loadState once
// our load attempt is complete. // our load attempt is complete.
let loadCompletion: (AnyObject?) -> Void = { [weak self] (possibleMedia) in let loadCompletion: (AnyObject?) -> Void = { [weak self] possibleMedia in
AssertIsOnMainThread() guard self?.loadState.wrappedValue == .loading else {
guard let strongSelf = self else {
return
}
guard strongSelf.loadState == .loading else {
Logger.verbose("Skipping obsolete load.") Logger.verbose("Skipping obsolete load.")
return return
} }
guard let media = possibleMedia else { guard let media: AnyObject = possibleMedia else {
strongSelf.loadState = .failed self?.loadState.mutate { $0 = .failed }
// TODO: // TODO:
// [self showAttachmentErrorViewWithMediaView:mediaView]; // [self showAttachmentErrorViewWithMediaView:mediaView];
return return
} }
applyMediaBlock(media) applyMediaBlock(media)
strongSelf.loadState = .loaded self?.mediaCache.setObject(media, forKey: cacheKey as NSString)
self?.loadState.mutate { $0 = .loaded }
} }
guard loadState == .loading else { guard loadState.wrappedValue == .loading else {
owsFailDebug("Unexpected load state: \(loadState)") owsFailDebug("Unexpected load state: \(loadState)")
return return
} }
let mediaCache = self.mediaCache if let media: AnyObject = self.mediaCache.object(forKey: cacheKey as NSString) {
if let media = mediaCache.object(forKey: cacheKey as NSString) {
Logger.verbose("media cache hit") Logger.verbose("media cache hit")
guard Thread.isMainThread else {
DispatchQueue.main.async {
loadCompletion(media)
}
return
}
loadCompletion(media) loadCompletion(media)
return return
} }
Logger.verbose("media cache miss") Logger.verbose("media cache miss")
let threadSafeLoadState = self.threadSafeLoadState MediaView.loadQueue.async { [weak self] in
MediaView.loadQueue.async { guard self?.loadState.wrappedValue == .loading else {
guard threadSafeLoadState.get() == .loading else {
Logger.verbose("Skipping obsolete load.") Logger.verbose("Skipping obsolete load.")
return return
} }
guard let media = loadMediaBlock() else { loadMediaBlock { media in
Logger.error("Failed to load media.") guard Thread.isMainThread else {
DispatchQueue.main.async {
DispatchQueue.main.async { loadCompletion(media)
loadCompletion(nil) }
return
} }
return
}
DispatchQueue.main.async {
mediaCache.setObject(media, forKey: cacheKey as NSString)
loadCompletion(media) loadCompletion(media)
} }
} }
@ -459,32 +454,18 @@ public class MediaView: UIView {
// "skip rate" of obsolete loads. // "skip rate" of obsolete loads.
private static let loadQueue = ReverseDispatchQueue(label: "org.signal.asyncMediaLoadQueue") private static let loadQueue = ReverseDispatchQueue(label: "org.signal.asyncMediaLoadQueue")
@objc
public func loadMedia() { public func loadMedia() {
AssertIsOnMainThread() switch loadState.wrappedValue {
case .unloaded:
switch loadState { loadState.mutate { $0 = .loading }
case .unloaded: loadBlock?()
loadState = .loading
case .loading, .loaded, .failed: break
guard let loadBlock = loadBlock else {
return
}
loadBlock()
case .loading, .loaded, .failed:
break
} }
} }
@objc
public func unloadMedia() { public func unloadMedia() {
AssertIsOnMainThread() loadState.mutate { $0 = .unloaded }
unloadBlock?()
loadState = .unloaded
guard let unloadBlock = unloadBlock else {
return
}
unloadBlock()
} }
} }

View File

@ -1,30 +1,24 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class OpenGroupInvitationView : UIView { import UIKit
private let name: String import SessionUIKit
private let rawURL: String import SessionMessagingKit
private let textColor: UIColor
private let isOutgoing: Bool final class OpenGroupInvitationView: UIView {
private lazy var url: String = {
if let range = rawURL.range(of: "?public_key=") {
return String(rawURL[..<range.lowerBound])
} else {
return rawURL
}
}()
// MARK: Settings
private static let iconSize: CGFloat = 24 private static let iconSize: CGFloat = 24
private static let iconImageViewSize: CGFloat = 48 private static let iconImageViewSize: CGFloat = 48
// MARK: Lifecycle // MARK: - Lifecycle
init(name: String, url: String, textColor: UIColor, isOutgoing: Bool) { init(name: String, url: String, textColor: UIColor, isOutgoing: Bool) {
self.name = name
self.rawURL = url
self.textColor = textColor
self.isOutgoing = isOutgoing
super.init(frame: CGRect.zero) super.init(frame: CGRect.zero)
setUpViewHierarchy()
setUpViewHierarchy(
name: name,
rawUrl: url,
textColor: textColor,
isOutgoing: isOutgoing
)
} }
override init(frame: CGRect) { override init(frame: CGRect) {
@ -35,41 +29,56 @@ final class OpenGroupInvitationView : UIView {
preconditionFailure("Use init(name:url:textColor:) instead.") preconditionFailure("Use init(name:url:textColor:) instead.")
} }
private func setUpViewHierarchy() { private func setUpViewHierarchy(name: String, rawUrl: String, textColor: UIColor, isOutgoing: Bool) {
// Title // Title
let titleLabel = UILabel() let titleLabel = UILabel()
titleLabel.lineBreakMode = .byTruncatingTail titleLabel.lineBreakMode = .byTruncatingTail
titleLabel.text = name titleLabel.text = name
titleLabel.textColor = textColor titleLabel.textColor = textColor
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
// Subtitle // Subtitle
let subtitleLabel = UILabel() let subtitleLabel = UILabel()
subtitleLabel.lineBreakMode = .byTruncatingTail subtitleLabel.lineBreakMode = .byTruncatingTail
subtitleLabel.text = NSLocalizedString("view_open_group_invitation_description", comment: "") subtitleLabel.text = NSLocalizedString("view_open_group_invitation_description", comment: "")
subtitleLabel.textColor = textColor subtitleLabel.textColor = textColor
subtitleLabel.font = .systemFont(ofSize: Values.smallFontSize) subtitleLabel.font = .systemFont(ofSize: Values.smallFontSize)
// URL // URL
let urlLabel = UILabel() let urlLabel = UILabel()
urlLabel.lineBreakMode = .byCharWrapping urlLabel.lineBreakMode = .byCharWrapping
urlLabel.text = url urlLabel.text = {
if let range = rawUrl.range(of: "?public_key=") {
return String(rawUrl[..<range.lowerBound])
}
return rawUrl
}()
urlLabel.textColor = textColor urlLabel.textColor = textColor
urlLabel.numberOfLines = 0 urlLabel.numberOfLines = 0
urlLabel.font = .systemFont(ofSize: Values.verySmallFontSize) urlLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
// Label stack // Label stack
let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, UIView.vSpacer(2), subtitleLabel, UIView.vSpacer(4), urlLabel ]) let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, UIView.vSpacer(2), subtitleLabel, UIView.vSpacer(4), urlLabel ])
labelStackView.axis = .vertical labelStackView.axis = .vertical
// Icon // Icon
let iconSize = OpenGroupInvitationView.iconSize let iconSize = OpenGroupInvitationView.iconSize
let iconName = isOutgoing ? "Globe" : "Plus" let iconName = (isOutgoing ? "Globe" : "Plus")
let icon = UIImage(named: iconName)?.withTint(.white)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
let iconImageViewSize = OpenGroupInvitationView.iconImageViewSize let iconImageViewSize = OpenGroupInvitationView.iconImageViewSize
let iconImageView = UIImageView(image: icon) let iconImageView = UIImageView(
image: UIImage(named: iconName)?
.resizedImage(to: CGSize(width: iconSize, height: iconSize))?
.withRenderingMode(.alwaysTemplate)
)
iconImageView.tintColor = .white
iconImageView.contentMode = .center iconImageView.contentMode = .center
iconImageView.layer.cornerRadius = iconImageViewSize / 2 iconImageView.layer.cornerRadius = iconImageViewSize / 2
iconImageView.layer.masksToBounds = true iconImageView.layer.masksToBounds = true
iconImageView.backgroundColor = Colors.accent iconImageView.backgroundColor = Colors.accent
iconImageView.set(.width, to: iconImageViewSize) iconImageView.set(.width, to: iconImageViewSize)
iconImageView.set(.height, to: iconImageViewSize) iconImageView.set(.height, to: iconImageViewSize)
// Main stack // Main stack
let mainStackView = UIStackView(arrangedSubviews: [ iconImageView, labelStackView ]) let mainStackView = UIStackView(arrangedSubviews: [ iconImageView, labelStackView ])
mainStackView.axis = .horizontal mainStackView.axis = .horizontal

View File

@ -1,100 +1,57 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class QuoteView : UIView { import UIKit
private let mode: Mode import SessionUIKit
private let thread: TSThread import SessionMessagingKit
private let direction: Direction
private let hInset: CGFloat
private let maxWidth: CGFloat
private let delegate: QuoteViewDelegate?
private var maxBodyLabelHeight: CGFloat { final class QuoteView: UIView {
switch mode {
case .regular: return 60
case .draft: return 40
}
}
private var attachments: [OWSAttachmentInfo] {
switch mode {
case .regular(let viewItem): return (viewItem.interaction as? TSMessage)?.quotedMessage!.quotedAttachments ?? []
case .draft(let model): return given(model.attachmentStream) { [ OWSAttachmentInfo(attachmentStream: $0) ] } ?? []
}
}
private var thumbnail: UIImage? {
switch mode {
case .regular(let viewItem): return viewItem.quotedReply!.thumbnailImage
case .draft(let model): return model.thumbnailImage
}
}
private var body: String? {
switch mode {
case .regular(let viewItem): return (viewItem.interaction as? TSMessage)?.quotedMessage!.body
case .draft(let model): return model.body
}
}
private var authorID: String {
switch mode {
case .regular(let viewItem): return viewItem.quotedReply!.authorId
case .draft(let model): return model.authorId
}
}
private var lineColor: UIColor {
switch (mode, AppModeManager.shared.currentAppMode) {
case (.regular, .light), (.draft, .light): return .black
case (.regular, .dark): return (direction == .outgoing) ? .black : Colors.accent
case (.draft, .dark): return Colors.accent
}
}
private var textColor: UIColor {
if case .draft = mode { return Colors.text }
switch (direction, AppModeManager.shared.currentAppMode) {
case (.outgoing, .dark), (.incoming, .light): return .black
default: return .white
}
}
// MARK: Mode
enum Mode {
case regular(ConversationViewItem)
case draft(OWSQuotedReplyModel)
}
// MARK: Direction
enum Direction { case incoming, outgoing }
// MARK: Settings
static let thumbnailSize: CGFloat = 48 static let thumbnailSize: CGFloat = 48
static let iconSize: CGFloat = 24 static let iconSize: CGFloat = 24
static let labelStackViewSpacing: CGFloat = 2 static let labelStackViewSpacing: CGFloat = 2
static let labelStackViewVMargin: CGFloat = 4 static let labelStackViewVMargin: CGFloat = 4
static let cancelButtonSize: CGFloat = 33 static let cancelButtonSize: CGFloat = 33
// MARK: Lifecycle enum Mode {
init(for viewItem: ConversationViewItem, in thread: TSThread?, direction: Direction, hInset: CGFloat, maxWidth: CGFloat) { case regular
self.mode = .regular(viewItem) case draft
self.thread = thread ?? TSThread.fetch(uniqueId: viewItem.interaction.uniqueThreadId)!
self.maxWidth = maxWidth
self.direction = direction
self.hInset = hInset
self.delegate = nil
super.init(frame: CGRect.zero)
setUpViewHierarchy()
} }
enum Direction { case incoming, outgoing }
// MARK: - Variables
private let onCancel: (() -> ())?
init(for model: OWSQuotedReplyModel, direction: Direction, hInset: CGFloat, maxWidth: CGFloat, delegate: QuoteViewDelegate) { // MARK: - Lifecycle
self.mode = .draft(model)
self.thread = TSThread.fetch(uniqueId: model.threadId)! init(
self.maxWidth = maxWidth for mode: Mode,
self.direction = direction authorId: String,
self.hInset = hInset quotedText: String?,
self.delegate = delegate threadVariant: SessionThread.Variant,
currentUserPublicKey: String?,
currentUserBlindedPublicKey: String?,
direction: Direction,
attachment: Attachment?,
hInset: CGFloat,
maxWidth: CGFloat,
onCancel: (() -> ())? = nil
) {
self.onCancel = onCancel
super.init(frame: CGRect.zero) super.init(frame: CGRect.zero)
setUpViewHierarchy()
setUpViewHierarchy(
mode: mode,
authorId: authorId,
quotedText: quotedText,
threadVariant: threadVariant,
currentUserPublicKey: currentUserPublicKey,
currentUserBlindedPublicKey: currentUserBlindedPublicKey,
direction: direction,
attachment: attachment,
hInset: hInset,
maxWidth: maxWidth
)
} }
override init(frame: CGRect) { override init(frame: CGRect) {
@ -105,14 +62,24 @@ final class QuoteView : UIView {
preconditionFailure("Use init(for:maxMessageWidth:) instead.") preconditionFailure("Use init(for:maxMessageWidth:) instead.")
} }
private func setUpViewHierarchy() { private func setUpViewHierarchy(
mode: Mode,
authorId: String,
quotedText: String?,
threadVariant: SessionThread.Variant,
currentUserPublicKey: String?,
currentUserBlindedPublicKey: String?,
direction: Direction,
attachment: Attachment?,
hInset: CGFloat,
maxWidth: CGFloat
) {
// There's quite a bit of calculation going on here. It's a bit complex so don't make changes // There's quite a bit of calculation going on here. It's a bit complex so don't make changes
// if you don't need to. If you do then test: // if you don't need to. If you do then test:
// Quoted text in both private chats and group chats // Quoted text in both private chats and group chats
// Quoted images and videos in both private chats and group chats // Quoted images and videos in both private chats and group chats
// Quoted voice messages and documents in both private chats and group chats // Quoted voice messages and documents in both private chats and group chats
// All of the above in both dark mode and light mode // All of the above in both dark mode and light mode
let hasAttachments = !attachments.isEmpty
let thumbnailSize = QuoteView.thumbnailSize let thumbnailSize = QuoteView.thumbnailSize
let iconSize = QuoteView.iconSize let iconSize = QuoteView.iconSize
let labelStackViewSpacing = QuoteView.labelStackViewSpacing let labelStackViewSpacing = QuoteView.labelStackViewSpacing
@ -120,18 +87,23 @@ final class QuoteView : UIView {
let smallSpacing = Values.smallSpacing let smallSpacing = Values.smallSpacing
let cancelButtonSize = QuoteView.cancelButtonSize let cancelButtonSize = QuoteView.cancelButtonSize
var availableWidth: CGFloat var availableWidth: CGFloat
// Subtract smallSpacing twice; once for the spacing in between the stack view elements and // Subtract smallSpacing twice; once for the spacing in between the stack view elements and
// once for the trailing margin. // once for the trailing margin.
if !hasAttachments { if attachment == nil {
availableWidth = maxWidth - 2 * hInset - Values.accentLineThickness - 2 * smallSpacing availableWidth = maxWidth - 2 * hInset - Values.accentLineThickness - 2 * smallSpacing
} else { }
else {
availableWidth = maxWidth - 2 * hInset - thumbnailSize - 2 * smallSpacing availableWidth = maxWidth - 2 * hInset - thumbnailSize - 2 * smallSpacing
} }
if case .draft = mode { if case .draft = mode {
availableWidth -= cancelButtonSize availableWidth -= cancelButtonSize
} }
let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude) let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude)
var body = self.body var body: String? = quotedText
// Main stack view // Main stack view
let mainStackView = UIStackView(arrangedSubviews: []) let mainStackView = UIStackView(arrangedSubviews: [])
mainStackView.axis = .horizontal mainStackView.axis = .horizontal
@ -139,49 +111,129 @@ final class QuoteView : UIView {
mainStackView.isLayoutMarginsRelativeArrangement = true mainStackView.isLayoutMarginsRelativeArrangement = true
mainStackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: smallSpacing) mainStackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: smallSpacing)
mainStackView.alignment = .center mainStackView.alignment = .center
// Content view // Content view
let contentView = UIView() let contentView = UIView()
addSubview(contentView) addSubview(contentView)
contentView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self) contentView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self)
contentView.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor).isActive = true contentView.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor).isActive = true
// Line view // Line view
let lineColor: UIColor = {
switch (mode, AppModeManager.shared.currentAppMode) {
case (.regular, .light), (.draft, .light): return .black
case (.regular, .dark): return (direction == .outgoing) ? .black : Colors.accent
case (.draft, .dark): return Colors.accent
}
}()
let lineView = UIView() let lineView = UIView()
lineView.backgroundColor = lineColor lineView.backgroundColor = lineColor
lineView.set(.width, to: Values.accentLineThickness) lineView.set(.width, to: Values.accentLineThickness)
if !hasAttachments {
mainStackView.addArrangedSubview(lineView) if let attachment: Attachment = attachment {
} else { let isAudio: Bool = MIMETypeUtil.isAudio(attachment.contentType)
let isAudio = MIMETypeUtil.isAudio(attachments.first!.contentType ?? "") let fallbackImageName: String = (isAudio ? "attachment_audio" : "actionsheet_document_black")
let fallbackImageName = isAudio ? "attachment_audio" : "actionsheet_document_black" let imageView: UIImageView = UIImageView(
let fallbackImage = UIImage(named: fallbackImageName)?.withTint(.white)?.resizedImage(to: CGSize(width: iconSize, height: iconSize)) image: UIImage(named: fallbackImageName)?
let imageView = UIImageView(image: thumbnail ?? fallbackImage) .resizedImage(to: CGSize(width: iconSize, height: iconSize))?
imageView.contentMode = (thumbnail != nil) ? .scaleAspectFill : .center .withRenderingMode(.alwaysTemplate)
)
imageView.tintColor = .white
imageView.contentMode = .center
imageView.backgroundColor = lineColor imageView.backgroundColor = lineColor
imageView.layer.cornerRadius = VisibleMessageCell.smallCornerRadius imageView.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
imageView.layer.masksToBounds = true imageView.layer.masksToBounds = true
imageView.set(.width, to: thumbnailSize) imageView.set(.width, to: thumbnailSize)
imageView.set(.height, to: thumbnailSize) imageView.set(.height, to: thumbnailSize)
mainStackView.addArrangedSubview(imageView) mainStackView.addArrangedSubview(imageView)
if (body ?? "").isEmpty { if (body ?? "").isEmpty {
body = (thumbnail != nil) ? "Image" : (isAudio ? "Audio" : "Document") body = (attachment.isImage ?
"Image" :
(isAudio ? "Audio" : "Document")
)
}
// Generate the thumbnail if needed
if attachment.isVisualMedia {
attachment.thumbnail(
size: .small,
success: { image, _ in
guard Thread.isMainThread else {
DispatchQueue.main.async {
imageView.image = image
imageView.contentMode = .scaleAspectFill
}
return
}
imageView.image = image
imageView.contentMode = .scaleAspectFill
},
failure: {}
)
} }
} }
else {
mainStackView.addArrangedSubview(lineView)
}
// Body label // Body label
let textColor: UIColor = {
guard mode != .draft else { return Colors.text }
switch (direction, AppModeManager.shared.currentAppMode) {
case (.outgoing, .dark), (.incoming, .light): return .black
default: return .white
}
}()
let bodyLabel = UILabel() let bodyLabel = UILabel()
bodyLabel.numberOfLines = 0 bodyLabel.numberOfLines = 0
bodyLabel.lineBreakMode = .byTruncatingTail bodyLabel.lineBreakMode = .byTruncatingTail
let isOutgoing = (direction == .outgoing) let isOutgoing = (direction == .outgoing)
bodyLabel.font = .systemFont(ofSize: Values.smallFontSize) bodyLabel.font = .systemFont(ofSize: Values.smallFontSize)
bodyLabel.attributedText = given(body) { MentionUtilities.highlightMentions(in: $0, isOutgoingMessage: isOutgoing, threadID: thread.uniqueId!, attributes: [:]) } ?? given(attachments.first?.contentType) { NSAttributedString(string: MIMETypeUtil.isAudio($0) ? "Audio" : "Document") } ?? NSAttributedString(string: "Document") bodyLabel.attributedText = body
.map {
MentionUtilities.highlightMentions(
in: $0,
threadVariant: threadVariant,
currentUserPublicKey: currentUserPublicKey,
currentUserBlindedPublicKey: currentUserBlindedPublicKey,
isOutgoingMessage: isOutgoing,
attributes: [:]
)
}
.defaulting(
to: attachment.map {
NSAttributedString(string: MIMETypeUtil.isAudio($0.contentType) ? "Audio" : "Document")
}
)
.defaulting(to: NSAttributedString(string: "Document"))
bodyLabel.textColor = textColor bodyLabel.textColor = textColor
let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace) let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace)
// Label stack view // Label stack view
var authorLabelHeight: CGFloat? var authorLabelHeight: CGFloat?
if let groupThread = thread as? TSGroupThread { if threadVariant == .openGroup || threadVariant == .closedGroup {
let isCurrentUser: Bool = [
currentUserPublicKey,
currentUserBlindedPublicKey,
]
.compactMap { $0 }
.asSet()
.contains(authorId)
let authorLabel = UILabel() let authorLabel = UILabel()
authorLabel.lineBreakMode = .byTruncatingTail authorLabel.lineBreakMode = .byTruncatingTail
let context: Contact.Context = groupThread.isOpenGroup ? .openGroup : .regular authorLabel.text = (isCurrentUser ?
authorLabel.text = Storage.shared.getContact(with: authorID)?.displayName(for: context) ?? authorID "MEDIA_GALLERY_SENDER_NAME_YOU".localized() :
Profile.displayName(
id: authorId,
threadVariant: threadVariant
)
)
authorLabel.textColor = textColor authorLabel.textColor = textColor
authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize)
let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace) let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace)
@ -195,51 +247,56 @@ final class QuoteView : UIView {
labelStackView.isLayoutMarginsRelativeArrangement = true labelStackView.isLayoutMarginsRelativeArrangement = true
labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0) labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0)
mainStackView.addArrangedSubview(labelStackView) mainStackView.addArrangedSubview(labelStackView)
} else { }
else {
mainStackView.addArrangedSubview(bodyLabel) mainStackView.addArrangedSubview(bodyLabel)
} }
// Cancel button // Cancel button
let cancelButton = UIButton(type: .custom) let cancelButton = UIButton(type: .custom)
let tint: UIColor = isLightMode ? .black : .white cancelButton.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: UIControl.State.normal)
cancelButton.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal) cancelButton.tintColor = (isLightMode ? .black : .white)
cancelButton.set(.width, to: cancelButtonSize) cancelButton.set(.width, to: cancelButtonSize)
cancelButton.set(.height, to: cancelButtonSize) cancelButton.set(.height, to: cancelButtonSize)
cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside) cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
// Constraints // Constraints
contentView.addSubview(mainStackView) contentView.addSubview(mainStackView)
mainStackView.pin(to: contentView) mainStackView.pin(to: contentView)
if !thread.isGroupThread() {
if threadVariant != .openGroup && threadVariant != .closedGroup {
bodyLabel.set(.width, to: bodyLabelSize.width) bodyLabel.set(.width, to: bodyLabelSize.width)
} }
let bodyLabelHeight = bodyLabelSize.height.clamp(0, maxBodyLabelHeight)
let bodyLabelHeight = bodyLabelSize.height.clamp(0, (mode == .regular ? 60 : 40))
let contentViewHeight: CGFloat let contentViewHeight: CGFloat
if hasAttachments {
if attachment != nil {
contentViewHeight = thumbnailSize + 8 // Add a small amount of spacing above and below the thumbnail contentViewHeight = thumbnailSize + 8 // Add a small amount of spacing above and below the thumbnail
bodyLabel.set(.height, to: 18) // Experimentally determined bodyLabel.set(.height, to: 18) // Experimentally determined
} else { }
else {
if let authorLabelHeight = authorLabelHeight { // Group thread if let authorLabelHeight = authorLabelHeight { // Group thread
contentViewHeight = bodyLabelHeight + (authorLabelHeight + labelStackViewSpacing) + 2 * labelStackViewVMargin contentViewHeight = bodyLabelHeight + (authorLabelHeight + labelStackViewSpacing) + 2 * labelStackViewVMargin
} else { }
else {
contentViewHeight = bodyLabelHeight + 2 * smallSpacing contentViewHeight = bodyLabelHeight + 2 * smallSpacing
} }
} }
contentView.set(.height, to: contentViewHeight) contentView.set(.height, to: contentViewHeight)
lineView.set(.height, to: contentViewHeight - 8) // Add a small amount of spacing above and below the line lineView.set(.height, to: contentViewHeight - 8) // Add a small amount of spacing above and below the line
if case .draft = mode {
if mode == .draft {
addSubview(cancelButton) addSubview(cancelButton)
cancelButton.center(.vertical, in: self) cancelButton.center(.vertical, in: self)
cancelButton.pin(.right, to: .right, of: self) cancelButton.pin(.right, to: .right, of: self)
} }
} }
// MARK: Interaction // MARK: - Interaction
@objc private func cancel() { @objc private func cancel() {
delegate?.handleQuoteViewCancelButtonTapped() onCancel?()
} }
} }
// MARK: Delegate
protocol QuoteViewDelegate {
func handleQuoteViewCancelButtonTapped()
}

View File

@ -1,78 +1,84 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import NVActivityIndicatorView import NVActivityIndicatorView
import SessionUIKit
import SessionMessagingKit
@objc(SNVoiceMessageView) public final class VoiceMessageView: UIView {
public final class VoiceMessageView : UIView { private static let width: CGFloat = 160
private let viewItem: ConversationViewItem private static let toggleContainerSize: CGFloat = 20
private var isShowingSpeedUpLabel = false private static let inset = Values.smallSpacing
@objc var progress: Int = 0 { didSet { handleProgressChanged() } }
@objc var isPlaying = false { didSet { handleIsPlayingChanged() } } // MARK: - UI
private lazy var progressViewRightConstraint = progressView.pin(.right, to: .right, of: self, withInset: -VoiceMessageView.width) private lazy var progressViewRightConstraint = progressView.pin(.right, to: .right, of: self, withInset: -VoiceMessageView.width)
private var attachment: TSAttachment? { viewItem.attachmentStream ?? viewItem.attachmentPointer }
private var duration: Int { Int(viewItem.audioDurationSeconds) }
// MARK: UI Components
private lazy var progressView: UIView = { private lazy var progressView: UIView = {
let result = UIView() let result: UIView = UIView()
result.backgroundColor = UIColor.black.withAlphaComponent(0.2) result.backgroundColor = UIColor.black.withAlphaComponent(0.2)
return result return result
}() }()
private lazy var toggleImageView: UIImageView = { private lazy var toggleImageView: UIImageView = {
let result = UIImageView(image: UIImage(named: "Play")) let result: UIImageView = UIImageView(image: UIImage(named: "Play"))
result.contentMode = .scaleAspectFit
result.set(.width, to: 8) result.set(.width, to: 8)
result.set(.height, to: 8) result.set(.height, to: 8)
result.contentMode = .scaleAspectFit
return result return result
}() }()
private lazy var loader: NVActivityIndicatorView = { private lazy var loader: NVActivityIndicatorView = {
let result = NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: Colors.text, padding: nil) let result: NVActivityIndicatorView = NVActivityIndicatorView(
frame: .zero,
type: .circleStrokeSpin,
color: Colors.text,
padding: nil
)
result.set(.width, to: VoiceMessageView.toggleContainerSize + 2) result.set(.width, to: VoiceMessageView.toggleContainerSize + 2)
result.set(.height, to: VoiceMessageView.toggleContainerSize + 2) result.set(.height, to: VoiceMessageView.toggleContainerSize + 2)
return result return result
}() }()
private lazy var countdownLabelContainer: UIView = { private lazy var countdownLabelContainer: UIView = {
let result = UIView() let result: UIView = UIView()
result.backgroundColor = .white result.backgroundColor = .white
result.layer.masksToBounds = true result.layer.masksToBounds = true
result.set(.height, to: VoiceMessageView.toggleContainerSize) result.set(.height, to: VoiceMessageView.toggleContainerSize)
result.set(.width, to: 44) result.set(.width, to: 44)
return result return result
}() }()
private lazy var countdownLabel: UILabel = { private lazy var countdownLabel: UILabel = {
let result = UILabel() let result: UILabel = UILabel()
result.textColor = .black result.textColor = .black
result.font = .systemFont(ofSize: Values.smallFontSize) result.font = .systemFont(ofSize: Values.smallFontSize)
result.text = "0:00" result.text = "0:00"
return result return result
}() }()
private lazy var speedUpLabel: UILabel = { private lazy var speedUpLabel: UILabel = {
let result = UILabel() let result: UILabel = UILabel()
result.textColor = .black result.textColor = .black
result.font = .systemFont(ofSize: Values.smallFontSize) result.font = .systemFont(ofSize: Values.smallFontSize)
result.alpha = 0 result.alpha = 0
result.text = "1.5x" result.text = "1.5x"
result.textAlignment = .center result.textAlignment = .center
return result return result
}() }()
// MARK: Settings // MARK: - Lifecycle
private static let width: CGFloat = 160
private static let toggleContainerSize: CGFloat = 20 init() {
private static let inset = Values.smallSpacing
// MARK: Lifecycle
init(viewItem: ConversationViewItem) {
self.viewItem = viewItem
self.progress = Int(viewItem.audioProgressSeconds)
super.init(frame: CGRect.zero) super.init(frame: CGRect.zero)
setUpViewHierarchy() setUpViewHierarchy()
handleProgressChanged()
} }
override init(frame: CGRect) { override init(frame: CGRect) {
@ -86,27 +92,33 @@ public final class VoiceMessageView : UIView {
private func setUpViewHierarchy() { private func setUpViewHierarchy() {
let toggleContainerSize = VoiceMessageView.toggleContainerSize let toggleContainerSize = VoiceMessageView.toggleContainerSize
let inset = VoiceMessageView.inset let inset = VoiceMessageView.inset
// Width & height // Width & height
set(.width, to: VoiceMessageView.width) set(.width, to: VoiceMessageView.width)
// Toggle // Toggle
let toggleContainer = UIView() let toggleContainer: UIView = UIView()
toggleContainer.backgroundColor = .white toggleContainer.backgroundColor = .white
toggleContainer.set(.width, to: toggleContainerSize) toggleContainer.set(.width, to: toggleContainerSize)
toggleContainer.set(.height, to: toggleContainerSize) toggleContainer.set(.height, to: toggleContainerSize)
toggleContainer.addSubview(toggleImageView) toggleContainer.addSubview(toggleImageView)
toggleImageView.center(in: toggleContainer) toggleImageView.center(in: toggleContainer)
toggleContainer.layer.cornerRadius = toggleContainerSize / 2 toggleContainer.layer.cornerRadius = (toggleContainerSize / 2)
toggleContainer.layer.masksToBounds = true toggleContainer.layer.masksToBounds = true
// Line // Line
let lineView = UIView() let lineView = UIView()
lineView.backgroundColor = .white lineView.backgroundColor = .white
lineView.set(.height, to: 1) lineView.set(.height, to: 1)
// Countdown label // Countdown label
countdownLabelContainer.addSubview(countdownLabel) countdownLabelContainer.addSubview(countdownLabel)
countdownLabel.center(in: countdownLabelContainer) countdownLabel.center(in: countdownLabelContainer)
// Speed up label // Speed up label
countdownLabelContainer.addSubview(speedUpLabel) countdownLabelContainer.addSubview(speedUpLabel)
speedUpLabel.center(in: countdownLabelContainer) speedUpLabel.center(in: countdownLabelContainer)
// Constraints // Constraints
addSubview(progressView) addSubview(progressView)
progressView.pin(.left, to: .left, of: self) progressView.pin(.left, to: .left, of: self)
@ -114,60 +126,73 @@ public final class VoiceMessageView : UIView {
progressViewRightConstraint.isActive = true progressViewRightConstraint.isActive = true
progressView.pin(.bottom, to: .bottom, of: self) progressView.pin(.bottom, to: .bottom, of: self)
addSubview(toggleContainer) addSubview(toggleContainer)
toggleContainer.pin(.left, to: .left, of: self, withInset: inset) toggleContainer.pin(.left, to: .left, of: self, withInset: inset)
toggleContainer.pin(.top, to: .top, of: self, withInset: inset) toggleContainer.pin(.top, to: .top, of: self, withInset: inset)
toggleContainer.pin(.bottom, to: .bottom, of: self, withInset: -inset) toggleContainer.pin(.bottom, to: .bottom, of: self, withInset: -inset)
addSubview(lineView) addSubview(lineView)
lineView.pin(.left, to: .right, of: toggleContainer) lineView.pin(.left, to: .right, of: toggleContainer)
lineView.center(.vertical, in: self) lineView.center(.vertical, in: self)
addSubview(countdownLabelContainer) addSubview(countdownLabelContainer)
countdownLabelContainer.pin(.left, to: .right, of: lineView) countdownLabelContainer.pin(.left, to: .right, of: lineView)
countdownLabelContainer.pin(.right, to: .right, of: self, withInset: -inset) countdownLabelContainer.pin(.right, to: .right, of: self, withInset: -inset)
countdownLabelContainer.center(.vertical, in: self) countdownLabelContainer.center(.vertical, in: self)
addSubview(loader) addSubview(loader)
loader.center(in: toggleContainer) loader.center(in: toggleContainer)
} }
// MARK: Updating
public override func layoutSubviews() { public override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
countdownLabelContainer.layer.cornerRadius = countdownLabelContainer.bounds.height / 2
countdownLabelContainer.layer.cornerRadius = (countdownLabelContainer.bounds.height / 2)
} }
private func handleIsPlayingChanged() { // MARK: - Updating
toggleImageView.image = isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play")
if !isPlaying { progress = 0 } public func update(
} with attachment: Attachment,
isPlaying: Bool,
private func handleProgressChanged() { progress: TimeInterval,
let isDownloaded = (attachment?.isDownloaded == true) playbackRate: Double,
loader.isHidden = isDownloaded oldPlaybackRate: Double
if isDownloaded { loader.stopAnimating() } else if !loader.isAnimating { loader.startAnimating() } ) {
guard isDownloaded else { return } switch attachment.state {
countdownLabel.text = OWSFormat.formatDurationSeconds(duration - progress) case .downloaded, .uploaded:
guard viewItem.audioProgressSeconds > 0 && viewItem.audioDurationSeconds > 0 else { loader.isHidden = true
return progressViewRightConstraint.constant = -VoiceMessageView.width loader.stopAnimating()
}
let fraction = viewItem.audioProgressSeconds / viewItem.audioDurationSeconds toggleImageView.image = (isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play"))
progressViewRightConstraint.constant = -(VoiceMessageView.width * (1 - fraction)) countdownLabel.text = OWSFormat.formatDurationSeconds(max(0, Int(floor(attachment.duration.defaulting(to: 0) - progress))))
}
guard let duration: TimeInterval = attachment.duration, duration > 0, progress > 0 else {
func showSpeedUpLabel() { return progressViewRightConstraint.constant = -VoiceMessageView.width
guard !isShowingSpeedUpLabel else { return } }
isShowingSpeedUpLabel = true
UIView.animate(withDuration: 0.25) { [weak self] in let fraction: Double = (progress / duration)
guard let self = self else { return } progressViewRightConstraint.constant = -(VoiceMessageView.width * (1 - fraction))
self.countdownLabel.alpha = 0
self.speedUpLabel.alpha = 1 // If the playback rate changed then show the 'speedUpLabel' briefly
} guard playbackRate > oldPlaybackRate else { return }
Timer.scheduledTimer(withTimeInterval: 1.25, repeats: false) { [weak self] _ in
UIView.animate(withDuration: 0.25, animations: { UIView.animate(withDuration: 0.25) { [weak self] in
guard let self = self else { return } self?.countdownLabel.alpha = 0
self.countdownLabel.alpha = 1 self?.speedUpLabel.alpha = 1
self.speedUpLabel.alpha = 0 }
}, completion: { _ in
self?.isShowingSpeedUpLabel = false DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1250)) {
}) UIView.animate(withDuration: 0.25) { [weak self] in
self?.countdownLabel.alpha = 1
self?.speedUpLabel.alpha = 0
}
}
default:
if !loader.isAnimating {
loader.startAnimating()
}
} }
} }
} }

View File

@ -1,72 +1,87 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class InfoMessageCell : MessageCell { import UIKit
import SessionUIKit
import SessionMessagingKit
final class InfoMessageCell: MessageCell {
private static let iconSize: CGFloat = 16
private static let inset = Values.mediumSpacing
// MARK: - UI
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: InfoMessageCell.iconSize) private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: InfoMessageCell.iconSize)
private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: InfoMessageCell.iconSize) private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: InfoMessageCell.iconSize)
// MARK: UI Components private lazy var iconImageView: UIImageView = UIImageView()
private lazy var iconImageView = UIImageView()
private lazy var label: UILabel = { private lazy var label: UILabel = {
let result = UILabel() let result: UILabel = UILabel()
result.numberOfLines = 0 result.numberOfLines = 0
result.lineBreakMode = .byWordWrapping result.lineBreakMode = .byWordWrapping
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
result.textColor = Colors.text result.textColor = Colors.text
result.textAlignment = .center result.textAlignment = .center
return result return result
}() }()
private lazy var stackView: UIStackView = { private lazy var stackView: UIStackView = {
let result = UIStackView(arrangedSubviews: [ iconImageView, label ]) let result: UIStackView = UIStackView(arrangedSubviews: [ iconImageView, label ])
result.axis = .vertical result.axis = .vertical
result.alignment = .center result.alignment = .center
result.spacing = Values.smallSpacing result.spacing = Values.smallSpacing
return result return result
}() }()
// MARK: - Lifecycle
// MARK: Settings
private static let iconSize: CGFloat = 16
private static let inset = Values.mediumSpacing
override class var identifier: String { "InfoMessageCell" }
// MARK: Lifecycle
override func setUpViewHierarchy() { override func setUpViewHierarchy() {
super.setUpViewHierarchy() super.setUpViewHierarchy()
iconImageViewWidthConstraint.isActive = true iconImageViewWidthConstraint.isActive = true
iconImageViewHeightConstraint.isActive = true iconImageViewHeightConstraint.isActive = true
addSubview(stackView) addSubview(stackView)
stackView.pin(.left, to: .left, of: self, withInset: InfoMessageCell.inset) stackView.pin(.left, to: .left, of: self, withInset: InfoMessageCell.inset)
stackView.pin(.top, to: .top, of: self, withInset: InfoMessageCell.inset) stackView.pin(.top, to: .top, of: self, withInset: InfoMessageCell.inset)
stackView.pin(.right, to: .right, of: self, withInset: -InfoMessageCell.inset) stackView.pin(.right, to: .right, of: self, withInset: -InfoMessageCell.inset)
stackView.pin(.bottom, to: .bottom, of: self, withInset: -InfoMessageCell.inset) stackView.pin(.bottom, to: .bottom, of: self, withInset: -InfoMessageCell.inset)
} }
// MARK: - Updating
// MARK: Updating override func update(with cellViewModel: MessageViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
override func update() { guard cellViewModel.variant.isInfoMessage else { return }
guard let message = viewItem?.interaction as? TSInfoMessage else { return }
let icon: UIImage? self.viewModel = cellViewModel
switch message.messageType {
case .disappearingMessagesUpdate: let icon: UIImage? = {
var configuration: OWSDisappearingMessagesConfiguration? switch cellViewModel.variant {
Storage.read { transaction in case .infoDisappearingMessagesUpdate:
configuration = message.thread(with: transaction).disappearingMessagesConfiguration(with: transaction) return (cellViewModel.threadHasDisappearingMessagesEnabled ?
UIImage(named: "ic_timer") :
UIImage(named: "ic_timer_disabled")
)
case .infoMediaSavedNotification: return UIImage(named: "ic_download")
default: return nil
} }
if let configuration = configuration { }()
icon = configuration.isEnabled ? UIImage(named: "ic_timer") : UIImage(named: "ic_timer_disabled")
} else {
icon = nil
}
case .mediaSavedNotification: icon = UIImage(named: "ic_download")
default: icon = nil
}
if let icon = icon { if let icon = icon {
iconImageView.image = icon.withTint(Colors.text) iconImageView.image = icon.withRenderingMode(.alwaysTemplate)
iconImageView.tintColor = Colors.text
} }
iconImageViewWidthConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0 iconImageViewWidthConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0
iconImageViewHeightConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0 iconImageViewHeightConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0
Storage.read { transaction in
self.label.text = message.previewText(with: transaction) self.label.text = cellViewModel.body
} }
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
} }
} }

View File

@ -1,3 +1,5 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit import UIKit
import SessionMessagingKit import SessionMessagingKit
@ -7,77 +9,79 @@ public enum SwipeState {
case cancelled case cancelled
} }
class MessageCell : UITableViewCell { public class MessageCell: UITableViewCell {
weak var delegate: MessageCellDelegate? weak var delegate: MessageCellDelegate?
var thread: TSThread? { var viewModel: MessageViewModel?
didSet {
if viewItem != nil { update() } // MARK: - Lifecycle
}
}
var viewItem: ConversationViewItem? {
didSet {
if thread != nil { update() }
}
}
// MARK: Settings
class var identifier: String { preconditionFailure("Must be overridden by subclasses.") }
// MARK: Lifecycle
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
setUpViewHierarchy() setUpViewHierarchy()
setUpGestureRecognizers() setUpGestureRecognizers()
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
super.init(coder: coder) super.init(coder: coder)
setUpViewHierarchy() setUpViewHierarchy()
setUpGestureRecognizers() setUpGestureRecognizers()
} }
func setUpViewHierarchy() { func setUpViewHierarchy() {
backgroundColor = .clear backgroundColor = .clear
let selectedBackgroundView = UIView() let selectedBackgroundView = UIView()
selectedBackgroundView.backgroundColor = .clear selectedBackgroundView.backgroundColor = .clear
self.selectedBackgroundView = selectedBackgroundView self.selectedBackgroundView = selectedBackgroundView
} }
func setUpGestureRecognizers() { func setUpGestureRecognizers() {
// To be overridden by subclasses // To be overridden by subclasses
} }
// MARK: - Updating
// MARK: Updating func update(with cellViewModel: MessageViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
func update() {
preconditionFailure("Must be overridden by subclasses.") preconditionFailure("Must be overridden by subclasses.")
} }
// MARK: Convenience /// This is a cut-down version of the 'update' function which doesn't re-create the UI (it should be used for dynamically-updating content
static func getCellType(for viewItem: ConversationViewItem) -> MessageCell.Type { /// like playing inline audio/video)
switch viewItem.interaction { func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
case is TSIncomingMessage: fallthrough preconditionFailure("Must be overridden by subclasses.")
case is TSOutgoingMessage: return VisibleMessageCell.self }
case is TSInfoMessage:
if let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call { // MARK: - Convenience
static func cellType(for viewModel: MessageViewModel) -> MessageCell.Type {
guard viewModel.cellType != .typingIndicator else { return TypingIndicatorCell.self }
switch viewModel.variant {
case .standardOutgoing, .standardIncoming, .standardIncomingDeleted:
return VisibleMessageCell.self
case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft,
.infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification,
.infoMessageRequestAccepted:
return InfoMessageCell.self
case .infoCall:
return CallMessageCell.self return CallMessageCell.self
}
return InfoMessageCell.self
case is TypingIndicatorInteraction: return TypingIndicatorCell.self
default: preconditionFailure()
} }
} }
} }
protocol MessageCellDelegate : AnyObject { // MARK: - MessageCellDelegate
var lastSearchedText: String? { get }
protocol MessageCellDelegate: AnyObject {
func getMediaCache() -> NSCache<NSString, AnyObject> func handleItemLongPressed(_ cellViewModel: MessageViewModel)
func handleViewItemLongPressed(_ viewItem: ConversationViewItem) func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer)
func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer) func handleItemDoubleTapped(_ cellViewModel: MessageViewModel)
func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem) func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState)
func handleViewItemSwiped(_ viewItem: ConversationViewItem, state: SwipeState) func openUrl(_ urlString: String)
func showFullText(_ viewItem: ConversationViewItem) func handleReplyButtonTapped(for cellViewModel: MessageViewModel)
func openURL(_ url: URL) func showUserDetails(for profile: Profile)
func handleReplyButtonTapped(for viewItem: ConversationViewItem) func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?)
func showUserDetails(for sessionID: String)
} }

View File

@ -1,85 +1,94 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
import SessionMessagingKit
// Assumptions // Assumptions
// We'll never encounter an outgoing typing indicator. // We'll never encounter an outgoing typing indicator.
// Typing indicators are only sent in contact threads. // Typing indicators are only sent in contact threads.
final class TypingIndicatorCell: MessageCell {
final class TypingIndicatorCell : MessageCell { // MARK: - UI
private var positionInCluster: Position? {
guard let viewItem = viewItem else { return nil }
if viewItem.isFirstInCluster { return .top }
if viewItem.isLastInCluster { return .bottom }
return .middle
}
private var isOnlyMessageInCluster: Bool { viewItem?.isFirstInCluster == true && viewItem?.isLastInCluster == true }
// MARK: UI Components
private lazy var bubbleView: UIView = { private lazy var bubbleView: UIView = {
let result = UIView() let result: UIView = UIView()
result.layer.cornerRadius = VisibleMessageCell.smallCornerRadius result.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
result.backgroundColor = Colors.receivedMessageBackground result.backgroundColor = Colors.receivedMessageBackground
return result return result
}() }()
private let bubbleViewMaskLayer = CAShapeLayer() private let bubbleViewMaskLayer: CAShapeLayer = CAShapeLayer()
private lazy var typingIndicatorView = TypingIndicatorView() private lazy var typingIndicatorView: TypingIndicatorView = TypingIndicatorView()
// MARK: Settings // MARK: - Lifecycle
override class var identifier: String { "TypingIndicatorCell" }
// MARK: Direction & Position
enum Position { case top, middle, bottom }
// MARK: Lifecycle
override func setUpViewHierarchy() { override func setUpViewHierarchy() {
super.setUpViewHierarchy() super.setUpViewHierarchy()
// Bubble view // Bubble view
addSubview(bubbleView) addSubview(bubbleView)
bubbleView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing) bubbleView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing)
bubbleView.pin(.top, to: .top, of: self, withInset: 1) bubbleView.pin(.top, to: .top, of: self, withInset: 1)
// Typing indicator view // Typing indicator view
bubbleView.addSubview(typingIndicatorView) bubbleView.addSubview(typingIndicatorView)
typingIndicatorView.pin(to: bubbleView, withInset: 12) typingIndicatorView.pin(to: bubbleView, withInset: 12)
} }
// MARK: Updating // MARK: - Updating
override func update() {
guard let viewItem = viewItem, viewItem.interaction is TypingIndicatorInteraction else { return } override func update(with cellViewModel: MessageViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
guard cellViewModel.cellType == .typingIndicator else { return }
self.viewModel = cellViewModel
// Bubble view // Bubble view
updateBubbleViewCorners() updateBubbleViewCorners()
// Typing indicator view // Typing indicator view
typingIndicatorView.startAnimation() typingIndicatorView.startAnimation()
} }
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
}
override func layoutSubviews() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
updateBubbleViewCorners() updateBubbleViewCorners()
} }
private func updateBubbleViewCorners() { private func updateBubbleViewCorners() {
let maskPath = UIBezierPath(roundedRect: bubbleView.bounds, byRoundingCorners: getCornersToRound(), let maskPath = UIBezierPath(
cornerRadii: CGSize(width: VisibleMessageCell.largeCornerRadius, height: VisibleMessageCell.largeCornerRadius)) roundedRect: bubbleView.bounds,
byRoundingCorners: getCornersToRound(),
cornerRadii: CGSize(
width: VisibleMessageCell.largeCornerRadius,
height: VisibleMessageCell.largeCornerRadius)
)
bubbleViewMaskLayer.path = maskPath.cgPath bubbleViewMaskLayer.path = maskPath.cgPath
bubbleView.layer.mask = bubbleViewMaskLayer bubbleView.layer.mask = bubbleViewMaskLayer
} }
override func prepareForReuse() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()
typingIndicatorView.stopAnimation() typingIndicatorView.stopAnimation()
} }
// MARK: Convenience // MARK: - Convenience
private func getCornersToRound() -> UIRectCorner { private func getCornersToRound() -> UIRectCorner {
guard !isOnlyMessageInCluster else { return .allCorners } guard viewModel?.isOnlyMessageInCluster == false else { return .allCorners }
let result: UIRectCorner
switch positionInCluster { switch viewModel?.positionInCluster {
case .top: result = [ .topLeft, .topRight, .bottomRight ] case .top: return [ .topLeft, .topRight, .bottomRight ]
case .middle: result = [ .topRight, .bottomRight ] case .middle: return [ .topRight, .bottomRight ]
case .bottom: result = [ .topRight, .bottomRight, .bottomLeft ] case .bottom: return [ .topRight, .bottomRight, .bottomLeft ]
case nil: result = .allCorners case .none: return .allCorners
} }
return result
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,7 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic) BOOL showVerificationOnAppear; @property (nonatomic) BOOL showVerificationOnAppear;
- (void)configureWithThread:(TSThread *)thread uiDatabaseConnection:(YapDatabaseConnection *)uiDatabaseConnection; - (void)configureWithThreadId:(NSString *)threadId threadName:(NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf;
@end @end

View File

@ -9,17 +9,8 @@
#import "UIView+OWS.h" #import "UIView+OWS.h"
#import <Curve25519Kit/Curve25519.h> #import <Curve25519Kit/Curve25519.h>
#import <SignalCoreKit/NSDate+OWS.h> #import <SignalCoreKit/NSDate+OWS.h>
#import <SessionMessagingKit/Environment.h>
#import <SignalUtilitiesKit/OWSProfileManager.h>
#import <SessionMessagingKit/OWSSounds.h>
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h> #import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
#import <SignalUtilitiesKit/UIUtil.h> #import <SignalUtilitiesKit/UIUtil.h>
#import <SessionMessagingKit/OWSDisappearingConfigurationUpdateInfoMessage.h>
#import <SessionMessagingKit/OWSDisappearingMessagesConfiguration.h>
#import <SessionMessagingKit/OWSPrimaryStorage.h>
#import <SessionMessagingKit/TSGroupThread.h>
#import <SessionMessagingKit/TSOutgoingMessage.h>
#import <SessionMessagingKit/TSThread.h>
@import ContactsUI; @import ContactsUI;
@import PromiseKit; @import PromiseKit;
@ -30,12 +21,18 @@ CGFloat kIconViewLength = 24;
@interface OWSConversationSettingsViewController () <OWSSheetViewControllerDelegate> @interface OWSConversationSettingsViewController () <OWSSheetViewControllerDelegate>
@property (nonatomic) TSThread *thread; @property (nonatomic) NSString *threadId;
@property (nonatomic) YapDatabaseConnection *uiDatabaseConnection; @property (nonatomic) NSString *threadName;
@property (nonatomic, readonly) YapDatabaseConnection *editingDatabaseConnection; @property (nonatomic) BOOL isNoteToSelf;
@property (nonatomic) BOOL isClosedGroup;
@property (nonatomic) BOOL isOpenGroup;
@property (nonatomic) NSArray<NSNumber *> *disappearingMessagesDurations; @property (nonatomic) NSArray<NSNumber *> *disappearingMessagesDurations;
@property (nonatomic) OWSDisappearingMessagesConfiguration *disappearingMessagesConfiguration;
@property (nullable, nonatomic) MediaGallery *mediaGallery; @property (nonatomic) BOOL originalIsDisappearingMessagesEnabled;
@property (nonatomic) NSInteger originalDisappearingMessagesDurationIndex;
@property (nonatomic) BOOL isDisappearingMessagesEnabled;
@property (nonatomic) NSInteger disappearingMessagesDurationIndex;
@property (nonatomic, readonly) UIImageView *avatarView; @property (nonatomic, readonly) UIImageView *avatarView;
@property (nonatomic, readonly) UILabel *disappearingMessagesDurationLabel; @property (nonatomic, readonly) UILabel *disappearingMessagesDurationLabel;
@property (nonatomic) UILabel *displayNameLabel; @property (nonatomic) UILabel *displayNameLabel;
@ -56,8 +53,6 @@ CGFloat kIconViewLength = 24;
return self; return self;
} }
[self commonInit];
return self; return self;
} }
@ -68,8 +63,6 @@ CGFloat kIconViewLength = 24;
return self; return self;
} }
[self commonInit];
return self; return self;
} }
@ -80,95 +73,24 @@ CGFloat kIconViewLength = 24;
return self; return self;
} }
[self commonInit];
return self; return self;
} }
- (void)commonInit
{
[self observeNotifications];
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - Dependencies
- (TSAccountManager *)tsAccountManager
{
OWSAssertDebug(SSKEnvironment.shared.tsAccountManager);
return SSKEnvironment.shared.tsAccountManager;
}
- (OWSProfileManager *)profileManager
{
return [OWSProfileManager sharedManager];
}
#pragma mark #pragma mark
- (void)observeNotifications - (void)configureWithThreadId:(NSString *)threadId threadName:(NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf {
{ self.threadId = threadId;
[[NSNotificationCenter defaultCenter] addObserver:self self.threadName = threadName;
selector:@selector(identityStateDidChange:) self.isClosedGroup = isClosedGroup;
name:kNSNotificationName_IdentityStateDidChange self.isOpenGroup = isOpenGroup;
object:nil]; self.isNoteToSelf = isNoteToSelf;
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(otherUsersProfileDidChange:) if (!isClosedGroup && !isOpenGroup) {
name:kNSNotificationName_OtherUsersProfileDidChange self.threadName = [SMKProfile displayNameWithId:threadId customFallback:@"Anonymous"];
object:nil];
}
- (YapDatabaseConnection *)editingDatabaseConnection
{
return [OWSPrimaryStorage sharedManager].dbReadWriteConnection;
}
- (nullable NSString *)threadName
{
NSString *threadName = self.thread.name;
if ([self.thread isKindOfClass:TSContactThread.class]) {
TSContactThread *thread = (TSContactThread *)self.thread;
return [[LKStorage.shared getContactWithSessionID:thread.contactSessionID] displayNameFor:SNContactContextRegular] ?: @"Anonymous";
} else if (threadName.length == 0 && [self isGroupThread]) {
threadName = [MessageStrings newGroupDefaultTitle];
} }
return threadName; else {
} self.threadName = threadName;
- (BOOL)isGroupThread
{
return [self.thread isKindOfClass:[TSGroupThread class]];
}
- (BOOL)isOpenGroup
{
if ([self isGroupThread]) {
TSGroupThread *thread = (TSGroupThread *)self.thread;
return thread.isOpenGroup;
} }
return false;
}
-(BOOL)isClosedGroup
{
if (self.isGroupThread) {
TSGroupThread *thread = (TSGroupThread *)self.thread;
return thread.groupModel.groupType == closedGroup;
}
return false;
}
- (void)configureWithThread:(TSThread *)thread uiDatabaseConnection:(YapDatabaseConnection *)uiDatabaseConnection
{
OWSAssertDebug(thread);
self.thread = thread;
self.uiDatabaseConnection = uiDatabaseConnection;
} }
#pragma mark - ContactEditingDelegate #pragma mark - ContactEditingDelegate
@ -211,7 +133,7 @@ CGFloat kIconViewLength = 24;
self.displayNameLabel.font = [UIFont boldSystemFontOfSize:LKValues.largeFontSize]; self.displayNameLabel.font = [UIFont boldSystemFontOfSize:LKValues.largeFontSize];
self.displayNameLabel.lineBreakMode = NSLineBreakByTruncatingTail; self.displayNameLabel.lineBreakMode = NSLineBreakByTruncatingTail;
self.displayNameLabel.textAlignment = NSTextAlignmentCenter; self.displayNameLabel.textAlignment = NSTextAlignmentCenter;
self.displayNameTextField = [[SNTextField alloc] initWithPlaceholder:@"Enter a name" usesDefaultHeight:NO]; self.displayNameTextField = [[SNTextField alloc] initWithPlaceholder:@"Enter a name" usesDefaultHeight:NO];
self.displayNameTextField.textAlignment = NSTextAlignmentCenter; self.displayNameTextField.textAlignment = NSTextAlignmentCenter;
self.displayNameTextField.accessibilityLabel = @"Edit name text field"; self.displayNameTextField.accessibilityLabel = @"Edit name text field";
@ -220,46 +142,42 @@ CGFloat kIconViewLength = 24;
self.displayNameContainer = [UIView new]; self.displayNameContainer = [UIView new];
self.displayNameContainer.accessibilityLabel = @"Edit name text field"; self.displayNameContainer.accessibilityLabel = @"Edit name text field";
self.displayNameContainer.isAccessibilityElement = YES; self.displayNameContainer.isAccessibilityElement = YES;
[self.displayNameContainer autoSetDimension:ALDimensionHeight toSize:40]; [self.displayNameContainer autoSetDimension:ALDimensionHeight toSize:40];
[self.displayNameContainer addSubview:self.displayNameLabel]; [self.displayNameContainer addSubview:self.displayNameLabel];
[self.displayNameLabel autoPinToEdgesOfView:self.displayNameContainer]; [self.displayNameLabel autoPinToEdgesOfView:self.displayNameContainer];
[self.displayNameContainer addSubview:self.displayNameTextField]; [self.displayNameContainer addSubview:self.displayNameTextField];
[self.displayNameTextField autoPinToEdgesOfView:self.displayNameContainer]; [self.displayNameTextField autoPinToEdgesOfView:self.displayNameContainer];
if ([self.thread isKindOfClass:TSContactThread.class]) { if (!self.isClosedGroup && !self.isOpenGroup) {
UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showEditNameUI)]; UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showEditNameUI)];
[self.displayNameContainer addGestureRecognizer:tapGestureRecognizer]; [self.displayNameContainer addGestureRecognizer:tapGestureRecognizer];
} }
self.tableView.estimatedRowHeight = 45; self.tableView.estimatedRowHeight = 45;
self.tableView.rowHeight = UITableViewAutomaticDimension; self.tableView.rowHeight = UITableViewAutomaticDimension;
_disappearingMessagesDurationLabel = [UILabel new]; _disappearingMessagesDurationLabel = [UILabel new];
SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _disappearingMessagesDurationLabel); SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _disappearingMessagesDurationLabel);
self.disappearingMessagesDurations = [OWSDisappearingMessagesConfiguration validDurationsSeconds]; self.disappearingMessagesDurations = [SMKDisappearingMessagesConfiguration validDurationsSeconds];
self.isDisappearingMessagesEnabled = [SMKDisappearingMessagesConfiguration isEnabledFor: self.threadId];
self.disappearingMessagesConfiguration = self.disappearingMessagesDurationIndex = [SMKDisappearingMessagesConfiguration durationIndexFor: self.threadId];
[OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:self.thread.uniqueId]; self.originalIsDisappearingMessagesEnabled = self.isDisappearingMessagesEnabled;
self.originalDisappearingMessagesDurationIndex = self.disappearingMessagesDurationIndex;
if (!self.disappearingMessagesConfiguration) {
self.disappearingMessagesConfiguration =
[[OWSDisappearingMessagesConfiguration alloc] initDefaultWithThreadId:self.thread.uniqueId];
}
[self updateTableContents];
[self updateTableContents];
NSString *title; NSString *title;
if ([self.thread isKindOfClass:[TSContactThread class]]) { if (!self.isClosedGroup && !self.isOpenGroup) {
title = NSLocalizedString(@"Settings", @""); title = NSLocalizedString(@"Settings", @"");
} else { } else {
title = NSLocalizedString(@"Group Settings", @""); title = NSLocalizedString(@"Group Settings", @"");
} }
[LKViewControllerUtilities setUpDefaultSessionStyleForVC:self withTitle:title customBackButton:YES]; [LKViewControllerUtilities setUpDefaultSessionStyleForVC:self withTitle:title customBackButton:YES];
self.tableView.backgroundColor = UIColor.clearColor; self.tableView.backgroundColor = UIColor.clearColor;
if ([self.thread isKindOfClass:TSContactThread.class]) { if (!self.isClosedGroup && !self.isOpenGroup) {
[self updateNavBarButtons]; [self updateNavBarButtons];
} }
} }
@ -269,8 +187,6 @@ CGFloat kIconViewLength = 24;
OWSTableContents *contents = [OWSTableContents new]; OWSTableContents *contents = [OWSTableContents new];
contents.title = NSLocalizedString(@"CONVERSATION_SETTINGS", @"title for conversation settings screen"); contents.title = NSLocalizedString(@"CONVERSATION_SETTINGS", @"title for conversation settings screen");
BOOL isNoteToSelf = self.thread.isNoteToSelf;
__weak OWSConversationSettingsViewController *weakSelf = self; __weak OWSConversationSettingsViewController *weakSelf = self;
OWSTableSection *section = [OWSTableSection new]; OWSTableSection *section = [OWSTableSection new];
@ -279,7 +195,7 @@ CGFloat kIconViewLength = 24;
section.customHeaderHeight = @(UITableViewAutomaticDimension); section.customHeaderHeight = @(UITableViewAutomaticDimension);
// Copy Session ID // Copy Session ID
if ([self.thread isKindOfClass:TSContactThread.class]) { if (!self.isClosedGroup && !self.isOpenGroup) {
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{ [section addItem:[OWSTableItem itemWithCustomCellBlock:^{
return [weakSelf return [weakSelf
disclosureCellWithName:NSLocalizedString(@"vc_conversation_settings_copy_session_id_button_title", "") disclosureCellWithName:NSLocalizedString(@"vc_conversation_settings_copy_session_id_button_title", "")
@ -300,7 +216,7 @@ CGFloat kIconViewLength = 24;
} actionBlock:^{ } actionBlock:^{
[weakSelf showMediaGallery]; [weakSelf showMediaGallery];
}]]; }]];
// Invite button // Invite button
if (self.isOpenGroup) { if (self.isOpenGroup) {
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{ [section addItem:[OWSTableItem itemWithCustomCellBlock:^{
@ -325,9 +241,9 @@ CGFloat kIconViewLength = 24;
} actionBlock:^{ } actionBlock:^{
[weakSelf tappedConversationSearch]; [weakSelf tappedConversationSearch];
}]]; }]];
// Disappearing messages // Disappearing messages
if (![self isOpenGroup] && !self.thread.isBlocked) { if (![self isOpenGroup] && ![SMKContact isBlockedFor:self.threadId]) {
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{ [section addItem:[OWSTableItem itemWithCustomCellBlock:^{
UITableViewCell *cell = [OWSTableItem newCell]; UITableViewCell *cell = [OWSTableItem newCell];
OWSConversationSettingsViewController *strongSelf = weakSelf; OWSConversationSettingsViewController *strongSelf = weakSelf;
@ -337,7 +253,7 @@ CGFloat kIconViewLength = 24;
cell.selectionStyle = UITableViewCellSelectionStyleNone; cell.selectionStyle = UITableViewCellSelectionStyleNone;
NSString *iconName NSString *iconName
= (strongSelf.disappearingMessagesConfiguration.isEnabled ? @"ic_timer" : @"ic_timer_disabled"); = (strongSelf.isDisappearingMessagesEnabled ? @"ic_timer" : @"ic_timer_disabled");
UIImageView *iconView = [strongSelf viewForIconWithName:iconName]; UIImageView *iconView = [strongSelf viewForIconWithName:iconName];
UILabel *rowLabel = [UILabel new]; UILabel *rowLabel = [UILabel new];
@ -348,7 +264,7 @@ CGFloat kIconViewLength = 24;
rowLabel.lineBreakMode = NSLineBreakByTruncatingTail; rowLabel.lineBreakMode = NSLineBreakByTruncatingTail;
UISwitch *switchView = [UISwitch new]; UISwitch *switchView = [UISwitch new];
switchView.on = strongSelf.disappearingMessagesConfiguration.isEnabled; switchView.on = strongSelf.isDisappearingMessagesEnabled;
[switchView addTarget:strongSelf action:@selector(disappearingMessagesSwitchValueDidChange:) [switchView addTarget:strongSelf action:@selector(disappearingMessagesSwitchValueDidChange:)
forControlEvents:UIControlEventValueChanged]; forControlEvents:UIControlEventValueChanged];
@ -361,11 +277,10 @@ CGFloat kIconViewLength = 24;
UILabel *subtitleLabel = [UILabel new]; UILabel *subtitleLabel = [UILabel new];
NSString *displayName; NSString *displayName;
if (self.thread.isGroupThread) { if (self.isClosedGroup || self.isOpenGroup) {
displayName = @"the group"; displayName = @"the group";
} else { } else {
TSContactThread *thread = (TSContactThread *)self.thread; displayName = [SMKProfile displayNameWithId:self.threadId customFallback:@"anonymous"];
displayName = [[LKStorage.shared getContactWithSessionID:thread.contactSessionID] displayNameFor:SNContactContextRegular] ?: @"anonymous";
} }
subtitleLabel.text = [NSString stringWithFormat:NSLocalizedString(@"When enabled, messages between you and %@ will disappear after they have been seen.", ""), displayName]; subtitleLabel.text = [NSString stringWithFormat:NSLocalizedString(@"When enabled, messages between you and %@ will disappear after they have been seen.", ""), displayName];
subtitleLabel.textColor = LKColors.text; subtitleLabel.textColor = LKColors.text;
@ -385,7 +300,7 @@ CGFloat kIconViewLength = 24;
return cell; return cell;
} customRowHeight:UITableViewAutomaticDimension actionBlock:nil]]; } customRowHeight:UITableViewAutomaticDimension actionBlock:nil]];
if (self.disappearingMessagesConfiguration.isEnabled) { if (self.isDisappearingMessagesEnabled) {
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{ [section addItem:[OWSTableItem itemWithCustomCellBlock:^{
UITableViewCell *cell = [OWSTableItem newCell]; UITableViewCell *cell = [OWSTableItem newCell];
OWSConversationSettingsViewController *strongSelf = weakSelf; OWSConversationSettingsViewController *strongSelf = weakSelf;
@ -415,7 +330,7 @@ CGFloat kIconViewLength = 24;
slider.minimumValue = 0; slider.minimumValue = 0;
slider.tintColor = LKColors.accent; slider.tintColor = LKColors.accent;
slider.continuous = NO; slider.continuous = NO;
slider.value = strongSelf.disappearingMessagesConfiguration.durationIndex; slider.value = strongSelf.disappearingMessagesDurationIndex;
[slider addTarget:strongSelf action:@selector(durationSliderDidChange:) [slider addTarget:strongSelf action:@selector(durationSliderDidChange:)
forControlEvents:UIControlEventValueChanged]; forControlEvents:UIControlEventValueChanged];
[cell.contentView addSubview:slider]; [cell.contentView addSubview:slider];
@ -423,7 +338,7 @@ CGFloat kIconViewLength = 24;
[slider autoPinEdge:ALEdgeLeading toEdge:ALEdgeLeading ofView:rowLabel]; [slider autoPinEdge:ALEdgeLeading toEdge:ALEdgeLeading ofView:rowLabel];
[slider autoPinTrailingToSuperviewMargin]; [slider autoPinTrailingToSuperviewMargin];
[slider autoPinBottomToSuperviewMargin]; [slider autoPinBottomToSuperviewMargin];
cell.userInteractionEnabled = !strongSelf.hasLeftGroup; cell.userInteractionEnabled = !strongSelf.hasLeftGroup;
cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME( cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(
@ -438,11 +353,10 @@ CGFloat kIconViewLength = 24;
// Closed group settings // Closed group settings
__block BOOL isUserMember = NO; __block BOOL isUserMember = NO;
if (self.isGroupThread) { if (self.isClosedGroup || self.isOpenGroup) {
NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey]; isUserMember = [SMKGroupMember isCurrentUserMemberOf:self.threadId];
isUserMember = [(TSGroupThread *)self.thread isUserMemberInGroup:userPublicKey];
} }
if (self.isGroupThread && self.isClosedGroup && isUserMember) { if (self.isClosedGroup && isUserMember) {
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{ [section addItem:[OWSTableItem itemWithCustomCellBlock:^{
UITableViewCell *cell = UITableViewCell *cell =
[weakSelf disclosureCellWithName:NSLocalizedString(@"EDIT_GROUP_ACTION", @"table cell label in conversation settings") [weakSelf disclosureCellWithName:NSLocalizedString(@"EDIT_GROUP_ACTION", @"table cell label in conversation settings")
@ -465,8 +379,8 @@ CGFloat kIconViewLength = 24;
[weakSelf didTapLeaveGroup]; [weakSelf didTapLeaveGroup];
}]]; }]];
} }
if (!isNoteToSelf) { if (!self.isNoteToSelf) {
// Notification sound // Notification sound
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{ [section addItem:[OWSTableItem itemWithCustomCellBlock:^{
UITableViewCell *cell = UITableViewCell *cell =
@ -493,8 +407,8 @@ CGFloat kIconViewLength = 24;
[cell.contentView addSubview:contentRow]; [cell.contentView addSubview:contentRow];
[contentRow autoPinEdgesToSuperviewMargins]; [contentRow autoPinEdgesToSuperviewMargins];
OWSSound sound = [OWSSounds notificationSoundForThread:strongSelf.thread]; NSInteger sound = [SMKSound notificationSoundFor:strongSelf.threadId];
cell.detailTextLabel.text = [OWSSounds displayNameForSound:sound]; cell.detailTextLabel.text = [SMKSound displayNameFor:sound];
cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME( cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(
OWSConversationSettingsViewController, @"notifications"); OWSConversationSettingsViewController, @"notifications");
@ -504,11 +418,11 @@ CGFloat kIconViewLength = 24;
customRowHeight:UITableViewAutomaticDimension customRowHeight:UITableViewAutomaticDimension
actionBlock:^{ actionBlock:^{
OWSSoundSettingsViewController *vc = [OWSSoundSettingsViewController new]; OWSSoundSettingsViewController *vc = [OWSSoundSettingsViewController new];
vc.thread = weakSelf.thread; vc.threadId = weakSelf.threadId;
[weakSelf.navigationController pushViewController:vc animated:YES]; [weakSelf.navigationController pushViewController:vc animated:YES];
}]]; }]];
if (self.isGroupThread) { if (self.isClosedGroup || self.isOpenGroup) {
// Notification Settings // Notification Settings
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{ [section addItem:[OWSTableItem itemWithCustomCellBlock:^{
UITableViewCell *cell = [OWSTableItem newCell]; UITableViewCell *cell = [OWSTableItem newCell];
@ -527,7 +441,7 @@ CGFloat kIconViewLength = 24;
rowLabel.lineBreakMode = NSLineBreakByTruncatingTail; rowLabel.lineBreakMode = NSLineBreakByTruncatingTail;
UISwitch *switchView = [UISwitch new]; UISwitch *switchView = [UISwitch new];
switchView.on = ((TSGroupThread *)strongSelf.thread).isOnlyNotifyingForMentions; switchView.on = [SMKThread isOnlyNotifyingForMentions:strongSelf.threadId];
[switchView addTarget:strongSelf action:@selector(notifyForMentionsOnlySwitchValueDidChange:) [switchView addTarget:strongSelf action:@selector(notifyForMentionsOnlySwitchValueDidChange:)
forControlEvents:UIControlEventValueChanged]; forControlEvents:UIControlEventValueChanged];
@ -557,7 +471,7 @@ CGFloat kIconViewLength = 24;
return cell; return cell;
} customRowHeight:UITableViewAutomaticDimension actionBlock:nil]]; } customRowHeight:UITableViewAutomaticDimension actionBlock:nil]];
} }
// Mute thread // Mute thread
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{ [section addItem:[OWSTableItem itemWithCustomCellBlock:^{
OWSConversationSettingsViewController *strongSelf = weakSelf; OWSConversationSettingsViewController *strongSelf = weakSelf;
@ -570,7 +484,7 @@ CGFloat kIconViewLength = 24;
cell.selectionStyle = UITableViewCellSelectionStyleNone; cell.selectionStyle = UITableViewCellSelectionStyleNone;
UISwitch *muteConversationSwitch = [UISwitch new]; UISwitch *muteConversationSwitch = [UISwitch new];
NSDate *mutedUntilDate = strongSelf.thread.mutedUntilDate; NSDate *mutedUntilDate = [SMKThread mutedUntilDateFor:strongSelf.threadId];
NSDate *now = [NSDate date]; NSDate *now = [NSDate date];
muteConversationSwitch.on = (mutedUntilDate != nil && [mutedUntilDate timeIntervalSinceDate:now] > 0); muteConversationSwitch.on = (mutedUntilDate != nil && [mutedUntilDate timeIntervalSinceDate:now] > 0);
[muteConversationSwitch addTarget:strongSelf action:@selector(handleMuteSwitchToggled:) [muteConversationSwitch addTarget:strongSelf action:@selector(handleMuteSwitchToggled:)
@ -580,9 +494,9 @@ CGFloat kIconViewLength = 24;
return cell; return cell;
} actionBlock:nil]]; } actionBlock:nil]];
} }
// Block contact // Block contact
if (!isNoteToSelf && [self.thread isKindOfClass:TSContactThread.class]) { if (!self.isNoteToSelf && !self.isClosedGroup && !self.isOpenGroup) {
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{ [section addItem:[OWSTableItem itemWithCustomCellBlock:^{
OWSConversationSettingsViewController *strongSelf = weakSelf; OWSConversationSettingsViewController *strongSelf = weakSelf;
if (!strongSelf) { return [UITableViewCell new]; } if (!strongSelf) { return [UITableViewCell new]; }
@ -594,7 +508,7 @@ CGFloat kIconViewLength = 24;
cell.selectionStyle = UITableViewCellSelectionStyleNone; cell.selectionStyle = UITableViewCellSelectionStyleNone;
UISwitch *blockConversationSwitch = [UISwitch new]; UISwitch *blockConversationSwitch = [UISwitch new];
blockConversationSwitch.on = strongSelf.thread.isBlocked; blockConversationSwitch.on = [SMKContact isBlockedFor:strongSelf.threadId];
[blockConversationSwitch addTarget:strongSelf action:@selector(blockConversationSwitchDidChange:) [blockConversationSwitch addTarget:strongSelf action:@selector(blockConversationSwitchDidChange:)
forControlEvents:UIControlEventValueChanged]; forControlEvents:UIControlEventValueChanged];
cell.accessoryView = blockConversationSwitch; cell.accessoryView = blockConversationSwitch;
@ -681,36 +595,36 @@ CGFloat kIconViewLength = 24;
[profilePictureView autoSetDimension:ALDimensionWidth toSize:size]; [profilePictureView autoSetDimension:ALDimensionWidth toSize:size];
[profilePictureView autoSetDimension:ALDimensionHeight toSize:size]; [profilePictureView autoSetDimension:ALDimensionHeight toSize:size];
[profilePictureView addGestureRecognizer:profilePictureTapGestureRecognizer]; [profilePictureView addGestureRecognizer:profilePictureTapGestureRecognizer];
self.displayNameLabel.text = (self.threadName != nil && self.threadName.length > 0) ? self.threadName : @"Anonymous"; self.displayNameLabel.text = (self.threadName != nil && self.threadName.length > 0) ? self.threadName : @"Anonymous";
if ([self.thread isKindOfClass:TSContactThread.class]) { if (!self.isClosedGroup && !self.isOpenGroup) {
UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showEditNameUI)]; UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showEditNameUI)];
[self.displayNameContainer addGestureRecognizer:tapGestureRecognizer]; [self.displayNameContainer addGestureRecognizer:tapGestureRecognizer];
} }
UIStackView *stackView = [[UIStackView alloc] initWithArrangedSubviews:@[ profilePictureView, self.displayNameContainer ]]; UIStackView *stackView = [[UIStackView alloc] initWithArrangedSubviews:@[ profilePictureView, self.displayNameContainer ]];
stackView.axis = UILayoutConstraintAxisVertical; stackView.axis = UILayoutConstraintAxisVertical;
stackView.spacing = LKValues.mediumSpacing; stackView.spacing = LKValues.mediumSpacing;
stackView.distribution = UIStackViewDistributionEqualCentering; stackView.distribution = UIStackViewDistributionEqualCentering;
stackView.alignment = UIStackViewAlignmentCenter; stackView.alignment = UIStackViewAlignmentCenter;
BOOL isSmallScreen = (UIScreen.mainScreen.bounds.size.height - 568) < 1; BOOL isSmallScreen = (UIScreen.mainScreen.bounds.size.height - 568) < 1;
CGFloat horizontalSpacing = isSmallScreen ? LKValues.largeSpacing : LKValues.veryLargeSpacing; CGFloat horizontalSpacing = isSmallScreen ? LKValues.largeSpacing : LKValues.veryLargeSpacing;
stackView.layoutMargins = UIEdgeInsetsMake(LKValues.mediumSpacing, horizontalSpacing, LKValues.mediumSpacing, horizontalSpacing); stackView.layoutMargins = UIEdgeInsetsMake(LKValues.mediumSpacing, horizontalSpacing, LKValues.mediumSpacing, horizontalSpacing);
[stackView setLayoutMarginsRelativeArrangement:YES]; [stackView setLayoutMarginsRelativeArrangement:YES];
if (!self.isGroupThread) { if (!self.isClosedGroup && !self.isOpenGroup) {
SRCopyableLabel *subtitleView = [SRCopyableLabel new]; SRCopyableLabel *subtitleView = [SRCopyableLabel new];
subtitleView.textColor = LKColors.text; subtitleView.textColor = LKColors.text;
subtitleView.font = [LKFonts spaceMonoOfSize:LKValues.smallFontSize]; subtitleView.font = [LKFonts spaceMonoOfSize:LKValues.smallFontSize];
subtitleView.lineBreakMode = NSLineBreakByCharWrapping; subtitleView.lineBreakMode = NSLineBreakByCharWrapping;
subtitleView.numberOfLines = 2; subtitleView.numberOfLines = 2;
subtitleView.text = ((TSContactThread *)self.thread).contactSessionID; subtitleView.text = self.threadId;
subtitleView.textAlignment = NSTextAlignmentCenter; subtitleView.textAlignment = NSTextAlignmentCenter;
[stackView addArrangedSubview:subtitleView]; [stackView addArrangedSubview:subtitleView];
} }
[profilePictureView updateForThread:self.thread]; [profilePictureView updateForThreadId:self.threadId];
return stackView; return stackView;
} }
@ -749,48 +663,41 @@ CGFloat kIconViewLength = 24;
{ {
[super viewWillDisappear:animated]; [super viewWillDisappear:animated];
if (self.disappearingMessagesConfiguration.isNewRecord && !self.disappearingMessagesConfiguration.isEnabled) { // Do nothing if the values haven't changed (or if it's disabled and only the 'durationIndex'
// don't save defaults, else we'll unintentionally save the configuration and notify the contact. // has changed as the 'durationIndex' value defaults to 1 hour when disabled)
if (
self.isDisappearingMessagesEnabled == self.originalIsDisappearingMessagesEnabled && (
!self.originalIsDisappearingMessagesEnabled ||
self.disappearingMessagesDurationIndex == self.originalDisappearingMessagesDurationIndex
)
) {
return; return;
} }
if (self.disappearingMessagesConfiguration.dictionaryValueDidChange) { [SMKDisappearingMessagesConfiguration
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { update:self.threadId
[self.disappearingMessagesConfiguration saveWithTransaction:transaction]; isEnabled: self.isDisappearingMessagesEnabled
OWSDisappearingConfigurationUpdateInfoMessage *infoMessage = [[OWSDisappearingConfigurationUpdateInfoMessage alloc] durationIndex: self.disappearingMessagesDurationIndex
initWithTimestamp:[NSDate ows_millisecondTimeStamp] ];
thread:self.thread
configuration:self.disappearingMessagesConfiguration
createdByRemoteName:nil
createdInExistingGroup:NO];
[infoMessage saveWithTransaction:transaction];
SNExpirationTimerUpdate *expirationTimerUpdate = [SNExpirationTimerUpdate new];
BOOL isEnabled = self.disappearingMessagesConfiguration.enabled;
expirationTimerUpdate.duration = isEnabled ? self.disappearingMessagesConfiguration.durationSeconds : 0;
[SNMessageSender send:expirationTimerUpdate inThread:self.thread usingTransaction:transaction];
}];
}
} }
#pragma mark - Actions #pragma mark - Actions
- (void)editGroup - (void)editGroup
{ {
SNEditClosedGroupVC *editClosedGroupVC = [[SNEditClosedGroupVC alloc] initWithThreadID:self.thread.uniqueId]; SNEditClosedGroupVC *editClosedGroupVC = [[SNEditClosedGroupVC alloc] initWithThreadId:self.threadId];
[self.navigationController pushViewController:editClosedGroupVC animated:YES completion:nil]; [self.navigationController pushViewController:editClosedGroupVC animated:YES completion:nil];
} }
- (void)didTapLeaveGroup - (void)didTapLeaveGroup
{ {
NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey];
NSString *message; NSString *message;
if ([((TSGroupThread *)self.thread).groupModel.groupAdminIds containsObject:userPublicKey]) { if ([SMKGroupMember isCurrentUserAdminOf:self.threadId]) {
message = @"Because you are the creator of this group it will be deleted for everyone. This cannot be undone."; message = @"Because you are the creator of this group it will be deleted for everyone. This cannot be undone.";
} else { } else {
message = NSLocalizedString(@"CONFIRM_LEAVE_GROUP_DESCRIPTION", @"Alert body"); message = NSLocalizedString(@"CONFIRM_LEAVE_GROUP_DESCRIPTION", @"Alert body");
} }
UIAlertController *alert = UIAlertController *alert =
[UIAlertController alertControllerWithTitle:NSLocalizedString(@"CONFIRM_LEAVE_GROUP_TITLE", @"Alert title") [UIAlertController alertControllerWithTitle:NSLocalizedString(@"CONFIRM_LEAVE_GROUP_TITLE", @"Alert title")
message:message message:message
@ -811,9 +718,8 @@ CGFloat kIconViewLength = 24;
- (BOOL)hasLeftGroup - (BOOL)hasLeftGroup
{ {
if (self.isGroupThread) { if (self.isClosedGroup) {
TSGroupThread *groupThread = (TSGroupThread *)self.thread; return ![SMKGroupMember isCurrentUserMemberOf:self.threadId];
return !groupThread.isCurrentUserMemberInGroup;
} }
return NO; return NO;
@ -821,13 +727,8 @@ CGFloat kIconViewLength = 24;
- (void)leaveGroup - (void)leaveGroup
{ {
TSGroupThread *gThread = (TSGroupThread *)self.thread; if (self.isClosedGroup) {
[[SMKMessageSender leaveClosedGroupWithPublicKey:self.threadId] retainUntilComplete];
if (gThread.isClosedGroup) {
NSString *groupPublicKey = [LKGroupUtilities getDecodedGroupID:gThread.groupModel.groupId];
[LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) {
[[SNMessageSender leaveClosedGroupWithPublicKey:groupPublicKey using:transaction] retainUntilComplete];
}];
} }
[self.navigationController popViewControllerAnimated:YES]; [self.navigationController popViewControllerAnimated:YES];
@ -846,13 +747,9 @@ CGFloat kIconViewLength = 24;
{ {
UISwitch *uiSwitch = (UISwitch *)sender; UISwitch *uiSwitch = (UISwitch *)sender;
if (uiSwitch.isOn) { if (uiSwitch.isOn) {
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { [SMKThread updateWithMutedUntilDateTo:[NSDate distantFuture] forThreadId:self.threadId];
[self.thread updateWithMutedUntilDate:[NSDate distantFuture] transaction:transaction];
}];
} else { } else {
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { [SMKThread updateWithMutedUntilDateTo:nil forThreadId:self.threadId];
[self.thread updateWithMutedUntilDate:nil transaction:transaction];
}];
} }
} }
@ -861,13 +758,12 @@ CGFloat kIconViewLength = 24;
if (![sender isKindOfClass:[UISwitch class]]) { if (![sender isKindOfClass:[UISwitch class]]) {
OWSFailDebug(@"Unexpected sender for block user switch: %@", sender); OWSFailDebug(@"Unexpected sender for block user switch: %@", sender);
} }
if (![self.thread isKindOfClass:[TSContactThread class]]) { if (self.isClosedGroup || self.isOpenGroup) {
OWSFailDebug(@"unexpected thread type: %@", self.thread.class); OWSFailDebug(@"unexpected group thread");
} }
UISwitch *blockConversationSwitch = (UISwitch *)sender; UISwitch *blockConversationSwitch = (UISwitch *)sender;
TSContactThread *contactThread = (TSContactThread *)self.thread;
BOOL isCurrentlyBlocked = contactThread.isBlocked; BOOL isCurrentlyBlocked = [SMKContact isBlockedFor:self.threadId];
__weak OWSConversationSettingsViewController *weakSelf = self; __weak OWSConversationSettingsViewController *weakSelf = self;
if (blockConversationSwitch.isOn) { if (blockConversationSwitch.isOn) {
@ -875,15 +771,15 @@ CGFloat kIconViewLength = 24;
if (isCurrentlyBlocked) { if (isCurrentlyBlocked) {
return; return;
} }
[BlockListUIUtils showBlockThreadActionSheet:contactThread [BlockListUIUtils showBlockThreadActionSheet:self.threadId
from:self from:self
completionBlock:^(BOOL isBlocked) { completionBlock:^(BOOL isBlocked) {
// Update switch state if user cancels action. // Update switch state if user cancels action.
blockConversationSwitch.on = isBlocked; blockConversationSwitch.on = isBlocked;
// If we successfully blocked then force a config sync // If we successfully blocked then force a config sync
if (isBlocked) { if (isBlocked) {
[SNMessageSender forceSyncConfigurationNow]; [SMKMessageSender forceSyncConfigurationNow];
} }
[weakSelf updateTableContents]; [weakSelf updateTableContents];
@ -894,15 +790,15 @@ CGFloat kIconViewLength = 24;
if (!isCurrentlyBlocked) { if (!isCurrentlyBlocked) {
return; return;
} }
[BlockListUIUtils showUnblockThreadActionSheet:contactThread [BlockListUIUtils showUnblockThreadActionSheet:self.threadId
from:self from:self
completionBlock:^(BOOL isBlocked) { completionBlock:^(BOOL isBlocked) {
// Update switch state if user cancels action. // Update switch state if user cancels action.
blockConversationSwitch.on = isBlocked; blockConversationSwitch.on = isBlocked;
// If we successfully unblocked then force a config sync // If we successfully unblocked then force a config sync
if (!isBlocked) { if (!isBlocked) {
[SNMessageSender forceSyncConfigurationNow]; [SMKMessageSender forceSyncConfigurationNow];
} }
[weakSelf updateTableContents]; [weakSelf updateTableContents];
@ -912,7 +808,7 @@ CGFloat kIconViewLength = 24;
- (void)toggleDisappearingMessages:(BOOL)flag - (void)toggleDisappearingMessages:(BOOL)flag
{ {
self.disappearingMessagesConfiguration.enabled = flag; self.isDisappearingMessagesEnabled = flag;
[self updateTableContents]; [self updateTableContents];
} }
@ -920,21 +816,23 @@ CGFloat kIconViewLength = 24;
- (void)durationSliderDidChange:(UISlider *)slider - (void)durationSliderDidChange:(UISlider *)slider
{ {
// snap the slider to a valid value // snap the slider to a valid value
NSUInteger index = (NSUInteger)(slider.value + 0.5); NSInteger index = (NSInteger)(slider.value + 0.5);
[slider setValue:index animated:YES]; [slider setValue:index animated:YES];
NSNumber *numberOfSeconds = self.disappearingMessagesDurations[index]; self.disappearingMessagesDurationIndex = index;
self.disappearingMessagesConfiguration.durationSeconds = [numberOfSeconds unsignedIntValue];
[self updateDisappearingMessagesDurationLabel]; [self updateDisappearingMessagesDurationLabel];
} }
- (void)updateDisappearingMessagesDurationLabel - (void)updateDisappearingMessagesDurationLabel
{ {
if (self.disappearingMessagesConfiguration.isEnabled) { if (self.isDisappearingMessagesEnabled) {
NSString *keepForFormat = @"Disappear after %@"; NSString *keepForFormat = @"Disappear after %@";
self.disappearingMessagesDurationLabel.text = self.disappearingMessagesDurationLabel.text = [NSString
[NSString stringWithFormat:keepForFormat, self.disappearingMessagesConfiguration.durationString]; stringWithFormat:keepForFormat,
} else { [SMKDisappearingMessagesConfiguration durationStringFor: self.disappearingMessagesDurationIndex]
];
}
else {
self.disappearingMessagesDurationLabel.text self.disappearingMessagesDurationLabel.text
= NSLocalizedString(@"KEEP_MESSAGES_FOREVER", @"Slider label when disappearing messages is off"); = NSLocalizedString(@"KEEP_MESSAGES_FOREVER", @"Slider label when disappearing messages is off");
} }
@ -945,30 +843,16 @@ CGFloat kIconViewLength = 24;
- (void)copySessionID - (void)copySessionID
{ {
UIPasteboard.generalPasteboard.string = ((TSContactThread *)self.thread).contactSessionID; UIPasteboard.generalPasteboard.string = self.threadId;
} }
- (void)inviteUsersToOpenGroup - (void)inviteUsersToOpenGroup
{ {
NSString *threadID = self.thread.uniqueId; NSString *threadId = self.threadId;
SNOpenGroupV2 *openGroup = [LKStorage.shared getV2OpenGroupForThreadID:threadID];
NSString *url = [NSString stringWithFormat:@"%@/%@?public_key=%@", openGroup.server, openGroup.room, openGroup.publicKey];
SNUserSelectionVC *userSelectionVC = [[SNUserSelectionVC alloc] initWithTitle:NSLocalizedString(@"vc_conversation_settings_invite_button_title", @"") SNUserSelectionVC *userSelectionVC = [[SNUserSelectionVC alloc] initWithTitle:NSLocalizedString(@"vc_conversation_settings_invite_button_title", @"")
excluding:[NSSet new] excluding:[NSSet new]
completion:^(NSSet<NSString *> *selectedUsers) { completion:^(NSSet<NSString *> *selectedUsers) {
for (NSString *user in selectedUsers) { [SMKOpenGroup inviteUsers:selectedUsers toOpenGroupFor:threadId];
SNVisibleMessage *message = [SNVisibleMessage new];
message.sentTimestamp = [NSDate millisecondTimestamp];
message.openGroupInvitation = [[SNOpenGroupInvitation alloc] initWithName:openGroup.name url:url];
TSContactThread *thread = [TSContactThread getOrCreateThreadWithContactSessionID:user];
TSOutgoingMessage *tsMessage = [TSOutgoingMessage from:message associatedWith:thread];
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
[tsMessage saveWithTransaction:transaction];
}];
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
[SNMessageSender send:message inThread:thread usingTransaction:transaction];
}];
}
}]; }];
[self.navigationController pushViewController:userSelectionVC animated:YES]; [self.navigationController pushViewController:userSelectionVC animated:YES];
} }
@ -977,13 +861,8 @@ CGFloat kIconViewLength = 24;
{ {
OWSLogDebug(@""); OWSLogDebug(@"");
MediaGallery *mediaGallery = [[MediaGallery alloc] initWithThread:self.thread
options:MediaGalleryOptionSliderEnabled];
self.mediaGallery = mediaGallery;
OWSAssertDebug([self.navigationController isKindOfClass:[OWSNavigationController class]]); OWSAssertDebug([self.navigationController isKindOfClass:[OWSNavigationController class]]);
[mediaGallery pushTileViewFromNavController:(OWSNavigationController *)self.navigationController]; [SNMediaGallery pushTileViewWithSliderEnabledForThreadId:self.threadId isClosedGroup:self.isClosedGroup isOpenGroup:self.isOpenGroup fromNavController:(OWSNavigationController *)self.navigationController];
} }
- (void)tappedConversationSearch - (void)tappedConversationSearch
@ -995,9 +874,8 @@ CGFloat kIconViewLength = 24;
{ {
UISwitch *uiSwitch = (UISwitch *)sender; UISwitch *uiSwitch = (UISwitch *)sender;
BOOL isEnabled = uiSwitch.isOn; BOOL isEnabled = uiSwitch.isOn;
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
[(TSGroupThread *)self.thread setIsOnlyNotifyingForMentions:isEnabled withTransaction:transaction]; [SMKThread setIsOnlyNotifyingForMentions:self.threadId to:isEnabled];
}];
} }
- (void)hideEditNameUI - (void)hideEditNameUI
@ -1013,9 +891,9 @@ CGFloat kIconViewLength = 24;
- (void)setIsEditingDisplayName:(BOOL)isEditingDisplayName - (void)setIsEditingDisplayName:(BOOL)isEditingDisplayName
{ {
_isEditingDisplayName = isEditingDisplayName; _isEditingDisplayName = isEditingDisplayName;
[self updateNavBarButtons]; [self updateNavBarButtons];
[UIView animateWithDuration:0.25 animations:^{ [UIView animateWithDuration:0.25 animations:^{
self.displayNameLabel.alpha = self.isEditingDisplayName ? 0 : 1; self.displayNameLabel.alpha = self.isEditingDisplayName ? 0 : 1;
self.displayNameTextField.alpha = self.isEditingDisplayName ? 1 : 0; self.displayNameTextField.alpha = self.isEditingDisplayName ? 1 : 0;
@ -1029,18 +907,10 @@ CGFloat kIconViewLength = 24;
- (void)saveName - (void)saveName
{ {
if (![self.thread isKindOfClass:TSContactThread.class]) { return; } if (self.isClosedGroup || self.isOpenGroup) { return; }
NSString *sessionID = ((TSContactThread *)self.thread).contactSessionID;
SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID];
if (contact == nil) {
contact = [[SNContact alloc] initWithSessionID:sessionID];
}
NSString *text = [self.displayNameTextField.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; NSString *text = [self.displayNameTextField.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
contact.nickname = text.length > 0 ? text : nil; self.displayNameLabel.text = [SMKProfile displayNameAfterSavingNickname:text forProfileId:self.threadId];
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
[LKStorage.shared setContact:contact usingTransaction:transaction];
}];
self.displayNameLabel.text = text.length > 0 ? text : contact.name;
[self hideEditNameUI]; [self hideEditNameUI];
} }
@ -1069,23 +939,16 @@ CGFloat kIconViewLength = 24;
#pragma mark - Notifications #pragma mark - Notifications
- (void)identityStateDidChange:(NSNotification *)notification // FIXME: When this screen gets refactored, make sure to observe changes for relevant profile image updates
{
OWSAssertIsOnMainThread();
[self updateTableContents];
}
- (void)otherUsersProfileDidChange:(NSNotification *)notification - (void)otherUsersProfileDidChange:(NSNotification *)notification
{ {
OWSAssertIsOnMainThread(); NSString *recipientId = @"";//notification.userInfo[NSNotification.profileRecipientIdKey];
NSString *recipientId = notification.userInfo[kNSNotificationKey_ProfileRecipientId];
OWSAssertDebug(recipientId.length > 0); OWSAssertDebug(recipientId.length > 0);
if (recipientId.length > 0 && [self.thread isKindOfClass:[TSContactThread class]] && if (recipientId.length > 0 && !self.isClosedGroup && !self.isOpenGroup && self.threadId == recipientId) {
[((TSContactThread *)self.thread).contactSessionID isEqualToString:recipientId]) { DispatchMainThreadSafe(^{
[self updateTableContents]; [self updateTableContents];
});
} }
} }

View File

@ -9,11 +9,8 @@ NS_ASSUME_NONNULL_BEGIN
@protocol OWSConversationSettingsViewDelegate <NSObject> @protocol OWSConversationSettingsViewDelegate <NSObject>
- (void)groupWasUpdated:(TSGroupModel *)groupModel;
- (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController; - (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController;
- (void)popAllConversationSettingsViewsWithCompletion:(void (^_Nullable)(void))completionBlock;
@end @end
NS_ASSUME_NONNULL_END NS_ASSUME_NONNULL_END

View File

@ -1,13 +1,17 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
/// Shown when the user taps a profile picture in the conversation settings. /// Shown when the user taps a profile picture in the conversation settings.
@objc(SNProfilePictureVC) @objc(SNProfilePictureVC)
final class ProfilePictureVC : BaseVC { final class ProfilePictureVC: BaseVC {
private let image: UIImage private let image: UIImage
private let snTitle: String private let snTitle: String
@objc init(image: UIImage, title: String) { @objc init(image: UIImage, title: String) {
self.image = image self.image = image
self.snTitle = title self.snTitle = title
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -1,3 +1,9 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import GRDB
import SessionUIKit
import SessionUtilitiesKit
import SessionMessagingKit import SessionMessagingKit
final class BlockedModal: Modal { final class BlockedModal: Modal {
@ -19,7 +25,7 @@ final class BlockedModal: Modal {
override func populateContentView() { override func populateContentView() {
// Name // Name
let name = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey let name = Profile.displayName(id: publicKey)
// Title // Title
let titleLabel = UILabel() let titleLabel = UILabel()
titleLabel.textColor = Colors.text titleLabel.textColor = Colors.text
@ -67,23 +73,20 @@ final class BlockedModal: Modal {
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing) contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
} }
// MARK: Interaction // MARK: - Interaction
@objc private func unblock() { @objc private func unblock() {
let publicKey: String = self.publicKey let publicKey: String = self.publicKey
Storage.shared.write( Storage.shared.writeAsync { db in
with: { transaction in try Contact
guard let transaction = transaction as? YapDatabaseReadWriteTransaction, let contact: Contact = Storage.shared.getContact(with: publicKey, using: transaction) else { .filter(id: publicKey)
return .updateAll(db, Contact.Columns.isBlocked.set(to: false))
}
try MessageSender
contact.isBlocked = false .syncConfiguration(db, forceSyncNow: true)
Storage.shared.setContact(contact, using: transaction as Any) .retainUntilComplete()
}, }
completion: {
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
}
)
presentingViewController?.dismiss(animated: true, completion: nil) presentingViewController?.dismiss(animated: true, completion: nil)
} }

View File

@ -1,20 +1,37 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
// Requirements: // Requirements:
// Links should show up properly and be tappable. // Links should show up properly and be tappable
// Text should * not * be selectable. // Text should * not * be selectable (this is handled via the 'textViewDidChangeSelection(_:)'
// The long press interaction that shows the context menu should still work. // delegate method)
// The long press interaction that shows the context menu should still work
final class BodyTextView : UITextView { final class BodyTextView: UITextView {
private let snDelegate: BodyTextViewDelegate private let snDelegate: BodyTextViewDelegate?
private let highlightedMentionBackgroundView: HighlightMentionBackgroundView = HighlightMentionBackgroundView()
override var selectedTextRange: UITextRange? { override var attributedText: NSAttributedString! {
get { return nil } didSet {
set { } guard attributedText != nil else { return }
highlightedMentionBackgroundView.maxPadding = highlightedMentionBackgroundView
.calculateMaxPadding(for: attributedText)
highlightedMentionBackgroundView.frame = self.bounds.insetBy(
dx: -highlightedMentionBackgroundView.maxPadding,
dy: -highlightedMentionBackgroundView.maxPadding
)
}
} }
init(snDelegate: BodyTextViewDelegate) { init(snDelegate: BodyTextViewDelegate?) {
self.snDelegate = snDelegate self.snDelegate = snDelegate
super.init(frame: CGRect.zero, textContainer: nil) super.init(frame: CGRect.zero, textContainer: nil)
self.clipsToBounds = false // Needed for the 'HighlightMentionBackgroundView'
addSubview(highlightedMentionBackgroundView)
setUpGestureRecognizers() setUpGestureRecognizers()
} }
@ -35,12 +52,21 @@ final class BodyTextView : UITextView {
} }
@objc private func handleLongPress() { @objc private func handleLongPress() {
snDelegate.handleLongPress() snDelegate?.handleLongPress()
} }
@objc private func handleDoubleTap() { @objc private func handleDoubleTap() {
// Do nothing // Do nothing
} }
override func layoutSubviews() {
super.layoutSubviews()
highlightedMentionBackgroundView.frame = self.bounds.insetBy(
dx: -highlightedMentionBackgroundView.maxPadding,
dy: -highlightedMentionBackgroundView.maxPadding
)
}
} }
protocol BodyTextViewDelegate { protocol BodyTextViewDelegate {

View File

@ -1,13 +1,21 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
import SessionMessagingKit
@objc @objc
final class CallModal : Modal { final class CallModal: Modal {
private let onCallEnabled: () -> Void private let onCallEnabled: () -> Void
// MARK: Lifecycle // MARK: - Lifecycle
@objc @objc
init(onCallEnabled: @escaping () -> Void) { init(onCallEnabled: @escaping () -> Void) {
self.onCallEnabled = onCallEnabled self.onCallEnabled = onCallEnabled
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
self.modalPresentationStyle = .overFullScreen self.modalPresentationStyle = .overFullScreen
self.modalTransitionStyle = .crossDissolve self.modalTransitionStyle = .crossDissolve
} }
@ -27,15 +35,16 @@ final class CallModal : Modal {
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
titleLabel.text = NSLocalizedString("modal_call_title", comment: "") titleLabel.text = NSLocalizedString("modal_call_title", comment: "")
titleLabel.textAlignment = .center titleLabel.textAlignment = .center
// Message // Message
let messageLabel = UILabel() let messageLabel = UILabel()
messageLabel.textColor = Colors.text messageLabel.textColor = Colors.text
messageLabel.font = .systemFont(ofSize: Values.smallFontSize) messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
let message = NSLocalizedString("modal_call_explanation", comment: "") messageLabel.text = "modal_call_explanation".localized()
messageLabel.text = message
messageLabel.numberOfLines = 0 messageLabel.numberOfLines = 0
messageLabel.lineBreakMode = .byWordWrapping messageLabel.lineBreakMode = .byWordWrapping
messageLabel.textAlignment = .center messageLabel.textAlignment = .center
// Enable button // Enable button
let enableButton = UIButton() let enableButton = UIButton()
enableButton.set(.height, to: Values.mediumButtonHeight) enableButton.set(.height, to: Values.mediumButtonHeight)
@ -45,25 +54,29 @@ final class CallModal : Modal {
enableButton.setTitleColor(Colors.text, for: UIControl.State.normal) enableButton.setTitleColor(Colors.text, for: UIControl.State.normal)
enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal) enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal)
enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside) enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside)
// Button stack view // Button stack view
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ]) let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
buttonStackView.axis = .horizontal buttonStackView.axis = .horizontal
buttonStackView.spacing = Values.mediumSpacing buttonStackView.spacing = Values.mediumSpacing
buttonStackView.distribution = .fillEqually buttonStackView.distribution = .fillEqually
// Main stack view // Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ]) let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
mainStackView.axis = .vertical mainStackView.axis = .vertical
mainStackView.spacing = Values.largeSpacing mainStackView.spacing = Values.largeSpacing
contentView.addSubview(mainStackView) contentView.addSubview(mainStackView)
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing) mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing) mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing) contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing) contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
} }
// MARK: Interaction // MARK: - Interaction
@objc private func enable() { @objc private func enable() {
SSKPreferences.areCallsEnabled = true Storage.shared.writeAsync { db in db[.areCallsEnabled] = true }
presentingViewController?.dismiss(animated: true, completion: nil) presentingViewController?.dismiss(animated: true, completion: nil)
onCallEnabled() onCallEnabled()
} }

View File

@ -1,26 +1,35 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class ConversationTitleView : UIView { import UIKit
private let thread: TSThread import SessionUIKit
weak var delegate: ConversationTitleViewDelegate? import SessionMessagingKit
import SessionUtilitiesKit
final class ConversationTitleView: UIView {
private static let leftInset: CGFloat = 8
private static let leftInsetWithCallButton: CGFloat = 54
override var intrinsicContentSize: CGSize { override var intrinsicContentSize: CGSize {
return UIView.layoutFittingExpandedSize return UIView.layoutFittingExpandedSize
} }
// MARK: UI Components // MARK: - UI Components
private lazy var titleLabel: UILabel = { private lazy var titleLabel: UILabel = {
let result = UILabel() let result: UILabel = UILabel()
result.textColor = Colors.text result.textColor = Colors.text
result.font = .boldSystemFont(ofSize: Values.mediumFontSize) result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
result.lineBreakMode = .byTruncatingTail result.lineBreakMode = .byTruncatingTail
return result return result
}() }()
private lazy var subtitleLabel: UILabel = { private lazy var subtitleLabel: UILabel = {
let result = UILabel() let result: UILabel = UILabel()
result.textColor = Colors.text result.textColor = Colors.text
result.font = .systemFont(ofSize: 13) result.font = .systemFont(ofSize: 13)
result.lineBreakMode = .byTruncatingTail result.lineBreakMode = .byTruncatingTail
return result return result
}() }()
@ -29,114 +38,119 @@ final class ConversationTitleView : UIView {
result.axis = .vertical result.axis = .vertical
result.alignment = .center result.alignment = .center
result.isLayoutMarginsRelativeArrangement = true result.isLayoutMarginsRelativeArrangement = true
return result return result
}() }()
// MARK: Lifecycle // MARK: - Initialization
init(thread: TSThread) {
self.thread = thread init() {
super.init(frame: CGRect.zero) super.init(frame: .zero)
initialize()
}
override init(frame: CGRect) {
preconditionFailure("Use init(thread:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(coder:) instead.")
}
private func initialize() {
addSubview(stackView)
stackView.pin(to: self)
let shouldShowCallButton = SessionCall.isEnabled && !thread.isNoteToSelf() && !thread.isGroupThread()
let leftMargin: CGFloat = shouldShowCallButton ? 54 : 8 // Contact threads also have the call button to compensate for
stackView.layoutMargins = UIEdgeInsets(top: 0, left: leftMargin, bottom: 0, right: 0)
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) addSubview(stackView)
addGestureRecognizer(tapGestureRecognizer)
let notificationCenter = NotificationCenter.default stackView.pin(to: self)
notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.groupThreadUpdated, object: nil)
notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.muteSettingUpdated, object: nil)
notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.contactUpdated, object: nil)
update()
} }
deinit { deinit {
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }
// MARK: Updating required init?(coder: NSCoder) {
@objc private func update() { preconditionFailure("Use init() instead.")
titleLabel.text = getTitle()
let subtitle = getSubtitle()
subtitleLabel.attributedText = subtitle
let titleFontSize = (subtitle != nil) ? Values.mediumFontSize : Values.veryLargeFontSize
titleLabel.font = .boldSystemFont(ofSize: titleFontSize)
} }
// MARK: General // MARK: - Content
private func getTitle() -> String {
if let thread = thread as? TSGroupThread { public func initialSetup(with threadVariant: SessionThread.Variant) {
return thread.groupModel.groupName! self.update(
} with: " ",
else if thread.isNoteToSelf() { isNoteToSelf: false,
return "Note to Self" threadVariant: threadVariant,
} mutedUntilTimestamp: nil,
else { onlyNotifyForMentions: false,
let sessionID = (thread as! TSContactThread).contactSessionID() userCount: (threadVariant != .contact ? 0 : nil)
var result = sessionID )
Storage.read { transaction in }
let displayName: String = ((Storage.shared.getContact(with: sessionID)?.displayName(for: .regular)) ?? sessionID)
let middleTruncatedHexKey: String = "\(sessionID.prefix(4))...\(sessionID.suffix(4))" public func update(
result = (displayName == sessionID ? middleTruncatedHexKey : displayName) with name: String,
isNoteToSelf: Bool,
threadVariant: SessionThread.Variant,
mutedUntilTimestamp: TimeInterval?,
onlyNotifyForMentions: Bool,
userCount: Int?
) {
guard Thread.isMainThread else {
DispatchQueue.main.async { [weak self] in
self?.update(
with: name,
isNoteToSelf: isNoteToSelf,
threadVariant: threadVariant,
mutedUntilTimestamp: mutedUntilTimestamp,
onlyNotifyForMentions: onlyNotifyForMentions,
userCount: userCount
)
} }
return result return
} }
}
// Generate the subtitle
private func getSubtitle() -> NSAttributedString? { let subtitle: NSAttributedString? = {
let result = NSMutableAttributedString() guard Date().timeIntervalSince1970 > (mutedUntilTimestamp ?? 0) else {
if thread.isMuted { return NSAttributedString(
result.append(NSAttributedString(string: "\u{e067} ", attributes: [ .font : UIFont.ows_elegantIconsFont(10), .foregroundColor : Colors.text ])) string: "\u{e067} ",
result.append(NSAttributedString(string: "Muted")) attributes: [
return result .font: UIFont.ows_elegantIconsFont(10),
} else if let thread = self.thread as? TSGroupThread { .foregroundColor: Colors.text
if thread.isOnlyNotifyingForMentions { ]
)
.appending(string: "Muted")
}
guard !onlyNotifyForMentions else {
// FIXME: This is going to have issues when swapping between light/dark mode
let imageAttachment = NSTextAttachment() let imageAttachment = NSTextAttachment()
let color: UIColor = isDarkMode ? .white : .black let color: UIColor = (isDarkMode ? .white : .black)
imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: color) imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: color)
imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize) imageAttachment.bounds = CGRect(
let imageAsString = NSAttributedString(attachment: imageAttachment) x: 0,
result.append(imageAsString) y: -2,
result.append(NSAttributedString(string: " " + NSLocalizedString("view_conversation_title_notify_for_mentions_only", comment: ""))) width: Values.smallFontSize,
return result height: Values.smallFontSize
} else { )
var userCount: UInt64?
switch thread.groupModel.groupType { return NSAttributedString(attachment: imageAttachment)
case .closedGroup: userCount = UInt64(thread.groupModel.groupMemberIds.count) .appending(string: " ")
case .openGroup: .appending(string: "view_conversation_title_notify_for_mentions_only".localized())
guard let openGroupV2 = Storage.shared.getV2OpenGroup(for: self.thread.uniqueId!) else { return nil }
userCount = Storage.shared.getUserCount(forV2OpenGroupWithID: openGroupV2.id)
default: break
}
if let userCount = userCount {
return NSAttributedString(string: "\(userCount) members")
}
} }
} guard let userCount: Int = userCount else { return nil }
return nil
} return NSAttributedString(string: "\(userCount) member\(userCount == 1 ? "" : "s")")
}()
// MARK: Interaction
@objc private func handleTap() { self.titleLabel.text = name
delegate?.handleTitleViewTapped() self.titleLabel.font = .boldSystemFont(
ofSize: (subtitle != nil ?
Values.mediumFontSize :
Values.veryLargeFontSize
)
)
self.subtitleLabel.attributedText = subtitle
// Contact threads also have the call button to compensate for
let shouldShowCallButton: Bool = (
SessionCall.isEnabled &&
!isNoteToSelf &&
threadVariant == .contact
)
self.stackView.layoutMargins = UIEdgeInsets(
top: 0,
left: (shouldShowCallButton ?
ConversationTitleView.leftInsetWithCallButton :
ConversationTitleView.leftInset
),
bottom: 0,
right: 0
)
} }
} }
// MARK: Delegate
protocol ConversationTitleViewDelegate : AnyObject {
func handleTitleViewTapped()
}

View File

@ -1,42 +1,58 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class DownloadAttachmentModal : Modal { import UIKit
private let viewItem: ConversationViewItem import GRDB
import SessionUIKit
import SessionUtilitiesKit
import SessionMessagingKit
final class DownloadAttachmentModal: Modal {
private let profile: Profile?
// MARK: - Lifecycle
// MARK: Lifecycle init(profile: Profile?) {
init(viewItem: ConversationViewItem) { self.profile = profile
self.viewItem = viewItem
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
override init(nibName: String?, bundle: Bundle?) { override init(nibName: String?, bundle: Bundle?) {
preconditionFailure("Use init(viewItem:) instead.") preconditionFailure("Use init(viewItem:) instead.")
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
preconditionFailure("Use init(viewItem:) instead.") preconditionFailure("Use init(viewItem:) instead.")
} }
override func populateContentView() { override func populateContentView() {
guard let publicKey = (viewItem.interaction as? TSIncomingMessage)?.authorId else { return } guard let profile: Profile = profile else { return }
// Name // Name
let name = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey let name: String = profile.displayName()
// Title // Title
let titleLabel = UILabel() let titleLabel = UILabel()
titleLabel.textColor = Colors.text titleLabel.textColor = Colors.text
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize) titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
titleLabel.text = String(format: NSLocalizedString("modal_download_attachment_title", comment: ""), name) titleLabel.text = String(format: NSLocalizedString("modal_download_attachment_title", comment: ""), name)
titleLabel.textAlignment = .center titleLabel.textAlignment = .center
// Message // Message
let messageLabel = UILabel() let messageLabel = UILabel()
messageLabel.textColor = Colors.text messageLabel.textColor = Colors.text
messageLabel.font = .systemFont(ofSize: Values.smallFontSize) messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
let message = String(format: NSLocalizedString("modal_download_attachment_explanation", comment: ""), name) let message = String(format: NSLocalizedString("modal_download_attachment_explanation", comment: ""), name)
let attributedMessage = NSMutableAttributedString(string: message) let attributedMessage = NSMutableAttributedString(string: message)
attributedMessage.addAttributes([ .font : UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], range: (message as NSString).range(of: name)) attributedMessage.addAttributes(
[.font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ],
range: (message as NSString).range(of: name)
)
messageLabel.attributedText = attributedMessage messageLabel.attributedText = attributedMessage
messageLabel.numberOfLines = 0 messageLabel.numberOfLines = 0
messageLabel.lineBreakMode = .byWordWrapping messageLabel.lineBreakMode = .byWordWrapping
messageLabel.textAlignment = .center messageLabel.textAlignment = .center
// Download button // Download button
let downloadButton = UIButton() let downloadButton = UIButton()
downloadButton.set(.height, to: Values.mediumButtonHeight) downloadButton.set(.height, to: Values.mediumButtonHeight)
@ -45,15 +61,18 @@ final class DownloadAttachmentModal : Modal {
downloadButton.setTitleColor(Colors.text, for: UIControl.State.normal) downloadButton.setTitleColor(Colors.text, for: UIControl.State.normal)
downloadButton.setTitle(NSLocalizedString("modal_download_button_title", comment: ""), for: UIControl.State.normal) downloadButton.setTitle(NSLocalizedString("modal_download_button_title", comment: ""), for: UIControl.State.normal)
downloadButton.addTarget(self, action: #selector(trust), for: UIControl.Event.touchUpInside) downloadButton.addTarget(self, action: #selector(trust), for: UIControl.Event.touchUpInside)
// Button stack view // Button stack view
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, downloadButton ]) let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, downloadButton ])
buttonStackView.axis = .horizontal buttonStackView.axis = .horizontal
buttonStackView.spacing = Values.mediumSpacing buttonStackView.spacing = Values.mediumSpacing
buttonStackView.distribution = .fillEqually buttonStackView.distribution = .fillEqually
// Content stack view // Content stack view
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ]) let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
contentStackView.axis = .vertical contentStackView.axis = .vertical
contentStackView.spacing = Values.largeSpacing contentStackView.spacing = Values.largeSpacing
// Main stack view // Main stack view
let spacing = Values.largeSpacing - Values.smallFontSize / 2 let spacing = Values.largeSpacing - Values.smallFontSize / 2
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ]) let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
@ -65,19 +84,37 @@ final class DownloadAttachmentModal : Modal {
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing) contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing) contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
} }
// MARK: - Interaction
// MARK: Interaction
@objc private func trust() { @objc private func trust() {
guard let message = viewItem.interaction as? TSIncomingMessage else { return } guard let profileId: String = profile?.id else { return }
let publicKey = message.authorId
let contact = Storage.shared.getContact(with: publicKey) ?? Contact(sessionID: publicKey) Storage.shared.writeAsync { db in
contact.isTrusted = true try Contact
Storage.write(with: { transaction in .filter(id: profileId)
Storage.shared.setContact(contact, using: transaction) .updateAll(db, Contact.Columns.isTrusted.set(to: true))
MessageInvalidator.invalidate(message, with: transaction)
}, completion: { // Start downloading any pending attachments for this contact (UI will automatically be
Storage.shared.resumeAttachmentDownloadJobsIfNeeded(for: message.uniqueThreadId) // updated due to the database observation)
}) try Attachment
.stateInfo(authorId: profileId, state: .pendingDownload)
.fetchAll(db)
.forEach { attachmentDownloadInfo in
JobRunner.add(
db,
job: Job(
variant: .attachmentDownload,
threadId: profileId,
interactionId: attachmentDownloadInfo.interactionId,
details: AttachmentDownloadJob.Details(
attachmentId: attachmentDownloadInfo.attachmentId
)
)
)
}
}
presentingViewController?.dismiss(animated: true, completion: nil) presentingViewController?.dismiss(animated: true, completion: nil)
} }
} }

View File

@ -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
}
}

View File

@ -1,12 +1,20 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class JoinOpenGroupModal : Modal { import UIKit
import GRDB
import SessionMessagingKit
import SessionUtilitiesKit
final class JoinOpenGroupModal: Modal {
private let name: String private let name: String
private let url: String private let url: String
// MARK: Lifecycle // MARK: - Lifecycle
init(name: String, url: String) {
self.name = name init(name: String?, url: String) {
self.name = (name ?? "Open Group")
self.url = url self.url = url
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@ -25,6 +33,7 @@ final class JoinOpenGroupModal : Modal {
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize) titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
titleLabel.text = "Join \(name)?" titleLabel.text = "Join \(name)?"
titleLabel.textAlignment = .center titleLabel.textAlignment = .center
// Message // Message
let messageLabel = UILabel() let messageLabel = UILabel()
messageLabel.textColor = Colors.text messageLabel.textColor = Colors.text
@ -36,6 +45,7 @@ final class JoinOpenGroupModal : Modal {
messageLabel.numberOfLines = 0 messageLabel.numberOfLines = 0
messageLabel.lineBreakMode = .byWordWrapping messageLabel.lineBreakMode = .byWordWrapping
messageLabel.textAlignment = .center messageLabel.textAlignment = .center
// Join button // Join button
let joinButton = UIButton() let joinButton = UIButton()
joinButton.set(.height, to: Values.mediumButtonHeight) joinButton.set(.height, to: Values.mediumButtonHeight)
@ -45,15 +55,18 @@ final class JoinOpenGroupModal : Modal {
joinButton.setTitleColor(Colors.text, for: UIControl.State.normal) joinButton.setTitleColor(Colors.text, for: UIControl.State.normal)
joinButton.setTitle("Join", for: UIControl.State.normal) joinButton.setTitle("Join", for: UIControl.State.normal)
joinButton.addTarget(self, action: #selector(joinOpenGroup), for: UIControl.Event.touchUpInside) joinButton.addTarget(self, action: #selector(joinOpenGroup), for: UIControl.Event.touchUpInside)
// Button stack view // Button stack view
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, joinButton ]) let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, joinButton ])
buttonStackView.axis = .horizontal buttonStackView.axis = .horizontal
buttonStackView.spacing = Values.mediumSpacing buttonStackView.spacing = Values.mediumSpacing
buttonStackView.distribution = .fillEqually buttonStackView.distribution = .fillEqually
// Content stack view // Content stack view
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ]) let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
contentStackView.axis = .vertical contentStackView.axis = .vertical
contentStackView.spacing = Values.largeSpacing contentStackView.spacing = Values.largeSpacing
// Main stack view // Main stack view
let spacing = Values.largeSpacing - Values.smallFontSize / 2 let spacing = Values.largeSpacing - Values.smallFontSize / 2
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ]) let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
@ -66,24 +79,39 @@ final class JoinOpenGroupModal : Modal {
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing) contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
} }
// MARK: Interaction // MARK: - Interaction
@objc private func joinOpenGroup() { @objc private func joinOpenGroup() {
guard let (room, server, publicKey) = OpenGroupManagerV2.parseV2OpenGroup(from: url) else { guard let presentingViewController: UIViewController = self.presentingViewController else { return }
guard let (room, server, publicKey) = OpenGroupManager.parseOpenGroup(from: url) else {
let alert = UIAlertController(title: "Couldn't Join", message: nil, preferredStyle: .alert) let alert = UIAlertController(title: "Couldn't Join", message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
return presentingViewController!.presentAlert(alert)
return presentingViewController.present(alert, animated: true, completion: nil)
} }
presentingViewController!.dismiss(animated: true, completion: nil)
Storage.shared.write { [presentingViewController = self.presentingViewController!] transaction in presentingViewController.dismiss(animated: true, completion: nil)
OpenGroupManagerV2.shared.add(room: room, server: server, publicKey: publicKey, using: transaction)
Storage.shared
.writeAsync { db in
OpenGroupManager.shared.add(
db,
roomToken: room,
server: server,
publicKey: publicKey,
isConfigMessage: false
)
}
.done(on: DispatchQueue.main) { _ in .done(on: DispatchQueue.main) { _ in
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) Storage.shared.writeAsync { db in
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...)
}
} }
.catch(on: DispatchQueue.main) { error in .catch(on: DispatchQueue.main) { error in
let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert) let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
presentingViewController.presentAlert(alert) presentingViewController.present(alert, animated: true, completion: nil)
} }
} .retainUntilComplete()
} }
} }

View File

@ -1,8 +1,15 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class LinkPreviewModal : Modal { import UIKit
import GRDB
import SessionUIKit
import SessionMessagingKit
final class LinkPreviewModal: Modal {
private let onLinkPreviewsEnabled: () -> Void private let onLinkPreviewsEnabled: () -> Void
// MARK: Lifecycle // MARK: - Lifecycle
init(onLinkPreviewsEnabled: @escaping () -> Void) { init(onLinkPreviewsEnabled: @escaping () -> Void) {
self.onLinkPreviewsEnabled = onLinkPreviewsEnabled self.onLinkPreviewsEnabled = onLinkPreviewsEnabled
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@ -18,22 +25,23 @@ final class LinkPreviewModal : Modal {
override func populateContentView() { override func populateContentView() {
// Title // Title
let titleLabel = UILabel() let titleLabel: UILabel = UILabel()
titleLabel.textColor = Colors.text titleLabel.textColor = Colors.text
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize) titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
titleLabel.text = NSLocalizedString("modal_link_previews_title", comment: "") titleLabel.text = "modal_link_previews_title".localized()
titleLabel.textAlignment = .center titleLabel.textAlignment = .center
// Message // Message
let messageLabel = UILabel() let messageLabel: UILabel = UILabel()
messageLabel.textColor = Colors.text messageLabel.textColor = Colors.text
messageLabel.font = .systemFont(ofSize: Values.smallFontSize) messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
let message = NSLocalizedString("modal_link_previews_explanation", comment: "") messageLabel.text = "modal_link_previews_explanation".localized()
messageLabel.text = message
messageLabel.numberOfLines = 0 messageLabel.numberOfLines = 0
messageLabel.lineBreakMode = .byWordWrapping messageLabel.lineBreakMode = .byWordWrapping
messageLabel.textAlignment = .center messageLabel.textAlignment = .center
// Enable button // Enable button
let enableButton = UIButton() let enableButton: UIButton = UIButton()
enableButton.set(.height, to: Values.mediumButtonHeight) enableButton.set(.height, to: Values.mediumButtonHeight)
enableButton.layer.cornerRadius = Modal.buttonCornerRadius enableButton.layer.cornerRadius = Modal.buttonCornerRadius
enableButton.backgroundColor = Colors.buttonBackground enableButton.backgroundColor = Colors.buttonBackground
@ -41,18 +49,22 @@ final class LinkPreviewModal : Modal {
enableButton.setTitleColor(Colors.text, for: UIControl.State.normal) enableButton.setTitleColor(Colors.text, for: UIControl.State.normal)
enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal) enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal)
enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside) enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside)
// Button stack view // Button stack view
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ]) let buttonStackView: UIStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
buttonStackView.axis = .horizontal buttonStackView.axis = .horizontal
buttonStackView.spacing = Values.mediumSpacing buttonStackView.spacing = Values.mediumSpacing
buttonStackView.distribution = .fillEqually buttonStackView.distribution = .fillEqually
// Content stack view // Content stack view
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ]) let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
contentStackView.axis = .vertical contentStackView.axis = .vertical
contentStackView.spacing = Values.largeSpacing contentStackView.spacing = Values.largeSpacing
// Main stack view // Main stack view
let spacing = Values.largeSpacing - Values.smallFontSize / 2 let spacing = Values.largeSpacing - Values.smallFontSize / 2
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ]) let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
mainStackView.axis = .vertical mainStackView.axis = .vertical
mainStackView.spacing = spacing mainStackView.spacing = spacing
contentView.addSubview(mainStackView) contentView.addSubview(mainStackView)
@ -62,9 +74,13 @@ final class LinkPreviewModal : Modal {
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing) contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
} }
// MARK: Interaction // MARK: - Interaction
@objc private func enable() { @objc private func enable() {
SSKPreferences.areLinkPreviewsEnabled = true Storage.shared.writeAsync { db in
db[.areLinkPreviewsEnabled] = true
}
presentingViewController?.dismiss(animated: true, completion: nil) presentingViewController?.dismiss(animated: true, completion: nil)
onLinkPreviewsEnabled() onLinkPreviewsEnabled()
} }

View File

@ -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
}
}

View File

@ -1,12 +1,17 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class ScrollToBottomButton : UIView {
import UIKit
final class ScrollToBottomButton: UIView {
private weak var delegate: ScrollToBottomButtonDelegate? private weak var delegate: ScrollToBottomButtonDelegate?
// MARK: Settings // MARK: - Settings
private static let size: CGFloat = 40 private static let size: CGFloat = 40
private static let iconSize: CGFloat = 16 private static let iconSize: CGFloat = 16
// MARK: Lifecycle // MARK: - Lifecycle
init(delegate: ScrollToBottomButtonDelegate) { init(delegate: ScrollToBottomButtonDelegate) {
self.delegate = delegate self.delegate = delegate
super.init(frame: CGRect.zero) super.init(frame: CGRect.zero)
@ -55,13 +60,15 @@ final class ScrollToBottomButton : UIView {
addGestureRecognizer(tapGestureRecognizer) addGestureRecognizer(tapGestureRecognizer)
} }
// MARK: Interaction // MARK: - Interaction
@objc private func handleTap() { @objc private func handleTap() {
delegate?.handleScrollToBottomButtonTapped() delegate?.handleScrollToBottomButtonTapped()
} }
} }
protocol ScrollToBottomButtonDelegate : class { // MARK: - ScrollToBottomButtonDelegate
protocol ScrollToBottomButtonDelegate: AnyObject {
func handleScrollToBottomButtonTapped() func handleScrollToBottomButtonTapped()
} }

View File

@ -1,8 +1,13 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class URLModal : Modal { import UIKit
import SessionUIKit
final class URLModal: Modal {
private let url: URL private let url: URL
// MARK: Lifecycle // MARK: - Lifecycle
init(url: URL) { init(url: URL) {
self.url = url self.url = url
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
@ -23,6 +28,7 @@ final class URLModal : Modal {
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize) titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
titleLabel.text = NSLocalizedString("modal_open_url_title", comment: "") titleLabel.text = NSLocalizedString("modal_open_url_title", comment: "")
titleLabel.textAlignment = .center titleLabel.textAlignment = .center
// Message // Message
let messageLabel = UILabel() let messageLabel = UILabel()
messageLabel.textColor = Colors.text messageLabel.textColor = Colors.text
@ -34,6 +40,7 @@ final class URLModal : Modal {
messageLabel.numberOfLines = 0 messageLabel.numberOfLines = 0
messageLabel.lineBreakMode = .byWordWrapping messageLabel.lineBreakMode = .byWordWrapping
messageLabel.textAlignment = .center messageLabel.textAlignment = .center
// Open button // Open button
let openButton = UIButton() let openButton = UIButton()
openButton.set(.height, to: Values.mediumButtonHeight) openButton.set(.height, to: Values.mediumButtonHeight)
@ -42,16 +49,19 @@ final class URLModal : Modal {
openButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize) openButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
openButton.setTitleColor(Colors.text, for: UIControl.State.normal) openButton.setTitleColor(Colors.text, for: UIControl.State.normal)
openButton.setTitle(NSLocalizedString("modal_open_url_button_title", comment: ""), for: UIControl.State.normal) openButton.setTitle(NSLocalizedString("modal_open_url_button_title", comment: ""), for: UIControl.State.normal)
openButton.addTarget(self, action: #selector(openURL), for: UIControl.Event.touchUpInside) openButton.addTarget(self, action: #selector(openUrl), for: UIControl.Event.touchUpInside)
// Button stack view // Button stack view
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, openButton ]) let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, openButton ])
buttonStackView.axis = .horizontal buttonStackView.axis = .horizontal
buttonStackView.spacing = Values.mediumSpacing buttonStackView.spacing = Values.mediumSpacing
buttonStackView.distribution = .fillEqually buttonStackView.distribution = .fillEqually
// Content stack view // Content stack view
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ]) let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
contentStackView.axis = .vertical contentStackView.axis = .vertical
contentStackView.spacing = Values.largeSpacing contentStackView.spacing = Values.largeSpacing
// Main stack view // Main stack view
let spacing = Values.largeSpacing - Values.smallFontSize / 2 let spacing = Values.largeSpacing - Values.smallFontSize / 2
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ]) let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
@ -64,9 +74,11 @@ final class URLModal : Modal {
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing) contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
} }
// MARK: Interaction // MARK: - Interaction
@objc private func openURL() {
@objc private func openUrl() {
let url = self.url let url = self.url
presentingViewController?.dismiss(animated: true, completion: { presentingViewController?.dismiss(animated: true, completion: {
UIApplication.shared.open(url, options: [:], completionHandler: nil) UIApplication.shared.open(url, options: [:], completionHandler: nil)
}) })

View File

@ -1,9 +1,14 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class UserDetailsSheet : Sheet { import UIKit
private let sessionID: String import SessionMessagingKit
final class UserDetailsSheet: Sheet {
private let profile: Profile
init(for sessionID: String) { init(for profile: Profile) {
self.sessionID = sessionID self.profile = profile
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@ -22,16 +27,21 @@ final class UserDetailsSheet : Sheet {
profilePictureView.size = size profilePictureView.size = size
profilePictureView.set(.width, to: size) profilePictureView.set(.width, to: size)
profilePictureView.set(.height, to: size) profilePictureView.set(.height, to: size)
profilePictureView.publicKey = sessionID profilePictureView.update(
profilePictureView.update() publicKey: profile.id,
profile: profile,
threadVariant: .contact
)
// Display name label // Display name label
let displayNameLabel = UILabel() let displayNameLabel = UILabel()
let displayName = Storage.shared.getContact(with: sessionID)?.displayName(for: .regular) ?? sessionID let displayName = profile.displayName()
displayNameLabel.text = displayName displayNameLabel.text = displayName
displayNameLabel.font = .boldSystemFont(ofSize: Values.largeFontSize) displayNameLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
displayNameLabel.textColor = Colors.text displayNameLabel.textColor = Colors.text
displayNameLabel.numberOfLines = 1 displayNameLabel.numberOfLines = 1
displayNameLabel.lineBreakMode = .byTruncatingTail displayNameLabel.lineBreakMode = .byTruncatingTail
// Session ID label // Session ID label
let sessionIDLabel = UILabel() let sessionIDLabel = UILabel()
sessionIDLabel.textColor = Colors.text sessionIDLabel.textColor = Colors.text
@ -39,7 +49,8 @@ final class UserDetailsSheet : Sheet {
sessionIDLabel.numberOfLines = 0 sessionIDLabel.numberOfLines = 0
sessionIDLabel.lineBreakMode = .byCharWrapping sessionIDLabel.lineBreakMode = .byCharWrapping
sessionIDLabel.accessibilityLabel = "Session ID label" sessionIDLabel.accessibilityLabel = "Session ID label"
sessionIDLabel.text = sessionID sessionIDLabel.text = profile.id
// Session ID label container // Session ID label container
let sessionIDLabelContainer = UIView() let sessionIDLabelContainer = UIView()
sessionIDLabelContainer.addSubview(sessionIDLabel) sessionIDLabelContainer.addSubview(sessionIDLabel)
@ -47,23 +58,26 @@ final class UserDetailsSheet : Sheet {
sessionIDLabelContainer.layer.cornerRadius = TextField.cornerRadius sessionIDLabelContainer.layer.cornerRadius = TextField.cornerRadius
sessionIDLabelContainer.layer.borderWidth = 1 sessionIDLabelContainer.layer.borderWidth = 1
sessionIDLabelContainer.layer.borderColor = isLightMode ? UIColor.black.cgColor : UIColor.white.cgColor sessionIDLabelContainer.layer.borderColor = isLightMode ? UIColor.black.cgColor : UIColor.white.cgColor
// Copy button // Copy button
let copyButton = Button(style: .prominentOutline, size: .medium) let copyButton = Button(style: .prominentOutline, size: .medium)
copyButton.setTitle(NSLocalizedString("copy", comment: ""), for: UIControl.State.normal) copyButton.setTitle(NSLocalizedString("copy", comment: ""), for: UIControl.State.normal)
copyButton.addTarget(self, action: #selector(copySessionID), for: UIControl.Event.touchUpInside) copyButton.addTarget(self, action: #selector(copySessionID), for: UIControl.Event.touchUpInside)
copyButton.set(.width, to: 160) copyButton.set(.width, to: 160)
// Stack view // Stack view
let stackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel, sessionIDLabelContainer, copyButton, UIView.vSpacer(Values.largeSpacing) ]) let stackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel, sessionIDLabelContainer, copyButton, UIView.vSpacer(Values.largeSpacing) ])
stackView.axis = .vertical stackView.axis = .vertical
stackView.spacing = Values.largeSpacing stackView.spacing = Values.largeSpacing
stackView.alignment = .center stackView.alignment = .center
// Constraints // Constraints
contentView.addSubview(stackView) contentView.addSubview(stackView)
stackView.pin(to: contentView, withInset: Values.largeSpacing) stackView.pin(to: contentView, withInset: Values.largeSpacing)
} }
@objc private func copySessionID() { @objc private func copySessionID() {
UIPasteboard.general.string = sessionID UIPasteboard.general.string = profile.id
presentingViewController?.dismiss(animated: true, completion: nil) presentingViewController?.dismiss(animated: true, completion: nil)
} }
} }

View File

@ -1,3 +1,10 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import GRDB
import Curve25519Kit
import SessionMessagingKit
import SessionUtilitiesKit
final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate { final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate {
private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
@ -71,12 +78,7 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
// Set up tab bar // Set up tab bar
view.addSubview(tabBar) view.addSubview(tabBar)
tabBar.pin(.leading, to: .leading, of: view) tabBar.pin(.leading, to: .leading, of: view)
let tabBarInset: CGFloat let tabBarInset: CGFloat = (UIDevice.current.isIPad ? navigationBar.height() + 20 : navigationBar.height())
if #available(iOS 13, *) {
tabBarInset = UIDevice.current.isIPad ? navigationBar.height() + 20 : navigationBar.height()
} else {
tabBarInset = 0
}
tabBar.pin(.top, to: .top, of: view, withInset: tabBarInset) tabBar.pin(.top, to: .top, of: view, withInset: tabBarInset)
view.pin(.trailing, to: .trailing, of: tabBar) view.pin(.trailing, to: .trailing, of: tabBar)
// Set up page VC constraints // Set up page VC constraints
@ -88,13 +90,7 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
view.pin(.bottom, to: .bottom, of: pageVCView) view.pin(.bottom, to: .bottom, of: pageVCView)
let screen = UIScreen.main.bounds let screen = UIScreen.main.bounds
pageVCView.set(.width, to: screen.width) pageVCView.set(.width, to: screen.width)
let height: CGFloat let height: CGFloat = (navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight)
if #available(iOS 13, *) {
height = navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight
} else {
let statusBarHeight = UIApplication.shared.statusBarFrame.height
height = navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight - statusBarHeight
}
pageVCView.set(.height, to: height) pageVCView.set(.height, to: height)
enterPublicKeyVC.constrainHeight(to: height) enterPublicKeyVC.constrainHeight(to: height)
scanQRCodePlaceholderVC.constrainHeight(to: height) scanQRCodePlaceholderVC.constrainHeight(to: height)
@ -138,38 +134,57 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
} }
fileprivate func startNewDMIfPossible(with onsNameOrPublicKey: String) { fileprivate func startNewDMIfPossible(with onsNameOrPublicKey: String) {
if ECKeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) { let maybeSessionId: SessionId? = SessionId(from: onsNameOrPublicKey)
if ECKeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) && maybeSessionId?.prefix == .standard {
startNewDM(with: onsNameOrPublicKey) startNewDM(with: onsNameOrPublicKey)
} else { return
// This could be an ONS name }
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
SnodeAPI.getSessionID(for: onsNameOrPublicKey).done { sessionID in // This could be an ONS name
modalActivityIndicator.dismiss { ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
self?.startNewDM(with: sessionID) SnodeAPI.getSessionID(for: onsNameOrPublicKey).done { sessionID in
} modalActivityIndicator.dismiss {
}.catch { error in self?.startNewDM(with: sessionID)
modalActivityIndicator.dismiss { }
var messageOrNil: String? }.catch { error in
if let error = error as? SnodeAPI.Error { modalActivityIndicator.dismiss {
switch error { var messageOrNil: String?
case .decryptionFailed, .hashingFailed, .validationFailed: messageOrNil = error.errorDescription if let error = error as? SnodeAPIError {
switch error {
case .decryptionFailed, .hashingFailed, .validationFailed:
messageOrNil = error.errorDescription
default: break default: break
}
} }
let message = messageOrNil ?? "Please check the Session ID or ONS name and try again"
let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
self?.presentAlert(alert)
} }
let message: String = {
if let messageOrNil: String = messageOrNil {
return messageOrNil
}
return (maybeSessionId?.prefix == .blinded ?
"You can only send messages to Blinded IDs from within an Open Group" :
"Please check the Session ID or ONS name and try again"
)
}()
let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
self?.presentAlert(alert)
} }
} }
} }
} }
private func startNewDM(with sessionID: String) { private func startNewDM(with sessionId: String) {
let thread = TSContactThread.getOrCreateThread(contactSessionID: sessionID) let maybeThread: SessionThread? = Storage.shared.write { db in
try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact)
}
guard maybeThread != nil else { return }
presentingViewController?.dismiss(animated: true, completion: nil) presentingViewController?.dismiss(animated: true, completion: nil)
SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false)
SessionApp.presentConversation(for: sessionId, action: .compose, animated: false)
} }
} }

View File

@ -1,10 +1,12 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import PureLayout
import SessionUIKit
import SessionUtilitiesKit
import NVActivityIndicatorView import NVActivityIndicatorView
class EmptySearchResultCell: UITableViewCell { class EmptySearchResultCell: UITableViewCell {
static let reuseIdentifier = "EmptySearchResultCell"
private lazy var messageLabel: UILabel = { private lazy var messageLabel: UILabel = {
let result = UILabel() let result = UILabel()
result.textAlignment = .center result.textAlignment = .center
@ -24,6 +26,7 @@ class EmptySearchResultCell: UITableViewCell {
super.init(style: style, reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .clear backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(messageLabel) contentView.addSubview(messageLabel)
messageLabel.autoSetDimension(.height, toSize: 150) messageLabel.autoSetDimension(.height, toSize: 150)

View File

@ -1,11 +1,42 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit import UIKit
import GRDB
import DifferenceKit
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
import SignalUtilitiesKit
@objc
class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource { class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
fileprivate typealias SectionModel = ArraySection<SearchSection, SessionThreadViewModel>
let isRecentSearchResultsEnabled = false // MARK: - SearchSection
enum SearchSection: Int, Differentiable {
case noResults
case contactsAndGroups
case messages
}
// MARK: - Variables
private lazy var defaultSearchResults: [SectionModel] = {
let result: SessionThreadViewModel? = Storage.shared.read { db -> SessionThreadViewModel? in
try SessionThreadViewModel
.noteToSelfOnlyQuery(userPublicKey: getUserHexEncodedPublicKey(db))
.fetchOne(db)
}
return [ result.map { ArraySection(model: .contactsAndGroups, elements: [$0]) } ]
.compactMap { $0 }
}()
private lazy var searchResultSet: [SectionModel] = self.defaultSearchResults
private var termForCurrentSearchResultSet: String = ""
private var lastSearchText: String?
private var refreshTimer: Timer?
var isLoading = false
@objc public var searchText = "" { @objc public var searchText = "" {
didSet { didSet {
@ -14,55 +45,37 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
refreshSearchResults() refreshSearchResults()
} }
} }
var recentSearchResults: [String] = Array(Storage.shared.getRecentSearchResults().reversed())
var defaultSearchResults: HomeScreenSearchResultSet = HomeScreenSearchResultSet.noteToSelfOnly
var searchResultSet: HomeScreenSearchResultSet = HomeScreenSearchResultSet.empty
private var lastSearchText: String?
var searcher: FullTextSearcher {
return FullTextSearcher.shared
}
var isLoading = false
enum SearchSection: Int { // MARK: - UI Components
case noResults
case contacts
case messages
case recent
}
// MARK: UI Components
internal lazy var searchBar: SearchBar = { internal lazy var searchBar: SearchBar = {
let result = SearchBar() let result: SearchBar = SearchBar()
result.tintColor = Colors.text result.tintColor = Colors.text
result.delegate = self result.delegate = self
result.showsCancelButton = true result.showsCancelButton = true
return result return result
}() }()
internal lazy var tableView: UITableView = { internal lazy var tableView: UITableView = {
let result = UITableView(frame: .zero, style: .grouped) let result: UITableView = UITableView(frame: .zero, style: .grouped)
result.rowHeight = UITableView.automaticDimension result.rowHeight = UITableView.automaticDimension
result.estimatedRowHeight = 60 result.estimatedRowHeight = 60
result.separatorStyle = .none result.separatorStyle = .none
result.keyboardDismissMode = .onDrag result.keyboardDismissMode = .onDrag
result.register(EmptySearchResultCell.self, forCellReuseIdentifier: EmptySearchResultCell.reuseIdentifier) result.register(view: EmptySearchResultCell.self)
result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier) result.register(view: FullConversationCell.self)
result.showsVerticalScrollIndicator = false result.showsVerticalScrollIndicator = false
return result return result
}() }()
// MARK: Dependencies
var dbReadConnection: YapDatabaseConnection { // MARK: - View Lifecycle
return OWSPrimaryStorage.shared().dbReadConnection
}
// MARK: View Lifecycle
public override func viewDidLoad() { public override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
setUpGradientBackground()
setUpGradientBackground()
tableView.dataSource = self tableView.dataSource = self
tableView.delegate = self tableView.delegate = self
view.addSubview(tableView) view.addSubview(tableView)
@ -74,22 +87,22 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
navigationItem.hidesBackButton = true navigationItem.hidesBackButton = true
setupNavigationBar() setupNavigationBar()
} }
public override func viewWillAppear(_ animated: Bool) { public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
searchBar.becomeFirstResponder() searchBar.becomeFirstResponder()
} }
public override func viewWillDisappear(_ animated: Bool) { public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
searchBar.resignFirstResponder() searchBar.resignFirstResponder()
} }
private func setupNavigationBar() { private func setupNavigationBar() {
// This is a workaround for a UI issue that the navigation bar can be a bit higher if // This is a workaround for a UI issue that the navigation bar can be a bit higher if
// the search bar is put directly to be the titleView. And this can cause the tableView // the search bar is put directly to be the titleView. And this can cause the tableView
// in home screen doing a weird scrolling when going back to home screen. // in home screen doing a weird scrolling when going back to home screen.
let searchBarContainer = UIView() let searchBarContainer: UIView = UIView()
searchBarContainer.layoutMargins = UIEdgeInsets.zero searchBarContainer.layoutMargins = UIEdgeInsets.zero
searchBar.sizeToFit() searchBar.sizeToFit()
searchBar.layoutMargins = UIEdgeInsets.zero searchBar.layoutMargins = UIEdgeInsets.zero
@ -103,37 +116,35 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
if UIDevice.current.isIPad { if UIDevice.current.isIPad {
let ipadCancelButton = UIButton() let ipadCancelButton = UIButton()
ipadCancelButton.setTitle("Cancel", for: .normal) ipadCancelButton.setTitle("Cancel", for: .normal)
ipadCancelButton.addTarget(self, action: #selector(cancel(_:)), for: .touchUpInside) ipadCancelButton.addTarget(self, action: #selector(cancel), for: .touchUpInside)
ipadCancelButton.setTitleColor(Colors.text, for: .normal) ipadCancelButton.setTitleColor(Colors.text, for: .normal)
searchBarContainer.addSubview(ipadCancelButton) searchBarContainer.addSubview(ipadCancelButton)
ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer) ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer)
ipadCancelButton.autoVCenterInSuperview() ipadCancelButton.autoVCenterInSuperview()
searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing) searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing)
searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing) searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing)
} else { }
else {
searchBar.autoPinEdgesToSuperviewMargins() searchBar.autoPinEdgesToSuperviewMargins()
} }
} }
private func reloadTableData() { private func reloadTableData() {
tableView.reloadData() tableView.reloadData()
} }
// MARK: Update Search Results
var refreshTimer: Timer? // MARK: - Update Search Results
private func refreshSearchResults() { private func refreshSearchResults() {
refreshTimer?.invalidate() refreshTimer?.invalidate()
refreshTimer = WeakTimer.scheduledTimer(timeInterval: 0.1, target: self, userInfo: nil, repeats: false) { [weak self] _ in refreshTimer = WeakTimer.scheduledTimer(timeInterval: 0.1, target: self, userInfo: nil, repeats: false) { [weak self] _ in
guard let self = self else { return } self?.updateSearchResults(searchText: (self?.searchText ?? ""))
self.updateSearchResults(searchText: self.searchText)
} }
} }
private func updateSearchResults(searchText rawSearchText: String) {
private func updateSearchResults(searchText rawSearchText: String) {
let searchText = rawSearchText.stripped let searchText = rawSearchText.stripped
guard searchText.count > 0 else { guard searchText.count > 0 else {
searchResultSet = defaultSearchResults searchResultSet = defaultSearchResults
lastSearchText = nil lastSearchText = nil
@ -144,56 +155,81 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
lastSearchText = searchText lastSearchText = searchText
var searchResults: HomeScreenSearchResultSet? let result: Result<[SectionModel], Error>? = Storage.shared.read { db -> Result<[SectionModel], Error> in
self.dbReadConnection.asyncRead({[weak self] transaction in do {
guard let self = self else { return } let userPublicKey: String = getUserHexEncodedPublicKey(db)
self.isLoading = true let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel
// The max search result count is set according to the keyword length. This is just a workaround for performance issue. .contactsAndGroupsQuery(
// The longer and more accurate the keyword is, the less search results should there be. userPublicKey: userPublicKey,
searchResults = self.searcher.searchForHomeScreen(searchText: searchText, maxSearchResults: 500, transaction: transaction) pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText),
}, completionBlock: { [weak self] in searchTerm: searchText
AssertIsOnMainThread() )
guard let self = self, let results = searchResults, self.lastSearchText == searchText else { return } .fetchAll(db)
self.searchResultSet = results let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel
self.isLoading = false .messagesQuery(
self.reloadTableData() userPublicKey: userPublicKey,
self.refreshTimer = nil pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText)
}) )
.fetchAll(db)
return .success([
ArraySection(model: .contactsAndGroups, elements: contactsAndGroupsResults),
ArraySection(model: .messages, elements: messageResults)
])
}
catch {
return .failure(error)
}
}
switch result {
case .success(let sections):
let hasResults: Bool = (
!searchText.isEmpty &&
(sections.map { $0.elements.count }.reduce(0, +) > 0)
)
self.termForCurrentSearchResultSet = searchText
self.searchResultSet = [
(hasResults ? nil : [ArraySection(model: .noResults, elements: [SessionThreadViewModel(unreadCount: 0)])]),
(hasResults ? sections : nil)
]
.compactMap { $0 }
.flatMap { $0 }
self.isLoading = false
self.reloadTableData()
self.refreshTimer = nil
default: break
}
} }
// MARK: Interaction @objc func cancel() {
@objc func clearRecentSearchResults() {
recentSearchResults = []
tableView.reloadSections([ SearchSection.recent.rawValue ], with: .top)
Storage.shared.clearRecentSearchResults()
}
@objc func cancel(_ sender: Any) {
self.navigationController?.popViewController(animated: true) self.navigationController?.popViewController(animated: true)
} }
} }
// MARK: - UISearchBarDelegate // MARK: - UISearchBarDelegate
extension GlobalSearchViewController: UISearchBarDelegate { extension GlobalSearchViewController: UISearchBarDelegate {
public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
self.updateSearchText() self.updateSearchText()
} }
public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
self.updateSearchText() self.updateSearchText()
} }
public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
self.updateSearchText() self.updateSearchText()
} }
public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
searchBar.text = nil searchBar.text = nil
searchBar.resignFirstResponder() searchBar.resignFirstResponder()
self.navigationController?.popViewController(animated: true) self.navigationController?.popViewController(animated: true)
} }
func updateSearchText() { func updateSearchText() {
guard let searchText = searchBar.text?.ows_stripped() else { return } guard let searchText = searchBar.text?.ows_stripped() else { return }
self.searchText = searchText self.searchText = searchText
@ -201,53 +237,59 @@ extension GlobalSearchViewController: UISearchBarDelegate {
} }
// MARK: - UITableViewDelegate & UITableViewDataSource // MARK: - UITableViewDelegate & UITableViewDataSource
extension GlobalSearchViewController { extension GlobalSearchViewController {
// MARK: UITableViewDelegate // MARK: - UITableViewDelegate
public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: false) tableView.deselectRow(at: indexPath, animated: false)
guard let searchSection = SearchSection(rawValue: indexPath.section) else { return }
switch searchSection {
case .noResults:
SNLog("shouldn't be able to tap 'no results' section")
case .contacts:
let sectionResults = searchResultSet.conversations
guard let searchResult = sectionResults[safe: indexPath.row] else { return }
show(searchResult.thread.threadRecord, highlightedMessageID: nil, animated: true)
case .messages:
let sectionResults = searchResultSet.messages
guard let searchResult = sectionResults[safe: indexPath.row] else { return }
show(searchResult.thread.threadRecord, highlightedMessageID: searchResult.message?.uniqueId, animated: true)
case .recent:
guard let threadId = recentSearchResults[safe: indexPath.row], let thread = TSThread.fetch(uniqueId: threadId) else { return }
show(thread, highlightedMessageID: nil, animated: true, isFromRecent: true)
}
}
private func show(_ thread: TSThread, highlightedMessageID: String?, animated: Bool, isFromRecent: Bool = false) {
if let threadId = thread.uniqueId {
recentSearchResults = Array(Storage.shared.addSearchResults(threadID: threadId).reversed())
}
DispatchMainThreadSafe { let section: SectionModel = self.searchResultSet[indexPath.section]
if let presentedVC = self.presentedViewController {
presentedVC.dismiss(animated: false, completion: nil) switch section.model {
} case .noResults: break
let conversationVC = ConversationVC(thread: thread, focusedMessageID: highlightedMessageID) case .contactsAndGroups, .messages:
var viewControllers = self.navigationController?.viewControllers show(
if isFromRecent, let index = viewControllers?.firstIndex(of: self) { viewControllers?.remove(at: index) } threadId: section.elements[indexPath.row].threadId,
viewControllers?.append(conversationVC) threadVariant: section.elements[indexPath.row].threadVariant,
self.navigationController?.setViewControllers(viewControllers!, animated: true) focusedInteractionId: section.elements[indexPath.row].interactionId
)
} }
} }
// MARK: UITableViewDataSource private func show(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64? = nil, animated: Bool = true) {
guard Thread.isMainThread else {
DispatchQueue.main.async { [weak self] in
self?.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId, animated: animated)
}
return
}
if let presentedVC = self.presentedViewController {
presentedVC.dismiss(animated: false, completion: nil)
}
let viewControllers: [UIViewController] = (self.navigationController?
.viewControllers)
.defaulting(to: [])
.appending(
ConversationVC(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId)
)
self.navigationController?.setViewControllers(viewControllers, animated: true)
}
// MARK: - UITableViewDataSource
public func numberOfSections(in tableView: UITableView) -> Int { public func numberOfSections(in tableView: UITableView) -> Int {
return 4 return self.searchResultSet.count
} }
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.searchResultSet[section].elements.count
}
public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
UIView() UIView()
} }
@ -260,79 +302,36 @@ extension GlobalSearchViewController {
guard nil != self.tableView(tableView, titleForHeaderInSection: section) else { guard nil != self.tableView(tableView, titleForHeaderInSection: section) else {
return .leastNonzeroMagnitude return .leastNonzeroMagnitude
} }
return UITableView.automaticDimension return UITableView.automaticDimension
} }
public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
guard let searchSection = SearchSection(rawValue: section) else { return nil } guard let title: String = self.tableView(tableView, titleForHeaderInSection: section) else {
guard let title = self.tableView(tableView, titleForHeaderInSection: section) else {
return UIView() return UIView()
} }
let titleLabel = UILabel() let titleLabel = UILabel()
titleLabel.text = title titleLabel.text = title
titleLabel.textColor = Colors.text titleLabel.textColor = Colors.text
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize) titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
let container = UIView() let container = UIView()
container.backgroundColor = Colors.cellBackground container.backgroundColor = Colors.cellBackground
container.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, left: Values.mediumSpacing, bottom: Values.smallSpacing, right: Values.mediumSpacing) container.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, left: Values.mediumSpacing, bottom: Values.smallSpacing, right: Values.mediumSpacing)
container.addSubview(titleLabel) container.addSubview(titleLabel)
titleLabel.autoPinEdgesToSuperviewMargins() titleLabel.autoPinEdgesToSuperviewMargins()
if searchSection == .recent {
let clearButton = UIButton()
clearButton.setTitle("Clear", for: .normal)
clearButton.setTitleColor(Colors.text, for: UIControl.State.normal)
clearButton.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize)
clearButton.addTarget(self, action: #selector(clearRecentSearchResults), for: .touchUpInside)
container.addSubview(clearButton)
clearButton.autoPinTrailingToSuperviewMargin()
clearButton.autoVCenterInSuperview()
}
return container return container
} }
public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
guard let searchSection = SearchSection(rawValue: section) else { return nil } let section: SectionModel = self.searchResultSet[section]
switch searchSection { switch section.model {
case .noResults: case .noResults: return nil
return nil case .contactsAndGroups: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_CONTACTS".localized())
case .contacts: case .messages: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_MESSAGES".localized())
if searchResultSet.conversations.count > 0 {
return NSLocalizedString("SEARCH_SECTION_CONTACTS", comment: "")
} else {
return nil
}
case .messages:
if searchResultSet.messages.count > 0 {
return NSLocalizedString("SEARCH_SECTION_MESSAGES", comment: "")
} else {
return nil
}
case .recent:
if recentSearchResults.count > 0 && searchText.isEmpty && isRecentSearchResultsEnabled {
return NSLocalizedString("SEARCH_SECTION_RECENT", comment: "")
} else {
return nil
}
}
}
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
guard let searchSection = SearchSection(rawValue: section) else { return 0 }
switch searchSection {
case .noResults:
return (searchText.count > 0 && searchResultSet.isEmpty) ? 1 : 0
case .contacts:
return searchResultSet.conversations.count
case .messages:
return searchResultSet.messages.count
case .recent:
return searchText.isEmpty && isRecentSearchResultsEnabled ? recentSearchResults.count : 0
} }
} }
@ -341,41 +340,23 @@ extension GlobalSearchViewController {
} }
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let section: SectionModel = self.searchResultSet[indexPath.section]
guard let searchSection = SearchSection(rawValue: indexPath.section) else {
return UITableViewCell() switch section.model {
} case .noResults:
let cell: EmptySearchResultCell = tableView.dequeue(type: EmptySearchResultCell.self, for: indexPath)
switch searchSection { cell.configure(isLoading: isLoading)
case .noResults: return cell
guard let cell = tableView.dequeueReusableCell(withIdentifier: EmptySearchResultCell.reuseIdentifier) as? EmptySearchResultCell, indexPath.row == 0 else { return UITableViewCell() }
cell.configure(isLoading: isLoading) case .contactsAndGroups:
return cell let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
case .contacts: cell.updateForContactAndGroupSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet)
let sectionResults = searchResultSet.conversations return cell
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
cell.isShowingGlobalSearchResult = true case .messages:
let searchResult = sectionResults[safe: indexPath.row] let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
cell.threadViewModel = searchResult?.thread cell.updateForMessageSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet)
cell.configure(snippet: searchResult?.snippet, searchText: searchResultSet.searchText) return cell
return cell
case .messages:
let sectionResults = searchResultSet.messages
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
cell.isShowingGlobalSearchResult = true
let searchResult = sectionResults[safe: indexPath.row]
cell.threadViewModel = searchResult?.thread
cell.configure(snippet: searchResult?.snippet, searchText: searchResultSet.searchText, message: searchResult?.message)
return cell
case .recent:
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
cell.isShowingGlobalSearchResult = true
dbReadConnection.read { transaction in
guard let threadId = self.recentSearchResults[safe: indexPath.row], let thread = TSThread.fetch(uniqueId: threadId, transaction: transaction) else { return }
cell.threadViewModel = ThreadViewModel(thread: thread, transaction: transaction)
}
cell.configureForRecent()
return cell
} }
} }
} }

View File

@ -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

View File

@ -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
}
}

View File

@ -1,49 +1,60 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit import UIKit
import GRDB
import DifferenceKit
import SessionUIKit import SessionUIKit
import SessionMessagingKit import SessionMessagingKit
import SignalUtilitiesKit
@objc
class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource { class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
private var threads: YapDatabaseViewMappings! = { private static let loadingHeaderHeight: CGFloat = 20
let result = YapDatabaseViewMappings(groups: [ TSMessageRequestGroup ], view: TSThreadDatabaseViewExtensionName)
result.setIsReversed(true, forGroup: TSMessageRequestGroup)
return result
}()
private var threadViewModelCache: [String: ThreadViewModel] = [:] // Thread ID to ThreadViewModel
private var tableViewTopConstraint: NSLayoutConstraint!
private var messageRequestCount: UInt { private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
threads.numberOfItems(inGroup: TSMessageRequestGroup) private var dataChangeObservable: DatabaseCancellable?
private var hasLoadedInitialThreadData: Bool = false
private var isLoadingMore: Bool = false
private var isAutoLoadingNextPage: Bool = false
private var viewHasAppeared: Bool = false
// MARK: - Intialization
init() {
Storage.shared.addObserver(viewModel.pagedDataObserver)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
preconditionFailure("Use init() instead.")
} }
private lazy var dbConnection: YapDatabaseConnection = { deinit {
let result = OWSPrimaryStorage.shared().newDatabaseConnection() NotificationCenter.default.removeObserver(self)
result.objectCacheLimit = 500 }
return result
}()
// MARK: - UI // MARK: - UI
private lazy var tableView: UITableView = { private lazy var tableView: UITableView = {
let result: UITableView = UITableView() let result: UITableView = UITableView()
result.translatesAutoresizingMaskIntoConstraints = false result.translatesAutoresizingMaskIntoConstraints = false
result.backgroundColor = .clear result.backgroundColor = .clear
result.separatorStyle = .none result.separatorStyle = .none
result.register(MessageRequestsCell.self, forCellReuseIdentifier: MessageRequestsCell.reuseIdentifier) result.register(view: FullConversationCell.self)
result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier)
result.dataSource = self result.dataSource = self
result.delegate = self result.delegate = self
let bottomInset = Values.newConversationButtonBottomOffset + NewConversationButtonSet.expandedButtonSize + Values.largeSpacing + NewConversationButtonSet.collapsedButtonSize let bottomInset = Values.newConversationButtonBottomOffset + NewConversationButtonSet.expandedButtonSize + Values.largeSpacing + NewConversationButtonSet.collapsedButtonSize
result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0) result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
result.showsVerticalScrollIndicator = false result.showsVerticalScrollIndicator = false
if #available(iOS 15.0, *) {
result.sectionHeaderTopPadding = 0
}
return result return result
}() }()
private lazy var emptyStateLabel: UILabel = { private lazy var emptyStateLabel: UILabel = {
let result: UILabel = UILabel() let result: UILabel = UILabel()
result.translatesAutoresizingMaskIntoConstraints = false result.translatesAutoresizingMaskIntoConstraints = false
@ -54,19 +65,19 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
result.textAlignment = .center result.textAlignment = .center
result.numberOfLines = 0 result.numberOfLines = 0
result.isHidden = true result.isHidden = true
return result return result
}() }()
private lazy var fadeView: UIView = { private lazy var fadeView: UIView = {
let result: UIView = UIView() let result: UIView = UIView()
result.translatesAutoresizingMaskIntoConstraints = false result.translatesAutoresizingMaskIntoConstraints = false
result.isUserInteractionEnabled = false result.isUserInteractionEnabled = false
result.setGradient(Gradients.homeVCFade) result.setGradient(Gradients.homeVCFade)
return result return result
}() }()
private lazy var clearAllButton: Button = { private lazy var clearAllButton: Button = {
let result: Button = Button(style: .destructiveOutline, size: .large) let result: Button = Button(style: .destructiveOutline, size: .large)
result.translatesAutoresizingMaskIntoConstraints = false result.translatesAutoresizingMaskIntoConstraints = false
@ -78,17 +89,21 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
for: .highlighted for: .highlighted
) )
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside) result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
return result return result
}() }()
// MARK: - Lifecycle // MARK: - Lifecycle
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: NSLocalizedString("MESSAGE_REQUESTS_TITLE", comment: ""), hasCustomBackButton: false) ViewControllerUtilities.setUpDefaultSessionStyle(
for: self,
title: "MESSAGE_REQUESTS_TITLE".localized(),
hasCustomBackButton: false
)
// Add the UI (MUST be done after the thread freeze so the 'tableView' creation and setting // Add the UI (MUST be done after the thread freeze so the 'tableView' creation and setting
// the dataSource has the correct data) // the dataSource has the correct data)
view.addSubview(tableView) view.addSubview(tableView)
@ -96,58 +111,69 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
view.addSubview(fadeView) view.addSubview(fadeView)
view.addSubview(clearAllButton) view.addSubview(clearAllButton)
setupLayout() setupLayout()
// Notifications // Notifications
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
selector: #selector(handleYapDatabaseModifiedNotification(_:)), selector: #selector(applicationDidBecomeActive(_:)),
name: .YapDatabaseModified, name: UIApplication.didBecomeActiveNotification,
object: OWSPrimaryStorage.shared().dbNotificationObject
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleProfileDidChangeNotification(_:)),
name: NSNotification.Name(rawValue: kNSNotificationName_OtherUsersProfileDidChange),
object: nil object: nil
) )
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
selector: #selector(handleBlockedContactsUpdatedNotification(_:)), selector: #selector(applicationDidResignActive(_:)),
name: .blockedContactsUpdated, name: UIApplication.didEnterBackgroundNotification, object: nil
object: nil
) )
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
reload() startObservingChanges()
} }
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
reload()
self.viewHasAppeared = true
self.autoLoadNextPageIfNeeded()
} }
deinit { override func viewWillDisappear(_ animated: Bool) {
NotificationCenter.default.removeObserver(self) super.viewWillDisappear(animated)
// Stop observing database changes
dataChangeObservable?.cancel()
} }
@objc func applicationDidBecomeActive(_ notification: Notification) {
startObservingChanges(didReturnFromBackground: true)
}
@objc func applicationDidResignActive(_ notification: Notification) {
// Stop observing database changes
dataChangeObservable?.cancel()
}
// MARK: - Layout // MARK: - Layout
private func setupLayout() { private func setupLayout() {
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.smallSpacing), tableView.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.smallSpacing),
tableView.leftAnchor.constraint(equalTo: view.leftAnchor), tableView.leftAnchor.constraint(equalTo: view.leftAnchor),
tableView.rightAnchor.constraint(equalTo: view.rightAnchor), tableView.rightAnchor.constraint(equalTo: view.rightAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
emptyStateLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.massiveSpacing), emptyStateLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.massiveSpacing),
emptyStateLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.mediumSpacing), emptyStateLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.mediumSpacing),
emptyStateLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.mediumSpacing), emptyStateLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.mediumSpacing),
emptyStateLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), emptyStateLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
fadeView.topAnchor.constraint(equalTo: view.topAnchor, constant: (0.15 * view.bounds.height)), fadeView.topAnchor.constraint(equalTo: view.topAnchor, constant: (0.15 * view.bounds.height)),
fadeView.leftAnchor.constraint(equalTo: view.leftAnchor), fadeView.leftAnchor.constraint(equalTo: view.leftAnchor),
fadeView.rightAnchor.constraint(equalTo: view.rightAnchor), fadeView.rightAnchor.constraint(equalTo: view.rightAnchor),
fadeView.bottomAnchor.constraint(equalTo: view.bottomAnchor), fadeView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
clearAllButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), clearAllButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
clearAllButton.bottomAnchor.constraint( clearAllButton.bottomAnchor.constraint(
equalTo: view.safeAreaLayoutGuide.bottomAnchor, equalTo: view.safeAreaLayoutGuide.bottomAnchor,
@ -158,277 +184,285 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
]) ])
} }
// MARK: - UITableViewDataSource
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return Int(messageRequestCount)
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
cell.threadViewModel = threadViewModel(at: indexPath.row)
return cell
}
// MARK: - Updating // MARK: - Updating
private func reload() { private func startObservingChanges(didReturnFromBackground: Bool = false) {
AssertIsOnMainThread() self.viewModel.onThreadChange = { [weak self] updatedThreadData in
dbConnection.beginLongLivedReadTransaction() // Jump to the latest commit self?.handleThreadUpdates(updatedThreadData)
dbConnection.read { transaction in }
self.threads.update(with: transaction)
// Note: When returning from the background we could have received notifications but the
// PagedDatabaseObserver won't have them so we need to force a re-fetch of the current
// data to ensure everything is up to date
if didReturnFromBackground {
self.viewModel.pagedDataObserver?.reload()
} }
threadViewModelCache.removeAll()
tableView.reloadData()
clearAllButton.isHidden = (messageRequestCount == 0)
emptyStateLabel.isHidden = (messageRequestCount != 0)
} }
@objc private func handleYapDatabaseModifiedNotification(_ yapDatabase: YapDatabase) { private func handleThreadUpdates(_ updatedData: [MessageRequestsViewModel.SectionModel], initialLoad: Bool = false) {
// NOTE: This code is very finicky and crashes easily. Modify with care. // Ensure the first load runs without animations (if we don't do this the cells will animate
AssertIsOnMainThread() // in from a frame of CGRect.zero)
guard hasLoadedInitialThreadData else {
hasLoadedInitialThreadData = true
UIView.performWithoutAnimation { handleThreadUpdates(updatedData, initialLoad: true) }
return
}
// If we don't capture `threads` here, a race condition can occur where the // Show the empty state if there is no data
// `thread.snapshotOfLastUpdate != firstSnapshot - 1` check below evaluates to clearAllButton.isHidden = updatedData.isEmpty
// `false`, but `threads` then changes between that check and the emptyStateLabel.isHidden = !updatedData.isEmpty
// `ext.getSectionChanges(&sectionChanges, rowChanges: &rowChanges, for: notifications, with: threads)`
// line. This causes `tableView.endUpdates()` to crash with an `NSInternalInconsistencyException`.
let threads = threads!
// Create a stable state for the connection and jump to the latest commit CATransaction.begin()
let notifications = dbConnection.beginLongLivedReadTransaction() CATransaction.setCompletionBlock { [weak self] in
// Complete page loading
self?.isLoadingMore = false
self?.autoLoadNextPageIfNeeded()
}
guard !notifications.isEmpty else { return } // Reload the table content (animate changes after the first load)
tableView.reload(
using: StagedChangeset(source: viewModel.threadData, target: updatedData),
deleteSectionsAnimation: .none,
insertSectionsAnimation: .none,
reloadSectionsAnimation: .none,
deleteRowsAnimation: .bottom,
insertRowsAnimation: .top,
reloadRowsAnimation: .none,
interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues
) { [weak self] updatedData in
self?.viewModel.updateThreadData(updatedData)
}
let ext = dbConnection.ext(TSThreadDatabaseViewExtensionName) as! YapDatabaseViewConnection CATransaction.commit()
let hasChanges = ext.hasChanges(forGroup: TSMessageRequestGroup, in: notifications) }
private func autoLoadNextPageIfNeeded() {
guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return }
guard hasChanges else { return } self.isAutoLoadingNextPage = true
if let firstChangeSet = notifications[0].userInfo { DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in
let firstSnapshot = firstChangeSet[YapDatabaseSnapshotKey] as! UInt64 self?.isAutoLoadingNextPage = false
if threads.snapshotOfLastUpdate != firstSnapshot - 1 { // Note: We sort the headers as we want to prioritise loading newer pages over older ones
return reload() // The code below will crash if we try to process multiple commits at once let sections: [(MessageRequestsViewModel.Section, CGRect)] = (self?.viewModel.threadData
} .enumerated()
} .map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) })
.defaulting(to: [])
var sectionChanges = NSArray() let shouldLoadMore: Bool = sections
var rowChanges = NSArray() .contains { section, headerRect in
ext.getSectionChanges(&sectionChanges, rowChanges: &rowChanges, for: notifications, with: threads) section == .loadMore &&
headerRect != .zero &&
guard sectionChanges.count > 0 || rowChanges.count > 0 else { return } (self?.tableView.bounds.contains(headerRect) == true)
}
tableView.beginUpdates()
rowChanges.forEach { rowChange in
let rowChange = rowChange as! YapDatabaseViewRowChange
let key = rowChange.collectionKey.key
threadViewModelCache[key] = nil
switch rowChange.type {
case .delete: tableView.deleteRows(at: [ rowChange.indexPath! ], with: UITableView.RowAnimation.automatic)
case .insert: tableView.insertRows(at: [ rowChange.newIndexPath! ], with: UITableView.RowAnimation.automatic)
case .update: tableView.reloadRows(at: [ rowChange.indexPath! ], with: UITableView.RowAnimation.automatic)
default: break
}
}
tableView.endUpdates()
// HACK: Moves can have conflicts with the other 3 types of change.
// Just batch perform all the moves separately to prevent crashing.
// Since all the changes are from the original state to the final state,
// it will still be correct if we pick the moves out.
tableView.beginUpdates()
rowChanges.forEach { rowChange in
let rowChange = rowChange as! YapDatabaseViewRowChange
let key = rowChange.collectionKey.key
threadViewModelCache[key] = nil
switch rowChange.type { guard shouldLoadMore else { return }
case .move: tableView.moveRow(at: rowChange.indexPath!, to: rowChange.newIndexPath!)
default: break self?.isLoadingMore = true
DispatchQueue.global(qos: .default).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(.pageAfter)
} }
} }
tableView.endUpdates()
clearAllButton.isHidden = (messageRequestCount == 0)
emptyStateLabel.isHidden = (messageRequestCount != 0)
}
@objc private func handleProfileDidChangeNotification(_ notification: Notification) {
tableView.reloadData() // TODO: Just reload the affected cell
}
@objc private func handleBlockedContactsUpdatedNotification(_ notification: Notification) {
tableView.reloadData() // TODO: Just reload the affected cell
} }
@objc override internal func handleAppModeChangedNotification(_ notification: Notification) { @objc override internal func handleAppModeChangedNotification(_ notification: Notification) {
super.handleAppModeChangedNotification(notification) super.handleAppModeChangedNotification(notification)
let gradient = Gradients.homeVCFade let gradient = Gradients.homeVCFade
fadeView.setGradient(gradient) // Re-do the gradient fadeView.setGradient(gradient) // Re-do the gradient
tableView.reloadData() tableView.reloadData()
} }
// MARK: - UITableViewDataSource
func numberOfSections(in tableView: UITableView) -> Int {
return viewModel.threadData.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
return section.elements.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[indexPath.section]
switch section.model {
case .threads:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
cell.update(with: threadViewModel)
return cell
default: preconditionFailure("Other sections should have no content")
}
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
switch section.model {
case .loadMore:
let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
loadingIndicator.tintColor = Colors.text
loadingIndicator.alpha = 0.5
loadingIndicator.startAnimating()
let view: UIView = UIView()
view.addSubview(loadingIndicator)
loadingIndicator.center(in: view)
return view
default: return nil
}
}
// MARK: - UITableViewDelegate // MARK: - UITableViewDelegate
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
switch section.model {
case .loadMore: return MessageRequestsViewController.loadingHeaderHeight
default: return 0
}
}
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
guard self.hasLoadedInitialThreadData && self.viewHasAppeared && !self.isLoadingMore else { return }
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[section]
switch section.model {
case .loadMore:
self.isLoadingMore = true
DispatchQueue.global(qos: .default).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(.pageAfter)
}
default: break
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true) tableView.deselectRow(at: indexPath, animated: true)
guard let thread = self.thread(at: indexPath.row) else { return } let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
let conversationVC = ConversationVC(thread: thread) switch section.model {
self.navigationController?.pushViewController(conversationVC, animated: true) case .threads:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let conversationVC: ConversationVC = ConversationVC(
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant
)
self.navigationController?.pushViewController(conversationVC, animated: true)
default: break
}
} }
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true return true
} }
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
guard let thread = self.thread(at: indexPath.row) else { return [] } let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
let delete = UITableViewRowAction(style: .destructive, title: NSLocalizedString("TXT_DELETE_TITLE", comment: "")) { [weak self] _, _ in switch section.model {
self?.delete(thread) case .threads:
let threadId: String = section.elements[indexPath.row].threadId
let delete = UITableViewRowAction(
style: .destructive,
title: "TXT_DELETE_TITLE".localized()
) { [weak self] _, _ in
self?.delete(threadId)
}
delete.backgroundColor = Colors.destructive
return [ delete ]
default: return []
} }
delete.backgroundColor = Colors.destructive
return [ delete ]
} }
// MARK: - Interaction // MARK: - Interaction
private func updateContactAndThread(thread: TSThread, with transaction: YapDatabaseReadWriteTransaction, onComplete: ((Bool) -> ())? = nil) { @objc private func clearAllTapped() {
guard let contactThread: TSContactThread = thread as? TSContactThread else { guard viewModel.threadData.first(where: { $0.model == .threads })?.elements.isEmpty == false else {
onComplete?(false)
return return
} }
var needsSync: Bool = false let threadIds: [String] = (viewModel.threadData
.first { $0.model == .threads }?
// Update the contact .elements
let sessionId: String = contactThread.contactSessionID() .map { $0.threadId })
.defaulting(to: [])
if let contact: Contact = Storage.shared.getContact(with: sessionId), (contact.isApproved || !contact.isBlocked) { let alertVC: UIAlertController = UIAlertController(
contact.isApproved = false title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE".localized(),
contact.isBlocked = true message: nil,
preferredStyle: .actionSheet
Storage.shared.setContact(contact, using: transaction) )
needsSync = true alertVC.addAction(UIAlertAction(
} title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON".localized(),
style: .destructive
// Delete all thread content ) { _ in
thread.removeAllThreadInteractions(with: transaction)
thread.remove(with: transaction)
onComplete?(needsSync)
}
@objc private func clearAllTapped() {
let threadCount: Int = Int(messageRequestCount)
let threads: [TSThread] = (0..<threadCount).compactMap { self.thread(at: $0) }
var needsSync: Bool = false
let alertVC: UIAlertController = UIAlertController(title: NSLocalizedString("MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE", comment: ""), message: nil, preferredStyle: .actionSheet)
alertVC.addAction(UIAlertAction(title: NSLocalizedString("MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON", comment: ""), style: .destructive) { _ in
// Clear the requests // Clear the requests
Storage.write( Storage.shared.write { db in
with: { [weak self] transaction in _ = try SessionThread
threads.forEach { thread in .filter(ids: threadIds)
if let uniqueId: String = thread.uniqueId { .deleteAll(db)
Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction)
} try threadIds.forEach { threadId in
_ = try Contact
self?.updateContactAndThread(thread: thread, with: transaction) { threadNeedsSync in .fetchOrCreate(db, id: threadId)
if threadNeedsSync { .with(
needsSync = true isApproved: false,
} isBlocked: true
} )
.saved(db)
// Block the contact
if
let sessionId: String = (thread as? TSContactThread)?.contactSessionID(),
!thread.isBlocked(),
let contact: Contact = Storage.shared.getContact(with: sessionId, using: transaction)
{
contact.isBlocked = true
Storage.shared.setContact(contact, using: transaction)
needsSync = true
}
}
},
completion: {
// Force a config sync
if needsSync {
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
}
} }
)
}) // Force a config sync
alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .cancel, handler: nil)) try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
self.present(alertVC, animated: true, completion: nil)
}
private func delete(_ thread: TSThread) {
guard let uniqueId: String = thread.uniqueId else { return }
let alertVC: UIAlertController = UIAlertController(title: NSLocalizedString("MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON", comment: ""), message: nil, preferredStyle: .actionSheet)
alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_DELETE_TITLE", comment: ""), style: .destructive) { _ in
Storage.write(
with: { [weak self] transaction in
Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction)
self?.updateContactAndThread(thread: thread, with: transaction)
// Block the contact
if
let sessionId: String = (thread as? TSContactThread)?.contactSessionID(),
!thread.isBlocked(),
let contact: Contact = Storage.shared.getContact(with: sessionId, using: transaction)
{
contact.isBlocked = true
Storage.shared.setContact(contact, using: transaction)
}
},
completion: {
// Force a config sync
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
}
)
})
alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .cancel, handler: nil))
self.present(alertVC, animated: true, completion: nil)
}
// MARK: - Convenience
private func thread(at index: Int) -> TSThread? {
var thread: TSThread? = nil
dbConnection.read { transaction in
let ext: YapDatabaseViewTransaction? = transaction.ext(TSThreadDatabaseViewExtensionName) as? YapDatabaseViewTransaction
thread = ext?.object(atRow: UInt(index), inSection: 0, with: self.threads) as? TSThread
}
return thread
}
private func threadViewModel(at index: Int) -> ThreadViewModel? {
guard let thread = thread(at: index), let uniqueId: String = thread.uniqueId else { return nil }
if let cachedThreadViewModel = threadViewModelCache[uniqueId] {
return cachedThreadViewModel
}
else {
var threadViewModel: ThreadViewModel? = nil
dbConnection.read { transaction in
threadViewModel = ThreadViewModel(thread: thread, transaction: transaction)
} }
threadViewModelCache[uniqueId] = threadViewModel })
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
return threadViewModel self.present(alertVC, animated: true, completion: nil)
} }
private func delete(_ threadId: String) {
let alertVC: UIAlertController = UIAlertController(
title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(),
message: nil,
preferredStyle: .actionSheet
)
alertVC.addAction(UIAlertAction(
title: "TXT_DELETE_TITLE".localized(),
style: .destructive
) { _ in
Storage.shared.write { db in
_ = try SessionThread
.filter(id: threadId)
.deleteAll(db)
_ = try Contact
.fetchOrCreate(db, id: threadId)
.with(
isApproved: false,
isBlocked: true
)
.saved(db)
// Force a config sync
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
}
})
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
self.present(alertVC, animated: true, completion: nil)
} }
} }

View File

@ -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
}
}

View File

@ -60,7 +60,7 @@ class MessageRequestsCell: UITableViewCell {
result.translatesAutoresizingMaskIntoConstraints = false result.translatesAutoresizingMaskIntoConstraints = false
result.clipsToBounds = true result.clipsToBounds = true
result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity) result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity)
result.layer.cornerRadius = (ConversationCell.unreadCountViewSize / 2) result.layer.cornerRadius = (FullConversationCell.unreadCountViewSize / 2)
return result return result
}() }()
@ -115,8 +115,8 @@ class MessageRequestsCell: UITableViewCell {
unreadCountView.leftAnchor.constraint(equalTo: titleLabel.rightAnchor, constant: (Values.smallSpacing / 2)), unreadCountView.leftAnchor.constraint(equalTo: titleLabel.rightAnchor, constant: (Values.smallSpacing / 2)),
unreadCountView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), unreadCountView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
unreadCountView.widthAnchor.constraint(equalToConstant: ConversationCell.unreadCountViewSize), unreadCountView.widthAnchor.constraint(equalToConstant: FullConversationCell.unreadCountViewSize),
unreadCountView.heightAnchor.constraint(equalToConstant: ConversationCell.unreadCountViewSize), unreadCountView.heightAnchor.constraint(equalToConstant: FullConversationCell.unreadCountViewSize),
unreadCountLabel.topAnchor.constraint(equalTo: unreadCountView.topAnchor), unreadCountLabel.topAnchor.constraint(equalTo: unreadCountView.topAnchor),
unreadCountLabel.leftAnchor.constraint(equalTo: unreadCountView.leftAnchor), unreadCountLabel.leftAnchor.constraint(equalTo: unreadCountView.leftAnchor),

View File

@ -4,7 +4,7 @@
import Foundation import Foundation
protocol GifPickerLayoutDelegate: class { protocol GifPickerLayoutDelegate: AnyObject {
func imageInfosForLayout() -> [GiphyImageInfo] func imageInfosForLayout() -> [GiphyImageInfo]
} }

View File

@ -8,11 +8,6 @@ import SignalUtilitiesKit
import PromiseKit import PromiseKit
import SessionUIKit import SessionUIKit
@objc
protocol GifPickerViewControllerDelegate: class {
func gifPickerDidSelect(attachment: SignalAttachment)
}
class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate, GifPickerLayoutDelegate { class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate, GifPickerLayoutDelegate {
// MARK: Properties // MARK: Properties
@ -31,11 +26,8 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
var lastQuery: String = "" var lastQuery: String = ""
@objc
public weak var delegate: GifPickerViewControllerDelegate? public weak var delegate: GifPickerViewControllerDelegate?
let thread: TSThread
let searchBar: SearchBar let searchBar: SearchBar
let layout: GifPickerLayout let layout: GifPickerLayout
let collectionView: UICollectionView let collectionView: UICollectionView
@ -51,17 +43,14 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
var progressiveSearchTimer: Timer? var progressiveSearchTimer: Timer?
// MARK: Initializers // MARK: - Initialization
@available(*, unavailable, message:"use other constructor instead.") @available(*, unavailable, message:"use other constructor instead.")
required init?(coder aDecoder: NSCoder) { required init?(coder aDecoder: NSCoder) {
notImplemented() notImplemented()
} }
@objc required init() {
required init(thread: TSThread) {
self.thread = thread
self.searchBar = SearchBar() self.searchBar = SearchBar()
self.layout = GifPickerLayout() self.layout = GifPickerLayout()
self.collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: self.layout) self.collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: self.layout)
@ -116,7 +105,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
// Loki: Customize title // Loki: Customize title
let titleLabel = UILabel() let titleLabel = UILabel()
titleLabel.text = NSLocalizedString("GIF", comment: "") titleLabel.text = "accessibility_gif_button".localized().uppercased()
titleLabel.textColor = Colors.text titleLabel.textColor = Colors.text
titleLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize) titleLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
navigationItem.titleView = titleLabel navigationItem.titleView = titleLabel
@ -469,8 +458,8 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
progressiveSearchTimer = nil progressiveSearchTimer = nil
guard let text = searchBar.text else { guard let text = searchBar.text else {
OWSAlerts.showErrorAlert(message: NSLocalizedString("GIF_PICKER_VIEW_MISSING_QUERY", // Alert message shown when user tries to search for GIFs without entering any search terms
comment: "Alert message shown when user tries to search for GIFs without entering any search terms.")) OWSAlerts.showErrorAlert(message: "GIF_PICKER_VIEW_MISSING_QUERY".localized())
return return
} }
@ -556,3 +545,9 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
layout.invalidateLayout() layout.invalidateLayout()
} }
} }
// MARK: - GifPickerViewControllerDelegate
protocol GifPickerViewControllerDelegate: AnyObject {
func gifPickerDidSelect(attachment: SignalAttachment)
}

View File

@ -5,6 +5,7 @@
import Foundation import Foundation
import Photos import Photos
import PromiseKit import PromiseKit
import SessionUIKit
protocol ImagePickerGridControllerDelegate: AnyObject { protocol ImagePickerGridControllerDelegate: AnyObject {
func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController) func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController)
@ -46,6 +47,8 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
self.view.backgroundColor = Colors.navigationBarBackground
library.add(delegate: self) library.add(delegate: self)
@ -54,12 +57,11 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
return return
} }
collectionView.register(PhotoGridViewCell.self, forCellWithReuseIdentifier: PhotoGridViewCell.reuseIdentifier) collectionView.register(view: PhotoGridViewCell.self)
// ensure images at the end of the list can be scrolled above the bottom buttons // ensure images at the end of the list can be scrolled above the bottom buttons
let bottomButtonInset = -1 * SendMediaNavigationController.bottomButtonsCenterOffset + SendMediaNavigationController.bottomButtonWidth / 2 + 16 let bottomButtonInset = -1 * SendMediaNavigationController.bottomButtonsCenterOffset + SendMediaNavigationController.bottomButtonWidth / 2 + 16
collectionView.contentInset.bottom = bottomButtonInset + 16 collectionView.contentInset.bottom = bottomButtonInset + 16
view.backgroundColor = .white
// The PhotoCaptureVC needs a shadow behind it's cancel button, so we use a custom icon. // The PhotoCaptureVC needs a shadow behind it's cancel button, so we use a custom icon.
// This VC has a visible navbar so doesn't need the shadow, but because the user can // This VC has a visible navbar so doesn't need the shadow, but because the user can
@ -69,24 +71,16 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
let cancelImage = UIImage(imageLiteralResourceName: "X") let cancelImage = UIImage(imageLiteralResourceName: "X")
let cancelButton = UIBarButtonItem(image: cancelImage, style: .plain, target: self, action: #selector(didPressCancel)) let cancelButton = UIBarButtonItem(image: cancelImage, style: .plain, target: self, action: #selector(didPressCancel))
cancelButton.tintColor = .black cancelButton.tintColor = Colors.text
navigationItem.leftBarButtonItem = cancelButton navigationItem.leftBarButtonItem = cancelButton
let titleView = TitleView() let titleView = TitleView()
titleView.delegate = self titleView.delegate = self
titleView.text = photoCollection.localizedTitle() titleView.text = photoCollection.localizedTitle()
if #available(iOS 11, *) {
// do nothing
} else {
// must assign titleView frame manually on older iOS
titleView.frame = CGRect(origin: .zero, size: titleView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize))
}
navigationItem.titleView = titleView navigationItem.titleView = titleView
self.titleView = titleView self.titleView = titleView
collectionView.backgroundColor = .white collectionView.backgroundColor = Colors.navigationBarBackground
let selectionPanGesture = DirectionalPanGestureRecognizer(direction: [.horizontal], target: self, action: #selector(didPanSelection)) let selectionPanGesture = DirectionalPanGestureRecognizer(direction: [.horizontal], target: self, action: #selector(didPanSelection))
selectionPanGesture.delegate = self selectionPanGesture.delegate = self
@ -200,16 +194,15 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
// Loki: Set navigation bar background color let backgroundImage: UIImage = UIImage(color: Colors.navigationBarBackground)
let navigationBar = navigationController!.navigationBar self.navigationItem.title = nil
navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default) self.navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default)
navigationBar.shadowImage = UIImage() self.navigationController?.navigationBar.shadowImage = UIImage()
navigationBar.isTranslucent = false self.navigationController?.navigationBar.isTranslucent = false
navigationBar.barTintColor = .white self.navigationController?.navigationBar.barTintColor = Colors.navigationBarBackground
(navigationBar as! OWSNavigationBar).respectsTheme = false (self.navigationController?.navigationBar as? OWSNavigationBar)?.respectsTheme = true
navigationBar.backgroundColor = .white self.navigationController?.navigationBar.backgroundColor = Colors.navigationBarBackground
let backgroundImage = UIImage(color: .white) self.navigationController?.navigationBar.setBackgroundImage(backgroundImage, for: .default)
navigationBar.setBackgroundImage(backgroundImage, for: .default)
// Determine the size of the thumbnails to request // Determine the size of the thumbnails to request
let scale = UIScreen.main.scale let scale = UIScreen.main.scale
@ -268,11 +261,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
// MARK: // MARK:
var lastPageYOffset: CGFloat { var lastPageYOffset: CGFloat {
var yOffset = collectionView.contentSize.height - collectionView.bounds.height + collectionView.adjustedContentInset.bottom return (collectionView.contentSize.height - collectionView.bounds.height + collectionView.adjustedContentInset.bottom + view.safeAreaInsets.bottom)
if #available(iOS 11.0, *) {
yOffset += view.safeAreaInsets.bottom
}
return yOffset
} }
func scrollToBottom(animated: Bool) { func scrollToBottom(animated: Bool) {
@ -343,10 +332,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
static let kInterItemSpacing: CGFloat = 2 static let kInterItemSpacing: CGFloat = 2
private class func buildLayout() -> UICollectionViewFlowLayout { private class func buildLayout() -> UICollectionViewFlowLayout {
let layout = UICollectionViewFlowLayout() let layout = UICollectionViewFlowLayout()
layout.sectionInsetReference = .fromSafeArea
if #available(iOS 11, *) {
layout.sectionInsetReference = .fromSafeArea
}
layout.minimumInteritemSpacing = kInterItemSpacing layout.minimumInteritemSpacing = kInterItemSpacing
layout.minimumLineSpacing = kInterItemSpacing layout.minimumLineSpacing = kInterItemSpacing
layout.sectionHeadersPinToVisibleBounds = true layout.sectionHeadersPinToVisibleBounds = true
@ -355,13 +341,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
} }
func updateLayout() { func updateLayout() {
let containerWidth: CGFloat let containerWidth: CGFloat = self.view.safeAreaLayoutGuide.layoutFrame.size.width
if #available(iOS 11.0, *) {
containerWidth = self.view.safeAreaLayoutGuide.layoutFrame.size.width
} else {
containerWidth = self.view.frame.size.width
}
let kItemsPerPortraitRow = 4 let kItemsPerPortraitRow = 4
let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
let approxItemWidth = screenWidth / CGFloat(kItemsPerPortraitRow) let approxItemWidth = screenWidth / CGFloat(kItemsPerPortraitRow)
@ -556,11 +536,9 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
return UICollectionViewCell(forAutoLayout: ()) return UICollectionViewCell(forAutoLayout: ())
} }
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PhotoGridViewCell.reuseIdentifier, for: indexPath) as? PhotoGridViewCell else { let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath)
owsFail("cell was unexpectedly nil")
}
cell.loadingColor = UIColor(white: 0.2, alpha: 1) cell.loadingColor = UIColor(white: 0.2, alpha: 1)
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize) let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
cell.configure(item: assetItem) cell.configure(item: assetItem)
@ -587,7 +565,7 @@ extension ImagePickerGridController: UIGestureRecognizerDelegate {
} }
} }
protocol TitleViewDelegate: class { protocol TitleViewDelegate: AnyObject {
func titleViewWasTapped(_ titleView: TitleView) func titleViewWasTapped(_ titleView: TitleView)
} }
@ -615,10 +593,10 @@ class TitleView: UIView {
addSubview(stackView) addSubview(stackView)
stackView.autoPinEdgesToSuperviewEdges() stackView.autoPinEdgesToSuperviewEdges()
label.textColor = .black label.textColor = Colors.text
label.font = .boldSystemFont(ofSize: Values.mediumFontSize) label.font = .boldSystemFont(ofSize: Values.mediumFontSize)
iconView.tintColor = .black iconView.tintColor = Colors.text
iconView.image = UIImage(named: "navbar_disclosure_down")?.withRenderingMode(.alwaysTemplate) iconView.image = UIImage(named: "navbar_disclosure_down")?.withRenderingMode(.alwaysTemplate)
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(titleTapped))) addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(titleTapped)))

View File

@ -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

View File

@ -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

View File

@ -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)
}

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -41,18 +41,13 @@ class PhotoCapture: NSObject {
self.session = AVCaptureSession() self.session = AVCaptureSession()
self.captureOutput = CaptureOutput() self.captureOutput = CaptureOutput()
} }
// MARK: - Dependencies
var audioSession: OWSAudioSession {
return Environment.shared.audioSession
}
// MARK: - // MARK: -
var audioDeviceInput: AVCaptureDeviceInput? var audioDeviceInput: AVCaptureDeviceInput?
func startAudioCapture() throws { func startAudioCapture() throws {
assertIsOnSessionQueue() assertIsOnSessionQueue()
guard audioSession.startAudioActivity(recordingAudioActivity) else { guard Environment.shared?.audioSession.startAudioActivity(recordingAudioActivity) == true else {
throw PhotoCaptureError.assertionError(description: "unable to capture audio activity") throw PhotoCaptureError.assertionError(description: "unable to capture audio activity")
} }
@ -83,7 +78,7 @@ class PhotoCapture: NSObject {
} }
session.removeInput(audioDeviceInput) session.removeInput(audioDeviceInput)
self.audioDeviceInput = nil self.audioDeviceInput = nil
audioSession.endAudioActivity(recordingAudioActivity) Environment.shared?.audioSession.endAudioActivity(recordingAudioActivity)
} }
func startCapture() -> Promise<Void> { func startCapture() -> Promise<Void> {
@ -458,16 +453,10 @@ protocol ImageCaptureOutput: AnyObject {
class CaptureOutput { class CaptureOutput {
let imageOutput: ImageCaptureOutput let imageOutput: ImageCaptureOutput = PhotoCaptureOutputAdaptee()
let movieOutput: AVCaptureMovieFileOutput let movieOutput: AVCaptureMovieFileOutput
init() { init() {
if #available(iOS 10.0, *) {
imageOutput = PhotoCaptureOutputAdaptee()
} else {
imageOutput = StillImageCaptureOutput()
}
movieOutput = AVCaptureMovieFileOutput() movieOutput = AVCaptureMovieFileOutput()
// disable movie fragment writing since it's not supported on mp4 // disable movie fragment writing since it's not supported on mp4
// leaving it enabled causes all audio to be lost on videos longer // leaving it enabled causes all audio to be lost on videos longer
@ -536,7 +525,6 @@ class CaptureOutput {
} }
} }
@available(iOS 10.0, *)
class PhotoCaptureOutputAdaptee: NSObject, ImageCaptureOutput { class PhotoCaptureOutputAdaptee: NSObject, ImageCaptureOutput {
let photoOutput = AVCapturePhotoOutput() let photoOutput = AVCapturePhotoOutput()
@ -591,7 +579,6 @@ class PhotoCaptureOutputAdaptee: NSObject, ImageCaptureOutput {
self.completion = completion self.completion = completion
} }
@available(iOS 11.0, *)
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
var data = photo.fileDataRepresentation()! var data = photo.fileDataRepresentation()!
// Call normalized here to fix the orientation // Call normalized here to fix the orientation

View File

@ -115,12 +115,7 @@ class PhotoCaptureViewController: OWSViewController {
init(imageName: String, block: @escaping () -> Void) { init(imageName: String, block: @escaping () -> Void) {
self.button = OWSButton(imageName: imageName, tintColor: .ows_white, block: block) self.button = OWSButton(imageName: imageName, tintColor: .ows_white, block: block)
if #available(iOS 10, *) { button.autoPinToSquareAspectRatio()
button.autoPinToSquareAspectRatio()
} else {
button.sizeToFit()
}
button.layer.shadowOffset = CGSize.zero button.layer.shadowOffset = CGSize.zero
button.layer.shadowOpacity = 0.35 button.layer.shadowOpacity = 0.35
button.layer.shadowRadius = 4 button.layer.shadowRadius = 4
@ -600,20 +595,6 @@ class RecordingTimerView: UIView {
return icon return icon
}() }()
// MARK: - Overrides //
override func sizeThatFits(_ size: CGSize) -> CGSize {
if #available(iOS 10, *) {
return super.sizeThatFits(size)
} else {
// iOS9 manual layout sizing required for items in the navigation bar
var baseSize = label.frame.size
baseSize.width = baseSize.width + stackViewSpacing + RecordingTimerView.iconWidth + layoutMargins.left + layoutMargins.right
baseSize.height = baseSize.height + layoutMargins.top + layoutMargins.bottom
return baseSize
}
}
// MARK: - // MARK: -
var recordingStartTime: TimeInterval? var recordingStartTime: TimeInterval?
@ -662,10 +643,5 @@ class RecordingTimerView: UIView {
Logger.verbose("recordingDuration: \(recordingDuration)") Logger.verbose("recordingDuration: \(recordingDuration)")
let durationDate = Date(timeIntervalSinceReferenceDate: recordingDuration) let durationDate = Date(timeIntervalSinceReferenceDate: recordingDuration)
label.text = timeFormatter.string(from: durationDate) label.text = timeFormatter.string(from: durationDate)
if #available(iOS 10, *) {
// do nothing
} else {
label.sizeToFit()
}
} }
} }

View File

@ -6,7 +6,7 @@ import Foundation
import Photos import Photos
import PromiseKit import PromiseKit
protocol PhotoCollectionPickerDelegate: class { protocol PhotoCollectionPickerDelegate: AnyObject {
func photoCollectionPicker(_ photoCollectionPicker: PhotoCollectionPickerController, didPickCollection collection: PhotoCollection) func photoCollectionPicker(_ photoCollectionPicker: PhotoCollectionPickerController, didPickCollection collection: PhotoCollection)
} }
@ -102,7 +102,7 @@ class PhotoCollectionPickerController: OWSTableViewController, PhotoLibraryDeleg
let photoMediaSize = PhotoMediaSize(thumbnailSize: CGSize(width: kImageSize, height: kImageSize)) let photoMediaSize = PhotoMediaSize(thumbnailSize: CGSize(width: kImageSize, height: kImageSize))
if let assetItem = contents.lastAssetItem(photoMediaSize: photoMediaSize) { if let assetItem = contents.lastAssetItem(photoMediaSize: photoMediaSize) {
imageView.image = assetItem.asyncThumbnail { [weak imageView] image in assetItem.asyncThumbnail { [weak imageView] image in
AssertIsOnMainThread() AssertIsOnMainThread()
guard let imageView = imageView else { guard let imageView = imageView else {

View File

@ -9,15 +9,13 @@ public enum PhotoGridItemType {
case photo, animated, video case photo, animated, video
} }
public protocol PhotoGridItem: class { public protocol PhotoGridItem: AnyObject {
var type: PhotoGridItemType { get } var type: PhotoGridItemType { get }
func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage?
func asyncThumbnail(completion: @escaping (UIImage?) -> Void)
} }
public class PhotoGridViewCell: UICollectionViewCell { public class PhotoGridViewCell: UICollectionViewCell {
static let reuseIdentifier = "PhotoGridViewCell"
public let imageView: UIImageView public let imageView: UIImageView
private let contentTypeBadgeView: UIImageView private let contentTypeBadgeView: UIImageView
@ -119,28 +117,23 @@ public class PhotoGridViewCell: UICollectionViewCell {
public func configure(item: PhotoGridItem) { public func configure(item: PhotoGridItem) {
self.item = item self.item = item
self.image = item.asyncThumbnail { image in item.asyncThumbnail { [weak self] image in
guard let currentItem = self.item else { guard let currentItem = self?.item else { return }
return guard currentItem === item else { return }
}
guard currentItem === item else {
return
}
if image == nil { if image == nil {
Logger.debug("image == nil") Logger.debug("image == nil")
} }
self.image = image
DispatchQueue.main.async {
self?.image = image
}
} }
switch item.type { switch item.type {
case .video: case .video: self.contentTypeBadgeImage = PhotoGridViewCell.videoBadgeImage
self.contentTypeBadgeImage = PhotoGridViewCell.videoBadgeImage case .animated: self.contentTypeBadgeImage = PhotoGridViewCell.animatedBadgeImage
case .animated: case .photo: self.contentTypeBadgeImage = nil
self.contentTypeBadgeImage = PhotoGridViewCell.animatedBadgeImage
case .photo:
self.contentTypeBadgeImage = nil
} }
} }

View File

@ -7,7 +7,7 @@ import Photos
import PromiseKit import PromiseKit
import CoreServices import CoreServices
protocol PhotoLibraryDelegate: class { protocol PhotoLibraryDelegate: AnyObject {
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) func photoLibraryDidChange(_ photoLibrary: PhotoLibrary)
} }
@ -47,16 +47,13 @@ class PhotoPickerAssetItem: PhotoGridItem {
return .photo return .photo
} }
func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? { func asyncThumbnail(completion: @escaping (UIImage?) -> Void) {
var syncImageResult: UIImage?
var hasLoadedImage = false var hasLoadedImage = false
// Surprisingly, iOS will opportunistically run the completion block sync if the image is // Surprisingly, iOS will opportunistically run the completion block sync if the image is
// already available. // already available.
photoCollectionContents.requestThumbnail(for: self.asset, thumbnailSize: photoMediaSize.thumbnailSize) { image, _ in photoCollectionContents.requestThumbnail(for: self.asset, thumbnailSize: photoMediaSize.thumbnailSize) { image, _ in
DispatchMainThreadSafe({ DispatchMainThreadSafe({
syncImageResult = image
// Once we've _successfully_ completed (e.g. invoked the completion with // Once we've _successfully_ completed (e.g. invoked the completion with
// a non-nil image), don't invoke the completion again with a nil argument. // a non-nil image), don't invoke the completion again with a nil argument.
if !hasLoadedImage || image != nil { if !hasLoadedImage || image != nil {
@ -68,7 +65,6 @@ class PhotoPickerAssetItem: PhotoGridItem {
} }
}) })
} }
return syncImageResult
} }
} }

View File

@ -1,27 +1,30 @@
// // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation import UIKit
import Photos import Photos
import PromiseKit import PromiseKit
import SignalUtilitiesKit
@objc
protocol SendMediaNavDelegate: AnyObject {
func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController)
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], messageText: String?)
func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String?
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?)
}
@objc
class SendMediaNavigationController: OWSNavigationController { class SendMediaNavigationController: OWSNavigationController {
// This is a sensitive constant, if you change it make sure to check // This is a sensitive constant, if you change it make sure to check
// on iPhone5, 6, 6+, X, layouts. // on iPhone5, 6, 6+, X, layouts.
static let bottomButtonsCenterOffset: CGFloat = -50 static let bottomButtonsCenterOffset: CGFloat = -50
private let threadId: String
// MARK: - Initialization
init(threadId: String) {
self.threadId = threadId
super.init()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Overrides // MARK: - Overrides
override var prefersStatusBarHidden: Bool { return true } override var prefersStatusBarHidden: Bool { return true }
@ -56,21 +59,20 @@ class SendMediaNavigationController: OWSNavigationController {
// MARK: - // MARK: -
@objc
public weak var sendMediaNavDelegate: SendMediaNavDelegate? public weak var sendMediaNavDelegate: SendMediaNavDelegate?
@objc @objc
public class func showingCameraFirst() -> SendMediaNavigationController { public class func showingCameraFirst(threadId: String) -> SendMediaNavigationController {
let navController = SendMediaNavigationController() let navController = SendMediaNavigationController(threadId: threadId)
navController.setViewControllers([navController.captureViewController], animated: false) navController.viewControllers = [navController.captureViewController]
return navController return navController
} }
@objc @objc
public class func showingMediaLibraryFirst() -> SendMediaNavigationController { public class func showingMediaLibraryFirst(threadId: String) -> SendMediaNavigationController {
let navController = SendMediaNavigationController() let navController = SendMediaNavigationController(threadId: threadId)
navController.setViewControllers([navController.mediaLibraryViewController], animated: false) navController.viewControllers = [navController.mediaLibraryViewController]
return navController return navController
} }
@ -230,7 +232,11 @@ class SendMediaNavigationController: OWSNavigationController {
return return
} }
let approvalViewController = AttachmentApprovalViewController(mode: .sharedNavigation, attachments: self.attachments) let approvalViewController = AttachmentApprovalViewController(
mode: .sharedNavigation,
threadId: self.threadId,
attachments: self.attachments
)
approvalViewController.approvalDelegate = self approvalViewController.approvalDelegate = self
approvalViewController.messageText = sendMediaNavDelegate.sendMediaNavInitialMessageText(self) approvalViewController.messageText = sendMediaNavDelegate.sendMediaNavInitialMessageText(self)
@ -276,8 +282,6 @@ extension SendMediaNavigationController: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
if viewController == captureViewController { if viewController == captureViewController {
setNavBarBackgroundColor(to: .black) setNavBarBackgroundColor(to: .black)
} else if viewController == mediaLibraryViewController {
setNavBarBackgroundColor(to: .white)
} else { } else {
setNavBarBackgroundColor(to: Colors.navigationBarBackground) setNavBarBackgroundColor(to: Colors.navigationBarBackground)
} }
@ -305,8 +309,6 @@ extension SendMediaNavigationController: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
if viewController == captureViewController { if viewController == captureViewController {
setNavBarBackgroundColor(to: .black) setNavBarBackgroundColor(to: .black)
} else if viewController == mediaLibraryViewController {
setNavBarBackgroundColor(to: .white)
} else { } else {
setNavBarBackgroundColor(to: Colors.navigationBarBackground) setNavBarBackgroundColor(to: Colors.navigationBarBackground)
} }
@ -441,8 +443,8 @@ extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegat
attachmentDraftCollection.remove(attachment: attachment) attachmentDraftCollection.remove(attachment: attachment)
} }
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) { func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) {
sendMediaNavDelegate?.sendMediaNav(self, didApproveAttachments: attachments, messageText: messageText) sendMediaNavDelegate?.sendMediaNav(self, didApproveAttachments: attachments, forThreadId: threadId, messageText: messageText)
} }
func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) { func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) {
@ -680,3 +682,13 @@ private class DoneButton: UIView {
delegate?.doneButtonWasTapped(self) delegate?.doneButtonWasTapped(self)
} }
} }
// MARK: - SendMediaNavDelegate
protocol SendMediaNavDelegate: AnyObject {
func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController)
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?)
func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String?
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?)
}

View File

@ -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