Merge branch 'feature/database-refactor' into emoji-reacts
# Conflicts: # Session.xcodeproj/project.pbxproj # Session/Conversations/Context Menu/ContextMenuVC+Action.swift # Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift # Session/Conversations/Context Menu/ContextMenuVC.swift # Session/Conversations/ConversationVC+Interaction.swift # Session/Conversations/ConversationVC.swift # Session/Conversations/ConversationViewItem.h # Session/Conversations/ConversationViewItem.m # Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift # Session/Conversations/Message Cells/MessageCell.swift # Session/Conversations/Message Cells/VisibleMessageCell.swift # Session/Conversations/Views & Modals/BodyTextView.swift # Session/Meta/Translations/en.lproj/Localizable.strings # Session/Shared/UserCell.swift # SessionMessagingKit/Jobs/MessageSendJob.swift # SessionMessagingKit/Messages/Signal/TSMessage.h # SessionMessagingKit/Messages/Signal/TSMessage.m # SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift # SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift # SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift # SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.h # SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift # SessionMessagingKit/Utilities/General.swift # SessionNotificationServiceExtension/NSENotificationPresenter.swift # SignalUtilitiesKit/Utilities/DisplayableText.swift # SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift # SignalUtilitiesKit/Utilities/Notification+Loki.swift
This commit is contained in:
commit
c25f378c54
44
Podfile
44
Podfile
|
@ -1,4 +1,4 @@
|
|||
platform :ios, '12.0'
|
||||
platform :ios, '13.0'
|
||||
source 'https://github.com/CocoaPods/Specs.git'
|
||||
|
||||
use_frameworks!
|
||||
|
@ -8,7 +8,12 @@ inhibit_all_warnings!
|
|||
abstract_target 'GlobalDependencies' do
|
||||
pod 'PromiseKit'
|
||||
pod 'CryptoSwift'
|
||||
pod 'Sodium', '~> 0.9.1'
|
||||
# FIXME: If https://github.com/jedisct1/swift-sodium/pull/249 gets resolved then revert this back to the standard pod
|
||||
pod 'Sodium', :git => 'https://github.com/oxen-io/session-ios-swift-sodium.git', branch: 'session-build'
|
||||
pod 'GRDB.swift/SQLCipher'
|
||||
pod 'SQLCipher', '~> 4.0'
|
||||
|
||||
# FIXME: We want to remove this once it's been long enough since the migration to GRDB
|
||||
pod 'YapDatabase/SQLCipher', :git => 'https://github.com/oxen-io/session-ios-yap-database.git', branch: 'signal-release'
|
||||
pod 'WebRTC-lib'
|
||||
pod 'SocketRocket', '~> 0.5.1'
|
||||
|
@ -19,13 +24,13 @@ abstract_target 'GlobalDependencies' do
|
|||
pod 'PureLayout', '~> 3.1.8'
|
||||
pod 'NVActivityIndicatorView'
|
||||
pod 'YYImage', git: 'https://github.com/signalapp/YYImage'
|
||||
pod 'Mantle', git: 'https://github.com/signalapp/Mantle', branch: 'signal-master'
|
||||
pod 'ZXingObjC'
|
||||
pod 'DifferenceKit'
|
||||
end
|
||||
|
||||
# Dependencies to be included only in all extensions/frameworks
|
||||
abstract_target 'FrameworkAndExtensionDependencies' do
|
||||
pod 'Curve25519Kit', git: 'https://github.com/signalapp/Curve25519Kit.git'
|
||||
pod 'Curve25519Kit', git: 'https://github.com/oxen-io/session-ios-curve-25519-kit.git', branch: 'session-version'
|
||||
pod 'SignalCoreKit', git: 'https://github.com/oxen-io/session-ios-core-kit', branch: 'session-version'
|
||||
|
||||
target 'SessionNotificationServiceExtension'
|
||||
|
@ -35,10 +40,10 @@ abstract_target 'GlobalDependencies' do
|
|||
abstract_target 'ExtendedDependencies' do
|
||||
pod 'AFNetworking'
|
||||
pod 'PureLayout', '~> 3.1.8'
|
||||
pod 'Mantle', git: 'https://github.com/signalapp/Mantle', branch: 'signal-master'
|
||||
|
||||
target 'SessionShareExtension' do
|
||||
pod 'NVActivityIndicatorView'
|
||||
pod 'DifferenceKit'
|
||||
end
|
||||
|
||||
target 'SignalUtilitiesKit' do
|
||||
|
@ -47,16 +52,32 @@ abstract_target 'GlobalDependencies' do
|
|||
pod 'SAMKeychain'
|
||||
pod 'SwiftProtobuf', '~> 1.5.0'
|
||||
pod 'YYImage', git: 'https://github.com/signalapp/YYImage'
|
||||
pod 'DifferenceKit'
|
||||
end
|
||||
|
||||
target 'SessionMessagingKit' do
|
||||
pod 'Reachability'
|
||||
pod 'SAMKeychain'
|
||||
pod 'SwiftProtobuf', '~> 1.5.0'
|
||||
pod 'DifferenceKit'
|
||||
|
||||
target 'SessionMessagingKitTests' do
|
||||
inherit! :complete
|
||||
|
||||
pod 'Quick'
|
||||
pod 'Nimble'
|
||||
end
|
||||
end
|
||||
|
||||
target 'SessionUtilitiesKit' do
|
||||
pod 'SAMKeychain'
|
||||
|
||||
target 'SessionUtilitiesKitTests' do
|
||||
inherit! :complete
|
||||
|
||||
pod 'Quick'
|
||||
pod 'Nimble'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -69,6 +90,7 @@ target 'SessionUIKit'
|
|||
post_install do |installer|
|
||||
enable_whole_module_optimization_for_crypto_swift(installer)
|
||||
set_minimum_deployment_target(installer)
|
||||
enable_fts5_support(installer)
|
||||
end
|
||||
|
||||
def enable_whole_module_optimization_for_crypto_swift(installer)
|
||||
|
@ -85,7 +107,17 @@ end
|
|||
def set_minimum_deployment_target(installer)
|
||||
installer.pods_project.targets.each do |target|
|
||||
target.build_configurations.each do |build_configuration|
|
||||
build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0'
|
||||
build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# This is to ensure we enable support for FastTextSearch5 (might not be enabled by default)
|
||||
# For more info see https://github.com/groue/GRDB.swift/blob/master/Documentation/FullTextSearch.md#enabling-fts5-support
|
||||
def enable_fts5_support(installer)
|
||||
installer.pods_project.targets.select { |target| target.name == "GRDB.swift" }.each do |target|
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['OTHER_SWIFT_FLAGS'] = "$(inherited) -D SQLITE_ENABLE_FTS5"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
58
Podfile.lock
58
Podfile.lock
|
@ -21,9 +21,15 @@ PODS:
|
|||
- Curve25519Kit (2.1.0):
|
||||
- CocoaLumberjack
|
||||
- SignalCoreKit
|
||||
- Mantle (2.1.0):
|
||||
- Mantle/extobjc (= 2.1.0)
|
||||
- Mantle/extobjc (2.1.0)
|
||||
- DifferenceKit (1.2.0):
|
||||
- DifferenceKit/Core (= 1.2.0)
|
||||
- DifferenceKit/UIKitExtension (= 1.2.0)
|
||||
- DifferenceKit/Core (1.2.0)
|
||||
- DifferenceKit/UIKitExtension (1.2.0):
|
||||
- DifferenceKit/Core
|
||||
- GRDB.swift/SQLCipher (5.24.1):
|
||||
- SQLCipher (>= 3.4.0)
|
||||
- Nimble (10.0.0)
|
||||
- NVActivityIndicatorView (5.1.1):
|
||||
- NVActivityIndicatorView/Base (= 5.1.1)
|
||||
- NVActivityIndicatorView/Base (5.1.1)
|
||||
|
@ -38,6 +44,7 @@ PODS:
|
|||
- PromiseKit/UIKit (6.15.3):
|
||||
- PromiseKit/CorePromise
|
||||
- PureLayout (3.1.9)
|
||||
- Quick (5.0.1)
|
||||
- Reachability (3.2)
|
||||
- SAMKeychain (1.5.3)
|
||||
- SignalCoreKit (1.0.0):
|
||||
|
@ -124,16 +131,20 @@ PODS:
|
|||
DEPENDENCIES:
|
||||
- AFNetworking
|
||||
- CryptoSwift
|
||||
- Curve25519Kit (from `https://github.com/signalapp/Curve25519Kit.git`)
|
||||
- Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`)
|
||||
- Curve25519Kit (from `https://github.com/oxen-io/session-ios-curve-25519-kit.git`, branch `session-version`)
|
||||
- DifferenceKit
|
||||
- GRDB.swift/SQLCipher
|
||||
- Nimble
|
||||
- NVActivityIndicatorView
|
||||
- PromiseKit
|
||||
- PureLayout (~> 3.1.8)
|
||||
- Quick
|
||||
- Reachability
|
||||
- SAMKeychain
|
||||
- SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`)
|
||||
- SocketRocket (~> 0.5.1)
|
||||
- Sodium (~> 0.9.1)
|
||||
- Sodium (from `https://github.com/oxen-io/session-ios-swift-sodium.git`, branch `session-build`)
|
||||
- SQLCipher (~> 4.0)
|
||||
- SwiftProtobuf (~> 1.5.0)
|
||||
- WebRTC-lib
|
||||
- YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`)
|
||||
|
@ -145,14 +156,17 @@ SPEC REPOS:
|
|||
- AFNetworking
|
||||
- CocoaLumberjack
|
||||
- CryptoSwift
|
||||
- DifferenceKit
|
||||
- GRDB.swift
|
||||
- Nimble
|
||||
- NVActivityIndicatorView
|
||||
- OpenSSL-Universal
|
||||
- PromiseKit
|
||||
- PureLayout
|
||||
- Quick
|
||||
- Reachability
|
||||
- SAMKeychain
|
||||
- SocketRocket
|
||||
- Sodium
|
||||
- SQLCipher
|
||||
- SwiftProtobuf
|
||||
- WebRTC-lib
|
||||
|
@ -160,13 +174,14 @@ SPEC REPOS:
|
|||
|
||||
EXTERNAL SOURCES:
|
||||
Curve25519Kit:
|
||||
:git: https://github.com/signalapp/Curve25519Kit.git
|
||||
Mantle:
|
||||
:branch: signal-master
|
||||
:git: https://github.com/signalapp/Mantle
|
||||
:branch: session-version
|
||||
:git: https://github.com/oxen-io/session-ios-curve-25519-kit.git
|
||||
SignalCoreKit:
|
||||
:branch: session-version
|
||||
:git: https://github.com/oxen-io/session-ios-core-kit
|
||||
Sodium:
|
||||
:branch: session-build
|
||||
:git: https://github.com/oxen-io/session-ios-swift-sodium.git
|
||||
YapDatabase:
|
||||
:branch: signal-release
|
||||
:git: https://github.com/oxen-io/session-ios-yap-database.git
|
||||
|
@ -175,14 +190,14 @@ EXTERNAL SOURCES:
|
|||
|
||||
CHECKOUT OPTIONS:
|
||||
Curve25519Kit:
|
||||
:commit: 4fc1c10e98fff2534b5379a9bb587430fdb8e577
|
||||
:git: https://github.com/signalapp/Curve25519Kit.git
|
||||
Mantle:
|
||||
:commit: e7e46253bb01ce39525d90aa69ed9e85e758bfc4
|
||||
:git: https://github.com/signalapp/Mantle
|
||||
:commit: b79c2ace600bfd3784e9c33cf1f254b121312edc
|
||||
:git: https://github.com/oxen-io/session-ios-curve-25519-kit.git
|
||||
SignalCoreKit:
|
||||
:commit: 4590c2737a2b5dc0ef4ace9f9019b581caccc1de
|
||||
:git: https://github.com/oxen-io/session-ios-core-kit
|
||||
Sodium:
|
||||
:commit: 4ecfe2ddfd75e7b396c57975b4163e5c8cf4d5cc
|
||||
:git: https://github.com/oxen-io/session-ios-swift-sodium.git
|
||||
YapDatabase:
|
||||
:commit: d84069e25e12a16ab4422e5258127a04b70489ad
|
||||
:git: https://github.com/oxen-io/session-ios-yap-database.git
|
||||
|
@ -195,16 +210,19 @@ SPEC CHECKSUMS:
|
|||
CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646
|
||||
CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17
|
||||
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
|
||||
Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b
|
||||
DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805
|
||||
GRDB.swift: b3180ce2135fc06a453297889b746b1478c4d8c7
|
||||
Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84
|
||||
NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667
|
||||
OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2
|
||||
PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5
|
||||
PureLayout: 5fb5e5429519627d60d079ccb1eaa7265ce7cf88
|
||||
Quick: 749aa754fd1e7d984f2000fe051e18a3a9809179
|
||||
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d
|
||||
SocketRocket: d57c7159b83c3c6655745cd15302aa24b6bae531
|
||||
Sodium: 23d11554ecd556196d313cf6130d406dfe7ac6da
|
||||
Sodium: a7d42cb46e789d2630fa552d35870b416ed055ae
|
||||
SQLCipher: 98dc22f27c0b1790d39e710d440f22a466ebdb59
|
||||
SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2
|
||||
WebRTC-lib: 508fe02efa0c1a3a8867082a77d24c9be5d29aeb
|
||||
|
@ -212,6 +230,6 @@ SPEC CHECKSUMS:
|
|||
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
|
||||
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
||||
|
||||
PODFILE CHECKSUM: a3d89a6cc8735285fd51348ca05cea71f2c28872
|
||||
PODFILE CHECKSUM: 6ab902a81a379cc2c0a9a92c334c78d413190338
|
||||
|
||||
COCOAPODS: 1.11.2
|
||||
COCOAPODS: 1.11.3
|
||||
|
|
|
@ -0,0 +1,251 @@
|
|||
#!/usr/bin/xcrun --sdk macosx swift
|
||||
|
||||
//
|
||||
// ListLocalizableStrings.swift
|
||||
// Archa
|
||||
//
|
||||
// Created by Morgan Pretty on 18/5/20.
|
||||
// Copyright © 2020 Archa. All rights reserved.
|
||||
//
|
||||
// This script is based on https://github.com/ginowu7/CleanSwiftLocalizableExample the main difference
|
||||
// is canges to the localized usage regex
|
||||
|
||||
import Foundation
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let currentPath = (
|
||||
ProcessInfo.processInfo.environment["PROJECT_DIR"] ?? fileManager.currentDirectoryPath
|
||||
)
|
||||
|
||||
/// List of files in currentPath - recursive
|
||||
var pathFiles: [String] = {
|
||||
guard let enumerator = fileManager.enumerator(atPath: currentPath), let files = enumerator.allObjects as? [String] else {
|
||||
fatalError("Could not locate files in path directory: \(currentPath)")
|
||||
}
|
||||
|
||||
return files
|
||||
}()
|
||||
|
||||
|
||||
/// List of localizable files - not including Localizable files in the Pods
|
||||
var localizableFiles: [String] = {
|
||||
return pathFiles
|
||||
.filter {
|
||||
$0.hasSuffix("Localizable.strings") &&
|
||||
!$0.contains(".app/") && // Exclude Built Localizable.strings files
|
||||
!$0.contains("Pods") // Exclude Pods
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
/// List of executable files
|
||||
var executableFiles: [String] = {
|
||||
return pathFiles.filter {
|
||||
!$0.localizedCaseInsensitiveContains("test") && // Exclude test files
|
||||
!$0.contains(".app/") && // Exclude Built Localizable.strings files
|
||||
!$0.contains("Pods") && // Exclude Pods
|
||||
(
|
||||
NSString(string: $0).pathExtension == "swift" ||
|
||||
NSString(string: $0).pathExtension == "m"
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
/// Reads contents in path
|
||||
///
|
||||
/// - Parameter path: path of file
|
||||
/// - Returns: content in file
|
||||
func contents(atPath path: String) -> String {
|
||||
print("Path: \(path)")
|
||||
guard let data = fileManager.contents(atPath: path), let content = String(data: data, encoding: .utf8) else {
|
||||
fatalError("Could not read from path: \(path)")
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
/// Returns a list of strings that match regex pattern from content
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - pattern: regex pattern
|
||||
/// - content: content to match
|
||||
/// - Returns: list of results
|
||||
func regexFor(_ pattern: String, content: String) -> [String] {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
|
||||
fatalError("Regex not formatted correctly: \(pattern)")
|
||||
}
|
||||
|
||||
let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: content.utf16.count))
|
||||
|
||||
return matches.map {
|
||||
guard let range = Range($0.range(at: 0), in: content) else {
|
||||
fatalError("Incorrect range match")
|
||||
}
|
||||
|
||||
return String(content[range])
|
||||
}
|
||||
}
|
||||
|
||||
func create() -> [LocalizationStringsFile] {
|
||||
return localizableFiles.map(LocalizationStringsFile.init(path:))
|
||||
}
|
||||
|
||||
///
|
||||
///
|
||||
/// - Returns: A list of LocalizationCodeFile - contains path of file and all keys in it
|
||||
func localizedStringsInCode() -> [LocalizationCodeFile] {
|
||||
return executableFiles.compactMap {
|
||||
let content = contents(atPath: $0)
|
||||
// Note: Need to exclude escaped quotation marks from strings
|
||||
let matchesOld = regexFor("(?<=NSLocalizedString\\()\\s*\"(?!.*?%d)(.*?)\"", content: content)
|
||||
let matchesNew = regexFor("\"(?!.*?%d)([^(\\\")]*?)\"(?=\\s*)(?=\\.localized)", content: content)
|
||||
let allMatches = (matchesOld + matchesNew)
|
||||
|
||||
return allMatches.isEmpty ? nil : LocalizationCodeFile(path: $0, keys: Set(allMatches))
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws error if ALL localizable files does not have matching keys
|
||||
///
|
||||
/// - Parameter files: list of localizable files to validate
|
||||
func validateMatchKeys(_ files: [LocalizationStringsFile]) {
|
||||
print("------------ Validating keys match in all localizable files ------------")
|
||||
|
||||
guard let base = files.first, files.count > 1 else { return }
|
||||
|
||||
let files = Array(files.dropFirst())
|
||||
|
||||
files.forEach {
|
||||
guard let extraKey = Set(base.keys).symmetricDifference($0.keys).first else { return }
|
||||
let incorrectFile = $0.keys.contains(extraKey) ? $0 : base
|
||||
printPretty("error: Found extra key: \(extraKey) in file: \(incorrectFile.path)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws error if localizable files are missing keys
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - codeFiles: Array of LocalizationCodeFile
|
||||
/// - localizationFiles: Array of LocalizableStringFiles
|
||||
func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
|
||||
print("------------ Checking for missing keys -----------")
|
||||
|
||||
guard let baseFile = localizationFiles.first else {
|
||||
fatalError("Could not locate base localization file")
|
||||
}
|
||||
|
||||
let baseKeys = Set(baseFile.keys)
|
||||
|
||||
codeFiles.forEach {
|
||||
let extraKeys = $0.keys.subtracting(baseKeys)
|
||||
if !extraKeys.isEmpty {
|
||||
printPretty("error: Found keys in code missing in strings file: \(extraKeys) from \($0.path)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Throws warning if keys exist in localizable file but are not being used
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - codeFiles: Array of LocalizationCodeFile
|
||||
/// - localizationFiles: Array of LocalizableStringFiles
|
||||
func validateDeadKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
|
||||
print("------------ Checking for any dead keys in localizable file -----------")
|
||||
|
||||
guard let baseFile = localizationFiles.first else {
|
||||
fatalError("Could not locate base localization file")
|
||||
}
|
||||
|
||||
let baseKeys: Set<String> = Set(baseFile.keys)
|
||||
let allCodeFileKeys: [String] = codeFiles.flatMap { $0.keys }
|
||||
let deadKeys: [String] = Array(baseKeys.subtracting(allCodeFileKeys))
|
||||
.sorted()
|
||||
.map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) }
|
||||
|
||||
if !deadKeys.isEmpty {
|
||||
printPretty("warning: \(deadKeys) - Suggest cleaning dead keys")
|
||||
}
|
||||
}
|
||||
|
||||
protocol Pathable {
|
||||
var path: String { get }
|
||||
}
|
||||
|
||||
struct LocalizationStringsFile: Pathable {
|
||||
let path: String
|
||||
let kv: [String: String]
|
||||
|
||||
var keys: [String] {
|
||||
return Array(kv.keys)
|
||||
}
|
||||
|
||||
init(path: String) {
|
||||
self.path = path
|
||||
self.kv = ContentParser.parse(path)
|
||||
}
|
||||
|
||||
/// Writes back to localizable file with sorted keys and removed whitespaces and new lines
|
||||
func cleanWrite() {
|
||||
print("------------ Sort and remove whitespaces: \(path) ------------")
|
||||
let content = kv.keys.sorted().map { "\($0) = \(kv[$0]!);" }.joined(separator: "\n")
|
||||
try! content.write(toFile: path, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct LocalizationCodeFile: Pathable {
|
||||
let path: String
|
||||
let keys: Set<String>
|
||||
}
|
||||
|
||||
struct ContentParser {
|
||||
|
||||
/// Parses contents of a file to localizable keys and values - Throws error if localizable file have duplicated keys
|
||||
///
|
||||
/// - Parameter path: Localizable file paths
|
||||
/// - Returns: localizable key and value for content at path
|
||||
static func parse(_ path: String) -> [String: String] {
|
||||
print("------------ Checking for duplicate keys: \(path) ------------")
|
||||
|
||||
let content = contents(atPath: path)
|
||||
let trimmed = content
|
||||
.replacingOccurrences(of: "\n+", with: "", options: .regularExpression, range: nil)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let keys = regexFor("\"([^\"]*?)\"(?= =)", content: trimmed)
|
||||
let values = regexFor("(?<== )\"(.*?)\"(?=;)", content: trimmed)
|
||||
|
||||
if keys.count != values.count {
|
||||
fatalError("Error parsing contents: Make sure all keys and values are in correct format (this could be due to extra spaces between keys and values)")
|
||||
}
|
||||
|
||||
return zip(keys, values).reduce(into: [String: String]()) { results, keyValue in
|
||||
if results[keyValue.0] != nil {
|
||||
printPretty("error: Found duplicate key: \(keyValue.0) in file: \(path)")
|
||||
abort()
|
||||
}
|
||||
results[keyValue.0] = keyValue.1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printPretty(_ string: String) {
|
||||
print(string.replacingOccurrences(of: "\\", with: ""))
|
||||
}
|
||||
|
||||
let stringFiles = create()
|
||||
|
||||
if !stringFiles.isEmpty {
|
||||
print("------------ Found \(stringFiles.count) file(s) ------------")
|
||||
|
||||
stringFiles.forEach { print($0.path) }
|
||||
validateMatchKeys(stringFiles)
|
||||
|
||||
// Note: Uncomment the below file to clean out all comments from the localizable file (we don't want this because comments make it readable...)
|
||||
// stringFiles.forEach { $0.cleanWrite() }
|
||||
|
||||
let codeFiles = localizedStringsInCode()
|
||||
validateMissingKeys(codeFiles, localizationFiles: stringFiles)
|
||||
validateDeadKeys(codeFiles, localizationFiles: stringFiles)
|
||||
}
|
||||
|
||||
print("------------ SUCCESS ------------")
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1020"
|
||||
LastUpgradeVersion = "1320"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
@ -20,27 +20,15 @@
|
|||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "11319FE11E0F163FEF714A606CCC265F"
|
||||
BuildableName = "SignalServiceKit.framework"
|
||||
BlueprintName = "SignalServiceKit"
|
||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "NO">
|
||||
shouldUseLaunchSchemeArgsEnv = "NO"
|
||||
codeCoverageEnabled = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
|
@ -57,173 +45,87 @@
|
|||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
<CodeCoverageTargets>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D221A088169C9E5E00537ABF"
|
||||
BuildableName = "Session.app"
|
||||
BlueprintName = "Session"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "7BC01A3A241F40AB00BC7C55"
|
||||
BuildableName = "SessionNotificationServiceExtension.appex"
|
||||
BlueprintName = "SessionNotificationServiceExtension"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "453518671FC635DD00210559"
|
||||
BuildableName = "SessionShareExtension.appex"
|
||||
BlueprintName = "SessionShareExtension"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
|
||||
BuildableName = "SessionMessagingKit.framework"
|
||||
BlueprintName = "SessionMessagingKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A59E255385C100C340D1"
|
||||
BuildableName = "SessionSnodeKit.framework"
|
||||
BlueprintName = "SessionSnodeKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C331FF1A2558F9D300070591"
|
||||
BuildableName = "SessionUIKit.framework"
|
||||
BlueprintName = "SessionUIKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
|
||||
BuildableName = "SessionUtilitiesKit.framework"
|
||||
BlueprintName = "SessionUtilitiesKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C33FD9AA255A548A00E217F9"
|
||||
BuildableName = "SignalUtilitiesKit.framework"
|
||||
BlueprintName = "SignalUtilitiesKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</CodeCoverageTargets>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D221A0A9169C9E5F00537ABF"
|
||||
BuildableName = "SignalTests.xctest"
|
||||
BlueprintName = "SignalTests"
|
||||
BlueprintIdentifier = "FDC4388D27B9FFC700C60D73"
|
||||
BuildableName = "SessionMessagingKitTests.xctest"
|
||||
BlueprintName = "SessionMessagingKitTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B772E882F193AA2F25932C514BBF0805"
|
||||
BuildableName = "SignalServiceKit-Unit-Tests.xctest"
|
||||
BlueprintName = "SignalServiceKit-Unit-Tests"
|
||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
<SkippedTests>
|
||||
<Test
|
||||
Identifier = "ContactSortingTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "DeviceNamesTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "JobQueueTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "MessageSenderJobQueueTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSAnalyticsTests">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSDeviceProvisionerTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSDisappearingMessageFinderTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSDisappearingMessagesConfigurationTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSDisappearingMessagesJobTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSFingerprintTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSIncomingMessageFinderTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSLinkPreviewTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSMessageManagerTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSMessageSenderTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSProvisioningCipherTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSSignalAddressTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "OWSUDManagerTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "PhoneNumberTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "PhoneNumberUtilTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "SSKBaseTestObjC">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "SSKBaseTestSwift">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "SSKMessageSenderJobRecordTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "SignalRecipientTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "SignedPreKeyDeletionTests">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "TSContactThreadTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "TSGroupThreadTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "TSMessageStorageTests">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "TSMessageTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "TSOutgoingMessageTest">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "TSStorageIdentityKeyStoreTests">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "TSStoragePreKeyStoreTests">
|
||||
</Test>
|
||||
<Test
|
||||
Identifier = "TSThreadTest">
|
||||
</Test>
|
||||
</SkippedTests>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "5C9F6BA9ADC4724B2612C9F20FBE2076"
|
||||
BuildableName = "SignalCoreKit-Unit-Tests.xctest"
|
||||
BlueprintName = "SignalCoreKit-Unit-Tests"
|
||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BF2BCB29C9D47F15FB156F1EC64E5CC2"
|
||||
BuildableName = "AxolotlKit-Unit-Tests.xctest"
|
||||
BlueprintName = "AxolotlKit-Unit-Tests"
|
||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "78DE33AED82B26B4B8D899CC403003AF"
|
||||
BuildableName = "Curve25519Kit-Unit-Tests.xctest"
|
||||
BlueprintName = "Curve25519Kit-Unit-Tests"
|
||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "AF7FC2C93AA68E33600807F168BD483A"
|
||||
BuildableName = "HKDFKit-Unit-Tests.xctest"
|
||||
BlueprintName = "HKDFKit-Unit-Tests"
|
||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B086B0C72F8A5814FF48795531F21635"
|
||||
BuildableName = "SignalMetadataKit-Unit-Tests.xctest"
|
||||
BlueprintName = "SignalMetadataKit-Unit-Tests"
|
||||
ReferencedContainer = "container:Pods/Pods.xcodeproj">
|
||||
BlueprintIdentifier = "FD83B9AE27CF200A005E1583"
|
||||
BuildableName = "SessionUtilitiesKitTests.xctest"
|
||||
BlueprintName = "SessionUtilitiesKitTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1320"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
|
||||
BuildableName = "SessionMessagingKit.framework"
|
||||
BlueprintName = "SessionMessagingKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
codeCoverageEnabled = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||
<CodeCoverageTargets>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
|
||||
BuildableName = "SessionMessagingKit.framework"
|
||||
BlueprintName = "SessionMessagingKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</CodeCoverageTargets>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FDC4388D27B9FFC700C60D73"
|
||||
BuildableName = "SessionMessagingKitTests.xctest"
|
||||
BlueprintName = "SessionMessagingKitTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "App Store Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
|
||||
BuildableName = "SessionMessagingKit.framework"
|
||||
BlueprintName = "SessionMessagingKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "App Store Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1140"
|
||||
LastUpgradeVersion = "1320"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
|
@ -43,6 +43,16 @@
|
|||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FDC4388027B9FF1E00C60D73"
|
||||
BuildableName = "SessionTests.xctest"
|
||||
BlueprintName = "SessionTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
|
@ -73,6 +83,7 @@
|
|||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1020"
|
||||
LastUpgradeVersion = "1320"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
|
@ -52,6 +52,16 @@
|
|||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FDC4388027B9FF1E00C60D73"
|
||||
BuildableName = "SessionTests.xctest"
|
||||
BlueprintName = "SessionTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
|
@ -83,6 +93,7 @@
|
|||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1320"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
|
||||
BuildableName = "SessionUtilitiesKit.framework"
|
||||
BlueprintName = "SessionUtilitiesKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
codeCoverageEnabled = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||
<CodeCoverageTargets>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
|
||||
BuildableName = "SessionUtilitiesKit.framework"
|
||||
BlueprintName = "SessionUtilitiesKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</CodeCoverageTargets>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES"
|
||||
testExecutionOrdering = "random">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FD83B9AE27CF200A005E1583"
|
||||
BuildableName = "SessionUtilitiesKitTests.xctest"
|
||||
BlueprintName = "SessionUtilitiesKitTests"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "App Store Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
|
||||
BuildableName = "SessionUtilitiesKit.framework"
|
||||
BlueprintName = "SessionUtilitiesKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "App Store Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1210"
|
||||
LastUpgradeVersion = "1320"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
|
|
@ -1,37 +1,33 @@
|
|||
import Foundation
|
||||
import WebRTC
|
||||
import SessionMessagingKit
|
||||
import PromiseKit
|
||||
import CallKit
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
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
|
||||
|
||||
// MARK: Metadata Properties
|
||||
let uuid: String
|
||||
let callID: UUID // This is for CallKit
|
||||
let sessionID: String
|
||||
let mode: Mode
|
||||
// MARK: - Metadata Properties
|
||||
public let uuid: String
|
||||
public let callId: UUID // This is for CallKit
|
||||
let sessionId: String
|
||||
let mode: CallMode
|
||||
var audioMode: AudioMode
|
||||
let webRTCSession: WebRTCSession
|
||||
public let webRTCSession: WebRTCSession
|
||||
let isOutgoing: Bool
|
||||
var remoteSDP: RTCSessionDescription? = nil
|
||||
var callMessageID: String?
|
||||
var callInteractionId: Int64?
|
||||
var answerCallAction: CXAnswerCallAction? = nil
|
||||
var contactName: String {
|
||||
let contact = Storage.shared.getContact(with: self.sessionID)
|
||||
return contact?.displayName(for: Contact.Context.regular) ?? "\(self.sessionID.prefix(4))...\(self.sessionID.suffix(4))"
|
||||
}
|
||||
var profilePicture: UIImage {
|
||||
if let result = OWSProfileManager.shared().profileAvatar(forRecipientId: sessionID) {
|
||||
return result
|
||||
} else {
|
||||
return Identicon.generatePlaceholderIcon(seed: sessionID, text: contactName, size: 300)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Control
|
||||
let contactName: String
|
||||
let profilePicture: UIImage
|
||||
|
||||
// MARK: - Control
|
||||
|
||||
lazy public var videoCapturer: RTCVideoCapturer = {
|
||||
return RTCCameraVideoCapturer(delegate: webRTCSession.localVideoSource)
|
||||
}()
|
||||
|
@ -61,21 +57,8 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: Mode
|
||||
enum Mode {
|
||||
case offer
|
||||
case answer
|
||||
}
|
||||
// MARK: - Audio I/O mode
|
||||
|
||||
// MARK: End call mode
|
||||
enum EndCallMode {
|
||||
case local
|
||||
case remote
|
||||
case unanswered
|
||||
case answeredElsewhere
|
||||
}
|
||||
|
||||
// MARK: Audio I/O mode
|
||||
enum AudioMode {
|
||||
case earpiece
|
||||
case speaker
|
||||
|
@ -83,7 +66,8 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
|||
case bluetooth
|
||||
}
|
||||
|
||||
// MARK: Call State Properties
|
||||
// MARK: - Call State Properties
|
||||
|
||||
var connectingDate: Date? {
|
||||
didSet {
|
||||
stateDidChange?()
|
||||
|
@ -112,7 +96,8 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: State Change Callbacks
|
||||
// MARK: - State Change Callbacks
|
||||
|
||||
var stateDidChange: (() -> Void)?
|
||||
var hasStartedConnectingDidChange: (() -> Void)?
|
||||
var hasConnectedDidChange: (() -> Void)?
|
||||
|
@ -121,8 +106,9 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
|||
var hasStartedReconnecting: (() -> Void)?
|
||||
var hasReconnected: (() -> Void)?
|
||||
|
||||
// MARK: Derived Properties
|
||||
var hasStartedConnecting: Bool {
|
||||
// MARK: - Derived Properties
|
||||
|
||||
public var hasStartedConnecting: Bool {
|
||||
get { return connectingDate != nil }
|
||||
set { connectingDate = newValue ? Date() : nil }
|
||||
}
|
||||
|
@ -153,73 +139,111 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
|||
|
||||
var reconnectTimer: Timer? = nil
|
||||
|
||||
// MARK: Initialization
|
||||
init(for sessionID: String, uuid: String, mode: Mode, outgoing: Bool = false) {
|
||||
self.sessionID = sessionID
|
||||
// MARK: - Initialization
|
||||
|
||||
init(_ db: Database, for sessionId: String, uuid: String, mode: CallMode, outgoing: Bool = false) {
|
||||
self.sessionId = sessionId
|
||||
self.uuid = uuid
|
||||
self.callID = UUID()
|
||||
self.callId = UUID()
|
||||
self.mode = mode
|
||||
self.audioMode = .earpiece
|
||||
self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionID, with: uuid)
|
||||
self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionId, with: uuid)
|
||||
self.isOutgoing = outgoing
|
||||
|
||||
self.contactName = Profile.displayName(db, id: sessionId, threadVariant: .contact)
|
||||
self.profilePicture = ProfileManager.profileAvatar(db, id: sessionId)
|
||||
.map { UIImage(data: $0) }
|
||||
.defaulting(to: Identicon.generatePlaceholderIcon(seed: sessionId, text: self.contactName, size: 300))
|
||||
|
||||
WebRTCSession.current = self.webRTCSession
|
||||
super.init()
|
||||
self.webRTCSession.delegate = self
|
||||
|
||||
if AppEnvironment.shared.callManager.currentCall == nil {
|
||||
AppEnvironment.shared.callManager.currentCall = self
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
SNLog("[Calls] A call is ongoing.")
|
||||
}
|
||||
}
|
||||
|
||||
func reportIncomingCallIfNeeded(completion: @escaping (Error?) -> Void) {
|
||||
guard case .answer = mode else { return }
|
||||
|
||||
setupTimeoutTimer()
|
||||
AppEnvironment.shared.callManager.reportIncomingCall(self, callerName: contactName) { error in
|
||||
completion(error)
|
||||
}
|
||||
}
|
||||
|
||||
func didReceiveRemoteSDP(sdp: RTCSessionDescription) {
|
||||
public func didReceiveRemoteSDP(sdp: RTCSessionDescription) {
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
self.didReceiveRemoteSDP(sdp: sdp)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
SNLog("[Calls] Did receive remote sdp.")
|
||||
remoteSDP = sdp
|
||||
if hasStartedConnecting {
|
||||
webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally
|
||||
webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
func startSessionCall() {
|
||||
guard case .offer = mode else { return }
|
||||
guard let thread = TSContactThread.fetch(uniqueId: TSContactThread.threadID(fromContactSessionID: sessionID)) else { return }
|
||||
// MARK: - Actions
|
||||
|
||||
public func startSessionCall(_ db: Database) {
|
||||
let sessionId: String = self.sessionId
|
||||
let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .outgoing)
|
||||
|
||||
let message = CallMessage()
|
||||
message.sender = getUserHexEncodedPublicKey()
|
||||
message.sentTimestamp = NSDate.millisecondTimestamp()
|
||||
message.uuid = self.uuid
|
||||
message.kind = .preOffer
|
||||
let infoMessage = TSInfoMessage.from(message, associatedWith: thread)
|
||||
infoMessage.save()
|
||||
self.callMessageID = infoMessage.uniqueId
|
||||
guard
|
||||
case .offer = mode,
|
||||
let messageInfoData: Data = try? JSONEncoder().encode(messageInfo),
|
||||
let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId)
|
||||
else { return }
|
||||
|
||||
var promise: Promise<Void>!
|
||||
Storage.write(with: { transaction in
|
||||
promise = self.webRTCSession.sendPreOffer(message, in: thread, using: transaction)
|
||||
}, completion: { [weak self] in
|
||||
let _ = promise.done {
|
||||
Storage.shared.write { transaction in
|
||||
self?.webRTCSession.sendOffer(to: self!.sessionID, using: transaction as! YapDatabaseReadWriteTransaction).retainUntilComplete()
|
||||
let timestampMs: Int64 = Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
let message: CallMessage = CallMessage(
|
||||
uuid: self.uuid,
|
||||
kind: .preOffer,
|
||||
sdps: [],
|
||||
sentTimestampMs: UInt64(timestampMs)
|
||||
)
|
||||
let interaction: Interaction? = try? Interaction(
|
||||
messageUuid: self.uuid,
|
||||
threadId: sessionId,
|
||||
authorId: getUserHexEncodedPublicKey(db),
|
||||
variant: .infoCall,
|
||||
body: String(data: messageInfoData, encoding: .utf8),
|
||||
timestampMs: timestampMs
|
||||
)
|
||||
.inserted(db)
|
||||
|
||||
self.callInteractionId = interaction?.id
|
||||
try? self.webRTCSession
|
||||
.sendPreOffer(
|
||||
db,
|
||||
message: message,
|
||||
interactionId: interaction?.id,
|
||||
in: thread
|
||||
)
|
||||
.done { [weak self] _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
self?.webRTCSession.sendOffer(db, to: sessionId)
|
||||
}
|
||||
|
||||
self?.setupTimeoutTimer()
|
||||
}
|
||||
})
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
func answerSessionCall() {
|
||||
guard case .answer = mode else { return }
|
||||
|
||||
hasStartedConnecting = true
|
||||
|
||||
if let sdp = remoteSDP {
|
||||
webRTCSession.handleRemoteSDP(sdp, from: sessionID) // This sends an answer message internally
|
||||
webRTCSession.handleRemoteSDP(sdp, from: sessionId) // This sends an answer message internally
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -230,47 +254,79 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
|||
|
||||
func endSessionCall() {
|
||||
guard !hasEnded else { return }
|
||||
|
||||
let sessionId: String = self.sessionId
|
||||
|
||||
webRTCSession.hangUp()
|
||||
Storage.write { transaction in
|
||||
self.webRTCSession.endCall(with: self.sessionID, using: transaction)
|
||||
|
||||
Storage.shared.writeAsync { [weak self] db in
|
||||
try self?.webRTCSession.endCall(db, with: sessionId)
|
||||
}
|
||||
|
||||
hasEnded = true
|
||||
}
|
||||
|
||||
// MARK: Update call message
|
||||
func updateCallMessage(mode: EndCallMode) {
|
||||
guard let callMessageID = callMessageID else { return }
|
||||
Storage.write { transaction in
|
||||
let infoMessage = TSInfoMessage.fetch(uniqueId: callMessageID, transaction: transaction)
|
||||
if let messageToUpdate = infoMessage {
|
||||
var shouldMarkAsRead = false
|
||||
if self.duration > 0 {
|
||||
shouldMarkAsRead = true
|
||||
} else if self.hasStartedConnecting {
|
||||
shouldMarkAsRead = true
|
||||
} else {
|
||||
switch mode {
|
||||
case .local:
|
||||
shouldMarkAsRead = true
|
||||
fallthrough
|
||||
case .remote:
|
||||
fallthrough
|
||||
case .unanswered:
|
||||
if messageToUpdate.callState == .incoming {
|
||||
messageToUpdate.updateCallInfoMessage(.missed, using: transaction)
|
||||
}
|
||||
case .answeredElsewhere:
|
||||
shouldMarkAsRead = true
|
||||
}
|
||||
}
|
||||
if shouldMarkAsRead {
|
||||
messageToUpdate.markAsRead(atTimestamp: NSDate.ows_millisecondTimeStamp(), trySendReadReceipt: false, transaction: transaction)
|
||||
}
|
||||
// MARK: - Call Message Handling
|
||||
|
||||
public func updateCallMessage(mode: EndCallMode) {
|
||||
guard let callInteractionId: Int64 = callInteractionId else { return }
|
||||
|
||||
let duration: TimeInterval = self.duration
|
||||
let hasStartedConnecting: Bool = self.hasStartedConnecting
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
guard let interaction: Interaction = try? Interaction.fetchOne(db, id: callInteractionId) else {
|
||||
return
|
||||
}
|
||||
|
||||
let updateToMissedIfNeeded: () throws -> () = {
|
||||
let missedCallInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(state: .missed)
|
||||
|
||||
guard
|
||||
let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8),
|
||||
let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode(
|
||||
CallMessage.MessageInfo.self,
|
||||
from: infoMessageData
|
||||
),
|
||||
messageInfo.state == .incoming,
|
||||
let missedCallInfoData: Data = try? JSONEncoder().encode(missedCallInfo)
|
||||
else { return }
|
||||
|
||||
_ = try interaction
|
||||
.with(body: String(data: missedCallInfoData, encoding: .utf8))
|
||||
.saved(db)
|
||||
}
|
||||
let shouldMarkAsRead: Bool = try {
|
||||
if duration > 0 { return true }
|
||||
if hasStartedConnecting { return true }
|
||||
|
||||
switch mode {
|
||||
case .local:
|
||||
try updateToMissedIfNeeded()
|
||||
return true
|
||||
|
||||
case .remote, .unanswered:
|
||||
try updateToMissedIfNeeded()
|
||||
return false
|
||||
|
||||
case .answeredElsewhere: return true
|
||||
}
|
||||
}()
|
||||
|
||||
guard shouldMarkAsRead else { return }
|
||||
|
||||
try Interaction.markAsRead(
|
||||
db,
|
||||
interactionId: interaction.id,
|
||||
threadId: interaction.threadId,
|
||||
includingOlder: false,
|
||||
trySendReadReceipt: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Renderer
|
||||
// MARK: - Renderer
|
||||
|
||||
func attachRemoteVideoRenderer(_ renderer: RTCVideoRenderer) {
|
||||
webRTCSession.attachRemoteRenderer(renderer)
|
||||
}
|
||||
|
@ -283,14 +339,17 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
|||
webRTCSession.attachLocalRenderer(renderer)
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
// MARK: - Delegate
|
||||
|
||||
public func webRTCIsConnected() {
|
||||
self.invalidateTimeoutTimer()
|
||||
self.reconnectTimer?.invalidate()
|
||||
|
||||
guard !self.hasConnected else {
|
||||
hasReconnected?()
|
||||
return
|
||||
}
|
||||
|
||||
self.hasConnected = true
|
||||
self.answerCallAction?.fulfill()
|
||||
}
|
||||
|
@ -327,23 +386,32 @@ public final class SessionCall: NSObject, WebRTCSessionDelegate {
|
|||
|
||||
private func tryToReconnect() {
|
||||
reconnectTimer?.invalidate()
|
||||
if SSKEnvironment.shared.reachabilityManager.isReachable {
|
||||
Storage.write { transaction in
|
||||
self.webRTCSession.sendOffer(to: self.sessionID, using: transaction, isRestartingICEConnection: true).retainUntilComplete()
|
||||
}
|
||||
} else {
|
||||
|
||||
guard Environment.shared?.reachabilityManager.isReachable == true else {
|
||||
reconnectTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 5, repeats: false) { _ in
|
||||
self.tryToReconnect()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let sessionId: String = self.sessionId
|
||||
let webRTCSession: WebRTCSession = self.webRTCSession
|
||||
|
||||
Storage.shared
|
||||
.read { db in webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true) }
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
// MARK: Timeout
|
||||
// MARK: - Timeout
|
||||
|
||||
public func setupTimeoutTimer() {
|
||||
invalidateTimeoutTimer()
|
||||
let timeInterval: TimeInterval = hasConnected ? 60 : 30
|
||||
|
||||
let timeInterval: TimeInterval = (hasConnected ? 60 : 30)
|
||||
|
||||
timeOutTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: timeInterval, repeats: false) { _ in
|
||||
self.didTimeout = true
|
||||
|
||||
AppEnvironment.shared.callManager.endCall(self) { error in
|
||||
self.timeOutTimer = nil
|
||||
}
|
||||
|
|
|
@ -1,24 +1,37 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
|
||||
extension SessionCallManager {
|
||||
@discardableResult
|
||||
public func startCallAction() -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
call.startSessionCall()
|
||||
guard let call: CurrentCallProtocol = self.currentCall else { return false }
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
call.startSessionCall(db)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func answerCallAction() -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
guard let call: SessionCall = (self.currentCall as? SessionCall) else { return false }
|
||||
|
||||
if let _ = CurrentAppContext().frontmostViewController() as? CallVC {
|
||||
call.answerSessionCall()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else { return false } // FIXME: Handle more gracefully
|
||||
let callVC = CallVC(for: self.currentCall!)
|
||||
let callVC = CallVC(for: call)
|
||||
|
||||
if let conversationVC = presentingVC as? ConversationVC {
|
||||
callVC.conversationVC = conversationVC
|
||||
conversationVC.inputAccessoryView?.isHidden = true
|
||||
conversationVC.inputAccessoryView?.alpha = 0
|
||||
}
|
||||
|
||||
presentingVC.present(callVC, animated: true) {
|
||||
call.answerSessionCall()
|
||||
}
|
||||
|
@ -28,20 +41,26 @@ extension SessionCallManager {
|
|||
|
||||
@discardableResult
|
||||
public func endCallAction() -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
guard let call: SessionCall = (self.currentCall as? SessionCall) else { return false }
|
||||
|
||||
call.endSessionCall()
|
||||
|
||||
if call.didTimeout {
|
||||
reportCurrentCallEnded(reason: .unanswered)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
reportCurrentCallEnded(reason: nil)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func setMutedCallAction(isMuted: Bool) -> Bool {
|
||||
guard let call = self.currentCall else { return false }
|
||||
guard let call: SessionCall = (self.currentCall as? SessionCall) else { return false }
|
||||
|
||||
call.isMuted = isMuted
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import CallKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
|
@ -5,10 +8,12 @@ extension SessionCallManager {
|
|||
public func startCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||
guard case .offer = call.mode else { return }
|
||||
guard !call.hasConnected else { return }
|
||||
|
||||
reportOutgoingCall(call)
|
||||
|
||||
if callController != nil {
|
||||
let handle = CXHandle(type: .generic, value: call.sessionID)
|
||||
let startCallAction = CXStartCallAction(call: call.callID, handle: handle)
|
||||
let handle = CXHandle(type: .generic, value: call.sessionId)
|
||||
let startCallAction = CXStartCallAction(call: call.callId, handle: handle)
|
||||
|
||||
startCallAction.isVideo = false
|
||||
|
||||
|
@ -16,7 +21,8 @@ extension SessionCallManager {
|
|||
transaction.addAction(startCallAction)
|
||||
|
||||
requestTransaction(transaction, completion: completion)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
startCallAction()
|
||||
completion?(nil)
|
||||
}
|
||||
|
@ -24,12 +30,13 @@ extension SessionCallManager {
|
|||
|
||||
public func answerCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||
if callController != nil {
|
||||
let answerCallAction = CXAnswerCallAction(call: call.callID)
|
||||
let answerCallAction = CXAnswerCallAction(call: call.callId)
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(answerCallAction)
|
||||
|
||||
requestTransaction(transaction, completion: completion)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
answerCallAction()
|
||||
completion?(nil)
|
||||
}
|
||||
|
@ -37,12 +44,13 @@ extension SessionCallManager {
|
|||
|
||||
public func endCall(_ call: SessionCall, completion: ((Error?) -> Void)?) {
|
||||
if callController != nil {
|
||||
let endCallAction = CXEndCallAction(call: call.callID)
|
||||
let endCallAction = CXEndCallAction(call: call.callId)
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(endCallAction)
|
||||
|
||||
requestTransaction(transaction, completion: completion)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
endCallAction()
|
||||
completion?(nil)
|
||||
}
|
||||
|
@ -51,7 +59,7 @@ extension SessionCallManager {
|
|||
// Not currently in use
|
||||
public func setOnHoldStatus(for call: SessionCall) {
|
||||
if callController != nil {
|
||||
let setHeldCallAction = CXSetHeldCallAction(call: call.callID, onHold: true)
|
||||
let setHeldCallAction = CXSetHeldCallAction(call: call.callId, onHold: true)
|
||||
let transaction = CXTransaction()
|
||||
transaction.addAction(setHeldCallAction)
|
||||
|
||||
|
@ -63,9 +71,11 @@ extension SessionCallManager {
|
|||
callController?.request(transaction) { error in
|
||||
if let error = error {
|
||||
SNLog("Error requesting transaction: \(error)")
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
SNLog("Requested transaction successfully")
|
||||
}
|
||||
|
||||
completion?(error)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import CallKit
|
||||
import SignalCoreKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension SessionCallManager: CXProviderDelegate {
|
||||
public func providerDidReset(_ provider: CXProvider) {
|
||||
AssertIsOnMainThread()
|
||||
currentCall?.endSessionCall()
|
||||
(currentCall as? SessionCall)?.endSessionCall()
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||
AssertIsOnMainThread()
|
||||
if startCallAction() {
|
||||
action.fulfill()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
@ -18,14 +24,18 @@ extension SessionCallManager: CXProviderDelegate {
|
|||
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||
AssertIsOnMainThread()
|
||||
print("[CallKit] Perform CXAnswerCallAction")
|
||||
guard let call = self.currentCall else { return action.fail() }
|
||||
|
||||
guard let call: SessionCall = (self.currentCall as? SessionCall) else { return action.fail() }
|
||||
|
||||
if CurrentAppContext().isMainAppAndActive {
|
||||
if answerCallAction() {
|
||||
action.fulfill()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
action.fail()
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
call.answerSessionCallInBackground(action: action)
|
||||
}
|
||||
}
|
||||
|
@ -33,9 +43,11 @@ extension SessionCallManager: CXProviderDelegate {
|
|||
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
||||
print("[CallKit] Perform CXEndCallAction")
|
||||
AssertIsOnMainThread()
|
||||
|
||||
if endCallAction() {
|
||||
action.fulfill()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
@ -43,9 +55,11 @@ extension SessionCallManager: CXProviderDelegate {
|
|||
public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
||||
print("[CallKit] Perform CXSetMutedCallAction, isMuted: \(action.isMuted)")
|
||||
AssertIsOnMainThread()
|
||||
|
||||
if setMutedCallAction(isMuted: action.isMuted) {
|
||||
action.fulfill()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
action.fail()
|
||||
}
|
||||
}
|
||||
|
@ -61,7 +75,8 @@ extension SessionCallManager: CXProviderDelegate {
|
|||
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
||||
print("[CallKit] Audio session did activate.")
|
||||
AssertIsOnMainThread()
|
||||
guard let call = self.currentCall else { return }
|
||||
guard let call: SessionCall = (self.currentCall as? SessionCall) else { return }
|
||||
|
||||
call.webRTCSession.audioSessionDidActivate(audioSession)
|
||||
if call.isOutgoing && !call.hasConnected { CallRingTonePlayer.shared.startPlayingRingTone() }
|
||||
}
|
||||
|
@ -69,7 +84,8 @@ extension SessionCallManager: CXProviderDelegate {
|
|||
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
||||
print("[CallKit] Audio session did deactivate.")
|
||||
AssertIsOnMainThread()
|
||||
guard let call = self.currentCall else { return }
|
||||
guard let call: SessionCall = (self.currentCall as? SessionCall) else { return }
|
||||
|
||||
call.webRTCSession.audioSessionDidDeactivate(audioSession)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import CallKit
|
||||
import GRDB
|
||||
import SessionMessagingKit
|
||||
|
||||
public final class SessionCallManager: NSObject {
|
||||
public final class SessionCallManager: NSObject, CallManagerProtocol {
|
||||
let provider: CXProvider?
|
||||
let callController: CXCallController?
|
||||
var currentCall: SessionCall? = nil {
|
||||
|
||||
public var currentCall: CurrentCallProtocol? = nil {
|
||||
willSet {
|
||||
if (newValue != nil) {
|
||||
DispatchQueue.main.async {
|
||||
|
@ -19,13 +24,14 @@ public final class SessionCallManager: NSObject {
|
|||
}
|
||||
|
||||
private static var _sharedProvider: CXProvider?
|
||||
class func sharedProvider(useSystemCallLog: Bool) -> CXProvider {
|
||||
static func sharedProvider(useSystemCallLog: Bool) -> CXProvider {
|
||||
let configuration = buildProviderConfiguration(useSystemCallLog: useSystemCallLog)
|
||||
|
||||
if let sharedProvider = self._sharedProvider {
|
||||
sharedProvider.configuration = configuration
|
||||
return sharedProvider
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
SwiftSingletons.register(self)
|
||||
let provider = CXProvider(configuration: configuration)
|
||||
_sharedProvider = provider
|
||||
|
@ -33,9 +39,8 @@ public final class SessionCallManager: NSObject {
|
|||
}
|
||||
}
|
||||
|
||||
class func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration {
|
||||
let localizedName = NSLocalizedString("APPLICATION_NAME", comment: "Name of application")
|
||||
let providerConfiguration = CXProviderConfiguration(localizedName: localizedName)
|
||||
static func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration {
|
||||
let providerConfiguration = CXProviderConfiguration(localizedName: "Session")
|
||||
providerConfiguration.supportsVideo = true
|
||||
providerConfiguration.maximumCallGroups = 1
|
||||
providerConfiguration.maximumCallsPerCallGroup = 1
|
||||
|
@ -47,30 +52,37 @@ public final class SessionCallManager: NSObject {
|
|||
return providerConfiguration
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(useSystemCallLog: Bool = false) {
|
||||
AssertIsOnMainThread()
|
||||
if SSKPreferences.isCallKitSupported {
|
||||
self.provider = type(of: self).sharedProvider(useSystemCallLog: useSystemCallLog)
|
||||
if Preferences.isCallKitSupported {
|
||||
self.provider = SessionCallManager.sharedProvider(useSystemCallLog: useSystemCallLog)
|
||||
self.callController = CXCallController()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
self.provider = nil
|
||||
self.callController = nil
|
||||
}
|
||||
|
||||
super.init()
|
||||
|
||||
// We cannot assert singleton here, because this class gets rebuilt when the user changes relevant call settings
|
||||
self.provider?.setDelegate(self, queue: nil)
|
||||
}
|
||||
|
||||
// MARK: Report calls
|
||||
// MARK: - Report calls
|
||||
|
||||
public func reportOutgoingCall(_ call: SessionCall) {
|
||||
AssertIsOnMainThread()
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
||||
UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing")
|
||||
|
||||
call.stateDidChange = {
|
||||
if call.hasStartedConnecting {
|
||||
self.provider?.reportOutgoingCall(with: call.callID, startedConnectingAt: call.connectingDate)
|
||||
self.provider?.reportOutgoingCall(with: call.callId, startedConnectingAt: call.connectingDate)
|
||||
}
|
||||
|
||||
if call.hasConnected {
|
||||
self.provider?.reportOutgoingCall(with: call.callID, connectedAt: call.connectedDate)
|
||||
self.provider?.reportOutgoingCall(with: call.callId, connectedAt: call.connectedDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -82,47 +94,59 @@ public final class SessionCallManager: NSObject {
|
|||
// Construct a CXCallUpdate describing the incoming call, including the caller.
|
||||
let update = CXCallUpdate()
|
||||
update.localizedCallerName = callerName
|
||||
update.remoteHandle = CXHandle(type: .generic, value: call.callID.uuidString)
|
||||
update.remoteHandle = CXHandle(type: .generic, value: call.callId.uuidString)
|
||||
update.hasVideo = false
|
||||
|
||||
disableUnsupportedFeatures(callUpdate: update)
|
||||
|
||||
// Report the incoming call to the system
|
||||
provider.reportNewIncomingCall(with: call.callID, update: update) { error in
|
||||
provider.reportNewIncomingCall(with: call.callId, update: update) { error in
|
||||
guard error == nil else {
|
||||
self.reportCurrentCallEnded(reason: .failed)
|
||||
completion(error)
|
||||
return
|
||||
}
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
||||
UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing")
|
||||
completion(nil)
|
||||
}
|
||||
} else {
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(true, forKey: "isCallOngoing")
|
||||
UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing")
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
|
||||
public func reportCurrentCallEnded(reason: CXCallEndedReason?) {
|
||||
guard let call = currentCall else { return }
|
||||
if let reason = reason {
|
||||
self.provider?.reportCall(with: call.callID, endedAt: nil, reason: reason)
|
||||
switch (reason) {
|
||||
case .answeredElsewhere: call.updateCallMessage(mode: .answeredElsewhere)
|
||||
case .unanswered: call.updateCallMessage(mode: .unanswered)
|
||||
case .declinedElsewhere: call.updateCallMessage(mode: .local)
|
||||
default: call.updateCallMessage(mode: .remote)
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
self.reportCurrentCallEnded(reason: reason)
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let call = currentCall else { return }
|
||||
|
||||
if let reason = reason {
|
||||
self.provider?.reportCall(with: call.callId, endedAt: nil, reason: reason)
|
||||
|
||||
switch (reason) {
|
||||
case .answeredElsewhere: call.updateCallMessage(mode: .answeredElsewhere)
|
||||
case .unanswered: call.updateCallMessage(mode: .unanswered)
|
||||
case .declinedElsewhere: call.updateCallMessage(mode: .local)
|
||||
default: call.updateCallMessage(mode: .remote)
|
||||
}
|
||||
}
|
||||
else {
|
||||
call.updateCallMessage(mode: .local)
|
||||
}
|
||||
|
||||
call.webRTCSession.dropConnection()
|
||||
self.currentCall = nil
|
||||
WebRTCSession.current = nil
|
||||
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(false, forKey: "isCallOngoing")
|
||||
UserDefaults.sharedLokiProject?.set(false, forKey: "isCallOngoing")
|
||||
}
|
||||
|
||||
// MARK: Util
|
||||
// MARK: - Util
|
||||
|
||||
private func disableUnsupportedFeatures(callUpdate: CXCallUpdate) {
|
||||
// Call Holding is failing to restart audio when "swapping" calls on the CallKit screen
|
||||
// until user returns to in-app call screen.
|
||||
|
@ -136,17 +160,67 @@ public final class SessionCallManager: NSObject {
|
|||
callUpdate.supportsDTMF = false
|
||||
}
|
||||
|
||||
public func handleIncomingCallOfferInBusyState(offerMessage: CallMessage, using transaction: YapDatabaseReadWriteTransaction) {
|
||||
guard let caller = offerMessage.sender, let thread = TSContactThread.fetch(for: caller, using: transaction) else { return }
|
||||
let message = CallMessage()
|
||||
message.uuid = offerMessage.uuid
|
||||
message.kind = .endCall
|
||||
SNLog("[Calls] Sending end call message because there is an ongoing call.")
|
||||
MessageSender.sendNonDurably(message, in: thread, using: transaction).retainUntilComplete()
|
||||
let infoMessage = TSInfoMessage.from(offerMessage, associatedWith: thread)
|
||||
infoMessage.updateCallInfoMessage(.missed, using: transaction)
|
||||
// MARK: - UI
|
||||
|
||||
public func showCallUIForCall(caller: String, uuid: String, mode: CallMode, interactionId: Int64?) {
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
self.showCallUIForCall(caller: caller, uuid: uuid, mode: mode, interactionId: interactionId)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let call: SessionCall = Storage.shared.read({ db in SessionCall(db, for: caller, uuid: uuid, mode: mode) }) else {
|
||||
return
|
||||
}
|
||||
|
||||
call.callInteractionId = interactionId
|
||||
call.reportIncomingCallIfNeeded { error in
|
||||
if let error = error {
|
||||
SNLog("[Calls] Failed to report incoming call to CallKit due to error: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
guard CurrentAppContext().isMainAppAndActive else { return }
|
||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else {
|
||||
preconditionFailure() // FIXME: Handle more gracefully
|
||||
}
|
||||
|
||||
if let conversationVC: ConversationVC = presentingVC as? ConversationVC, conversationVC.viewModel.threadData.threadId == call.sessionId {
|
||||
let callVC = CallVC(for: call)
|
||||
callVC.conversationVC = conversationVC
|
||||
conversationVC.inputAccessoryView?.isHidden = true
|
||||
conversationVC.inputAccessoryView?.alpha = 0
|
||||
presentingVC.present(callVC, animated: true, completion: nil)
|
||||
}
|
||||
else if !Preferences.isCallKitSupported {
|
||||
let incomingCallBanner = IncomingCallBanner(for: call)
|
||||
incomingCallBanner.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func handleAnswerMessage(_ message: CallMessage) {
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
self.handleAnswerMessage(message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
(CurrentAppContext().frontmostViewController() as? CallVC)?.handleAnswerMessage(message)
|
||||
}
|
||||
|
||||
public func dismissAllCallUI() {
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
self.dismissAllCallUI()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
IncomingCallBanner.current?.dismiss()
|
||||
(CurrentAppContext().frontmostViewController() as? CallVC)?.handleEndCallMessage()
|
||||
MiniCallView.current?.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import SessionUtilitiesKit
|
|||
import UIKit
|
||||
import MediaPlayer
|
||||
|
||||
final class CallVC : UIViewController, VideoPreviewDelegate {
|
||||
final class CallVC: UIViewController, VideoPreviewDelegate {
|
||||
let call: SessionCall
|
||||
var latestKnownAudioOutputDeviceName: String?
|
||||
var durationTimer: Timer?
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import UIKit
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
@objc
|
||||
final class CallMissedTipsModal : Modal {
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
final class CallMissedTipsModal: Modal {
|
||||
private let caller: String
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(caller: String) {
|
||||
self.caller = caller
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
@ -26,27 +28,37 @@ final class CallMissedTipsModal : Modal {
|
|||
let tipsIconImageView = UIImageView(image: UIImage(named: "Tips")?.withTint(Colors.text))
|
||||
tipsIconImageView.set(.width, to: 19)
|
||||
tipsIconImageView.set(.height, to: 28)
|
||||
|
||||
// Tips icon container view
|
||||
let tipsIconContainerView = UIView()
|
||||
tipsIconContainerView.addSubview(tipsIconImageView)
|
||||
tipsIconImageView.pin(.top, to: .top, of: tipsIconContainerView)
|
||||
tipsIconImageView.pin(.bottom, to: .bottom, of: tipsIconContainerView)
|
||||
tipsIconImageView.center(in: tipsIconContainerView)
|
||||
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_call_missed_tips_title", comment: "")
|
||||
titleLabel.text = "modal_call_missed_tips_title".localized()
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = String(format: NSLocalizedString("modal_call_missed_tips_explanation", comment: ""), caller)
|
||||
messageLabel.text = message
|
||||
messageLabel.text = String(format: "modal_call_missed_tips_explanation".localized(), caller)
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .natural
|
||||
|
||||
// Cancel Button
|
||||
cancelButton.setTitle(NSLocalizedString("OK", comment: ""), for: .normal)
|
||||
cancelButton.setTitle("BUTTON_OK".localized(), for: .normal)
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ tipsIconImageView, titleLabel, messageLabel, cancelButton ])
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ tipsIconContainerView, titleLabel, messageLabel, cancelButton ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.alignment = .center
|
||||
mainStackView.alignment = .fill
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import WebRTC
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
||||
|
@ -82,8 +85,12 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
|||
self.layer.cornerRadius = Values.largeSpacing
|
||||
self.layer.masksToBounds = true
|
||||
self.set(.height, to: 100)
|
||||
profilePictureView.publicKey = call.sessionID
|
||||
profilePictureView.update()
|
||||
|
||||
profilePictureView.update(
|
||||
publicKey: call.sessionId,
|
||||
profile: Profile.fetchOrCreate(id: call.sessionId),
|
||||
threadVariant: .contact
|
||||
)
|
||||
displayNameLabel.text = call.contactName
|
||||
let stackView = UIStackView(arrangedSubviews: [profilePictureView, displayNameLabel, hangUpButton, answerButton])
|
||||
stackView.axis = .horizontal
|
||||
|
|
|
@ -1,63 +1,77 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc(SNEditClosedGroupVC)
|
||||
final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate {
|
||||
private let thread: TSGroupThread
|
||||
private var name = ""
|
||||
private var zombies: Set<String> = []
|
||||
private var membersAndZombies: [String] = [] { didSet { handleMembersChanged() } }
|
||||
final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate {
|
||||
private struct GroupMemberDisplayInfo: FetchableRecord, Decodable {
|
||||
let profileId: String
|
||||
let role: GroupMember.Role
|
||||
let profile: Profile?
|
||||
}
|
||||
|
||||
private let threadId: String
|
||||
private var originalName: String = ""
|
||||
private var originalMembersAndZombieIds: Set<String> = []
|
||||
private var name: String = ""
|
||||
private var hasContactsToAdd: Bool = false
|
||||
private var userPublicKey: String = ""
|
||||
private var membersAndZombies: [GroupMemberDisplayInfo] = []
|
||||
private var adminIds: Set<String> = []
|
||||
private var isEditingGroupName = false { didSet { handleIsEditingGroupNameChanged() } }
|
||||
private var tableViewHeightConstraint: NSLayoutConstraint!
|
||||
|
||||
private lazy var groupPublicKey: String = {
|
||||
let groupID = thread.groupModel.groupId
|
||||
return LKGroupUtilities.getDecodedGroupID(groupID)
|
||||
}()
|
||||
// MARK: - Components
|
||||
|
||||
// MARK: Components
|
||||
private lazy var groupNameLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var groupNameTextField: TextField = {
|
||||
let result = TextField(placeholder: "Enter a group name", usesDefaultHeight: false)
|
||||
let result: TextField = TextField(placeholder: "Enter a group name", usesDefaultHeight: false)
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var addMembersButton: Button = {
|
||||
let result = Button(style: .prominentOutline, size: .large)
|
||||
let result: Button = Button(style: .prominentOutline, size: .large)
|
||||
result.setTitle("Add Members", for: UIControl.State.normal)
|
||||
result.addTarget(self, action: #selector(addMembers), for: UIControl.Event.touchUpInside)
|
||||
result.contentEdgeInsets = UIEdgeInsets(top: 0, leading: Values.mediumSpacing, bottom: 0, trailing: Values.mediumSpacing)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
@objc private lazy var tableView: UITableView = {
|
||||
let result = UITableView()
|
||||
let result: UITableView = UITableView()
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
result.register(UserCell.self, forCellReuseIdentifier: "UserCell")
|
||||
result.separatorStyle = .none
|
||||
result.backgroundColor = .clear
|
||||
result.isScrollEnabled = false
|
||||
result.register(view: UserCell.self)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
@objc(initWithThreadID:)
|
||||
init(with threadID: String) {
|
||||
var thread: TSGroupThread!
|
||||
Storage.read { transaction in
|
||||
thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction)!
|
||||
}
|
||||
self.thread = thread
|
||||
// MARK: - Lifecycle
|
||||
|
||||
@objc(initWithThreadId:)
|
||||
init(with threadId: String) {
|
||||
self.threadId = threadId
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
@ -67,27 +81,62 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setUpGradientBackground()
|
||||
setUpNavBarStyle()
|
||||
setNavBarTitle("Edit Group")
|
||||
|
||||
let backButton = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil)
|
||||
backButton.tintColor = Colors.text
|
||||
navigationItem.backBarButtonItem = backButton
|
||||
func getDisplayName(for publicKey: String) -> String {
|
||||
return Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey
|
||||
|
||||
let threadId: String = self.threadId
|
||||
|
||||
Storage.shared.read { [weak self] db in
|
||||
self?.userPublicKey = getUserHexEncodedPublicKey(db)
|
||||
self?.name = try ClosedGroup
|
||||
.select(.name)
|
||||
.filter(id: threadId)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db)
|
||||
.defaulting(to: "Group")
|
||||
self?.originalName = (self?.name ?? "")
|
||||
|
||||
let profileAlias: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
let allGroupMembers: [GroupMemberDisplayInfo] = try GroupMember
|
||||
.filter(GroupMember.Columns.groupId == threadId)
|
||||
.including(optional: GroupMember.profile.aliased(profileAlias))
|
||||
.order(
|
||||
(GroupMember.Columns.role == GroupMember.Role.zombie), // Non-zombies at the top
|
||||
profileAlias[.nickname],
|
||||
profileAlias[.name],
|
||||
GroupMember.Columns.profileId
|
||||
)
|
||||
.asRequest(of: GroupMemberDisplayInfo.self)
|
||||
.fetchAll(db)
|
||||
self?.membersAndZombies = allGroupMembers
|
||||
.filter { $0.role == .standard || $0.role == .zombie }
|
||||
self?.adminIds = allGroupMembers
|
||||
.filter { $0.role == .admin }
|
||||
.map { $0.profileId }
|
||||
.asSet()
|
||||
|
||||
let uniqueGroupMemberIds: Set<String> = allGroupMembers
|
||||
.map { $0.profileId }
|
||||
.asSet()
|
||||
self?.originalMembersAndZombieIds = uniqueGroupMemberIds
|
||||
self?.hasContactsToAdd = ((try Profile.fetchCount(db) - uniqueGroupMemberIds.count) > 0)
|
||||
}
|
||||
|
||||
setUpViewHierarchy()
|
||||
// Always show zombies at the bottom
|
||||
zombies = Storage.shared.getZombieMembers(for: groupPublicKey)
|
||||
membersAndZombies = GroupUtilities.getClosedGroupMembers(thread).sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
|
||||
+ zombies.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
|
||||
updateNavigationBarButtons()
|
||||
name = thread.groupModel.groupName!
|
||||
handleMembersChanged()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
// Group name container
|
||||
groupNameLabel.text = thread.groupModel.groupName
|
||||
groupNameLabel.text = name
|
||||
|
||||
let groupNameContainer = UIView()
|
||||
groupNameContainer.addSubview(groupNameLabel)
|
||||
groupNameLabel.pin(to: groupNameContainer)
|
||||
|
@ -95,6 +144,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
groupNameTextField.pin(to: groupNameContainer)
|
||||
groupNameContainer.set(.height, to: 40)
|
||||
groupNameTextField.alpha = 0
|
||||
|
||||
// Top container
|
||||
let topContainer = UIView()
|
||||
topContainer.addSubview(groupNameContainer)
|
||||
|
@ -102,19 +152,21 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
topContainer.set(.height, to: 40)
|
||||
let topContainerTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(showEditGroupNameUI))
|
||||
topContainer.addGestureRecognizer(topContainerTapGestureRecognizer)
|
||||
|
||||
// Members label
|
||||
let membersLabel = UILabel()
|
||||
membersLabel.textColor = Colors.text
|
||||
membersLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
membersLabel.text = "Members"
|
||||
|
||||
// Add members button
|
||||
let hasContactsToAdd = !Set(ContactUtilities.getAllContacts()).subtracting(self.membersAndZombies).isEmpty
|
||||
if (!hasContactsToAdd) {
|
||||
if !self.hasContactsToAdd {
|
||||
addMembersButton.isUserInteractionEnabled = false
|
||||
let disabledColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
addMembersButton.layer.borderColor = disabledColor.cgColor
|
||||
addMembersButton.setTitleColor(disabledColor, for: UIControl.State.normal)
|
||||
}
|
||||
|
||||
// Middle stack view
|
||||
let middleStackView = UIStackView(arrangedSubviews: [ membersLabel, addMembersButton ])
|
||||
middleStackView.axis = .horizontal
|
||||
|
@ -122,8 +174,10 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
middleStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.mediumSpacing, bottom: Values.smallSpacing, trailing: Values.mediumSpacing)
|
||||
middleStackView.isLayoutMarginsRelativeArrangement = true
|
||||
middleStackView.set(.height, to: Values.largeButtonHeight + Values.smallSpacing * 2)
|
||||
|
||||
// Table view
|
||||
tableViewHeightConstraint = tableView.set(.height, to: 0)
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [
|
||||
UIView.vSpacer(Values.veryLargeSpacing),
|
||||
|
@ -137,6 +191,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
mainStackView.axis = .vertical
|
||||
mainStackView.alignment = .fill
|
||||
mainStackView.set(.width, to: UIScreen.main.bounds.width)
|
||||
|
||||
// Scroll view
|
||||
let scrollView = UIScrollView()
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
|
@ -152,41 +207,49 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell
|
||||
let publicKey = membersAndZombies[indexPath.row]
|
||||
cell.publicKey = publicKey
|
||||
cell.isZombie = zombies.contains(publicKey)
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
let isCurrentUserAdmin = thread.groupModel.groupAdminIds.contains(userPublicKey)
|
||||
cell.accessory = !isCurrentUserAdmin ? .lock : .none
|
||||
cell.update()
|
||||
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
|
||||
cell.update(
|
||||
with: membersAndZombies[indexPath.row].profileId,
|
||||
profile: membersAndZombies[indexPath.row].profile,
|
||||
isZombie: (membersAndZombies[indexPath.row].role == .zombie),
|
||||
accessory: (adminIds.contains(userPublicKey) ?
|
||||
.none :
|
||||
.lock
|
||||
)
|
||||
)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
return thread.groupModel.groupAdminIds.contains(userPublicKey)
|
||||
return adminIds.contains(userPublicKey)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
||||
let publicKey = membersAndZombies[indexPath.row]
|
||||
let profileId: String = self.membersAndZombies[indexPath.row].profileId
|
||||
|
||||
let removeAction = UITableViewRowAction(style: .destructive, title: "Remove") { [weak self] _, _ in
|
||||
guard let self = self, let index = self.membersAndZombies.firstIndex(of: publicKey) else { return }
|
||||
self.membersAndZombies.remove(at: index)
|
||||
self?.adminIds.remove(profileId)
|
||||
self?.membersAndZombies.remove(at: indexPath.row)
|
||||
self?.handleMembersChanged()
|
||||
}
|
||||
removeAction.backgroundColor = Colors.destructive
|
||||
|
||||
return [ removeAction ]
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
// MARK: - Updating
|
||||
|
||||
private func updateNavigationBarButtons() {
|
||||
if isEditingGroupName {
|
||||
let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(handleCancelGroupNameEditingButtonTapped))
|
||||
cancelButton.tintColor = Colors.text
|
||||
navigationItem.leftBarButtonItem = cancelButton
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
navigationItem.leftBarButtonItem = nil
|
||||
}
|
||||
|
||||
let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(handleDoneButtonTapped))
|
||||
doneButton.tintColor = Colors.text
|
||||
navigationItem.rightBarButtonItem = doneButton
|
||||
|
@ -196,21 +259,25 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 67
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
|
||||
private func handleIsEditingGroupNameChanged() {
|
||||
updateNavigationBarButtons()
|
||||
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.groupNameLabel.alpha = self.isEditingGroupName ? 0 : 1
|
||||
self.groupNameTextField.alpha = self.isEditingGroupName ? 1 : 0
|
||||
}
|
||||
|
||||
if isEditingGroupName {
|
||||
groupNameTextField.becomeFirstResponder()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
groupNameTextField.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func showEditGroupNameUI() {
|
||||
isEditingGroupName = true
|
||||
}
|
||||
|
@ -222,93 +289,163 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
|
|||
@objc private func handleDoneButtonTapped() {
|
||||
if isEditingGroupName {
|
||||
updateGroupName()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
commitChanges()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateGroupName() {
|
||||
let name = groupNameTextField.text!.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
guard !name.isEmpty else {
|
||||
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_missing_error", comment: ""))
|
||||
let updatedName: String = groupNameTextField.text
|
||||
.defaulting(to: "")
|
||||
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
|
||||
guard !updatedName.isEmpty else {
|
||||
return showError(title: "vc_create_closed_group_group_name_missing_error".lowercased())
|
||||
}
|
||||
guard name.count < 64 else {
|
||||
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_too_long_error", comment: ""))
|
||||
guard updatedName.count < 64 else {
|
||||
return showError(title: "vc_create_closed_group_group_name_too_long_error".localized())
|
||||
}
|
||||
|
||||
isEditingGroupName = false
|
||||
self.name = name
|
||||
groupNameLabel.text = name
|
||||
groupNameLabel.text = updatedName
|
||||
self.name = updatedName
|
||||
}
|
||||
|
||||
@objc private func addMembers() {
|
||||
let title = "Add Members"
|
||||
let userSelectionVC = UserSelectionVC(with: title, excluding: Set(membersAndZombies)) { [weak self] selectedUsers in
|
||||
guard let self = self else { return }
|
||||
var members = self.membersAndZombies
|
||||
members.append(contentsOf: selectedUsers)
|
||||
func getDisplayName(for publicKey: String) -> String {
|
||||
return Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey
|
||||
|
||||
let userSelectionVC: UserSelectionVC = UserSelectionVC(
|
||||
with: title,
|
||||
excluding: membersAndZombies
|
||||
.map { $0.profileId }
|
||||
.asSet()
|
||||
) { [weak self] selectedUserIds in
|
||||
Storage.shared.read { [weak self] db in
|
||||
let selectedGroupMembers: [GroupMemberDisplayInfo] = try Profile
|
||||
.filter(selectedUserIds.contains(Profile.Columns.id))
|
||||
.fetchAll(db)
|
||||
.map { profile in
|
||||
GroupMemberDisplayInfo(
|
||||
profileId: profile.id,
|
||||
role: .standard,
|
||||
profile: profile
|
||||
)
|
||||
}
|
||||
self?.membersAndZombies = (self?.membersAndZombies ?? [])
|
||||
.appending(contentsOf: selectedGroupMembers)
|
||||
.sorted(by: { lhs, rhs in
|
||||
if lhs.role == .zombie && rhs.role != .zombie {
|
||||
return false
|
||||
}
|
||||
else if lhs.role != .zombie && rhs.role == .zombie {
|
||||
return true
|
||||
}
|
||||
|
||||
let lhsDisplayName: String = Profile.displayName(
|
||||
for: .contact,
|
||||
id: lhs.profileId,
|
||||
name: lhs.profile?.name,
|
||||
nickname: lhs.profile?.nickname
|
||||
)
|
||||
let rhsDisplayName: String = Profile.displayName(
|
||||
for: .contact,
|
||||
id: rhs.profileId,
|
||||
name: rhs.profile?.name,
|
||||
nickname: rhs.profile?.nickname
|
||||
)
|
||||
|
||||
return (lhsDisplayName < rhsDisplayName)
|
||||
})
|
||||
.filter { $0.role == .standard || $0.role == .zombie }
|
||||
|
||||
let uniqueGroupMemberIds: Set<String> = (self?.membersAndZombies ?? [])
|
||||
.map { $0.profileId }
|
||||
.asSet()
|
||||
.inserting(contentsOf: self?.adminIds)
|
||||
self?.hasContactsToAdd = ((try Profile.fetchCount(db) - uniqueGroupMemberIds.count) > 0)
|
||||
}
|
||||
self.membersAndZombies = members.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
|
||||
let hasContactsToAdd = !Set(ContactUtilities.getAllContacts()).subtracting(self.membersAndZombies).isEmpty
|
||||
self.addMembersButton.isUserInteractionEnabled = hasContactsToAdd
|
||||
let color = hasContactsToAdd ? Colors.accent : Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
self.addMembersButton.layer.borderColor = color.cgColor
|
||||
self.addMembersButton.setTitleColor(color, for: UIControl.State.normal)
|
||||
|
||||
let color = (self?.hasContactsToAdd == true ?
|
||||
Colors.accent :
|
||||
Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
)
|
||||
self?.addMembersButton.isUserInteractionEnabled = (self?.hasContactsToAdd == true)
|
||||
self?.addMembersButton.layer.borderColor = color.cgColor
|
||||
self?.addMembersButton.setTitleColor(color, for: UIControl.State.normal)
|
||||
self?.handleMembersChanged()
|
||||
}
|
||||
navigationController!.pushViewController(userSelectionVC, animated: true, completion: nil)
|
||||
|
||||
navigationController?.pushViewController(userSelectionVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
private func commitChanges() {
|
||||
let popToConversationVC: (EditClosedGroupVC) -> Void = { editVC in
|
||||
if let conversationVC = editVC.navigationController!.viewControllers.first(where: { $0 is ConversationVC }) {
|
||||
editVC.navigationController!.popToViewController(conversationVC, animated: true)
|
||||
} else {
|
||||
editVC.navigationController!.popViewController(animated: true)
|
||||
let popToConversationVC: ((EditClosedGroupVC?) -> ()) = { editVC in
|
||||
guard
|
||||
let viewControllers: [UIViewController] = editVC?.navigationController?.viewControllers,
|
||||
let conversationVC: ConversationVC = viewControllers.first(where: { $0 is ConversationVC }) as? ConversationVC
|
||||
else {
|
||||
editVC?.navigationController?.popViewController(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
editVC?.navigationController?.popToViewController(conversationVC, animated: true)
|
||||
}
|
||||
let storage = SNMessagingKitConfiguration.shared.storage
|
||||
let members = Set(self.membersAndZombies)
|
||||
let name = self.name
|
||||
let zombies = storage.getZombieMembers(for: groupPublicKey)
|
||||
guard members != Set(thread.groupModel.groupMemberIds + zombies) || name != thread.groupModel.groupName else {
|
||||
|
||||
let threadId: String = self.threadId
|
||||
let updatedName: String = self.name
|
||||
let userPublicKey: String = self.userPublicKey
|
||||
let updatedMemberIds: Set<String> = self.membersAndZombies
|
||||
.map { $0.profileId }
|
||||
.asSet()
|
||||
|
||||
guard updatedMemberIds != self.originalMembersAndZombieIds || updatedName != self.originalName else {
|
||||
return popToConversationVC(self)
|
||||
}
|
||||
if !members.contains(getUserHexEncodedPublicKey()) {
|
||||
guard Set(thread.groupModel.groupMemberIds).subtracting([ getUserHexEncodedPublicKey() ]) == members else {
|
||||
return showError(title: "Couldn't Update Group", message: "Can't leave while adding or removing other members.")
|
||||
|
||||
if !updatedMemberIds.contains(userPublicKey) {
|
||||
guard self.originalMembersAndZombieIds.removing(userPublicKey) == updatedMemberIds else {
|
||||
return showError(
|
||||
title: "Couldn't Update Group",
|
||||
message: "Can't leave while adding or removing other members."
|
||||
)
|
||||
}
|
||||
}
|
||||
guard members.count <= 100 else {
|
||||
return showError(title: NSLocalizedString("vc_create_closed_group_too_many_group_members_error", comment: ""))
|
||||
guard updatedMemberIds.count <= 100 else {
|
||||
return showError(title: "vc_create_closed_group_too_many_group_members_error".localized())
|
||||
}
|
||||
var promise: Promise<Void>!
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController!) { [groupPublicKey, weak self] _ in
|
||||
Storage.write(with: { transaction in
|
||||
if !members.contains(getUserHexEncodedPublicKey()) {
|
||||
promise = MessageSender.leave(groupPublicKey, using: transaction)
|
||||
} else {
|
||||
promise = MessageSender.update(groupPublicKey, with: members, name: name, transaction: transaction)
|
||||
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
|
||||
Storage.shared
|
||||
.writeAsync { db in
|
||||
if !updatedMemberIds.contains(userPublicKey) {
|
||||
return try MessageSender.leave(db, groupPublicKey: threadId)
|
||||
}
|
||||
|
||||
return try MessageSender.update(
|
||||
db,
|
||||
groupPublicKey: threadId,
|
||||
with: updatedMemberIds,
|
||||
name: updatedName
|
||||
)
|
||||
}
|
||||
}, completion: {
|
||||
let _ = promise.done(on: DispatchQueue.main) {
|
||||
guard let self = self else { return }
|
||||
MentionsManager.populateUserPublicKeyCacheIfNeeded(for: self.thread.uniqueId!)
|
||||
self.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
.done(on: DispatchQueue.main) { [weak self] in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
popToConversationVC(self)
|
||||
}
|
||||
promise.catch(on: DispatchQueue.main) { error in
|
||||
.catch(on: DispatchQueue.main) { [weak self] error in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
self?.showError(title: "Couldn't Update Group", message: error.localizedDescription)
|
||||
}
|
||||
})
|
||||
.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Convenience
|
||||
// MARK: - Convenience
|
||||
|
||||
private func showError(title: String, message: String = "") {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
||||
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||
presentAlert(alert)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
private protocol TableViewTouchDelegate {
|
||||
|
||||
func tableViewWasTouched(_ tableView: TableView)
|
||||
}
|
||||
|
||||
private final class TableView : UITableView {
|
||||
private final class TableView: UITableView {
|
||||
var touchDelegate: TableViewTouchDelegate?
|
||||
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
|
@ -14,107 +19,127 @@ private final class TableView : UITableView {
|
|||
}
|
||||
}
|
||||
|
||||
final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate {
|
||||
private let contacts = ContactUtilities.getAllContacts()
|
||||
final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate {
|
||||
private let contactProfiles: [Profile] = Profile.fetchAllContactProfiles(excludeCurrentUser: true)
|
||||
private var selectedContacts: Set<String> = []
|
||||
|
||||
// MARK: Components
|
||||
private lazy var nameTextField = TextField(placeholder: NSLocalizedString("vc_create_closed_group_text_field_hint", comment: ""))
|
||||
// MARK: - Components
|
||||
|
||||
private lazy var nameTextField = TextField(placeholder: "vc_create_closed_group_text_field_hint".localized())
|
||||
|
||||
private lazy var tableView: TableView = {
|
||||
let result = TableView()
|
||||
let result: TableView = TableView()
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
result.touchDelegate = self
|
||||
result.register(UserCell.self, forCellReuseIdentifier: "UserCell")
|
||||
result.separatorStyle = .none
|
||||
result.backgroundColor = .clear
|
||||
result.isScrollEnabled = false
|
||||
result.register(view: UserCell.self)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
setUpGradientBackground()
|
||||
setUpNavBarStyle()
|
||||
|
||||
let customTitleFontSize = Values.largeFontSize
|
||||
setNavBarTitle(NSLocalizedString("vc_create_closed_group_title", comment: ""), customFontSize: customTitleFontSize)
|
||||
setNavBarTitle("vc_create_closed_group_title".localized(), customFontSize: customTitleFontSize)
|
||||
|
||||
// Set up navigation bar buttons
|
||||
let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close))
|
||||
closeButton.tintColor = Colors.text
|
||||
navigationItem.leftBarButtonItem = closeButton
|
||||
|
||||
let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(createClosedGroup))
|
||||
doneButton.tintColor = Colors.text
|
||||
navigationItem.rightBarButtonItem = doneButton
|
||||
|
||||
// Set up content
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
if !contacts.isEmpty {
|
||||
let mainStackView = UIStackView()
|
||||
mainStackView.axis = .vertical
|
||||
nameTextField.delegate = self
|
||||
let nameTextFieldContainer = UIView()
|
||||
nameTextFieldContainer.addSubview(nameTextField)
|
||||
nameTextField.pin(.leading, to: .leading, of: nameTextFieldContainer, withInset: Values.largeSpacing)
|
||||
nameTextField.pin(.top, to: .top, of: nameTextFieldContainer, withInset: Values.mediumSpacing)
|
||||
nameTextFieldContainer.pin(.trailing, to: .trailing, of: nameTextField, withInset: Values.largeSpacing)
|
||||
nameTextFieldContainer.pin(.bottom, to: .bottom, of: nameTextField, withInset: Values.largeSpacing)
|
||||
mainStackView.addArrangedSubview(nameTextFieldContainer)
|
||||
let separator = UIView()
|
||||
separator.backgroundColor = Colors.separator
|
||||
separator.set(.height, to: Values.separatorThickness)
|
||||
mainStackView.addArrangedSubview(separator)
|
||||
tableView.set(.height, to: CGFloat(contacts.count * 65)) // A cell is exactly 65 points high
|
||||
tableView.set(.width, to: UIScreen.main.bounds.width)
|
||||
mainStackView.addArrangedSubview(tableView)
|
||||
let scrollView = UIScrollView(wrapping: mainStackView, withInsets: UIEdgeInsets.zero)
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.delegate = self
|
||||
view.addSubview(scrollView)
|
||||
scrollView.set(.width, to: UIScreen.main.bounds.width)
|
||||
scrollView.pin(to: view)
|
||||
} else {
|
||||
let explanationLabel = UILabel()
|
||||
guard !contactProfiles.isEmpty else {
|
||||
let explanationLabel: UILabel = UILabel()
|
||||
explanationLabel.textColor = Colors.text
|
||||
explanationLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
explanationLabel.numberOfLines = 0
|
||||
explanationLabel.lineBreakMode = .byWordWrapping
|
||||
explanationLabel.textAlignment = .center
|
||||
explanationLabel.text = NSLocalizedString("vc_create_closed_group_empty_state_message", comment: "")
|
||||
let createNewPrivateChatButton = Button(style: .prominentOutline, size: .large)
|
||||
|
||||
let createNewPrivateChatButton: Button = Button(style: .prominentOutline, size: .large)
|
||||
createNewPrivateChatButton.setTitle(NSLocalizedString("vc_create_closed_group_empty_state_button_title", comment: ""), for: UIControl.State.normal)
|
||||
createNewPrivateChatButton.addTarget(self, action: #selector(createNewDM), for: UIControl.Event.touchUpInside)
|
||||
createNewPrivateChatButton.set(.width, to: 196)
|
||||
let stackView = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ])
|
||||
|
||||
let stackView: UIStackView = UIStackView(arrangedSubviews: [ explanationLabel, createNewPrivateChatButton ])
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = Values.mediumSpacing
|
||||
stackView.alignment = .center
|
||||
view.addSubview(stackView)
|
||||
stackView.center(.horizontal, in: view)
|
||||
|
||||
let verticalCenteringConstraint = stackView.center(.vertical, in: view)
|
||||
verticalCenteringConstraint.constant = -16 // Makes things appear centered visually
|
||||
return
|
||||
}
|
||||
|
||||
let mainStackView: UIStackView = UIStackView()
|
||||
mainStackView.axis = .vertical
|
||||
nameTextField.delegate = self
|
||||
|
||||
let nameTextFieldContainer: UIView = UIView()
|
||||
nameTextFieldContainer.addSubview(nameTextField)
|
||||
nameTextField.pin(.leading, to: .leading, of: nameTextFieldContainer, withInset: Values.largeSpacing)
|
||||
nameTextField.pin(.top, to: .top, of: nameTextFieldContainer, withInset: Values.mediumSpacing)
|
||||
nameTextFieldContainer.pin(.trailing, to: .trailing, of: nameTextField, withInset: Values.largeSpacing)
|
||||
nameTextFieldContainer.pin(.bottom, to: .bottom, of: nameTextField, withInset: Values.largeSpacing)
|
||||
mainStackView.addArrangedSubview(nameTextFieldContainer)
|
||||
|
||||
let separator: UIView = UIView()
|
||||
separator.backgroundColor = Colors.separator
|
||||
separator.set(.height, to: Values.separatorThickness)
|
||||
mainStackView.addArrangedSubview(separator)
|
||||
tableView.set(.height, to: CGFloat(contactProfiles.count * 65)) // A cell is exactly 65 points high
|
||||
tableView.set(.width, to: UIScreen.main.bounds.width)
|
||||
mainStackView.addArrangedSubview(tableView)
|
||||
|
||||
let scrollView: UIScrollView = UIScrollView(wrapping: mainStackView, withInsets: UIEdgeInsets.zero)
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.delegate = self
|
||||
view.addSubview(scrollView)
|
||||
|
||||
scrollView.set(.width, to: UIScreen.main.bounds.width)
|
||||
scrollView.pin(to: view)
|
||||
}
|
||||
|
||||
// MARK: Table View Data Source
|
||||
// MARK: - Table View Data Source
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return contacts.count
|
||||
return contactProfiles.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell
|
||||
let publicKey = contacts[indexPath.row]
|
||||
cell.publicKey = publicKey
|
||||
let isSelected = selectedContacts.contains(publicKey)
|
||||
cell.accessory = .tick(isSelected: isSelected)
|
||||
cell.update()
|
||||
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
|
||||
cell.update(
|
||||
with: contactProfiles[indexPath.row].id,
|
||||
profile: contactProfiles[indexPath.row],
|
||||
isZombie: false,
|
||||
accessory: .tick(isSelected: selectedContacts.contains(contactProfiles[indexPath.row].id))
|
||||
)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||
crossfadeLabel.text = textField.text!.isEmpty ? NSLocalizedString("vc_create_closed_group_title", comment: "") : textField.text!
|
||||
}
|
||||
|
@ -135,13 +160,15 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
let publicKey = contacts[indexPath.row]
|
||||
if !selectedContacts.contains(publicKey) { selectedContacts.insert(publicKey) } else { selectedContacts.remove(publicKey) }
|
||||
guard let cell = tableView.cellForRow(at: indexPath) as? UserCell else { return }
|
||||
let isSelected = selectedContacts.contains(publicKey)
|
||||
cell.accessory = .tick(isSelected: isSelected)
|
||||
cell.update()
|
||||
if !selectedContacts.contains(contactProfiles[indexPath.row].id) {
|
||||
selectedContacts.insert(contactProfiles[indexPath.row].id)
|
||||
}
|
||||
else {
|
||||
selectedContacts.remove(contactProfiles[indexPath.row].id)
|
||||
}
|
||||
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
tableView.reloadRows(at: [indexPath], with: .none)
|
||||
}
|
||||
|
||||
@objc private func close() {
|
||||
|
@ -169,28 +196,34 @@ final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
let selectedContacts = self.selectedContacts
|
||||
let message: String? = (selectedContacts.count > 20) ? "Please wait while the group is created..." : nil
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in
|
||||
var promise: Promise<TSGroupThread>!
|
||||
Storage.writeSync { transaction in
|
||||
promise = MessageSender.createClosedGroup(name: name, members: selectedContacts, transaction: transaction)
|
||||
}
|
||||
let _ = promise.done(on: DispatchQueue.main) { thread in
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
||||
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false)
|
||||
}
|
||||
promise.catch(on: DispatchQueue.main) { _ in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
let title = "Couldn't Create Group"
|
||||
let message = "Please check your internet connection and try again."
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
||||
self?.presentAlert(alert)
|
||||
}
|
||||
Storage.shared
|
||||
.writeAsync { db in
|
||||
try MessageSender.createClosedGroup(db, name: name, members: selectedContacts)
|
||||
}
|
||||
.done(on: DispatchQueue.main) { thread in
|
||||
Storage.shared.writeAsync { db in
|
||||
try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
|
||||
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
SessionApp.presentConversation(for: thread.id, action: .compose, animated: false)
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { [weak self] _ in
|
||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||
|
||||
let title = "Couldn't Create Group"
|
||||
let message = "Please check your internet connection and try again."
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
||||
self?.presentAlert(alert)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func createNewDM() {
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
SignalApp.shared().homeViewController!.createNewDM()
|
||||
|
||||
SessionApp.homeViewController.wrappedValue?.createNewDM()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,101 +1,191 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
extension ContextMenuVC {
|
||||
|
||||
struct Action {
|
||||
let icon: UIImage
|
||||
let icon: UIImage?
|
||||
let title: String
|
||||
let isEmojiAction: Bool
|
||||
let isEmojiPlus: Bool
|
||||
let isDismissAction: Bool
|
||||
let work: () -> Void
|
||||
|
||||
static func reply(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
let title = NSLocalizedString("context_menu_reply", comment: "")
|
||||
return Action(icon: UIImage(named: "ic_reply")!, title: title) { delegate?.reply(viewItem) }
|
||||
}
|
||||
|
||||
static func copy(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
let title = NSLocalizedString("copy", comment: "")
|
||||
return Action(icon: UIImage(named: "ic_copy")!, title: title) { delegate?.copy(viewItem) }
|
||||
}
|
||||
|
||||
static func copySessionID(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
let title = NSLocalizedString("vc_conversation_settings_copy_session_id_button_title", comment: "")
|
||||
return Action(icon: UIImage(named: "ic_copy")!, title: title) { delegate?.copySessionID(viewItem) }
|
||||
}
|
||||
|
||||
static func delete(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
let title = NSLocalizedString("TXT_DELETE_TITLE", comment: "")
|
||||
return Action(icon: UIImage(named: "ic_trash")!, title: title) { delegate?.delete(viewItem) }
|
||||
}
|
||||
|
||||
static func save(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
let title = NSLocalizedString("context_menu_save", comment: "")
|
||||
return Action(icon: UIImage(named: "ic_download")!, title: title) { delegate?.save(viewItem) }
|
||||
}
|
||||
|
||||
static func ban(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
let title = NSLocalizedString("context_menu_ban_user", comment: "")
|
||||
return Action(icon: UIImage(named: "ic_block")!, title: title) { delegate?.ban(viewItem) }
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
icon: UIImage? = nil,
|
||||
title: String = "",
|
||||
isEmojiAction: Bool = false,
|
||||
isEmojiPlus: Bool = false,
|
||||
isDismissAction: Bool = false,
|
||||
work: @escaping () -> Void
|
||||
) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self.isEmojiAction = isEmojiAction
|
||||
self.isEmojiPlus = isEmojiPlus
|
||||
self.isDismissAction = isDismissAction
|
||||
self.work = work
|
||||
}
|
||||
|
||||
static func banAndDeleteAllMessages(_ viewItem: ConversationViewItem, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
let title = NSLocalizedString("context_menu_ban_and_delete_all", comment: "")
|
||||
return Action(icon: UIImage(named: "ic_block")!, title: title) { delegate?.banAndDeleteAllMessages(viewItem) }
|
||||
// MARK: - Actions
|
||||
|
||||
static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_reply"),
|
||||
title: "context_menu_reply".localized()
|
||||
) { delegate?.reply(cellViewModel) }
|
||||
}
|
||||
|
||||
static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_copy"),
|
||||
title: "copy".localized()
|
||||
) { delegate?.copy(cellViewModel) }
|
||||
}
|
||||
|
||||
static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_copy"),
|
||||
title: "vc_conversation_settings_copy_session_id_button_title".localized()
|
||||
) { delegate?.copySessionID(cellViewModel) }
|
||||
}
|
||||
|
||||
static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_trash"),
|
||||
title: "TXT_DELETE_TITLE".localized()
|
||||
) { delegate?.delete(cellViewModel) }
|
||||
}
|
||||
|
||||
static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_download"),
|
||||
title: "context_menu_save".localized()
|
||||
) { delegate?.save(cellViewModel) }
|
||||
}
|
||||
|
||||
static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_block"),
|
||||
title: "context_menu_ban_user".localized()
|
||||
) { delegate?.ban(cellViewModel) }
|
||||
}
|
||||
|
||||
static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_block"),
|
||||
title: "context_menu_ban_and_delete_all".localized()
|
||||
) { delegate?.banAndDeleteAllMessages(cellViewModel) }
|
||||
}
|
||||
|
||||
static func react(_ cellViewModel: MessageViewModel, _ emoji: EmojiWithSkinTones, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
title: emoji.rawValue,
|
||||
isEmojiAction: true
|
||||
) { delegate?.react(cellViewModel, with: emoji) }
|
||||
}
|
||||
|
||||
static func emojiPlusButton(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
isEmojiPlus: true
|
||||
) { delegate?.showFullEmojiKeyboard(cellViewModel) }
|
||||
}
|
||||
|
||||
static func dismiss(_ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
isDismissAction: true
|
||||
) { delegate?.contextMenuDismissed() }
|
||||
}
|
||||
}
|
||||
|
||||
static func actions(for viewItem: ConversationViewItem, delegate: ContextMenuActionDelegate?) -> [Action] {
|
||||
func isReplyingAllowed() -> Bool {
|
||||
guard let message = viewItem.interaction as? TSOutgoingMessage else { return true }
|
||||
switch message.messageState {
|
||||
case .failed, .sending: return false
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
switch viewItem.messageCellType {
|
||||
case .textOnlyMessage:
|
||||
var result: [Action] = []
|
||||
if isReplyingAllowed() { result.append(Action.reply(viewItem, delegate)) }
|
||||
result.append(Action.copy(viewItem, delegate))
|
||||
let isGroup = viewItem.isGroupThread
|
||||
if let message = viewItem.interaction as? TSIncomingMessage, isGroup, !message.isOpenGroupMessage {
|
||||
result.append(Action.copySessionID(viewItem, delegate))
|
||||
}
|
||||
if !isGroup || viewItem.userCanDeleteGroupMessage { result.append(Action.delete(viewItem, delegate)) }
|
||||
if isGroup && viewItem.interaction is TSIncomingMessage && viewItem.userHasModerationPermission {
|
||||
result.append(Action.ban(viewItem, delegate))
|
||||
result.append(Action.banAndDeleteAllMessages(viewItem, delegate))
|
||||
}
|
||||
return result
|
||||
case .mediaMessage, .audio, .genericAttachment:
|
||||
var result: [Action] = []
|
||||
if isReplyingAllowed() { result.append(Action.reply(viewItem, delegate)) }
|
||||
if viewItem.canCopyMedia() { result.append(Action.copy(viewItem, delegate)) }
|
||||
if viewItem.canSaveMedia() { result.append(Action.save(viewItem, delegate)) }
|
||||
let isGroup = viewItem.isGroupThread
|
||||
if let message = viewItem.interaction as? TSIncomingMessage, isGroup, !message.isOpenGroupMessage {
|
||||
result.append(Action.copySessionID(viewItem, delegate))
|
||||
}
|
||||
if !isGroup || viewItem.userCanDeleteGroupMessage { result.append(Action.delete(viewItem, delegate)) }
|
||||
if isGroup && viewItem.interaction is TSIncomingMessage && viewItem.userHasModerationPermission {
|
||||
result.append(Action.ban(viewItem, delegate))
|
||||
result.append(Action.banAndDeleteAllMessages(viewItem, delegate))
|
||||
}
|
||||
return result
|
||||
default: return []
|
||||
static func actions(
|
||||
for cellViewModel: MessageViewModel,
|
||||
recentEmojis: [EmojiWithSkinTones],
|
||||
currentUserIsOpenGroupModerator: Bool,
|
||||
delegate: ContextMenuActionDelegate?
|
||||
) -> [Action]? {
|
||||
// No context items for info messages
|
||||
guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let canReply: Bool = (
|
||||
cellViewModel.variant != .standardOutgoing || (
|
||||
cellViewModel.state != .failed &&
|
||||
cellViewModel.state != .sending
|
||||
)
|
||||
)
|
||||
let canCopy: Bool = (
|
||||
cellViewModel.cellType == .textOnlyMessage || (
|
||||
(
|
||||
cellViewModel.cellType == .genericAttachment ||
|
||||
cellViewModel.cellType == .mediaMessage
|
||||
) &&
|
||||
(cellViewModel.attachments ?? []).count == 1 &&
|
||||
(cellViewModel.attachments ?? []).first?.isVisualMedia == true &&
|
||||
(cellViewModel.attachments ?? []).first?.isValid == true && (
|
||||
(cellViewModel.attachments ?? []).first?.state == .downloaded ||
|
||||
(cellViewModel.attachments ?? []).first?.state == .uploaded
|
||||
)
|
||||
)
|
||||
)
|
||||
let canSave: Bool = (
|
||||
cellViewModel.cellType == .mediaMessage &&
|
||||
(cellViewModel.attachments ?? [])
|
||||
.filter { attachment in
|
||||
attachment.isValid &&
|
||||
attachment.isVisualMedia && (
|
||||
attachment.state == .downloaded ||
|
||||
attachment.state == .uploaded
|
||||
)
|
||||
}.isEmpty == false
|
||||
)
|
||||
let canCopySessionId: Bool = (
|
||||
cellViewModel.variant == .standardIncoming &&
|
||||
cellViewModel.threadVariant != .openGroup
|
||||
)
|
||||
let canDelete: Bool = (
|
||||
cellViewModel.threadVariant != .openGroup ||
|
||||
currentUserIsOpenGroupModerator
|
||||
)
|
||||
let canBan: Bool = (
|
||||
cellViewModel.threadVariant == .openGroup &&
|
||||
currentUserIsOpenGroupModerator
|
||||
)
|
||||
|
||||
let generatedActions: [Action] = [
|
||||
(canReply ? Action.reply(cellViewModel, delegate) : nil),
|
||||
(canCopy ? Action.copy(cellViewModel, delegate) : nil),
|
||||
(canSave ? Action.save(cellViewModel, delegate) : nil),
|
||||
(canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil),
|
||||
(canDelete ? Action.delete(cellViewModel, delegate) : nil),
|
||||
(canBan ? Action.ban(cellViewModel, delegate) : nil),
|
||||
(canBan ? Action.banAndDeleteAllMessages(cellViewModel, delegate) : nil),
|
||||
]
|
||||
.appending(contentsOf: recentEmojis.map { Action.react(cellViewModel, $0, delegate) })
|
||||
.appending(Action.emojiPlusButton(cellViewModel, delegate))
|
||||
.compactMap { $0 }
|
||||
|
||||
guard !generatedActions.isEmpty else { return [] }
|
||||
|
||||
return generatedActions.appending(Action.dismiss(delegate))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
protocol ContextMenuActionDelegate : AnyObject {
|
||||
|
||||
func reply(_ viewItem: ConversationViewItem)
|
||||
func copy(_ viewItem: ConversationViewItem)
|
||||
func copySessionID(_ viewItem: ConversationViewItem)
|
||||
func delete(_ viewItem: ConversationViewItem)
|
||||
func save(_ viewItem: ConversationViewItem)
|
||||
func ban(_ viewItem: ConversationViewItem)
|
||||
func banAndDeleteAllMessages(_ viewItem: ConversationViewItem)
|
||||
func react(_ viewItem: ConversationViewItem, with emoji: EmojiWithSkinTones)
|
||||
func showFullEmojiKeyboard(_ viewItem: ConversationViewItem)
|
||||
// MARK: - Delegate
|
||||
|
||||
protocol ContextMenuActionDelegate {
|
||||
func reply(_ cellViewModel: MessageViewModel)
|
||||
func copy(_ cellViewModel: MessageViewModel)
|
||||
func copySessionID(_ cellViewModel: MessageViewModel)
|
||||
func delete(_ cellViewModel: MessageViewModel)
|
||||
func save(_ cellViewModel: MessageViewModel)
|
||||
func ban(_ cellViewModel: MessageViewModel)
|
||||
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel)
|
||||
func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones)
|
||||
func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel)
|
||||
func contextMenuDismissed()
|
||||
}
|
||||
|
|
|
@ -1,19 +1,25 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension ContextMenuVC {
|
||||
|
||||
final class ActionView : UIView {
|
||||
private let action: Action
|
||||
private let dismiss: () -> Void
|
||||
|
||||
// MARK: Settings
|
||||
final class ActionView: UIView {
|
||||
private static let iconSize: CGFloat = 16
|
||||
private static let iconImageViewSize: CGFloat = 24
|
||||
|
||||
// MARK: Lifecycle
|
||||
private let action: Action
|
||||
private let dismiss: () -> Void
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(for action: Action, dismiss: @escaping () -> Void) {
|
||||
self.action = action
|
||||
self.dismiss = dismiss
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
@ -28,33 +34,46 @@ extension ContextMenuVC {
|
|||
private func setUpViewHierarchy() {
|
||||
// Icon
|
||||
let iconSize = ActionView.iconSize
|
||||
let iconImageView = UIImageView(image: action.icon.resizedImage(to: CGSize(width: iconSize, height: iconSize))!.withRenderingMode(.alwaysTemplate))
|
||||
iconImageView.tintColor = Colors.text
|
||||
let iconImageViewSize = ActionView.iconImageViewSize
|
||||
iconImageView.set(.width, to: iconImageViewSize)
|
||||
iconImageView.set(.height, to: iconImageViewSize)
|
||||
let iconImageView: UIImageView = UIImageView(
|
||||
image: action.icon?
|
||||
.resizedImage(to: CGSize(width: iconSize, height: iconSize))?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
)
|
||||
iconImageView.set(.width, to: ActionView.iconImageViewSize)
|
||||
iconImageView.set(.height, to: ActionView.iconImageViewSize)
|
||||
iconImageView.contentMode = .center
|
||||
iconImageView.tintColor = Colors.text
|
||||
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = action.title
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ iconImageView, titleLabel ])
|
||||
let stackView: UIStackView = UIStackView(arrangedSubviews: [ iconImageView, titleLabel ])
|
||||
stackView.axis = .horizontal
|
||||
stackView.spacing = Values.smallSpacing
|
||||
stackView.alignment = .center
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
|
||||
let smallSpacing = Values.smallSpacing
|
||||
stackView.layoutMargins = UIEdgeInsets(top: smallSpacing, leading: smallSpacing, bottom: smallSpacing, trailing: Values.mediumSpacing)
|
||||
stackView.layoutMargins = UIEdgeInsets(
|
||||
top: smallSpacing,
|
||||
leading: smallSpacing,
|
||||
bottom: smallSpacing,
|
||||
trailing: Values.mediumSpacing
|
||||
)
|
||||
addSubview(stackView)
|
||||
stackView.pin(to: self)
|
||||
|
||||
// Tap gesture recognizer
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func handleTap() {
|
||||
action.work()
|
||||
dismiss()
|
||||
|
|
|
@ -1,20 +1,26 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
extension ContextMenuVC {
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension ContextMenuVC {
|
||||
final class EmojiReactsView: UIView {
|
||||
private let emoji: EmojiWithSkinTones
|
||||
private let action: Action
|
||||
private let dismiss: () -> Void
|
||||
private let work: () -> Void
|
||||
|
||||
// MARK: Settings
|
||||
// MARK: - Settings
|
||||
|
||||
private static let size: CGFloat = 40
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(for emoji: EmojiWithSkinTones, dismiss: @escaping () -> Void, work: @escaping () -> Void) {
|
||||
self.emoji = emoji
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(for action: Action, dismiss: @escaping () -> Void) {
|
||||
self.action = action
|
||||
self.dismiss = dismiss
|
||||
self.work = work
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
@ -28,36 +34,42 @@ extension ContextMenuVC {
|
|||
|
||||
private func setUpViewHierarchy() {
|
||||
let emojiLabel = UILabel()
|
||||
emojiLabel.text = self.emoji.rawValue
|
||||
emojiLabel.text = self.action.title
|
||||
emojiLabel.font = .systemFont(ofSize: Values.veryLargeFontSize)
|
||||
emojiLabel.set(.height, to: ContextMenuVC.EmojiReactsView.size)
|
||||
addSubview(emojiLabel)
|
||||
emojiLabel.pin(to: self)
|
||||
|
||||
// Tap gesture recognizer
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func handleTap() {
|
||||
work()
|
||||
action.work()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
final class EmojiPlusButton: UIView {
|
||||
private let action: Action?
|
||||
private let dismiss: () -> Void
|
||||
private let work: () -> Void
|
||||
|
||||
// MARK: Settings
|
||||
// MARK: - Settings
|
||||
|
||||
public static let size: CGFloat = 28
|
||||
private let iconSize: CGFloat = 14
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(dismiss: @escaping () -> Void, work: @escaping () -> Void) {
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(action: Action?, dismiss: @escaping () -> Void) {
|
||||
self.action = action
|
||||
self.dismiss = dismiss
|
||||
self.work = work
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
@ -78,22 +90,24 @@ extension ContextMenuVC {
|
|||
iconImageView.contentMode = .scaleAspectFit
|
||||
addSubview(iconImageView)
|
||||
iconImageView.center(in: self)
|
||||
|
||||
// Background
|
||||
isUserInteractionEnabled = true
|
||||
backgroundColor = Colors.sessionEmojiPlusButtonBackground
|
||||
|
||||
// Tap gesture recognizer
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func handleTap() {
|
||||
dismiss()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: { [weak self] in
|
||||
self?.work()
|
||||
})
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: { [weak self] in
|
||||
self?.action?.work()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,17 +1,22 @@
|
|||
import CoreGraphics
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class ContextMenuVC : UIViewController {
|
||||
private let snapshot: UIView
|
||||
private let viewItem: ConversationViewItem
|
||||
private let frame: CGRect
|
||||
private let dismiss: () -> Void
|
||||
private weak var delegate: ContextMenuActionDelegate?
|
||||
final class ContextMenuVC: UIViewController {
|
||||
private static let actionViewHeight: CGFloat = 40
|
||||
private static let menuCornerRadius: CGFloat = 8
|
||||
|
||||
private var recentEmoji: [EmojiWithSkinTones] = []
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var blurView = UIVisualEffectView(effect: nil)
|
||||
private let snapshot: UIView
|
||||
private let frame: CGRect
|
||||
private let cellViewModel: MessageViewModel
|
||||
private let actions: [Action]
|
||||
private let dismiss: () -> Void
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var blurView: UIVisualEffectView = UIVisualEffectView(effect: nil)
|
||||
|
||||
private lazy var emojiBar: UIView = {
|
||||
let result = UIView()
|
||||
|
@ -20,51 +25,61 @@ final class ContextMenuVC : UIViewController {
|
|||
result.layer.shadowOpacity = 0.4
|
||||
result.layer.shadowRadius = 4
|
||||
result.set(.height, to: ContextMenuVC.actionViewHeight)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var emojiPlusButton: EmojiPlusButton = {
|
||||
let result = EmojiPlusButton(dismiss: snDismiss) { self.delegate?.showFullEmojiKeyboard(self.viewItem) }
|
||||
let result = EmojiPlusButton(
|
||||
action: self.actions.first(where: { $0.isEmojiPlus }),
|
||||
dismiss: snDismiss
|
||||
)
|
||||
result.set(.width, to: EmojiPlusButton.size)
|
||||
result.set(.height, to: EmojiPlusButton.size)
|
||||
result.layer.cornerRadius = EmojiPlusButton.size / 2
|
||||
result.layer.masksToBounds = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var menuView: UIView = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.layer.shadowColor = UIColor.black.cgColor
|
||||
result.layer.shadowOffset = CGSize.zero
|
||||
result.layer.shadowOpacity = 0.4
|
||||
result.layer.shadowRadius = 4
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var timestampLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let date = viewItem.interaction.dateForUI()
|
||||
result.text = DateUtil.formatDate(forDisplay: date)
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
result.textColor = isLightMode ? .black : .white
|
||||
result.textColor = (isLightMode ? .black : .white)
|
||||
|
||||
if let dateForUI: Date = cellViewModel.dateForUI {
|
||||
result.text = dateForUI.formattedForDisplay
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
private static let actionViewHeight: CGFloat = 40
|
||||
private static let menuCornerRadius: CGFloat = 8
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(snapshot: UIView, viewItem: ConversationViewItem, frame: CGRect, delegate: ContextMenuActionDelegate, dismiss: @escaping () -> Void) {
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
snapshot: UIView,
|
||||
frame: CGRect,
|
||||
cellViewModel: MessageViewModel,
|
||||
actions: [Action],
|
||||
dismiss: @escaping () -> Void
|
||||
) {
|
||||
self.snapshot = snapshot
|
||||
self.viewItem = viewItem
|
||||
self.frame = frame
|
||||
self.delegate = delegate
|
||||
self.cellViewModel = cellViewModel
|
||||
self.actions = actions
|
||||
self.dismiss = dismiss
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
Storage.read { transaction in
|
||||
self.recentEmoji = Array(Storage.shared.getRecentEmoji(withDefaultEmoji: true, transaction: transaction)[...5])
|
||||
}
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
|
@ -74,29 +89,37 @@ final class ContextMenuVC : UIViewController {
|
|||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(coder:) instead.")
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Background color
|
||||
view.backgroundColor = .clear
|
||||
|
||||
// Blur
|
||||
view.addSubview(blurView)
|
||||
blurView.pin(to: view)
|
||||
|
||||
// Snapshot
|
||||
snapshot.layer.shadowColor = UIColor.black.cgColor
|
||||
snapshot.layer.shadowOffset = CGSize.zero
|
||||
snapshot.layer.shadowOpacity = 0.4
|
||||
snapshot.layer.shadowRadius = 4
|
||||
view.addSubview(snapshot)
|
||||
|
||||
// Timestamp
|
||||
view.addSubview(timestampLabel)
|
||||
timestampLabel.center(.vertical, in: snapshot)
|
||||
let isOutgoing = (viewItem.interaction.interactionType() == .outgoingMessage)
|
||||
if isOutgoing {
|
||||
|
||||
if cellViewModel.variant == .standardOutgoing {
|
||||
timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
timestampLabel.pin(.left, to: .right, of: snapshot, withInset: Values.smallSpacing)
|
||||
}
|
||||
|
||||
// Emoji reacts
|
||||
let emojiBarBackgroundView = UIView()
|
||||
emojiBarBackgroundView.backgroundColor = Colors.receivedMessageBackground
|
||||
|
@ -109,12 +132,11 @@ final class ContextMenuVC : UIViewController {
|
|||
emojiPlusButton.pin(.right, to: .right, of: emojiBar, withInset: -Values.smallSpacing)
|
||||
emojiPlusButton.center(.vertical, in: emojiBar)
|
||||
|
||||
let emojiLabels = recentEmoji.map { emoji -> EmojiReactsView in
|
||||
EmojiReactsView(for: emoji, dismiss: snDismiss) {
|
||||
self.delegate?.react(self.viewItem, with: emoji)
|
||||
}
|
||||
}
|
||||
let emojiBarStackView = UIStackView(arrangedSubviews: emojiLabels)
|
||||
let emojiBarStackView = UIStackView(
|
||||
arrangedSubviews: actions
|
||||
.filter { $0.isEmojiAction }
|
||||
.map { action -> EmojiReactsView in EmojiReactsView(for: action, dismiss: snDismiss) }
|
||||
)
|
||||
emojiBarStackView.axis = .horizontal
|
||||
emojiBarStackView.spacing = Values.smallSpacing
|
||||
emojiBarStackView.layoutMargins = UIEdgeInsets(top: 0, left: Values.smallSpacing, bottom: 0, right: Values.smallSpacing)
|
||||
|
@ -123,7 +145,10 @@ final class ContextMenuVC : UIViewController {
|
|||
emojiBarStackView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: emojiBar)
|
||||
emojiBarStackView.pin(.right, to: .left, of: emojiPlusButton)
|
||||
|
||||
// Hide the emoji bar if we have no emoji actions
|
||||
emojiBar.isHidden = emojiBarStackView.arrangedSubviews.isEmpty
|
||||
view.addSubview(emojiBar)
|
||||
|
||||
// Menu
|
||||
let menuBackgroundView = UIView()
|
||||
menuBackgroundView.backgroundColor = Colors.receivedMessageBackground
|
||||
|
@ -131,31 +156,41 @@ final class ContextMenuVC : UIViewController {
|
|||
menuBackgroundView.layer.masksToBounds = true
|
||||
menuView.addSubview(menuBackgroundView)
|
||||
menuBackgroundView.pin(to: menuView)
|
||||
let actionViews = ContextMenuVC.actions(for: viewItem, delegate: delegate).map { ActionView(for: $0, dismiss: snDismiss) }
|
||||
let menuStackView = UIStackView(arrangedSubviews: actionViews)
|
||||
|
||||
let menuStackView = UIStackView(
|
||||
arrangedSubviews: actions
|
||||
.filter { !$0.isEmojiAction && !$0.isEmojiPlus && !$0.isDismissAction }
|
||||
.map { action -> ActionView in ActionView(for: action, dismiss: snDismiss) }
|
||||
)
|
||||
menuStackView.axis = .vertical
|
||||
menuView.addSubview(menuStackView)
|
||||
menuStackView.pin(to: menuView)
|
||||
view.addSubview(menuView)
|
||||
|
||||
// Constrains
|
||||
let menuHeight = CGFloat(actionViews.count) * ContextMenuVC.actionViewHeight
|
||||
let spacing = Values.smallSpacing
|
||||
let frame = calculateFrame(menuHeight: menuHeight, spacing: spacing)
|
||||
snapshot.pin(.left, to: .left, of: view, withInset: frame.origin.x)
|
||||
snapshot.pin(.top, to: .top, of: view, withInset: frame.origin.y)
|
||||
let menuHeight: CGFloat = CGFloat(menuStackView.arrangedSubviews.count) * ContextMenuVC.actionViewHeight
|
||||
let spacing: CGFloat = Values.smallSpacing
|
||||
let targetFrame: CGRect = calculateFrame(menuHeight: menuHeight, spacing: spacing)
|
||||
|
||||
snapshot.pin(.left, to: .left, of: view, withInset: targetFrame.origin.x)
|
||||
snapshot.pin(.top, to: .top, of: view, withInset: targetFrame.origin.y)
|
||||
snapshot.set(.width, to: frame.width)
|
||||
snapshot.set(.height, to: frame.height)
|
||||
emojiBar.pin(.bottom, to: .top, 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)
|
||||
emojiBar.pin(.right, to: .right, of: snapshot)
|
||||
case .incomingMessage:
|
||||
menuView.pin(.left, to: .left, of: snapshot)
|
||||
emojiBar.pin(.left, to: .left, of: snapshot)
|
||||
default: break // Should never occur
|
||||
|
||||
switch cellViewModel.variant {
|
||||
case .standardOutgoing:
|
||||
menuView.pin(.right, to: .right, of: snapshot)
|
||||
emojiBar.pin(.right, to: .right, of: snapshot)
|
||||
|
||||
case .standardIncoming:
|
||||
menuView.pin(.left, to: .left, of: snapshot)
|
||||
emojiBar.pin(.left, to: .left, of: snapshot)
|
||||
|
||||
default: break // Should never occur
|
||||
}
|
||||
|
||||
// Tap gesture
|
||||
let mainTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
view.addGestureRecognizer(mainTapGestureRecognizer)
|
||||
|
@ -163,6 +198,7 @@ final class ContextMenuVC : UIViewController {
|
|||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.blurView.effect = UIBlurEffect(style: .regular)
|
||||
self.menuView.alpha = 1
|
||||
|
@ -170,45 +206,65 @@ final class ContextMenuVC : UIViewController {
|
|||
}
|
||||
|
||||
func calculateFrame(menuHeight: CGFloat, spacing: CGFloat) -> CGRect {
|
||||
var finalFrame = frame
|
||||
let ratio = frame.width / frame.height
|
||||
var finalFrame: CGRect = frame
|
||||
let ratio: CGFloat = (frame.width / frame.height)
|
||||
|
||||
// FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement)
|
||||
let topMargin = max(UIApplication.shared.keyWindow!.safeAreaInsets.top, Values.mediumSpacing)
|
||||
let bottomMargin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing)
|
||||
let diffY = finalFrame.height + menuHeight + Self.actionViewHeight + 2 * spacing + topMargin + bottomMargin - UIScreen.main.bounds.height
|
||||
|
||||
if diffY > 0 {
|
||||
finalFrame.size.height -= diffY
|
||||
let newWidth = ratio * finalFrame.size.height
|
||||
if viewItem.interaction.interactionType() == .outgoingMessage {
|
||||
if cellViewModel.variant == .standardOutgoing {
|
||||
finalFrame.origin.x += finalFrame.size.width - newWidth
|
||||
}
|
||||
finalFrame.size.width = newWidth
|
||||
finalFrame.origin.y = UIScreen.main.bounds.height - finalFrame.size.height - menuHeight - bottomMargin - spacing
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
finalFrame.origin.y = (UIScreen.main.bounds.height - finalFrame.size.height) / 2
|
||||
}
|
||||
|
||||
return finalFrame
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
// MARK: - Layout
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
menuView.layer.shadowPath = UIBezierPath(roundedRect: menuView.bounds, cornerRadius: ContextMenuVC.menuCornerRadius).cgPath
|
||||
emojiBar.layer.shadowPath = UIBezierPath(roundedRect: emojiBar.bounds, cornerRadius: ContextMenuVC.actionViewHeight / 2).cgPath
|
||||
|
||||
menuView.layer.shadowPath = UIBezierPath(
|
||||
roundedRect: menuView.bounds,
|
||||
cornerRadius: ContextMenuVC.menuCornerRadius
|
||||
).cgPath
|
||||
emojiBar.layer.shadowPath = UIBezierPath(
|
||||
roundedRect: emojiBar.bounds,
|
||||
cornerRadius: (ContextMenuVC.actionViewHeight / 2)
|
||||
).cgPath
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func handleTap() {
|
||||
snDismiss()
|
||||
}
|
||||
|
||||
func snDismiss() {
|
||||
UIView.animate(withDuration: 0.25, animations: {
|
||||
self.blurView.effect = nil
|
||||
self.menuView.alpha = 0
|
||||
self.timestampLabel.alpha = 0
|
||||
}, completion: { _ in
|
||||
self.dismiss()
|
||||
self.delegate?.contextMenuDismissed()
|
||||
})
|
||||
UIView.animate(
|
||||
withDuration: 0.25,
|
||||
animations: { [weak self] in
|
||||
self?.blurView.effect = nil
|
||||
self?.menuView.alpha = 0
|
||||
self?.emojiBar.alpha = 0
|
||||
self?.snapshot.alpha = 0
|
||||
self?.timestampLabel.alpha = 0
|
||||
},
|
||||
completion: { [weak self] _ in
|
||||
self?.dismiss()
|
||||
self?.actions.first(where: { $0.isDismissAction })?.work()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@ final class ContextMenuWindow : UIWindow {
|
|||
initialize()
|
||||
}
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
override init(windowScene: UIWindowScene) {
|
||||
super.init(windowScene: windowScene)
|
||||
initialize()
|
||||
|
|
|
@ -1,333 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@objc
|
||||
public class ConversationMessageMapping: NSObject {
|
||||
private let viewName: String
|
||||
private let group: String?
|
||||
|
||||
// The desired number of the items to load BEFORE the pivot (see below).
|
||||
@objc
|
||||
public var desiredLength: UInt
|
||||
|
||||
typealias ItemId = String
|
||||
|
||||
// The list of currently loaded items.
|
||||
private var itemIds = [ItemId]()
|
||||
|
||||
// When we enter a conversation, we want to load up to N interactions. This
|
||||
// is the "initial load window".
|
||||
//
|
||||
// We subsequently expand the load window in two directions using two very
|
||||
// different behaviors.
|
||||
//
|
||||
// * We expand the load window "upwards" (backwards in time) only when
|
||||
// loadMore() is called, in "pages".
|
||||
// * We auto-expand the load window "downwards" (forward in time) to include
|
||||
// any new interactions created after the initial load.
|
||||
//
|
||||
// We define the "pivot" as the last item in the initial load window. This
|
||||
// value is only set once.
|
||||
//
|
||||
// For example, if you enter a conversation with messages, 1..15:
|
||||
//
|
||||
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
||||
//
|
||||
// We initially load just the last 5 (if 5 is the initial desired length):
|
||||
//
|
||||
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
||||
// | pivot ^ | <-- load window
|
||||
// pivot: 15, desired length=5.
|
||||
//
|
||||
// If a few more messages (16..18) are sent or received, we'll always load
|
||||
// them immediately (they're after the pivot):
|
||||
//
|
||||
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
||||
// | pivot ^ | <-- load window
|
||||
// pivot: 15, desired length=5.
|
||||
//
|
||||
// To load an additional page of items (perhaps due to user scrolling
|
||||
// upward), we extend the desired length and thereby load more items
|
||||
// before the pivot.
|
||||
//
|
||||
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
||||
// | pivot ^ | <-- load window
|
||||
// pivot: 15, desired length=10.
|
||||
//
|
||||
// To reiterate:
|
||||
//
|
||||
// * The pivot doesn't move.
|
||||
// * The desired length applies _before_ the pivot.
|
||||
// * Everything after the pivot is auto-loaded.
|
||||
//
|
||||
// One last optimization:
|
||||
//
|
||||
// After an update, we _can sometimes_ move the pivot (for perf
|
||||
// reasons), but we also adjust the "desired length" so that this
|
||||
// no effect on the load behavior.
|
||||
//
|
||||
// And note: we use the pivot's sort id, not its uniqueId, which works
|
||||
// even if the pivot itself is deleted.
|
||||
private var pivotSortId: UInt64?
|
||||
|
||||
@objc
|
||||
public var canLoadMore = false
|
||||
|
||||
@objc
|
||||
public required init(group: String?, desiredLength: UInt) {
|
||||
self.viewName = TSMessageDatabaseViewExtensionName
|
||||
self.group = group
|
||||
self.desiredLength = desiredLength
|
||||
}
|
||||
|
||||
@objc
|
||||
public func loadedUniqueIds() -> [String] {
|
||||
return itemIds
|
||||
}
|
||||
|
||||
@objc
|
||||
public func contains(uniqueId: String) -> Bool {
|
||||
return loadedUniqueIds().contains(uniqueId)
|
||||
}
|
||||
|
||||
// This method can be used to extend the desired length
|
||||
// and update.
|
||||
@objc
|
||||
public func update(withDesiredLength desiredLength: UInt, transaction: YapDatabaseReadTransaction) {
|
||||
assert(desiredLength >= self.desiredLength)
|
||||
|
||||
self.desiredLength = desiredLength
|
||||
|
||||
update(transaction: transaction)
|
||||
}
|
||||
|
||||
// This is the core method of the class. It updates the state to
|
||||
// reflect the latest database state & the current desired length.
|
||||
@objc
|
||||
public func update(transaction: YapDatabaseReadTransaction) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else {
|
||||
owsFailDebug("Could not load view.")
|
||||
return
|
||||
}
|
||||
guard let group = group else {
|
||||
owsFailDebug("No group.")
|
||||
return
|
||||
}
|
||||
|
||||
// Deserializing interactions is expensive, so we only
|
||||
// do that when necessary.
|
||||
let sortIdForItemId: (String) -> UInt64? = { (itemId) in
|
||||
guard let interaction = TSInteraction.fetch(uniqueId: itemId, transaction: transaction) else {
|
||||
owsFailDebug("Could not load interaction.")
|
||||
return nil
|
||||
}
|
||||
return interaction.sortId
|
||||
}
|
||||
|
||||
// If we have a "pivot", load all items AFTER the pivot and up to minDesiredLength items BEFORE the pivot.
|
||||
// If we do not have a "pivot", load up to minDesiredLength BEFORE the pivot.
|
||||
var newItemIds = [ItemId]()
|
||||
var canLoadMore = false
|
||||
let desiredLength = self.desiredLength
|
||||
// Not all items "count" towards the desired length. On an initial load, all items count. Subsequently,
|
||||
// only items above the pivot count.
|
||||
var afterPivotCount: UInt = 0
|
||||
var beforePivotCount: UInt = 0
|
||||
// (void (^)(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop))block;
|
||||
view.enumerateKeys(inGroup: group, with: NSEnumerationOptions.reverse) { (_, key, _, stop) in
|
||||
let itemId = key
|
||||
|
||||
// Load "uncounted" items after the pivot if possible.
|
||||
//
|
||||
// As an optimization, we can skip this check (which requires
|
||||
// deserializing the interaction) if beforePivotCount is non-zero,
|
||||
// e.g. after we "pass" the pivot.
|
||||
if beforePivotCount == 0,
|
||||
let pivotSortId = self.pivotSortId {
|
||||
if let sortId = sortIdForItemId(itemId) {
|
||||
let isAfterPivot = sortId > pivotSortId
|
||||
if isAfterPivot {
|
||||
newItemIds.append(itemId)
|
||||
afterPivotCount += 1
|
||||
return
|
||||
}
|
||||
} else {
|
||||
owsFailDebug("Could not determine sort id for interaction: \(itemId)")
|
||||
}
|
||||
}
|
||||
|
||||
// Load "counted" items unless the load window overflows.
|
||||
if beforePivotCount >= desiredLength {
|
||||
// Overflow
|
||||
canLoadMore = true
|
||||
stop.pointee = true
|
||||
} else {
|
||||
newItemIds.append(itemId)
|
||||
beforePivotCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
// The items need to be reversed, since we load them in reverse order.
|
||||
self.itemIds = Array(newItemIds.reversed())
|
||||
self.canLoadMore = canLoadMore
|
||||
|
||||
// Establish the pivot, if necessary and possible.
|
||||
//
|
||||
// Deserializing interactions is expensive. We only need to deserialize
|
||||
// interactions that are "after" the pivot. So there would be performance
|
||||
// benefits to moving the pivot after each update to the last loaded item.
|
||||
//
|
||||
// However, this would undesirable side effects. The desired length for
|
||||
// conversations with very short disappearing message durations would
|
||||
// continuously grow as messages appeared and disappeared.
|
||||
//
|
||||
// Therefore, we only move the pivot when we've accumulated N items after
|
||||
// the pivot. This puts an upper bound on the number of interactions we
|
||||
// have to deserialize while minimizing "load window size creep".
|
||||
let kMaxItemCountAfterPivot = 32
|
||||
let shouldSetPivot = (self.pivotSortId == nil ||
|
||||
afterPivotCount > kMaxItemCountAfterPivot)
|
||||
if shouldSetPivot {
|
||||
if let newLastItemId = newItemIds.first {
|
||||
// newItemIds is in reverse order, so its "first" element is actually last.
|
||||
if let sortId = sortIdForItemId(newLastItemId) {
|
||||
// Update the pivot.
|
||||
if self.pivotSortId != nil {
|
||||
self.desiredLength += afterPivotCount
|
||||
}
|
||||
self.pivotSortId = sortId
|
||||
} else {
|
||||
owsFailDebug("Could not determine sort id for interaction: \(newLastItemId)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tries to ensure that the load window includes a given item.
|
||||
// On success, returns the index path of that item.
|
||||
// On failure, returns nil.
|
||||
@objc(ensureLoadWindowContainsUniqueId:transaction:)
|
||||
public func ensureLoadWindowContains(uniqueId: String,
|
||||
transaction: YapDatabaseReadTransaction) -> IndexPath? {
|
||||
if let oldIndex = loadedUniqueIds().firstIndex(of: uniqueId) {
|
||||
return IndexPath(row: oldIndex, section: 0)
|
||||
}
|
||||
guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else {
|
||||
SNLog("Could not load view.")
|
||||
return nil
|
||||
}
|
||||
guard let group = group else {
|
||||
SNLog("No group.")
|
||||
return nil
|
||||
}
|
||||
|
||||
let indexPtr: UnsafeMutablePointer<UInt> = UnsafeMutablePointer<UInt>.allocate(capacity: 1)
|
||||
let wasFound = view.getGroup(nil, index: indexPtr, forKey: uniqueId, inCollection: TSInteraction.collection())
|
||||
guard wasFound else {
|
||||
SNLog("Could not find interaction.")
|
||||
return nil
|
||||
}
|
||||
let index = indexPtr.pointee
|
||||
let threadInteractionCount = view.numberOfItems(inGroup: group)
|
||||
guard index < threadInteractionCount else {
|
||||
SNLog("Invalid index.")
|
||||
return nil
|
||||
}
|
||||
// This math doesn't take into account the number of items loaded _after_ the pivot.
|
||||
// That's fine; it's okay to load too many interactions here.
|
||||
let desiredWindowSize: UInt = threadInteractionCount - index
|
||||
self.update(withDesiredLength: desiredWindowSize, transaction: transaction)
|
||||
|
||||
guard let newIndex = loadedUniqueIds().firstIndex(of: uniqueId) else {
|
||||
SNLog("Couldn't find interaction.")
|
||||
return nil
|
||||
}
|
||||
return IndexPath(row: newIndex, section: 0)
|
||||
}
|
||||
|
||||
@objc
|
||||
public class ConversationMessageMappingDiff: NSObject {
|
||||
@objc
|
||||
public let addedItemIds: Set<String>
|
||||
@objc
|
||||
public let removedItemIds: Set<String>
|
||||
@objc
|
||||
public let updatedItemIds: Set<String>
|
||||
|
||||
init(addedItemIds: Set<String>, removedItemIds: Set<String>, updatedItemIds: Set<String>) {
|
||||
self.addedItemIds = addedItemIds
|
||||
self.removedItemIds = removedItemIds
|
||||
self.updatedItemIds = updatedItemIds
|
||||
}
|
||||
}
|
||||
|
||||
// Updates and then calculates which items were inserted, removed or modified.
|
||||
@objc
|
||||
public func updateAndCalculateDiff(transaction: YapDatabaseReadTransaction,
|
||||
notifications: [NSNotification]) -> ConversationMessageMappingDiff? {
|
||||
let oldItemIds = Set(self.itemIds)
|
||||
self.update(transaction: transaction)
|
||||
let newItemIds = Set(self.itemIds)
|
||||
|
||||
let removedItemIds = oldItemIds.subtracting(newItemIds)
|
||||
let addedItemIds = newItemIds.subtracting(oldItemIds)
|
||||
// We only notify for updated items that a) were previously loaded b) weren't also inserted or removed.
|
||||
let updatedItemIds = (self.updatedItemIds(for: notifications)
|
||||
.subtracting(addedItemIds)
|
||||
.subtracting(removedItemIds)
|
||||
.intersection(oldItemIds))
|
||||
|
||||
return ConversationMessageMappingDiff(addedItemIds: addedItemIds,
|
||||
removedItemIds: removedItemIds,
|
||||
updatedItemIds: updatedItemIds)
|
||||
}
|
||||
|
||||
// For performance reasons, the database modification notifications are used
|
||||
// to determine which items were modified. If YapDatabase ever changes the
|
||||
// structure or semantics of these notifications, we'll need to update this
|
||||
// code to reflect that.
|
||||
private func updatedItemIds(for notifications: [NSNotification]) -> Set<String> {
|
||||
var updatedItemIds = Set<String>()
|
||||
for notification in notifications {
|
||||
// Unpack the YDB notification, looking for row changes.
|
||||
guard let userInfo =
|
||||
notification.userInfo else {
|
||||
owsFailDebug("Missing userInfo.")
|
||||
continue
|
||||
}
|
||||
guard let viewChangesets =
|
||||
userInfo[YapDatabaseExtensionsKey] as? NSDictionary else {
|
||||
// No changes for any views, skip.
|
||||
continue
|
||||
}
|
||||
guard let changeset =
|
||||
viewChangesets[viewName] as? NSDictionary else {
|
||||
// No changes for this view, skip.
|
||||
continue
|
||||
}
|
||||
// This constant matches a private constant in YDB.
|
||||
let changeset_key_changes: String = "changes"
|
||||
guard let changesetChanges = changeset[changeset_key_changes] as? [Any] else {
|
||||
owsFailDebug("Missing changeset changes.")
|
||||
continue
|
||||
}
|
||||
for change in changesetChanges {
|
||||
if change as? YapDatabaseViewSectionChange != nil {
|
||||
// Ignore.
|
||||
} else if let rowChange = change as? YapDatabaseViewRowChange {
|
||||
updatedItemIds.insert(rowChange.collectionKey.key)
|
||||
} else {
|
||||
owsFailDebug("Invalid change: \(type(of: change)).")
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedItemIds
|
||||
}
|
||||
}
|
|
@ -1,143 +1,100 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc
|
||||
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate {
|
||||
public class ConversationSearchController: NSObject {
|
||||
public static let minimumSearchTextLength: UInt = 2
|
||||
|
||||
@objc
|
||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController,
|
||||
didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?)
|
||||
|
||||
@objc
|
||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController,
|
||||
didSelectMessageId: String)
|
||||
}
|
||||
|
||||
@objc
|
||||
public class ConversationSearchController : NSObject {
|
||||
|
||||
@objc
|
||||
public static let kMinimumSearchTextLength: UInt = 2
|
||||
|
||||
@objc
|
||||
public let uiSearchController = UISearchController(searchResultsController: nil)
|
||||
|
||||
@objc
|
||||
private let threadId: String
|
||||
public weak var delegate: ConversationSearchControllerDelegate?
|
||||
|
||||
let thread: TSThread
|
||||
|
||||
@objc
|
||||
public let uiSearchController: UISearchController = UISearchController(searchResultsController: nil)
|
||||
public let resultsBar: SearchResultsBar = SearchResultsBar()
|
||||
|
||||
private var lastSearchText: String?
|
||||
|
||||
// MARK: Initializer
|
||||
|
||||
@objc
|
||||
required public init(thread: TSThread) {
|
||||
self.thread = thread
|
||||
public init(threadId: String) {
|
||||
self.threadId = threadId
|
||||
|
||||
super.init()
|
||||
|
||||
self.resultsBar.resultsBarDelegate = self
|
||||
self.uiSearchController.delegate = self
|
||||
self.uiSearchController.searchResultsUpdater = self
|
||||
|
||||
resultsBar.resultsBarDelegate = self
|
||||
uiSearchController.delegate = self
|
||||
uiSearchController.searchResultsUpdater = self
|
||||
|
||||
uiSearchController.hidesNavigationBarDuringPresentation = false
|
||||
if #available(iOS 13, *) {
|
||||
// Do nothing
|
||||
} else {
|
||||
uiSearchController.dimsBackgroundDuringPresentation = false
|
||||
}
|
||||
uiSearchController.searchBar.inputAccessoryView = resultsBar
|
||||
}
|
||||
|
||||
// MARK: Dependencies
|
||||
|
||||
var dbReadConnection: YapDatabaseConnection {
|
||||
return OWSPrimaryStorage.shared().dbReadConnection
|
||||
self.uiSearchController.hidesNavigationBarDuringPresentation = false
|
||||
self.uiSearchController.searchBar.inputAccessoryView = resultsBar
|
||||
}
|
||||
}
|
||||
|
||||
extension ConversationSearchController : UISearchControllerDelegate {
|
||||
|
||||
// MARK: - UISearchControllerDelegate
|
||||
|
||||
extension ConversationSearchController: UISearchControllerDelegate {
|
||||
public func didPresentSearchController(_ searchController: UISearchController) {
|
||||
Logger.verbose("")
|
||||
delegate?.didPresentSearchController?(searchController)
|
||||
}
|
||||
|
||||
public func didDismissSearchController(_ searchController: UISearchController) {
|
||||
Logger.verbose("")
|
||||
delegate?.didDismissSearchController?(searchController)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConversationSearchController : UISearchResultsUpdating {
|
||||
|
||||
var dbSearcher: FullTextSearcher {
|
||||
return FullTextSearcher.shared
|
||||
}
|
||||
// MARK: - UISearchResultsUpdating
|
||||
|
||||
extension ConversationSearchController: UISearchResultsUpdating {
|
||||
public func updateSearchResults(for searchController: UISearchController) {
|
||||
Logger.verbose("searchBar.text: \( searchController.searchBar.text ?? "<blank>")")
|
||||
|
||||
guard let rawSearchText = searchController.searchBar.text?.stripped else {
|
||||
self.resultsBar.updateResults(resultSet: nil)
|
||||
self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil)
|
||||
guard
|
||||
let searchText: String = searchController.searchBar.text?.stripped,
|
||||
searchText.count >= ConversationSearchController.minimumSearchTextLength
|
||||
else {
|
||||
self.resultsBar.updateResults(results: nil)
|
||||
self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil, searchText: nil)
|
||||
return
|
||||
}
|
||||
let searchText = FullTextSearchFinder.normalize(text: rawSearchText)
|
||||
lastSearchText = searchText
|
||||
|
||||
guard searchText.count >= ConversationSearchController.kMinimumSearchTextLength else {
|
||||
lastSearchText = nil
|
||||
self.resultsBar.updateResults(resultSet: nil)
|
||||
self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil)
|
||||
return
|
||||
|
||||
let threadId: String = self.threadId
|
||||
let results: [Int64] = Storage.shared.read { db -> [Int64] in
|
||||
try Interaction.idsForTermWithin(
|
||||
threadId: threadId,
|
||||
pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText)
|
||||
)
|
||||
.fetchAll(db)
|
||||
}
|
||||
|
||||
var resultSet: ConversationScreenSearchResultSet?
|
||||
self.dbReadConnection.asyncRead({ [weak self] transaction in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
resultSet = self.dbSearcher.searchWithinConversation(thread: self.thread, searchText: searchText, transaction: transaction)
|
||||
}, completionBlock: { [weak self] in
|
||||
guard let self = self, searchText == self.lastSearchText else {
|
||||
return
|
||||
}
|
||||
self.resultsBar.updateResults(resultSet: resultSet)
|
||||
self.delegate?.conversationSearchController(self, didUpdateSearchResults: resultSet)
|
||||
})
|
||||
.defaulting(to: [])
|
||||
|
||||
self.resultsBar.updateResults(results: results)
|
||||
self.delegate?.conversationSearchController(self, didUpdateSearchResults: results, searchText: searchText)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConversationSearchController : SearchResultsBarDelegate {
|
||||
|
||||
func searchResultsBar(_ searchResultsBar: SearchResultsBar,
|
||||
setCurrentIndex currentIndex: Int,
|
||||
resultSet: ConversationScreenSearchResultSet) {
|
||||
guard let searchResult = resultSet.messages[safe: currentIndex] else {
|
||||
owsFailDebug("messageId was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
// MARK: - SearchResultsBarDelegate
|
||||
|
||||
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 {
|
||||
|
||||
func searchResultsBar(_ searchResultsBar: SearchResultsBar,
|
||||
setCurrentIndex currentIndex: Int,
|
||||
resultSet: ConversationScreenSearchResultSet)
|
||||
protocol SearchResultsBarDelegate: AnyObject {
|
||||
func searchResultsBar(
|
||||
_ searchResultsBar: SearchResultsBar,
|
||||
setCurrentIndex currentIndex: Int,
|
||||
results: [Int64]
|
||||
)
|
||||
}
|
||||
|
||||
public final class SearchResultsBar : UIView {
|
||||
private var resultSet: ConversationScreenSearchResultSet?
|
||||
public final class SearchResultsBar: UIView {
|
||||
private var results: [Int64]?
|
||||
var currentIndex: Int?
|
||||
weak var resultsBarDelegate: SearchResultsBarDelegate?
|
||||
|
||||
|
@ -145,7 +102,6 @@ public final class SearchResultsBar : UIView {
|
|||
|
||||
private lazy var label: UILabel = {
|
||||
let result = UILabel()
|
||||
result.text = "Test"
|
||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.text
|
||||
return result
|
||||
|
@ -169,6 +125,14 @@ public final class SearchResultsBar : UIView {
|
|||
return result
|
||||
}()
|
||||
|
||||
private lazy var loadingIndicator: UIActivityIndicatorView = {
|
||||
let result = UIActivityIndicatorView(style: .medium)
|
||||
result.tintColor = Colors.text
|
||||
result.alpha = 0.5
|
||||
result.hidesWhenStopped = true
|
||||
return result
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setUpViewHierarchy()
|
||||
|
@ -181,6 +145,7 @@ public final class SearchResultsBar : UIView {
|
|||
|
||||
private func setUpViewHierarchy() {
|
||||
autoresizingMask = .flexibleHeight
|
||||
|
||||
// Background & blur
|
||||
let backgroundView = UIView()
|
||||
backgroundView.backgroundColor = isLightMode ? .white : .black
|
||||
|
@ -190,18 +155,22 @@ public final class SearchResultsBar : UIView {
|
|||
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
addSubview(blurView)
|
||||
blurView.pin(to: self)
|
||||
|
||||
// Separator
|
||||
let separator = UIView()
|
||||
separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
|
||||
separator.set(.height, to: 1 / UIScreen.main.scale)
|
||||
addSubview(separator)
|
||||
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
|
||||
|
||||
// Spacers
|
||||
let spacer1 = UIView.hStretchingSpacer()
|
||||
let spacer2 = UIView.hStretchingSpacer()
|
||||
|
||||
// Button containers
|
||||
let upButtonContainer = UIView(wrapping: upButton, withInsets: UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0))
|
||||
let downButtonContainer = UIView(wrapping: downButton, withInsets: UIEdgeInsets(top: 0, left: 0, bottom: 2, right: 0))
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ upButtonContainer, downButtonContainer, spacer1, label, spacer2 ])
|
||||
mainStackView.axis = .horizontal
|
||||
|
@ -209,110 +178,116 @@ public final class SearchResultsBar : UIView {
|
|||
mainStackView.isLayoutMarginsRelativeArrangement = true
|
||||
mainStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.largeSpacing, bottom: Values.smallSpacing, trailing: Values.largeSpacing)
|
||||
addSubview(mainStackView)
|
||||
|
||||
mainStackView.pin(.top, to: .bottom, of: separator)
|
||||
mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self)
|
||||
mainStackView.pin(.bottom, to: .bottom, of: self, withInset: -2)
|
||||
|
||||
addSubview(loadingIndicator)
|
||||
loadingIndicator.pin(.left, to: .right, of: label, withInset: 10)
|
||||
loadingIndicator.centerYAnchor.constraint(equalTo: label.centerYAnchor).isActive = true
|
||||
|
||||
// Remaining constraints
|
||||
label.center(.horizontal, in: self)
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
@objc
|
||||
public func handleUpButtonTapped() {
|
||||
Logger.debug("")
|
||||
guard let resultSet = resultSet else {
|
||||
owsFailDebug("resultSet was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
guard let currentIndex = currentIndex else {
|
||||
owsFailDebug("currentIndex was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
guard currentIndex + 1 < resultSet.messages.count else {
|
||||
owsFailDebug("showLessRecent button should be disabled")
|
||||
return
|
||||
}
|
||||
guard let results: [Int64] = results else { return }
|
||||
guard let currentIndex: Int = currentIndex else { return }
|
||||
guard currentIndex + 1 < results.count else { return }
|
||||
|
||||
let newIndex = currentIndex + 1
|
||||
self.currentIndex = newIndex
|
||||
updateBarItems()
|
||||
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, resultSet: resultSet)
|
||||
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, results: results)
|
||||
}
|
||||
|
||||
@objc
|
||||
public func handleDownButtonTapped() {
|
||||
Logger.debug("")
|
||||
guard let resultSet = resultSet else {
|
||||
owsFailDebug("resultSet was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
guard let currentIndex = currentIndex else {
|
||||
owsFailDebug("currentIndex was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
guard currentIndex > 0 else {
|
||||
owsFailDebug("showMoreRecent button should be disabled")
|
||||
return
|
||||
}
|
||||
guard let results: [Int64] = results else { return }
|
||||
guard let currentIndex: Int = currentIndex, currentIndex > 0 else { return }
|
||||
|
||||
let newIndex = currentIndex - 1
|
||||
self.currentIndex = newIndex
|
||||
updateBarItems()
|
||||
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, resultSet: resultSet)
|
||||
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: newIndex, results: results)
|
||||
}
|
||||
|
||||
func updateResults(resultSet: ConversationScreenSearchResultSet?) {
|
||||
if let resultSet = resultSet {
|
||||
if resultSet.messages.count > 0 {
|
||||
currentIndex = min(currentIndex ?? 0, resultSet.messages.count - 1)
|
||||
} else {
|
||||
currentIndex = nil
|
||||
func updateResults(results: [Int64]?) {
|
||||
currentIndex = {
|
||||
guard let results: [Int64] = results, !results.isEmpty else { return nil }
|
||||
|
||||
if let currentIndex: Int = currentIndex {
|
||||
return max(0, min(currentIndex, results.count - 1))
|
||||
}
|
||||
} else {
|
||||
currentIndex = nil
|
||||
}
|
||||
|
||||
return 0
|
||||
}()
|
||||
|
||||
self.resultSet = resultSet
|
||||
self.results = results
|
||||
|
||||
updateBarItems()
|
||||
if let currentIndex = currentIndex, let resultSet = resultSet {
|
||||
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: currentIndex, resultSet: resultSet)
|
||||
|
||||
if let currentIndex = currentIndex, let results = results {
|
||||
resultsBarDelegate?.searchResultsBar(self, setCurrentIndex: currentIndex, results: results)
|
||||
}
|
||||
}
|
||||
|
||||
func updateBarItems() {
|
||||
guard let resultSet = resultSet else {
|
||||
guard let results: [Int64] = results else {
|
||||
label.text = ""
|
||||
downButton.isEnabled = false
|
||||
upButton.isEnabled = false
|
||||
return
|
||||
}
|
||||
|
||||
switch resultSet.messages.count {
|
||||
case 0:
|
||||
label.text = NSLocalizedString("CONVERSATION_SEARCH_NO_RESULTS", comment: "keyboard toolbar label when no messages match the search string")
|
||||
case 1:
|
||||
label.text = NSLocalizedString("CONVERSATION_SEARCH_ONE_RESULT", comment: "keyboard toolbar label when exactly 1 message matches the search string")
|
||||
default:
|
||||
let format = NSLocalizedString("CONVERSATION_SEARCH_RESULTS_FORMAT",
|
||||
comment: "keyboard toolbar label when more than 1 message matches the search string. Embeds {{number/position of the 'currently viewed' result}} and the {{total number of results}}")
|
||||
switch results.count {
|
||||
case 0:
|
||||
// Keyboard toolbar label when no messages match the search string
|
||||
label.text = "CONVERSATION_SEARCH_NO_RESULTS".localized()
|
||||
|
||||
case 1:
|
||||
// Keyboard toolbar label when exactly 1 message matches the search string
|
||||
label.text = "CONVERSATION_SEARCH_ONE_RESULT".localized()
|
||||
|
||||
default:
|
||||
// Keyboard toolbar label when more than 1 message matches the search string
|
||||
//
|
||||
// Embeds {{number/position of the 'currently viewed' result}} and
|
||||
// the {{total number of results}}
|
||||
let format = "CONVERSATION_SEARCH_RESULTS_FORMAT".localized()
|
||||
|
||||
guard let currentIndex = currentIndex else {
|
||||
owsFailDebug("currentIndex was unexpectedly nil")
|
||||
return
|
||||
guard let currentIndex: Int = currentIndex else { return }
|
||||
|
||||
label.text = String(format: format, currentIndex + 1, results.count)
|
||||
}
|
||||
label.text = String(format: format, currentIndex + 1, resultSet.messages.count)
|
||||
}
|
||||
|
||||
if let currentIndex = currentIndex {
|
||||
if let currentIndex: Int = currentIndex {
|
||||
downButton.isEnabled = currentIndex > 0
|
||||
upButton.isEnabled = currentIndex + 1 < resultSet.messages.count
|
||||
} else {
|
||||
upButton.isEnabled = (currentIndex + 1 < results.count)
|
||||
}
|
||||
else {
|
||||
downButton.isEnabled = false
|
||||
upButton.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
public func startLoading() {
|
||||
loadingIndicator.startAnimating()
|
||||
}
|
||||
|
||||
public func stopLoading() {
|
||||
loadingIndicator.stopAnimating()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ConversationSearchControllerDelegate
|
||||
|
||||
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate {
|
||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?)
|
||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId: Int64)
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -1,8 +0,0 @@
|
|||
@import Foundation;
|
||||
|
||||
typedef NS_ENUM(NSUInteger, ConversationViewAction) {
|
||||
ConversationViewActionNone,
|
||||
ConversationViewActionCompose,
|
||||
ConversationViewActionAudioCall,
|
||||
ConversationViewActionVideoCall,
|
||||
};
|
|
@ -1,167 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <SessionMessagingKit/OWSAudioPlayer.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
extern NSString *const SNAudioDidFinishPlayingNotification;
|
||||
|
||||
typedef NS_ENUM(NSInteger, OWSMessageCellType) {
|
||||
OWSMessageCellType_Unknown,
|
||||
OWSMessageCellType_TextOnlyMessage,
|
||||
OWSMessageCellType_Audio,
|
||||
OWSMessageCellType_GenericAttachment,
|
||||
OWSMessageCellType_MediaMessage,
|
||||
OWSMessageCellType_OversizeTextDownloading,
|
||||
OWSMessageCellType_DeletedMessage
|
||||
};
|
||||
|
||||
NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType);
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@class ContactShareViewModel;
|
||||
@class ConversationViewCell;
|
||||
@class DisplayableText;
|
||||
@class SNVoiceMessageView;
|
||||
@class OWSLinkPreview;
|
||||
@class OWSQuotedReplyModel;
|
||||
@class OWSUnreadIndicator;
|
||||
@class TSAttachment;
|
||||
@class TSAttachmentPointer;
|
||||
@class TSAttachmentStream;
|
||||
@class TSInteraction;
|
||||
@class TSThread;
|
||||
@class YapDatabaseReadTransaction;
|
||||
|
||||
@interface ConversationMediaAlbumItem : NSObject
|
||||
|
||||
@property (nonatomic, readonly) TSAttachment *attachment;
|
||||
|
||||
// This property will only be set if the attachment is downloaded.
|
||||
@property (nonatomic, readonly, nullable) TSAttachmentStream *attachmentStream;
|
||||
|
||||
// This property will be non-zero if the attachment is valid.
|
||||
@property (nonatomic, readonly) CGSize mediaSize;
|
||||
|
||||
@property (nonatomic, readonly, nullable) NSString *caption;
|
||||
|
||||
@property (nonatomic, readonly) BOOL isFailedDownload;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@protocol ConversationViewItem <NSObject, OWSAudioPlayerDelegate>
|
||||
|
||||
@property (nonatomic, readonly) TSInteraction *interaction;
|
||||
|
||||
@property (nonatomic, readonly, nullable) OWSQuotedReplyModel *quotedReply;
|
||||
|
||||
@property (nonatomic, readonly) BOOL isGroupThread;
|
||||
@property (nonatomic, readonly) BOOL userCanDeleteGroupMessage;
|
||||
@property (nonatomic, readonly) BOOL userHasModerationPermission;
|
||||
|
||||
@property (nonatomic, readonly) BOOL hasBodyText;
|
||||
|
||||
@property (nonatomic, readonly) BOOL isQuotedReply;
|
||||
@property (nonatomic, readonly) BOOL hasQuotedAttachment;
|
||||
@property (nonatomic, readonly) BOOL hasQuotedText;
|
||||
@property (nonatomic, readonly) BOOL hasCellHeader;
|
||||
|
||||
@property (nonatomic, readonly) BOOL isExpiringMessage;
|
||||
|
||||
@property (nonatomic) BOOL shouldShowDate;
|
||||
@property (nonatomic) BOOL shouldShowSenderProfilePicture;
|
||||
@property (nonatomic, nullable) NSAttributedString *senderName;
|
||||
@property (nonatomic) BOOL shouldHideFooter;
|
||||
@property (nonatomic) BOOL isFirstInCluster;
|
||||
@property (nonatomic) BOOL isOnlyMessageInCluster;
|
||||
@property (nonatomic) BOOL isLastInCluster;
|
||||
@property (nonatomic) BOOL wasPreviousItemInfoMessage;
|
||||
|
||||
@property (nonatomic, nullable) OWSUnreadIndicator *unreadIndicator;
|
||||
|
||||
- (void)replaceInteraction:(TSInteraction *)interaction transaction:(YapDatabaseReadTransaction *)transaction;
|
||||
|
||||
- (void)clearCachedLayoutState;
|
||||
|
||||
@property (nonatomic, readonly) BOOL hasCachedLayoutState;
|
||||
|
||||
#pragma mark - Audio Playback
|
||||
|
||||
@property (nonatomic, weak) SNVoiceMessageView *lastAudioMessageView;
|
||||
|
||||
@property (nonatomic, readonly) CGFloat audioDurationSeconds;
|
||||
@property (nonatomic, readonly) CGFloat audioProgressSeconds;
|
||||
|
||||
#pragma mark - View State Caching
|
||||
|
||||
// These methods only apply to text & attachment messages.
|
||||
@property (nonatomic, readonly) OWSMessageCellType messageCellType;
|
||||
@property (nonatomic, readonly, nullable) DisplayableText *displayableBodyText;
|
||||
@property (nonatomic, readonly, nullable) TSAttachmentStream *attachmentStream;
|
||||
@property (nonatomic, readonly, nullable) TSAttachmentPointer *attachmentPointer;
|
||||
@property (nonatomic, readonly, nullable) NSArray<ConversationMediaAlbumItem *> *mediaAlbumItems;
|
||||
|
||||
@property (nonatomic, readonly, nullable) DisplayableText *displayableQuotedText;
|
||||
@property (nonatomic, readonly, nullable) NSString *quotedAttachmentMimetype;
|
||||
@property (nonatomic, readonly, nullable) NSString *quotedRecipientId;
|
||||
|
||||
// We don't want to try to load the media for this item (if any)
|
||||
// if a load has previously failed.
|
||||
@property (nonatomic) BOOL didCellMediaFailToLoad;
|
||||
|
||||
@property (nonatomic, readonly, nullable) ContactShareViewModel *contactShare;
|
||||
|
||||
@property (nonatomic, readonly, nullable) OWSLinkPreview *linkPreview;
|
||||
@property (nonatomic, readonly, nullable) TSAttachment *linkPreviewAttachment;
|
||||
|
||||
@property (nonatomic, readonly, nullable) NSString *systemMessageText;
|
||||
|
||||
@property (nonatomic) BOOL reactionShouldExpanded;
|
||||
|
||||
// NOTE: This property is only set for incoming messages.
|
||||
@property (nonatomic, readonly, nullable) NSString *authorConversationColorName;
|
||||
|
||||
#pragma mark - MessageActions
|
||||
|
||||
@property (nonatomic, readonly) BOOL hasBodyTextActionContent;
|
||||
@property (nonatomic, readonly) BOOL hasMediaActionContent;
|
||||
|
||||
- (void)copyMediaAction;
|
||||
- (void)copyTextAction;
|
||||
- (void)saveMediaAction;
|
||||
- (void)deleteLocallyAction;
|
||||
- (void)deleteRemotelyAction;
|
||||
|
||||
- (void)deleteAction; // Remove this after the unsend request is enabled
|
||||
|
||||
- (BOOL)canCopyMedia;
|
||||
- (BOOL)canSaveMedia;
|
||||
|
||||
// For view items that correspond to interactions, this is the interaction's unique id.
|
||||
// For other view views (like the typing indicator), this is a unique, stable string.
|
||||
- (NSString *)itemId;
|
||||
|
||||
- (nullable TSAttachmentStream *)firstValidAlbumAttachment;
|
||||
|
||||
- (BOOL)mediaAlbumHasFailedAttachment;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface ConversationInteractionViewItem
|
||||
: NSObject <ConversationViewItem, OWSAudioPlayerDelegate>
|
||||
|
||||
- (instancetype)init NS_UNAVAILABLE;
|
||||
- (instancetype)initWithInteraction:(TSInteraction *)interaction
|
||||
isGroupThread:(BOOL)isGroupThread
|
||||
transaction:(YapDatabaseReadTransaction *)transaction;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
File diff suppressed because it is too large
Load Diff
|
@ -1,142 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class ConversationStyle;
|
||||
@class ConversationViewModel;
|
||||
@class OWSQuotedReplyModel;
|
||||
@class TSOutgoingMessage;
|
||||
@class TSThread;
|
||||
@class ThreadDynamicInteractions;
|
||||
|
||||
@protocol ConversationViewItem;
|
||||
|
||||
typedef NS_ENUM(NSUInteger, ConversationUpdateType) {
|
||||
// No view items in the load window were effected.
|
||||
ConversationUpdateType_Minor,
|
||||
// A subset of view items in the load window were effected;
|
||||
// the view should be updated using the update items.
|
||||
ConversationUpdateType_Diff,
|
||||
// Complicated or unexpected changes occurred in the load window;
|
||||
// the view should be reloaded.
|
||||
ConversationUpdateType_Reload,
|
||||
};
|
||||
|
||||
#pragma mark -
|
||||
|
||||
typedef NS_ENUM(NSUInteger, ConversationUpdateItemType) {
|
||||
ConversationUpdateItemType_Insert,
|
||||
ConversationUpdateItemType_Delete,
|
||||
ConversationUpdateItemType_Update,
|
||||
};
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface ConversationViewState : NSObject
|
||||
|
||||
@property (nonatomic, readonly) NSArray<id<ConversationViewItem>> *viewItems;
|
||||
@property (nonatomic, readonly) NSDictionary<NSString *, NSNumber *> *interactionIndexMap;
|
||||
// We have to track interactionIds separately. We can't just use interactionIndexMap.allKeys,
|
||||
// as that won't preserve ordering.
|
||||
@property (nonatomic, readonly) NSArray<NSString *> *interactionIds;
|
||||
@property (nonatomic, readonly, nullable) NSNumber *unreadIndicatorIndex;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface ConversationUpdateItem : NSObject
|
||||
|
||||
@property (nonatomic, readonly) ConversationUpdateItemType updateItemType;
|
||||
// Only applies in the "delete" and "update" cases.
|
||||
@property (nonatomic, readonly) NSUInteger oldIndex;
|
||||
// Only applies in the "insert" and "update" cases.
|
||||
@property (nonatomic, readonly) NSUInteger newIndex;
|
||||
// Only applies in the "insert" and "update" cases.
|
||||
@property (nonatomic, readonly, nullable) id<ConversationViewItem> viewItem;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface ConversationUpdate : NSObject
|
||||
|
||||
@property (nonatomic, readonly) ConversationUpdateType conversationUpdateType;
|
||||
// Only applies in the "diff" case.
|
||||
@property (nonatomic, readonly, nullable) NSArray<ConversationUpdateItem *> *updateItems;
|
||||
//// Only applies in the "diff" case.
|
||||
@property (nonatomic, readonly) BOOL shouldAnimateUpdates;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@protocol ConversationViewModelDelegate <NSObject>
|
||||
|
||||
- (void)conversationViewModelWillUpdate;
|
||||
- (void)conversationViewModelDidUpdate:(ConversationUpdate *)conversationUpdate;
|
||||
|
||||
- (void)conversationViewModelWillLoadMoreItems;
|
||||
- (void)conversationViewModelDidLoadMoreItems;
|
||||
- (void)conversationViewModelDidLoadPrevPage;
|
||||
- (void)conversationViewModelRangeDidChange;
|
||||
|
||||
// Called after the view model recovers from a severe error
|
||||
// to prod the view to reset its scroll state, etc.
|
||||
- (void)conversationViewModelDidReset;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
// Always load up to n messages when user arrives.
|
||||
//
|
||||
// The smaller this number is, the faster the conversation can display.
|
||||
// To test, shrink you accessibility font as much as possible, then count how many 1-line system info messages (our
|
||||
// shortest cells) can fit on screen at a time on an iPhoneX
|
||||
//
|
||||
// PERF: we could do less messages on shorter (older, slower) devices
|
||||
// PERF: we could cache the cell height, since some messages will be much taller.
|
||||
static const int kYapDatabasePageSize = 250;
|
||||
|
||||
// Never show more than n messages in conversation view when user arrives.
|
||||
static const int kConversationInitialMaxRangeSize = 250;
|
||||
|
||||
// Never show more than n messages in conversation view at a time.
|
||||
static const int kYapDatabaseRangeMaxLength = 250000;
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface ConversationViewModel : NSObject
|
||||
|
||||
@property (nonatomic, readonly) ConversationViewState *viewState;
|
||||
@property (nonatomic, nullable) NSString *focusMessageIdOnOpen;
|
||||
@property (nonatomic, readonly, nullable) ThreadDynamicInteractions *dynamicInteractions;
|
||||
|
||||
- (instancetype)init NS_UNAVAILABLE;
|
||||
- (instancetype)initWithThread:(TSThread *)thread
|
||||
focusMessageIdOnOpen:(nullable NSString *)focusMessageIdOnOpen
|
||||
delegate:(id<ConversationViewModelDelegate>)delegate NS_DESIGNATED_INITIALIZER;
|
||||
|
||||
- (void)ensureDynamicInteractionsAndUpdateIfNecessary;
|
||||
|
||||
- (void)loadAnotherPageOfMessages;
|
||||
|
||||
- (void)viewDidResetContentAndLayout;
|
||||
|
||||
- (void)viewDidLoad;
|
||||
|
||||
- (BOOL)canLoadMoreItems;
|
||||
|
||||
- (nullable NSIndexPath *)ensureLoadWindowContainsQuotedReply:(OWSQuotedReplyModel *)quotedReply;
|
||||
- (nullable NSIndexPath *)ensureLoadWindowContainsInteractionId:(NSString *)interactionId;
|
||||
|
||||
- (void)appendUnsavedOutgoingTextMessage:(TSOutgoingMessage *)outgoingMessage;
|
||||
|
||||
- (BOOL)reloadViewItems;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,719 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||
public typealias SectionModel = ArraySection<Section, MessageViewModel>
|
||||
|
||||
// MARK: - Action
|
||||
|
||||
public enum Action {
|
||||
case none
|
||||
case compose
|
||||
case audioCall
|
||||
case videoCall
|
||||
}
|
||||
|
||||
// MARK: - Section
|
||||
|
||||
public enum Section: Differentiable, Equatable, Comparable, Hashable {
|
||||
case loadOlder
|
||||
case messages
|
||||
case loadNewer
|
||||
}
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
public static let pageSize: Int = 50
|
||||
|
||||
private var threadId: String
|
||||
public let initialThreadVariant: SessionThread.Variant
|
||||
public var sentMessageBeforeUpdate: Bool = false
|
||||
public var lastSearchedText: String?
|
||||
public let focusedInteractionId: Int64? // Note: This is used for global search
|
||||
|
||||
public lazy var blockedBannerMessage: String = {
|
||||
switch self.threadData.threadVariant {
|
||||
case .contact:
|
||||
let name: String = Profile.displayName(
|
||||
id: self.threadData.threadId,
|
||||
threadVariant: self.threadData.threadVariant
|
||||
)
|
||||
|
||||
return "\(name) is blocked. Unblock them?"
|
||||
|
||||
default: return "Thread is blocked. Unblock it?"
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64?) {
|
||||
// If we have a specified 'focusedInteractionId' then use that, otherwise retrieve the oldest
|
||||
// unread interaction and start focused around that one
|
||||
let targetInteractionId: Int64? = {
|
||||
if let focusedInteractionId: Int64 = focusedInteractionId { return focusedInteractionId }
|
||||
|
||||
return Storage.shared.read { db in
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
|
||||
return try Interaction
|
||||
.select(.id)
|
||||
.filter(interaction[.wasRead] == false)
|
||||
.filter(interaction[.threadId] == threadId)
|
||||
.order(interaction[.timestampMs].asc)
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchOne(db)
|
||||
}
|
||||
}()
|
||||
|
||||
self.threadId = threadId
|
||||
self.initialThreadVariant = threadVariant
|
||||
self.focusedInteractionId = targetInteractionId
|
||||
self.pagedDataObserver = nil
|
||||
|
||||
// Note: Since this references self we need to finish initializing before setting it, we
|
||||
// also want to skip the initial query and trigger it async so that the push animation
|
||||
// doesn't stutter (it should load basically immediately but without this there is a
|
||||
// distinct stutter)
|
||||
self.pagedDataObserver = self.setupPagedObserver(
|
||||
for: threadId,
|
||||
userPublicKey: getUserHexEncodedPublicKey()
|
||||
)
|
||||
|
||||
// Run the initial query on a background thread so we don't block the push transition
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
// If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query
|
||||
// from a `0` offset)
|
||||
guard let initialFocusedId: Int64 = targetInteractionId else {
|
||||
self?.pagedDataObserver?.load(.pageBefore)
|
||||
return
|
||||
}
|
||||
|
||||
self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Thread Data
|
||||
|
||||
/// This value is the current state of the view
|
||||
public private(set) lazy var threadData: SessionThreadViewModel = SessionThreadViewModel(
|
||||
threadId: self.threadId,
|
||||
threadVariant: self.initialThreadVariant,
|
||||
currentUserIsClosedGroupMember: (self.initialThreadVariant != .closedGroup ?
|
||||
nil :
|
||||
Storage.shared.read { db in
|
||||
try GroupMember
|
||||
.filter(GroupMember.Columns.groupId == self.threadId)
|
||||
.filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db))
|
||||
.filter(GroupMember.Columns.role == GroupMember.Role.standard)
|
||||
.isNotEmpty(db)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
||||
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||
///
|
||||
/// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static
|
||||
/// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
|
||||
///
|
||||
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
|
||||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
||||
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||
public lazy var observableThreadData: ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> = setupObservableThreadData(for: self.threadId)
|
||||
|
||||
private func setupObservableThreadData(for threadId: String) -> ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> {
|
||||
return ValueObservation
|
||||
.trackingConstantRegion { db -> SessionThreadViewModel? in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let recentReactionEmoji: [String] = try Emoji.getRecent(db, withDefaultEmoji: true)
|
||||
let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
||||
.conversationQuery(threadId: threadId, userPublicKey: userPublicKey)
|
||||
.fetchOne(db)
|
||||
|
||||
return threadViewModel
|
||||
.map { $0.with(recentReactionEmoji: recentReactionEmoji) }
|
||||
}
|
||||
.removeDuplicates()
|
||||
}
|
||||
|
||||
public func updateThreadData(_ updatedData: SessionThreadViewModel) {
|
||||
self.threadData = updatedData
|
||||
}
|
||||
|
||||
// MARK: - Interaction Data
|
||||
|
||||
public private(set) var unobservedInteractionDataChanges: [SectionModel]?
|
||||
public private(set) var interactionData: [SectionModel] = []
|
||||
public private(set) var reactionExpandedInteractionIds: Set<Int64> = []
|
||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<Interaction, MessageViewModel>?
|
||||
|
||||
public var onInteractionChange: (([SectionModel]) -> ())? {
|
||||
didSet {
|
||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||
// data was changed while we weren't observing
|
||||
if let unobservedInteractionDataChanges: [SectionModel] = self.unobservedInteractionDataChanges {
|
||||
onInteractionChange?(unobservedInteractionDataChanges)
|
||||
self.unobservedInteractionDataChanges = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupPagedObserver(for threadId: String, userPublicKey: String) -> PagedDatabaseObserver<Interaction, MessageViewModel> {
|
||||
return PagedDatabaseObserver(
|
||||
pagedTable: Interaction.self,
|
||||
pageSize: ConversationViewModel.pageSize,
|
||||
idColumn: .id,
|
||||
observedChanges: [
|
||||
PagedData.ObservedChanges(
|
||||
table: Interaction.self,
|
||||
columns: Interaction.Columns
|
||||
.allCases
|
||||
.filter { $0 != .wasRead }
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: Contact.self,
|
||||
columns: [.isTrusted],
|
||||
joinToPagedType: {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: Profile.self,
|
||||
columns: [.profilePictureFileName],
|
||||
joinToPagedType: {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
|
||||
}()
|
||||
)
|
||||
],
|
||||
filterSQL: MessageViewModel.filterSQL(threadId: threadId),
|
||||
groupSQL: MessageViewModel.groupSQL,
|
||||
orderSQL: MessageViewModel.orderSQL,
|
||||
dataQuery: MessageViewModel.baseQuery(
|
||||
userPublicKey: userPublicKey,
|
||||
orderSQL: MessageViewModel.orderSQL,
|
||||
groupSQL: MessageViewModel.groupSQL
|
||||
),
|
||||
associatedRecords: [
|
||||
AssociatedRecord<MessageViewModel.AttachmentInteractionInfo, MessageViewModel>(
|
||||
trackedAgainst: Attachment.self,
|
||||
observedChanges: [
|
||||
PagedData.ObservedChanges(
|
||||
table: Attachment.self,
|
||||
columns: [.state]
|
||||
)
|
||||
],
|
||||
dataQuery: MessageViewModel.AttachmentInteractionInfo.baseQuery,
|
||||
joinToPagedType: MessageViewModel.AttachmentInteractionInfo.joinToViewModelQuerySQL,
|
||||
associateData: MessageViewModel.AttachmentInteractionInfo.createAssociateDataClosure()
|
||||
),
|
||||
AssociatedRecord<MessageViewModel.ReactionInfo, MessageViewModel>(
|
||||
trackedAgainst: Reaction.self,
|
||||
observedChanges: [
|
||||
PagedData.ObservedChanges(
|
||||
table: Reaction.self,
|
||||
columns: [.count]
|
||||
)
|
||||
],
|
||||
dataQuery: MessageViewModel.ReactionInfo.baseQuery,
|
||||
joinToPagedType: MessageViewModel.ReactionInfo.joinToViewModelQuerySQL,
|
||||
associateData: MessageViewModel.ReactionInfo.createAssociateDataClosure()
|
||||
),
|
||||
AssociatedRecord<MessageViewModel.TypingIndicatorInfo, MessageViewModel>(
|
||||
trackedAgainst: ThreadTypingIndicator.self,
|
||||
observedChanges: [
|
||||
PagedData.ObservedChanges(
|
||||
table: ThreadTypingIndicator.self,
|
||||
events: [.insert, .delete],
|
||||
columns: []
|
||||
)
|
||||
],
|
||||
dataQuery: MessageViewModel.TypingIndicatorInfo.baseQuery,
|
||||
joinToPagedType: MessageViewModel.TypingIndicatorInfo.joinToViewModelQuerySQL,
|
||||
associateData: MessageViewModel.TypingIndicatorInfo.createAssociateDataClosure()
|
||||
)
|
||||
],
|
||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
guard let updatedInteractionData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
|
||||
return
|
||||
}
|
||||
|
||||
// If we have the 'onInteractionChanged' callback then trigger it, otherwise just store the changes
|
||||
// to be sent to the callback if we ever start observing again (when we have the callback it needs
|
||||
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
|
||||
// correct order)
|
||||
guard let onInteractionChange: (([SectionModel]) -> ()) = self?.onInteractionChange else {
|
||||
self?.unobservedInteractionDataChanges = updatedInteractionData
|
||||
return
|
||||
}
|
||||
|
||||
onInteractionChange(updatedInteractionData)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
||||
let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true })
|
||||
let sortedData: [MessageViewModel] = data
|
||||
.filter { $0.isTypingIndicator != true }
|
||||
.sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs }
|
||||
|
||||
// We load messages from newest to oldest so having a pageOffset larger than zero means
|
||||
// there are newer pages to load
|
||||
return [
|
||||
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
|
||||
[SectionModel(section: .loadOlder)] :
|
||||
[]
|
||||
),
|
||||
[
|
||||
SectionModel(
|
||||
section: .messages,
|
||||
elements: sortedData
|
||||
.enumerated()
|
||||
.map { index, cellViewModel -> MessageViewModel in
|
||||
cellViewModel.withClusteringChanges(
|
||||
prevModel: (index > 0 ? sortedData[index - 1] : nil),
|
||||
nextModel: (index < (sortedData.count - 1) ? sortedData[index + 1] : nil),
|
||||
isLast: (
|
||||
// The database query sorts by timestampMs descending so the "last"
|
||||
// interaction will actually have a 'pageOffset' of '0' even though
|
||||
// it's the last element in the 'sortedData' array
|
||||
index == (sortedData.count - 1) &&
|
||||
pageInfo.pageOffset == 0
|
||||
),
|
||||
currentUserBlindedPublicKey: threadData.currentUserBlindedPublicKey
|
||||
)
|
||||
}
|
||||
.appending(typingIndicator)
|
||||
)
|
||||
],
|
||||
(!data.isEmpty && pageInfo.pageOffset > 0 ?
|
||||
[SectionModel(section: .loadNewer)] :
|
||||
[]
|
||||
)
|
||||
].flatMap { $0 }
|
||||
}
|
||||
|
||||
public func updateInteractionData(_ updatedData: [SectionModel]) {
|
||||
self.interactionData = updatedData
|
||||
}
|
||||
|
||||
public func expandReactions(for interactionId: Int64) {
|
||||
reactionExpandedInteractionIds.insert(interactionId)
|
||||
}
|
||||
|
||||
public func collapseReactions(for interactionId: Int64) {
|
||||
reactionExpandedInteractionIds.remove(interactionId)
|
||||
}
|
||||
|
||||
// MARK: - Mentions
|
||||
|
||||
public struct MentionInfo: FetchableRecord, Decodable {
|
||||
fileprivate static let threadVariantKey = CodingKeys.threadVariant.stringValue
|
||||
fileprivate static let openGroupServerKey = CodingKeys.openGroupServer.stringValue
|
||||
fileprivate static let openGroupRoomTokenKey = CodingKeys.openGroupRoomToken.stringValue
|
||||
|
||||
let profile: Profile
|
||||
let threadVariant: SessionThread.Variant
|
||||
let openGroupServer: String?
|
||||
let openGroupRoomToken: String?
|
||||
}
|
||||
|
||||
public func mentions(for query: String = "") -> [MentionInfo] {
|
||||
let threadData: SessionThreadViewModel = self.threadData
|
||||
|
||||
let results: [MentionInfo] = Storage.shared
|
||||
.read { db -> [MentionInfo] in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
switch threadData.threadVariant {
|
||||
case .contact:
|
||||
guard userPublicKey != threadData.threadId else { return [] }
|
||||
|
||||
return [Profile.fetchOrCreate(db, id: threadData.threadId)]
|
||||
.map { profile in
|
||||
MentionInfo(
|
||||
profile: profile,
|
||||
threadVariant: threadData.threadVariant,
|
||||
openGroupServer: nil,
|
||||
openGroupRoomToken: nil
|
||||
)
|
||||
}
|
||||
.filter {
|
||||
query.count < 2 ||
|
||||
$0.profile.displayName(for: $0.threadVariant).contains(query)
|
||||
}
|
||||
|
||||
case .closedGroup:
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
|
||||
return try GroupMember
|
||||
.select(
|
||||
profile.allColumns(),
|
||||
SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey)
|
||||
)
|
||||
.filter(GroupMember.Columns.groupId == threadData.threadId)
|
||||
.filter(GroupMember.Columns.profileId != userPublicKey)
|
||||
.filter(GroupMember.Columns.role == GroupMember.Role.standard)
|
||||
.joining(
|
||||
required: GroupMember.profile
|
||||
.aliased(profile)
|
||||
// Note: LIKE is case-insensitive in SQLite
|
||||
.filter(
|
||||
query.count < 2 || (
|
||||
profile[.nickname] != nil &&
|
||||
profile[.nickname].like("%\(query)%")
|
||||
) || (
|
||||
profile[.nickname] == nil &&
|
||||
profile[.name].like("%\(query)%")
|
||||
)
|
||||
)
|
||||
)
|
||||
.asRequest(of: MentionInfo.self)
|
||||
.fetchAll(db)
|
||||
|
||||
case .openGroup:
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
|
||||
return try Interaction
|
||||
.select(
|
||||
profile.allColumns(),
|
||||
SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey),
|
||||
SQL("\(threadData.openGroupServer)").forKey(MentionInfo.openGroupServerKey),
|
||||
SQL("\(threadData.openGroupRoomToken)").forKey(MentionInfo.openGroupRoomTokenKey)
|
||||
)
|
||||
.distinct()
|
||||
.group(Interaction.Columns.authorId)
|
||||
.filter(Interaction.Columns.threadId == threadData.threadId)
|
||||
.filter(Interaction.Columns.authorId != userPublicKey)
|
||||
.joining(
|
||||
required: Interaction.profile
|
||||
.aliased(profile)
|
||||
// Note: LIKE is case-insensitive in SQLite
|
||||
.filter(
|
||||
query.count < 2 || (
|
||||
profile[.nickname] != nil &&
|
||||
profile[.nickname].like("%\(query)%")
|
||||
) || (
|
||||
profile[.nickname] == nil &&
|
||||
profile[.name].like("%\(query)%")
|
||||
)
|
||||
)
|
||||
)
|
||||
.order(Interaction.Columns.timestampMs.desc)
|
||||
.limit(20)
|
||||
.asRequest(of: MentionInfo.self)
|
||||
.fetchAll(db)
|
||||
}
|
||||
}
|
||||
.defaulting(to: [])
|
||||
|
||||
guard query.count >= 2 else {
|
||||
return results.sorted { lhs, rhs -> Bool in
|
||||
lhs.profile.displayName(for: lhs.threadVariant) < rhs.profile.displayName(for: rhs.threadVariant)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.sorted { lhs, rhs -> Bool in
|
||||
let maybeLhsRange = lhs.profile.displayName(for: lhs.threadVariant).lowercased().range(of: query.lowercased())
|
||||
let maybeRhsRange = rhs.profile.displayName(for: rhs.threadVariant).lowercased().range(of: query.lowercased())
|
||||
|
||||
guard let lhsRange: Range<String.Index> = maybeLhsRange, let rhsRange: Range<String.Index> = maybeRhsRange else {
|
||||
return true
|
||||
}
|
||||
|
||||
return (lhsRange.lowerBound < rhsRange.lowerBound)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public func updateDraft(to draft: String) {
|
||||
Storage.shared.writeAsync { db in
|
||||
try SessionThread
|
||||
.filter(id: self.threadId)
|
||||
.updateAll(db, SessionThread.Columns.messageDraft.set(to: draft))
|
||||
}
|
||||
}
|
||||
|
||||
public func markAllAsRead() {
|
||||
guard let lastInteractionId: Int64 = self.threadData.interactionId else { return }
|
||||
|
||||
let threadId: String = self.threadData.threadId
|
||||
let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false)
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
try Interaction.markAsRead(
|
||||
db,
|
||||
interactionId: lastInteractionId,
|
||||
threadId: threadId,
|
||||
includingOlder: true,
|
||||
trySendReadReceipt: trySendReadReceipt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func swapToThread(updatedThreadId: String) {
|
||||
let oldestMessageId: Int64? = self.interactionData
|
||||
.filter { $0.model == .messages }
|
||||
.first?
|
||||
.elements
|
||||
.first?
|
||||
.id
|
||||
|
||||
self.threadId = updatedThreadId
|
||||
self.observableThreadData = self.setupObservableThreadData(for: updatedThreadId)
|
||||
self.pagedDataObserver = self.setupPagedObserver(
|
||||
for: updatedThreadId,
|
||||
userPublicKey: getUserHexEncodedPublicKey()
|
||||
)
|
||||
|
||||
// Try load everything up to the initial visible message, fallback to just the initial page of messages
|
||||
// if we don't have one
|
||||
switch oldestMessageId {
|
||||
case .some(let id): self.pagedDataObserver?.load(.untilInclusive(id: id, padding: 0))
|
||||
case .none: self.pagedDataObserver?.load(.pageBefore)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio Playback
|
||||
|
||||
public struct PlaybackInfo {
|
||||
let state: AudioPlaybackState
|
||||
let progress: TimeInterval
|
||||
let playbackRate: Double
|
||||
let oldPlaybackRate: Double
|
||||
let updateCallback: (PlaybackInfo?, Error?) -> ()
|
||||
|
||||
public func with(
|
||||
state: AudioPlaybackState? = nil,
|
||||
progress: TimeInterval? = nil,
|
||||
playbackRate: Double? = nil,
|
||||
updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil
|
||||
) -> PlaybackInfo {
|
||||
return PlaybackInfo(
|
||||
state: (state ?? self.state),
|
||||
progress: (progress ?? self.progress),
|
||||
playbackRate: (playbackRate ?? self.playbackRate),
|
||||
oldPlaybackRate: self.playbackRate,
|
||||
updateCallback: (updateCallback ?? self.updateCallback)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var audioPlayer: Atomic<OWSAudioPlayer?> = Atomic(nil)
|
||||
private var currentPlayingInteraction: Atomic<Int64?> = Atomic(nil)
|
||||
private var playbackInfo: Atomic<[Int64: PlaybackInfo]> = Atomic([:])
|
||||
|
||||
public func playbackInfo(for viewModel: MessageViewModel, updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil) -> PlaybackInfo? {
|
||||
// Use the existing info if it already exists (update it's callback if provided as that means
|
||||
// the cell was reloaded)
|
||||
if let currentPlaybackInfo: PlaybackInfo = playbackInfo.wrappedValue[viewModel.id] {
|
||||
let updatedPlaybackInfo: PlaybackInfo = currentPlaybackInfo
|
||||
.with(updateCallback: updateCallback)
|
||||
|
||||
playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
|
||||
|
||||
return updatedPlaybackInfo
|
||||
}
|
||||
|
||||
// Validate the item is a valid audio item
|
||||
guard
|
||||
let updateCallback: ((PlaybackInfo?, Error?) -> ()) = updateCallback,
|
||||
let attachment: Attachment = viewModel.attachments?.first,
|
||||
attachment.isAudio,
|
||||
attachment.isValid,
|
||||
let originalFilePath: String = attachment.originalFilePath,
|
||||
FileManager.default.fileExists(atPath: originalFilePath)
|
||||
else { return nil }
|
||||
|
||||
// Create the info with the update callback
|
||||
let newPlaybackInfo: PlaybackInfo = PlaybackInfo(
|
||||
state: .stopped,
|
||||
progress: 0,
|
||||
playbackRate: 1,
|
||||
oldPlaybackRate: 1,
|
||||
updateCallback: updateCallback
|
||||
)
|
||||
|
||||
// Cache the info
|
||||
playbackInfo.mutate { $0[viewModel.id] = newPlaybackInfo }
|
||||
|
||||
return newPlaybackInfo
|
||||
}
|
||||
|
||||
public func playOrPauseAudio(for viewModel: MessageViewModel) {
|
||||
guard
|
||||
let attachment: Attachment = viewModel.attachments?.first,
|
||||
let originalFilePath: String = attachment.originalFilePath,
|
||||
FileManager.default.fileExists(atPath: originalFilePath)
|
||||
else { return }
|
||||
|
||||
// If the user interacted with the currently playing item
|
||||
guard currentPlayingInteraction.wrappedValue != viewModel.id else {
|
||||
let currentPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[viewModel.id]
|
||||
let updatedPlaybackInfo: PlaybackInfo? = currentPlaybackInfo?
|
||||
.with(
|
||||
state: (currentPlaybackInfo?.state != .playing ? .playing : .paused),
|
||||
playbackRate: 1
|
||||
)
|
||||
|
||||
audioPlayer.wrappedValue?.playbackRate = 1
|
||||
|
||||
switch currentPlaybackInfo?.state {
|
||||
case .playing: audioPlayer.wrappedValue?.pause()
|
||||
default: audioPlayer.wrappedValue?.play()
|
||||
}
|
||||
|
||||
// Update the state and then update the UI with the updated state
|
||||
playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
|
||||
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// First stop any existing audio
|
||||
audioPlayer.wrappedValue?.stop()
|
||||
|
||||
// Then setup the state for the new audio
|
||||
currentPlayingInteraction.mutate { $0 = viewModel.id }
|
||||
|
||||
audioPlayer.mutate { [weak self] player in
|
||||
// Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer
|
||||
// gets deallocated it triggers state changes which cause UI bugs when auto-playing
|
||||
player?.delegate = nil
|
||||
player = nil
|
||||
|
||||
let audioPlayer: OWSAudioPlayer = OWSAudioPlayer(
|
||||
mediaUrl: URL(fileURLWithPath: originalFilePath),
|
||||
audioBehavior: .audioMessagePlayback,
|
||||
delegate: self
|
||||
)
|
||||
audioPlayer.play()
|
||||
audioPlayer.setCurrentTime(playbackInfo.wrappedValue[viewModel.id]?.progress ?? 0)
|
||||
player = audioPlayer
|
||||
}
|
||||
}
|
||||
|
||||
public func speedUpAudio(for viewModel: MessageViewModel) {
|
||||
// If we aren't playing the specified item then just start playing it
|
||||
guard viewModel.id == currentPlayingInteraction.wrappedValue else {
|
||||
playOrPauseAudio(for: viewModel)
|
||||
return
|
||||
}
|
||||
|
||||
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[viewModel.id]?
|
||||
.with(playbackRate: 1.5)
|
||||
|
||||
// Speed up the audio player
|
||||
audioPlayer.wrappedValue?.playbackRate = 1.5
|
||||
|
||||
playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
|
||||
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
||||
}
|
||||
|
||||
public func stopAudio() {
|
||||
audioPlayer.wrappedValue?.stop()
|
||||
|
||||
currentPlayingInteraction.mutate { $0 = nil }
|
||||
audioPlayer.mutate {
|
||||
// Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer
|
||||
// gets deallocated it triggers state changes which cause UI bugs when auto-playing
|
||||
$0?.delegate = nil
|
||||
$0 = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OWSAudioPlayerDelegate
|
||||
|
||||
public func audioPlaybackState() -> AudioPlaybackState {
|
||||
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return .stopped }
|
||||
|
||||
return (playbackInfo.wrappedValue[interactionId]?.state ?? .stopped)
|
||||
}
|
||||
|
||||
public func setAudioPlaybackState(_ state: AudioPlaybackState) {
|
||||
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
|
||||
|
||||
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
|
||||
.with(state: state)
|
||||
|
||||
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
|
||||
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
||||
}
|
||||
|
||||
public func setAudioProgress(_ progress: CGFloat, duration: CGFloat) {
|
||||
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
|
||||
|
||||
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
|
||||
.with(progress: TimeInterval(progress))
|
||||
|
||||
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
|
||||
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
||||
}
|
||||
|
||||
public func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully: Bool) {
|
||||
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
|
||||
guard successfully else { return }
|
||||
|
||||
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
|
||||
.with(
|
||||
state: .stopped,
|
||||
progress: 0,
|
||||
playbackRate: 1
|
||||
)
|
||||
|
||||
// Safe the changes and send one final update to the UI
|
||||
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
|
||||
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
|
||||
|
||||
// Clear out the currently playing record
|
||||
currentPlayingInteraction.mutate { $0 = nil }
|
||||
audioPlayer.mutate {
|
||||
// Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer
|
||||
// gets deallocated it triggers state changes which cause UI bugs when auto-playing
|
||||
$0?.delegate = nil
|
||||
$0 = nil
|
||||
}
|
||||
|
||||
// If the next interaction is another voice message then autoplay it
|
||||
guard
|
||||
let messageSection: SectionModel = self.interactionData
|
||||
.first(where: { $0.model == .messages }),
|
||||
let currentIndex: Int = messageSection.elements
|
||||
.firstIndex(where: { $0.id == interactionId }),
|
||||
currentIndex < (messageSection.elements.count - 1),
|
||||
messageSection.elements[currentIndex + 1].cellType == .audio
|
||||
else { return }
|
||||
|
||||
let nextItem: MessageViewModel = messageSection.elements[currentIndex + 1]
|
||||
playOrPauseAudio(for: nextItem)
|
||||
}
|
||||
|
||||
public func showInvalidAudioFileAlert() {
|
||||
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
|
||||
|
||||
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
|
||||
.with(
|
||||
state: .stopped,
|
||||
progress: 0,
|
||||
playbackRate: 1
|
||||
)
|
||||
|
||||
currentPlayingInteraction.mutate { $0 = nil }
|
||||
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
|
||||
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, AttachmentError.invalidData)
|
||||
}
|
||||
}
|
|
@ -1,6 +1,10 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
protocol EmojiPickerCollectionViewDelegate: AnyObject {
|
||||
func emojiPicker(_ emojiPicker: EmojiPickerCollectionView, didSelectEmoji emoji: EmojiWithSkinTones)
|
||||
func emojiPicker(_ emojiPicker: EmojiPickerCollectionView?, didSelectEmoji emoji: EmojiWithSkinTones)
|
||||
func emojiPickerWillBeginDragging(_ emojiPicker: EmojiPickerCollectionView)
|
||||
}
|
||||
|
||||
|
@ -38,6 +42,8 @@ class EmojiPickerCollectionView: UICollectionView {
|
|||
}
|
||||
|
||||
lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissSkinTonePicker))
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
layout = UICollectionViewFlowLayout()
|
||||
|
@ -66,24 +72,31 @@ class EmojiPickerCollectionView: UICollectionView {
|
|||
addGestureRecognizer(tapGestureRecognizer)
|
||||
tapGestureRecognizer.delegate = self
|
||||
|
||||
Storage.read { transaction in
|
||||
self.recentEmoji = Storage.shared.getRecentEmoji(withDefaultEmoji: false, transaction: transaction)
|
||||
|
||||
// Fetch the emoji data from the database
|
||||
let maybeEmojiData: (recent: [EmojiWithSkinTones], allGrouped: [Emoji.Category: [EmojiWithSkinTones]])? = Storage.shared.read { db in
|
||||
// Some emoji have two different code points but identical appearances. Let's remove them!
|
||||
// If we normalize to a different emoji than the one currently in our array, we want to drop
|
||||
// the non-normalized variant if the normalized variant already exists. Otherwise, map to the
|
||||
// normalized variant.
|
||||
for (idx, emoji) in self.recentEmoji.enumerated().reversed() {
|
||||
if !emoji.isNormalized {
|
||||
if self.recentEmoji.contains(emoji.normalized) {
|
||||
self.recentEmoji.remove(at: idx)
|
||||
} else {
|
||||
self.recentEmoji[idx] = emoji.normalized
|
||||
let recentEmoji: [EmojiWithSkinTones] = try Emoji.getRecent(db, withDefaultEmoji: false)
|
||||
.compactMap { EmojiWithSkinTones(rawValue: $0) }
|
||||
.reduce(into: [EmojiWithSkinTones]()) { result, emoji in
|
||||
guard !emoji.isNormalized else {
|
||||
result.append(emoji)
|
||||
return
|
||||
}
|
||||
guard !result.contains(emoji.normalized) else { return }
|
||||
|
||||
result.append(emoji.normalized)
|
||||
}
|
||||
}
|
||||
|
||||
self.allSendableEmojiByCategory = Emoji.allSendableEmojiByCategoryWithPreferredSkinTones(transaction: transaction)
|
||||
let allSendableEmojiByCategory: [Emoji.Category: [EmojiWithSkinTones]] = Emoji.allSendableEmojiByCategoryWithPreferredSkinTones(db)
|
||||
|
||||
return (recentEmoji, allSendableEmojiByCategory)
|
||||
}
|
||||
|
||||
if let emojiData: (recent: [EmojiWithSkinTones], allGrouped: [Emoji.Category: [EmojiWithSkinTones]]) = maybeEmojiData {
|
||||
self.recentEmoji = emojiData.recent
|
||||
self.allSendableEmojiByCategory = emojiData.allGrouped
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -92,7 +105,9 @@ class EmojiPickerCollectionView: UICollectionView {
|
|||
}
|
||||
|
||||
// This is not an exact calculation, but is simple and works for our purposes.
|
||||
var numberOfColumns: Int { Int((self.width()) / (EmojiPickerCollectionView.emojiWidth + EmojiPickerCollectionView.minimumSpacing)) }
|
||||
var numberOfColumns: Int {
|
||||
Int((self.width()) / (EmojiPickerCollectionView.emojiWidth + EmojiPickerCollectionView.minimumSpacing))
|
||||
}
|
||||
|
||||
// At max, we show 3 rows of recent emoji
|
||||
private var maxRecentEmoji: Int { numberOfColumns * 3 }
|
||||
|
@ -170,19 +185,19 @@ class EmojiPickerCollectionView: UICollectionView {
|
|||
|
||||
currentSkinTonePicker?.dismiss()
|
||||
currentSkinTonePicker = EmojiSkinTonePicker.present(referenceView: cell, emoji: emoji) { [weak self] emoji in
|
||||
guard let self = self else { return }
|
||||
|
||||
if let emoji = emoji {
|
||||
Storage.write { transaction in
|
||||
Storage.shared.recordRecentEmoji(emoji, transaction: transaction)
|
||||
emoji.baseEmoji.setPreferredSkinTones(emoji.skinTones, transaction: transaction)
|
||||
if let emoji: EmojiWithSkinTones = emoji {
|
||||
Storage.shared.writeAsync { db in
|
||||
emoji.baseEmoji.setPreferredSkinTones(
|
||||
db,
|
||||
preferredSkinTonePermutation: emoji.skinTones
|
||||
)
|
||||
}
|
||||
|
||||
self.pickerDelegate?.emojiPicker(self, didSelectEmoji: emoji)
|
||||
self?.pickerDelegate?.emojiPicker(self, didSelectEmoji: emoji)
|
||||
}
|
||||
|
||||
self.currentSkinTonePicker?.dismiss()
|
||||
self.currentSkinTonePicker = nil
|
||||
self?.currentSkinTonePicker?.dismiss()
|
||||
self?.currentSkinTonePicker = nil
|
||||
}
|
||||
case .changed:
|
||||
currentSkinTonePicker?.didChangeLongPress(sender)
|
||||
|
@ -215,11 +230,7 @@ extension EmojiPickerCollectionView: UICollectionViewDelegate {
|
|||
guard let emoji = emojiForIndexPath(indexPath) else {
|
||||
return owsFailDebug("Missing emoji for indexPath \(indexPath)")
|
||||
}
|
||||
|
||||
Storage.write { transaction in
|
||||
Storage.shared.recordRecentEmoji(emoji, transaction: transaction)
|
||||
}
|
||||
|
||||
|
||||
pickerDelegate?.emojiPicker(self, didSelectEmoji: emoji)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -114,7 +114,7 @@ extension EmojiPickerSheet: EmojiPickerCollectionViewDelegate {
|
|||
searchBar.resignFirstResponder()
|
||||
}
|
||||
|
||||
func emojiPicker(_ emojiPicker: EmojiPickerCollectionView, didSelectEmoji emoji: EmojiWithSkinTones) {
|
||||
func emojiPicker(_ emojiPicker: EmojiPickerCollectionView?, didSelectEmoji emoji: EmojiWithSkinTones) {
|
||||
completionHandler(emoji)
|
||||
dismiss(animated: true, completion: dismissHandler)
|
||||
}
|
||||
|
|
|
@ -143,7 +143,7 @@ final class ExpandingAttachmentsButton : UIView, InputViewButtonDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
// MARK: - Delegate
|
||||
|
||||
protocol ExpandingAttachmentsButtonDelegate: AnyObject {
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ public final class InputTextView : UITextView, UITextViewDelegate {
|
|||
private let maxWidth: CGFloat
|
||||
private lazy var heightConstraint = self.set(.height, to: minHeight)
|
||||
|
||||
public override var text: String! { didSet { handleTextChanged() } }
|
||||
public override var text: String? { didSet { handleTextChanged() } }
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var placeholderLabel: UILabel = {
|
||||
|
@ -79,21 +79,26 @@ public final class InputTextView : UITextView, UITextViewDelegate {
|
|||
|
||||
private func handleTextChanged() {
|
||||
defer { snDelegate?.inputTextViewDidChangeContent(self) }
|
||||
placeholderLabel.isHidden = !text.isEmpty
|
||||
|
||||
placeholderLabel.isHidden = !(text ?? "").isEmpty
|
||||
|
||||
let height = frame.height
|
||||
let size = sizeThatFits(CGSize(width: maxWidth, height: .greatestFiniteMagnitude))
|
||||
|
||||
// `textView.contentSize` isn't accurate when restoring a multiline draft, so we set it here manually
|
||||
self.contentSize = size
|
||||
let newHeight = size.height.clamp(minHeight, maxHeight)
|
||||
|
||||
guard newHeight != height else { return }
|
||||
|
||||
heightConstraint.constant = newHeight
|
||||
snDelegate?.inputTextViewDidChangeSize(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
protocol InputTextViewDelegate : AnyObject {
|
||||
|
||||
// MARK: - InputTextViewDelegate
|
||||
|
||||
protocol InputTextViewDelegate: AnyObject {
|
||||
func inputTextViewDidChangeSize(_ inputTextView: InputTextView)
|
||||
func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
|
||||
func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage)
|
||||
|
|
|
@ -1,51 +1,64 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, QuoteViewDelegate, LinkPreviewViewDelegate, MentionSelectionViewDelegate {
|
||||
enum MessageTypes {
|
||||
case all
|
||||
case textOnly
|
||||
case none
|
||||
}
|
||||
final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate {
|
||||
// MARK: - Variables
|
||||
|
||||
private static let linkPreviewViewInset: CGFloat = 6
|
||||
|
||||
private let threadVariant: SessionThread.Variant
|
||||
private weak var delegate: InputViewDelegate?
|
||||
var quoteDraftInfo: (model: OWSQuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } }
|
||||
var linkPreviewInfo: (url: String, draft: OWSLinkPreviewDraft?)?
|
||||
|
||||
var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } }
|
||||
var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)?
|
||||
private var voiceMessageRecordingView: VoiceMessageRecordingView?
|
||||
private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0)
|
||||
|
||||
private lazy var linkPreviewView: LinkPreviewView = {
|
||||
let maxWidth = self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset
|
||||
return LinkPreviewView(for: nil, maxWidth: maxWidth, delegate: self)
|
||||
let maxWidth: CGFloat = (self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset)
|
||||
|
||||
return LinkPreviewView(maxWidth: maxWidth) { [weak self] in
|
||||
self?.linkPreviewInfo = nil
|
||||
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
}
|
||||
}()
|
||||
|
||||
var text: String {
|
||||
get { inputTextView.text }
|
||||
get { inputTextView.text ?? "" }
|
||||
set { inputTextView.text = newValue }
|
||||
}
|
||||
|
||||
var enabledMessageTypes: MessageTypes = .all {
|
||||
var selectedRange: NSRange {
|
||||
get { inputTextView.selectedRange }
|
||||
set { inputTextView.selectedRange = newValue }
|
||||
}
|
||||
|
||||
var inputTextViewIsFirstResponder: Bool { inputTextView.isFirstResponder }
|
||||
|
||||
var enabledMessageTypes: MessageInputTypes = .all {
|
||||
didSet {
|
||||
setEnabledMessageTypes(enabledMessageTypes, message: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override var intrinsicContentSize: CGSize { CGSize.zero }
|
||||
var lastSearchedText: String? { nil }
|
||||
|
||||
// MARK: UI Components
|
||||
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private var bottomStackView: UIStackView?
|
||||
private lazy var attachmentsButton = ExpandingAttachmentsButton(delegate: delegate)
|
||||
|
||||
|
||||
private lazy var voiceMessageButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self)
|
||||
result.accessibilityLabel = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE", comment: "")
|
||||
result.accessibilityHint = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE", comment: "")
|
||||
return result
|
||||
}()
|
||||
|
||||
|
||||
|
||||
private lazy var sendButton: InputViewButton = {
|
||||
let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self)
|
||||
result.isHidden = true
|
||||
|
@ -55,25 +68,28 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton)
|
||||
|
||||
private lazy var mentionsView: MentionSelectionView = {
|
||||
let result = MentionSelectionView()
|
||||
let result: MentionSelectionView = MentionSelectionView()
|
||||
result.delegate = self
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var mentionsViewContainer: UIView = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
let backgroundView = UIView()
|
||||
backgroundView.backgroundColor = isLightMode ? .white : .black
|
||||
backgroundView.backgroundColor = (isLightMode ? .white : .black)
|
||||
backgroundView.alpha = Values.lowOpacity
|
||||
result.addSubview(backgroundView)
|
||||
backgroundView.pin(to: result)
|
||||
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
|
||||
let blurView: UIVisualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
result.addSubview(blurView)
|
||||
blurView.pin(to: result)
|
||||
result.alpha = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
||||
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
|
||||
// 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)
|
||||
return InputTextView(delegate: self, maxWidth: maxWidth)
|
||||
}()
|
||||
|
||||
|
||||
private lazy var disabledInputLabel: UILabel = {
|
||||
let label: UILabel = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -91,71 +107,78 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
label.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
label.textAlignment = .center
|
||||
label.alpha = 0
|
||||
|
||||
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var additionalContentContainer = UIView()
|
||||
|
||||
// MARK: Settings
|
||||
private static let linkPreviewViewInset: CGFloat = 6
|
||||
// MARK: - Initialization
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(delegate: InputViewDelegate) {
|
||||
init(threadVariant: SessionThread.Variant, delegate: InputViewDelegate) {
|
||||
self.threadVariant = threadVariant
|
||||
self.delegate = delegate
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(delegate:) instead.")
|
||||
}
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(delegate:) instead.")
|
||||
}
|
||||
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
autoresizingMask = .flexibleHeight
|
||||
|
||||
// Background & blur
|
||||
let backgroundView = UIView()
|
||||
backgroundView.backgroundColor = isLightMode ? .white : .black
|
||||
backgroundView.alpha = Values.lowOpacity
|
||||
addSubview(backgroundView)
|
||||
backgroundView.pin(to: self)
|
||||
|
||||
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
addSubview(blurView)
|
||||
blurView.pin(to: self)
|
||||
|
||||
// Separator
|
||||
let separator = UIView()
|
||||
separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
|
||||
separator.set(.height, to: 1 / UIScreen.main.scale)
|
||||
addSubview(separator)
|
||||
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
|
||||
|
||||
// Bottom stack view
|
||||
let bottomStackView = UIStackView(arrangedSubviews: [ attachmentsButton, inputTextView, container(for: sendButton) ])
|
||||
bottomStackView.axis = .horizontal
|
||||
bottomStackView.spacing = Values.smallSpacing
|
||||
bottomStackView.alignment = .center
|
||||
self.bottomStackView = bottomStackView
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ additionalContentContainer, bottomStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.isLayoutMarginsRelativeArrangement = true
|
||||
|
||||
let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2
|
||||
mainStackView.layoutMargins = UIEdgeInsets(top: 2, leading: Values.mediumSpacing - adjustment, bottom: 2, trailing: Values.mediumSpacing - adjustment)
|
||||
addSubview(mainStackView)
|
||||
mainStackView.pin(.top, to: .bottom, of: separator)
|
||||
mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self)
|
||||
mainStackView.pin(.bottom, to: .bottom, of: self)
|
||||
|
||||
|
||||
addSubview(disabledInputLabel)
|
||||
|
||||
|
||||
disabledInputLabel.pin(.top, to: .top, of: mainStackView)
|
||||
disabledInputLabel.pin(.left, to: .left, of: mainStackView)
|
||||
disabledInputLabel.pin(.right, to: .right, of: mainStackView)
|
||||
disabledInputLabel.set(.height, to: InputViewButton.expandedSize)
|
||||
|
||||
|
||||
// Mentions
|
||||
insertSubview(mentionsViewContainer, belowSubview: mainStackView)
|
||||
mentionsViewContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self)
|
||||
|
@ -163,12 +186,14 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
mentionsViewContainer.addSubview(mentionsView)
|
||||
mentionsView.pin(to: mentionsViewContainer)
|
||||
mentionsViewHeightConstraint.isActive = true
|
||||
|
||||
// Voice message button
|
||||
addSubview(voiceMessageButtonContainer)
|
||||
voiceMessageButtonContainer.center(in: sendButton)
|
||||
}
|
||||
|
||||
// MARK: - Updating
|
||||
|
||||
// MARK: Updating
|
||||
func inputTextViewDidChangeSize(_ inputTextView: InputTextView) {
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
@ -180,7 +205,7 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
autoGenerateLinkPreviewIfPossible()
|
||||
delegate?.inputTextViewDidChangeContent(inputTextView)
|
||||
}
|
||||
|
||||
|
||||
func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) {
|
||||
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
|
||||
// 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.
|
||||
|
||||
|
||||
private func handleQuoteDraftChanged() {
|
||||
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
linkPreviewInfo = nil
|
||||
|
||||
guard let quoteDraftInfo = quoteDraftInfo else { return }
|
||||
let direction: QuoteView.Direction = quoteDraftInfo.isOutgoing ? .outgoing : .incoming
|
||||
|
||||
let hInset: CGFloat = 6 // Slight visual adjustment
|
||||
let maxWidth = additionalContentContainer.bounds.width
|
||||
let quoteView = QuoteView(for: quoteDraftInfo.model, direction: direction, hInset: hInset, maxWidth: maxWidth, delegate: self)
|
||||
|
||||
let quoteView: QuoteView = QuoteView(
|
||||
for: .draft,
|
||||
authorId: quoteDraftInfo.model.authorId,
|
||||
quotedText: quoteDraftInfo.model.body,
|
||||
threadVariant: threadVariant,
|
||||
currentUserPublicKey: nil,
|
||||
currentUserBlindedPublicKey: nil,
|
||||
direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming),
|
||||
attachment: quoteDraftInfo.model.attachment,
|
||||
hInset: hInset,
|
||||
maxWidth: maxWidth
|
||||
) { [weak self] in
|
||||
self?.quoteDraftInfo = nil
|
||||
}
|
||||
|
||||
additionalContentContainer.addSubview(quoteView)
|
||||
quoteView.pin(.left, to: .left, of: additionalContentContainer, withInset: hInset)
|
||||
quoteView.pin(.top, to: .top, of: additionalContentContainer, withInset: 12)
|
||||
|
@ -207,64 +248,78 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
private func autoGenerateLinkPreviewIfPossible() {
|
||||
// Don't allow link previews on 'none' or 'textOnly' input
|
||||
guard enabledMessageTypes == .all else { return }
|
||||
|
||||
|
||||
// Suggest that the user enable link previews if they haven't already and we haven't
|
||||
// told them about link previews yet
|
||||
let text = inputTextView.text!
|
||||
let userDefaults = UserDefaults.standard
|
||||
if !OWSLinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && !SSKPreferences.areLinkPreviewsEnabled
|
||||
&& !userDefaults[.hasSeenLinkPreviewSuggestion] {
|
||||
let areLinkPreviewsEnabled: Bool = Storage.shared[.areLinkPreviewsEnabled]
|
||||
|
||||
if
|
||||
!LinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty &&
|
||||
!areLinkPreviewsEnabled &&
|
||||
!UserDefaults.standard[.hasSeenLinkPreviewSuggestion]
|
||||
{
|
||||
delegate?.showLinkPreviewSuggestionModal()
|
||||
userDefaults[.hasSeenLinkPreviewSuggestion] = true
|
||||
UserDefaults.standard[.hasSeenLinkPreviewSuggestion] = true
|
||||
return
|
||||
}
|
||||
// Check that link previews are enabled
|
||||
guard SSKPreferences.areLinkPreviewsEnabled else { return }
|
||||
guard areLinkPreviewsEnabled else { return }
|
||||
|
||||
// Proceed
|
||||
autoGenerateLinkPreview()
|
||||
}
|
||||
|
||||
func autoGenerateLinkPreview() {
|
||||
// Check that a valid URL is present
|
||||
guard let linkPreviewURL = OWSLinkPreview.previewUrl(forRawBodyText: text, selectedRange: inputTextView.selectedRange) else {
|
||||
guard let linkPreviewURL = LinkPreview.previewUrl(for: text, selectedRange: inputTextView.selectedRange) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Guard against obsolete updates
|
||||
guard linkPreviewURL != self.linkPreviewInfo?.url else { return }
|
||||
|
||||
// Clear content container
|
||||
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
quoteDraftInfo = nil
|
||||
|
||||
// Set the state to loading
|
||||
linkPreviewInfo = (url: linkPreviewURL, draft: nil)
|
||||
linkPreviewView.linkPreviewState = LinkPreviewLoading()
|
||||
linkPreviewView.update(with: LinkPreview.LoadingState(), isOutgoing: false)
|
||||
|
||||
// Add the link preview view
|
||||
additionalContentContainer.addSubview(linkPreviewView)
|
||||
linkPreviewView.pin(.left, to: .left, of: additionalContentContainer, withInset: InputView.linkPreviewViewInset)
|
||||
linkPreviewView.pin(.top, to: .top, of: additionalContentContainer, withInset: 10)
|
||||
linkPreviewView.pin(.right, to: .right, of: additionalContentContainer)
|
||||
linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4)
|
||||
// Build the link preview
|
||||
OWSLinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL).done { [weak self] draft in
|
||||
guard let self = self else { return }
|
||||
guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
|
||||
self.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
|
||||
self.linkPreviewView.linkPreviewState = LinkPreviewDraft(linkPreviewDraft: draft)
|
||||
}.catch { _ in
|
||||
guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
|
||||
self.linkPreviewInfo = nil
|
||||
self.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
}.retainUntilComplete()
|
||||
}
|
||||
|
||||
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
|
||||
disabledInputLabel.text = (message ?? "")
|
||||
|
||||
|
||||
attachmentsButton.isUserInteractionEnabled = (messageTypes == .all)
|
||||
voiceMessageButton.isUserInteractionEnabled = (messageTypes == .all)
|
||||
|
||||
|
||||
UIView.animate(withDuration: 0.3) { [weak self] in
|
||||
self?.bottomStackView?.alpha = (messageTypes != .none ? 1 : 0)
|
||||
self?.attachmentsButton.alpha = (messageTypes == .all ?
|
||||
|
@ -278,35 +333,40 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
self?.disabledInputLabel.alpha = (messageTypes != .none ? 0 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
// MARK: Interaction
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
// Needed so that the user can tap the buttons when the expanding attachments button is expanded
|
||||
let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton,
|
||||
attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ]
|
||||
let buttonContainer = buttonContainers.first { $0.superview!.convert($0.frame, to: self).contains(point) }
|
||||
if let buttonContainer = buttonContainer {
|
||||
|
||||
if let buttonContainer: InputViewButton = buttonContainers.first(where: { $0.superview?.convert($0.frame, to: self).contains(point) == true }) {
|
||||
return buttonContainer
|
||||
} else {
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
|
||||
|
||||
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
||||
let buttonContainers = [ attachmentsButton.gifButtonContainer, attachmentsButton.documentButtonContainer,
|
||||
attachmentsButton.libraryButtonContainer, attachmentsButton.cameraButtonContainer, attachmentsButton.mainButtonContainer ]
|
||||
let isPointInsideAttachmentsButton = buttonContainers.contains { $0.superview!.convert($0.frame, to: self).contains(point) }
|
||||
let isPointInsideAttachmentsButton = buttonContainers
|
||||
.contains { $0.superview!.convert($0.frame, to: self).contains(point) }
|
||||
|
||||
if isPointInsideAttachmentsButton {
|
||||
// Needed so that the user can tap the buttons when the expanding attachments button is expanded
|
||||
return true
|
||||
} else if mentionsViewContainer.frame.contains(point) {
|
||||
}
|
||||
|
||||
if mentionsViewContainer.frame.contains(point) {
|
||||
// Needed so that the user can tap mentions
|
||||
return true
|
||||
} else {
|
||||
return super.point(inside: point, with: event)
|
||||
}
|
||||
|
||||
return super.point(inside: point, with: event)
|
||||
}
|
||||
|
||||
|
||||
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) {
|
||||
if inputViewButton == sendButton { delegate?.handleSendButtonTapped() }
|
||||
}
|
||||
|
@ -329,23 +389,18 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
voiceMessageRecordingView.handleLongPressEnded(at: location)
|
||||
}
|
||||
|
||||
func handleQuoteViewCancelButtonTapped() {
|
||||
delegate?.handleQuoteViewCancelButtonTapped()
|
||||
}
|
||||
|
||||
override func resignFirstResponder() -> Bool {
|
||||
inputTextView.resignFirstResponder()
|
||||
}
|
||||
|
||||
func inputTextViewBecomeFirstResponder() {
|
||||
inputTextView.becomeFirstResponder()
|
||||
}
|
||||
|
||||
func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
// Not relevant in this case
|
||||
}
|
||||
|
||||
func handleLinkPreviewCanceled() {
|
||||
linkPreviewInfo = nil
|
||||
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
}
|
||||
|
||||
@objc private func showVoiceMessageUI() {
|
||||
voiceMessageRecordingView?.removeFromSuperview()
|
||||
let voiceMessageButtonFrame = voiceMessageButton.superview!.convert(voiceMessageButton.frame, to: self)
|
||||
|
@ -373,54 +428,57 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
|
|||
}
|
||||
|
||||
func hideMentionsUI() {
|
||||
UIView.animate(withDuration: 0.25, animations: {
|
||||
self.mentionsViewContainer.alpha = 0
|
||||
}, completion: { _ in
|
||||
self.mentionsViewHeightConstraint.constant = 0
|
||||
self.mentionsView.tableView.contentOffset = CGPoint.zero
|
||||
})
|
||||
UIView.animate(
|
||||
withDuration: 0.25,
|
||||
animations: { [weak self] in
|
||||
self?.mentionsViewContainer.alpha = 0
|
||||
},
|
||||
completion: { [weak self] _ in
|
||||
self?.mentionsViewHeightConstraint.constant = 0
|
||||
self?.mentionsView.contentOffset = CGPoint.zero
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func showMentionsUI(for candidates: [Mention], in thread: TSThread) {
|
||||
if let openGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) {
|
||||
mentionsView.openGroupServer = openGroupV2.server
|
||||
mentionsView.openGroupRoom = openGroupV2.room
|
||||
}
|
||||
func showMentionsUI(for candidates: [ConversationViewModel.MentionInfo]) {
|
||||
mentionsView.candidates = candidates
|
||||
let mentionCellHeight = Values.smallProfilePictureSize + 2 * Values.smallSpacing
|
||||
|
||||
let mentionCellHeight = (Values.smallProfilePictureSize + 2 * Values.smallSpacing)
|
||||
mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight
|
||||
layoutIfNeeded()
|
||||
|
||||
UIView.animate(withDuration: 0.25) {
|
||||
self.mentionsViewContainer.alpha = 1
|
||||
}
|
||||
}
|
||||
|
||||
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) {
|
||||
delegate?.handleMentionSelected(mention, from: view)
|
||||
func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView) {
|
||||
delegate?.handleMentionSelected(mentionInfo, from: view)
|
||||
}
|
||||
|
||||
func tapableLabel(_ label: TappableLabel, didTapUrl url: String, atRange range: NSRange) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
// MARK: Convenience
|
||||
// MARK: - Convenience
|
||||
|
||||
private func container(for button: InputViewButton) -> UIView {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.addSubview(button)
|
||||
result.set(.width, to: InputViewButton.expandedSize)
|
||||
result.set(.height, to: InputViewButton.expandedSize)
|
||||
button.center(in: result)
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
protocol InputViewDelegate : AnyObject, ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate {
|
||||
// MARK: - Delegate
|
||||
|
||||
protocol InputViewDelegate: ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate {
|
||||
func showLinkPreviewSuggestionModal()
|
||||
func handleSendButtonTapped()
|
||||
func handleQuoteViewCancelButtonTapped()
|
||||
func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
|
||||
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView)
|
||||
func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView)
|
||||
func didPasteImageFromPasteboard(_ image: UIImage)
|
||||
}
|
||||
|
|
|
@ -141,17 +141,16 @@ final class InputViewButton : UIView {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
protocol InputViewButtonDelegate : class {
|
||||
|
||||
// MARK: - Delegate
|
||||
|
||||
protocol InputViewButtonDelegate: AnyObject {
|
||||
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton)
|
||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton)
|
||||
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch)
|
||||
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch)
|
||||
}
|
||||
|
||||
extension InputViewButtonDelegate {
|
||||
|
||||
extension InputViewButtonDelegate {
|
||||
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) { }
|
||||
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch) { }
|
||||
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch) { }
|
||||
|
|
|
@ -1,36 +1,50 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDelegate {
|
||||
var candidates: [Mention] = [] {
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDelegate {
|
||||
var candidates: [ConversationViewModel.MentionInfo] = [] {
|
||||
didSet {
|
||||
tableView.isScrollEnabled = (candidates.count > 4)
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
var openGroupServer: String?
|
||||
var openGroupChannel: UInt64?
|
||||
var openGroupRoom: String?
|
||||
|
||||
weak var delegate: MentionSelectionViewDelegate?
|
||||
|
||||
var contentOffset: CGPoint {
|
||||
get { tableView.contentOffset }
|
||||
set { tableView.contentOffset = newValue }
|
||||
}
|
||||
|
||||
// MARK: Components
|
||||
lazy var tableView: UITableView = { // TODO: Make this private
|
||||
let result = UITableView()
|
||||
// MARK: - Components
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let result: UITableView = UITableView()
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
result.register(Cell.self, forCellReuseIdentifier: "Cell")
|
||||
result.separatorStyle = .none
|
||||
result.backgroundColor = .clear
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.register(view: Cell.self)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Initialization
|
||||
// MARK: - Initialization
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
@ -38,43 +52,54 @@ final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDel
|
|||
// Table view
|
||||
addSubview(tableView)
|
||||
tableView.pin(to: self)
|
||||
|
||||
// Top separator
|
||||
let topSeparator = UIView()
|
||||
let topSeparator: UIView = UIView()
|
||||
topSeparator.backgroundColor = Colors.separator
|
||||
topSeparator.set(.height, to: Values.separatorThickness)
|
||||
addSubview(topSeparator)
|
||||
topSeparator.pin(.leading, to: .leading, of: self)
|
||||
topSeparator.pin(.top, to: .top, of: self)
|
||||
topSeparator.pin(.trailing, to: .trailing, of: self)
|
||||
|
||||
// Bottom separator
|
||||
let bottomSeparator = UIView()
|
||||
let bottomSeparator: UIView = UIView()
|
||||
bottomSeparator.backgroundColor = Colors.separator
|
||||
bottomSeparator.set(.height, to: Values.separatorThickness)
|
||||
addSubview(bottomSeparator)
|
||||
|
||||
bottomSeparator.pin(.leading, to: .leading, of: self)
|
||||
bottomSeparator.pin(.trailing, to: .trailing, of: self)
|
||||
bottomSeparator.pin(.bottom, to: .bottom, of: self)
|
||||
}
|
||||
|
||||
// MARK: Data
|
||||
// MARK: - Data
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return candidates.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! Cell
|
||||
let mentionCandidate = candidates[indexPath.row]
|
||||
cell.mentionCandidate = mentionCandidate
|
||||
cell.openGroupServer = openGroupServer
|
||||
cell.openGroupChannel = openGroupChannel
|
||||
cell.openGroupRoom = openGroupRoom
|
||||
cell.separator.isHidden = (indexPath.row == (candidates.count - 1))
|
||||
let cell: Cell = tableView.dequeue(type: Cell.self, for: indexPath)
|
||||
cell.update(
|
||||
with: candidates[indexPath.row].profile,
|
||||
threadVariant: candidates[indexPath.row].threadVariant,
|
||||
isUserModeratorOrAdmin: OpenGroupManager.isUserModeratorOrAdmin(
|
||||
candidates[indexPath.row].profile.id,
|
||||
for: candidates[indexPath.row].openGroupRoomToken,
|
||||
on: candidates[indexPath.row].openGroupServer
|
||||
),
|
||||
isLast: (indexPath.row == (candidates.count - 1))
|
||||
)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
let mentionCandidate = candidates[indexPath.row]
|
||||
|
||||
delegate?.handleMentionSelected(mentionCandidate, from: self)
|
||||
}
|
||||
}
|
||||
|
@ -82,56 +107,59 @@ final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDel
|
|||
// MARK: - Cell
|
||||
|
||||
private extension MentionSelectionView {
|
||||
final class Cell: UITableViewCell {
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView()
|
||||
|
||||
final class Cell : UITableViewCell {
|
||||
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 moderatorIconImageView: UIImageView = UIImageView(image: #imageLiteral(resourceName: "Crown"))
|
||||
|
||||
private lazy var displayNameLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
lazy var separator: UIView = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = Colors.separator
|
||||
result.set(.height, to: Values.separatorThickness)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Initialization
|
||||
// MARK: - Initialization
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
// Cell background color
|
||||
backgroundColor = .clear
|
||||
|
||||
// Highlight color
|
||||
let selectedBackgroundView = UIView()
|
||||
selectedBackgroundView.backgroundColor = .clear
|
||||
self.selectedBackgroundView = selectedBackgroundView
|
||||
|
||||
// Profile picture image view
|
||||
let profilePictureViewSize = Values.smallProfilePictureSize
|
||||
profilePictureView.set(.width, to: profilePictureViewSize)
|
||||
profilePictureView.set(.height, to: profilePictureViewSize)
|
||||
profilePictureView.size = profilePictureViewSize
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel ])
|
||||
mainStackView.axis = .horizontal
|
||||
|
@ -144,12 +172,14 @@ private extension MentionSelectionView {
|
|||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.mediumSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.smallSpacing)
|
||||
mainStackView.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing)
|
||||
|
||||
// Moderator icon image view
|
||||
moderatorIconImageView.set(.width, to: 20)
|
||||
moderatorIconImageView.set(.height, to: 20)
|
||||
contentView.addSubview(moderatorIconImageView)
|
||||
moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1)
|
||||
moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5)
|
||||
|
||||
// Separator
|
||||
addSubview(separator)
|
||||
separator.pin(.leading, to: .leading, of: self)
|
||||
|
@ -157,24 +187,28 @@ private extension MentionSelectionView {
|
|||
separator.pin(.bottom, to: .bottom, of: self)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
private func update() {
|
||||
displayNameLabel.text = mentionCandidate.displayName
|
||||
profilePictureView.publicKey = mentionCandidate.publicKey
|
||||
profilePictureView.update()
|
||||
if let server = openGroupServer, let room = openGroupRoom {
|
||||
let isUserModerator = OpenGroupAPIV2.isUserModerator(mentionCandidate.publicKey, for: room, on: server)
|
||||
moderatorIconImageView.isHidden = !isUserModerator
|
||||
} else {
|
||||
moderatorIconImageView.isHidden = true
|
||||
}
|
||||
// MARK: - Updating
|
||||
|
||||
fileprivate func update(
|
||||
with profile: Profile,
|
||||
threadVariant: SessionThread.Variant,
|
||||
isUserModeratorOrAdmin: Bool,
|
||||
isLast: Bool
|
||||
) {
|
||||
displayNameLabel.text = profile.displayName(for: threadVariant)
|
||||
profilePictureView.update(
|
||||
publicKey: profile.id,
|
||||
profile: profile,
|
||||
threadVariant: threadVariant
|
||||
)
|
||||
moderatorIconImageView.isHidden = !isUserModeratorOrAdmin
|
||||
separator.isHidden = isLast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delegate
|
||||
|
||||
protocol MentionSelectionViewDelegate : class {
|
||||
|
||||
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView)
|
||||
protocol MentionSelectionViewDelegate: AnyObject {
|
||||
func handleMentionSelected(_ mention: ConversationViewModel.MentionInfo, from view: MentionSelectionView)
|
||||
}
|
||||
|
|
|
@ -396,9 +396,9 @@ extension VoiceMessageRecordingView {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
protocol VoiceMessageRecordingViewDelegate : class {
|
||||
// MARK: - Delegate
|
||||
|
||||
protocol VoiceMessageRecordingViewDelegate: AnyObject {
|
||||
func startVoiceMessageRecording()
|
||||
func endVoiceMessageRecording()
|
||||
func cancelVoiceMessageRecording()
|
||||
|
|
|
@ -1,177 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc
|
||||
public protocol LongTextViewDelegate {
|
||||
@objc
|
||||
func longTextViewMessageWasDeleted(_ longTextViewController: LongTextViewController)
|
||||
}
|
||||
|
||||
@objc
|
||||
public class LongTextViewController: OWSViewController {
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
var uiDatabaseConnection: YapDatabaseConnection {
|
||||
return OWSPrimaryStorage.shared().uiDatabaseConnection
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
@objc
|
||||
weak var delegate: LongTextViewDelegate?
|
||||
|
||||
let viewItem: ConversationViewItem
|
||||
|
||||
var messageTextView: UITextView!
|
||||
|
||||
var displayableText: DisplayableText? {
|
||||
return viewItem.displayableBodyText
|
||||
}
|
||||
|
||||
var fullText: String {
|
||||
return displayableText?.fullText ?? ""
|
||||
}
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
@available(*, unavailable, message:"use other constructor instead.")
|
||||
public required init?(coder aDecoder: NSCoder) {
|
||||
notImplemented()
|
||||
}
|
||||
|
||||
@objc
|
||||
public required init(viewItem: ConversationViewItem) {
|
||||
self.viewItem = viewItem
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
// MARK: View Lifecycle
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: NSLocalizedString("LONG_TEXT_VIEW_TITLE", comment: ""), hasCustomBackButton: false)
|
||||
|
||||
createViews()
|
||||
|
||||
self.messageTextView.contentOffset = CGPoint(x: 0, y: self.messageTextView.contentInset.top)
|
||||
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(uiDatabaseDidUpdate),
|
||||
name: .OWSUIDatabaseConnectionDidUpdate,
|
||||
object: OWSPrimaryStorage.shared().dbNotificationObject)
|
||||
}
|
||||
|
||||
// MARK: - DB
|
||||
|
||||
@objc internal func uiDatabaseDidUpdate(notification: NSNotification) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let notifications = notification.userInfo?[OWSUIDatabaseConnectionNotificationsKey] as? [Notification] else {
|
||||
owsFailDebug("notifications was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
guard let uniqueId = self.viewItem.interaction.uniqueId else {
|
||||
Logger.error("Message is missing uniqueId.")
|
||||
return
|
||||
}
|
||||
|
||||
guard self.uiDatabaseConnection.hasChange(forKey: uniqueId,
|
||||
inCollection: TSInteraction.collection(),
|
||||
in: notifications) else {
|
||||
Logger.debug("No relevant changes.")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try uiDatabaseConnection.read { transaction in
|
||||
guard TSInteraction.fetch(uniqueId: uniqueId, transaction: transaction) != nil else {
|
||||
Logger.error("Message was deleted")
|
||||
throw LongTextViewError.messageWasDeleted
|
||||
}
|
||||
}
|
||||
} catch LongTextViewError.messageWasDeleted {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.longTextViewMessageWasDeleted(self)
|
||||
}
|
||||
} catch {
|
||||
owsFailDebug("unexpected error: \(error)")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
enum LongTextViewError: Error {
|
||||
case messageWasDeleted
|
||||
}
|
||||
|
||||
// MARK: - Create Views
|
||||
|
||||
private func createViews() {
|
||||
view.backgroundColor = Colors.navigationBarBackground
|
||||
|
||||
let messageTextView = OWSTextView()
|
||||
self.messageTextView = messageTextView
|
||||
messageTextView.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
messageTextView.backgroundColor = .clear
|
||||
messageTextView.isOpaque = true
|
||||
messageTextView.isEditable = false
|
||||
messageTextView.isSelectable = true
|
||||
messageTextView.isScrollEnabled = true
|
||||
messageTextView.showsHorizontalScrollIndicator = false
|
||||
messageTextView.showsVerticalScrollIndicator = true
|
||||
messageTextView.isUserInteractionEnabled = true
|
||||
messageTextView.textColor = Colors.text
|
||||
messageTextView.contentInset = UIEdgeInsets(top: Values.mediumSpacing, leading: 0, bottom: 0, trailing: 0)
|
||||
if let displayableText = displayableText {
|
||||
messageTextView.text = fullText
|
||||
messageTextView.ensureShouldLinkifyText(displayableText.shouldAllowLinkification)
|
||||
} else {
|
||||
owsFailDebug("displayableText was unexpectedly nil")
|
||||
messageTextView.text = ""
|
||||
}
|
||||
|
||||
let linkTextAttributes: [NSAttributedString.Key: Any] = [
|
||||
NSAttributedString.Key.foregroundColor: Colors.text,
|
||||
NSAttributedString.Key.underlineColor: Colors.text,
|
||||
NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue
|
||||
]
|
||||
messageTextView.linkTextAttributes = linkTextAttributes
|
||||
|
||||
view.addSubview(messageTextView)
|
||||
messageTextView.autoPinEdge(toSuperviewEdge: .top)
|
||||
messageTextView.autoPinEdge(toSuperviewEdge: .leading)
|
||||
messageTextView.autoPinEdge(toSuperviewEdge: .trailing)
|
||||
messageTextView.textContainerInset = UIEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
|
||||
|
||||
let footer = UIToolbar()
|
||||
view.addSubview(footer)
|
||||
footer.autoPinWidthToSuperview()
|
||||
footer.autoPinEdge(.top, to: .bottom, of: messageTextView)
|
||||
footer.autoPinEdge(toSuperviewSafeArea: .bottom)
|
||||
|
||||
footer.items = [
|
||||
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
|
||||
UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(shareButtonPressed)),
|
||||
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc func shareButtonPressed() {
|
||||
let shareVC = UIActivityViewController(activityItems: [ fullText ], applicationActivities: nil)
|
||||
if UIDevice.current.isIPad {
|
||||
shareVC.excludedActivityTypes = []
|
||||
shareVC.popoverPresentationController?.permittedArrowDirections = []
|
||||
shareVC.popoverPresentationController?.sourceView = self.view
|
||||
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
|
||||
}
|
||||
self.present(shareVC, animated: true, completion: nil)
|
||||
}
|
||||
}
|
|
@ -1,73 +1,88 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class CallMessageCell : MessageCell {
|
||||
final class CallMessageCell: MessageCell {
|
||||
private static let iconSize: CGFloat = 16
|
||||
private static let inset = Values.mediumSpacing
|
||||
private static let margin = UIScreen.main.bounds.width * 0.1
|
||||
|
||||
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: 0)
|
||||
private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: 0)
|
||||
|
||||
private lazy var infoImageViewWidthConstraint = infoImageView.set(.width, to: 0)
|
||||
private lazy var infoImageViewHeightConstraint = infoImageView.set(.height, to: 0)
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var iconImageView = UIImageView()
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var infoImageView = UIImageView(image: UIImage(named: "ic_info")?.withTint(Colors.text))
|
||||
private lazy var iconImageView: UIImageView = UIImageView()
|
||||
private lazy var infoImageView: UIImageView = {
|
||||
let result: UIImageView = UIImageView(image: UIImage(named: "ic_info")?.withRenderingMode(.alwaysTemplate))
|
||||
result.tintColor = Colors.text
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var timestampLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var label: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.numberOfLines = 0
|
||||
result.lineBreakMode = .byWordWrapping
|
||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var container: UIView = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.set(.height, to: 50)
|
||||
result.layer.cornerRadius = 18
|
||||
result.backgroundColor = Colors.callMessageBackground
|
||||
result.addSubview(label)
|
||||
|
||||
label.autoCenterInSuperview()
|
||||
result.addSubview(iconImageView)
|
||||
|
||||
iconImageView.autoVCenterInSuperview()
|
||||
iconImageView.pin(.left, to: .left, of: result, withInset: CallMessageCell.inset)
|
||||
result.addSubview(infoImageView)
|
||||
|
||||
infoImageView.autoVCenterInSuperview()
|
||||
infoImageView.pin(.right, to: .right, of: result, withInset: -CallMessageCell.inset)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var stackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ timestampLabel, container ])
|
||||
let result: UIStackView = UIStackView(arrangedSubviews: [ timestampLabel, container ])
|
||||
result.axis = .vertical
|
||||
result.alignment = .center
|
||||
result.spacing = Values.smallSpacing
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
private static let iconSize: CGFloat = 16
|
||||
private static let inset = Values.mediumSpacing
|
||||
private static let margin = UIScreen.main.bounds.width * 0.1
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override class var identifier: String { "CallMessageCell" }
|
||||
|
||||
// MARK: Lifecycle
|
||||
override func setUpViewHierarchy() {
|
||||
super.setUpViewHierarchy()
|
||||
|
||||
iconImageViewWidthConstraint.isActive = true
|
||||
iconImageViewHeightConstraint.isActive = true
|
||||
addSubview(stackView)
|
||||
|
||||
container.autoPinWidthToSuperview()
|
||||
stackView.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin)
|
||||
stackView.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset)
|
||||
|
@ -81,39 +96,72 @@ final class CallMessageCell : MessageCell {
|
|||
addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
override func update() {
|
||||
guard let message = viewItem?.interaction as? TSInfoMessage, message.messageType == .call else { return }
|
||||
let icon: UIImage?
|
||||
switch message.callState {
|
||||
case .outgoing: icon = UIImage(named: "CallOutgoing")?.withTint(Colors.text)
|
||||
case .incoming: icon = UIImage(named: "CallIncoming")?.withTint(Colors.text)
|
||||
case .missed, .permissionDenied: icon = UIImage(named: "CallMissed")?.withTint(Colors.destructive)
|
||||
default: icon = nil
|
||||
}
|
||||
iconImageView.image = icon
|
||||
iconImageViewWidthConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0
|
||||
iconImageViewHeightConstraint.constant = (icon != nil) ? CallMessageCell.iconSize : 0
|
||||
// MARK: - Updating
|
||||
|
||||
override func update(
|
||||
with cellViewModel: MessageViewModel,
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||
showExpandedReactions: Bool,
|
||||
lastSearchText: String?
|
||||
) {
|
||||
guard
|
||||
cellViewModel.variant == .infoCall,
|
||||
let infoMessageData: Data = (cellViewModel.rawBody ?? "").data(using: .utf8),
|
||||
let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode(
|
||||
CallMessage.MessageInfo.self,
|
||||
from: infoMessageData
|
||||
)
|
||||
else { return }
|
||||
|
||||
let shouldShowInfoIcon = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled
|
||||
infoImageViewWidthConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0
|
||||
infoImageViewHeightConstraint.constant = shouldShowInfoIcon ? CallMessageCell.iconSize : 0
|
||||
self.viewModel = cellViewModel
|
||||
|
||||
Storage.read { transaction in
|
||||
self.label.text = message.previewText(with: transaction)
|
||||
}
|
||||
iconImageView.image = {
|
||||
switch messageInfo.state {
|
||||
case .outgoing: return UIImage(named: "CallOutgoing")?.withRenderingMode(.alwaysTemplate)
|
||||
case .incoming: return UIImage(named: "CallIncoming")?.withRenderingMode(.alwaysTemplate)
|
||||
case .missed, .permissionDenied: return UIImage(named: "CallMissed")?.withRenderingMode(.alwaysTemplate)
|
||||
default: return nil
|
||||
}
|
||||
}()
|
||||
iconImageView.tintColor = {
|
||||
switch messageInfo.state {
|
||||
case .outgoing, .incoming: return Colors.text
|
||||
case .missed, .permissionDenied: return Colors.destructive
|
||||
default: return nil
|
||||
}
|
||||
}()
|
||||
iconImageViewWidthConstraint.constant = (iconImageView.image != nil ? CallMessageCell.iconSize : 0)
|
||||
iconImageViewHeightConstraint.constant = (iconImageView.image != nil ? CallMessageCell.iconSize : 0)
|
||||
|
||||
let date = message.dateForUI()
|
||||
let description = DateUtil.formatDate(forDisplay: date)
|
||||
timestampLabel.text = description
|
||||
let shouldShowInfoIcon: Bool = (
|
||||
messageInfo.state == .permissionDenied &&
|
||||
!Storage.shared[.areCallsEnabled]
|
||||
)
|
||||
infoImageViewWidthConstraint.constant = (shouldShowInfoIcon ? CallMessageCell.iconSize : 0)
|
||||
infoImageViewHeightConstraint.constant = (shouldShowInfoIcon ? CallMessageCell.iconSize : 0)
|
||||
|
||||
label.text = cellViewModel.body
|
||||
timestampLabel.text = cellViewModel.dateForUI?.formattedForDisplay
|
||||
}
|
||||
|
||||
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
guard let viewItem = viewItem, let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call else { return }
|
||||
let shouldBeTappable = message.callState == .permissionDenied && !SSKPreferences.areCallsEnabled
|
||||
if shouldBeTappable {
|
||||
delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer)
|
||||
}
|
||||
guard
|
||||
let cellViewModel: MessageViewModel = self.viewModel,
|
||||
cellViewModel.variant == .infoCall,
|
||||
let infoMessageData: Data = (cellViewModel.rawBody ?? "").data(using: .utf8),
|
||||
let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode(
|
||||
CallMessage.MessageInfo.self,
|
||||
from: infoMessageData
|
||||
)
|
||||
else { return }
|
||||
|
||||
// Should only be tappable if the info icon is visible
|
||||
guard messageInfo.state == .permissionDenied && !Storage.shared[.areCallsEnabled] else { return }
|
||||
|
||||
self.delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,18 +1,19 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class CallMessageView : UIView {
|
||||
private let viewItem: ConversationViewItem
|
||||
private let textColor: UIColor
|
||||
|
||||
// MARK: Settings
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class CallMessageView: UIView {
|
||||
private static let iconSize: CGFloat = 24
|
||||
private static let iconImageViewSize: CGFloat = 40
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
||||
self.viewItem = viewItem
|
||||
self.textColor = textColor
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(cellViewModel: MessageViewModel, textColor: UIColor) {
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
|
||||
setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -23,22 +24,27 @@ final class CallMessageView : UIView {
|
|||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
guard let message = viewItem.interaction as? TSMessage else { preconditionFailure() }
|
||||
private func setUpViewHierarchy(cellViewModel: MessageViewModel, textColor: UIColor) {
|
||||
// Image view
|
||||
let iconSize = CallMessageView.iconSize
|
||||
let icon = UIImage(named: "Phone")?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
let imageView = UIImageView(image: icon)
|
||||
let imageView: UIImageView = UIImageView(
|
||||
image: UIImage(named: "Phone")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
.resizedImage(to: CGSize(width: CallMessageView.iconSize, height: CallMessageView.iconSize))
|
||||
)
|
||||
imageView.tintColor = textColor
|
||||
imageView.contentMode = .center
|
||||
|
||||
let iconImageViewSize = CallMessageView.iconImageViewSize
|
||||
imageView.set(.width, to: iconImageViewSize)
|
||||
imageView.set(.height, to: iconImageViewSize)
|
||||
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = message.body
|
||||
titleLabel.text = cellViewModel.body
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||
stackView.axis = .horizontal
|
||||
|
|
|
@ -1,43 +1,51 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class DeletedMessageView : UIView {
|
||||
private let viewItem: ConversationViewItem
|
||||
private let textColor: UIColor
|
||||
|
||||
// MARK: Settings
|
||||
import UIKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class DeletedMessageView: UIView {
|
||||
private static let iconSize: CGFloat = 18
|
||||
private static let iconImageViewSize: CGFloat = 30
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
||||
self.viewItem = viewItem
|
||||
self.textColor = textColor
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(textColor: UIColor) {
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
|
||||
setUpViewHierarchy(textColor: textColor)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
preconditionFailure("Use init(textColor:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
preconditionFailure("Use init(textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
private func setUpViewHierarchy(textColor: UIColor) {
|
||||
// Image view
|
||||
let iconSize = DeletedMessageView.iconSize
|
||||
let icon = UIImage(named: "ic_trash")?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
let icon = UIImage(named: "ic_trash")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
.resizedImage(to: CGSize(
|
||||
width: DeletedMessageView.iconSize,
|
||||
height: DeletedMessageView.iconSize
|
||||
))
|
||||
|
||||
let imageView = UIImageView(image: icon)
|
||||
imageView.tintColor = textColor
|
||||
imageView.contentMode = .center
|
||||
let iconImageViewSize = DeletedMessageView.iconImageViewSize
|
||||
imageView.set(.width, to: iconImageViewSize)
|
||||
imageView.set(.height, to: iconImageViewSize)
|
||||
imageView.set(.width, to: DeletedMessageView.iconImageViewSize)
|
||||
imageView.set(.height, to: DeletedMessageView.iconImageViewSize)
|
||||
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = NSLocalizedString("message_deleted", comment: "")
|
||||
titleLabel.text = "message_deleted".localized()
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||
stackView.axis = .horizontal
|
||||
|
@ -45,7 +53,8 @@ final class DeletedMessageView : UIView {
|
|||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 6)
|
||||
addSubview(stackView)
|
||||
|
||||
stackView.pin(to: self, withInset: Values.smallSpacing)
|
||||
stackView.set(.height, to: .height, of: imageView)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,17 +1,18 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class DocumentView : UIView {
|
||||
private let viewItem: ConversationViewItem
|
||||
private let textColor: UIColor
|
||||
|
||||
// MARK: Settings
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class DocumentView: UIView {
|
||||
private static let iconImageViewSize: CGSize = CGSize(width: 31, height: 40)
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
||||
self.viewItem = viewItem
|
||||
self.textColor = textColor
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(attachment: Attachment, textColor: UIColor) {
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
|
||||
setUpViewHierarchy(attachment: attachment, textColor: textColor)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -22,30 +23,34 @@ final class DocumentView : UIView {
|
|||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
guard let attachment = viewItem.attachmentStream ?? viewItem.attachmentPointer else { return }
|
||||
private func setUpViewHierarchy(attachment: Attachment, textColor: UIColor) {
|
||||
// Image view
|
||||
let icon = UIImage(named: "File")?.withTint(textColor)
|
||||
let imageView = UIImageView(image: icon)
|
||||
let imageView = UIImageView(image: UIImage(named: "File")?.withRenderingMode(.alwaysTemplate))
|
||||
imageView.tintColor = textColor
|
||||
imageView.contentMode = .center
|
||||
|
||||
let iconImageViewSize = DocumentView.iconImageViewSize
|
||||
imageView.set(.width, to: iconImageViewSize.width)
|
||||
imageView.set(.height, to: iconImageViewSize.height)
|
||||
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = attachment.sourceFilename ?? "File"
|
||||
titleLabel.text = (attachment.sourceFilename ?? "File")
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.smallFontSize, weight: .light)
|
||||
|
||||
// Size label
|
||||
let sizeLabel = UILabel()
|
||||
sizeLabel.lineBreakMode = .byTruncatingTail
|
||||
sizeLabel.text = OWSFormat.formatFileSize(UInt(attachment.byteCount))
|
||||
sizeLabel.textColor = textColor
|
||||
sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
|
||||
// Label stack view
|
||||
let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, sizeLabel ])
|
||||
labelStackView.axis = .vertical
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, labelStackView ])
|
||||
stackView.axis = .horizontal
|
||||
|
|
|
@ -1,220 +1,138 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
extension CGPoint {
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
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 {
|
||||
return CGPoint(x: x + dx, y: y)
|
||||
// MARK: LoadingState
|
||||
|
||||
struct LoadingState: LinkPreviewState {
|
||||
var isLoaded: Bool { false }
|
||||
var urlString: String? { nil }
|
||||
var title: String? { nil }
|
||||
var imageState: LinkPreview.ImageState { .none }
|
||||
var image: UIImage? { nil }
|
||||
}
|
||||
|
||||
// MARK: DraftState
|
||||
|
||||
struct DraftState: LinkPreviewState {
|
||||
var isLoaded: Bool { true }
|
||||
var urlString: String? { linkPreviewDraft.urlString }
|
||||
|
||||
public func offsetBy(dy: CGFloat) -> CGPoint {
|
||||
return CGPoint(x: x, y: y + dy)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
var title: String? {
|
||||
guard let value = linkPreviewDraft.title, value.count > 0 else { return nil }
|
||||
|
||||
return value
|
||||
}
|
||||
return displayDomain
|
||||
}
|
||||
|
||||
public func title() -> String? {
|
||||
guard let value = linkPreviewDraft.title,
|
||||
value.count > 0 else {
|
||||
|
||||
var imageState: LinkPreview.ImageState {
|
||||
if linkPreviewDraft.jpegImageData != nil { return .loaded }
|
||||
|
||||
return .none
|
||||
}
|
||||
|
||||
var image: UIImage? {
|
||||
guard let jpegImageData = linkPreviewDraft.jpegImageData else { return nil }
|
||||
guard let image = UIImage(data: jpegImageData) else {
|
||||
owsFailDebug("Could not load image: \(jpegImageData.count)")
|
||||
return nil
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// MARK: - Type Specific
|
||||
|
||||
private let linkPreviewDraft: LinkPreviewDraft
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public func imageState() -> LinkPreviewImageState {
|
||||
if linkPreviewDraft.jpegImageData != nil {
|
||||
return .loaded
|
||||
} else {
|
||||
return .none
|
||||
init(linkPreviewDraft: LinkPreviewDraft) {
|
||||
self.linkPreviewDraft = linkPreviewDraft
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: SentState
|
||||
|
||||
struct SentState: LinkPreviewState {
|
||||
var isLoaded: Bool { true }
|
||||
var urlString: String? { linkPreview.url }
|
||||
|
||||
public func image() -> UIImage? {
|
||||
guard let jpegImageData = linkPreviewDraft.jpegImageData else {
|
||||
return nil
|
||||
var title: String? {
|
||||
guard let value = linkPreview.title, value.count > 0 else { return nil }
|
||||
|
||||
return value
|
||||
}
|
||||
guard let image = UIImage(data: jpegImageData) else {
|
||||
owsFailDebug("Could not load image: \(jpegImageData.count)")
|
||||
return nil
|
||||
|
||||
var imageState: LinkPreview.ImageState {
|
||||
guard linkPreview.attachmentId != nil else { return .none }
|
||||
guard let imageAttachment: Attachment = imageAttachment else {
|
||||
owsFailDebug("Missing imageAttachment.")
|
||||
return .none
|
||||
}
|
||||
|
||||
switch imageAttachment.state {
|
||||
case .downloaded, .uploaded:
|
||||
guard imageAttachment.isImage && imageAttachment.isValid else {
|
||||
return .invalid
|
||||
}
|
||||
|
||||
return .loaded
|
||||
|
||||
case .pendingDownload, .downloading, .uploading: return .loading
|
||||
case .failedDownload, .failedUpload, .invalid: return .invalid
|
||||
}
|
||||
}
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
@objc
|
||||
public class LinkPreviewSent: NSObject, LinkPreviewState {
|
||||
private let linkPreview: OWSLinkPreview
|
||||
private let imageAttachment: TSAttachment?
|
||||
|
||||
@objc
|
||||
public var imageSize: CGSize {
|
||||
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
|
||||
return CGSize.zero
|
||||
}
|
||||
return attachmentStream.imageSize()
|
||||
}
|
||||
|
||||
@objc
|
||||
public required init(linkPreview: OWSLinkPreview,
|
||||
imageAttachment: TSAttachment?) {
|
||||
self.linkPreview = linkPreview
|
||||
self.imageAttachment = imageAttachment
|
||||
}
|
||||
|
||||
public func isLoaded() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
public func urlString() -> String? {
|
||||
guard let urlString = linkPreview.urlString else {
|
||||
owsFailDebug("Missing url")
|
||||
return nil
|
||||
}
|
||||
return urlString
|
||||
}
|
||||
|
||||
public func displayDomain() -> String? {
|
||||
guard let displayDomain = linkPreview.displayDomain() else {
|
||||
Logger.error("Missing display domain")
|
||||
return nil
|
||||
}
|
||||
return displayDomain
|
||||
}
|
||||
|
||||
public func title() -> String? {
|
||||
guard let value = linkPreview.title,
|
||||
value.count > 0 else {
|
||||
var image: UIImage? {
|
||||
// Note: We don't check if the image is valid here because that can be confirmed
|
||||
// in 'imageState' and it's a little inefficient
|
||||
guard imageAttachment?.isImage == true else { return nil }
|
||||
guard let imageData: Data = try? imageAttachment?.readDataFromFile() else {
|
||||
return nil
|
||||
}
|
||||
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 {
|
||||
guard linkPreview.imageAttachmentId != nil else {
|
||||
return .none
|
||||
public var imageSize: CGSize {
|
||||
guard let width: UInt = imageAttachment?.width, let height: UInt = imageAttachment?.height else {
|
||||
return CGSize.zero
|
||||
}
|
||||
|
||||
return CGSize(width: CGFloat(width), height: CGFloat(height))
|
||||
}
|
||||
guard let imageAttachment = imageAttachment else {
|
||||
owsFailDebug("Missing imageAttachment.")
|
||||
return .none
|
||||
}
|
||||
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
|
||||
return .loading
|
||||
}
|
||||
guard attachmentStream.isImage,
|
||||
attachmentStream.isValidImage else {
|
||||
return .invalid
|
||||
}
|
||||
return .loaded
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public func image() -> UIImage? {
|
||||
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
|
||||
return nil
|
||||
init(linkPreview: LinkPreview, imageAttachment: Attachment?) {
|
||||
self.linkPreview = linkPreview
|
||||
self.imageAttachment = imageAttachment
|
||||
}
|
||||
guard attachmentStream.isImage,
|
||||
attachmentStream.isValidImage else {
|
||||
return nil
|
||||
}
|
||||
guard let imageFilepath = attachmentStream.originalFilePath else {
|
||||
owsFailDebug("Attachment is missing file path.")
|
||||
return nil
|
||||
}
|
||||
guard let image = UIImage(contentsOfFile: imageFilepath) else {
|
||||
owsFailDebug("Could not load image: \(imageFilepath)")
|
||||
return nil
|
||||
}
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
@objc
|
||||
public protocol LinkPreviewViewDraftDelegate {
|
||||
func linkPreviewCanCancel() -> Bool
|
||||
func linkPreviewDidCancel()
|
||||
}
|
||||
|
|
|
@ -1,98 +1,106 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import NVActivityIndicatorView
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class LinkPreviewView : UIView {
|
||||
private let viewItem: ConversationViewItem?
|
||||
final class LinkPreviewView: UIView {
|
||||
private static let loaderSize: CGFloat = 24
|
||||
private static let cancelButtonSize: CGFloat = 45
|
||||
|
||||
private let maxWidth: CGFloat
|
||||
private let delegate: LinkPreviewViewDelegate
|
||||
var linkPreviewState: LinkPreviewState? { didSet { update() } }
|
||||
private let onCancel: (() -> ())?
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var imageViewContainerWidthConstraint = imageView.set(.width, to: 100)
|
||||
private lazy var imageViewContainerHeightConstraint = imageView.set(.height, to: 100)
|
||||
|
||||
private lazy var sentLinkPreviewTextColor: UIColor = {
|
||||
let isOutgoing = (viewItem?.interaction.interactionType() == .outgoingMessage)
|
||||
switch (isOutgoing, AppModeManager.shared.currentAppMode) {
|
||||
case (false, .light): return .black
|
||||
case (true, .light): return Colors.grey
|
||||
default: return .white
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: UI Components
|
||||
|
||||
private lazy var imageView: UIImageView = {
|
||||
let result = UIImageView()
|
||||
let result: UIImageView = UIImageView()
|
||||
result.contentMode = .scaleAspectFill
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var imageViewContainer: UIView = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.clipsToBounds = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var loader: NVActivityIndicatorView = {
|
||||
let color: UIColor = isLightMode ? .black : .white
|
||||
// FIXME: This will have issues with theme transitions
|
||||
let color: UIColor = (isLightMode ? .black : .white)
|
||||
|
||||
return NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: color, padding: nil)
|
||||
}()
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.numberOfLines = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var bodyTextViewContainer = UIView()
|
||||
private lazy var bodyTappableLabelContainer: UIView = UIView()
|
||||
|
||||
private lazy var hStackViewContainer = UIView()
|
||||
private lazy var hStackViewContainer: UIView = UIView()
|
||||
|
||||
private lazy var hStackView = UIStackView()
|
||||
private lazy var hStackView: UIStackView = UIStackView()
|
||||
|
||||
private lazy var cancelButton: UIButton = {
|
||||
let result = UIButton(type: .custom)
|
||||
let tint: UIColor = isLightMode ? .black : .white
|
||||
result.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal)
|
||||
// FIXME: This will have issues with theme transitions
|
||||
let result: UIButton = UIButton(type: .custom)
|
||||
result.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: UIControl.State.normal)
|
||||
result.tintColor = (isLightMode ? .black : .white)
|
||||
|
||||
let cancelButtonSize = LinkPreviewView.cancelButtonSize
|
||||
result.set(.width, to: cancelButtonSize)
|
||||
result.set(.height, to: cancelButtonSize)
|
||||
result.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
var bodyTextView: TappableLabel?
|
||||
var bodyTappableLabel: TappableLabel?
|
||||
|
||||
// MARK: Settings
|
||||
private static let loaderSize: CGFloat = 24
|
||||
private static let cancelButtonSize: CGFloat = 45
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(for viewItem: ConversationViewItem?, maxWidth: CGFloat, delegate: LinkPreviewViewDelegate) {
|
||||
self.viewItem = viewItem
|
||||
// MARK: - Initialization
|
||||
|
||||
init(maxWidth: CGFloat, onCancel: (() -> ())? = nil) {
|
||||
self.maxWidth = maxWidth
|
||||
self.delegate = delegate
|
||||
self.onCancel = onCancel
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(for:maxWidth:delegate:) instead.")
|
||||
}
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(for:maxWidth:delegate:) instead.")
|
||||
}
|
||||
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
// Image view
|
||||
imageViewContainerWidthConstraint.isActive = true
|
||||
imageViewContainerHeightConstraint.isActive = true
|
||||
imageViewContainer.addSubview(imageView)
|
||||
imageView.pin(to: imageViewContainer)
|
||||
|
||||
// Title label
|
||||
let titleLabelContainer = UIView()
|
||||
titleLabelContainer.addSubview(titleLabel)
|
||||
titleLabel.pin(to: titleLabelContainer, withInset: Values.smallSpacing)
|
||||
|
||||
// Horizontal stack view
|
||||
hStackView.addArrangedSubview(imageViewContainer)
|
||||
hStackView.addArrangedSubview(titleLabelContainer)
|
||||
|
@ -100,72 +108,106 @@ final class LinkPreviewView : UIView {
|
|||
hStackView.alignment = .center
|
||||
hStackViewContainer.addSubview(hStackView)
|
||||
hStackView.pin(to: hStackViewContainer)
|
||||
|
||||
// Vertical stack view
|
||||
let vStackView = UIStackView(arrangedSubviews: [ hStackViewContainer, bodyTextViewContainer ])
|
||||
let vStackView = UIStackView(arrangedSubviews: [ hStackViewContainer, bodyTappableLabelContainer ])
|
||||
vStackView.axis = .vertical
|
||||
addSubview(vStackView)
|
||||
vStackView.pin(to: self)
|
||||
|
||||
// Loader
|
||||
addSubview(loader)
|
||||
|
||||
let loaderSize = LinkPreviewView.loaderSize
|
||||
loader.set(.width, to: loaderSize)
|
||||
loader.set(.height, to: loaderSize)
|
||||
loader.center(in: self)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
private func update() {
|
||||
// MARK: - Updating
|
||||
|
||||
public func update(
|
||||
with state: LinkPreviewState,
|
||||
isOutgoing: Bool,
|
||||
delegate: TappableLabelDelegate? = nil,
|
||||
cellViewModel: MessageViewModel? = nil,
|
||||
bodyLabelTextColor: UIColor? = nil,
|
||||
lastSearchText: String? = nil
|
||||
) {
|
||||
cancelButton.removeFromSuperview()
|
||||
guard let linkPreviewState = linkPreviewState else { return }
|
||||
var image = linkPreviewState.image()
|
||||
if image == nil && (linkPreviewState is LinkPreviewDraft || linkPreviewState is LinkPreviewSent) {
|
||||
|
||||
var image: UIImage? = state.image
|
||||
let stateHasImage: Bool = (image != nil)
|
||||
if image == nil && (state is LinkPreview.DraftState || state is LinkPreview.SentState) {
|
||||
image = UIImage(named: "Link")?.withTint(isLightMode ? .black : .white)
|
||||
}
|
||||
|
||||
// Image view
|
||||
let imageViewContainerSize: CGFloat = (linkPreviewState is LinkPreviewSent) ? 100 : 80
|
||||
let imageViewContainerSize: CGFloat = (state is LinkPreview.SentState ? 100 : 80)
|
||||
imageViewContainerWidthConstraint.constant = imageViewContainerSize
|
||||
imageViewContainerHeightConstraint.constant = imageViewContainerSize
|
||||
imageViewContainer.layer.cornerRadius = (linkPreviewState is LinkPreviewSent) ? 0 : 8
|
||||
if linkPreviewState is LinkPreviewLoading {
|
||||
imageViewContainer.layer.cornerRadius = (state is LinkPreview.SentState ? 0 : 8)
|
||||
|
||||
if state is LinkPreview.LoadingState {
|
||||
imageViewContainer.backgroundColor = .clear
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
imageViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06)
|
||||
}
|
||||
|
||||
imageView.image = image
|
||||
imageView.contentMode = (linkPreviewState.image() == nil) ? .center : .scaleAspectFill
|
||||
imageView.contentMode = (stateHasImage ? .scaleAspectFill : .center)
|
||||
|
||||
// Loader
|
||||
loader.alpha = (image != nil) ? 0 : 1
|
||||
loader.alpha = (image != nil ? 0 : 1)
|
||||
if image != nil { loader.stopAnimating() } else { loader.startAnimating() }
|
||||
|
||||
// Title
|
||||
let sentLinkPreviewTextColor: UIColor = {
|
||||
switch (isOutgoing, AppModeManager.shared.currentAppMode) {
|
||||
case (false, .light): return .black
|
||||
case (true, .light): return Colors.grey
|
||||
default: return .white
|
||||
}
|
||||
}()
|
||||
titleLabel.textColor = sentLinkPreviewTextColor
|
||||
titleLabel.text = linkPreviewState.title()
|
||||
titleLabel.text = state.title
|
||||
|
||||
// Horizontal stack view
|
||||
switch linkPreviewState {
|
||||
case is LinkPreviewSent: hStackViewContainer.backgroundColor = isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06)
|
||||
default: hStackViewContainer.backgroundColor = nil
|
||||
switch state {
|
||||
case is LinkPreview.SentState:
|
||||
// FIXME: This will have issues with theme transitions
|
||||
hStackViewContainer.backgroundColor = (isDarkMode ? .black : UIColor.black.withAlphaComponent(0.06))
|
||||
|
||||
default:
|
||||
hStackViewContainer.backgroundColor = nil
|
||||
}
|
||||
|
||||
// Body text view
|
||||
bodyTextViewContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
if let viewItem = viewItem {
|
||||
let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: sentLinkPreviewTextColor, delegate: delegate)
|
||||
self.bodyTextView = bodyTextView
|
||||
bodyTextViewContainer.addSubview(bodyTextView)
|
||||
bodyTextView.pin(to: bodyTextViewContainer, withInset: 12)
|
||||
bodyTappableLabelContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
|
||||
if let cellViewModel: MessageViewModel = cellViewModel {
|
||||
let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel(
|
||||
for: cellViewModel,
|
||||
with: maxWidth,
|
||||
textColor: (bodyLabelTextColor ?? sentLinkPreviewTextColor),
|
||||
searchText: lastSearchText,
|
||||
delegate: delegate
|
||||
)
|
||||
|
||||
self.bodyTappableLabel = bodyTappableLabel
|
||||
bodyTappableLabelContainer.addSubview(bodyTappableLabel)
|
||||
bodyTappableLabel.pin(to: bodyTappableLabelContainer, withInset: 12)
|
||||
}
|
||||
if linkPreviewState is LinkPreviewDraft {
|
||||
|
||||
if state is LinkPreview.DraftState {
|
||||
hStackView.addArrangedSubview(cancelButton)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func cancel() {
|
||||
delegate.handleLinkPreviewCanceled()
|
||||
onCancel?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
protocol LinkPreviewViewDelegate : TappableLabelDelegate {
|
||||
var lastSearchedText: String? { get }
|
||||
|
||||
func handleLinkPreviewCanceled()
|
||||
}
|
||||
|
|
|
@ -1,17 +1,11 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
@objc(OWSMediaAlbumView)
|
||||
public class MediaAlbumView: UIStackView {
|
||||
private let items: [ConversationMediaAlbumItem]
|
||||
|
||||
@objc
|
||||
private let items: [Attachment]
|
||||
public let itemViews: [MediaView]
|
||||
|
||||
@objc
|
||||
public var moreItemsView: MediaView?
|
||||
|
||||
private static let kSpacingPts: CGFloat = 2
|
||||
|
@ -22,19 +16,22 @@ public class MediaAlbumView: UIStackView {
|
|||
notImplemented()
|
||||
}
|
||||
|
||||
@objc
|
||||
public required init(mediaCache: NSCache<NSString, AnyObject>,
|
||||
items: [ConversationMediaAlbumItem],
|
||||
isOutgoing: Bool,
|
||||
maxMessageWidth: CGFloat) {
|
||||
public required init(
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
items: [Attachment],
|
||||
isOutgoing: Bool,
|
||||
maxMessageWidth: CGFloat
|
||||
) {
|
||||
self.items = items
|
||||
self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items).map {
|
||||
let result = MediaView(mediaCache: mediaCache,
|
||||
attachment: $0.attachment,
|
||||
isOutgoing: isOutgoing,
|
||||
maxMessageWidth: maxMessageWidth)
|
||||
return result
|
||||
}
|
||||
self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items)
|
||||
.map {
|
||||
MediaView(
|
||||
mediaCache: mediaCache,
|
||||
attachment: $0,
|
||||
isOutgoing: isOutgoing,
|
||||
maxMessageWidth: maxMessageWidth
|
||||
)
|
||||
}
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
|
@ -46,110 +43,137 @@ public class MediaAlbumView: UIStackView {
|
|||
|
||||
private func createContents(maxMessageWidth: CGFloat) {
|
||||
switch itemViews.count {
|
||||
case 0:
|
||||
owsFailDebug("No item views.")
|
||||
return
|
||||
case 1:
|
||||
// X
|
||||
guard let itemView = itemViews.first else {
|
||||
owsFailDebug("Missing item view.")
|
||||
return
|
||||
}
|
||||
addSubview(itemView)
|
||||
itemView.autoPinEdgesToSuperviewEdges()
|
||||
case 2:
|
||||
// X X
|
||||
// side-by-side.
|
||||
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
||||
autoSet(viewSize: imageSize, ofViews: itemViews)
|
||||
for itemView in itemViews {
|
||||
addArrangedSubview(itemView)
|
||||
}
|
||||
self.axis = .horizontal
|
||||
self.spacing = MediaAlbumView.kSpacingPts
|
||||
case 3:
|
||||
// x
|
||||
// X x
|
||||
// Big on left, 2 small on right.
|
||||
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
|
||||
let bigImageSize = smallImageSize * 2 + MediaAlbumView.kSpacingPts
|
||||
|
||||
guard let leftItemView = itemViews.first else {
|
||||
owsFailDebug("Missing view")
|
||||
return
|
||||
}
|
||||
autoSet(viewSize: bigImageSize, ofViews: [leftItemView])
|
||||
addArrangedSubview(leftItemView)
|
||||
|
||||
let rightViews = Array(itemViews[1..<3])
|
||||
addArrangedSubview(newRow(rowViews: rightViews,
|
||||
axis: .vertical,
|
||||
viewSize: smallImageSize))
|
||||
self.axis = .horizontal
|
||||
self.spacing = MediaAlbumView.kSpacingPts
|
||||
case 4:
|
||||
// X X
|
||||
// X X
|
||||
// Square
|
||||
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
||||
|
||||
let topViews = Array(itemViews[0..<2])
|
||||
addArrangedSubview(newRow(rowViews: topViews,
|
||||
axis: .horizontal,
|
||||
viewSize: imageSize))
|
||||
|
||||
let bottomViews = Array(itemViews[2..<4])
|
||||
addArrangedSubview(newRow(rowViews: bottomViews,
|
||||
axis: .horizontal,
|
||||
viewSize: imageSize))
|
||||
|
||||
self.axis = .vertical
|
||||
self.spacing = MediaAlbumView.kSpacingPts
|
||||
default:
|
||||
// X X
|
||||
// xxx
|
||||
// 2 big on top, 3 small on bottom.
|
||||
let bigImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
||||
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
|
||||
|
||||
let topViews = Array(itemViews[0..<2])
|
||||
addArrangedSubview(newRow(rowViews: topViews,
|
||||
axis: .horizontal,
|
||||
viewSize: bigImageSize))
|
||||
|
||||
let bottomViews = Array(itemViews[2..<5])
|
||||
addArrangedSubview(newRow(rowViews: bottomViews,
|
||||
axis: .horizontal,
|
||||
viewSize: smallImageSize))
|
||||
|
||||
self.axis = .vertical
|
||||
self.spacing = MediaAlbumView.kSpacingPts
|
||||
|
||||
if items.count > MediaAlbumView.kMaxItems {
|
||||
guard let lastView = bottomViews.last else {
|
||||
owsFailDebug("Missing lastView")
|
||||
case 0: return owsFailDebug("No item views.")
|
||||
|
||||
case 1:
|
||||
// X
|
||||
guard let itemView = itemViews.first else {
|
||||
owsFailDebug("Missing item view.")
|
||||
return
|
||||
}
|
||||
addSubview(itemView)
|
||||
itemView.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
case 2:
|
||||
// X X
|
||||
// side-by-side.
|
||||
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
||||
autoSet(viewSize: imageSize, ofViews: itemViews)
|
||||
for itemView in itemViews {
|
||||
addArrangedSubview(itemView)
|
||||
}
|
||||
self.axis = .horizontal
|
||||
self.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()
|
||||
tintView.backgroundColor = UIColor(white: 0, alpha: 0.4)
|
||||
lastView.addSubview(tintView)
|
||||
tintView.autoPinEdgesToSuperviewEdges()
|
||||
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 moreCount = max(1, items.count - MediaAlbumView.kMaxItems)
|
||||
let moreCountText = OWSFormat.formatInt(Int32(moreCount))
|
||||
let moreText = String(format: NSLocalizedString("MEDIA_GALLERY_MORE_ITEMS_FORMAT",
|
||||
comment: "Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}."), moreCountText)
|
||||
let moreLabel = UILabel()
|
||||
moreLabel.text = moreText
|
||||
moreLabel.textColor = UIColor.ows_white
|
||||
// We don't want to use dynamic text here.
|
||||
moreLabel.font = UIFont.systemFont(ofSize: 24)
|
||||
lastView.addSubview(moreLabel)
|
||||
moreLabel.autoCenterInSuperview()
|
||||
}
|
||||
let topViews = Array(itemViews[0..<2])
|
||||
addArrangedSubview(
|
||||
newRow(
|
||||
rowViews: topViews,
|
||||
axis: .horizontal,
|
||||
viewSize: imageSize
|
||||
)
|
||||
)
|
||||
|
||||
let bottomViews = Array(itemViews[2..<4])
|
||||
addArrangedSubview(
|
||||
newRow(
|
||||
rowViews: bottomViews,
|
||||
axis: .horizontal,
|
||||
viewSize: imageSize
|
||||
)
|
||||
)
|
||||
|
||||
self.axis = .vertical
|
||||
self.spacing = MediaAlbumView.kSpacingPts
|
||||
|
||||
default:
|
||||
// X X
|
||||
// xxx
|
||||
// 2 big on top, 3 small on bottom.
|
||||
let bigImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
|
||||
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
|
||||
|
||||
let topViews = Array(itemViews[0..<2])
|
||||
addArrangedSubview(
|
||||
newRow(
|
||||
rowViews: topViews,
|
||||
axis: .horizontal,
|
||||
viewSize: bigImageSize
|
||||
)
|
||||
)
|
||||
|
||||
let bottomViews = Array(itemViews[2..<5])
|
||||
addArrangedSubview(
|
||||
newRow(
|
||||
rowViews: bottomViews,
|
||||
axis: .horizontal,
|
||||
viewSize: smallImageSize
|
||||
)
|
||||
)
|
||||
|
||||
self.axis = .vertical
|
||||
self.spacing = MediaAlbumView.kSpacingPts
|
||||
|
||||
if items.count > MediaAlbumView.kMaxItems {
|
||||
guard let lastView = bottomViews.last else {
|
||||
owsFailDebug("Missing lastView")
|
||||
return
|
||||
}
|
||||
|
||||
moreItemsView = lastView
|
||||
|
||||
let tintView = UIView()
|
||||
tintView.backgroundColor = UIColor(white: 0, alpha: 0.4)
|
||||
lastView.addSubview(tintView)
|
||||
tintView.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
let moreCount = max(1, items.count - MediaAlbumView.kMaxItems)
|
||||
let moreCountText = OWSFormat.formatInt(Int32(moreCount))
|
||||
let moreText = String(
|
||||
// Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}.
|
||||
format: "MEDIA_GALLERY_MORE_ITEMS_FORMAT".localized(),
|
||||
moreCountText
|
||||
)
|
||||
let moreLabel = UILabel()
|
||||
moreLabel.text = moreText
|
||||
moreLabel.textColor = UIColor.ows_white
|
||||
// We don't want to use dynamic text here.
|
||||
moreLabel.font = UIFont.systemFont(ofSize: 24)
|
||||
lastView.addSubview(moreLabel)
|
||||
moreLabel.autoCenterInSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
for itemView in itemViews {
|
||||
|
@ -181,43 +205,47 @@ public class MediaAlbumView: UIStackView {
|
|||
}
|
||||
}
|
||||
|
||||
private func autoSet(viewSize: CGFloat,
|
||||
ofViews views: [MediaView]) {
|
||||
private func autoSet(
|
||||
viewSize: CGFloat,
|
||||
ofViews views: [MediaView]
|
||||
) {
|
||||
for itemView in views {
|
||||
itemView.autoSetDimensions(to: CGSize(width: viewSize, height: viewSize))
|
||||
}
|
||||
}
|
||||
|
||||
private func newRow(rowViews: [MediaView],
|
||||
axis: NSLayoutConstraint.Axis,
|
||||
viewSize: CGFloat) -> UIStackView {
|
||||
private func newRow(
|
||||
rowViews: [MediaView],
|
||||
axis: NSLayoutConstraint.Axis,
|
||||
viewSize: CGFloat
|
||||
) -> UIStackView {
|
||||
autoSet(viewSize: viewSize, ofViews: rowViews)
|
||||
return newRow(rowViews: rowViews, axis: axis)
|
||||
}
|
||||
|
||||
private func newRow(rowViews: [MediaView],
|
||||
axis: NSLayoutConstraint.Axis) -> UIStackView {
|
||||
private func newRow(
|
||||
rowViews: [MediaView],
|
||||
axis: NSLayoutConstraint.Axis
|
||||
) -> UIStackView {
|
||||
let stackView = UIStackView(arrangedSubviews: rowViews)
|
||||
stackView.axis = axis
|
||||
stackView.spacing = MediaAlbumView.kSpacingPts
|
||||
return stackView
|
||||
}
|
||||
|
||||
@objc
|
||||
public func loadMedia() {
|
||||
for itemView in itemViews {
|
||||
itemView.loadMedia()
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public func unloadMedia() {
|
||||
for itemView in itemViews {
|
||||
itemView.unloadMedia()
|
||||
}
|
||||
}
|
||||
|
||||
private class func itemsToDisplay(forItems items: [ConversationMediaAlbumItem]) -> [ConversationMediaAlbumItem] {
|
||||
private class func itemsToDisplay(forItems items: [Attachment]) -> [Attachment] {
|
||||
// TODO: Unless design changes, we want to display
|
||||
// items which are still downloading and invalid
|
||||
// items.
|
||||
|
@ -228,43 +256,47 @@ public class MediaAlbumView: UIStackView {
|
|||
return validItems
|
||||
}
|
||||
|
||||
@objc
|
||||
public class func layoutSize(forMaxMessageWidth maxMessageWidth: CGFloat,
|
||||
items: [ConversationMediaAlbumItem]) -> CGSize {
|
||||
public class func layoutSize(
|
||||
forMaxMessageWidth maxMessageWidth: CGFloat,
|
||||
items: [Attachment]
|
||||
) -> CGSize {
|
||||
let itemCount = itemsToDisplay(forItems: items).count
|
||||
|
||||
switch itemCount {
|
||||
case 0, 1, 4:
|
||||
// X
|
||||
//
|
||||
// or
|
||||
//
|
||||
// XX
|
||||
// XX
|
||||
// Square
|
||||
return CGSize(width: maxMessageWidth, height: maxMessageWidth)
|
||||
case 2:
|
||||
// X X
|
||||
// side-by-side.
|
||||
let imageSize = (maxMessageWidth - kSpacingPts) / 2
|
||||
return CGSize(width: maxMessageWidth, height: imageSize)
|
||||
case 3:
|
||||
// x
|
||||
// X x
|
||||
// Big on left, 2 small on right.
|
||||
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
||||
let bigImageSize = smallImageSize * 2 + kSpacingPts
|
||||
return CGSize(width: maxMessageWidth, height: bigImageSize)
|
||||
default:
|
||||
// X X
|
||||
// xxx
|
||||
// 2 big on top, 3 small on bottom.
|
||||
let bigImageSize = (maxMessageWidth - kSpacingPts) / 2
|
||||
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
||||
return CGSize(width: maxMessageWidth, height: bigImageSize + smallImageSize + kSpacingPts)
|
||||
case 0, 1, 4:
|
||||
// X
|
||||
//
|
||||
// or
|
||||
//
|
||||
// XX
|
||||
// XX
|
||||
// Square
|
||||
return CGSize(width: maxMessageWidth, height: maxMessageWidth)
|
||||
|
||||
case 2:
|
||||
// X X
|
||||
// side-by-side.
|
||||
let imageSize = (maxMessageWidth - kSpacingPts) / 2
|
||||
return CGSize(width: maxMessageWidth, height: imageSize)
|
||||
|
||||
case 3:
|
||||
// x
|
||||
// X x
|
||||
// Big on left, 2 small on right.
|
||||
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
||||
let bigImageSize = smallImageSize * 2 + kSpacingPts
|
||||
return CGSize(width: maxMessageWidth, height: bigImageSize)
|
||||
|
||||
default:
|
||||
// X X
|
||||
// xxx
|
||||
// 2 big on top, 3 small on bottom.
|
||||
let bigImageSize = (maxMessageWidth - kSpacingPts) / 2
|
||||
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
|
||||
return CGSize(width: maxMessageWidth, height: bigImageSize + smallImageSize + kSpacingPts)
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public func mediaView(forLocation location: CGPoint) -> MediaView? {
|
||||
var bestMediaView: MediaView?
|
||||
var bestDistance: CGFloat = 0
|
||||
|
@ -280,7 +312,6 @@ public class MediaAlbumView: UIStackView {
|
|||
return bestMediaView
|
||||
}
|
||||
|
||||
@objc
|
||||
public func isMoreItemsView(mediaView: MediaView) -> Bool {
|
||||
return moreItemsView == mediaView
|
||||
}
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class MediaPlaceholderView : UIView {
|
||||
private let viewItem: ConversationViewItem
|
||||
private let textColor: UIColor
|
||||
|
||||
// MARK: Settings
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class MediaPlaceholderView: UIView {
|
||||
private static let iconSize: CGFloat = 24
|
||||
private static let iconImageViewSize: CGFloat = 40
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem, textColor: UIColor) {
|
||||
self.viewItem = viewItem
|
||||
self.textColor = textColor
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(cellViewModel: MessageViewModel, textColor: UIColor) {
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
|
||||
setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -23,32 +23,47 @@ final class MediaPlaceholderView : UIView {
|
|||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
private func setUpViewHierarchy(
|
||||
cellViewModel: MessageViewModel,
|
||||
textColor: UIColor
|
||||
) {
|
||||
let (iconName, attachmentDescription): (String, String) = {
|
||||
guard let message = viewItem.interaction as? TSIncomingMessage else { return ("actionsheet_document_black", "file") } // Should never occur
|
||||
var attachments: [TSAttachment] = []
|
||||
Storage.read { transaction in
|
||||
attachments = message.attachments(with: transaction)
|
||||
guard
|
||||
cellViewModel.variant == .standardIncoming,
|
||||
let attachment: Attachment = cellViewModel.attachments?.first
|
||||
else {
|
||||
return ("actionsheet_document_black", "file") // Should never occur
|
||||
}
|
||||
guard let contentType = attachments.first?.contentType else { return ("actionsheet_document_black", "file") } // Should never occur
|
||||
if MIMETypeUtil.isAudio(contentType) { return ("attachment_audio", "audio") }
|
||||
if MIMETypeUtil.isImage(contentType) || MIMETypeUtil.isVideo(contentType) { return ("actionsheet_camera_roll_black", "media") }
|
||||
|
||||
if attachment.isAudio { return ("attachment_audio", "audio") }
|
||||
if attachment.isImage || attachment.isVideo { return ("actionsheet_camera_roll_black", "media") }
|
||||
|
||||
return ("actionsheet_document_black", "file")
|
||||
}()
|
||||
|
||||
// Image view
|
||||
let iconSize = MediaPlaceholderView.iconSize
|
||||
let icon = UIImage(named: iconName)?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
let imageView = UIImageView(image: icon)
|
||||
let imageView = UIImageView(
|
||||
image: UIImage(named: iconName)?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
.resizedImage(
|
||||
to: CGSize(
|
||||
width: MediaPlaceholderView.iconSize,
|
||||
height: MediaPlaceholderView.iconSize
|
||||
)
|
||||
)
|
||||
)
|
||||
imageView.tintColor = textColor
|
||||
imageView.contentMode = .center
|
||||
let iconImageViewSize = MediaPlaceholderView.iconImageViewSize
|
||||
imageView.set(.width, to: iconImageViewSize)
|
||||
imageView.set(.height, to: iconImageViewSize)
|
||||
imageView.set(.width, to: MediaPlaceholderView.iconImageViewSize)
|
||||
imageView.set(.height, to: MediaPlaceholderView.iconImageViewSize)
|
||||
|
||||
// Body label
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = "Tap to download \(attachmentDescription)"
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
|
||||
stackView.axis = .horizontal
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import YYImage
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
@objc(OWSMediaView)
|
||||
public class MediaView: UIView {
|
||||
|
||||
static let contentMode: UIView.ContentMode = .scaleAspectFill
|
||||
|
||||
private enum MediaError {
|
||||
case missing
|
||||
case invalid
|
||||
|
@ -17,8 +17,7 @@ public class MediaView: UIView {
|
|||
// MARK: -
|
||||
|
||||
private let mediaCache: NSCache<NSString, AnyObject>
|
||||
@objc
|
||||
public let attachment: TSAttachment
|
||||
public let attachment: Attachment
|
||||
private let isOutgoing: Bool
|
||||
private let maxMessageWidth: CGFloat
|
||||
private var loadBlock: (() -> Void)?
|
||||
|
@ -42,50 +41,16 @@ public class MediaView: UIView {
|
|||
case failed
|
||||
}
|
||||
|
||||
// Thread-safe access to load state.
|
||||
//
|
||||
// We use a "box" class so that we can capture a reference
|
||||
// to this box (rather than self) and a) safely access
|
||||
// if off the main thread b) not prevent deallocation of
|
||||
// self.
|
||||
private class ThreadSafeLoadState {
|
||||
private var value: LoadState
|
||||
|
||||
required init(_ value: LoadState) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
func get() -> LoadState {
|
||||
objc_sync_enter(self)
|
||||
let valueCopy = value
|
||||
objc_sync_exit(self)
|
||||
return valueCopy
|
||||
}
|
||||
|
||||
func set(_ newValue: LoadState) {
|
||||
objc_sync_enter(self)
|
||||
value = newValue
|
||||
objc_sync_exit(self)
|
||||
}
|
||||
}
|
||||
private let threadSafeLoadState = ThreadSafeLoadState(.unloaded)
|
||||
// Convenience accessors.
|
||||
private var loadState: LoadState {
|
||||
get {
|
||||
return threadSafeLoadState.get()
|
||||
}
|
||||
set {
|
||||
threadSafeLoadState.set(newValue)
|
||||
}
|
||||
}
|
||||
private let loadState: Atomic<LoadState> = Atomic(.unloaded)
|
||||
|
||||
// MARK: - Initializers
|
||||
|
||||
@objc
|
||||
public required init(mediaCache: NSCache<NSString, AnyObject>,
|
||||
attachment: TSAttachment,
|
||||
isOutgoing: Bool,
|
||||
maxMessageWidth: CGFloat) {
|
||||
public required init(
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
attachment: Attachment,
|
||||
isOutgoing: Bool,
|
||||
maxMessageWidth: CGFloat
|
||||
) {
|
||||
self.mediaCache = mediaCache
|
||||
self.attachment = attachment
|
||||
self.isOutgoing = isOutgoing
|
||||
|
@ -105,9 +70,7 @@ public class MediaView: UIView {
|
|||
}
|
||||
|
||||
deinit {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
loadState = .unloaded
|
||||
loadState.mutate { $0 = .unloaded }
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
@ -115,41 +78,45 @@ public class MediaView: UIView {
|
|||
private func createContents() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let attachmentStream = attachment as? TSAttachmentStream else {
|
||||
guard attachment.state != .pendingDownload && attachment.state != .downloading else {
|
||||
addDownloadProgressIfNecessary()
|
||||
return
|
||||
}
|
||||
guard !isFailedDownload else {
|
||||
guard attachment.state != .failedDownload else {
|
||||
configure(forError: .failed)
|
||||
return
|
||||
}
|
||||
if attachmentStream.isAnimated {
|
||||
configureForAnimatedImage(attachmentStream: attachmentStream)
|
||||
} else if attachmentStream.isImage {
|
||||
configureForStillImage(attachmentStream: attachmentStream)
|
||||
} else if attachmentStream.isVideo {
|
||||
configureForVideo(attachmentStream: attachmentStream)
|
||||
} else {
|
||||
guard attachment.isValid else {
|
||||
configure(forError: .invalid)
|
||||
return
|
||||
}
|
||||
|
||||
if attachment.isAnimated {
|
||||
configureForAnimatedImage(attachment: attachment)
|
||||
}
|
||||
else if attachment.isImage {
|
||||
configureForStillImage(attachment: attachment)
|
||||
}
|
||||
else if attachment.isVideo {
|
||||
configureForVideo(attachment: attachment)
|
||||
}
|
||||
else {
|
||||
owsFailDebug("Attachment has unexpected type.")
|
||||
configure(forError: .invalid)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func addDownloadProgressIfNecessary() {
|
||||
guard !isFailedDownload else {
|
||||
guard attachment.state != .failedDownload else {
|
||||
configure(forError: .failed)
|
||||
return
|
||||
}
|
||||
guard let attachmentPointer = attachment as? TSAttachmentPointer else {
|
||||
owsFailDebug("Attachment has unexpected type.")
|
||||
configure(forError: .invalid)
|
||||
return
|
||||
}
|
||||
guard attachmentPointer.pointerType == .incoming else {
|
||||
guard attachment.state != .uploading && attachment.state != .uploaded else {
|
||||
// TODO: Show "restoring" indicator and possibly progress.
|
||||
configure(forError: .missing)
|
||||
return
|
||||
}
|
||||
|
||||
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
||||
let loader = MediaLoaderView()
|
||||
addSubview(loader)
|
||||
|
@ -158,28 +125,30 @@ public class MediaView: UIView {
|
|||
|
||||
private func addUploadProgressIfNecessary(_ subview: UIView) -> Bool {
|
||||
guard isOutgoing else { return false }
|
||||
guard let attachmentStream = attachment as? TSAttachmentStream else { return false }
|
||||
guard !attachmentStream.isUploaded else { return false }
|
||||
guard attachment.state != .failedUpload else {
|
||||
configure(forError: .failed)
|
||||
return false
|
||||
}
|
||||
guard attachment.state != .uploaded else { return false }
|
||||
|
||||
let loader = MediaLoaderView()
|
||||
addSubview(loader)
|
||||
loader.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: self)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func configureForAnimatedImage(attachmentStream: TSAttachmentStream) {
|
||||
guard let cacheKey = attachmentStream.uniqueId else {
|
||||
owsFailDebug("Attachment stream missing unique ID.")
|
||||
return
|
||||
}
|
||||
let animatedImageView = YYAnimatedImageView()
|
||||
private func configureForAnimatedImage(attachment: Attachment) {
|
||||
let animatedImageView: YYAnimatedImageView = YYAnimatedImageView()
|
||||
// We need to specify a contentMode since the size of the image
|
||||
// might not match the aspect ratio of the view.
|
||||
animatedImageView.contentMode = .scaleAspectFill
|
||||
animatedImageView.contentMode = MediaView.contentMode
|
||||
// Use trilinear filters for better scaling quality at
|
||||
// some performance cost.
|
||||
animatedImageView.layer.minificationFilter = .trilinear
|
||||
animatedImageView.layer.magnificationFilter = .trilinear
|
||||
animatedImageView.backgroundColor = Colors.unimportant
|
||||
animatedImageView.isHidden = !attachment.isValid
|
||||
addSubview(animatedImageView)
|
||||
animatedImageView.autoPinEdgesToSuperviewEdges()
|
||||
_ = addUploadProgressIfNecessary(animatedImageView)
|
||||
|
@ -187,36 +156,34 @@ public class MediaView: UIView {
|
|||
loadBlock = { [weak self] in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
guard let strongSelf = self else { return }
|
||||
|
||||
if animatedImageView.image != nil {
|
||||
owsFailDebug("Unexpectedly already loaded.")
|
||||
return
|
||||
}
|
||||
strongSelf.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
|
||||
guard attachmentStream.isValidImage else {
|
||||
Logger.warn("Ignoring invalid attachment.")
|
||||
return nil
|
||||
}
|
||||
guard let filePath = attachmentStream.originalFilePath else {
|
||||
owsFailDebug("Attachment stream missing original file path.")
|
||||
return nil
|
||||
}
|
||||
let animatedImage = YYImage(contentsOfFile: filePath)
|
||||
return animatedImage
|
||||
},
|
||||
applyMediaBlock: { (media) in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let image = media as? YYImage else {
|
||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||
return
|
||||
}
|
||||
animatedImageView.image = image
|
||||
},
|
||||
cacheKey: cacheKey)
|
||||
strongSelf.tryToLoadMedia(
|
||||
loadMediaBlock: { applyMediaBlock in
|
||||
guard attachment.isValid else { return }
|
||||
guard let filePath: String = attachment.originalFilePath else {
|
||||
owsFailDebug("Attachment stream missing original file path.")
|
||||
return
|
||||
}
|
||||
|
||||
applyMediaBlock(YYImage(contentsOfFile: filePath))
|
||||
},
|
||||
applyMediaBlock: { media in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let image: YYImage = media as? YYImage else {
|
||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||
return
|
||||
}
|
||||
// FIXME: Animated images flicker when reloading the cells (even though they are in the cache)
|
||||
animatedImageView.image = image
|
||||
},
|
||||
cacheKey: attachment.id
|
||||
)
|
||||
}
|
||||
unloadBlock = {
|
||||
AssertIsOnMainThread()
|
||||
|
@ -225,23 +192,21 @@ public class MediaView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
private func configureForStillImage(attachmentStream: TSAttachmentStream) {
|
||||
guard let cacheKey = attachmentStream.uniqueId else {
|
||||
owsFailDebug("Attachment stream missing unique ID.")
|
||||
return
|
||||
}
|
||||
private func configureForStillImage(attachment: Attachment) {
|
||||
let stillImageView = UIImageView()
|
||||
// We need to specify a contentMode since the size of the image
|
||||
// might not match the aspect ratio of the view.
|
||||
stillImageView.contentMode = .scaleAspectFill
|
||||
stillImageView.contentMode = MediaView.contentMode
|
||||
// Use trilinear filters for better scaling quality at
|
||||
// some performance cost.
|
||||
stillImageView.layer.minificationFilter = .trilinear
|
||||
stillImageView.layer.magnificationFilter = .trilinear
|
||||
stillImageView.backgroundColor = Colors.unimportant
|
||||
stillImageView.isHidden = !attachment.isValid
|
||||
addSubview(stillImageView)
|
||||
stillImageView.autoPinEdgesToSuperviewEdges()
|
||||
_ = addUploadProgressIfNecessary(stillImageView)
|
||||
|
||||
loadBlock = { [weak self] in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
|
@ -249,29 +214,28 @@ public class MediaView: UIView {
|
|||
owsFailDebug("Unexpectedly already loaded.")
|
||||
return
|
||||
}
|
||||
self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
|
||||
guard attachmentStream.isValidImage else {
|
||||
Logger.warn("Ignoring invalid attachment.")
|
||||
return nil
|
||||
}
|
||||
return attachmentStream.thumbnailImageLarge(success: { (image) in
|
||||
self?.tryToLoadMedia(
|
||||
loadMediaBlock: { applyMediaBlock in
|
||||
guard attachment.isValid else { return }
|
||||
|
||||
attachment.thumbnail(
|
||||
size: .large,
|
||||
success: { image, _ in applyMediaBlock(image) },
|
||||
failure: { Logger.error("Could not load thumbnail") }
|
||||
)
|
||||
},
|
||||
applyMediaBlock: { media in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
|
||||
guard let image: UIImage = media as? UIImage else {
|
||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||
return
|
||||
}
|
||||
|
||||
stillImageView.image = image
|
||||
}, failure: {
|
||||
Logger.error("Could not load thumbnail")
|
||||
})
|
||||
},
|
||||
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)
|
||||
},
|
||||
cacheKey: attachment.id
|
||||
)
|
||||
}
|
||||
unloadBlock = {
|
||||
AssertIsOnMainThread()
|
||||
|
@ -280,20 +244,17 @@ public class MediaView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
private func configureForVideo(attachmentStream: TSAttachmentStream) {
|
||||
guard let cacheKey = attachmentStream.uniqueId else {
|
||||
owsFailDebug("Attachment stream missing unique ID.")
|
||||
return
|
||||
}
|
||||
private func configureForVideo(attachment: Attachment) {
|
||||
let stillImageView = UIImageView()
|
||||
// We need to specify a contentMode since the size of the image
|
||||
// might not match the aspect ratio of the view.
|
||||
stillImageView.contentMode = .scaleAspectFill
|
||||
stillImageView.contentMode = MediaView.contentMode
|
||||
// Use trilinear filters for better scaling quality at
|
||||
// some performance cost.
|
||||
stillImageView.layer.minificationFilter = .trilinear
|
||||
stillImageView.layer.magnificationFilter = .trilinear
|
||||
stillImageView.backgroundColor = Colors.unimportant
|
||||
stillImageView.isHidden = !attachment.isValid
|
||||
|
||||
addSubview(stillImageView)
|
||||
stillImageView.autoPinEdgesToSuperviewEdges()
|
||||
|
@ -314,29 +275,28 @@ public class MediaView: UIView {
|
|||
owsFailDebug("Unexpectedly already loaded.")
|
||||
return
|
||||
}
|
||||
self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
|
||||
guard attachmentStream.isValidVideo else {
|
||||
Logger.warn("Ignoring invalid attachment.")
|
||||
return nil
|
||||
}
|
||||
return attachmentStream.thumbnailImageMedium(success: { (image) in
|
||||
self?.tryToLoadMedia(
|
||||
loadMediaBlock: { applyMediaBlock in
|
||||
guard attachment.isValid else { return }
|
||||
|
||||
attachment.thumbnail(
|
||||
size: .medium,
|
||||
success: { image, _ in applyMediaBlock(image) },
|
||||
failure: { Logger.error("Could not load thumbnail") }
|
||||
)
|
||||
},
|
||||
applyMediaBlock: { media in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let image: UIImage = media as? UIImage else {
|
||||
owsFailDebug("Media has unexpected type: \(type(of: media))")
|
||||
return
|
||||
}
|
||||
|
||||
stillImageView.image = image
|
||||
}, failure: {
|
||||
Logger.error("Could not load thumbnail")
|
||||
})
|
||||
},
|
||||
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)
|
||||
},
|
||||
cacheKey: attachment.id
|
||||
)
|
||||
}
|
||||
unloadBlock = {
|
||||
AssertIsOnMainThread()
|
||||
|
@ -345,100 +305,105 @@ public class MediaView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
private var isFailedDownload: Bool {
|
||||
guard let attachmentPointer = attachment as? TSAttachmentPointer else {
|
||||
return false
|
||||
}
|
||||
return attachmentPointer.state == .failed
|
||||
}
|
||||
|
||||
private func configure(forError error: MediaError) {
|
||||
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
||||
let icon: UIImage
|
||||
|
||||
switch error {
|
||||
case .failed:
|
||||
guard let asset = UIImage(named: "media_retry") else {
|
||||
owsFailDebug("Missing image")
|
||||
return
|
||||
}
|
||||
icon = asset
|
||||
case .invalid:
|
||||
guard let asset = UIImage(named: "media_invalid") else {
|
||||
owsFailDebug("Missing image")
|
||||
return
|
||||
}
|
||||
icon = asset
|
||||
case .missing:
|
||||
return
|
||||
case .failed:
|
||||
guard let asset = UIImage(named: "media_retry") else {
|
||||
owsFailDebug("Missing image")
|
||||
return
|
||||
}
|
||||
icon = asset
|
||||
|
||||
case .invalid:
|
||||
guard let asset = UIImage(named: "media_invalid") else {
|
||||
owsFailDebug("Missing image")
|
||||
return
|
||||
}
|
||||
icon = asset
|
||||
|
||||
case .missing: return
|
||||
}
|
||||
|
||||
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
|
||||
|
||||
// For failed ougoing messages add an overlay to make the icon more visible
|
||||
if isOutgoing {
|
||||
let attachmentOverlayView: UIView = UIView()
|
||||
attachmentOverlayView.backgroundColor = Colors.navigationBarBackground
|
||||
.withAlphaComponent(Values.lowOpacity)
|
||||
addSubview(attachmentOverlayView)
|
||||
attachmentOverlayView.pin(to: self)
|
||||
}
|
||||
|
||||
let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
|
||||
iconView.tintColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
|
||||
iconView.tintColor = Colors.text
|
||||
.withAlphaComponent(Values.mediumOpacity)
|
||||
addSubview(iconView)
|
||||
iconView.autoCenterInSuperview()
|
||||
}
|
||||
|
||||
private func tryToLoadMedia(loadMediaBlock: @escaping () -> AnyObject?,
|
||||
applyMediaBlock: @escaping (AnyObject) -> Void,
|
||||
cacheKey: String) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
private func tryToLoadMedia(
|
||||
loadMediaBlock: @escaping (@escaping (AnyObject?) -> Void) -> Void,
|
||||
applyMediaBlock: @escaping (AnyObject) -> Void,
|
||||
cacheKey: String
|
||||
) {
|
||||
// It's critical that we update loadState once
|
||||
// our load attempt is complete.
|
||||
let loadCompletion: (AnyObject?) -> Void = { [weak self] (possibleMedia) in
|
||||
AssertIsOnMainThread()
|
||||
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
guard strongSelf.loadState == .loading else {
|
||||
let loadCompletion: (AnyObject?) -> Void = { [weak self] possibleMedia in
|
||||
guard self?.loadState.wrappedValue == .loading else {
|
||||
Logger.verbose("Skipping obsolete load.")
|
||||
return
|
||||
}
|
||||
guard let media = possibleMedia else {
|
||||
strongSelf.loadState = .failed
|
||||
guard let media: AnyObject = possibleMedia else {
|
||||
self?.loadState.mutate { $0 = .failed }
|
||||
// TODO:
|
||||
// [self showAttachmentErrorViewWithMediaView:mediaView];
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
applyMediaBlock(media)
|
||||
|
||||
strongSelf.loadState = .loaded
|
||||
|
||||
self?.mediaCache.setObject(media, forKey: cacheKey as NSString)
|
||||
self?.loadState.mutate { $0 = .loaded }
|
||||
}
|
||||
|
||||
guard loadState == .loading else {
|
||||
guard loadState.wrappedValue == .loading else {
|
||||
owsFailDebug("Unexpected load state: \(loadState)")
|
||||
return
|
||||
}
|
||||
|
||||
let mediaCache = self.mediaCache
|
||||
if let media = mediaCache.object(forKey: cacheKey as NSString) {
|
||||
if let media: AnyObject = self.mediaCache.object(forKey: cacheKey as NSString) {
|
||||
Logger.verbose("media cache hit")
|
||||
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
loadCompletion(media)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
loadCompletion(media)
|
||||
return
|
||||
}
|
||||
|
||||
Logger.verbose("media cache miss")
|
||||
|
||||
let threadSafeLoadState = self.threadSafeLoadState
|
||||
MediaView.loadQueue.async {
|
||||
guard threadSafeLoadState.get() == .loading else {
|
||||
MediaView.loadQueue.async { [weak self] in
|
||||
guard self?.loadState.wrappedValue == .loading else {
|
||||
Logger.verbose("Skipping obsolete load.")
|
||||
return
|
||||
}
|
||||
|
||||
guard let media = loadMediaBlock() else {
|
||||
Logger.error("Failed to load media.")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
loadCompletion(nil)
|
||||
|
||||
loadMediaBlock { media in
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
loadCompletion(media)
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
mediaCache.setObject(media, forKey: cacheKey as NSString)
|
||||
|
||||
|
||||
loadCompletion(media)
|
||||
}
|
||||
}
|
||||
|
@ -459,32 +424,18 @@ public class MediaView: UIView {
|
|||
// "skip rate" of obsolete loads.
|
||||
private static let loadQueue = ReverseDispatchQueue(label: "org.signal.asyncMediaLoadQueue")
|
||||
|
||||
@objc
|
||||
public func loadMedia() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
switch loadState {
|
||||
case .unloaded:
|
||||
loadState = .loading
|
||||
|
||||
guard let loadBlock = loadBlock else {
|
||||
return
|
||||
}
|
||||
loadBlock()
|
||||
case .loading, .loaded, .failed:
|
||||
break
|
||||
switch loadState.wrappedValue {
|
||||
case .unloaded:
|
||||
loadState.mutate { $0 = .loading }
|
||||
loadBlock?()
|
||||
|
||||
case .loading, .loaded, .failed: break
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public func unloadMedia() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
loadState = .unloaded
|
||||
|
||||
guard let unloadBlock = unloadBlock else {
|
||||
return
|
||||
}
|
||||
unloadBlock()
|
||||
loadState.mutate { $0 = .unloaded }
|
||||
unloadBlock?()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,30 +1,24 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class OpenGroupInvitationView : UIView {
|
||||
private let name: String
|
||||
private let rawURL: String
|
||||
private let textColor: UIColor
|
||||
private let isOutgoing: Bool
|
||||
|
||||
private lazy var url: String = {
|
||||
if let range = rawURL.range(of: "?public_key=") {
|
||||
return String(rawURL[..<range.lowerBound])
|
||||
} else {
|
||||
return rawURL
|
||||
}
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class OpenGroupInvitationView: UIView {
|
||||
private static let iconSize: CGFloat = 24
|
||||
private static let iconImageViewSize: CGFloat = 48
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(name: String, url: String, textColor: UIColor, isOutgoing: Bool) {
|
||||
self.name = name
|
||||
self.rawURL = url
|
||||
self.textColor = textColor
|
||||
self.isOutgoing = isOutgoing
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
|
||||
setUpViewHierarchy(
|
||||
name: name,
|
||||
rawUrl: url,
|
||||
textColor: textColor,
|
||||
isOutgoing: isOutgoing
|
||||
)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -35,41 +29,56 @@ final class OpenGroupInvitationView : UIView {
|
|||
preconditionFailure("Use init(name:url:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
private func setUpViewHierarchy(name: String, rawUrl: String, textColor: UIColor, isOutgoing: Bool) {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.text = name
|
||||
titleLabel.textColor = textColor
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
|
||||
// Subtitle
|
||||
let subtitleLabel = UILabel()
|
||||
subtitleLabel.lineBreakMode = .byTruncatingTail
|
||||
subtitleLabel.text = NSLocalizedString("view_open_group_invitation_description", comment: "")
|
||||
subtitleLabel.textColor = textColor
|
||||
subtitleLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
|
||||
// URL
|
||||
let urlLabel = UILabel()
|
||||
urlLabel.lineBreakMode = .byCharWrapping
|
||||
urlLabel.text = url
|
||||
urlLabel.text = {
|
||||
if let range = rawUrl.range(of: "?public_key=") {
|
||||
return String(rawUrl[..<range.lowerBound])
|
||||
}
|
||||
|
||||
return rawUrl
|
||||
}()
|
||||
urlLabel.textColor = textColor
|
||||
urlLabel.numberOfLines = 0
|
||||
urlLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
|
||||
// Label stack
|
||||
let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, UIView.vSpacer(2), subtitleLabel, UIView.vSpacer(4), urlLabel ])
|
||||
labelStackView.axis = .vertical
|
||||
|
||||
// Icon
|
||||
let iconSize = OpenGroupInvitationView.iconSize
|
||||
let iconName = isOutgoing ? "Globe" : "Plus"
|
||||
let icon = UIImage(named: iconName)?.withTint(.white)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
let iconName = (isOutgoing ? "Globe" : "Plus")
|
||||
let iconImageViewSize = OpenGroupInvitationView.iconImageViewSize
|
||||
let iconImageView = UIImageView(image: icon)
|
||||
let iconImageView = UIImageView(
|
||||
image: UIImage(named: iconName)?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
)
|
||||
iconImageView.tintColor = .white
|
||||
iconImageView.contentMode = .center
|
||||
iconImageView.layer.cornerRadius = iconImageViewSize / 2
|
||||
iconImageView.layer.masksToBounds = true
|
||||
iconImageView.backgroundColor = Colors.accent
|
||||
iconImageView.set(.width, to: iconImageViewSize)
|
||||
iconImageView.set(.height, to: iconImageViewSize)
|
||||
|
||||
// Main stack
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ iconImageView, labelStackView ])
|
||||
mainStackView.axis = .horizontal
|
||||
|
|
|
@ -1,100 +1,57 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class QuoteView : UIView {
|
||||
private let mode: Mode
|
||||
private let thread: TSThread
|
||||
private let direction: Direction
|
||||
private let hInset: CGFloat
|
||||
private let maxWidth: CGFloat
|
||||
private let delegate: QuoteViewDelegate?
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
private var maxBodyLabelHeight: CGFloat {
|
||||
switch mode {
|
||||
case .regular: return 60
|
||||
case .draft: return 40
|
||||
}
|
||||
}
|
||||
|
||||
private var attachments: [OWSAttachmentInfo] {
|
||||
switch mode {
|
||||
case .regular(let viewItem): return (viewItem.interaction as? TSMessage)?.quotedMessage!.quotedAttachments ?? []
|
||||
case .draft(let model): return given(model.attachmentStream) { [ OWSAttachmentInfo(attachmentStream: $0) ] } ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private var thumbnail: UIImage? {
|
||||
switch mode {
|
||||
case .regular(let viewItem): return viewItem.quotedReply!.thumbnailImage
|
||||
case .draft(let model): return model.thumbnailImage
|
||||
}
|
||||
}
|
||||
|
||||
private var body: String? {
|
||||
switch mode {
|
||||
case .regular(let viewItem): return (viewItem.interaction as? TSMessage)?.quotedMessage!.body
|
||||
case .draft(let model): return model.body
|
||||
}
|
||||
}
|
||||
|
||||
private var authorID: String {
|
||||
switch mode {
|
||||
case .regular(let viewItem): return viewItem.quotedReply!.authorId
|
||||
case .draft(let model): return model.authorId
|
||||
}
|
||||
}
|
||||
|
||||
private var lineColor: UIColor {
|
||||
switch (mode, AppModeManager.shared.currentAppMode) {
|
||||
case (.regular, .light), (.draft, .light): return .black
|
||||
case (.regular, .dark): return (direction == .outgoing) ? .black : Colors.accent
|
||||
case (.draft, .dark): return Colors.accent
|
||||
}
|
||||
}
|
||||
|
||||
private var textColor: UIColor {
|
||||
if case .draft = mode { return Colors.text }
|
||||
switch (direction, AppModeManager.shared.currentAppMode) {
|
||||
case (.outgoing, .dark), (.incoming, .light): return .black
|
||||
default: return .white
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Mode
|
||||
enum Mode {
|
||||
case regular(ConversationViewItem)
|
||||
case draft(OWSQuotedReplyModel)
|
||||
}
|
||||
|
||||
// MARK: Direction
|
||||
enum Direction { case incoming, outgoing }
|
||||
|
||||
// MARK: Settings
|
||||
final class QuoteView: UIView {
|
||||
static let thumbnailSize: CGFloat = 48
|
||||
static let iconSize: CGFloat = 24
|
||||
static let labelStackViewSpacing: CGFloat = 2
|
||||
static let labelStackViewVMargin: CGFloat = 4
|
||||
static let cancelButtonSize: CGFloat = 33
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(for viewItem: ConversationViewItem, in thread: TSThread?, direction: Direction, hInset: CGFloat, maxWidth: CGFloat) {
|
||||
self.mode = .regular(viewItem)
|
||||
self.thread = thread ?? TSThread.fetch(uniqueId: viewItem.interaction.uniqueThreadId)!
|
||||
self.maxWidth = maxWidth
|
||||
self.direction = direction
|
||||
self.hInset = hInset
|
||||
self.delegate = nil
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
|
||||
enum Mode {
|
||||
case regular
|
||||
case draft
|
||||
}
|
||||
enum Direction { case incoming, outgoing }
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
private let onCancel: (() -> ())?
|
||||
|
||||
init(for model: OWSQuotedReplyModel, direction: Direction, hInset: CGFloat, maxWidth: CGFloat, delegate: QuoteViewDelegate) {
|
||||
self.mode = .draft(model)
|
||||
self.thread = TSThread.fetch(uniqueId: model.threadId)!
|
||||
self.maxWidth = maxWidth
|
||||
self.direction = direction
|
||||
self.hInset = hInset
|
||||
self.delegate = delegate
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(
|
||||
for mode: Mode,
|
||||
authorId: String,
|
||||
quotedText: String?,
|
||||
threadVariant: SessionThread.Variant,
|
||||
currentUserPublicKey: String?,
|
||||
currentUserBlindedPublicKey: String?,
|
||||
direction: Direction,
|
||||
attachment: Attachment?,
|
||||
hInset: CGFloat,
|
||||
maxWidth: CGFloat,
|
||||
onCancel: (() -> ())? = nil
|
||||
) {
|
||||
self.onCancel = onCancel
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
|
||||
setUpViewHierarchy(
|
||||
mode: mode,
|
||||
authorId: authorId,
|
||||
quotedText: quotedText,
|
||||
threadVariant: threadVariant,
|
||||
currentUserPublicKey: currentUserPublicKey,
|
||||
currentUserBlindedPublicKey: currentUserBlindedPublicKey,
|
||||
direction: direction,
|
||||
attachment: attachment,
|
||||
hInset: hInset,
|
||||
maxWidth: maxWidth
|
||||
)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -105,14 +62,24 @@ final class QuoteView : UIView {
|
|||
preconditionFailure("Use init(for:maxMessageWidth:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
private func setUpViewHierarchy(
|
||||
mode: Mode,
|
||||
authorId: String,
|
||||
quotedText: String?,
|
||||
threadVariant: SessionThread.Variant,
|
||||
currentUserPublicKey: String?,
|
||||
currentUserBlindedPublicKey: String?,
|
||||
direction: Direction,
|
||||
attachment: Attachment?,
|
||||
hInset: CGFloat,
|
||||
maxWidth: CGFloat
|
||||
) {
|
||||
// There's quite a bit of calculation going on here. It's a bit complex so don't make changes
|
||||
// if you don't need to. If you do then test:
|
||||
// • Quoted text in both private chats and group chats
|
||||
// • Quoted images and videos in both private chats and group chats
|
||||
// • Quoted voice messages and documents in both private chats and group chats
|
||||
// • All of the above in both dark mode and light mode
|
||||
let hasAttachments = !attachments.isEmpty
|
||||
let thumbnailSize = QuoteView.thumbnailSize
|
||||
let iconSize = QuoteView.iconSize
|
||||
let labelStackViewSpacing = QuoteView.labelStackViewSpacing
|
||||
|
@ -120,18 +87,23 @@ final class QuoteView : UIView {
|
|||
let smallSpacing = Values.smallSpacing
|
||||
let cancelButtonSize = QuoteView.cancelButtonSize
|
||||
var availableWidth: CGFloat
|
||||
|
||||
// Subtract smallSpacing twice; once for the spacing in between the stack view elements and
|
||||
// once for the trailing margin.
|
||||
if !hasAttachments {
|
||||
if attachment == nil {
|
||||
availableWidth = maxWidth - 2 * hInset - Values.accentLineThickness - 2 * smallSpacing
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
availableWidth = maxWidth - 2 * hInset - thumbnailSize - 2 * smallSpacing
|
||||
}
|
||||
|
||||
if case .draft = mode {
|
||||
availableWidth -= cancelButtonSize
|
||||
}
|
||||
|
||||
let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude)
|
||||
var body = self.body
|
||||
var body: String? = quotedText
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [])
|
||||
mainStackView.axis = .horizontal
|
||||
|
@ -139,49 +111,126 @@ final class QuoteView : UIView {
|
|||
mainStackView.isLayoutMarginsRelativeArrangement = true
|
||||
mainStackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: smallSpacing)
|
||||
mainStackView.alignment = .center
|
||||
|
||||
// Content view
|
||||
let contentView = UIView()
|
||||
addSubview(contentView)
|
||||
contentView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self)
|
||||
contentView.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor).isActive = true
|
||||
|
||||
// Line view
|
||||
let lineColor: UIColor = {
|
||||
switch (mode, AppModeManager.shared.currentAppMode) {
|
||||
case (.regular, .light), (.draft, .light): return .black
|
||||
case (.regular, .dark): return (direction == .outgoing) ? .black : Colors.accent
|
||||
case (.draft, .dark): return Colors.accent
|
||||
}
|
||||
}()
|
||||
let lineView = UIView()
|
||||
lineView.backgroundColor = lineColor
|
||||
lineView.set(.width, to: Values.accentLineThickness)
|
||||
if !hasAttachments {
|
||||
mainStackView.addArrangedSubview(lineView)
|
||||
} else {
|
||||
let isAudio = MIMETypeUtil.isAudio(attachments.first!.contentType ?? "")
|
||||
let fallbackImageName = isAudio ? "attachment_audio" : "actionsheet_document_black"
|
||||
let fallbackImage = UIImage(named: fallbackImageName)?.withTint(.white)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
let imageView = UIImageView(image: thumbnail ?? fallbackImage)
|
||||
imageView.contentMode = (thumbnail != nil) ? .scaleAspectFill : .center
|
||||
|
||||
if let attachment: Attachment = attachment {
|
||||
let isAudio: Bool = MIMETypeUtil.isAudio(attachment.contentType)
|
||||
let fallbackImageName: String = (isAudio ? "attachment_audio" : "actionsheet_document_black")
|
||||
let imageView: UIImageView = UIImageView(
|
||||
image: UIImage(named: fallbackImageName)?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
.resizedImage(to: CGSize(width: iconSize, height: iconSize))
|
||||
)
|
||||
|
||||
attachment.thumbnail(
|
||||
size: .small,
|
||||
success: { image, _ in
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
imageView.image = image
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
imageView.image = image
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
},
|
||||
failure: {}
|
||||
)
|
||||
|
||||
imageView.tintColor = .white
|
||||
imageView.contentMode = .center
|
||||
imageView.backgroundColor = lineColor
|
||||
imageView.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
|
||||
imageView.layer.masksToBounds = true
|
||||
imageView.set(.width, to: thumbnailSize)
|
||||
imageView.set(.height, to: thumbnailSize)
|
||||
mainStackView.addArrangedSubview(imageView)
|
||||
|
||||
if (body ?? "").isEmpty {
|
||||
body = (thumbnail != nil) ? "Image" : (isAudio ? "Audio" : "Document")
|
||||
body = (attachment.isImage ?
|
||||
"Image" :
|
||||
(isAudio ? "Audio" : "Document")
|
||||
)
|
||||
}
|
||||
}
|
||||
else {
|
||||
mainStackView.addArrangedSubview(lineView)
|
||||
}
|
||||
|
||||
// Body label
|
||||
let textColor: UIColor = {
|
||||
guard mode != .draft else { return Colors.text }
|
||||
|
||||
switch (direction, AppModeManager.shared.currentAppMode) {
|
||||
case (.outgoing, .dark), (.incoming, .light): return .black
|
||||
default: return .white
|
||||
}
|
||||
}()
|
||||
let bodyLabel = UILabel()
|
||||
bodyLabel.numberOfLines = 0
|
||||
bodyLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
let isOutgoing = (direction == .outgoing)
|
||||
bodyLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
bodyLabel.attributedText = given(body) { MentionUtilities.highlightMentions(in: $0, isOutgoingMessage: isOutgoing, threadID: thread.uniqueId!, attributes: [:]) } ?? given(attachments.first?.contentType) { NSAttributedString(string: MIMETypeUtil.isAudio($0) ? "Audio" : "Document") } ?? NSAttributedString(string: "Document")
|
||||
bodyLabel.attributedText = body
|
||||
.map {
|
||||
MentionUtilities.highlightMentions(
|
||||
in: $0,
|
||||
threadVariant: threadVariant,
|
||||
currentUserPublicKey: currentUserPublicKey,
|
||||
currentUserBlindedPublicKey: currentUserBlindedPublicKey,
|
||||
isOutgoingMessage: isOutgoing,
|
||||
attributes: [:]
|
||||
)
|
||||
}
|
||||
.defaulting(
|
||||
to: attachment.map {
|
||||
NSAttributedString(string: MIMETypeUtil.isAudio($0.contentType) ? "Audio" : "Document")
|
||||
}
|
||||
)
|
||||
.defaulting(to: NSAttributedString(string: "Document"))
|
||||
bodyLabel.textColor = textColor
|
||||
|
||||
let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace)
|
||||
|
||||
// Label stack view
|
||||
var authorLabelHeight: CGFloat?
|
||||
if let groupThread = thread as? TSGroupThread {
|
||||
if threadVariant == .openGroup || threadVariant == .closedGroup {
|
||||
let isCurrentUser: Bool = [
|
||||
currentUserPublicKey,
|
||||
currentUserBlindedPublicKey,
|
||||
]
|
||||
.compactMap { $0 }
|
||||
.asSet()
|
||||
.contains(authorId)
|
||||
let authorLabel = UILabel()
|
||||
authorLabel.lineBreakMode = .byTruncatingTail
|
||||
let context: Contact.Context = groupThread.isOpenGroup ? .openGroup : .regular
|
||||
authorLabel.text = Storage.shared.getContact(with: authorID)?.displayName(for: context) ?? authorID
|
||||
authorLabel.text = (isCurrentUser ?
|
||||
"MEDIA_GALLERY_SENDER_NAME_YOU".localized() :
|
||||
Profile.displayName(
|
||||
id: authorId,
|
||||
threadVariant: threadVariant
|
||||
)
|
||||
)
|
||||
authorLabel.textColor = textColor
|
||||
authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace)
|
||||
|
@ -195,51 +244,56 @@ final class QuoteView : UIView {
|
|||
labelStackView.isLayoutMarginsRelativeArrangement = true
|
||||
labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0)
|
||||
mainStackView.addArrangedSubview(labelStackView)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
mainStackView.addArrangedSubview(bodyLabel)
|
||||
}
|
||||
|
||||
// Cancel button
|
||||
let cancelButton = UIButton(type: .custom)
|
||||
let tint: UIColor = isLightMode ? .black : .white
|
||||
cancelButton.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal)
|
||||
cancelButton.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: UIControl.State.normal)
|
||||
cancelButton.tintColor = (isLightMode ? .black : .white)
|
||||
cancelButton.set(.width, to: cancelButtonSize)
|
||||
cancelButton.set(.height, to: cancelButtonSize)
|
||||
cancelButton.addTarget(self, action: #selector(cancel), for: UIControl.Event.touchUpInside)
|
||||
|
||||
// Constraints
|
||||
contentView.addSubview(mainStackView)
|
||||
mainStackView.pin(to: contentView)
|
||||
if !thread.isGroupThread() {
|
||||
|
||||
if threadVariant != .openGroup && threadVariant != .closedGroup {
|
||||
bodyLabel.set(.width, to: bodyLabelSize.width)
|
||||
}
|
||||
let bodyLabelHeight = bodyLabelSize.height.clamp(0, maxBodyLabelHeight)
|
||||
|
||||
let bodyLabelHeight = bodyLabelSize.height.clamp(0, (mode == .regular ? 60 : 40))
|
||||
let contentViewHeight: CGFloat
|
||||
if hasAttachments {
|
||||
|
||||
if attachment != nil {
|
||||
contentViewHeight = thumbnailSize + 8 // Add a small amount of spacing above and below the thumbnail
|
||||
bodyLabel.set(.height, to: 18) // Experimentally determined
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
if let authorLabelHeight = authorLabelHeight { // Group thread
|
||||
contentViewHeight = bodyLabelHeight + (authorLabelHeight + labelStackViewSpacing) + 2 * labelStackViewVMargin
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
contentViewHeight = bodyLabelHeight + 2 * smallSpacing
|
||||
}
|
||||
}
|
||||
|
||||
contentView.set(.height, to: contentViewHeight)
|
||||
lineView.set(.height, to: contentViewHeight - 8) // Add a small amount of spacing above and below the line
|
||||
if case .draft = mode {
|
||||
|
||||
if mode == .draft {
|
||||
addSubview(cancelButton)
|
||||
cancelButton.center(.vertical, in: self)
|
||||
cancelButton.pin(.right, to: .right, of: self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func cancel() {
|
||||
delegate?.handleQuoteViewCancelButtonTapped()
|
||||
onCancel?()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
protocol QuoteViewDelegate {
|
||||
|
||||
func handleQuoteViewCancelButtonTapped()
|
||||
}
|
||||
|
|
|
@ -1,5 +1,19 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class ReactionContainerView : UIView {
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
final class ReactionContainerView: UIView {
|
||||
var showingAllReactions = false
|
||||
private var isOutgoingMessage = false
|
||||
private var showNumbers = true
|
||||
private var maxEmojisPerLine = isIPhone6OrSmaller ? 5 : 6
|
||||
|
||||
var reactions: [ReactionViewModel] = []
|
||||
var reactionViews: [ReactionButton] = []
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var mainStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ reactionContainerView ])
|
||||
result.axis = .vertical
|
||||
|
@ -16,14 +30,8 @@ final class ReactionContainerView : UIView {
|
|||
return result
|
||||
}()
|
||||
|
||||
var showingAllReactions = false
|
||||
private var isOutgoingMessage = false
|
||||
private var showNumbers = true
|
||||
private var maxEmojisPerLine = isIPhone6OrSmaller ? 5 : 6
|
||||
|
||||
var reactions: [ReactionViewModel] = []
|
||||
var reactionViews: [ReactionButton] = []
|
||||
var expandButton: ExpandingReactionButton?
|
||||
|
||||
var collapseButton: UIStackView = {
|
||||
let arrow = UIImageView(image: UIImage(named: "ic_chevron_up")?.resizedImage(to: CGSize(width: 15, height: 13))?.withRenderingMode(.alwaysTemplate))
|
||||
arrow.tintColor = Colors.text
|
||||
|
@ -39,7 +47,8 @@ final class ReactionContainerView : UIView {
|
|||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init() {
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
|
@ -55,6 +64,7 @@ final class ReactionContainerView : UIView {
|
|||
|
||||
private func setUpViewHierarchy() {
|
||||
addSubview(mainStackView)
|
||||
|
||||
mainStackView.pin(to: self)
|
||||
}
|
||||
|
||||
|
@ -62,10 +72,13 @@ final class ReactionContainerView : UIView {
|
|||
self.reactions = reactions
|
||||
self.isOutgoingMessage = isOutgoingMessage
|
||||
self.showNumbers = showNumbers
|
||||
|
||||
prepareForUpdate()
|
||||
|
||||
if showingAllReactions {
|
||||
updateAllReactions()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
updateCollapsedReactions(reactions)
|
||||
}
|
||||
}
|
||||
|
@ -75,10 +88,12 @@ final class ReactionContainerView : UIView {
|
|||
stackView.axis = .horizontal
|
||||
stackView.spacing = Values.smallSpacing
|
||||
stackView.alignment = .center
|
||||
|
||||
if isOutgoingMessage {
|
||||
stackView.semanticContentAttribute = .forceRightToLeft
|
||||
reactionContainerView.semanticContentAttribute = .forceRightToLeft
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
stackView.semanticContentAttribute = .unspecified
|
||||
reactionContainerView.semanticContentAttribute = .unspecified
|
||||
}
|
||||
|
@ -88,8 +103,10 @@ final class ReactionContainerView : UIView {
|
|||
|
||||
if reactions.count > maxEmojisPerLine {
|
||||
displayedReactions = Array(reactions[0...(maxEmojisPerLine - 3)])
|
||||
expandButtonReactions = Array(reactions[(maxEmojisPerLine - 2)...maxEmojisPerLine]).map{ $0.emoji }
|
||||
} else {
|
||||
expandButtonReactions = Array(reactions[(maxEmojisPerLine - 2)...maxEmojisPerLine])
|
||||
.map { $0.emoji }
|
||||
}
|
||||
else {
|
||||
displayedReactions = reactions
|
||||
expandButtonReactions = []
|
||||
}
|
||||
|
@ -99,29 +116,39 @@ final class ReactionContainerView : UIView {
|
|||
stackView.addArrangedSubview(reactionView)
|
||||
reactionViews.append(reactionView)
|
||||
}
|
||||
|
||||
if expandButtonReactions.count > 0 {
|
||||
expandButton = ExpandingReactionButton(emojis: expandButtonReactions)
|
||||
stackView.addArrangedSubview(expandButton!)
|
||||
} else {
|
||||
let expandButton: ExpandingReactionButton = ExpandingReactionButton(emojis: expandButtonReactions)
|
||||
stackView.addArrangedSubview(expandButton)
|
||||
|
||||
self.expandButton = expandButton
|
||||
}
|
||||
else {
|
||||
expandButton = nil
|
||||
}
|
||||
|
||||
reactionContainerView.addArrangedSubview(stackView)
|
||||
}
|
||||
|
||||
private func updateAllReactions() {
|
||||
var reactions = self.reactions
|
||||
var numberOfLines = 0
|
||||
|
||||
while reactions.count > 0 {
|
||||
var line: [ReactionViewModel] = []
|
||||
|
||||
while reactions.count > 0 && line.count < maxEmojisPerLine {
|
||||
line.append(reactions.removeFirst())
|
||||
}
|
||||
|
||||
updateCollapsedReactions(line)
|
||||
numberOfLines += 1
|
||||
}
|
||||
|
||||
if numberOfLines > 1 {
|
||||
mainStackView.addArrangedSubview(collapseButton)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
showingAllReactions = false
|
||||
}
|
||||
}
|
||||
|
@ -131,6 +158,7 @@ final class ReactionContainerView : UIView {
|
|||
reactionContainerView.removeArrangedSubview(subview)
|
||||
subview.removeFromSuperview()
|
||||
}
|
||||
|
||||
mainStackView.removeArrangedSubview(collapseButton)
|
||||
collapseButton.removeFromSuperview()
|
||||
reactionViews = []
|
||||
|
@ -138,12 +166,14 @@ final class ReactionContainerView : UIView {
|
|||
|
||||
public func showAllEmojis() {
|
||||
guard !showingAllReactions else { return }
|
||||
|
||||
showingAllReactions = true
|
||||
update(reactions, isOutgoingMessage: isOutgoingMessage, showNumbers: showNumbers)
|
||||
}
|
||||
|
||||
public func showLessEmojis() {
|
||||
guard showingAllReactions else { return }
|
||||
|
||||
showingAllReactions = false
|
||||
update(reactions, isOutgoingMessage: isOutgoingMessage, showNumbers: showNumbers)
|
||||
}
|
||||
|
|
|
@ -1,32 +1,32 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
public struct ReactionViewModel: Hashable {
|
||||
let emoji: EmojiWithSkinTones
|
||||
let number: Int
|
||||
let showBorder: Bool
|
||||
|
||||
init(emoji: EmojiWithSkinTones, value: Int, showBorder: Bool) {
|
||||
self.emoji = emoji
|
||||
self.number = value
|
||||
self.showBorder = showBorder
|
||||
}
|
||||
}
|
||||
|
||||
final class ReactionButton: UIView {
|
||||
let viewModel: ReactionViewModel
|
||||
let showNumber: Bool
|
||||
|
||||
// MARK: Settings
|
||||
// MARK: - Settings
|
||||
|
||||
private var height: CGFloat = 22
|
||||
private var fontSize: CGFloat = Values.verySmallFontSize
|
||||
|
||||
private var spacing: CGFloat = Values.verySmallSpacing
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(viewModel: ReactionViewModel, showNumber: Bool = true) {
|
||||
self.viewModel = viewModel
|
||||
self.showNumber = showNumber
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
@ -73,14 +73,18 @@ final class ReactionButton: UIView {
|
|||
final class ExpandingReactionButton: UIView {
|
||||
private let emojis: [EmojiWithSkinTones]
|
||||
|
||||
// MARK: Settings
|
||||
// MARK: - Settings
|
||||
|
||||
private let size: CGFloat = 22
|
||||
private let margin: CGFloat = 15
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(emojis: [EmojiWithSkinTones]) {
|
||||
self.emojis = emojis
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
|
@ -94,6 +98,7 @@ final class ExpandingReactionButton: UIView {
|
|||
|
||||
private func setUpViewHierarchy() {
|
||||
var rightMargin: CGFloat = 0
|
||||
|
||||
for emoji in self.emojis.reversed() {
|
||||
let container = UIView()
|
||||
container.set(.width, to: size)
|
||||
|
@ -101,7 +106,8 @@ final class ExpandingReactionButton: UIView {
|
|||
container.backgroundColor = Colors.receivedMessageBackground
|
||||
container.layer.cornerRadius = size / 2
|
||||
container.layer.borderWidth = 1
|
||||
container.layer.borderColor = isDarkMode ? UIColor.black.cgColor : UIColor.white.cgColor
|
||||
// FIXME: This is going to have issues when swapping between light/dark mode
|
||||
container.layer.borderColor = (isDarkMode ? UIColor.black.cgColor : UIColor.white.cgColor)
|
||||
|
||||
let emojiLabel = UILabel()
|
||||
emojiLabel.text = emoji.rawValue
|
||||
|
|
|
@ -1,78 +1,84 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import NVActivityIndicatorView
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
@objc(SNVoiceMessageView)
|
||||
public final class VoiceMessageView : UIView {
|
||||
private let viewItem: ConversationViewItem
|
||||
private var isShowingSpeedUpLabel = false
|
||||
@objc var progress: Int = 0 { didSet { handleProgressChanged() } }
|
||||
@objc var isPlaying = false { didSet { handleIsPlayingChanged() } }
|
||||
|
||||
public final class VoiceMessageView: UIView {
|
||||
private static let width: CGFloat = 160
|
||||
private static let toggleContainerSize: CGFloat = 20
|
||||
private static let inset = Values.smallSpacing
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var progressViewRightConstraint = progressView.pin(.right, to: .right, of: self, withInset: -VoiceMessageView.width)
|
||||
|
||||
private var attachment: TSAttachment? { viewItem.attachmentStream ?? viewItem.attachmentPointer }
|
||||
private var duration: Int { Int(viewItem.audioDurationSeconds) }
|
||||
|
||||
// MARK: UI Components
|
||||
|
||||
private lazy var progressView: UIView = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = UIColor.black.withAlphaComponent(0.2)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var toggleImageView: UIImageView = {
|
||||
let result = UIImageView(image: UIImage(named: "Play"))
|
||||
let result: UIImageView = UIImageView(image: UIImage(named: "Play"))
|
||||
result.contentMode = .scaleAspectFit
|
||||
result.set(.width, to: 8)
|
||||
result.set(.height, to: 8)
|
||||
result.contentMode = .scaleAspectFit
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var loader: NVActivityIndicatorView = {
|
||||
let result = NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: Colors.text, padding: nil)
|
||||
let result: NVActivityIndicatorView = NVActivityIndicatorView(
|
||||
frame: .zero,
|
||||
type: .circleStrokeSpin,
|
||||
color: Colors.text,
|
||||
padding: nil
|
||||
)
|
||||
result.set(.width, to: VoiceMessageView.toggleContainerSize + 2)
|
||||
result.set(.height, to: VoiceMessageView.toggleContainerSize + 2)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var countdownLabelContainer: UIView = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = .white
|
||||
result.layer.masksToBounds = true
|
||||
result.set(.height, to: VoiceMessageView.toggleContainerSize)
|
||||
result.set(.width, to: 44)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var countdownLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = .black
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.text = "0:00"
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var speedUpLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = .black
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.alpha = 0
|
||||
result.text = "1.5x"
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Settings
|
||||
private static let width: CGFloat = 160
|
||||
private static let toggleContainerSize: CGFloat = 20
|
||||
private static let inset = Values.smallSpacing
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem) {
|
||||
self.viewItem = viewItem
|
||||
self.progress = Int(viewItem.audioProgressSeconds)
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init() {
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
handleProgressChanged()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
|
@ -86,27 +92,33 @@ public final class VoiceMessageView : UIView {
|
|||
private func setUpViewHierarchy() {
|
||||
let toggleContainerSize = VoiceMessageView.toggleContainerSize
|
||||
let inset = VoiceMessageView.inset
|
||||
|
||||
// Width & height
|
||||
set(.width, to: VoiceMessageView.width)
|
||||
|
||||
// Toggle
|
||||
let toggleContainer = UIView()
|
||||
let toggleContainer: UIView = UIView()
|
||||
toggleContainer.backgroundColor = .white
|
||||
toggleContainer.set(.width, to: toggleContainerSize)
|
||||
toggleContainer.set(.height, to: toggleContainerSize)
|
||||
toggleContainer.addSubview(toggleImageView)
|
||||
toggleImageView.center(in: toggleContainer)
|
||||
toggleContainer.layer.cornerRadius = toggleContainerSize / 2
|
||||
toggleContainer.layer.cornerRadius = (toggleContainerSize / 2)
|
||||
toggleContainer.layer.masksToBounds = true
|
||||
|
||||
// Line
|
||||
let lineView = UIView()
|
||||
lineView.backgroundColor = .white
|
||||
lineView.set(.height, to: 1)
|
||||
|
||||
// Countdown label
|
||||
countdownLabelContainer.addSubview(countdownLabel)
|
||||
countdownLabel.center(in: countdownLabelContainer)
|
||||
|
||||
// Speed up label
|
||||
countdownLabelContainer.addSubview(speedUpLabel)
|
||||
speedUpLabel.center(in: countdownLabelContainer)
|
||||
|
||||
// Constraints
|
||||
addSubview(progressView)
|
||||
progressView.pin(.left, to: .left, of: self)
|
||||
|
@ -114,60 +126,73 @@ public final class VoiceMessageView : UIView {
|
|||
progressViewRightConstraint.isActive = true
|
||||
progressView.pin(.bottom, to: .bottom, of: self)
|
||||
addSubview(toggleContainer)
|
||||
|
||||
toggleContainer.pin(.left, to: .left, of: self, withInset: inset)
|
||||
toggleContainer.pin(.top, to: .top, of: self, withInset: inset)
|
||||
toggleContainer.pin(.bottom, to: .bottom, of: self, withInset: -inset)
|
||||
addSubview(lineView)
|
||||
|
||||
lineView.pin(.left, to: .right, of: toggleContainer)
|
||||
lineView.center(.vertical, in: self)
|
||||
addSubview(countdownLabelContainer)
|
||||
|
||||
countdownLabelContainer.pin(.left, to: .right, of: lineView)
|
||||
countdownLabelContainer.pin(.right, to: .right, of: self, withInset: -inset)
|
||||
countdownLabelContainer.center(.vertical, in: self)
|
||||
|
||||
addSubview(loader)
|
||||
loader.center(in: toggleContainer)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
countdownLabelContainer.layer.cornerRadius = countdownLabelContainer.bounds.height / 2
|
||||
|
||||
countdownLabelContainer.layer.cornerRadius = (countdownLabelContainer.bounds.height / 2)
|
||||
}
|
||||
|
||||
private func handleIsPlayingChanged() {
|
||||
toggleImageView.image = isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play")
|
||||
if !isPlaying { progress = 0 }
|
||||
}
|
||||
|
||||
private func handleProgressChanged() {
|
||||
let isDownloaded = (attachment?.isDownloaded == true)
|
||||
loader.isHidden = isDownloaded
|
||||
if isDownloaded { loader.stopAnimating() } else if !loader.isAnimating { loader.startAnimating() }
|
||||
guard isDownloaded else { return }
|
||||
countdownLabel.text = OWSFormat.formatDurationSeconds(duration - progress)
|
||||
guard viewItem.audioProgressSeconds > 0 && viewItem.audioDurationSeconds > 0 else {
|
||||
return progressViewRightConstraint.constant = -VoiceMessageView.width
|
||||
}
|
||||
let fraction = viewItem.audioProgressSeconds / viewItem.audioDurationSeconds
|
||||
progressViewRightConstraint.constant = -(VoiceMessageView.width * (1 - fraction))
|
||||
}
|
||||
|
||||
func showSpeedUpLabel() {
|
||||
guard !isShowingSpeedUpLabel else { return }
|
||||
isShowingSpeedUpLabel = true
|
||||
UIView.animate(withDuration: 0.25) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.countdownLabel.alpha = 0
|
||||
self.speedUpLabel.alpha = 1
|
||||
}
|
||||
Timer.scheduledTimer(withTimeInterval: 1.25, repeats: false) { [weak self] _ in
|
||||
UIView.animate(withDuration: 0.25, animations: {
|
||||
guard let self = self else { return }
|
||||
self.countdownLabel.alpha = 1
|
||||
self.speedUpLabel.alpha = 0
|
||||
}, completion: { _ in
|
||||
self?.isShowingSpeedUpLabel = false
|
||||
})
|
||||
// MARK: - Updating
|
||||
|
||||
public func update(
|
||||
with attachment: Attachment,
|
||||
isPlaying: Bool,
|
||||
progress: TimeInterval,
|
||||
playbackRate: Double,
|
||||
oldPlaybackRate: Double
|
||||
) {
|
||||
switch attachment.state {
|
||||
case .downloaded, .uploaded:
|
||||
loader.isHidden = true
|
||||
loader.stopAnimating()
|
||||
|
||||
toggleImageView.image = (isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play"))
|
||||
countdownLabel.text = OWSFormat.formatDurationSeconds(max(0, Int(floor(attachment.duration.defaulting(to: 0) - progress))))
|
||||
|
||||
guard let duration: TimeInterval = attachment.duration, duration > 0, progress > 0 else {
|
||||
return progressViewRightConstraint.constant = -VoiceMessageView.width
|
||||
}
|
||||
|
||||
let fraction: Double = (progress / duration)
|
||||
progressViewRightConstraint.constant = -(VoiceMessageView.width * (1 - fraction))
|
||||
|
||||
// If the playback rate changed then show the 'speedUpLabel' briefly
|
||||
guard playbackRate > oldPlaybackRate else { return }
|
||||
|
||||
UIView.animate(withDuration: 0.25) { [weak self] in
|
||||
self?.countdownLabel.alpha = 0
|
||||
self?.speedUpLabel.alpha = 1
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1250)) {
|
||||
UIView.animate(withDuration: 0.25) { [weak self] in
|
||||
self?.countdownLabel.alpha = 1
|
||||
self?.speedUpLabel.alpha = 0
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
if !loader.isAnimating {
|
||||
loader.startAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,72 +1,93 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class InfoMessageCell : MessageCell {
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class InfoMessageCell: MessageCell {
|
||||
private static let iconSize: CGFloat = 16
|
||||
private static let inset = Values.mediumSpacing
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: InfoMessageCell.iconSize)
|
||||
private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: InfoMessageCell.iconSize)
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var iconImageView = UIImageView()
|
||||
|
||||
private lazy var iconImageView: UIImageView = UIImageView()
|
||||
|
||||
private lazy var label: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.numberOfLines = 0
|
||||
result.lineBreakMode = .byWordWrapping
|
||||
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.textAlignment = .center
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
||||
private lazy var stackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ iconImageView, label ])
|
||||
let result: UIStackView = UIStackView(arrangedSubviews: [ iconImageView, label ])
|
||||
result.axis = .vertical
|
||||
result.alignment = .center
|
||||
result.spacing = Values.smallSpacing
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - 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() {
|
||||
super.setUpViewHierarchy()
|
||||
|
||||
iconImageViewWidthConstraint.isActive = true
|
||||
iconImageViewHeightConstraint.isActive = true
|
||||
addSubview(stackView)
|
||||
|
||||
stackView.pin(.left, to: .left, of: self, withInset: InfoMessageCell.inset)
|
||||
stackView.pin(.top, to: .top, of: self, withInset: InfoMessageCell.inset)
|
||||
stackView.pin(.right, to: .right, of: self, withInset: -InfoMessageCell.inset)
|
||||
stackView.pin(.bottom, to: .bottom, of: self, withInset: -InfoMessageCell.inset)
|
||||
}
|
||||
|
||||
// MARK: - Updating
|
||||
|
||||
// MARK: Updating
|
||||
override func update() {
|
||||
guard let message = viewItem?.interaction as? TSInfoMessage else { return }
|
||||
let icon: UIImage?
|
||||
switch message.messageType {
|
||||
case .disappearingMessagesUpdate:
|
||||
var configuration: OWSDisappearingMessagesConfiguration?
|
||||
Storage.read { transaction in
|
||||
configuration = message.thread(with: transaction).disappearingMessagesConfiguration(with: transaction)
|
||||
override func update(
|
||||
with cellViewModel: MessageViewModel,
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||
showExpandedReactions: Bool,
|
||||
lastSearchText: String?
|
||||
) {
|
||||
guard cellViewModel.variant.isInfoMessage else { return }
|
||||
|
||||
self.viewModel = cellViewModel
|
||||
|
||||
let icon: UIImage? = {
|
||||
switch cellViewModel.variant {
|
||||
case .infoDisappearingMessagesUpdate:
|
||||
return (cellViewModel.threadHasDisappearingMessagesEnabled ?
|
||||
UIImage(named: "ic_timer") :
|
||||
UIImage(named: "ic_timer_disabled")
|
||||
)
|
||||
|
||||
case .infoMediaSavedNotification: return UIImage(named: "ic_download")
|
||||
|
||||
default: return nil
|
||||
}
|
||||
if let configuration = configuration {
|
||||
icon = configuration.isEnabled ? UIImage(named: "ic_timer") : UIImage(named: "ic_timer_disabled")
|
||||
} else {
|
||||
icon = nil
|
||||
}
|
||||
case .mediaSavedNotification: icon = UIImage(named: "ic_download")
|
||||
default: icon = nil
|
||||
}
|
||||
}()
|
||||
|
||||
if let icon = icon {
|
||||
iconImageView.image = icon.withTint(Colors.text)
|
||||
iconImageView.image = icon.withRenderingMode(.alwaysTemplate)
|
||||
iconImageView.tintColor = Colors.text
|
||||
}
|
||||
|
||||
iconImageViewWidthConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0
|
||||
iconImageViewHeightConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0
|
||||
Storage.read { transaction in
|
||||
self.label.text = message.previewText(with: transaction)
|
||||
}
|
||||
|
||||
self.label.text = cellViewModel.body
|
||||
}
|
||||
|
||||
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
|
@ -7,79 +9,87 @@ public enum SwipeState {
|
|||
case cancelled
|
||||
}
|
||||
|
||||
class MessageCell : UITableViewCell {
|
||||
public class MessageCell: UITableViewCell {
|
||||
weak var delegate: MessageCellDelegate?
|
||||
var thread: TSThread? {
|
||||
didSet {
|
||||
if viewItem != nil { update() }
|
||||
}
|
||||
}
|
||||
var viewItem: ConversationViewItem? {
|
||||
didSet {
|
||||
if thread != nil { update() }
|
||||
}
|
||||
}
|
||||
var viewModel: MessageViewModel?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
// MARK: Settings
|
||||
class var identifier: String { preconditionFailure("Must be overridden by subclasses.") }
|
||||
|
||||
// MARK: Lifecycle
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
setUpViewHierarchy()
|
||||
setUpGestureRecognizers()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
setUpViewHierarchy()
|
||||
setUpGestureRecognizers()
|
||||
}
|
||||
|
||||
func setUpViewHierarchy() {
|
||||
backgroundColor = .clear
|
||||
|
||||
let selectedBackgroundView = UIView()
|
||||
selectedBackgroundView.backgroundColor = .clear
|
||||
self.selectedBackgroundView = selectedBackgroundView
|
||||
}
|
||||
|
||||
|
||||
func setUpGestureRecognizers() {
|
||||
// To be overridden by subclasses
|
||||
}
|
||||
|
||||
// MARK: - Updating
|
||||
|
||||
// MARK: Updating
|
||||
func update() {
|
||||
func update(
|
||||
with cellViewModel: MessageViewModel,
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||
showExpandedReactions: Bool,
|
||||
lastSearchText: String?
|
||||
) {
|
||||
preconditionFailure("Must be overridden by subclasses.")
|
||||
}
|
||||
|
||||
// MARK: Convenience
|
||||
static func getCellType(for viewItem: ConversationViewItem) -> MessageCell.Type {
|
||||
switch viewItem.interaction {
|
||||
case is TSIncomingMessage: fallthrough
|
||||
case is TSOutgoingMessage: return VisibleMessageCell.self
|
||||
case is TSInfoMessage:
|
||||
if let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call {
|
||||
/// This is a cut-down version of the 'update' function which doesn't re-create the UI (it should be used for dynamically-updating content
|
||||
/// like playing inline audio/video)
|
||||
func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||
preconditionFailure("Must be overridden by subclasses.")
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
static func cellType(for viewModel: MessageViewModel) -> MessageCell.Type {
|
||||
guard viewModel.cellType != .typingIndicator else { return TypingIndicatorCell.self }
|
||||
|
||||
switch viewModel.variant {
|
||||
case .standardOutgoing, .standardIncoming, .standardIncomingDeleted:
|
||||
return VisibleMessageCell.self
|
||||
|
||||
case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft,
|
||||
.infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification,
|
||||
.infoMessageRequestAccepted:
|
||||
return InfoMessageCell.self
|
||||
|
||||
case .infoCall:
|
||||
return CallMessageCell.self
|
||||
}
|
||||
return InfoMessageCell.self
|
||||
case is TypingIndicatorInteraction: return TypingIndicatorCell.self
|
||||
default: preconditionFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol MessageCellDelegate : ReactionDelegate {
|
||||
var lastSearchedText: String? { get }
|
||||
|
||||
func getMediaCache() -> NSCache<NSString, AnyObject>
|
||||
func handleViewItemLongPressed(_ viewItem: ConversationViewItem)
|
||||
func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer)
|
||||
func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem)
|
||||
func handleViewItemSwiped(_ viewItem: ConversationViewItem, state: SwipeState)
|
||||
func showFullText(_ viewItem: ConversationViewItem)
|
||||
func openURL(_ url: URL)
|
||||
func handleReplyButtonTapped(for viewItem: ConversationViewItem)
|
||||
func showUserDetails(for sessionID: String)
|
||||
func showReactionList(_ viewItem: ConversationViewItem, selectedReaction: EmojiWithSkinTones?)
|
||||
func needsLayout(for viewItem: ConversationViewItem, expandingReactions: Bool)
|
||||
// MARK: - MessageCellDelegate
|
||||
|
||||
protocol MessageCellDelegate: ReactionDelegate {
|
||||
func handleItemLongPressed(_ cellViewModel: MessageViewModel)
|
||||
func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer)
|
||||
func handleItemDoubleTapped(_ cellViewModel: MessageViewModel)
|
||||
func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState)
|
||||
func openUrl(_ urlString: String)
|
||||
func handleReplyButtonTapped(for cellViewModel: MessageViewModel)
|
||||
func showUserDetails(for profile: Profile)
|
||||
func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?)
|
||||
func showReactionList(_ cellViewModel: MessageViewModel, selectedReaction: EmojiWithSkinTones?)
|
||||
func needsLayout(for cellViewModel: MessageViewModel, expandingReactions: Bool)
|
||||
}
|
||||
|
|
|
@ -1,85 +1,100 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
// Assumptions
|
||||
// • We'll never encounter an outgoing typing indicator.
|
||||
// • Typing indicators are only sent in contact threads.
|
||||
|
||||
final class TypingIndicatorCell : MessageCell {
|
||||
|
||||
private var positionInCluster: Position? {
|
||||
guard let viewItem = viewItem else { return nil }
|
||||
if viewItem.isFirstInCluster { return .top }
|
||||
if viewItem.isLastInCluster { return .bottom }
|
||||
return .middle
|
||||
}
|
||||
final class TypingIndicatorCell: MessageCell {
|
||||
// MARK: - UI
|
||||
|
||||
private var isOnlyMessageInCluster: Bool { viewItem?.isFirstInCluster == true && viewItem?.isLastInCluster == true }
|
||||
|
||||
// MARK: UI Components
|
||||
private lazy var bubbleView: UIView = {
|
||||
let result = UIView()
|
||||
let result: UIView = UIView()
|
||||
result.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
|
||||
result.backgroundColor = Colors.receivedMessageBackground
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let bubbleViewMaskLayer = CAShapeLayer()
|
||||
private let bubbleViewMaskLayer: CAShapeLayer = CAShapeLayer()
|
||||
|
||||
private lazy var typingIndicatorView = TypingIndicatorView()
|
||||
private lazy var typingIndicatorView: TypingIndicatorView = TypingIndicatorView()
|
||||
|
||||
// MARK: Settings
|
||||
override class var identifier: String { "TypingIndicatorCell" }
|
||||
|
||||
// MARK: Direction & Position
|
||||
enum Position { case top, middle, bottom }
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func setUpViewHierarchy() {
|
||||
super.setUpViewHierarchy()
|
||||
|
||||
// Bubble view
|
||||
addSubview(bubbleView)
|
||||
bubbleView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing)
|
||||
bubbleView.pin(.top, to: .top, of: self, withInset: 1)
|
||||
|
||||
// Typing indicator view
|
||||
bubbleView.addSubview(typingIndicatorView)
|
||||
typingIndicatorView.pin(to: bubbleView, withInset: 12)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
override func update() {
|
||||
guard let viewItem = viewItem, viewItem.interaction is TypingIndicatorInteraction else { return }
|
||||
// MARK: - Updating
|
||||
|
||||
override func update(
|
||||
with cellViewModel: MessageViewModel,
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||
showExpandedReactions: Bool,
|
||||
lastSearchText: String?
|
||||
) {
|
||||
guard cellViewModel.cellType == .typingIndicator else { return }
|
||||
|
||||
self.viewModel = cellViewModel
|
||||
|
||||
// Bubble view
|
||||
updateBubbleViewCorners()
|
||||
|
||||
// Typing indicator view
|
||||
typingIndicatorView.startAnimation()
|
||||
}
|
||||
|
||||
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
updateBubbleViewCorners()
|
||||
}
|
||||
|
||||
private func updateBubbleViewCorners() {
|
||||
let maskPath = UIBezierPath(roundedRect: bubbleView.bounds, byRoundingCorners: getCornersToRound(),
|
||||
cornerRadii: CGSize(width: VisibleMessageCell.largeCornerRadius, height: VisibleMessageCell.largeCornerRadius))
|
||||
let maskPath = UIBezierPath(
|
||||
roundedRect: bubbleView.bounds,
|
||||
byRoundingCorners: getCornersToRound(),
|
||||
cornerRadii: CGSize(
|
||||
width: VisibleMessageCell.largeCornerRadius,
|
||||
height: VisibleMessageCell.largeCornerRadius)
|
||||
)
|
||||
|
||||
bubbleViewMaskLayer.path = maskPath.cgPath
|
||||
bubbleView.layer.mask = bubbleViewMaskLayer
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
typingIndicatorView.stopAnimation()
|
||||
}
|
||||
|
||||
// MARK: Convenience
|
||||
// MARK: - Convenience
|
||||
|
||||
private func getCornersToRound() -> UIRectCorner {
|
||||
guard !isOnlyMessageInCluster else { return .allCorners }
|
||||
let result: UIRectCorner
|
||||
switch positionInCluster {
|
||||
case .top: result = [ .topLeft, .topRight, .bottomRight ]
|
||||
case .middle: result = [ .topRight, .bottomRight ]
|
||||
case .bottom: result = [ .topRight, .bottomRight, .bottomLeft ]
|
||||
case nil: result = .allCorners
|
||||
guard viewModel?.isOnlyMessageInCluster == false else { return .allCorners }
|
||||
|
||||
switch viewModel?.positionInCluster {
|
||||
case .top: return [ .topLeft, .topRight, .bottomRight ]
|
||||
case .middle: return [ .topRight, .bottomRight ]
|
||||
case .bottom: return [ .topRight, .bottomRight, .bottomLeft ]
|
||||
case .none: return .allCorners
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -17,7 +17,7 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
|
||||
@property (nonatomic) BOOL showVerificationOnAppear;
|
||||
|
||||
- (void)configureWithThread:(TSThread *)thread uiDatabaseConnection:(YapDatabaseConnection *)uiDatabaseConnection;
|
||||
- (void)configureWithThreadId:(NSString *)threadId threadName:(NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf;
|
||||
|
||||
@end
|
||||
|
||||
|
|
|
@ -9,17 +9,8 @@
|
|||
#import "UIView+OWS.h"
|
||||
#import <Curve25519Kit/Curve25519.h>
|
||||
#import <SignalCoreKit/NSDate+OWS.h>
|
||||
#import <SessionMessagingKit/Environment.h>
|
||||
#import <SignalUtilitiesKit/OWSProfileManager.h>
|
||||
#import <SessionMessagingKit/OWSSounds.h>
|
||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||
#import <SignalUtilitiesKit/UIUtil.h>
|
||||
#import <SessionMessagingKit/OWSDisappearingConfigurationUpdateInfoMessage.h>
|
||||
#import <SessionMessagingKit/OWSDisappearingMessagesConfiguration.h>
|
||||
#import <SessionMessagingKit/OWSPrimaryStorage.h>
|
||||
#import <SessionMessagingKit/TSGroupThread.h>
|
||||
#import <SessionMessagingKit/TSOutgoingMessage.h>
|
||||
#import <SessionMessagingKit/TSThread.h>
|
||||
|
||||
@import ContactsUI;
|
||||
@import PromiseKit;
|
||||
|
@ -30,12 +21,18 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
@interface OWSConversationSettingsViewController () <OWSSheetViewControllerDelegate>
|
||||
|
||||
@property (nonatomic) TSThread *thread;
|
||||
@property (nonatomic) YapDatabaseConnection *uiDatabaseConnection;
|
||||
@property (nonatomic, readonly) YapDatabaseConnection *editingDatabaseConnection;
|
||||
@property (nonatomic) NSString *threadId;
|
||||
@property (nonatomic) NSString *threadName;
|
||||
@property (nonatomic) BOOL isNoteToSelf;
|
||||
@property (nonatomic) BOOL isClosedGroup;
|
||||
@property (nonatomic) BOOL isOpenGroup;
|
||||
@property (nonatomic) NSArray<NSNumber *> *disappearingMessagesDurations;
|
||||
@property (nonatomic) OWSDisappearingMessagesConfiguration *disappearingMessagesConfiguration;
|
||||
@property (nullable, nonatomic) MediaGallery *mediaGallery;
|
||||
|
||||
@property (nonatomic) BOOL originalIsDisappearingMessagesEnabled;
|
||||
@property (nonatomic) NSInteger originalDisappearingMessagesDurationIndex;
|
||||
@property (nonatomic) BOOL isDisappearingMessagesEnabled;
|
||||
@property (nonatomic) NSInteger disappearingMessagesDurationIndex;
|
||||
|
||||
@property (nonatomic, readonly) UIImageView *avatarView;
|
||||
@property (nonatomic, readonly) UILabel *disappearingMessagesDurationLabel;
|
||||
@property (nonatomic) UILabel *displayNameLabel;
|
||||
|
@ -56,8 +53,6 @@ CGFloat kIconViewLength = 24;
|
|||
return self;
|
||||
}
|
||||
|
||||
[self commonInit];
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
|
@ -68,8 +63,6 @@ CGFloat kIconViewLength = 24;
|
|||
return self;
|
||||
}
|
||||
|
||||
[self commonInit];
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
|
@ -80,95 +73,24 @@ CGFloat kIconViewLength = 24;
|
|||
return self;
|
||||
}
|
||||
|
||||
[self commonInit];
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)commonInit
|
||||
{
|
||||
|
||||
[self observeNotifications];
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
#pragma mark - Dependencies
|
||||
|
||||
- (TSAccountManager *)tsAccountManager
|
||||
{
|
||||
OWSAssertDebug(SSKEnvironment.shared.tsAccountManager);
|
||||
|
||||
return SSKEnvironment.shared.tsAccountManager;
|
||||
}
|
||||
|
||||
- (OWSProfileManager *)profileManager
|
||||
{
|
||||
return [OWSProfileManager sharedManager];
|
||||
}
|
||||
|
||||
#pragma mark
|
||||
|
||||
- (void)observeNotifications
|
||||
{
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(identityStateDidChange:)
|
||||
name:kNSNotificationName_IdentityStateDidChange
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(otherUsersProfileDidChange:)
|
||||
name:kNSNotificationName_OtherUsersProfileDidChange
|
||||
object:nil];
|
||||
}
|
||||
|
||||
- (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];
|
||||
- (void)configureWithThreadId:(NSString *)threadId threadName:(NSString *)threadName isClosedGroup:(BOOL)isClosedGroup isOpenGroup:(BOOL)isOpenGroup isNoteToSelf:(BOOL)isNoteToSelf {
|
||||
self.threadId = threadId;
|
||||
self.threadName = threadName;
|
||||
self.isClosedGroup = isClosedGroup;
|
||||
self.isOpenGroup = isOpenGroup;
|
||||
self.isNoteToSelf = isNoteToSelf;
|
||||
|
||||
if (!isClosedGroup && !isOpenGroup) {
|
||||
self.threadName = [SMKProfile displayNameWithId:threadId customFallback:@"Anonymous"];
|
||||
}
|
||||
return threadName;
|
||||
}
|
||||
|
||||
- (BOOL)isGroupThread
|
||||
{
|
||||
return [self.thread isKindOfClass:[TSGroupThread class]];
|
||||
}
|
||||
|
||||
- (BOOL)isOpenGroup
|
||||
{
|
||||
if ([self isGroupThread]) {
|
||||
TSGroupThread *thread = (TSGroupThread *)self.thread;
|
||||
return thread.isOpenGroup;
|
||||
else {
|
||||
self.threadName = threadName;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
-(BOOL)isClosedGroup
|
||||
{
|
||||
if (self.isGroupThread) {
|
||||
TSGroupThread *thread = (TSGroupThread *)self.thread;
|
||||
return thread.groupModel.groupType == closedGroup;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
- (void)configureWithThread:(TSThread *)thread uiDatabaseConnection:(YapDatabaseConnection *)uiDatabaseConnection
|
||||
{
|
||||
OWSAssertDebug(thread);
|
||||
self.thread = thread;
|
||||
self.uiDatabaseConnection = uiDatabaseConnection;
|
||||
}
|
||||
|
||||
#pragma mark - ContactEditingDelegate
|
||||
|
@ -211,7 +133,7 @@ CGFloat kIconViewLength = 24;
|
|||
self.displayNameLabel.font = [UIFont boldSystemFontOfSize:LKValues.largeFontSize];
|
||||
self.displayNameLabel.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||
self.displayNameLabel.textAlignment = NSTextAlignmentCenter;
|
||||
|
||||
|
||||
self.displayNameTextField = [[SNTextField alloc] initWithPlaceholder:@"Enter a name" usesDefaultHeight:NO];
|
||||
self.displayNameTextField.textAlignment = NSTextAlignmentCenter;
|
||||
self.displayNameTextField.accessibilityLabel = @"Edit name text field";
|
||||
|
@ -220,46 +142,42 @@ CGFloat kIconViewLength = 24;
|
|||
self.displayNameContainer = [UIView new];
|
||||
self.displayNameContainer.accessibilityLabel = @"Edit name text field";
|
||||
self.displayNameContainer.isAccessibilityElement = YES;
|
||||
|
||||
|
||||
[self.displayNameContainer autoSetDimension:ALDimensionHeight toSize:40];
|
||||
[self.displayNameContainer addSubview:self.displayNameLabel];
|
||||
[self.displayNameLabel autoPinToEdgesOfView:self.displayNameContainer];
|
||||
[self.displayNameContainer addSubview:self.displayNameTextField];
|
||||
[self.displayNameTextField autoPinToEdgesOfView:self.displayNameContainer];
|
||||
|
||||
if ([self.thread isKindOfClass:TSContactThread.class]) {
|
||||
|
||||
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||
UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showEditNameUI)];
|
||||
[self.displayNameContainer addGestureRecognizer:tapGestureRecognizer];
|
||||
}
|
||||
|
||||
|
||||
self.tableView.estimatedRowHeight = 45;
|
||||
self.tableView.rowHeight = UITableViewAutomaticDimension;
|
||||
|
||||
_disappearingMessagesDurationLabel = [UILabel new];
|
||||
SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _disappearingMessagesDurationLabel);
|
||||
|
||||
self.disappearingMessagesDurations = [OWSDisappearingMessagesConfiguration validDurationsSeconds];
|
||||
|
||||
self.disappearingMessagesConfiguration =
|
||||
[OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:self.thread.uniqueId];
|
||||
|
||||
if (!self.disappearingMessagesConfiguration) {
|
||||
self.disappearingMessagesConfiguration =
|
||||
[[OWSDisappearingMessagesConfiguration alloc] initDefaultWithThreadId:self.thread.uniqueId];
|
||||
}
|
||||
|
||||
[self updateTableContents];
|
||||
self.disappearingMessagesDurations = [SMKDisappearingMessagesConfiguration validDurationsSeconds];
|
||||
self.isDisappearingMessagesEnabled = [SMKDisappearingMessagesConfiguration isEnabledFor: self.threadId];
|
||||
self.disappearingMessagesDurationIndex = [SMKDisappearingMessagesConfiguration durationIndexFor: self.threadId];
|
||||
self.originalIsDisappearingMessagesEnabled = self.isDisappearingMessagesEnabled;
|
||||
self.originalDisappearingMessagesDurationIndex = self.disappearingMessagesDurationIndex;
|
||||
|
||||
[self updateTableContents];
|
||||
|
||||
NSString *title;
|
||||
if ([self.thread isKindOfClass:[TSContactThread class]]) {
|
||||
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||
title = NSLocalizedString(@"Settings", @"");
|
||||
} else {
|
||||
title = NSLocalizedString(@"Group Settings", @"");
|
||||
}
|
||||
[LKViewControllerUtilities setUpDefaultSessionStyleForVC:self withTitle:title customBackButton:YES];
|
||||
self.tableView.backgroundColor = UIColor.clearColor;
|
||||
|
||||
if ([self.thread isKindOfClass:TSContactThread.class]) {
|
||||
|
||||
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||
[self updateNavBarButtons];
|
||||
}
|
||||
}
|
||||
|
@ -269,8 +187,6 @@ CGFloat kIconViewLength = 24;
|
|||
OWSTableContents *contents = [OWSTableContents new];
|
||||
contents.title = NSLocalizedString(@"CONVERSATION_SETTINGS", @"title for conversation settings screen");
|
||||
|
||||
BOOL isNoteToSelf = self.thread.isNoteToSelf;
|
||||
|
||||
__weak OWSConversationSettingsViewController *weakSelf = self;
|
||||
|
||||
OWSTableSection *section = [OWSTableSection new];
|
||||
|
@ -279,7 +195,7 @@ CGFloat kIconViewLength = 24;
|
|||
section.customHeaderHeight = @(UITableViewAutomaticDimension);
|
||||
|
||||
// Copy Session ID
|
||||
if ([self.thread isKindOfClass:TSContactThread.class]) {
|
||||
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
return [weakSelf
|
||||
disclosureCellWithName:NSLocalizedString(@"vc_conversation_settings_copy_session_id_button_title", "")
|
||||
|
@ -300,7 +216,7 @@ CGFloat kIconViewLength = 24;
|
|||
} actionBlock:^{
|
||||
[weakSelf showMediaGallery];
|
||||
}]];
|
||||
|
||||
|
||||
// Invite button
|
||||
if (self.isOpenGroup) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
|
@ -325,9 +241,9 @@ CGFloat kIconViewLength = 24;
|
|||
} actionBlock:^{
|
||||
[weakSelf tappedConversationSearch];
|
||||
}]];
|
||||
|
||||
|
||||
// Disappearing messages
|
||||
if (![self isOpenGroup] && !self.thread.isBlocked) {
|
||||
if (![self isOpenGroup] && ![SMKContact isBlockedFor:self.threadId]) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
UITableViewCell *cell = [OWSTableItem newCell];
|
||||
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
||||
|
@ -337,7 +253,7 @@ CGFloat kIconViewLength = 24;
|
|||
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
|
||||
NSString *iconName
|
||||
= (strongSelf.disappearingMessagesConfiguration.isEnabled ? @"ic_timer" : @"ic_timer_disabled");
|
||||
= (strongSelf.isDisappearingMessagesEnabled ? @"ic_timer" : @"ic_timer_disabled");
|
||||
UIImageView *iconView = [strongSelf viewForIconWithName:iconName];
|
||||
|
||||
UILabel *rowLabel = [UILabel new];
|
||||
|
@ -348,7 +264,7 @@ CGFloat kIconViewLength = 24;
|
|||
rowLabel.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||
|
||||
UISwitch *switchView = [UISwitch new];
|
||||
switchView.on = strongSelf.disappearingMessagesConfiguration.isEnabled;
|
||||
switchView.on = strongSelf.isDisappearingMessagesEnabled;
|
||||
[switchView addTarget:strongSelf action:@selector(disappearingMessagesSwitchValueDidChange:)
|
||||
forControlEvents:UIControlEventValueChanged];
|
||||
|
||||
|
@ -361,11 +277,10 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
UILabel *subtitleLabel = [UILabel new];
|
||||
NSString *displayName;
|
||||
if (self.thread.isGroupThread) {
|
||||
if (self.isClosedGroup || self.isOpenGroup) {
|
||||
displayName = @"the group";
|
||||
} else {
|
||||
TSContactThread *thread = (TSContactThread *)self.thread;
|
||||
displayName = [[LKStorage.shared getContactWithSessionID:thread.contactSessionID] displayNameFor:SNContactContextRegular] ?: @"anonymous";
|
||||
displayName = [SMKProfile displayNameWithId:self.threadId customFallback:@"anonymous"];
|
||||
}
|
||||
subtitleLabel.text = [NSString stringWithFormat:NSLocalizedString(@"When enabled, messages between you and %@ will disappear after they have been seen.", ""), displayName];
|
||||
subtitleLabel.textColor = LKColors.text;
|
||||
|
@ -385,7 +300,7 @@ CGFloat kIconViewLength = 24;
|
|||
return cell;
|
||||
} customRowHeight:UITableViewAutomaticDimension actionBlock:nil]];
|
||||
|
||||
if (self.disappearingMessagesConfiguration.isEnabled) {
|
||||
if (self.isDisappearingMessagesEnabled) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
UITableViewCell *cell = [OWSTableItem newCell];
|
||||
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
||||
|
@ -415,7 +330,7 @@ CGFloat kIconViewLength = 24;
|
|||
slider.minimumValue = 0;
|
||||
slider.tintColor = LKColors.accent;
|
||||
slider.continuous = NO;
|
||||
slider.value = strongSelf.disappearingMessagesConfiguration.durationIndex;
|
||||
slider.value = strongSelf.disappearingMessagesDurationIndex;
|
||||
[slider addTarget:strongSelf action:@selector(durationSliderDidChange:)
|
||||
forControlEvents:UIControlEventValueChanged];
|
||||
[cell.contentView addSubview:slider];
|
||||
|
@ -423,7 +338,7 @@ CGFloat kIconViewLength = 24;
|
|||
[slider autoPinEdge:ALEdgeLeading toEdge:ALEdgeLeading ofView:rowLabel];
|
||||
[slider autoPinTrailingToSuperviewMargin];
|
||||
[slider autoPinBottomToSuperviewMargin];
|
||||
|
||||
|
||||
cell.userInteractionEnabled = !strongSelf.hasLeftGroup;
|
||||
|
||||
cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(
|
||||
|
@ -438,11 +353,10 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
// Closed group settings
|
||||
__block BOOL isUserMember = NO;
|
||||
if (self.isGroupThread) {
|
||||
NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey];
|
||||
isUserMember = [(TSGroupThread *)self.thread isUserMemberInGroup:userPublicKey];
|
||||
if (self.isClosedGroup || self.isOpenGroup) {
|
||||
isUserMember = [SMKGroupMember isCurrentUserMemberOf:self.threadId];
|
||||
}
|
||||
if (self.isGroupThread && self.isClosedGroup && isUserMember) {
|
||||
if (self.isClosedGroup && isUserMember) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
UITableViewCell *cell =
|
||||
[weakSelf disclosureCellWithName:NSLocalizedString(@"EDIT_GROUP_ACTION", @"table cell label in conversation settings")
|
||||
|
@ -465,8 +379,8 @@ CGFloat kIconViewLength = 24;
|
|||
[weakSelf didTapLeaveGroup];
|
||||
}]];
|
||||
}
|
||||
|
||||
if (!isNoteToSelf) {
|
||||
|
||||
if (!self.isNoteToSelf) {
|
||||
// Notification sound
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
UITableViewCell *cell =
|
||||
|
@ -493,8 +407,8 @@ CGFloat kIconViewLength = 24;
|
|||
[cell.contentView addSubview:contentRow];
|
||||
[contentRow autoPinEdgesToSuperviewMargins];
|
||||
|
||||
OWSSound sound = [OWSSounds notificationSoundForThread:strongSelf.thread];
|
||||
cell.detailTextLabel.text = [OWSSounds displayNameForSound:sound];
|
||||
NSInteger sound = [SMKSound notificationSoundFor:strongSelf.threadId];
|
||||
cell.detailTextLabel.text = [SMKSound displayNameFor:sound];
|
||||
|
||||
cell.accessibilityIdentifier = ACCESSIBILITY_IDENTIFIER_WITH_NAME(
|
||||
OWSConversationSettingsViewController, @"notifications");
|
||||
|
@ -504,11 +418,11 @@ CGFloat kIconViewLength = 24;
|
|||
customRowHeight:UITableViewAutomaticDimension
|
||||
actionBlock:^{
|
||||
OWSSoundSettingsViewController *vc = [OWSSoundSettingsViewController new];
|
||||
vc.thread = weakSelf.thread;
|
||||
vc.threadId = weakSelf.threadId;
|
||||
[weakSelf.navigationController pushViewController:vc animated:YES];
|
||||
}]];
|
||||
|
||||
if (self.isGroupThread) {
|
||||
|
||||
if (self.isClosedGroup || self.isOpenGroup) {
|
||||
// Notification Settings
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
UITableViewCell *cell = [OWSTableItem newCell];
|
||||
|
@ -527,7 +441,7 @@ CGFloat kIconViewLength = 24;
|
|||
rowLabel.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||
|
||||
UISwitch *switchView = [UISwitch new];
|
||||
switchView.on = ((TSGroupThread *)strongSelf.thread).isOnlyNotifyingForMentions;
|
||||
switchView.on = [SMKThread isOnlyNotifyingForMentions:strongSelf.threadId];
|
||||
[switchView addTarget:strongSelf action:@selector(notifyForMentionsOnlySwitchValueDidChange:)
|
||||
forControlEvents:UIControlEventValueChanged];
|
||||
|
||||
|
@ -557,7 +471,7 @@ CGFloat kIconViewLength = 24;
|
|||
return cell;
|
||||
} customRowHeight:UITableViewAutomaticDimension actionBlock:nil]];
|
||||
}
|
||||
|
||||
|
||||
// Mute thread
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
||||
|
@ -570,7 +484,7 @@ CGFloat kIconViewLength = 24;
|
|||
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
|
||||
UISwitch *muteConversationSwitch = [UISwitch new];
|
||||
NSDate *mutedUntilDate = strongSelf.thread.mutedUntilDate;
|
||||
NSDate *mutedUntilDate = [SMKThread mutedUntilDateFor:strongSelf.threadId];
|
||||
NSDate *now = [NSDate date];
|
||||
muteConversationSwitch.on = (mutedUntilDate != nil && [mutedUntilDate timeIntervalSinceDate:now] > 0);
|
||||
[muteConversationSwitch addTarget:strongSelf action:@selector(handleMuteSwitchToggled:)
|
||||
|
@ -580,9 +494,9 @@ CGFloat kIconViewLength = 24;
|
|||
return cell;
|
||||
} actionBlock:nil]];
|
||||
}
|
||||
|
||||
|
||||
// Block contact
|
||||
if (!isNoteToSelf && [self.thread isKindOfClass:TSContactThread.class]) {
|
||||
if (!self.isNoteToSelf && !self.isClosedGroup && !self.isOpenGroup) {
|
||||
[section addItem:[OWSTableItem itemWithCustomCellBlock:^{
|
||||
OWSConversationSettingsViewController *strongSelf = weakSelf;
|
||||
if (!strongSelf) { return [UITableViewCell new]; }
|
||||
|
@ -594,7 +508,7 @@ CGFloat kIconViewLength = 24;
|
|||
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
|
||||
UISwitch *blockConversationSwitch = [UISwitch new];
|
||||
blockConversationSwitch.on = strongSelf.thread.isBlocked;
|
||||
blockConversationSwitch.on = [SMKContact isBlockedFor:strongSelf.threadId];
|
||||
[blockConversationSwitch addTarget:strongSelf action:@selector(blockConversationSwitchDidChange:)
|
||||
forControlEvents:UIControlEventValueChanged];
|
||||
cell.accessoryView = blockConversationSwitch;
|
||||
|
@ -681,36 +595,36 @@ CGFloat kIconViewLength = 24;
|
|||
[profilePictureView autoSetDimension:ALDimensionWidth toSize:size];
|
||||
[profilePictureView autoSetDimension:ALDimensionHeight toSize:size];
|
||||
[profilePictureView addGestureRecognizer:profilePictureTapGestureRecognizer];
|
||||
|
||||
|
||||
self.displayNameLabel.text = (self.threadName != nil && self.threadName.length > 0) ? self.threadName : @"Anonymous";
|
||||
if ([self.thread isKindOfClass:TSContactThread.class]) {
|
||||
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||
UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showEditNameUI)];
|
||||
[self.displayNameContainer addGestureRecognizer:tapGestureRecognizer];
|
||||
}
|
||||
|
||||
|
||||
UIStackView *stackView = [[UIStackView alloc] initWithArrangedSubviews:@[ profilePictureView, self.displayNameContainer ]];
|
||||
stackView.axis = UILayoutConstraintAxisVertical;
|
||||
stackView.spacing = LKValues.mediumSpacing;
|
||||
stackView.distribution = UIStackViewDistributionEqualCentering;
|
||||
stackView.distribution = UIStackViewDistributionEqualCentering;
|
||||
stackView.alignment = UIStackViewAlignmentCenter;
|
||||
BOOL isSmallScreen = (UIScreen.mainScreen.bounds.size.height - 568) < 1;
|
||||
CGFloat horizontalSpacing = isSmallScreen ? LKValues.largeSpacing : LKValues.veryLargeSpacing;
|
||||
stackView.layoutMargins = UIEdgeInsetsMake(LKValues.mediumSpacing, horizontalSpacing, LKValues.mediumSpacing, horizontalSpacing);
|
||||
[stackView setLayoutMarginsRelativeArrangement:YES];
|
||||
|
||||
if (!self.isGroupThread) {
|
||||
if (!self.isClosedGroup && !self.isOpenGroup) {
|
||||
SRCopyableLabel *subtitleView = [SRCopyableLabel new];
|
||||
subtitleView.textColor = LKColors.text;
|
||||
subtitleView.font = [LKFonts spaceMonoOfSize:LKValues.smallFontSize];
|
||||
subtitleView.lineBreakMode = NSLineBreakByCharWrapping;
|
||||
subtitleView.numberOfLines = 2;
|
||||
subtitleView.text = ((TSContactThread *)self.thread).contactSessionID;
|
||||
subtitleView.text = self.threadId;
|
||||
subtitleView.textAlignment = NSTextAlignmentCenter;
|
||||
[stackView addArrangedSubview:subtitleView];
|
||||
}
|
||||
|
||||
[profilePictureView updateForThread:self.thread];
|
||||
|
||||
|
||||
[profilePictureView updateForThreadId:self.threadId];
|
||||
|
||||
return stackView;
|
||||
}
|
||||
|
||||
|
@ -749,48 +663,41 @@ CGFloat kIconViewLength = 24;
|
|||
{
|
||||
[super viewWillDisappear:animated];
|
||||
|
||||
if (self.disappearingMessagesConfiguration.isNewRecord && !self.disappearingMessagesConfiguration.isEnabled) {
|
||||
// don't save defaults, else we'll unintentionally save the configuration and notify the contact.
|
||||
// Do nothing if the values haven't changed (or if it's disabled and only the 'durationIndex'
|
||||
// has changed as the 'durationIndex' value defaults to 1 hour when disabled)
|
||||
if (
|
||||
self.isDisappearingMessagesEnabled == self.originalIsDisappearingMessagesEnabled && (
|
||||
!self.originalIsDisappearingMessagesEnabled ||
|
||||
self.disappearingMessagesDurationIndex == self.originalDisappearingMessagesDurationIndex
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.disappearingMessagesConfiguration.dictionaryValueDidChange) {
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) {
|
||||
[self.disappearingMessagesConfiguration saveWithTransaction:transaction];
|
||||
OWSDisappearingConfigurationUpdateInfoMessage *infoMessage = [[OWSDisappearingConfigurationUpdateInfoMessage alloc]
|
||||
initWithTimestamp:[NSDate ows_millisecondTimeStamp]
|
||||
thread:self.thread
|
||||
configuration:self.disappearingMessagesConfiguration
|
||||
createdByRemoteName:nil
|
||||
createdInExistingGroup:NO];
|
||||
[infoMessage saveWithTransaction:transaction];
|
||||
|
||||
SNExpirationTimerUpdate *expirationTimerUpdate = [SNExpirationTimerUpdate new];
|
||||
BOOL isEnabled = self.disappearingMessagesConfiguration.enabled;
|
||||
expirationTimerUpdate.duration = isEnabled ? self.disappearingMessagesConfiguration.durationSeconds : 0;
|
||||
[SNMessageSender send:expirationTimerUpdate inThread:self.thread usingTransaction:transaction];
|
||||
}];
|
||||
}
|
||||
|
||||
[SMKDisappearingMessagesConfiguration
|
||||
update:self.threadId
|
||||
isEnabled: self.isDisappearingMessagesEnabled
|
||||
durationIndex: self.disappearingMessagesDurationIndex
|
||||
];
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)editGroup
|
||||
{
|
||||
SNEditClosedGroupVC *editClosedGroupVC = [[SNEditClosedGroupVC alloc] initWithThreadID:self.thread.uniqueId];
|
||||
SNEditClosedGroupVC *editClosedGroupVC = [[SNEditClosedGroupVC alloc] initWithThreadId:self.threadId];
|
||||
[self.navigationController pushViewController:editClosedGroupVC animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)didTapLeaveGroup
|
||||
{
|
||||
NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey];
|
||||
NSString *message;
|
||||
if ([((TSGroupThread *)self.thread).groupModel.groupAdminIds containsObject:userPublicKey]) {
|
||||
if ([SMKGroupMember isCurrentUserAdminOf:self.threadId]) {
|
||||
message = @"Because you are the creator of this group it will be deleted for everyone. This cannot be undone.";
|
||||
} else {
|
||||
message = NSLocalizedString(@"CONFIRM_LEAVE_GROUP_DESCRIPTION", @"Alert body");
|
||||
}
|
||||
|
||||
|
||||
UIAlertController *alert =
|
||||
[UIAlertController alertControllerWithTitle:NSLocalizedString(@"CONFIRM_LEAVE_GROUP_TITLE", @"Alert title")
|
||||
message:message
|
||||
|
@ -811,9 +718,8 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
- (BOOL)hasLeftGroup
|
||||
{
|
||||
if (self.isGroupThread) {
|
||||
TSGroupThread *groupThread = (TSGroupThread *)self.thread;
|
||||
return !groupThread.isCurrentUserMemberInGroup;
|
||||
if (self.isClosedGroup) {
|
||||
return ![SMKGroupMember isCurrentUserMemberOf:self.threadId];
|
||||
}
|
||||
|
||||
return NO;
|
||||
|
@ -821,13 +727,8 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
- (void)leaveGroup
|
||||
{
|
||||
TSGroupThread *gThread = (TSGroupThread *)self.thread;
|
||||
|
||||
if (gThread.isClosedGroup) {
|
||||
NSString *groupPublicKey = [LKGroupUtilities getDecodedGroupID:gThread.groupModel.groupId];
|
||||
[LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) {
|
||||
[[SNMessageSender leaveClosedGroupWithPublicKey:groupPublicKey using:transaction] retainUntilComplete];
|
||||
}];
|
||||
if (self.isClosedGroup) {
|
||||
[[SMKMessageSender leaveClosedGroupWithPublicKey:self.threadId] retainUntilComplete];
|
||||
}
|
||||
|
||||
[self.navigationController popViewControllerAnimated:YES];
|
||||
|
@ -846,13 +747,9 @@ CGFloat kIconViewLength = 24;
|
|||
{
|
||||
UISwitch *uiSwitch = (UISwitch *)sender;
|
||||
if (uiSwitch.isOn) {
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
[self.thread updateWithMutedUntilDate:[NSDate distantFuture] transaction:transaction];
|
||||
}];
|
||||
[SMKThread updateWithMutedUntilDateTo:[NSDate distantFuture] forThreadId:self.threadId];
|
||||
} else {
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
[self.thread updateWithMutedUntilDate:nil transaction:transaction];
|
||||
}];
|
||||
[SMKThread updateWithMutedUntilDateTo:nil forThreadId:self.threadId];
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -861,13 +758,12 @@ CGFloat kIconViewLength = 24;
|
|||
if (![sender isKindOfClass:[UISwitch class]]) {
|
||||
OWSFailDebug(@"Unexpected sender for block user switch: %@", sender);
|
||||
}
|
||||
if (![self.thread isKindOfClass:[TSContactThread class]]) {
|
||||
OWSFailDebug(@"unexpected thread type: %@", self.thread.class);
|
||||
if (self.isClosedGroup || self.isOpenGroup) {
|
||||
OWSFailDebug(@"unexpected group thread");
|
||||
}
|
||||
UISwitch *blockConversationSwitch = (UISwitch *)sender;
|
||||
TSContactThread *contactThread = (TSContactThread *)self.thread;
|
||||
|
||||
BOOL isCurrentlyBlocked = contactThread.isBlocked;
|
||||
BOOL isCurrentlyBlocked = [SMKContact isBlockedFor:self.threadId];
|
||||
|
||||
__weak OWSConversationSettingsViewController *weakSelf = self;
|
||||
if (blockConversationSwitch.isOn) {
|
||||
|
@ -875,15 +771,15 @@ CGFloat kIconViewLength = 24;
|
|||
if (isCurrentlyBlocked) {
|
||||
return;
|
||||
}
|
||||
[BlockListUIUtils showBlockThreadActionSheet:contactThread
|
||||
[BlockListUIUtils showBlockThreadActionSheet:self.threadId
|
||||
from:self
|
||||
completionBlock:^(BOOL isBlocked) {
|
||||
// Update switch state if user cancels action.
|
||||
blockConversationSwitch.on = isBlocked;
|
||||
|
||||
|
||||
// If we successfully blocked then force a config sync
|
||||
if (isBlocked) {
|
||||
[SNMessageSender forceSyncConfigurationNow];
|
||||
[SMKMessageSender forceSyncConfigurationNow];
|
||||
}
|
||||
|
||||
[weakSelf updateTableContents];
|
||||
|
@ -894,15 +790,15 @@ CGFloat kIconViewLength = 24;
|
|||
if (!isCurrentlyBlocked) {
|
||||
return;
|
||||
}
|
||||
[BlockListUIUtils showUnblockThreadActionSheet:contactThread
|
||||
[BlockListUIUtils showUnblockThreadActionSheet:self.threadId
|
||||
from:self
|
||||
completionBlock:^(BOOL isBlocked) {
|
||||
// Update switch state if user cancels action.
|
||||
blockConversationSwitch.on = isBlocked;
|
||||
|
||||
|
||||
// If we successfully unblocked then force a config sync
|
||||
if (!isBlocked) {
|
||||
[SNMessageSender forceSyncConfigurationNow];
|
||||
[SMKMessageSender forceSyncConfigurationNow];
|
||||
}
|
||||
|
||||
[weakSelf updateTableContents];
|
||||
|
@ -912,7 +808,7 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
- (void)toggleDisappearingMessages:(BOOL)flag
|
||||
{
|
||||
self.disappearingMessagesConfiguration.enabled = flag;
|
||||
self.isDisappearingMessagesEnabled = flag;
|
||||
|
||||
[self updateTableContents];
|
||||
}
|
||||
|
@ -920,21 +816,23 @@ CGFloat kIconViewLength = 24;
|
|||
- (void)durationSliderDidChange:(UISlider *)slider
|
||||
{
|
||||
// snap the slider to a valid value
|
||||
NSUInteger index = (NSUInteger)(slider.value + 0.5);
|
||||
NSInteger index = (NSInteger)(slider.value + 0.5);
|
||||
[slider setValue:index animated:YES];
|
||||
NSNumber *numberOfSeconds = self.disappearingMessagesDurations[index];
|
||||
self.disappearingMessagesConfiguration.durationSeconds = [numberOfSeconds unsignedIntValue];
|
||||
self.disappearingMessagesDurationIndex = index;
|
||||
|
||||
[self updateDisappearingMessagesDurationLabel];
|
||||
}
|
||||
|
||||
- (void)updateDisappearingMessagesDurationLabel
|
||||
{
|
||||
if (self.disappearingMessagesConfiguration.isEnabled) {
|
||||
if (self.isDisappearingMessagesEnabled) {
|
||||
NSString *keepForFormat = @"Disappear after %@";
|
||||
self.disappearingMessagesDurationLabel.text =
|
||||
[NSString stringWithFormat:keepForFormat, self.disappearingMessagesConfiguration.durationString];
|
||||
} else {
|
||||
self.disappearingMessagesDurationLabel.text = [NSString
|
||||
stringWithFormat:keepForFormat,
|
||||
[SMKDisappearingMessagesConfiguration durationStringFor: self.disappearingMessagesDurationIndex]
|
||||
];
|
||||
}
|
||||
else {
|
||||
self.disappearingMessagesDurationLabel.text
|
||||
= NSLocalizedString(@"KEEP_MESSAGES_FOREVER", @"Slider label when disappearing messages is off");
|
||||
}
|
||||
|
@ -945,30 +843,16 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
- (void)copySessionID
|
||||
{
|
||||
UIPasteboard.generalPasteboard.string = ((TSContactThread *)self.thread).contactSessionID;
|
||||
UIPasteboard.generalPasteboard.string = self.threadId;
|
||||
}
|
||||
|
||||
- (void)inviteUsersToOpenGroup
|
||||
{
|
||||
NSString *threadID = self.thread.uniqueId;
|
||||
SNOpenGroupV2 *openGroup = [LKStorage.shared getV2OpenGroupForThreadID:threadID];
|
||||
NSString *url = [NSString stringWithFormat:@"%@/%@?public_key=%@", openGroup.server, openGroup.room, openGroup.publicKey];
|
||||
NSString *threadId = self.threadId;
|
||||
SNUserSelectionVC *userSelectionVC = [[SNUserSelectionVC alloc] initWithTitle:NSLocalizedString(@"vc_conversation_settings_invite_button_title", @"")
|
||||
excluding:[NSSet new]
|
||||
completion:^(NSSet<NSString *> *selectedUsers) {
|
||||
for (NSString *user in selectedUsers) {
|
||||
SNVisibleMessage *message = [SNVisibleMessage new];
|
||||
message.sentTimestamp = [NSDate millisecondTimestamp];
|
||||
message.openGroupInvitation = [[SNOpenGroupInvitation alloc] initWithName:openGroup.name url:url];
|
||||
TSContactThread *thread = [TSContactThread getOrCreateThreadWithContactSessionID:user];
|
||||
TSOutgoingMessage *tsMessage = [TSOutgoingMessage from:message associatedWith:thread];
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
[tsMessage saveWithTransaction:transaction];
|
||||
}];
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
[SNMessageSender send:message inThread:thread usingTransaction:transaction];
|
||||
}];
|
||||
}
|
||||
[SMKOpenGroup inviteUsers:selectedUsers toOpenGroupFor:threadId];
|
||||
}];
|
||||
[self.navigationController pushViewController:userSelectionVC animated:YES];
|
||||
}
|
||||
|
@ -977,13 +861,8 @@ CGFloat kIconViewLength = 24;
|
|||
{
|
||||
OWSLogDebug(@"");
|
||||
|
||||
MediaGallery *mediaGallery = [[MediaGallery alloc] initWithThread:self.thread
|
||||
options:MediaGalleryOptionSliderEnabled];
|
||||
|
||||
self.mediaGallery = mediaGallery;
|
||||
|
||||
OWSAssertDebug([self.navigationController isKindOfClass:[OWSNavigationController class]]);
|
||||
[mediaGallery pushTileViewFromNavController:(OWSNavigationController *)self.navigationController];
|
||||
[SNMediaGallery pushTileViewWithSliderEnabledForThreadId:self.threadId isClosedGroup:self.isClosedGroup isOpenGroup:self.isOpenGroup fromNavController:(OWSNavigationController *)self.navigationController];
|
||||
}
|
||||
|
||||
- (void)tappedConversationSearch
|
||||
|
@ -995,9 +874,8 @@ CGFloat kIconViewLength = 24;
|
|||
{
|
||||
UISwitch *uiSwitch = (UISwitch *)sender;
|
||||
BOOL isEnabled = uiSwitch.isOn;
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
[(TSGroupThread *)self.thread setIsOnlyNotifyingForMentions:isEnabled withTransaction:transaction];
|
||||
}];
|
||||
|
||||
[SMKThread setIsOnlyNotifyingForMentions:self.threadId to:isEnabled];
|
||||
}
|
||||
|
||||
- (void)hideEditNameUI
|
||||
|
@ -1013,9 +891,9 @@ CGFloat kIconViewLength = 24;
|
|||
- (void)setIsEditingDisplayName:(BOOL)isEditingDisplayName
|
||||
{
|
||||
_isEditingDisplayName = isEditingDisplayName;
|
||||
|
||||
|
||||
[self updateNavBarButtons];
|
||||
|
||||
|
||||
[UIView animateWithDuration:0.25 animations:^{
|
||||
self.displayNameLabel.alpha = self.isEditingDisplayName ? 0 : 1;
|
||||
self.displayNameTextField.alpha = self.isEditingDisplayName ? 1 : 0;
|
||||
|
@ -1029,18 +907,10 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
- (void)saveName
|
||||
{
|
||||
if (![self.thread isKindOfClass:TSContactThread.class]) { return; }
|
||||
NSString *sessionID = ((TSContactThread *)self.thread).contactSessionID;
|
||||
SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID];
|
||||
if (contact == nil) {
|
||||
contact = [[SNContact alloc] initWithSessionID:sessionID];
|
||||
}
|
||||
if (self.isClosedGroup || self.isOpenGroup) { return; }
|
||||
|
||||
NSString *text = [self.displayNameTextField.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
||||
contact.nickname = text.length > 0 ? text : nil;
|
||||
[LKStorage writeWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
|
||||
[LKStorage.shared setContact:contact usingTransaction:transaction];
|
||||
}];
|
||||
self.displayNameLabel.text = text.length > 0 ? text : contact.name;
|
||||
self.displayNameLabel.text = [SMKProfile displayNameAfterSavingNickname:text forProfileId:self.threadId];
|
||||
[self hideEditNameUI];
|
||||
}
|
||||
|
||||
|
@ -1069,23 +939,16 @@ CGFloat kIconViewLength = 24;
|
|||
|
||||
#pragma mark - Notifications
|
||||
|
||||
- (void)identityStateDidChange:(NSNotification *)notification
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
[self updateTableContents];
|
||||
}
|
||||
|
||||
// FIXME: When this screen gets refactored, make sure to observe changes for relevant profile image updates
|
||||
- (void)otherUsersProfileDidChange:(NSNotification *)notification
|
||||
{
|
||||
OWSAssertIsOnMainThread();
|
||||
|
||||
NSString *recipientId = notification.userInfo[kNSNotificationKey_ProfileRecipientId];
|
||||
NSString *recipientId = @"";//notification.userInfo[NSNotification.profileRecipientIdKey];
|
||||
OWSAssertDebug(recipientId.length > 0);
|
||||
|
||||
if (recipientId.length > 0 && [self.thread isKindOfClass:[TSContactThread class]] &&
|
||||
[((TSContactThread *)self.thread).contactSessionID isEqualToString:recipientId]) {
|
||||
[self updateTableContents];
|
||||
if (recipientId.length > 0 && !self.isClosedGroup && !self.isOpenGroup && self.threadId == recipientId) {
|
||||
DispatchMainThreadSafe(^{
|
||||
[self updateTableContents];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,11 +9,8 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
|
||||
@protocol OWSConversationSettingsViewDelegate <NSObject>
|
||||
|
||||
- (void)groupWasUpdated:(TSGroupModel *)groupModel;
|
||||
- (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController;
|
||||
|
||||
- (void)popAllConversationSettingsViewsWithCompletion:(void (^_Nullable)(void))completionBlock;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
/// Shown when the user taps a profile picture in the conversation settings.
|
||||
@objc(SNProfilePictureVC)
|
||||
final class ProfilePictureVC : BaseVC {
|
||||
final class ProfilePictureVC: BaseVC {
|
||||
private let image: UIImage
|
||||
private let snTitle: String
|
||||
|
||||
@objc init(image: UIImage, title: String) {
|
||||
self.image = image
|
||||
self.snTitle = title
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,9 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class BlockedModal: Modal {
|
||||
|
@ -19,7 +25,7 @@ final class BlockedModal: Modal {
|
|||
|
||||
override func populateContentView() {
|
||||
// Name
|
||||
let name = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey
|
||||
let name = Profile.displayName(id: publicKey)
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
|
@ -67,23 +73,20 @@ final class BlockedModal: Modal {
|
|||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func unblock() {
|
||||
let publicKey: String = self.publicKey
|
||||
|
||||
Storage.shared.write(
|
||||
with: { transaction in
|
||||
guard let transaction = transaction as? YapDatabaseReadWriteTransaction, let contact: Contact = Storage.shared.getContact(with: publicKey, using: transaction) else {
|
||||
return
|
||||
}
|
||||
|
||||
contact.isBlocked = false
|
||||
Storage.shared.setContact(contact, using: transaction as Any)
|
||||
},
|
||||
completion: {
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
)
|
||||
Storage.shared.writeAsync { db in
|
||||
try Contact
|
||||
.filter(id: publicKey)
|
||||
.updateAll(db, Contact.Columns.isBlocked.set(to: false))
|
||||
|
||||
try MessageSender
|
||||
.syncConfiguration(db, forceSyncNow: true)
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
|
||||
// Requirements:
|
||||
// • Links should show up properly and be tappable.
|
||||
// • Text should * not * be selectable.
|
||||
// • The long press interaction that shows the context menu should still work.
|
||||
|
||||
final class BodyTextView : UITextView {
|
||||
private let snDelegate: BodyTextViewDelegate
|
||||
|
||||
override var selectedTextRange: UITextRange? {
|
||||
get { return nil }
|
||||
set { }
|
||||
}
|
||||
|
||||
init(snDelegate: BodyTextViewDelegate) {
|
||||
self.snDelegate = snDelegate
|
||||
super.init(frame: CGRect.zero, textContainer: nil)
|
||||
setUpGestureRecognizers()
|
||||
}
|
||||
|
||||
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
||||
preconditionFailure("Use init(snDelegate:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(snDelegate:) instead.")
|
||||
}
|
||||
|
||||
private func setUpGestureRecognizers() {
|
||||
let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
|
||||
addGestureRecognizer(longPressGestureRecognizer)
|
||||
let doubleTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap))
|
||||
doubleTapGestureRecognizer.numberOfTapsRequired = 2
|
||||
addGestureRecognizer(doubleTapGestureRecognizer)
|
||||
}
|
||||
|
||||
@objc private func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
snDelegate.handleLongPress(gestureRecognizer)
|
||||
}
|
||||
|
||||
@objc private func handleDoubleTap() {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
protocol BodyTextViewDelegate {
|
||||
|
||||
func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer)
|
||||
}
|
|
@ -1,13 +1,21 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
@objc
|
||||
final class CallModal : Modal {
|
||||
final class CallModal: Modal {
|
||||
private let onCallEnabled: () -> Void
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
@objc
|
||||
init(onCallEnabled: @escaping () -> Void) {
|
||||
self.onCallEnabled = onCallEnabled
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
self.modalPresentationStyle = .overFullScreen
|
||||
self.modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
@ -27,15 +35,16 @@ final class CallModal : Modal {
|
|||
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_call_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = NSLocalizedString("modal_call_explanation", comment: "")
|
||||
messageLabel.text = message
|
||||
messageLabel.text = "modal_call_explanation".localized()
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
|
||||
// Enable button
|
||||
let enableButton = UIButton()
|
||||
enableButton.set(.height, to: Values.mediumButtonHeight)
|
||||
|
@ -45,25 +54,29 @@ final class CallModal : Modal {
|
|||
enableButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal)
|
||||
enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside)
|
||||
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Main stack view
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = Values.largeSpacing
|
||||
contentView.addSubview(mainStackView)
|
||||
|
||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
|
||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func enable() {
|
||||
SSKPreferences.areCallsEnabled = true
|
||||
Storage.shared.writeAsync { db in db[.areCallsEnabled] = true }
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
onCallEnabled()
|
||||
}
|
||||
|
|
|
@ -1,26 +1,35 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class ConversationTitleView : UIView {
|
||||
private let thread: TSThread
|
||||
weak var delegate: ConversationTitleViewDelegate?
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class ConversationTitleView: UIView {
|
||||
private static let leftInset: CGFloat = 8
|
||||
private static let leftInsetWithCallButton: CGFloat = 54
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
return UIView.layoutFittingExpandedSize
|
||||
}
|
||||
|
||||
// MARK: UI Components
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var titleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var subtitleLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .systemFont(ofSize: 13)
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
@ -29,114 +38,119 @@ final class ConversationTitleView : UIView {
|
|||
result.axis = .vertical
|
||||
result.alignment = .center
|
||||
result.isLayoutMarginsRelativeArrangement = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(thread: TSThread) {
|
||||
self.thread = thread
|
||||
super.init(frame: CGRect.zero)
|
||||
initialize()
|
||||
}
|
||||
|
||||
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)
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
let notificationCenter = NotificationCenter.default
|
||||
notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.groupThreadUpdated, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.muteSettingUpdated, object: nil)
|
||||
notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.contactUpdated, object: nil)
|
||||
update()
|
||||
addSubview(stackView)
|
||||
|
||||
stackView.pin(to: self)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
@objc private func update() {
|
||||
titleLabel.text = getTitle()
|
||||
let subtitle = getSubtitle()
|
||||
subtitleLabel.attributedText = subtitle
|
||||
let titleFontSize = (subtitle != nil) ? Values.mediumFontSize : Values.veryLargeFontSize
|
||||
titleLabel.font = .boldSystemFont(ofSize: titleFontSize)
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init() instead.")
|
||||
}
|
||||
|
||||
// MARK: General
|
||||
private func getTitle() -> String {
|
||||
if let thread = thread as? TSGroupThread {
|
||||
return thread.groupModel.groupName!
|
||||
}
|
||||
else if thread.isNoteToSelf() {
|
||||
return "Note to Self"
|
||||
}
|
||||
else {
|
||||
let sessionID = (thread as! TSContactThread).contactSessionID()
|
||||
var result = sessionID
|
||||
Storage.read { transaction in
|
||||
let displayName: String = ((Storage.shared.getContact(with: sessionID)?.displayName(for: .regular)) ?? sessionID)
|
||||
let middleTruncatedHexKey: String = "\(sessionID.prefix(4))...\(sessionID.suffix(4))"
|
||||
result = (displayName == sessionID ? middleTruncatedHexKey : displayName)
|
||||
// MARK: - Content
|
||||
|
||||
public func initialSetup(with threadVariant: SessionThread.Variant) {
|
||||
self.update(
|
||||
with: " ",
|
||||
isNoteToSelf: false,
|
||||
threadVariant: threadVariant,
|
||||
mutedUntilTimestamp: nil,
|
||||
onlyNotifyForMentions: false,
|
||||
userCount: (threadVariant != .contact ? 0 : nil)
|
||||
)
|
||||
}
|
||||
|
||||
public func update(
|
||||
with name: String,
|
||||
isNoteToSelf: Bool,
|
||||
threadVariant: SessionThread.Variant,
|
||||
mutedUntilTimestamp: TimeInterval?,
|
||||
onlyNotifyForMentions: Bool,
|
||||
userCount: Int?
|
||||
) {
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.update(
|
||||
with: name,
|
||||
isNoteToSelf: isNoteToSelf,
|
||||
threadVariant: threadVariant,
|
||||
mutedUntilTimestamp: mutedUntilTimestamp,
|
||||
onlyNotifyForMentions: onlyNotifyForMentions,
|
||||
userCount: userCount
|
||||
)
|
||||
}
|
||||
return result
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func getSubtitle() -> NSAttributedString? {
|
||||
let result = NSMutableAttributedString()
|
||||
if thread.isMuted {
|
||||
result.append(NSAttributedString(string: "\u{e067} ", attributes: [ .font : UIFont.ows_elegantIconsFont(10), .foregroundColor : Colors.text ]))
|
||||
result.append(NSAttributedString(string: "Muted"))
|
||||
return result
|
||||
} else if let thread = self.thread as? TSGroupThread {
|
||||
if thread.isOnlyNotifyingForMentions {
|
||||
|
||||
// Generate the subtitle
|
||||
let subtitle: NSAttributedString? = {
|
||||
guard Date().timeIntervalSince1970 > (mutedUntilTimestamp ?? 0) else {
|
||||
return NSAttributedString(
|
||||
string: "\u{e067} ",
|
||||
attributes: [
|
||||
.font: UIFont.ows_elegantIconsFont(10),
|
||||
.foregroundColor: Colors.text
|
||||
]
|
||||
)
|
||||
.appending(string: "Muted")
|
||||
}
|
||||
guard !onlyNotifyForMentions else {
|
||||
// FIXME: This is going to have issues when swapping between light/dark mode
|
||||
let imageAttachment = NSTextAttachment()
|
||||
let color: UIColor = isDarkMode ? .white : .black
|
||||
let color: UIColor = (isDarkMode ? .white : .black)
|
||||
imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: color)
|
||||
imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize)
|
||||
let imageAsString = NSAttributedString(attachment: imageAttachment)
|
||||
result.append(imageAsString)
|
||||
result.append(NSAttributedString(string: " " + NSLocalizedString("view_conversation_title_notify_for_mentions_only", comment: "")))
|
||||
return result
|
||||
} else {
|
||||
var userCount: UInt64?
|
||||
switch thread.groupModel.groupType {
|
||||
case .closedGroup: userCount = UInt64(thread.groupModel.groupMemberIds.count)
|
||||
case .openGroup:
|
||||
guard let openGroupV2 = Storage.shared.getV2OpenGroup(for: self.thread.uniqueId!) else { return nil }
|
||||
userCount = Storage.shared.getUserCount(forV2OpenGroupWithID: openGroupV2.id)
|
||||
default: break
|
||||
}
|
||||
if let userCount = userCount {
|
||||
return NSAttributedString(string: "\(userCount) members")
|
||||
}
|
||||
imageAttachment.bounds = CGRect(
|
||||
x: 0,
|
||||
y: -2,
|
||||
width: Values.smallFontSize,
|
||||
height: Values.smallFontSize
|
||||
)
|
||||
|
||||
return NSAttributedString(attachment: imageAttachment)
|
||||
.appending(string: " ")
|
||||
.appending(string: "view_conversation_title_notify_for_mentions_only".localized())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc private func handleTap() {
|
||||
delegate?.handleTitleViewTapped()
|
||||
guard let userCount: Int = userCount else { return nil }
|
||||
|
||||
return NSAttributedString(string: "\(userCount) member\(userCount == 1 ? "" : "s")")
|
||||
}()
|
||||
|
||||
self.titleLabel.text = name
|
||||
self.titleLabel.font = .boldSystemFont(
|
||||
ofSize: (subtitle != nil ?
|
||||
Values.mediumFontSize :
|
||||
Values.veryLargeFontSize
|
||||
)
|
||||
)
|
||||
self.subtitleLabel.attributedText = subtitle
|
||||
|
||||
// Contact threads also have the call button to compensate for
|
||||
let shouldShowCallButton: Bool = (
|
||||
SessionCall.isEnabled &&
|
||||
!isNoteToSelf &&
|
||||
threadVariant == .contact
|
||||
)
|
||||
self.stackView.layoutMargins = UIEdgeInsets(
|
||||
top: 0,
|
||||
left: (shouldShowCallButton ?
|
||||
ConversationTitleView.leftInsetWithCallButton :
|
||||
ConversationTitleView.leftInset
|
||||
),
|
||||
bottom: 0,
|
||||
right: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
protocol ConversationTitleViewDelegate : AnyObject {
|
||||
|
||||
func handleTitleViewTapped()
|
||||
}
|
||||
|
|
|
@ -1,42 +1,58 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class DownloadAttachmentModal : Modal {
|
||||
private let viewItem: ConversationViewItem
|
||||
import UIKit
|
||||
import GRDB
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class DownloadAttachmentModal: Modal {
|
||||
private let profile: Profile?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(viewItem: ConversationViewItem) {
|
||||
self.viewItem = viewItem
|
||||
init(profile: Profile?) {
|
||||
self.profile = profile
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(viewItem:) instead.")
|
||||
}
|
||||
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(viewItem:) instead.")
|
||||
}
|
||||
|
||||
|
||||
override func populateContentView() {
|
||||
guard let publicKey = (viewItem.interaction as? TSIncomingMessage)?.authorId else { return }
|
||||
guard let profile: Profile = profile else { return }
|
||||
|
||||
// Name
|
||||
let name = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey
|
||||
let name: String = profile.displayName()
|
||||
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = String(format: NSLocalizedString("modal_download_attachment_title", comment: ""), name)
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = String(format: NSLocalizedString("modal_download_attachment_explanation", comment: ""), name)
|
||||
let attributedMessage = NSMutableAttributedString(string: message)
|
||||
attributedMessage.addAttributes([ .font : UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], range: (message as NSString).range(of: name))
|
||||
attributedMessage.addAttributes(
|
||||
[.font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ],
|
||||
range: (message as NSString).range(of: name)
|
||||
)
|
||||
messageLabel.attributedText = attributedMessage
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
|
||||
// Download button
|
||||
let downloadButton = UIButton()
|
||||
downloadButton.set(.height, to: Values.mediumButtonHeight)
|
||||
|
@ -45,15 +61,18 @@ final class DownloadAttachmentModal : Modal {
|
|||
downloadButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
downloadButton.setTitle(NSLocalizedString("modal_download_button_title", comment: ""), for: UIControl.State.normal)
|
||||
downloadButton.addTarget(self, action: #selector(trust), for: UIControl.Event.touchUpInside)
|
||||
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, downloadButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
|
@ -65,19 +84,37 @@ final class DownloadAttachmentModal : Modal {
|
|||
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
|
||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
// MARK: Interaction
|
||||
@objc private func trust() {
|
||||
guard let message = viewItem.interaction as? TSIncomingMessage else { return }
|
||||
let publicKey = message.authorId
|
||||
let contact = Storage.shared.getContact(with: publicKey) ?? Contact(sessionID: publicKey)
|
||||
contact.isTrusted = true
|
||||
Storage.write(with: { transaction in
|
||||
Storage.shared.setContact(contact, using: transaction)
|
||||
MessageInvalidator.invalidate(message, with: transaction)
|
||||
}, completion: {
|
||||
Storage.shared.resumeAttachmentDownloadJobsIfNeeded(for: message.uniqueThreadId)
|
||||
})
|
||||
guard let profileId: String = profile?.id else { return }
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
try Contact
|
||||
.filter(id: profileId)
|
||||
.updateAll(db, Contact.Columns.isTrusted.set(to: true))
|
||||
|
||||
// Start downloading any pending attachments for this contact (UI will automatically be
|
||||
// updated due to the database observation)
|
||||
try Attachment
|
||||
.stateInfo(authorId: profileId, state: .pendingDownload)
|
||||
.fetchAll(db)
|
||||
.forEach { attachmentDownloadInfo in
|
||||
JobRunner.add(
|
||||
db,
|
||||
job: Job(
|
||||
variant: .attachmentDownload,
|
||||
threadId: profileId,
|
||||
interactionId: attachmentDownloadInfo.interactionId,
|
||||
details: AttachmentDownloadJob.Details(
|
||||
attachmentId: attachmentDownloadInfo.attachmentId
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
/// This custom UITableView gives us two convenience behaviours:
|
||||
///
|
||||
/// 1. It allows us to lock the contentOffset to a specific value - it's currently used to prevent the ConversationVC first
|
||||
/// responder resignation from making the MediaGalleryDetailViewController transition from looking buggy (ie. the table
|
||||
/// scrolls down with the resignation during the transition)
|
||||
///
|
||||
/// 2. It allows us to provode a callback which gets triggered if a condition closure returns true - it's currently used to prevent
|
||||
/// the table view from jumping when inserting new pages at the top of a conversation screen
|
||||
public class InsetLockableTableView: UITableView {
|
||||
public var lockContentOffset: Bool = false {
|
||||
didSet {
|
||||
guard !lockContentOffset else { return }
|
||||
|
||||
self.contentOffset = newOffset
|
||||
}
|
||||
}
|
||||
public var oldOffset: CGPoint = .zero
|
||||
public var newOffset: CGPoint = .zero
|
||||
private var callbackCondition: ((Int, [Int], CGSize) -> Bool)?
|
||||
private var afterLayoutSubviewsCallback: (() -> ())?
|
||||
|
||||
public override func layoutSubviews() {
|
||||
self.newOffset = self.contentOffset
|
||||
|
||||
// Store the callback locally to prevent infinite loops
|
||||
var callback: (() -> ())?
|
||||
|
||||
if self.checkCallbackCondition() {
|
||||
callback = self.afterLayoutSubviewsCallback
|
||||
self.afterLayoutSubviewsCallback = nil
|
||||
}
|
||||
|
||||
guard !lockContentOffset else {
|
||||
self.contentOffset = CGPoint(
|
||||
x: newOffset.x,
|
||||
y: oldOffset.y
|
||||
)
|
||||
|
||||
super.layoutSubviews()
|
||||
callback?()
|
||||
return
|
||||
}
|
||||
|
||||
super.layoutSubviews()
|
||||
callback?()
|
||||
|
||||
self.oldOffset = self.contentOffset
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public func afterNextLayoutSubviews(
|
||||
when condition: @escaping (Int, [Int], CGSize) -> Bool,
|
||||
then callback: @escaping () -> ()
|
||||
) {
|
||||
self.callbackCondition = condition
|
||||
self.afterLayoutSubviewsCallback = callback
|
||||
}
|
||||
|
||||
private func checkCallbackCondition() -> Bool {
|
||||
guard self.callbackCondition != nil else { return false }
|
||||
|
||||
let numSections: Int = self.numberOfSections
|
||||
let numRowInSections: [Int] = (0..<numSections)
|
||||
.map { self.numberOfRows(inSection: $0) }
|
||||
|
||||
// Store the layout info locally so if they pass we can clear the states before running to
|
||||
// prevent layouts within the callbacks from triggering infinite loops
|
||||
guard self.callbackCondition?(numSections, numRowInSections, self.contentSize) == true else {
|
||||
return false
|
||||
}
|
||||
|
||||
self.callbackCondition = nil
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -1,12 +1,20 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class JoinOpenGroupModal : Modal {
|
||||
import UIKit
|
||||
import GRDB
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class JoinOpenGroupModal: Modal {
|
||||
private let name: String
|
||||
private let url: String
|
||||
|
||||
// MARK: Lifecycle
|
||||
init(name: String, url: String) {
|
||||
self.name = name
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(name: String?, url: String) {
|
||||
self.name = (name ?? "Open Group")
|
||||
self.url = url
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
@ -25,6 +33,7 @@ final class JoinOpenGroupModal : Modal {
|
|||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = "Join \(name)?"
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
|
@ -36,6 +45,7 @@ final class JoinOpenGroupModal : Modal {
|
|||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
|
||||
// Join button
|
||||
let joinButton = UIButton()
|
||||
joinButton.set(.height, to: Values.mediumButtonHeight)
|
||||
|
@ -45,15 +55,18 @@ final class JoinOpenGroupModal : Modal {
|
|||
joinButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
joinButton.setTitle("Join", for: UIControl.State.normal)
|
||||
joinButton.addTarget(self, action: #selector(joinOpenGroup), for: UIControl.Event.touchUpInside)
|
||||
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, joinButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
|
@ -66,24 +79,39 @@ final class JoinOpenGroupModal : Modal {
|
|||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func joinOpenGroup() {
|
||||
guard let (room, server, publicKey) = OpenGroupManagerV2.parseV2OpenGroup(from: url) else {
|
||||
guard let presentingViewController: UIViewController = self.presentingViewController else { return }
|
||||
guard let (room, server, publicKey) = OpenGroupManager.parseOpenGroup(from: url) else {
|
||||
let alert = UIAlertController(title: "Couldn't Join", message: nil, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
||||
return presentingViewController!.presentAlert(alert)
|
||||
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||
|
||||
return presentingViewController.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
presentingViewController!.dismiss(animated: true, completion: nil)
|
||||
Storage.shared.write { [presentingViewController = self.presentingViewController!] transaction in
|
||||
OpenGroupManagerV2.shared.add(room: room, server: server, publicKey: publicKey, using: transaction)
|
||||
|
||||
presentingViewController.dismiss(animated: true, completion: nil)
|
||||
|
||||
Storage.shared
|
||||
.writeAsync { db in
|
||||
OpenGroupManager.shared.add(
|
||||
db,
|
||||
roomToken: room,
|
||||
server: server,
|
||||
publicKey: publicKey,
|
||||
isConfigMessage: false
|
||||
)
|
||||
}
|
||||
.done(on: DispatchQueue.main) { _ in
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...)
|
||||
Storage.shared.writeAsync { db in
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...)
|
||||
}
|
||||
}
|
||||
.catch(on: DispatchQueue.main) { error in
|
||||
let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
|
||||
presentingViewController.presentAlert(alert)
|
||||
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
|
||||
presentingViewController.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,15 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class LinkPreviewModal : Modal {
|
||||
import UIKit
|
||||
import GRDB
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class LinkPreviewModal: Modal {
|
||||
private let onLinkPreviewsEnabled: () -> Void
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(onLinkPreviewsEnabled: @escaping () -> Void) {
|
||||
self.onLinkPreviewsEnabled = onLinkPreviewsEnabled
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
@ -18,22 +25,23 @@ final class LinkPreviewModal : Modal {
|
|||
|
||||
override func populateContentView() {
|
||||
// Title
|
||||
let titleLabel = UILabel()
|
||||
let titleLabel: UILabel = UILabel()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_link_previews_title", comment: "")
|
||||
titleLabel.text = "modal_link_previews_title".localized()
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
let messageLabel: UILabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
let message = NSLocalizedString("modal_link_previews_explanation", comment: "")
|
||||
messageLabel.text = message
|
||||
messageLabel.text = "modal_link_previews_explanation".localized()
|
||||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
|
||||
// Enable button
|
||||
let enableButton = UIButton()
|
||||
let enableButton: UIButton = UIButton()
|
||||
enableButton.set(.height, to: Values.mediumButtonHeight)
|
||||
enableButton.layer.cornerRadius = Modal.buttonCornerRadius
|
||||
enableButton.backgroundColor = Colors.buttonBackground
|
||||
|
@ -41,18 +49,22 @@ final class LinkPreviewModal : Modal {
|
|||
enableButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
enableButton.setTitle(NSLocalizedString("modal_link_previews_button_title", comment: ""), for: UIControl.State.normal)
|
||||
enableButton.addTarget(self, action: #selector(enable), for: UIControl.Event.touchUpInside)
|
||||
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
|
||||
let buttonStackView: UIStackView = UIStackView(arrangedSubviews: [ cancelButton, enableButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
|
||||
mainStackView.axis = .vertical
|
||||
mainStackView.spacing = spacing
|
||||
contentView.addSubview(mainStackView)
|
||||
|
@ -62,9 +74,13 @@ final class LinkPreviewModal : Modal {
|
|||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func enable() {
|
||||
SSKPreferences.areLinkPreviewsEnabled = true
|
||||
Storage.shared.writeAsync { db in
|
||||
db[.areLinkPreviewsEnabled] = true
|
||||
}
|
||||
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
onLinkPreviewsEnabled()
|
||||
}
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
|
||||
final class MessagesTableView : UITableView {
|
||||
override init(frame: CGRect, style: UITableView.Style) {
|
||||
super.init(frame: frame, style: style)
|
||||
initialize()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
initialize()
|
||||
}
|
||||
|
||||
private func initialize() {
|
||||
register(VisibleMessageCell.self, forCellReuseIdentifier: VisibleMessageCell.identifier)
|
||||
register(InfoMessageCell.self, forCellReuseIdentifier: InfoMessageCell.identifier)
|
||||
register(TypingIndicatorCell.self, forCellReuseIdentifier: TypingIndicatorCell.identifier)
|
||||
register(CallMessageCell.self, forCellReuseIdentifier: CallMessageCell.identifier)
|
||||
separatorStyle = .none
|
||||
backgroundColor = .clear
|
||||
showsVerticalScrollIndicator = false
|
||||
contentInsetAdjustmentBehavior = .never
|
||||
keyboardDismissMode = .interactive
|
||||
}
|
||||
}
|
|
@ -1,80 +1,113 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class ReactionListSheet : BaseVC {
|
||||
private let thread: TSGroupThread
|
||||
private let viewItem: ConversationViewItem
|
||||
private var reactions: [ReactMessage] = []
|
||||
private var reactionMap: OrderedDictionary<EmojiWithSkinTones, [ReactMessage]> = OrderedDictionary()
|
||||
var selectedReaction: EmojiWithSkinTones?
|
||||
var delegate: ReactionDelegate?
|
||||
import UIKit
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class ReactionListSheet: BaseVC {
|
||||
public struct ReactionSummary: Hashable, Differentiable {
|
||||
let emoji: EmojiWithSkinTones
|
||||
let number: Int
|
||||
let isSelected: Bool
|
||||
|
||||
var description: String {
|
||||
return "\(emoji.rawValue) · \(number)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Components
|
||||
private let interactionId: Int64
|
||||
private let onDismiss: (() -> ())?
|
||||
private var messageViewModel: MessageViewModel = MessageViewModel()
|
||||
private var reactionSummaries: [ReactionSummary] = []
|
||||
private var selectedReactionUserList: [MessageViewModel.ReactionInfo] = []
|
||||
private var lastSelectedReactionIndex: Int = 0
|
||||
public var delegate: ReactionDelegate?
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var contentView: UIView = {
|
||||
let result = UIView()
|
||||
let line = UIView()
|
||||
line.set(.height, to: 0.5)
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = Colors.modalBackground
|
||||
|
||||
let line: UIView = UIView()
|
||||
line.backgroundColor = Colors.border.withAlphaComponent(0.5)
|
||||
result.addSubview(line)
|
||||
|
||||
line.set(.height, to: 0.5)
|
||||
line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top ], to: result)
|
||||
result.backgroundColor = Colors.modalBackground
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var layout: UICollectionViewFlowLayout = {
|
||||
let result = UICollectionViewFlowLayout()
|
||||
let result: UICollectionViewFlowLayout = UICollectionViewFlowLayout()
|
||||
result.scrollDirection = .horizontal
|
||||
result.minimumLineSpacing = Values.smallSpacing
|
||||
result.minimumInteritemSpacing = Values.smallSpacing
|
||||
result.sectionInset = UIEdgeInsets(
|
||||
top: 0,
|
||||
leading: Values.smallSpacing,
|
||||
bottom: 0,
|
||||
trailing: Values.smallSpacing
|
||||
)
|
||||
result.minimumLineSpacing = 0
|
||||
result.minimumInteritemSpacing = 0
|
||||
result.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var reactionContainer: UICollectionView = {
|
||||
let result = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
|
||||
result.register(Cell.self, forCellWithReuseIdentifier: Cell.identifier)
|
||||
let result: UICollectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
|
||||
result.register(view: Cell.self)
|
||||
result.set(.height, to: 48)
|
||||
result.backgroundColor = .clear
|
||||
result.isScrollEnabled = true
|
||||
result.showsHorizontalScrollIndicator = false
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var detailInfoLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
result.textColor = Colors.grey.withAlphaComponent(0.8)
|
||||
result.set(.height, to: 32)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var clearAllButton: Button = {
|
||||
let result = Button(style: .destructiveOutline, size: .small)
|
||||
let result: Button = Button(style: .destructiveOutline, size: .small)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.setTitle(NSLocalizedString("MESSAGE_REQUESTS_CLEAR_ALL", comment: ""), for: .normal)
|
||||
result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal)
|
||||
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
|
||||
result.layer.borderWidth = 0
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var userListView: UITableView = {
|
||||
let result = UITableView()
|
||||
let result: UITableView = UITableView()
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
result.register(UserCell.self, forCellReuseIdentifier: "UserCell")
|
||||
result.register(view: UserCell.self)
|
||||
result.separatorStyle = .none
|
||||
result.backgroundColor = .clear
|
||||
result.showsVerticalScrollIndicator = false
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(for viewItem: ConversationViewItem, thread: TSGroupThread) {
|
||||
self.viewItem = viewItem
|
||||
self.thread = thread
|
||||
init(for interactionId: Int64, onDismiss: (() -> ())? = nil) {
|
||||
self.interactionId = interactionId
|
||||
self.onDismiss = onDismiss
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
@ -88,21 +121,20 @@ final class ReactionListSheet : BaseVC {
|
|||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = .clear
|
||||
|
||||
let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(close))
|
||||
swipeGestureRecognizer.direction = .down
|
||||
view.addGestureRecognizer(swipeGestureRecognizer)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(update), name: .emojiReactsUpdated, object: nil)
|
||||
|
||||
setUpViewHierarchy()
|
||||
update()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
if let index = reactionMap.orderedKeys.firstIndex(of: selectedReaction!) {
|
||||
let indexPath = IndexPath(item: index, section: 0)
|
||||
reactionContainer.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally)
|
||||
}
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
self.onDismiss?()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
|
@ -117,6 +149,7 @@ final class ReactionListSheet : BaseVC {
|
|||
contentView.addSubview(reactionContainer)
|
||||
reactionContainer.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: contentView)
|
||||
reactionContainer.pin(.top, to: .top, of: contentView, withInset: Values.verySmallSpacing)
|
||||
|
||||
// Seperator
|
||||
let seperator = UIView()
|
||||
seperator.backgroundColor = Colors.border.withAlphaComponent(0.1)
|
||||
|
@ -125,12 +158,14 @@ final class ReactionListSheet : BaseVC {
|
|||
seperator.pin(.leading, to: .leading, of: contentView, withInset: Values.smallSpacing)
|
||||
seperator.pin(.trailing, to: .trailing, of: contentView, withInset: -Values.smallSpacing)
|
||||
seperator.pin(.top, to: .bottom, of: reactionContainer, withInset: Values.verySmallSpacing)
|
||||
|
||||
// Detail info & clear all
|
||||
let stackView = UIStackView(arrangedSubviews: [ detailInfoLabel, clearAllButton ])
|
||||
contentView.addSubview(stackView)
|
||||
stackView.pin(.top, to: .bottom, of: seperator, withInset: Values.smallSpacing)
|
||||
stackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing)
|
||||
stackView.pin(.trailing, to: .trailing, of: contentView, withInset: -Values.mediumSpacing)
|
||||
|
||||
// Line
|
||||
let line = UIView()
|
||||
line.set(.height, to: 0.5)
|
||||
|
@ -138,65 +173,169 @@ final class ReactionListSheet : BaseVC {
|
|||
contentView.addSubview(line)
|
||||
line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: contentView)
|
||||
line.pin(.top, to: .bottom, of: stackView, withInset: Values.smallSpacing)
|
||||
|
||||
// Reactor list
|
||||
contentView.addSubview(userListView)
|
||||
userListView.pin([ UIView.HorizontalEdge.trailing, UIView.HorizontalEdge.leading, UIView.VerticalEdge.bottom ], to: contentView)
|
||||
userListView.pin(.top, to: .bottom, of: line, withInset: 0)
|
||||
}
|
||||
|
||||
private func populateData() {
|
||||
self.reactions = []
|
||||
self.reactionMap = OrderedDictionary()
|
||||
if let messageId = viewItem.interaction.uniqueId, let message = TSMessage.fetch(uniqueId: messageId) {
|
||||
self.reactions = message.reactions as! [ReactMessage]
|
||||
}
|
||||
for reaction in reactions {
|
||||
if let rawEmoji = reaction.emoji, let emoji = EmojiWithSkinTones(rawValue: rawEmoji) {
|
||||
if !reactionMap.hasValue(forKey: emoji) { reactionMap.append(key: emoji, value: []) }
|
||||
var value = reactionMap.value(forKey: emoji)!
|
||||
if reaction.sender == getUserHexEncodedPublicKey() {
|
||||
value.insert(reaction, at: 0)
|
||||
} else {
|
||||
value.append(reaction)
|
||||
}
|
||||
reactionMap.replace(key: emoji, value: value)
|
||||
}
|
||||
}
|
||||
if (selectedReaction == nil || reactionMap.value(forKey: selectedReaction!) == nil) && reactionMap.orderedKeys.count > 0 {
|
||||
selectedReaction = reactionMap.orderedKeys[0]
|
||||
}
|
||||
}
|
||||
// MARK: - Content
|
||||
|
||||
private func reloadData() {
|
||||
reactionContainer.reloadData()
|
||||
let seletedData = reactionMap.value(forKey: selectedReaction!)!
|
||||
detailInfoLabel.text = "\(selectedReaction!.rawValue) · \(seletedData.count)"
|
||||
if thread.isOpenGroup, let threadId = thread.uniqueId, let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadId) {
|
||||
let isUserModerator = OpenGroupAPIV2.isUserModerator(getUserHexEncodedPublicKey(), for: openGroupV2.room, on: openGroupV2.server)
|
||||
clearAllButton.isHidden = !isUserModerator
|
||||
public func handleInteractionUpdates(
|
||||
_ allMessages: [MessageViewModel],
|
||||
selectedReaction: EmojiWithSkinTones? = nil,
|
||||
updatedReactionIndex: Int? = nil,
|
||||
initialLoad: Bool = false
|
||||
) {
|
||||
guard let cellViewModel: MessageViewModel = allMessages.first(where: { $0.id == self.interactionId }) else {
|
||||
return
|
||||
}
|
||||
userListView.reloadData()
|
||||
}
|
||||
|
||||
@objc private func update() {
|
||||
populateData()
|
||||
if reactions.isEmpty {
|
||||
|
||||
// If we have no more reactions (eg. the user removed the last one) then closed the list sheet
|
||||
guard cellViewModel.reactionInfo?.isEmpty == false else {
|
||||
close()
|
||||
return
|
||||
}
|
||||
reloadData()
|
||||
|
||||
// Generated the updated data
|
||||
let updatedReactionInfo: OrderedDictionary<EmojiWithSkinTones, [MessageViewModel.ReactionInfo]> = (cellViewModel.reactionInfo ?? [])
|
||||
.reduce(into: OrderedDictionary<EmojiWithSkinTones, [MessageViewModel.ReactionInfo]>()) {
|
||||
result, reactionInfo in
|
||||
guard let emoji: EmojiWithSkinTones = EmojiWithSkinTones(rawValue: reactionInfo.reaction.emoji) else {
|
||||
return
|
||||
}
|
||||
|
||||
guard var updatedValue: [MessageViewModel.ReactionInfo] = result.value(forKey: emoji) else {
|
||||
result.append(key: emoji, value: [reactionInfo])
|
||||
return
|
||||
}
|
||||
|
||||
if reactionInfo.reaction.authorId == cellViewModel.currentUserPublicKey {
|
||||
updatedValue.insert(reactionInfo, at: 0)
|
||||
}
|
||||
else {
|
||||
updatedValue.append(reactionInfo)
|
||||
}
|
||||
|
||||
result.replace(key: emoji, value: updatedValue)
|
||||
}
|
||||
let oldSelectedReactionIndex: Int = self.lastSelectedReactionIndex
|
||||
let updatedSelectedReactionIndex: Int = updatedReactionIndex
|
||||
.defaulting(
|
||||
to: {
|
||||
// If we explicitly provided a 'selectedReaction' value then try to use that
|
||||
if selectedReaction != nil, let targetIndex: Int = updatedReactionInfo.orderedKeys.firstIndex(where: { $0 == selectedReaction }) {
|
||||
return targetIndex
|
||||
}
|
||||
|
||||
// Otherwise try to maintain the index of the currently selected index
|
||||
guard
|
||||
!self.reactionSummaries.isEmpty,
|
||||
let emoji: EmojiWithSkinTones = self.reactionSummaries[safe: oldSelectedReactionIndex]?.emoji,
|
||||
let targetIndex: Int = updatedReactionInfo.orderedKeys.firstIndex(of: emoji)
|
||||
else { return 0 }
|
||||
|
||||
return targetIndex
|
||||
}()
|
||||
)
|
||||
let updatedSummaries: [ReactionSummary] = updatedReactionInfo
|
||||
.orderedKeys
|
||||
.enumerated()
|
||||
.map { index, emoji in
|
||||
ReactionSummary(
|
||||
emoji: emoji,
|
||||
number: updatedReactionInfo.value(forKey: emoji)
|
||||
.defaulting(to: [])
|
||||
.map { Int($0.reaction.count) }
|
||||
.reduce(0, +),
|
||||
isSelected: (index == updatedSelectedReactionIndex)
|
||||
)
|
||||
}
|
||||
|
||||
// Update the general UI
|
||||
|
||||
self.detailInfoLabel.text = updatedSummaries[safe: updatedSelectedReactionIndex]?.description
|
||||
self.clearAllButton.isHidden = !cellViewModel.isSenderOpenGroupModerator
|
||||
|
||||
// Update general properties
|
||||
self.messageViewModel = cellViewModel
|
||||
self.lastSelectedReactionIndex = updatedSelectedReactionIndex
|
||||
|
||||
// Ensure the first load or a load when returning from a child screen runs without animations (if
|
||||
// we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition)
|
||||
guard !initialLoad else {
|
||||
self.reactionSummaries = updatedSummaries
|
||||
self.selectedReactionUserList = updatedReactionInfo
|
||||
.orderedKeys[safe: updatedSelectedReactionIndex]
|
||||
.map { updatedReactionInfo.value(forKey: $0) }
|
||||
.defaulting(to: [])
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
self.reactionContainer.reloadData()
|
||||
self.userListView.reloadData()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Update the collection view content
|
||||
let collectionViewChangeset: StagedChangeset<[ReactionSummary]> = StagedChangeset(
|
||||
source: self.reactionSummaries,
|
||||
target: updatedSummaries
|
||||
)
|
||||
|
||||
// If there are changes then we want to reload both the collection and table views
|
||||
self.reactionContainer.reload(
|
||||
using: collectionViewChangeset,
|
||||
interrupt: { $0.changeCount > 1 }
|
||||
) { [weak self] updatedData in
|
||||
self?.reactionSummaries = updatedData
|
||||
}
|
||||
|
||||
// If we changed the selected index then no need to reload the changes
|
||||
guard
|
||||
oldSelectedReactionIndex == updatedSelectedReactionIndex &&
|
||||
self.reactionSummaries[safe: oldSelectedReactionIndex]?.emoji == updatedSummaries[safe: updatedSelectedReactionIndex]?.emoji
|
||||
else {
|
||||
self.selectedReactionUserList = updatedReactionInfo
|
||||
.orderedKeys[safe: updatedSelectedReactionIndex]
|
||||
.map { updatedReactionInfo.value(forKey: $0) }
|
||||
.defaulting(to: [])
|
||||
self.userListView.reloadData()
|
||||
return
|
||||
}
|
||||
|
||||
let tableChangeset: StagedChangeset<[MessageViewModel.ReactionInfo]> = StagedChangeset(
|
||||
source: self.selectedReactionUserList,
|
||||
target: updatedReactionInfo
|
||||
.orderedKeys[safe: updatedSelectedReactionIndex]
|
||||
.map { updatedReactionInfo.value(forKey: $0) }
|
||||
.defaulting(to: [])
|
||||
)
|
||||
|
||||
self.userListView.reload(
|
||||
using: tableChangeset,
|
||||
deleteSectionsAnimation: .none,
|
||||
insertSectionsAnimation: .none,
|
||||
reloadSectionsAnimation: .none,
|
||||
deleteRowsAnimation: .none,
|
||||
insertRowsAnimation: .none,
|
||||
reloadRowsAnimation: .none,
|
||||
interrupt: { $0.changeCount > 100 }
|
||||
) { [weak self] updatedData in
|
||||
self?.selectedReactionUserList = updatedData
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
let touch = touches.first!
|
||||
let location = touch.location(in: view)
|
||||
if contentView.frame.contains(location) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
} else {
|
||||
guard let touch: UITouch = touches.first, contentView.frame.contains(touch.location(in: view)) else {
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
super.touchesBegan(touches, with: event)
|
||||
}
|
||||
|
||||
@objc func close() {
|
||||
|
@ -204,83 +343,90 @@ final class ReactionListSheet : BaseVC {
|
|||
}
|
||||
|
||||
@objc private func clearAllTapped() {
|
||||
guard let reactMessages = reactionMap.value(forKey: selectedReaction!) else { return }
|
||||
delegate?.cancelAllReact(reactMessages: reactMessages)
|
||||
guard let selectedReaction: EmojiWithSkinTones = self.reactionSummaries.first(where: { $0.isSelected })?.emoji else { return }
|
||||
|
||||
delegate?.removeAllReactions(messageViewModel, for: selectedReaction.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UICollectionView
|
||||
// MARK: - UICollectionView
|
||||
|
||||
extension ReactionListSheet: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||
// MARK: Layout
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
|
||||
return UIEdgeInsets(top: 0, leading: Values.smallSpacing, bottom: 0, trailing: Values.smallSpacing)
|
||||
}
|
||||
|
||||
// MARK: Data Source
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
return reactionMap.orderedKeys.count
|
||||
return self.reactionSummaries.count
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Cell.identifier, for: indexPath) as! Cell
|
||||
let item = reactionMap.orderedItems[indexPath.item]
|
||||
cell.data = (item.0.rawValue, item.1.count)
|
||||
cell.isCurrentSelection = item.0 == selectedReaction!
|
||||
let cell: Cell = collectionView.dequeue(type: Cell.self, for: indexPath)
|
||||
let summary: ReactionSummary = self.reactionSummaries[indexPath.item]
|
||||
|
||||
cell.update(
|
||||
with: summary.emoji.rawValue,
|
||||
count: summary.number,
|
||||
isCurrentSelection: summary.isSelected
|
||||
)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
selectedReaction = reactionMap.orderedKeys[indexPath.item]
|
||||
reloadData()
|
||||
self.handleInteractionUpdates([messageViewModel], updatedReactionIndex: indexPath.item)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UITableView
|
||||
// MARK: - UITableViewDelegate & UITableViewDataSource
|
||||
|
||||
extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
|
||||
// MARK: Table View Data Source
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return reactionMap.value(forKey: selectedReaction!)?.count ?? 0
|
||||
return self.selectedReactionUserList.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell
|
||||
let publicKey = reactionMap.value(forKey: selectedReaction!)![indexPath.row].sender!
|
||||
cell.publicKey = publicKey
|
||||
cell.normalFont = true
|
||||
if publicKey == getUserHexEncodedPublicKey() {
|
||||
cell.accessory = .x
|
||||
} else {
|
||||
cell.accessory = .none
|
||||
}
|
||||
cell.update()
|
||||
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
|
||||
let cellViewModel: MessageViewModel.ReactionInfo = self.selectedReactionUserList[indexPath.row]
|
||||
cell.update(
|
||||
with: cellViewModel.reaction.authorId,
|
||||
profile: cellViewModel.profile,
|
||||
isZombie: false,
|
||||
mediumFont: true,
|
||||
accessory: (cellViewModel.reaction.authorId == self.messageViewModel.currentUserPublicKey ?
|
||||
.x :
|
||||
.none
|
||||
)
|
||||
)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let reactMessage = reactionMap.value(forKey: selectedReaction!)?[indexPath.row], let publicKey = reactMessage.sender else { return }
|
||||
if publicKey == getUserHexEncodedPublicKey() {
|
||||
delegate?.cancelReact(viewItem, for: selectedReaction!)
|
||||
}
|
||||
|
||||
let cellViewModel: MessageViewModel.ReactionInfo = self.selectedReactionUserList[indexPath.row]
|
||||
|
||||
guard
|
||||
let selectedReaction: EmojiWithSkinTones = self.reactionSummaries
|
||||
.first(where: { $0.isSelected })?
|
||||
.emoji,
|
||||
selectedReaction.rawValue == cellViewModel.reaction.emoji,
|
||||
cellViewModel.reaction.authorId == self.messageViewModel.currentUserPublicKey
|
||||
else { return }
|
||||
|
||||
delegate?.removeReact(self.messageViewModel, for: selectedReaction)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Cell
|
||||
// MARK: - Cell
|
||||
|
||||
extension ReactionListSheet {
|
||||
|
||||
fileprivate final class Cell : UICollectionViewCell {
|
||||
var data: (String, Int)? { didSet { update() } }
|
||||
var isCurrentSelection: Bool? { didSet { updateBorder() } }
|
||||
fileprivate final class Cell: UICollectionViewCell {
|
||||
// MARK: - UI
|
||||
|
||||
static let identifier = "ReactionListSheetCell"
|
||||
private static var contentViewHeight: CGFloat = 32
|
||||
private static var contentViewCornerRadius: CGFloat { contentViewHeight / 2 }
|
||||
|
||||
private lazy var snContentView: UIView = {
|
||||
let result = UIView()
|
||||
|
@ -300,27 +446,31 @@ extension ReactionListSheet {
|
|||
let result = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private static var contentViewHeight: CGFloat = 32
|
||||
private static var contentViewCornerRadius: CGFloat { contentViewHeight / 2 }
|
||||
// MARK: - Initialization
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
addSubview(snContentView)
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: [ emojiLabel, numberLabel ])
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
|
||||
let spacing = Values.smallSpacing + 2
|
||||
stackView.spacing = spacing
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, left: spacing, bottom: 0, right: spacing)
|
||||
|
@ -330,29 +480,30 @@ extension ReactionListSheet {
|
|||
snContentView.pin(to: self)
|
||||
}
|
||||
|
||||
private func update() {
|
||||
guard let data = data else { return }
|
||||
emojiLabel.text = data.0
|
||||
numberLabel.text = data.1 < 1000 ? "\(data.1)" : String(format: "%.1f", Float(data.1) / 1000) + "k"
|
||||
}
|
||||
// MARK: - Content
|
||||
|
||||
private func updateBorder() {
|
||||
if isCurrentSelection == true {
|
||||
snContentView.addBorder(with: Colors.accent)
|
||||
} else {
|
||||
snContentView.addBorder(with: .clear)
|
||||
}
|
||||
fileprivate func update(
|
||||
with emoji: String,
|
||||
count: Int,
|
||||
isCurrentSelection: Bool
|
||||
) {
|
||||
snContentView.addBorder(
|
||||
with: (isCurrentSelection == true ? Colors.accent : .clear)
|
||||
)
|
||||
|
||||
emojiLabel.text = emoji
|
||||
numberLabel.text = (count < 1000 ?
|
||||
"\(count)" :
|
||||
String(format: "%.1fk", Float(count) / 1000)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
// MARK: - Delegate
|
||||
|
||||
protocol ReactionDelegate : AnyObject {
|
||||
|
||||
func quickReact(_ viewItem: ConversationViewItem, with emoji: EmojiWithSkinTones)
|
||||
func cancelReact(_ viewItem: ConversationViewItem, for emoji: EmojiWithSkinTones)
|
||||
func cancelAllReact(reactMessages: [ReactMessage])
|
||||
|
||||
protocol ReactionDelegate: AnyObject {
|
||||
func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones)
|
||||
func removeReact(_ cellViewModel: MessageViewModel, for emoji: EmojiWithSkinTones)
|
||||
func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
|
||||
final class ScrollToBottomButton : UIView {
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
final class ScrollToBottomButton: UIView {
|
||||
private weak var delegate: ScrollToBottomButtonDelegate?
|
||||
|
||||
// MARK: Settings
|
||||
// MARK: - Settings
|
||||
|
||||
private static let size: CGFloat = 40
|
||||
private static let iconSize: CGFloat = 16
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(delegate: ScrollToBottomButtonDelegate) {
|
||||
self.delegate = delegate
|
||||
super.init(frame: CGRect.zero)
|
||||
|
@ -55,13 +60,15 @@ final class ScrollToBottomButton : UIView {
|
|||
addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func handleTap() {
|
||||
delegate?.handleScrollToBottomButtonTapped()
|
||||
}
|
||||
}
|
||||
|
||||
protocol ScrollToBottomButtonDelegate : class {
|
||||
|
||||
// MARK: - ScrollToBottomButtonDelegate
|
||||
|
||||
protocol ScrollToBottomButtonDelegate: AnyObject {
|
||||
func handleScrollToBottomButtonTapped()
|
||||
}
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class URLModal : Modal {
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
final class URLModal: Modal {
|
||||
private let url: URL
|
||||
|
||||
// MARK: Lifecycle
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
@ -23,6 +28,7 @@ final class URLModal : Modal {
|
|||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
titleLabel.text = NSLocalizedString("modal_open_url_title", comment: "")
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
// Message
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.textColor = Colors.text
|
||||
|
@ -34,6 +40,7 @@ final class URLModal : Modal {
|
|||
messageLabel.numberOfLines = 0
|
||||
messageLabel.lineBreakMode = .byWordWrapping
|
||||
messageLabel.textAlignment = .center
|
||||
|
||||
// Open button
|
||||
let openButton = UIButton()
|
||||
openButton.set(.height, to: Values.mediumButtonHeight)
|
||||
|
@ -42,16 +49,19 @@ final class URLModal : Modal {
|
|||
openButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
openButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
openButton.setTitle(NSLocalizedString("modal_open_url_button_title", comment: ""), for: UIControl.State.normal)
|
||||
openButton.addTarget(self, action: #selector(openURL), for: UIControl.Event.touchUpInside)
|
||||
openButton.addTarget(self, action: #selector(openUrl), for: UIControl.Event.touchUpInside)
|
||||
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, openButton ])
|
||||
buttonStackView.axis = .horizontal
|
||||
buttonStackView.spacing = Values.mediumSpacing
|
||||
buttonStackView.distribution = .fillEqually
|
||||
|
||||
// Content stack view
|
||||
let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ])
|
||||
contentStackView.axis = .vertical
|
||||
contentStackView.spacing = Values.largeSpacing
|
||||
|
||||
// Main stack view
|
||||
let spacing = Values.largeSpacing - Values.smallFontSize / 2
|
||||
let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ])
|
||||
|
@ -64,9 +74,11 @@ final class URLModal : Modal {
|
|||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc private func openURL() {
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func openUrl() {
|
||||
let url = self.url
|
||||
|
||||
presentingViewController?.dismiss(animated: true, completion: {
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
})
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
final class UserDetailsSheet : Sheet {
|
||||
private let sessionID: String
|
||||
import UIKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class UserDetailsSheet: Sheet {
|
||||
private let profile: Profile
|
||||
|
||||
init(for sessionID: String) {
|
||||
self.sessionID = sessionID
|
||||
init(for profile: Profile) {
|
||||
self.profile = profile
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
|
@ -22,16 +27,21 @@ final class UserDetailsSheet : Sheet {
|
|||
profilePictureView.size = size
|
||||
profilePictureView.set(.width, to: size)
|
||||
profilePictureView.set(.height, to: size)
|
||||
profilePictureView.publicKey = sessionID
|
||||
profilePictureView.update()
|
||||
profilePictureView.update(
|
||||
publicKey: profile.id,
|
||||
profile: profile,
|
||||
threadVariant: .contact
|
||||
)
|
||||
|
||||
// Display name label
|
||||
let displayNameLabel = UILabel()
|
||||
let displayName = Storage.shared.getContact(with: sessionID)?.displayName(for: .regular) ?? sessionID
|
||||
let displayName = profile.displayName()
|
||||
displayNameLabel.text = displayName
|
||||
displayNameLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
|
||||
displayNameLabel.textColor = Colors.text
|
||||
displayNameLabel.numberOfLines = 1
|
||||
displayNameLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
// Session ID label
|
||||
let sessionIDLabel = UILabel()
|
||||
sessionIDLabel.textColor = Colors.text
|
||||
|
@ -39,7 +49,8 @@ final class UserDetailsSheet : Sheet {
|
|||
sessionIDLabel.numberOfLines = 0
|
||||
sessionIDLabel.lineBreakMode = .byCharWrapping
|
||||
sessionIDLabel.accessibilityLabel = "Session ID label"
|
||||
sessionIDLabel.text = sessionID
|
||||
sessionIDLabel.text = profile.id
|
||||
|
||||
// Session ID label container
|
||||
let sessionIDLabelContainer = UIView()
|
||||
sessionIDLabelContainer.addSubview(sessionIDLabel)
|
||||
|
@ -47,23 +58,26 @@ final class UserDetailsSheet : Sheet {
|
|||
sessionIDLabelContainer.layer.cornerRadius = TextField.cornerRadius
|
||||
sessionIDLabelContainer.layer.borderWidth = 1
|
||||
sessionIDLabelContainer.layer.borderColor = isLightMode ? UIColor.black.cgColor : UIColor.white.cgColor
|
||||
|
||||
// Copy button
|
||||
let copyButton = Button(style: .prominentOutline, size: .medium)
|
||||
copyButton.setTitle(NSLocalizedString("copy", comment: ""), for: UIControl.State.normal)
|
||||
copyButton.addTarget(self, action: #selector(copySessionID), for: UIControl.Event.touchUpInside)
|
||||
copyButton.set(.width, to: 160)
|
||||
|
||||
// Stack view
|
||||
let stackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel, sessionIDLabelContainer, copyButton, UIView.vSpacer(Values.largeSpacing) ])
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = Values.largeSpacing
|
||||
stackView.alignment = .center
|
||||
|
||||
// Constraints
|
||||
contentView.addSubview(stackView)
|
||||
stackView.pin(to: contentView, withInset: Values.largeSpacing)
|
||||
}
|
||||
|
||||
@objc private func copySessionID() {
|
||||
UIPasteboard.general.string = sessionID
|
||||
UIPasteboard.general.string = profile.id
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import Curve25519Kit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate {
|
||||
private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
||||
|
@ -71,12 +78,7 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
|||
// Set up tab bar
|
||||
view.addSubview(tabBar)
|
||||
tabBar.pin(.leading, to: .leading, of: view)
|
||||
let tabBarInset: CGFloat
|
||||
if #available(iOS 13, *) {
|
||||
tabBarInset = UIDevice.current.isIPad ? navigationBar.height() + 20 : navigationBar.height()
|
||||
} else {
|
||||
tabBarInset = 0
|
||||
}
|
||||
let tabBarInset: CGFloat = (UIDevice.current.isIPad ? navigationBar.height() + 20 : navigationBar.height())
|
||||
tabBar.pin(.top, to: .top, of: view, withInset: tabBarInset)
|
||||
view.pin(.trailing, to: .trailing, of: tabBar)
|
||||
// Set up page VC constraints
|
||||
|
@ -88,13 +90,7 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
|||
view.pin(.bottom, to: .bottom, of: pageVCView)
|
||||
let screen = UIScreen.main.bounds
|
||||
pageVCView.set(.width, to: screen.width)
|
||||
let height: CGFloat
|
||||
if #available(iOS 13, *) {
|
||||
height = navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight
|
||||
} else {
|
||||
let statusBarHeight = UIApplication.shared.statusBarFrame.height
|
||||
height = navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight - statusBarHeight
|
||||
}
|
||||
let height: CGFloat = (navigationController!.view.bounds.height - navigationBar.height() - TabBar.snHeight)
|
||||
pageVCView.set(.height, to: height)
|
||||
enterPublicKeyVC.constrainHeight(to: height)
|
||||
scanQRCodePlaceholderVC.constrainHeight(to: height)
|
||||
|
@ -150,10 +146,11 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
|||
}.catch { error in
|
||||
modalActivityIndicator.dismiss {
|
||||
var messageOrNil: String?
|
||||
if let error = error as? SnodeAPI.Error {
|
||||
if let error = error as? SnodeAPIError {
|
||||
switch error {
|
||||
case .decryptionFailed, .hashingFailed, .validationFailed: messageOrNil = error.errorDescription
|
||||
default: break
|
||||
case .decryptionFailed, .hashingFailed, .validationFailed:
|
||||
messageOrNil = error.errorDescription
|
||||
default: break
|
||||
}
|
||||
}
|
||||
let message = messageOrNil ?? "Please check the Session ID or ONS name and try again"
|
||||
|
@ -166,10 +163,16 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
|
|||
}
|
||||
}
|
||||
|
||||
private func startNewDM(with sessionID: String) {
|
||||
let thread = TSContactThread.getOrCreateThread(contactSessionID: sessionID)
|
||||
private func startNewDM(with sessionId: String) {
|
||||
let maybeThread: SessionThread? = Storage.shared.write { db in
|
||||
try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact)
|
||||
}
|
||||
|
||||
guard maybeThread != nil else { return }
|
||||
|
||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false)
|
||||
|
||||
SessionApp.presentConversation(for: sessionId, action: .compose, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
//
|
||||
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
public struct EmojiWithSkinTones: Hashable {
|
||||
import Foundation
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionMessagingKit
|
||||
|
||||
public struct EmojiWithSkinTones: Hashable, Equatable, ContentEquatable, ContentIdentifiable {
|
||||
let baseEmoji: Emoji
|
||||
let skinTones: [Emoji.SkinTone]?
|
||||
|
||||
|
||||
init(baseEmoji: Emoji, skinTones: [Emoji.SkinTone]? = nil) {
|
||||
self.baseEmoji = baseEmoji
|
||||
|
||||
|
@ -40,28 +43,63 @@ public struct EmojiWithSkinTones: Hashable {
|
|||
}
|
||||
|
||||
extension Emoji {
|
||||
private static let emojiWithPreferredSkinToneCollection = "Emoji+PreferredSkinTonePermutation"
|
||||
|
||||
static func allSendableEmojiByCategoryWithPreferredSkinTones(transaction: YapDatabaseReadTransaction) -> [Category: [EmojiWithSkinTones]] {
|
||||
return Category.allCases.reduce(into: [Category: [EmojiWithSkinTones]]()) { result, category in
|
||||
result[category] = category.normalizedEmoji.filter { $0.available }.map { $0.withPreferredSkinTones(transaction: transaction) }
|
||||
}
|
||||
static func getRecent(_ db: Database, withDefaultEmoji: Bool) throws -> [String] {
|
||||
let recentReactionEmoji: [String] = (db[.recentReactionEmoji]?
|
||||
.components(separatedBy: ","))
|
||||
.defaulting(to: [])
|
||||
|
||||
// No need to continue if we don't want the default emoji to pad out the list
|
||||
guard withDefaultEmoji else { return recentReactionEmoji }
|
||||
|
||||
// Add in our default emoji if desired
|
||||
let defaultEmoji = ["🙈", "🙉", "🙊", "😈", "🥸", "🐀"]
|
||||
.filter { !recentReactionEmoji.contains($0) }
|
||||
|
||||
return Array(recentReactionEmoji
|
||||
.appending(contentsOf: defaultEmoji)
|
||||
.prefix(6))
|
||||
}
|
||||
|
||||
static func addRecent(_ db: Database, emoji: String) {
|
||||
// Add/move the emoji to the start of the most recent list
|
||||
db[.recentReactionEmoji] = (db[.recentReactionEmoji]?
|
||||
.components(separatedBy: ","))
|
||||
.defaulting(to: [])
|
||||
.filter { $0 != emoji }
|
||||
.inserting(emoji, at: 0)
|
||||
.prefix(6)
|
||||
.joined(separator: ",")
|
||||
}
|
||||
|
||||
func withPreferredSkinTones(transaction: YapDatabaseReadTransaction) -> EmojiWithSkinTones {
|
||||
guard let rawSkinTones = transaction.object(forKey: rawValue, inCollection: Self.emojiWithPreferredSkinToneCollection) as? [String] else {
|
||||
static func allSendableEmojiByCategoryWithPreferredSkinTones(_ db: Database) -> [Category: [EmojiWithSkinTones]] {
|
||||
return Category.allCases
|
||||
.reduce(into: [Category: [EmojiWithSkinTones]]()) { result, category in
|
||||
result[category] = category.normalizedEmoji
|
||||
.filter { $0.available }
|
||||
.map { $0.withPreferredSkinTones(db) }
|
||||
}
|
||||
}
|
||||
|
||||
private func withPreferredSkinTones(_ db: Database) -> EmojiWithSkinTones {
|
||||
guard let rawSkinTones: String = db[.emojiPreferredSkinTones(emoji: rawValue)] else {
|
||||
return EmojiWithSkinTones(baseEmoji: self, skinTones: nil)
|
||||
}
|
||||
|
||||
return EmojiWithSkinTones(baseEmoji: self, skinTones: rawSkinTones.compactMap { SkinTone(rawValue: $0) })
|
||||
return EmojiWithSkinTones(
|
||||
baseEmoji: self,
|
||||
skinTones: rawSkinTones
|
||||
.split(separator: ",")
|
||||
.compactMap { SkinTone(rawValue: String($0)) }
|
||||
)
|
||||
}
|
||||
|
||||
func setPreferredSkinTones(_ preferredSkinTonePermutation: [SkinTone]?, transaction: YapDatabaseReadWriteTransaction) {
|
||||
if let preferredSkinTonePermutation = preferredSkinTonePermutation {
|
||||
transaction.setObject(preferredSkinTonePermutation.map { $0.rawValue }, forKey: rawValue, inCollection: Self.emojiWithPreferredSkinToneCollection)
|
||||
} else {
|
||||
transaction.removeObject(forKey: rawValue, inCollection: Self.emojiWithPreferredSkinToneCollection)
|
||||
}
|
||||
func setPreferredSkinTones(_ db: Database, preferredSkinTonePermutation: [SkinTone]?) {
|
||||
db[.emojiPreferredSkinTones(emoji: rawValue)] = preferredSkinTonePermutation
|
||||
.map { preferredSkinTonePermutation in
|
||||
preferredSkinTonePermutation
|
||||
.map { $0.rawValue }
|
||||
.joined(separator: ",")
|
||||
}
|
||||
}
|
||||
|
||||
init?(_ string: String) {
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
extension Storage {
|
||||
|
||||
private static let emojiPickerCollection = "EmojiPickerCollection"
|
||||
private static let recentEmojiKey = "recentEmoji"
|
||||
|
||||
func getRecentEmoji(withDefaultEmoji: Bool, transaction: YapDatabaseReadTransaction) -> [EmojiWithSkinTones] {
|
||||
var rawRecentEmoji = transaction.object(forKey: Self.recentEmojiKey, inCollection: Self.emojiPickerCollection) as? [String] ?? []
|
||||
let defaultEmoji = ["🙈", "🙉", "🙊", "😈", "🥸", "🐀"].filter{ !rawRecentEmoji.contains($0) }
|
||||
|
||||
if rawRecentEmoji.count < 6 && withDefaultEmoji {
|
||||
rawRecentEmoji.append(contentsOf: defaultEmoji[..<(defaultEmoji.count - rawRecentEmoji.count + 1)])
|
||||
}
|
||||
|
||||
return rawRecentEmoji.compactMap { EmojiWithSkinTones(rawValue: $0) }
|
||||
}
|
||||
|
||||
func recordRecentEmoji(_ emoji: EmojiWithSkinTones, transaction: YapDatabaseReadWriteTransaction) {
|
||||
let recentEmoji = getRecentEmoji(withDefaultEmoji: false, transaction: transaction)
|
||||
guard recentEmoji.first != emoji else { return }
|
||||
guard emoji.isNormalized else {
|
||||
recordRecentEmoji(emoji.normalized, transaction: transaction)
|
||||
return
|
||||
}
|
||||
|
||||
var newRecentEmoji = recentEmoji
|
||||
|
||||
// Remove any existing entries for this emoji
|
||||
newRecentEmoji.removeAll { emoji == $0 }
|
||||
// Insert the selected emoji at the start of the list
|
||||
newRecentEmoji.insert(emoji, at: 0)
|
||||
// Truncate the recent emoji list to a maximum of 50 stored
|
||||
newRecentEmoji = Array(newRecentEmoji[0..<min(50, newRecentEmoji.count)])
|
||||
|
||||
transaction.setObject(newRecentEmoji.map { $0.rawValue }, forKey: Self.recentEmojiKey, inCollection: Self.emojiPickerCollection)
|
||||
}
|
||||
}
|
|
@ -1,10 +1,12 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import PureLayout
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import NVActivityIndicatorView
|
||||
|
||||
class EmptySearchResultCell: UITableViewCell {
|
||||
static let reuseIdentifier = "EmptySearchResultCell"
|
||||
|
||||
private lazy var messageLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textAlignment = .center
|
||||
|
@ -24,6 +26,7 @@ class EmptySearchResultCell: UITableViewCell {
|
|||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
|
||||
contentView.addSubview(messageLabel)
|
||||
messageLabel.autoSetDimension(.height, toSize: 150)
|
||||
|
|
|
@ -1,11 +1,42 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc
|
||||
class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
||||
fileprivate typealias SectionModel = ArraySection<SearchSection, SessionThreadViewModel>
|
||||
|
||||
let isRecentSearchResultsEnabled = false
|
||||
// MARK: - SearchSection
|
||||
|
||||
enum SearchSection: Int, Differentiable {
|
||||
case noResults
|
||||
case contactsAndGroups
|
||||
case messages
|
||||
}
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
private lazy var defaultSearchResults: [SectionModel] = {
|
||||
let result: SessionThreadViewModel? = Storage.shared.read { db -> SessionThreadViewModel? in
|
||||
try SessionThreadViewModel
|
||||
.noteToSelfOnlyQuery(userPublicKey: getUserHexEncodedPublicKey(db))
|
||||
.fetchOne(db)
|
||||
}
|
||||
|
||||
return [ result.map { ArraySection(model: .contactsAndGroups, elements: [$0]) } ]
|
||||
.compactMap { $0 }
|
||||
}()
|
||||
private lazy var searchResultSet: [SectionModel] = self.defaultSearchResults
|
||||
private var termForCurrentSearchResultSet: String = ""
|
||||
private var lastSearchText: String?
|
||||
private var refreshTimer: Timer?
|
||||
|
||||
var isLoading = false
|
||||
|
||||
@objc public var searchText = "" {
|
||||
didSet {
|
||||
|
@ -14,55 +45,37 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
refreshSearchResults()
|
||||
}
|
||||
}
|
||||
var recentSearchResults: [String] = Array(Storage.shared.getRecentSearchResults().reversed())
|
||||
var defaultSearchResults: HomeScreenSearchResultSet = HomeScreenSearchResultSet.noteToSelfOnly
|
||||
var searchResultSet: HomeScreenSearchResultSet = HomeScreenSearchResultSet.empty
|
||||
private var lastSearchText: String?
|
||||
var searcher: FullTextSearcher {
|
||||
return FullTextSearcher.shared
|
||||
}
|
||||
var isLoading = false
|
||||
|
||||
enum SearchSection: Int {
|
||||
case noResults
|
||||
case contacts
|
||||
case messages
|
||||
case recent
|
||||
}
|
||||
|
||||
// MARK: UI Components
|
||||
|
||||
// MARK: - UI Components
|
||||
|
||||
internal lazy var searchBar: SearchBar = {
|
||||
let result = SearchBar()
|
||||
let result: SearchBar = SearchBar()
|
||||
result.tintColor = Colors.text
|
||||
result.delegate = self
|
||||
result.showsCancelButton = true
|
||||
return result
|
||||
}()
|
||||
|
||||
|
||||
internal lazy var tableView: UITableView = {
|
||||
let result = UITableView(frame: .zero, style: .grouped)
|
||||
let result: UITableView = UITableView(frame: .zero, style: .grouped)
|
||||
result.rowHeight = UITableView.automaticDimension
|
||||
result.estimatedRowHeight = 60
|
||||
result.separatorStyle = .none
|
||||
result.keyboardDismissMode = .onDrag
|
||||
result.register(EmptySearchResultCell.self, forCellReuseIdentifier: EmptySearchResultCell.reuseIdentifier)
|
||||
result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier)
|
||||
result.register(view: EmptySearchResultCell.self)
|
||||
result.register(view: FullConversationCell.self)
|
||||
result.showsVerticalScrollIndicator = false
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Dependencies
|
||||
|
||||
var dbReadConnection: YapDatabaseConnection {
|
||||
return OWSPrimaryStorage.shared().dbReadConnection
|
||||
}
|
||||
// MARK: - View Lifecycle
|
||||
|
||||
// MARK: View Lifecycle
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setUpGradientBackground()
|
||||
|
||||
setUpGradientBackground()
|
||||
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
view.addSubview(tableView)
|
||||
|
@ -74,22 +87,22 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
navigationItem.hidesBackButton = true
|
||||
setupNavigationBar()
|
||||
}
|
||||
|
||||
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
searchBar.becomeFirstResponder()
|
||||
}
|
||||
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
searchBar.resignFirstResponder()
|
||||
}
|
||||
|
||||
|
||||
private func setupNavigationBar() {
|
||||
// This is a workaround for a UI issue that the navigation bar can be a bit higher if
|
||||
// the search bar is put directly to be the titleView. And this can cause the tableView
|
||||
// in home screen doing a weird scrolling when going back to home screen.
|
||||
let searchBarContainer = UIView()
|
||||
let searchBarContainer: UIView = UIView()
|
||||
searchBarContainer.layoutMargins = UIEdgeInsets.zero
|
||||
searchBar.sizeToFit()
|
||||
searchBar.layoutMargins = UIEdgeInsets.zero
|
||||
|
@ -103,37 +116,35 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
if UIDevice.current.isIPad {
|
||||
let ipadCancelButton = UIButton()
|
||||
ipadCancelButton.setTitle("Cancel", for: .normal)
|
||||
ipadCancelButton.addTarget(self, action: #selector(cancel(_:)), for: .touchUpInside)
|
||||
ipadCancelButton.addTarget(self, action: #selector(cancel), for: .touchUpInside)
|
||||
ipadCancelButton.setTitleColor(Colors.text, for: .normal)
|
||||
searchBarContainer.addSubview(ipadCancelButton)
|
||||
ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer)
|
||||
ipadCancelButton.autoVCenterInSuperview()
|
||||
searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing)
|
||||
searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing)
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
searchBar.autoPinEdgesToSuperviewMargins()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func reloadTableData() {
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
// MARK: Update Search Results
|
||||
|
||||
var refreshTimer: Timer?
|
||||
|
||||
// MARK: - Update Search Results
|
||||
|
||||
private func refreshSearchResults() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = WeakTimer.scheduledTimer(timeInterval: 0.1, target: self, userInfo: nil, repeats: false) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.updateSearchResults(searchText: self.searchText)
|
||||
self?.updateSearchResults(searchText: (self?.searchText ?? ""))
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSearchResults(searchText rawSearchText: String) {
|
||||
|
||||
private func updateSearchResults(searchText rawSearchText: String) {
|
||||
let searchText = rawSearchText.stripped
|
||||
|
||||
guard searchText.count > 0 else {
|
||||
searchResultSet = defaultSearchResults
|
||||
lastSearchText = nil
|
||||
|
@ -144,56 +155,81 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
|
||||
lastSearchText = searchText
|
||||
|
||||
var searchResults: HomeScreenSearchResultSet?
|
||||
self.dbReadConnection.asyncRead({[weak self] transaction in
|
||||
guard let self = self else { return }
|
||||
self.isLoading = true
|
||||
// The max search result count is set according to the keyword length. This is just a workaround for performance issue.
|
||||
// The longer and more accurate the keyword is, the less search results should there be.
|
||||
searchResults = self.searcher.searchForHomeScreen(searchText: searchText, maxSearchResults: 500, transaction: transaction)
|
||||
}, completionBlock: { [weak self] in
|
||||
AssertIsOnMainThread()
|
||||
guard let self = self, let results = searchResults, self.lastSearchText == searchText else { return }
|
||||
self.searchResultSet = results
|
||||
self.isLoading = false
|
||||
self.reloadTableData()
|
||||
self.refreshTimer = nil
|
||||
})
|
||||
let result: Result<[SectionModel], Error>? = Storage.shared.read { db -> Result<[SectionModel], Error> in
|
||||
do {
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel
|
||||
.contactsAndGroupsQuery(
|
||||
userPublicKey: userPublicKey,
|
||||
pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText),
|
||||
searchTerm: searchText
|
||||
)
|
||||
.fetchAll(db)
|
||||
let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel
|
||||
.messagesQuery(
|
||||
userPublicKey: userPublicKey,
|
||||
pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText)
|
||||
)
|
||||
.fetchAll(db)
|
||||
|
||||
return .success([
|
||||
ArraySection(model: .contactsAndGroups, elements: contactsAndGroupsResults),
|
||||
ArraySection(model: .messages, elements: messageResults)
|
||||
])
|
||||
}
|
||||
catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let sections):
|
||||
let hasResults: Bool = (
|
||||
!searchText.isEmpty &&
|
||||
(sections.map { $0.elements.count }.reduce(0, +) > 0)
|
||||
)
|
||||
|
||||
self.termForCurrentSearchResultSet = searchText
|
||||
self.searchResultSet = [
|
||||
(hasResults ? nil : [ArraySection(model: .noResults, elements: [SessionThreadViewModel(unreadCount: 0)])]),
|
||||
(hasResults ? sections : nil)
|
||||
]
|
||||
.compactMap { $0 }
|
||||
.flatMap { $0 }
|
||||
self.isLoading = false
|
||||
self.reloadTableData()
|
||||
self.refreshTimer = nil
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc func clearRecentSearchResults() {
|
||||
recentSearchResults = []
|
||||
tableView.reloadSections([ SearchSection.recent.rawValue ], with: .top)
|
||||
Storage.shared.clearRecentSearchResults()
|
||||
}
|
||||
|
||||
@objc func cancel(_ sender: Any) {
|
||||
@objc func cancel() {
|
||||
self.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UISearchBarDelegate
|
||||
|
||||
extension GlobalSearchViewController: UISearchBarDelegate {
|
||||
public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
|
||||
self.updateSearchText()
|
||||
}
|
||||
|
||||
|
||||
public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
|
||||
self.updateSearchText()
|
||||
}
|
||||
|
||||
|
||||
public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
self.updateSearchText()
|
||||
}
|
||||
|
||||
|
||||
public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
||||
searchBar.text = nil
|
||||
searchBar.resignFirstResponder()
|
||||
self.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
|
||||
func updateSearchText() {
|
||||
guard let searchText = searchBar.text?.ows_stripped() else { return }
|
||||
self.searchText = searchText
|
||||
|
@ -201,53 +237,59 @@ extension GlobalSearchViewController: UISearchBarDelegate {
|
|||
}
|
||||
|
||||
// MARK: - UITableViewDelegate & UITableViewDataSource
|
||||
|
||||
extension GlobalSearchViewController {
|
||||
|
||||
// MARK: UITableViewDelegate
|
||||
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: false)
|
||||
guard let searchSection = SearchSection(rawValue: indexPath.section) else { return }
|
||||
switch searchSection {
|
||||
case .noResults:
|
||||
SNLog("shouldn't be able to tap 'no results' section")
|
||||
case .contacts:
|
||||
let sectionResults = searchResultSet.conversations
|
||||
guard let searchResult = sectionResults[safe: indexPath.row] else { return }
|
||||
show(searchResult.thread.threadRecord, highlightedMessageID: nil, animated: true)
|
||||
case .messages:
|
||||
let sectionResults = searchResultSet.messages
|
||||
guard let searchResult = sectionResults[safe: indexPath.row] else { return }
|
||||
show(searchResult.thread.threadRecord, highlightedMessageID: searchResult.message?.uniqueId, animated: true)
|
||||
case .recent:
|
||||
guard let threadId = recentSearchResults[safe: indexPath.row], let thread = TSThread.fetch(uniqueId: threadId) else { return }
|
||||
show(thread, highlightedMessageID: nil, animated: true, isFromRecent: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func show(_ thread: TSThread, highlightedMessageID: String?, animated: Bool, isFromRecent: Bool = false) {
|
||||
if let threadId = thread.uniqueId {
|
||||
recentSearchResults = Array(Storage.shared.addSearchResults(threadID: threadId).reversed())
|
||||
}
|
||||
|
||||
DispatchMainThreadSafe {
|
||||
if let presentedVC = self.presentedViewController {
|
||||
presentedVC.dismiss(animated: false, completion: nil)
|
||||
}
|
||||
let conversationVC = ConversationVC(thread: thread, focusedMessageID: highlightedMessageID)
|
||||
var viewControllers = self.navigationController?.viewControllers
|
||||
if isFromRecent, let index = viewControllers?.firstIndex(of: self) { viewControllers?.remove(at: index) }
|
||||
viewControllers?.append(conversationVC)
|
||||
self.navigationController?.setViewControllers(viewControllers!, animated: true)
|
||||
let section: SectionModel = self.searchResultSet[indexPath.section]
|
||||
|
||||
switch section.model {
|
||||
case .noResults: break
|
||||
case .contactsAndGroups, .messages:
|
||||
show(
|
||||
threadId: section.elements[indexPath.row].threadId,
|
||||
threadVariant: section.elements[indexPath.row].threadVariant,
|
||||
focusedInteractionId: section.elements[indexPath.row].interactionId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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? {
|
||||
UIView()
|
||||
}
|
||||
|
@ -260,79 +302,36 @@ extension GlobalSearchViewController {
|
|||
guard nil != self.tableView(tableView, titleForHeaderInSection: section) else {
|
||||
return .leastNonzeroMagnitude
|
||||
}
|
||||
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
|
||||
|
||||
public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
guard let searchSection = SearchSection(rawValue: section) else { return nil }
|
||||
|
||||
guard let title = self.tableView(tableView, titleForHeaderInSection: section) else {
|
||||
guard let title: String = self.tableView(tableView, titleForHeaderInSection: section) else {
|
||||
return UIView()
|
||||
}
|
||||
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = title
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
|
||||
|
||||
let container = UIView()
|
||||
container.backgroundColor = Colors.cellBackground
|
||||
container.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, left: Values.mediumSpacing, bottom: Values.smallSpacing, right: Values.mediumSpacing)
|
||||
container.addSubview(titleLabel)
|
||||
titleLabel.autoPinEdgesToSuperviewMargins()
|
||||
|
||||
if searchSection == .recent {
|
||||
let clearButton = UIButton()
|
||||
clearButton.setTitle("Clear", for: .normal)
|
||||
clearButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
clearButton.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
clearButton.addTarget(self, action: #selector(clearRecentSearchResults), for: .touchUpInside)
|
||||
container.addSubview(clearButton)
|
||||
clearButton.autoPinTrailingToSuperviewMargin()
|
||||
clearButton.autoVCenterInSuperview()
|
||||
}
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
guard let searchSection = SearchSection(rawValue: section) else { return nil }
|
||||
|
||||
switch searchSection {
|
||||
case .noResults:
|
||||
return nil
|
||||
case .contacts:
|
||||
if searchResultSet.conversations.count > 0 {
|
||||
return NSLocalizedString("SEARCH_SECTION_CONTACTS", comment: "")
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
case .messages:
|
||||
if searchResultSet.messages.count > 0 {
|
||||
return NSLocalizedString("SEARCH_SECTION_MESSAGES", comment: "")
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
case .recent:
|
||||
if recentSearchResults.count > 0 && searchText.isEmpty && isRecentSearchResultsEnabled {
|
||||
return NSLocalizedString("SEARCH_SECTION_RECENT", comment: "")
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
guard let searchSection = SearchSection(rawValue: section) else { return 0 }
|
||||
switch searchSection {
|
||||
case .noResults:
|
||||
return (searchText.count > 0 && searchResultSet.isEmpty) ? 1 : 0
|
||||
case .contacts:
|
||||
return searchResultSet.conversations.count
|
||||
case .messages:
|
||||
return searchResultSet.messages.count
|
||||
case .recent:
|
||||
return searchText.isEmpty && isRecentSearchResultsEnabled ? recentSearchResults.count : 0
|
||||
let section: SectionModel = self.searchResultSet[section]
|
||||
|
||||
switch section.model {
|
||||
case .noResults: return nil
|
||||
case .contactsAndGroups: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_CONTACTS".localized())
|
||||
case .messages: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_MESSAGES".localized())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -341,41 +340,23 @@ extension GlobalSearchViewController {
|
|||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
|
||||
guard let searchSection = SearchSection(rawValue: indexPath.section) else {
|
||||
return UITableViewCell()
|
||||
}
|
||||
|
||||
switch searchSection {
|
||||
case .noResults:
|
||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: EmptySearchResultCell.reuseIdentifier) as? EmptySearchResultCell, indexPath.row == 0 else { return UITableViewCell() }
|
||||
cell.configure(isLoading: isLoading)
|
||||
return cell
|
||||
case .contacts:
|
||||
let sectionResults = searchResultSet.conversations
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
|
||||
cell.isShowingGlobalSearchResult = true
|
||||
let searchResult = sectionResults[safe: indexPath.row]
|
||||
cell.threadViewModel = searchResult?.thread
|
||||
cell.configure(snippet: searchResult?.snippet, searchText: searchResultSet.searchText)
|
||||
return cell
|
||||
case .messages:
|
||||
let sectionResults = searchResultSet.messages
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
|
||||
cell.isShowingGlobalSearchResult = true
|
||||
let searchResult = sectionResults[safe: indexPath.row]
|
||||
cell.threadViewModel = searchResult?.thread
|
||||
cell.configure(snippet: searchResult?.snippet, searchText: searchResultSet.searchText, message: searchResult?.message)
|
||||
return cell
|
||||
case .recent:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
|
||||
cell.isShowingGlobalSearchResult = true
|
||||
dbReadConnection.read { transaction in
|
||||
guard let threadId = self.recentSearchResults[safe: indexPath.row], let thread = TSThread.fetch(uniqueId: threadId, transaction: transaction) else { return }
|
||||
cell.threadViewModel = ThreadViewModel(thread: thread, transaction: transaction)
|
||||
}
|
||||
cell.configureForRecent()
|
||||
return cell
|
||||
let section: SectionModel = self.searchResultSet[indexPath.section]
|
||||
|
||||
switch section.model {
|
||||
case .noResults:
|
||||
let cell: EmptySearchResultCell = tableView.dequeue(type: EmptySearchResultCell.self, for: indexPath)
|
||||
cell.configure(isLoading: isLoading)
|
||||
return cell
|
||||
|
||||
case .contactsAndGroups:
|
||||
let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
|
||||
cell.updateForContactAndGroupSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet)
|
||||
return cell
|
||||
|
||||
case .messages:
|
||||
let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
|
||||
cell.updateForMessageSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
extension Storage{
|
||||
|
||||
private static let recentSearchResultDatabaseCollection = "RecentSearchResultDatabaseCollection"
|
||||
private static let recentSearchResultKey = "RecentSearchResult"
|
||||
|
||||
public func getRecentSearchResults() -> [String] {
|
||||
var result: [String]?
|
||||
Storage.read { transaction in
|
||||
result = transaction.object(forKey: Storage.recentSearchResultKey, inCollection: Storage.recentSearchResultDatabaseCollection) as? [String]
|
||||
}
|
||||
return result ?? []
|
||||
}
|
||||
|
||||
public func clearRecentSearchResults() {
|
||||
Storage.write { transaction in
|
||||
transaction.removeObject(forKey: Storage.recentSearchResultKey, inCollection: Storage.recentSearchResultDatabaseCollection)
|
||||
}
|
||||
}
|
||||
|
||||
public func addSearchResults(threadID: String) -> [String] {
|
||||
var recentSearchResults = getRecentSearchResults()
|
||||
if recentSearchResults.count > 20 { recentSearchResults.remove(at: 0) } // Limit the size of the collection to 20
|
||||
if let index = recentSearchResults.firstIndex(of: threadID) { recentSearchResults.remove(at: index) }
|
||||
recentSearchResults.append(threadID)
|
||||
Storage.write { transaction in
|
||||
transaction.setObject(recentSearchResults, forKey: Storage.recentSearchResultKey, inCollection: Storage.recentSearchResultDatabaseCollection)
|
||||
}
|
||||
return recentSearchResults
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,302 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public class HomeViewModel {
|
||||
public typealias SectionModel = ArraySection<Section, SessionThreadViewModel>
|
||||
|
||||
// MARK: - Section
|
||||
|
||||
public enum Section: Differentiable {
|
||||
case messageRequests
|
||||
case threads
|
||||
case loadMore
|
||||
}
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
public static let pageSize: Int = 15
|
||||
|
||||
public struct State: Equatable {
|
||||
let showViewedSeedBanner: Bool
|
||||
let hasHiddenMessageRequests: Bool
|
||||
let unreadMessageRequestThreadCount: Int
|
||||
let userProfile: Profile?
|
||||
|
||||
init(
|
||||
showViewedSeedBanner: Bool = !Storage.shared[.hasViewedSeed],
|
||||
hasHiddenMessageRequests: Bool = Storage.shared[.hasHiddenMessageRequests],
|
||||
unreadMessageRequestThreadCount: Int = 0,
|
||||
userProfile: Profile? = nil
|
||||
) {
|
||||
self.showViewedSeedBanner = showViewedSeedBanner
|
||||
self.hasHiddenMessageRequests = hasHiddenMessageRequests
|
||||
self.unreadMessageRequestThreadCount = unreadMessageRequestThreadCount
|
||||
self.userProfile = userProfile
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
self.state = Storage.shared.read { db in try HomeViewModel.retrieveState(db) }
|
||||
.defaulting(to: State())
|
||||
self.pagedDataObserver = nil
|
||||
|
||||
// Note: Since this references self we need to finish initializing before setting it, we
|
||||
// also want to skip the initial query and trigger it async so that the push animation
|
||||
// doesn't stutter (it should load basically immediately but without this there is a
|
||||
// distinct stutter)
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey()
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
self.pagedDataObserver = PagedDatabaseObserver(
|
||||
pagedTable: SessionThread.self,
|
||||
pageSize: HomeViewModel.pageSize,
|
||||
idColumn: .id,
|
||||
observedChanges: [
|
||||
PagedData.ObservedChanges(
|
||||
table: SessionThread.self,
|
||||
columns: [
|
||||
.id,
|
||||
.shouldBeVisible,
|
||||
.isPinned,
|
||||
.mutedUntilTimestamp,
|
||||
.onlyNotifyForMentions
|
||||
]
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: Interaction.self,
|
||||
columns: [
|
||||
.body,
|
||||
.wasRead
|
||||
],
|
||||
joinToPagedType: {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: Contact.self,
|
||||
columns: [.isBlocked],
|
||||
joinToPagedType: {
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: Profile.self,
|
||||
columns: [.name, .nickname, .profilePictureFileName],
|
||||
joinToPagedType: {
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: ClosedGroup.self,
|
||||
columns: [.name],
|
||||
joinToPagedType: {
|
||||
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: OpenGroup.self,
|
||||
columns: [.name, .imageData],
|
||||
joinToPagedType: {
|
||||
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: RecipientState.self,
|
||||
columns: [.state],
|
||||
joinToPagedType: {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
|
||||
|
||||
return """
|
||||
LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
|
||||
LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
|
||||
"""
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: ThreadTypingIndicator.self,
|
||||
columns: [.threadId],
|
||||
joinToPagedType: {
|
||||
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(typingIndicator[.threadId]) = \(thread[.id])")
|
||||
}()
|
||||
)
|
||||
],
|
||||
/// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query
|
||||
joinSQL: SessionThreadViewModel.optimisedJoinSQL,
|
||||
filterSQL: SessionThreadViewModel.homeFilterSQL(userPublicKey: userPublicKey),
|
||||
groupSQL: SessionThreadViewModel.groupSQL,
|
||||
orderSQL: SessionThreadViewModel.homeOrderSQL,
|
||||
dataQuery: SessionThreadViewModel.baseQuery(
|
||||
userPublicKey: userPublicKey,
|
||||
filterSQL: SessionThreadViewModel.homeFilterSQL(userPublicKey: userPublicKey),
|
||||
groupSQL: SessionThreadViewModel.groupSQL,
|
||||
orderSQL: SessionThreadViewModel.homeOrderSQL
|
||||
),
|
||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
guard let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
|
||||
return
|
||||
}
|
||||
|
||||
// If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes
|
||||
// to be sent to the callback if we ever start observing again (when we have the callback it needs
|
||||
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
|
||||
// correct order)
|
||||
guard let onThreadChange: (([SectionModel]) -> ()) = self?.onThreadChange else {
|
||||
self?.unobservedThreadDataChanges = updatedThreadData
|
||||
return
|
||||
}
|
||||
|
||||
onThreadChange(updatedThreadData)
|
||||
}
|
||||
)
|
||||
|
||||
// Run the initial query on the main thread so we prevent the app from leaving the loading screen
|
||||
// until we have data (Note: the `.pageBefore` will query from a `0` offset loading the first page)
|
||||
self.pagedDataObserver?.load(.pageBefore)
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
/// This value is the current state of the view
|
||||
public private(set) var state: State
|
||||
|
||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
||||
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||
///
|
||||
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
|
||||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
||||
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||
public lazy var observableState = ValueObservation
|
||||
.trackingConstantRegion { db -> State in try HomeViewModel.retrieveState(db) }
|
||||
.removeDuplicates()
|
||||
|
||||
private static func retrieveState(_ db: Database) throws -> State {
|
||||
let hasViewedSeed: Bool = db[.hasViewedSeed]
|
||||
let hasHiddenMessageRequests: Bool = db[.hasHiddenMessageRequests]
|
||||
let userProfile: Profile = Profile.fetchOrCreateCurrentUser(db)
|
||||
let unreadMessageRequestThreadCount: Int = try SessionThread
|
||||
.unreadMessageRequestsThreadIdQuery(userPublicKey: userProfile.id)
|
||||
.fetchCount(db)
|
||||
|
||||
return State(
|
||||
showViewedSeedBanner: !hasViewedSeed,
|
||||
hasHiddenMessageRequests: hasHiddenMessageRequests,
|
||||
unreadMessageRequestThreadCount: unreadMessageRequestThreadCount,
|
||||
userProfile: userProfile
|
||||
)
|
||||
}
|
||||
|
||||
public func updateState(_ updatedState: State) {
|
||||
let oldState: State = self.state
|
||||
self.state = updatedState
|
||||
|
||||
// If the messageRequest content changed then we need to re-process the thread data
|
||||
guard
|
||||
(
|
||||
oldState.hasHiddenMessageRequests != updatedState.hasHiddenMessageRequests ||
|
||||
oldState.unreadMessageRequestThreadCount != updatedState.unreadMessageRequestThreadCount
|
||||
),
|
||||
let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo.wrappedValue
|
||||
else { return }
|
||||
|
||||
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
|
||||
let currentData: [SessionThreadViewModel] = self.threadData.flatMap { $0.elements }
|
||||
let updatedThreadData: [SectionModel] = self.process(data: currentData, for: currentPageInfo)
|
||||
|
||||
guard let onThreadChange: (([SectionModel]) -> ()) = self.onThreadChange else {
|
||||
self.unobservedThreadDataChanges = updatedThreadData
|
||||
return
|
||||
}
|
||||
|
||||
onThreadChange(updatedThreadData)
|
||||
}
|
||||
|
||||
// MARK: - Thread Data
|
||||
|
||||
public private(set) var unobservedThreadDataChanges: [SectionModel]?
|
||||
public private(set) var threadData: [SectionModel] = []
|
||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
|
||||
|
||||
public var onThreadChange: (([SectionModel]) -> ())? {
|
||||
didSet {
|
||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||
// data was changed while we weren't observing
|
||||
if let unobservedThreadDataChanges: [SectionModel] = self.unobservedThreadDataChanges {
|
||||
onThreadChange?(unobservedThreadDataChanges)
|
||||
self.unobservedThreadDataChanges = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func process(data: [SessionThreadViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
||||
let finalUnreadMessageRequestCount: Int = (self.state.hasHiddenMessageRequests ?
|
||||
0 :
|
||||
self.state.unreadMessageRequestThreadCount
|
||||
)
|
||||
let groupedOldData: [String: [SessionThreadViewModel]] = (self.threadData
|
||||
.first(where: { $0.model == .threads })?
|
||||
.elements)
|
||||
.defaulting(to: [])
|
||||
.grouped(by: \.threadId)
|
||||
|
||||
return [
|
||||
// If there are no unread message requests then hide the message request banner
|
||||
(finalUnreadMessageRequestCount == 0 ?
|
||||
[] :
|
||||
[SectionModel(
|
||||
section: .messageRequests,
|
||||
elements: [
|
||||
SessionThreadViewModel(unreadCount: UInt(finalUnreadMessageRequestCount))
|
||||
]
|
||||
)]
|
||||
),
|
||||
[
|
||||
SectionModel(
|
||||
section: .threads,
|
||||
elements: data
|
||||
.filter { $0.id != SessionThreadViewModel.invalidId }
|
||||
.sorted { lhs, rhs -> Bool in
|
||||
if lhs.threadIsPinned && !rhs.threadIsPinned { return true }
|
||||
if !lhs.threadIsPinned && rhs.threadIsPinned { return false }
|
||||
|
||||
return lhs.lastInteractionDate > rhs.lastInteractionDate
|
||||
}
|
||||
.map { viewModel -> SessionThreadViewModel in
|
||||
viewModel.populatingCurrentUserBlindedKey(
|
||||
currentUserBlindedPublicKeyForThisThread: groupedOldData[viewModel.threadId]?
|
||||
.first?
|
||||
.currentUserBlindedPublicKey
|
||||
)
|
||||
}
|
||||
)
|
||||
],
|
||||
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
|
||||
[SectionModel(section: .loadMore)] :
|
||||
[]
|
||||
)
|
||||
].flatMap { $0 }
|
||||
}
|
||||
|
||||
public func updateThreadData(_ updatedData: [SectionModel]) {
|
||||
self.threadData = updatedData
|
||||
}
|
||||
}
|
|
@ -1,49 +1,60 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc
|
||||
class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
||||
private var threads: YapDatabaseViewMappings! = {
|
||||
let result = YapDatabaseViewMappings(groups: [ TSMessageRequestGroup ], view: TSThreadDatabaseViewExtensionName)
|
||||
result.setIsReversed(true, forGroup: TSMessageRequestGroup)
|
||||
return result
|
||||
}()
|
||||
private var threadViewModelCache: [String: ThreadViewModel] = [:] // Thread ID to ThreadViewModel
|
||||
private var tableViewTopConstraint: NSLayoutConstraint!
|
||||
private static let loadingHeaderHeight: CGFloat = 20
|
||||
|
||||
private var messageRequestCount: UInt {
|
||||
threads.numberOfItems(inGroup: TSMessageRequestGroup)
|
||||
private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
|
||||
private var dataChangeObservable: DatabaseCancellable?
|
||||
private var hasLoadedInitialThreadData: Bool = false
|
||||
private var isLoadingMore: Bool = false
|
||||
private var isAutoLoadingNextPage: Bool = false
|
||||
private var viewHasAppeared: Bool = false
|
||||
|
||||
// MARK: - Intialization
|
||||
|
||||
init() {
|
||||
Storage.shared.addObserver(viewModel.pagedDataObserver)
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init() instead.")
|
||||
}
|
||||
|
||||
private lazy var dbConnection: YapDatabaseConnection = {
|
||||
let result = OWSPrimaryStorage.shared().newDatabaseConnection()
|
||||
result.objectCacheLimit = 500
|
||||
|
||||
return result
|
||||
}()
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
|
||||
private lazy var tableView: UITableView = {
|
||||
let result: UITableView = UITableView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.backgroundColor = .clear
|
||||
result.separatorStyle = .none
|
||||
result.register(MessageRequestsCell.self, forCellReuseIdentifier: MessageRequestsCell.reuseIdentifier)
|
||||
result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier)
|
||||
result.register(view: FullConversationCell.self)
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
|
||||
|
||||
let bottomInset = Values.newConversationButtonBottomOffset + NewConversationButtonSet.expandedButtonSize + Values.largeSpacing + NewConversationButtonSet.collapsedButtonSize
|
||||
result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
|
||||
result.showsVerticalScrollIndicator = false
|
||||
|
||||
if #available(iOS 15.0, *) {
|
||||
result.sectionHeaderTopPadding = 0
|
||||
}
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
||||
private lazy var emptyStateLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -54,19 +65,19 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
result.textAlignment = .center
|
||||
result.numberOfLines = 0
|
||||
result.isHidden = true
|
||||
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
||||
private lazy var fadeView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.isUserInteractionEnabled = false
|
||||
result.setGradient(Gradients.homeVCFade)
|
||||
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
||||
private lazy var clearAllButton: Button = {
|
||||
let result: Button = Button(style: .destructiveOutline, size: .large)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
@ -78,17 +89,21 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
for: .highlighted
|
||||
)
|
||||
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
|
||||
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: NSLocalizedString("MESSAGE_REQUESTS_TITLE", comment: ""), hasCustomBackButton: false)
|
||||
|
||||
|
||||
ViewControllerUtilities.setUpDefaultSessionStyle(
|
||||
for: self,
|
||||
title: "MESSAGE_REQUESTS_TITLE".localized(),
|
||||
hasCustomBackButton: false
|
||||
)
|
||||
|
||||
// Add the UI (MUST be done after the thread freeze so the 'tableView' creation and setting
|
||||
// the dataSource has the correct data)
|
||||
view.addSubview(tableView)
|
||||
|
@ -96,58 +111,69 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
view.addSubview(fadeView)
|
||||
view.addSubview(clearAllButton)
|
||||
setupLayout()
|
||||
|
||||
|
||||
// Notifications
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleYapDatabaseModifiedNotification(_:)),
|
||||
name: .YapDatabaseModified,
|
||||
object: OWSPrimaryStorage.shared().dbNotificationObject
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleProfileDidChangeNotification(_:)),
|
||||
name: NSNotification.Name(rawValue: kNSNotificationName_OtherUsersProfileDidChange),
|
||||
selector: #selector(applicationDidBecomeActive(_:)),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleBlockedContactsUpdatedNotification(_:)),
|
||||
name: .blockedContactsUpdated,
|
||||
object: nil
|
||||
selector: #selector(applicationDidResignActive(_:)),
|
||||
name: UIApplication.didEnterBackgroundNotification, object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
reload()
|
||||
startObservingChanges()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
reload()
|
||||
|
||||
self.viewHasAppeared = true
|
||||
self.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
// Stop observing database changes
|
||||
dataChangeObservable?.cancel()
|
||||
}
|
||||
|
||||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||
startObservingChanges()
|
||||
}
|
||||
|
||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||
// Stop observing database changes
|
||||
dataChangeObservable?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
|
||||
private func setupLayout() {
|
||||
NSLayoutConstraint.activate([
|
||||
tableView.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.smallSpacing),
|
||||
tableView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||
tableView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
|
||||
emptyStateLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Values.massiveSpacing),
|
||||
emptyStateLabel.leftAnchor.constraint(equalTo: view.leftAnchor, constant: Values.mediumSpacing),
|
||||
emptyStateLabel.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.mediumSpacing),
|
||||
emptyStateLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
|
||||
|
||||
fadeView.topAnchor.constraint(equalTo: view.topAnchor, constant: (0.15 * view.bounds.height)),
|
||||
fadeView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||
fadeView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
||||
fadeView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
|
||||
|
||||
clearAllButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
clearAllButton.bottomAnchor.constraint(
|
||||
equalTo: view.safeAreaLayoutGuide.bottomAnchor,
|
||||
|
@ -158,277 +184,278 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
])
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return Int(messageRequestCount)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
|
||||
cell.threadViewModel = threadViewModel(at: indexPath.row)
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: - Updating
|
||||
|
||||
private func reload() {
|
||||
AssertIsOnMainThread()
|
||||
dbConnection.beginLongLivedReadTransaction() // Jump to the latest commit
|
||||
dbConnection.read { transaction in
|
||||
self.threads.update(with: transaction)
|
||||
private func startObservingChanges() {
|
||||
self.viewModel.onThreadChange = { [weak self] updatedThreadData in
|
||||
self?.handleThreadUpdates(updatedThreadData)
|
||||
}
|
||||
threadViewModelCache.removeAll()
|
||||
tableView.reloadData()
|
||||
clearAllButton.isHidden = (messageRequestCount == 0)
|
||||
emptyStateLabel.isHidden = (messageRequestCount != 0)
|
||||
}
|
||||
|
||||
@objc private func handleYapDatabaseModifiedNotification(_ yapDatabase: YapDatabase) {
|
||||
// NOTE: This code is very finicky and crashes easily. Modify with care.
|
||||
AssertIsOnMainThread()
|
||||
private func handleThreadUpdates(_ updatedData: [MessageRequestsViewModel.SectionModel], initialLoad: Bool = false) {
|
||||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||
// in from a frame of CGRect.zero)
|
||||
guard hasLoadedInitialThreadData else {
|
||||
hasLoadedInitialThreadData = true
|
||||
UIView.performWithoutAnimation { handleThreadUpdates(updatedData, initialLoad: true) }
|
||||
return
|
||||
}
|
||||
|
||||
// If we don't capture `threads` here, a race condition can occur where the
|
||||
// `thread.snapshotOfLastUpdate != firstSnapshot - 1` check below evaluates to
|
||||
// `false`, but `threads` then changes between that check and the
|
||||
// `ext.getSectionChanges(§ionChanges, rowChanges: &rowChanges, for: notifications, with: threads)`
|
||||
// line. This causes `tableView.endUpdates()` to crash with an `NSInternalInconsistencyException`.
|
||||
let threads = threads!
|
||||
// Show the empty state if there is no data
|
||||
clearAllButton.isHidden = updatedData.isEmpty
|
||||
emptyStateLabel.isHidden = !updatedData.isEmpty
|
||||
|
||||
// Create a stable state for the connection and jump to the latest commit
|
||||
let notifications = dbConnection.beginLongLivedReadTransaction()
|
||||
CATransaction.begin()
|
||||
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
|
||||
let hasChanges = ext.hasChanges(forGroup: TSMessageRequestGroup, in: notifications)
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
private func autoLoadNextPageIfNeeded() {
|
||||
guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return }
|
||||
|
||||
guard hasChanges else { return }
|
||||
self.isAutoLoadingNextPage = true
|
||||
|
||||
if let firstChangeSet = notifications[0].userInfo {
|
||||
let firstSnapshot = firstChangeSet[YapDatabaseSnapshotKey] as! UInt64
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in
|
||||
self?.isAutoLoadingNextPage = false
|
||||
|
||||
if threads.snapshotOfLastUpdate != firstSnapshot - 1 {
|
||||
return reload() // The code below will crash if we try to process multiple commits at once
|
||||
}
|
||||
}
|
||||
|
||||
var sectionChanges = NSArray()
|
||||
var rowChanges = NSArray()
|
||||
ext.getSectionChanges(§ionChanges, rowChanges: &rowChanges, for: notifications, with: threads)
|
||||
|
||||
guard sectionChanges.count > 0 || rowChanges.count > 0 else { return }
|
||||
|
||||
tableView.beginUpdates()
|
||||
|
||||
rowChanges.forEach { rowChange in
|
||||
let rowChange = rowChange as! YapDatabaseViewRowChange
|
||||
let key = rowChange.collectionKey.key
|
||||
threadViewModelCache[key] = nil
|
||||
switch rowChange.type {
|
||||
case .delete: tableView.deleteRows(at: [ rowChange.indexPath! ], with: UITableView.RowAnimation.automatic)
|
||||
case .insert: tableView.insertRows(at: [ rowChange.newIndexPath! ], with: UITableView.RowAnimation.automatic)
|
||||
case .update: tableView.reloadRows(at: [ rowChange.indexPath! ], with: UITableView.RowAnimation.automatic)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
tableView.endUpdates()
|
||||
|
||||
// HACK: Moves can have conflicts with the other 3 types of change.
|
||||
// Just batch perform all the moves separately to prevent crashing.
|
||||
// Since all the changes are from the original state to the final state,
|
||||
// it will still be correct if we pick the moves out.
|
||||
|
||||
tableView.beginUpdates()
|
||||
|
||||
rowChanges.forEach { rowChange in
|
||||
let rowChange = rowChange as! YapDatabaseViewRowChange
|
||||
let key = rowChange.collectionKey.key
|
||||
threadViewModelCache[key] = nil
|
||||
// Note: We sort the headers as we want to prioritise loading newer pages over older ones
|
||||
let sections: [(MessageRequestsViewModel.Section, CGRect)] = (self?.viewModel.threadData
|
||||
.enumerated()
|
||||
.map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) })
|
||||
.defaulting(to: [])
|
||||
let shouldLoadMore: Bool = sections
|
||||
.contains { section, headerRect in
|
||||
section == .loadMore &&
|
||||
headerRect != .zero &&
|
||||
(self?.tableView.bounds.contains(headerRect) == true)
|
||||
}
|
||||
|
||||
switch rowChange.type {
|
||||
case .move: tableView.moveRow(at: rowChange.indexPath!, to: rowChange.newIndexPath!)
|
||||
default: break
|
||||
guard shouldLoadMore else { return }
|
||||
|
||||
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) {
|
||||
super.handleAppModeChangedNotification(notification)
|
||||
|
||||
|
||||
let gradient = Gradients.homeVCFade
|
||||
fadeView.setGradient(gradient) // Re-do the gradient
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return viewModel.threadData.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
|
||||
|
||||
return section.elements.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[indexPath.section]
|
||||
|
||||
switch section.model {
|
||||
case .threads:
|
||||
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||
let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
|
||||
cell.update(with: threadViewModel)
|
||||
return cell
|
||||
|
||||
default: preconditionFailure("Other sections should have no content")
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
|
||||
|
||||
switch section.model {
|
||||
case .loadMore:
|
||||
let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
|
||||
loadingIndicator.tintColor = Colors.text
|
||||
loadingIndicator.alpha = 0.5
|
||||
loadingIndicator.startAnimating()
|
||||
|
||||
let view: UIView = UIView()
|
||||
view.addSubview(loadingIndicator)
|
||||
loadingIndicator.center(in: view)
|
||||
|
||||
return view
|
||||
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
|
||||
|
||||
switch section.model {
|
||||
case .loadMore: return MessageRequestsViewController.loadingHeaderHeight
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
|
||||
guard self.hasLoadedInitialThreadData && self.viewHasAppeared && !self.isLoadingMore else { return }
|
||||
|
||||
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[section]
|
||||
|
||||
switch section.model {
|
||||
case .loadMore:
|
||||
self.isLoadingMore = true
|
||||
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(.pageAfter)
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
|
||||
guard let thread = self.thread(at: indexPath.row) else { return }
|
||||
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
||||
|
||||
let conversationVC = ConversationVC(thread: thread)
|
||||
self.navigationController?.pushViewController(conversationVC, animated: true)
|
||||
switch section.model {
|
||||
case .threads:
|
||||
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||
let conversationVC: ConversationVC = ConversationVC(
|
||||
threadId: threadViewModel.threadId,
|
||||
threadVariant: threadViewModel.threadVariant
|
||||
)
|
||||
self.navigationController?.pushViewController(conversationVC, animated: true)
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
||||
guard let thread = self.thread(at: indexPath.row) else { return [] }
|
||||
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
||||
|
||||
let delete = UITableViewRowAction(style: .destructive, title: NSLocalizedString("TXT_DELETE_TITLE", comment: "")) { [weak self] _, _ in
|
||||
self?.delete(thread)
|
||||
switch section.model {
|
||||
case .threads:
|
||||
let threadId: String = section.elements[indexPath.row].threadId
|
||||
let delete = UITableViewRowAction(
|
||||
style: .destructive,
|
||||
title: "TXT_DELETE_TITLE".localized()
|
||||
) { [weak self] _, _ in
|
||||
self?.delete(threadId)
|
||||
}
|
||||
delete.backgroundColor = Colors.destructive
|
||||
|
||||
return [ delete ]
|
||||
|
||||
default: return []
|
||||
}
|
||||
delete.backgroundColor = Colors.destructive
|
||||
|
||||
return [ delete ]
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
private func updateContactAndThread(thread: TSThread, with transaction: YapDatabaseReadWriteTransaction, onComplete: ((Bool) -> ())? = nil) {
|
||||
guard let contactThread: TSContactThread = thread as? TSContactThread else {
|
||||
onComplete?(false)
|
||||
@objc private func clearAllTapped() {
|
||||
guard viewModel.threadData.first(where: { $0.model == .threads })?.elements.isEmpty == false else {
|
||||
return
|
||||
}
|
||||
|
||||
var needsSync: Bool = false
|
||||
|
||||
// Update the contact
|
||||
let sessionId: String = contactThread.contactSessionID()
|
||||
|
||||
if let contact: Contact = Storage.shared.getContact(with: sessionId), (contact.isApproved || !contact.isBlocked) {
|
||||
contact.isApproved = false
|
||||
contact.isBlocked = true
|
||||
|
||||
Storage.shared.setContact(contact, using: transaction)
|
||||
needsSync = true
|
||||
}
|
||||
|
||||
// Delete all thread content
|
||||
thread.removeAllThreadInteractions(with: transaction)
|
||||
thread.remove(with: transaction)
|
||||
|
||||
onComplete?(needsSync)
|
||||
}
|
||||
|
||||
@objc private func clearAllTapped() {
|
||||
let threadCount: Int = Int(messageRequestCount)
|
||||
let threads: [TSThread] = (0..<threadCount).compactMap { self.thread(at: $0) }
|
||||
var needsSync: Bool = false
|
||||
|
||||
let alertVC: UIAlertController = UIAlertController(title: NSLocalizedString("MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE", comment: ""), message: nil, preferredStyle: .actionSheet)
|
||||
alertVC.addAction(UIAlertAction(title: NSLocalizedString("MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON", comment: ""), style: .destructive) { _ in
|
||||
let threadIds: [String] = (viewModel.threadData
|
||||
.first { $0.model == .threads }?
|
||||
.elements
|
||||
.map { $0.threadId })
|
||||
.defaulting(to: [])
|
||||
let alertVC: UIAlertController = UIAlertController(
|
||||
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE".localized(),
|
||||
message: nil,
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
alertVC.addAction(UIAlertAction(
|
||||
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON".localized(),
|
||||
style: .destructive
|
||||
) { _ in
|
||||
// Clear the requests
|
||||
Storage.write(
|
||||
with: { [weak self] transaction in
|
||||
threads.forEach { thread in
|
||||
if let uniqueId: String = thread.uniqueId {
|
||||
Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction)
|
||||
}
|
||||
|
||||
self?.updateContactAndThread(thread: thread, with: transaction) { threadNeedsSync in
|
||||
if threadNeedsSync {
|
||||
needsSync = true
|
||||
}
|
||||
}
|
||||
|
||||
// Block the contact
|
||||
if
|
||||
let sessionId: String = (thread as? TSContactThread)?.contactSessionID(),
|
||||
!thread.isBlocked(),
|
||||
let contact: Contact = Storage.shared.getContact(with: sessionId, using: transaction)
|
||||
{
|
||||
contact.isBlocked = true
|
||||
Storage.shared.setContact(contact, using: transaction)
|
||||
needsSync = true
|
||||
}
|
||||
}
|
||||
},
|
||||
completion: {
|
||||
// Force a config sync
|
||||
if needsSync {
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
Storage.shared.write { db in
|
||||
_ = try SessionThread
|
||||
.filter(ids: threadIds)
|
||||
.deleteAll(db)
|
||||
|
||||
try threadIds.forEach { threadId in
|
||||
_ = try Contact
|
||||
.fetchOrCreate(db, id: threadId)
|
||||
.with(
|
||||
isApproved: false,
|
||||
isBlocked: true
|
||||
)
|
||||
.saved(db)
|
||||
}
|
||||
)
|
||||
})
|
||||
alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .cancel, handler: nil))
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
private func delete(_ thread: TSThread) {
|
||||
guard let uniqueId: String = thread.uniqueId else { return }
|
||||
|
||||
let alertVC: UIAlertController = UIAlertController(title: NSLocalizedString("MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON", comment: ""), message: nil, preferredStyle: .actionSheet)
|
||||
alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_DELETE_TITLE", comment: ""), style: .destructive) { _ in
|
||||
Storage.write(
|
||||
with: { [weak self] transaction in
|
||||
Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction)
|
||||
self?.updateContactAndThread(thread: thread, with: transaction)
|
||||
|
||||
// Block the contact
|
||||
if
|
||||
let sessionId: String = (thread as? TSContactThread)?.contactSessionID(),
|
||||
!thread.isBlocked(),
|
||||
let contact: Contact = Storage.shared.getContact(with: sessionId, using: transaction)
|
||||
{
|
||||
contact.isBlocked = true
|
||||
Storage.shared.setContact(contact, using: transaction)
|
||||
}
|
||||
},
|
||||
completion: {
|
||||
// Force a config sync
|
||||
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
)
|
||||
})
|
||||
alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .cancel, handler: nil))
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
private func thread(at index: Int) -> TSThread? {
|
||||
var thread: TSThread? = nil
|
||||
|
||||
dbConnection.read { transaction in
|
||||
let ext: YapDatabaseViewTransaction? = transaction.ext(TSThreadDatabaseViewExtensionName) as? YapDatabaseViewTransaction
|
||||
thread = ext?.object(atRow: UInt(index), inSection: 0, with: self.threads) as? TSThread
|
||||
}
|
||||
|
||||
return thread
|
||||
}
|
||||
|
||||
private func threadViewModel(at index: Int) -> ThreadViewModel? {
|
||||
guard let thread = thread(at: index), let uniqueId: String = thread.uniqueId else { return nil }
|
||||
|
||||
if let cachedThreadViewModel = threadViewModelCache[uniqueId] {
|
||||
return cachedThreadViewModel
|
||||
}
|
||||
else {
|
||||
var threadViewModel: ThreadViewModel? = nil
|
||||
dbConnection.read { transaction in
|
||||
threadViewModel = ThreadViewModel(thread: thread, transaction: transaction)
|
||||
|
||||
// Force a config sync
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
threadViewModelCache[uniqueId] = threadViewModel
|
||||
|
||||
return threadViewModel
|
||||
}
|
||||
})
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
private func delete(_ threadId: String) {
|
||||
let alertVC: UIAlertController = UIAlertController(
|
||||
title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(),
|
||||
message: nil,
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
alertVC.addAction(UIAlertAction(
|
||||
title: "TXT_DELETE_TITLE".localized(),
|
||||
style: .destructive
|
||||
) { _ in
|
||||
Storage.shared.write { db in
|
||||
_ = try SessionThread
|
||||
.filter(id: threadId)
|
||||
.deleteAll(db)
|
||||
_ = try Contact
|
||||
.fetchOrCreate(db, id: threadId)
|
||||
.with(
|
||||
isApproved: false,
|
||||
isBlocked: true
|
||||
)
|
||||
.saved(db)
|
||||
|
||||
// Force a config sync
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
||||
}
|
||||
})
|
||||
|
||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||
self.present(alertVC, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,174 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
public class MessageRequestsViewModel {
|
||||
public typealias SectionModel = ArraySection<Section, SessionThreadViewModel>
|
||||
|
||||
// MARK: - Section
|
||||
|
||||
public enum Section: Differentiable {
|
||||
case threads
|
||||
case loadMore
|
||||
}
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
public static let pageSize: Int = 20
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
self.pagedDataObserver = nil
|
||||
|
||||
// Note: Since this references self we need to finish initializing before setting it, we
|
||||
// also want to skip the initial query and trigger it async so that the push animation
|
||||
// doesn't stutter (it should load basically immediately but without this there is a
|
||||
// distinct stutter)
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey()
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
self.pagedDataObserver = PagedDatabaseObserver(
|
||||
pagedTable: SessionThread.self,
|
||||
pageSize: MessageRequestsViewModel.pageSize,
|
||||
idColumn: .id,
|
||||
observedChanges: [
|
||||
PagedData.ObservedChanges(
|
||||
table: SessionThread.self,
|
||||
columns: [
|
||||
.id,
|
||||
.shouldBeVisible
|
||||
]
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: Interaction.self,
|
||||
columns: [
|
||||
.body,
|
||||
.wasRead
|
||||
],
|
||||
joinToPagedType: {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: Contact.self,
|
||||
columns: [.isBlocked],
|
||||
joinToPagedType: {
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: Profile.self,
|
||||
columns: [.name, .nickname, .profilePictureFileName],
|
||||
joinToPagedType: {
|
||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
|
||||
}()
|
||||
),
|
||||
PagedData.ObservedChanges(
|
||||
table: RecipientState.self,
|
||||
columns: [.state],
|
||||
joinToPagedType: {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
|
||||
|
||||
return """
|
||||
LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
|
||||
LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
|
||||
"""
|
||||
}()
|
||||
)
|
||||
],
|
||||
/// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query
|
||||
joinSQL: SessionThreadViewModel.optimisedJoinSQL,
|
||||
filterSQL: SessionThreadViewModel.messageRequestsFilterSQL(userPublicKey: userPublicKey),
|
||||
groupSQL: SessionThreadViewModel.groupSQL,
|
||||
orderSQL: SessionThreadViewModel.messageRequetsOrderSQL,
|
||||
dataQuery: SessionThreadViewModel.baseQuery(
|
||||
userPublicKey: userPublicKey,
|
||||
filterSQL: SessionThreadViewModel.messageRequestsFilterSQL(userPublicKey: userPublicKey),
|
||||
groupSQL: SessionThreadViewModel.groupSQL,
|
||||
orderSQL: SessionThreadViewModel.messageRequetsOrderSQL
|
||||
),
|
||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
guard let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
|
||||
return
|
||||
}
|
||||
|
||||
// If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes
|
||||
// to be sent to the callback if we ever start observing again (when we have the callback it needs
|
||||
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
|
||||
// correct order)
|
||||
guard let onThreadChange: (([SectionModel]) -> ()) = self?.onThreadChange else {
|
||||
self?.unobservedThreadDataChanges = updatedThreadData
|
||||
return
|
||||
}
|
||||
|
||||
onThreadChange(updatedThreadData)
|
||||
}
|
||||
)
|
||||
|
||||
// Run the initial query on a background thread so we don't block the push transition
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
// The `.pageBefore` will query from a `0` offset loading the first page
|
||||
self?.pagedDataObserver?.load(.pageBefore)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Thread Data
|
||||
|
||||
public private(set) var unobservedThreadDataChanges: [SectionModel]?
|
||||
public private(set) var threadData: [SectionModel] = []
|
||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
|
||||
|
||||
public var onThreadChange: (([SectionModel]) -> ())? {
|
||||
didSet {
|
||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||
// data was changed while we weren't observing
|
||||
if let unobservedThreadDataChanges: [SectionModel] = self.unobservedThreadDataChanges {
|
||||
onThreadChange?(unobservedThreadDataChanges)
|
||||
self.unobservedThreadDataChanges = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func process(data: [SessionThreadViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
||||
let groupedOldData: [String: [SessionThreadViewModel]] = (self.threadData
|
||||
.first(where: { $0.model == .threads })?
|
||||
.elements)
|
||||
.defaulting(to: [])
|
||||
.grouped(by: \.threadId)
|
||||
|
||||
return [
|
||||
[
|
||||
SectionModel(
|
||||
section: .threads,
|
||||
elements: data
|
||||
.sorted { lhs, rhs -> Bool in lhs.lastInteractionDate > rhs.lastInteractionDate }
|
||||
.map { viewModel -> SessionThreadViewModel in
|
||||
viewModel.populatingCurrentUserBlindedKey(
|
||||
currentUserBlindedPublicKeyForThisThread: groupedOldData[viewModel.threadId]?
|
||||
.first?
|
||||
.currentUserBlindedPublicKey
|
||||
)
|
||||
}
|
||||
)
|
||||
],
|
||||
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
|
||||
[SectionModel(section: .loadMore)] :
|
||||
[]
|
||||
)
|
||||
].flatMap { $0 }
|
||||
}
|
||||
|
||||
public func updateThreadData(_ updatedData: [SectionModel]) {
|
||||
self.threadData = updatedData
|
||||
}
|
||||
}
|
|
@ -60,7 +60,7 @@ class MessageRequestsCell: UITableViewCell {
|
|||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.clipsToBounds = true
|
||||
result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity)
|
||||
result.layer.cornerRadius = (ConversationCell.unreadCountViewSize / 2)
|
||||
result.layer.cornerRadius = (FullConversationCell.unreadCountViewSize / 2)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -115,8 +115,8 @@ class MessageRequestsCell: UITableViewCell {
|
|||
|
||||
unreadCountView.leftAnchor.constraint(equalTo: titleLabel.rightAnchor, constant: (Values.smallSpacing / 2)),
|
||||
unreadCountView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
|
||||
unreadCountView.widthAnchor.constraint(equalToConstant: ConversationCell.unreadCountViewSize),
|
||||
unreadCountView.heightAnchor.constraint(equalToConstant: ConversationCell.unreadCountViewSize),
|
||||
unreadCountView.widthAnchor.constraint(equalToConstant: FullConversationCell.unreadCountViewSize),
|
||||
unreadCountView.heightAnchor.constraint(equalToConstant: FullConversationCell.unreadCountViewSize),
|
||||
|
||||
unreadCountLabel.topAnchor.constraint(equalTo: unreadCountView.topAnchor),
|
||||
unreadCountLabel.leftAnchor.constraint(equalTo: unreadCountView.leftAnchor),
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
protocol GifPickerLayoutDelegate: class {
|
||||
protocol GifPickerLayoutDelegate: AnyObject {
|
||||
func imageInfosForLayout() -> [GiphyImageInfo]
|
||||
}
|
||||
|
||||
|
|
|
@ -8,11 +8,6 @@ import SignalUtilitiesKit
|
|||
import PromiseKit
|
||||
import SessionUIKit
|
||||
|
||||
@objc
|
||||
protocol GifPickerViewControllerDelegate: class {
|
||||
func gifPickerDidSelect(attachment: SignalAttachment)
|
||||
}
|
||||
|
||||
class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate, GifPickerLayoutDelegate {
|
||||
|
||||
// MARK: Properties
|
||||
|
@ -31,11 +26,8 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
|
||||
var lastQuery: String = ""
|
||||
|
||||
@objc
|
||||
public weak var delegate: GifPickerViewControllerDelegate?
|
||||
|
||||
let thread: TSThread
|
||||
|
||||
let searchBar: SearchBar
|
||||
let layout: GifPickerLayout
|
||||
let collectionView: UICollectionView
|
||||
|
@ -51,17 +43,14 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
|
||||
var progressiveSearchTimer: Timer?
|
||||
|
||||
// MARK: Initializers
|
||||
// MARK: - Initialization
|
||||
|
||||
@available(*, unavailable, message:"use other constructor instead.")
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
notImplemented()
|
||||
}
|
||||
|
||||
@objc
|
||||
required init(thread: TSThread) {
|
||||
self.thread = thread
|
||||
|
||||
required init() {
|
||||
self.searchBar = SearchBar()
|
||||
self.layout = GifPickerLayout()
|
||||
self.collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: self.layout)
|
||||
|
@ -116,7 +105,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
|
||||
// Loki: Customize title
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = NSLocalizedString("GIF", comment: "")
|
||||
titleLabel.text = "accessibility_gif_button".localized().uppercased()
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
||||
navigationItem.titleView = titleLabel
|
||||
|
@ -469,8 +458,8 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
progressiveSearchTimer = nil
|
||||
|
||||
guard let text = searchBar.text else {
|
||||
OWSAlerts.showErrorAlert(message: NSLocalizedString("GIF_PICKER_VIEW_MISSING_QUERY",
|
||||
comment: "Alert message shown when user tries to search for GIFs without entering any search terms."))
|
||||
// Alert message shown when user tries to search for GIFs without entering any search terms
|
||||
OWSAlerts.showErrorAlert(message: "GIF_PICKER_VIEW_MISSING_QUERY".localized())
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -556,3 +545,9 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
layout.invalidateLayout()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - GifPickerViewControllerDelegate
|
||||
|
||||
protocol GifPickerViewControllerDelegate: AnyObject {
|
||||
func gifPickerDidSelect(attachment: SignalAttachment)
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
import Foundation
|
||||
import Photos
|
||||
import PromiseKit
|
||||
import SessionUIKit
|
||||
|
||||
protocol ImagePickerGridControllerDelegate: AnyObject {
|
||||
func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController)
|
||||
|
@ -46,6 +47,8 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.view.backgroundColor = Colors.navigationBarBackground
|
||||
|
||||
library.add(delegate: self)
|
||||
|
||||
|
@ -54,12 +57,11 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
return
|
||||
}
|
||||
|
||||
collectionView.register(PhotoGridViewCell.self, forCellWithReuseIdentifier: PhotoGridViewCell.reuseIdentifier)
|
||||
collectionView.register(view: PhotoGridViewCell.self)
|
||||
|
||||
// ensure images at the end of the list can be scrolled above the bottom buttons
|
||||
let bottomButtonInset = -1 * SendMediaNavigationController.bottomButtonsCenterOffset + SendMediaNavigationController.bottomButtonWidth / 2 + 16
|
||||
collectionView.contentInset.bottom = bottomButtonInset + 16
|
||||
view.backgroundColor = .white
|
||||
|
||||
// The PhotoCaptureVC needs a shadow behind it's cancel button, so we use a custom icon.
|
||||
// This VC has a visible navbar so doesn't need the shadow, but because the user can
|
||||
|
@ -69,24 +71,16 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
let cancelImage = UIImage(imageLiteralResourceName: "X")
|
||||
let cancelButton = UIBarButtonItem(image: cancelImage, style: .plain, target: self, action: #selector(didPressCancel))
|
||||
|
||||
cancelButton.tintColor = .black
|
||||
cancelButton.tintColor = Colors.text
|
||||
navigationItem.leftBarButtonItem = cancelButton
|
||||
|
||||
let titleView = TitleView()
|
||||
titleView.delegate = self
|
||||
titleView.text = photoCollection.localizedTitle()
|
||||
|
||||
if #available(iOS 11, *) {
|
||||
// do nothing
|
||||
} else {
|
||||
// must assign titleView frame manually on older iOS
|
||||
titleView.frame = CGRect(origin: .zero, size: titleView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize))
|
||||
}
|
||||
|
||||
navigationItem.titleView = titleView
|
||||
self.titleView = titleView
|
||||
|
||||
collectionView.backgroundColor = .white
|
||||
collectionView.backgroundColor = Colors.navigationBarBackground
|
||||
|
||||
let selectionPanGesture = DirectionalPanGestureRecognizer(direction: [.horizontal], target: self, action: #selector(didPanSelection))
|
||||
selectionPanGesture.delegate = self
|
||||
|
@ -200,16 +194,15 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
// Loki: Set navigation bar background color
|
||||
let navigationBar = navigationController!.navigationBar
|
||||
navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default)
|
||||
navigationBar.shadowImage = UIImage()
|
||||
navigationBar.isTranslucent = false
|
||||
navigationBar.barTintColor = .white
|
||||
(navigationBar as! OWSNavigationBar).respectsTheme = false
|
||||
navigationBar.backgroundColor = .white
|
||||
let backgroundImage = UIImage(color: .white)
|
||||
navigationBar.setBackgroundImage(backgroundImage, for: .default)
|
||||
let backgroundImage: UIImage = UIImage(color: Colors.navigationBarBackground)
|
||||
self.navigationItem.title = nil
|
||||
self.navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default)
|
||||
self.navigationController?.navigationBar.shadowImage = UIImage()
|
||||
self.navigationController?.navigationBar.isTranslucent = false
|
||||
self.navigationController?.navigationBar.barTintColor = Colors.navigationBarBackground
|
||||
(self.navigationController?.navigationBar as? OWSNavigationBar)?.respectsTheme = true
|
||||
self.navigationController?.navigationBar.backgroundColor = Colors.navigationBarBackground
|
||||
self.navigationController?.navigationBar.setBackgroundImage(backgroundImage, for: .default)
|
||||
|
||||
// Determine the size of the thumbnails to request
|
||||
let scale = UIScreen.main.scale
|
||||
|
@ -268,11 +261,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
// MARK:
|
||||
|
||||
var lastPageYOffset: CGFloat {
|
||||
var yOffset = collectionView.contentSize.height - collectionView.bounds.height + collectionView.adjustedContentInset.bottom
|
||||
if #available(iOS 11.0, *) {
|
||||
yOffset += view.safeAreaInsets.bottom
|
||||
}
|
||||
return yOffset
|
||||
return (collectionView.contentSize.height - collectionView.bounds.height + collectionView.adjustedContentInset.bottom + view.safeAreaInsets.bottom)
|
||||
}
|
||||
|
||||
func scrollToBottom(animated: Bool) {
|
||||
|
@ -343,10 +332,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
static let kInterItemSpacing: CGFloat = 2
|
||||
private class func buildLayout() -> UICollectionViewFlowLayout {
|
||||
let layout = UICollectionViewFlowLayout()
|
||||
|
||||
if #available(iOS 11, *) {
|
||||
layout.sectionInsetReference = .fromSafeArea
|
||||
}
|
||||
layout.sectionInsetReference = .fromSafeArea
|
||||
layout.minimumInteritemSpacing = kInterItemSpacing
|
||||
layout.minimumLineSpacing = kInterItemSpacing
|
||||
layout.sectionHeadersPinToVisibleBounds = true
|
||||
|
@ -355,13 +341,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
}
|
||||
|
||||
func updateLayout() {
|
||||
let containerWidth: CGFloat
|
||||
if #available(iOS 11.0, *) {
|
||||
containerWidth = self.view.safeAreaLayoutGuide.layoutFrame.size.width
|
||||
} else {
|
||||
containerWidth = self.view.frame.size.width
|
||||
}
|
||||
|
||||
let containerWidth: CGFloat = self.view.safeAreaLayoutGuide.layoutFrame.size.width
|
||||
let kItemsPerPortraitRow = 4
|
||||
let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
|
||||
let approxItemWidth = screenWidth / CGFloat(kItemsPerPortraitRow)
|
||||
|
@ -556,11 +536,9 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
return UICollectionViewCell(forAutoLayout: ())
|
||||
}
|
||||
|
||||
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PhotoGridViewCell.reuseIdentifier, for: indexPath) as? PhotoGridViewCell else {
|
||||
owsFail("cell was unexpectedly nil")
|
||||
}
|
||||
|
||||
let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath)
|
||||
cell.loadingColor = UIColor(white: 0.2, alpha: 1)
|
||||
|
||||
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
|
||||
cell.configure(item: assetItem)
|
||||
|
||||
|
@ -587,7 +565,7 @@ extension ImagePickerGridController: UIGestureRecognizerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
protocol TitleViewDelegate: class {
|
||||
protocol TitleViewDelegate: AnyObject {
|
||||
func titleViewWasTapped(_ titleView: TitleView)
|
||||
}
|
||||
|
||||
|
@ -615,10 +593,10 @@ class TitleView: UIView {
|
|||
addSubview(stackView)
|
||||
stackView.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
label.textColor = .black
|
||||
label.textColor = Colors.text
|
||||
label.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
|
||||
iconView.tintColor = .black
|
||||
iconView.tintColor = Colors.text
|
||||
iconView.image = UIImage(named: "navbar_disclosure_down")?.withRenderingMode(.alwaysTemplate)
|
||||
|
||||
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(titleTapped)))
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import <SignalUtilitiesKit/OWSViewController.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@protocol ConversationViewItem;
|
||||
|
||||
@class GalleryItemBox;
|
||||
@class MediaDetailViewController;
|
||||
@class TSAttachment;
|
||||
|
||||
typedef NS_OPTIONS(NSInteger, MediaGalleryOption) {
|
||||
MediaGalleryOptionSliderEnabled = 1 << 0,
|
||||
MediaGalleryOptionShowAllMediaButton = 1 << 1
|
||||
};
|
||||
|
||||
@protocol MediaDetailViewControllerDelegate <NSObject>
|
||||
|
||||
- (void)mediaDetailViewController:(MediaDetailViewController *)mediaDetailViewController
|
||||
requestDeleteAttachment:(TSAttachment *)attachment;
|
||||
|
||||
- (void)mediaDetailViewController:(MediaDetailViewController *)mediaDetailViewController
|
||||
isPlayingVideo:(BOOL)isPlayingVideo;
|
||||
|
||||
- (void)mediaDetailViewControllerDidTapMedia:(MediaDetailViewController *)mediaDetailViewController;
|
||||
|
||||
@end
|
||||
|
||||
@interface MediaDetailViewController : OWSViewController
|
||||
|
||||
@property (nonatomic, weak) id<MediaDetailViewControllerDelegate> delegate;
|
||||
@property (nonatomic, readonly) GalleryItemBox *galleryItemBox;
|
||||
|
||||
// If viewItem is non-null, long press will show a menu controller.
|
||||
- (instancetype)initWithGalleryItemBox:(GalleryItemBox *)galleryItemBox
|
||||
viewItem:(nullable id<ConversationViewItem>)viewItem;
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)didPressPlayBarButton:(id)sender;
|
||||
- (void)didPressPauseBarButton:(id)sender;
|
||||
- (void)playVideo;
|
||||
|
||||
// Stops playback and rewinds
|
||||
- (void)stopAnyVideo;
|
||||
|
||||
- (void)setShouldHideToolbars:(BOOL)shouldHideToolbars;
|
||||
- (void)zoomOutAnimated:(BOOL)isAnimated;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -1,500 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
#import "MediaDetailViewController.h"
|
||||
#import "ConversationViewItem.h"
|
||||
#import "Session-Swift.h"
|
||||
#import "TSAttachmentStream.h"
|
||||
#import "TSInteraction.h"
|
||||
#import "UIColor+OWS.h"
|
||||
#import "UIUtil.h"
|
||||
#import "UIView+OWS.h"
|
||||
#import <AVKit/AVKit.h>
|
||||
#import <MediaPlayer/MPMoviePlayerViewController.h>
|
||||
#import <MediaPlayer/MediaPlayer.h>
|
||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||
#import <SessionUtilitiesKit/NSData+Image.h>
|
||||
#import <SessionUIKit/SessionUIKit.h>
|
||||
#import <YYImage/YYImage.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@interface MediaDetailViewController () <UIScrollViewDelegate,
|
||||
UIGestureRecognizerDelegate,
|
||||
PlayerProgressBarDelegate,
|
||||
OWSVideoPlayerDelegate>
|
||||
|
||||
@property (nonatomic) UIScrollView *scrollView;
|
||||
@property (nonatomic) UIView *mediaView;
|
||||
@property (nonatomic) UIView *presentationView;
|
||||
@property (nonatomic) UIView *replacingView;
|
||||
@property (nonatomic) UIButton *shareButton;
|
||||
|
||||
@property (nonatomic) TSAttachmentStream *attachmentStream;
|
||||
@property (nonatomic, nullable) id<ConversationViewItem> viewItem;
|
||||
@property (nonatomic, nullable) UIImage *image;
|
||||
|
||||
@property (nonatomic, nullable) OWSVideoPlayer *videoPlayer;
|
||||
@property (nonatomic, nullable) UIButton *playVideoButton;
|
||||
@property (nonatomic, nullable) PlayerProgressBar *videoProgressBar;
|
||||
@property (nonatomic, nullable) UIBarButtonItem *videoPlayBarButton;
|
||||
@property (nonatomic, nullable) UIBarButtonItem *videoPauseBarButton;
|
||||
|
||||
@property (nonatomic, nullable) NSArray<NSLayoutConstraint *> *presentationViewConstraints;
|
||||
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewBottomConstraint;
|
||||
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewLeadingConstraint;
|
||||
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewTopConstraint;
|
||||
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewTrailingConstraint;
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark -
|
||||
|
||||
@implementation MediaDetailViewController
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[self stopAnyVideo];
|
||||
}
|
||||
|
||||
- (instancetype)initWithGalleryItemBox:(GalleryItemBox *)galleryItemBox
|
||||
viewItem:(nullable id<ConversationViewItem>)viewItem
|
||||
{
|
||||
self = [super initWithNibName:nil bundle:nil];
|
||||
if (!self) {
|
||||
return self;
|
||||
}
|
||||
|
||||
_galleryItemBox = galleryItemBox;
|
||||
_viewItem = viewItem;
|
||||
|
||||
// We cache the image data in case the attachment stream is deleted.
|
||||
__weak MediaDetailViewController *weakSelf = self;
|
||||
_image = [galleryItemBox.attachmentStream
|
||||
thumbnailImageLargeWithSuccess:^(UIImage *image) {
|
||||
weakSelf.image = image;
|
||||
[weakSelf updateContents];
|
||||
[weakSelf updateMinZoomScale];
|
||||
}
|
||||
failure:^{
|
||||
OWSLogWarn(@"Could not load media.");
|
||||
}];
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (TSAttachmentStream *)attachmentStream
|
||||
{
|
||||
return self.galleryItemBox.attachmentStream;
|
||||
}
|
||||
|
||||
- (BOOL)isAnimated
|
||||
{
|
||||
return self.attachmentStream.isAnimated;
|
||||
}
|
||||
|
||||
- (BOOL)isVideo
|
||||
{
|
||||
return self.attachmentStream.isVideo;
|
||||
}
|
||||
|
||||
- (void)viewDidLoad
|
||||
{
|
||||
[super viewDidLoad];
|
||||
|
||||
self.view.backgroundColor = LKColors.navigationBarBackground;
|
||||
|
||||
[self updateContents];
|
||||
|
||||
// Loki: Set navigation bar background color
|
||||
UINavigationBar *navigationBar = self.navigationController.navigationBar;
|
||||
[navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault];
|
||||
navigationBar.shadowImage = [UIImage new];
|
||||
[navigationBar setTranslucent:NO];
|
||||
navigationBar.barTintColor = LKColors.navigationBarBackground;
|
||||
}
|
||||
|
||||
- (void)viewWillAppear:(BOOL)animated
|
||||
{
|
||||
[super viewWillAppear:animated];
|
||||
[self resetMediaFrame];
|
||||
}
|
||||
|
||||
- (void)viewDidLayoutSubviews
|
||||
{
|
||||
[super viewDidLayoutSubviews];
|
||||
|
||||
[self updateMinZoomScale];
|
||||
[self centerMediaViewConstraints];
|
||||
}
|
||||
|
||||
- (void)updateMinZoomScale
|
||||
{
|
||||
if (!self.image) {
|
||||
self.scrollView.minimumZoomScale = 1.f;
|
||||
self.scrollView.maximumZoomScale = 1.f;
|
||||
self.scrollView.zoomScale = 1.f;
|
||||
return;
|
||||
}
|
||||
|
||||
CGSize viewSize = self.scrollView.bounds.size;
|
||||
UIImage *image = self.image;
|
||||
OWSAssertDebug(image);
|
||||
|
||||
if (image.size.width == 0 || image.size.height == 0) {
|
||||
OWSFailDebug(@"Invalid image dimensions. %@", NSStringFromCGSize(image.size));
|
||||
return;
|
||||
}
|
||||
|
||||
CGFloat scaleWidth = viewSize.width / image.size.width;
|
||||
CGFloat scaleHeight = viewSize.height / image.size.height;
|
||||
CGFloat minScale = MIN(scaleWidth, scaleHeight);
|
||||
|
||||
if (minScale != self.scrollView.minimumZoomScale) {
|
||||
self.scrollView.minimumZoomScale = minScale;
|
||||
self.scrollView.maximumZoomScale = minScale * 8;
|
||||
self.scrollView.zoomScale = minScale;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)zoomOutAnimated:(BOOL)isAnimated
|
||||
{
|
||||
if (self.scrollView.zoomScale != self.scrollView.minimumZoomScale) {
|
||||
[self.scrollView setZoomScale:self.scrollView.minimumZoomScale animated:isAnimated];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Initializers
|
||||
|
||||
- (void)updateContents
|
||||
{
|
||||
[self.mediaView removeFromSuperview];
|
||||
[self.scrollView removeFromSuperview];
|
||||
[self.playVideoButton removeFromSuperview];
|
||||
[self.videoProgressBar removeFromSuperview];
|
||||
|
||||
UIScrollView *scrollView = [UIScrollView new];
|
||||
[self.view addSubview:scrollView];
|
||||
self.scrollView = scrollView;
|
||||
scrollView.delegate = self;
|
||||
|
||||
scrollView.showsVerticalScrollIndicator = NO;
|
||||
scrollView.showsHorizontalScrollIndicator = NO;
|
||||
scrollView.decelerationRate = UIScrollViewDecelerationRateFast;
|
||||
|
||||
if (@available(iOS 11.0, *)) {
|
||||
[scrollView contentInsetAdjustmentBehavior];
|
||||
} else {
|
||||
self.automaticallyAdjustsScrollViewInsets = NO;
|
||||
}
|
||||
|
||||
[scrollView ows_autoPinToSuperviewEdges];
|
||||
|
||||
if (self.isAnimated) {
|
||||
if (self.attachmentStream.isValidImage) {
|
||||
YYImage *animatedGif = [YYImage imageWithContentsOfFile:self.attachmentStream.originalFilePath];
|
||||
YYAnimatedImageView *animatedView = [YYAnimatedImageView new];
|
||||
animatedView.image = animatedGif;
|
||||
self.mediaView = animatedView;
|
||||
} else {
|
||||
self.mediaView = [UIView new];
|
||||
self.mediaView.backgroundColor = LKColors.unimportant;
|
||||
}
|
||||
} else if (!self.image) {
|
||||
// Still loading thumbnail.
|
||||
self.mediaView = [UIView new];
|
||||
self.mediaView.backgroundColor = LKColors.unimportant;
|
||||
} else if (self.isVideo) {
|
||||
if (self.attachmentStream.isValidVideo) {
|
||||
self.mediaView = [self buildVideoPlayerView];
|
||||
} else {
|
||||
self.mediaView = [UIView new];
|
||||
self.mediaView.backgroundColor = LKColors.unimportant;
|
||||
}
|
||||
} else {
|
||||
// Present the static image using standard UIImageView
|
||||
UIImageView *imageView = [[UIImageView alloc] initWithImage:self.image];
|
||||
self.mediaView = imageView;
|
||||
}
|
||||
|
||||
OWSAssertDebug(self.mediaView);
|
||||
|
||||
// We add these gestures to mediaView rather than
|
||||
// the root view so that interacting with the video player
|
||||
// progres bar doesn't trigger any of these gestures.
|
||||
[self addGestureRecognizersToView:self.mediaView];
|
||||
|
||||
[scrollView addSubview:self.mediaView];
|
||||
self.mediaViewLeadingConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeLeading];
|
||||
self.mediaViewTopConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTop];
|
||||
self.mediaViewTrailingConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTrailing];
|
||||
self.mediaViewBottomConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeBottom];
|
||||
|
||||
self.mediaView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
self.mediaView.userInteractionEnabled = YES;
|
||||
self.mediaView.clipsToBounds = YES;
|
||||
self.mediaView.layer.allowsEdgeAntialiasing = YES;
|
||||
self.mediaView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
|
||||
// Use trilinear filters for better scaling quality at
|
||||
// some performance cost.
|
||||
self.mediaView.layer.minificationFilter = kCAFilterTrilinear;
|
||||
self.mediaView.layer.magnificationFilter = kCAFilterTrilinear;
|
||||
|
||||
if (self.isVideo) {
|
||||
PlayerProgressBar *videoProgressBar = [PlayerProgressBar new];
|
||||
videoProgressBar.delegate = self;
|
||||
videoProgressBar.player = self.videoPlayer.avPlayer;
|
||||
|
||||
// We hide the progress bar until either:
|
||||
// 1. Video completes playing
|
||||
// 2. User taps the screen
|
||||
videoProgressBar.hidden = YES;
|
||||
|
||||
self.videoProgressBar = videoProgressBar;
|
||||
[self.view addSubview:videoProgressBar];
|
||||
[videoProgressBar autoPinWidthToSuperview];
|
||||
[videoProgressBar autoPinEdgeToSuperviewSafeArea:ALEdgeTop];
|
||||
CGFloat kVideoProgressBarHeight = 44;
|
||||
[videoProgressBar autoSetDimension:ALDimensionHeight toSize:kVideoProgressBarHeight];
|
||||
|
||||
UIButton *playVideoButton = [UIButton new];
|
||||
self.playVideoButton = playVideoButton;
|
||||
|
||||
[playVideoButton addTarget:self action:@selector(playVideo) forControlEvents:UIControlEventTouchUpInside];
|
||||
|
||||
UIImage *playImage = [UIImage imageNamed:@"CirclePlay"];
|
||||
[playVideoButton setBackgroundImage:playImage forState:UIControlStateNormal];
|
||||
playVideoButton.contentMode = UIViewContentModeScaleAspectFill;
|
||||
|
||||
[self.view addSubview:playVideoButton];
|
||||
|
||||
CGFloat playVideoButtonWidth = 72.f;
|
||||
[playVideoButton autoSetDimensionsToSize:CGSizeMake(playVideoButtonWidth, playVideoButtonWidth)];
|
||||
[playVideoButton autoCenterInSuperview];
|
||||
}
|
||||
}
|
||||
|
||||
- (UIView *)buildVideoPlayerView
|
||||
{
|
||||
NSURL *_Nullable attachmentUrl = self.attachmentStream.originalMediaURL;
|
||||
|
||||
NSFileManager *fileManager = [NSFileManager defaultManager];
|
||||
if (![fileManager fileExistsAtPath:[attachmentUrl path]]) {
|
||||
OWSFailDebug(@"Missing video file");
|
||||
}
|
||||
|
||||
OWSVideoPlayer *player = [[OWSVideoPlayer alloc] initWithUrl:attachmentUrl];
|
||||
[player seekToTime:kCMTimeZero];
|
||||
player.delegate = self;
|
||||
self.videoPlayer = player;
|
||||
|
||||
VideoPlayerView *playerView = [VideoPlayerView new];
|
||||
playerView.player = player.avPlayer;
|
||||
|
||||
[NSLayoutConstraint autoSetPriority:UILayoutPriorityDefaultLow
|
||||
forConstraints:^{
|
||||
[playerView autoSetDimensionsToSize:self.image.size];
|
||||
}];
|
||||
|
||||
return playerView;
|
||||
}
|
||||
|
||||
- (void)setShouldHideToolbars:(BOOL)shouldHideToolbars
|
||||
{
|
||||
self.videoProgressBar.hidden = shouldHideToolbars;
|
||||
}
|
||||
|
||||
- (void)addGestureRecognizersToView:(UIView *)view
|
||||
{
|
||||
UITapGestureRecognizer *doubleTap =
|
||||
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didDoubleTapImage:)];
|
||||
doubleTap.numberOfTapsRequired = 2;
|
||||
[view addGestureRecognizer:doubleTap];
|
||||
|
||||
UITapGestureRecognizer *singleTap =
|
||||
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didSingleTapImage:)];
|
||||
[singleTap requireGestureRecognizerToFail:doubleTap];
|
||||
[view addGestureRecognizer:singleTap];
|
||||
}
|
||||
|
||||
#pragma mark - Gesture Recognizers
|
||||
|
||||
- (void)didSingleTapImage:(UITapGestureRecognizer *)gesture
|
||||
{
|
||||
[self.delegate mediaDetailViewControllerDidTapMedia:self];
|
||||
}
|
||||
|
||||
- (void)didDoubleTapImage:(UITapGestureRecognizer *)gesture
|
||||
{
|
||||
OWSLogVerbose(@"did double tap image.");
|
||||
if (self.scrollView.zoomScale == self.scrollView.minimumZoomScale) {
|
||||
CGFloat kDoubleTapZoomScale = 2;
|
||||
|
||||
CGFloat zoomWidth = self.scrollView.width / kDoubleTapZoomScale;
|
||||
CGFloat zoomHeight = self.scrollView.height / kDoubleTapZoomScale;
|
||||
|
||||
// center zoom rect around tapLocation
|
||||
CGPoint tapLocation = [gesture locationInView:self.scrollView];
|
||||
CGFloat zoomX = MAX(0, tapLocation.x - zoomWidth / 2);
|
||||
CGFloat zoomY = MAX(0, tapLocation.y - zoomHeight / 2);
|
||||
|
||||
CGRect zoomRect = CGRectMake(zoomX, zoomY, zoomWidth, zoomHeight);
|
||||
|
||||
CGRect translatedRect = [self.mediaView convertRect:zoomRect fromView:self.scrollView];
|
||||
|
||||
[self.scrollView zoomToRect:translatedRect animated:YES];
|
||||
} else {
|
||||
// If already zoomed in at all, zoom out all the way.
|
||||
[self zoomOutAnimated:YES];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)didPressPlayBarButton:(id)sender
|
||||
{
|
||||
OWSAssertDebug(self.isVideo);
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
[self playVideo];
|
||||
}
|
||||
|
||||
- (void)didPressPauseBarButton:(id)sender
|
||||
{
|
||||
OWSAssertDebug(self.isVideo);
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
[self pauseVideo];
|
||||
}
|
||||
|
||||
#pragma mark - UIScrollViewDelegate
|
||||
|
||||
- (nullable UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
|
||||
{
|
||||
return self.mediaView;
|
||||
}
|
||||
|
||||
- (void)centerMediaViewConstraints
|
||||
{
|
||||
OWSAssertDebug(self.scrollView);
|
||||
|
||||
CGSize scrollViewSize = self.scrollView.bounds.size;
|
||||
CGSize imageViewSize = self.mediaView.frame.size;
|
||||
|
||||
CGFloat yOffset = MAX(0, (scrollViewSize.height - imageViewSize.height) / 2);
|
||||
self.mediaViewTopConstraint.constant = yOffset;
|
||||
self.mediaViewBottomConstraint.constant = yOffset;
|
||||
|
||||
CGFloat xOffset = MAX(0, (scrollViewSize.width - imageViewSize.width) / 2);
|
||||
self.mediaViewLeadingConstraint.constant = xOffset;
|
||||
self.mediaViewTrailingConstraint.constant = xOffset;
|
||||
}
|
||||
|
||||
- (void)scrollViewDidZoom:(UIScrollView *)scrollView
|
||||
{
|
||||
[self centerMediaViewConstraints];
|
||||
[self.view layoutIfNeeded];
|
||||
}
|
||||
|
||||
- (void)resetMediaFrame
|
||||
{
|
||||
// HACK: Setting the frame to itself *seems* like it should be a no-op, but
|
||||
// it ensures the content is drawn at the right frame. In particular I was
|
||||
// reproducibly seeing some images squished (they were EXIF rotated, maybe
|
||||
// related). similar to this report:
|
||||
// https://stackoverflow.com/questions/27961884/swift-uiimageview-stretched-aspect
|
||||
[self.view layoutIfNeeded];
|
||||
self.mediaView.frame = self.mediaView.frame;
|
||||
}
|
||||
|
||||
#pragma mark - Video Playback
|
||||
|
||||
- (void)playVideo
|
||||
{
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
|
||||
self.playVideoButton.hidden = YES;
|
||||
|
||||
[self.videoPlayer play];
|
||||
|
||||
[self.delegate mediaDetailViewController:self isPlayingVideo:YES];
|
||||
}
|
||||
|
||||
- (void)pauseVideo
|
||||
{
|
||||
OWSAssertDebug(self.isVideo);
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
|
||||
[self.videoPlayer pause];
|
||||
|
||||
[self.delegate mediaDetailViewController:self isPlayingVideo:NO];
|
||||
}
|
||||
|
||||
- (void)stopAnyVideo
|
||||
{
|
||||
if (self.isVideo) {
|
||||
[self stopVideo];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)stopVideo
|
||||
{
|
||||
OWSAssertDebug(self.isVideo);
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
|
||||
[self.videoPlayer stop];
|
||||
|
||||
self.playVideoButton.hidden = NO;
|
||||
|
||||
[self.delegate mediaDetailViewController:self isPlayingVideo:NO];
|
||||
}
|
||||
|
||||
#pragma mark - OWSVideoPlayer
|
||||
|
||||
- (void)videoPlayerDidPlayToCompletion:(OWSVideoPlayer *)videoPlayer
|
||||
{
|
||||
OWSAssertDebug(self.isVideo);
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
OWSLogVerbose(@"");
|
||||
|
||||
[self stopVideo];
|
||||
}
|
||||
|
||||
#pragma mark - PlayerProgressBarDelegate
|
||||
|
||||
- (void)playerProgressBarDidStartScrubbing:(PlayerProgressBar *)playerProgressBar
|
||||
{
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
[self.videoPlayer pause];
|
||||
}
|
||||
|
||||
- (void)playerProgressBar:(PlayerProgressBar *)playerProgressBar scrubbedToTime:(CMTime)time
|
||||
{
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
[self.videoPlayer seekToTime:time];
|
||||
}
|
||||
|
||||
- (void)playerProgressBar:(PlayerProgressBar *)playerProgressBar
|
||||
didFinishScrubbingAtTime:(CMTime)time
|
||||
shouldResumePlayback:(BOOL)shouldResumePlayback
|
||||
{
|
||||
OWSAssertDebug(self.videoPlayer);
|
||||
[self.videoPlayer seekToTime:time];
|
||||
|
||||
if (shouldResumePlayback) {
|
||||
[self.videoPlayer play];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Saving images to Camera Roll
|
||||
|
||||
- (void)image:(UIImage *)image didFinishSavingWithError:(NSError *)error contextInfo:(void *)contextInfo
|
||||
{
|
||||
if (error) {
|
||||
OWSLogWarn(@"There was a problem saving <%@> to camera roll.", error.localizedDescription);
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
|
@ -0,0 +1,436 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import YYImage
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
|
||||
public enum MediaGalleryOption {
|
||||
case sliderEnabled
|
||||
case showAllMediaButton
|
||||
}
|
||||
|
||||
class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVideoPlayerDelegate, PlayerProgressBarDelegate {
|
||||
public let galleryItem: MediaGalleryViewModel.Item
|
||||
public weak var delegate: MediaDetailViewControllerDelegate?
|
||||
private var image: UIImage?
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private var mediaViewBottomConstraint: NSLayoutConstraint?
|
||||
private var mediaViewLeadingConstraint: NSLayoutConstraint?
|
||||
private var mediaViewTopConstraint: NSLayoutConstraint?
|
||||
private var mediaViewTrailingConstraint: NSLayoutConstraint?
|
||||
|
||||
private lazy var scrollView: UIScrollView = {
|
||||
let result: UIScrollView = UIScrollView()
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.showsHorizontalScrollIndicator = false
|
||||
result.contentInsetAdjustmentBehavior = .never
|
||||
result.decelerationRate = .fast
|
||||
result.delegate = self
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
public var mediaView: UIView = UIView()
|
||||
private var playVideoButton: UIButton = UIButton()
|
||||
private var videoProgressBar: PlayerProgressBar = PlayerProgressBar()
|
||||
private var videoPlayer: OWSVideoPlayer?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
galleryItem: MediaGalleryViewModel.Item,
|
||||
delegate: MediaDetailViewControllerDelegate? = nil
|
||||
) {
|
||||
self.galleryItem = galleryItem
|
||||
self.delegate = delegate
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
// We cache the image data in case the attachment stream is deleted.
|
||||
galleryItem.attachment.thumbnail(
|
||||
size: .large,
|
||||
success: { [weak self] image, _ in
|
||||
// Only reload the content if the view has already loaded (if it
|
||||
// hasn't then it'll load with the image immediately)
|
||||
let updateUICallback = {
|
||||
self?.image = image
|
||||
|
||||
if self?.isViewLoaded == true {
|
||||
self?.updateContents()
|
||||
self?.updateMinZoomScale()
|
||||
}
|
||||
}
|
||||
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
updateUICallback()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
updateUICallback()
|
||||
},
|
||||
failure: {
|
||||
SNLog("Could not load media.")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.stopAnyVideo()
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.view.backgroundColor = Colors.navigationBarBackground
|
||||
|
||||
self.view.addSubview(scrollView)
|
||||
scrollView.pin(to: self.view)
|
||||
|
||||
self.updateContents()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
self.resetMediaFrame()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
if mediaView is YYAnimatedImageView {
|
||||
// Add a slight delay before starting the gif animation to prevent it from looking
|
||||
// buggy due to the custom transition
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { [weak self] in
|
||||
(self?.mediaView as? YYAnimatedImageView)?.startAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
self.updateMinZoomScale()
|
||||
self.centerMediaViewConstraints()
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
private func updateMinZoomScale() {
|
||||
guard let image: UIImage = image else {
|
||||
self.scrollView.minimumZoomScale = 1
|
||||
self.scrollView.maximumZoomScale = 1
|
||||
self.scrollView.zoomScale = 1
|
||||
return
|
||||
}
|
||||
|
||||
let viewSize: CGSize = self.scrollView.bounds.size
|
||||
|
||||
guard image.size.width > 0 && image.size.height > 0 else {
|
||||
SNLog("Invalid image dimensions (\(image.size.width), \(image.size.height))")
|
||||
return;
|
||||
}
|
||||
|
||||
let scaleWidth: CGFloat = (viewSize.width / image.size.width)
|
||||
let scaleHeight: CGFloat = (viewSize.height / image.size.height)
|
||||
let minScale: CGFloat = min(scaleWidth, scaleHeight)
|
||||
|
||||
if minScale != self.scrollView.minimumZoomScale {
|
||||
self.scrollView.minimumZoomScale = minScale
|
||||
self.scrollView.maximumZoomScale = (minScale * 8)
|
||||
self.scrollView.zoomScale = minScale
|
||||
}
|
||||
}
|
||||
|
||||
public func zoomOut(animated: Bool) {
|
||||
if self.scrollView.zoomScale != self.scrollView.minimumZoomScale {
|
||||
self.scrollView.setZoomScale(self.scrollView.minimumZoomScale, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
private func updateContents() {
|
||||
self.mediaView.removeFromSuperview()
|
||||
self.playVideoButton.removeFromSuperview()
|
||||
self.videoProgressBar.removeFromSuperview()
|
||||
self.scrollView.zoomScale = 1
|
||||
|
||||
if self.galleryItem.attachment.isAnimated {
|
||||
if self.galleryItem.attachment.isValid, let originalFilePath: String = self.galleryItem.attachment.originalFilePath {
|
||||
let animatedView: YYAnimatedImageView = YYAnimatedImageView()
|
||||
animatedView.autoPlayAnimatedImage = false
|
||||
animatedView.image = YYImage(contentsOfFile: originalFilePath)
|
||||
self.mediaView = animatedView
|
||||
}
|
||||
else {
|
||||
self.mediaView = UIView()
|
||||
self.mediaView.backgroundColor = Colors.unimportant
|
||||
}
|
||||
}
|
||||
else if self.image == nil {
|
||||
// Still loading thumbnail.
|
||||
self.mediaView = UIView()
|
||||
self.mediaView.backgroundColor = Colors.unimportant
|
||||
}
|
||||
else if self.galleryItem.attachment.isVideo {
|
||||
if self.galleryItem.attachment.isValid {
|
||||
self.mediaView = self.buildVideoPlayerView()
|
||||
}
|
||||
else {
|
||||
self.mediaView = UIView()
|
||||
self.mediaView.backgroundColor = Colors.unimportant
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Present the static image using standard UIImageView
|
||||
self.mediaView = UIImageView(image: self.image)
|
||||
}
|
||||
|
||||
// We add these gestures to mediaView rather than
|
||||
// the root view so that interacting with the video player
|
||||
// progres bar doesn't trigger any of these gestures.
|
||||
self.addGestureRecognizers(to: self.mediaView)
|
||||
self.scrollView.addSubview(self.mediaView)
|
||||
|
||||
self.mediaViewLeadingConstraint = self.mediaView.pin(.leading, to: .leading, of: self.scrollView)
|
||||
self.mediaViewTopConstraint = self.mediaView.pin(.top, to: .top, of: self.scrollView)
|
||||
self.mediaViewTrailingConstraint = self.mediaView.pin(.trailing, to: .trailing, of: self.scrollView)
|
||||
self.mediaViewBottomConstraint = self.mediaView.pin(.bottom, to: .bottom, of: self.scrollView)
|
||||
|
||||
self.mediaView.contentMode = .scaleAspectFit
|
||||
self.mediaView.isUserInteractionEnabled = true
|
||||
self.mediaView.clipsToBounds = true
|
||||
self.mediaView.layer.allowsEdgeAntialiasing = true
|
||||
self.mediaView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// Use trilinear filters for better scaling quality at
|
||||
// some performance cost.
|
||||
self.mediaView.layer.minificationFilter = .trilinear
|
||||
self.mediaView.layer.magnificationFilter = .trilinear
|
||||
|
||||
if self.galleryItem.attachment.isVideo {
|
||||
self.videoProgressBar = PlayerProgressBar()
|
||||
self.videoProgressBar.delegate = self
|
||||
self.videoProgressBar.player = self.videoPlayer?.avPlayer
|
||||
|
||||
// We hide the progress bar until either:
|
||||
// 1. Video completes playing
|
||||
// 2. User taps the screen
|
||||
self.videoProgressBar.isHidden = false
|
||||
|
||||
self.view.addSubview(self.videoProgressBar)
|
||||
|
||||
self.videoProgressBar.autoPinWidthToSuperview()
|
||||
self.videoProgressBar.autoPinEdge(toSuperviewSafeArea: .top)
|
||||
self.videoProgressBar.autoSetDimension(.height, toSize: 44)
|
||||
|
||||
self.playVideoButton = UIButton()
|
||||
self.playVideoButton.contentMode = .scaleAspectFill
|
||||
self.playVideoButton.setBackgroundImage(UIImage(named: "CirclePlay"), for: .normal)
|
||||
self.playVideoButton.addTarget(self, action: #selector(playVideo), for: .touchUpInside)
|
||||
self.view.addSubview(self.playVideoButton)
|
||||
|
||||
self.playVideoButton.set(.width, to: 72)
|
||||
self.playVideoButton.set(.height, to: 72)
|
||||
self.playVideoButton.center(in: self.view)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildVideoPlayerView() -> UIView {
|
||||
guard
|
||||
let originalFilePath: String = self.galleryItem.attachment.originalFilePath,
|
||||
FileManager.default.fileExists(atPath: originalFilePath)
|
||||
else {
|
||||
owsFailDebug("Missing video file")
|
||||
return UIView()
|
||||
}
|
||||
|
||||
self.videoPlayer = OWSVideoPlayer(url: URL(fileURLWithPath: originalFilePath))
|
||||
self.videoPlayer?.seek(to: .zero)
|
||||
self.videoPlayer?.delegate = self
|
||||
|
||||
let imageSize: CGSize = (self.image?.size ?? .zero)
|
||||
let playerView: VideoPlayerView = VideoPlayerView()
|
||||
playerView.player = self.videoPlayer?.avPlayer
|
||||
|
||||
NSLayoutConstraint.autoSetPriority(.defaultLow) {
|
||||
playerView.autoSetDimensions(to: imageSize)
|
||||
}
|
||||
|
||||
return playerView
|
||||
}
|
||||
|
||||
public func setShouldHideToolbars(_ shouldHideToolbars: Bool) {
|
||||
self.videoProgressBar.isHidden = shouldHideToolbars
|
||||
}
|
||||
|
||||
private func addGestureRecognizers(to view: UIView) {
|
||||
let doubleTap: UITapGestureRecognizer = UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(didDoubleTapImage(_:))
|
||||
)
|
||||
doubleTap.numberOfTapsRequired = 2
|
||||
view.addGestureRecognizer(doubleTap)
|
||||
|
||||
let singleTap: UITapGestureRecognizer = UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(didSingleTapImage(_:))
|
||||
)
|
||||
singleTap.require(toFail: doubleTap)
|
||||
view.addGestureRecognizer(singleTap)
|
||||
}
|
||||
|
||||
// MARK: - Gesture Recognizers
|
||||
|
||||
@objc private func didSingleTapImage(_ gesture: UITapGestureRecognizer) {
|
||||
self.delegate?.mediaDetailViewControllerDidTapMedia(self)
|
||||
}
|
||||
|
||||
@objc private func didDoubleTapImage(_ gesture: UITapGestureRecognizer) {
|
||||
guard self.scrollView.zoomScale == self.scrollView.minimumZoomScale else {
|
||||
// If already zoomed in at all, zoom out all the way.
|
||||
self.zoomOut(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
let doubleTapZoomScale: CGFloat = 2
|
||||
let zoomWidth: CGFloat = (self.scrollView.bounds.width / doubleTapZoomScale)
|
||||
let zoomHeight: CGFloat = (self.scrollView.bounds.height / doubleTapZoomScale)
|
||||
|
||||
// Center zoom rect around tapLocation
|
||||
let tapLocation: CGPoint = gesture.location(in: self.scrollView)
|
||||
let zoomX: CGFloat = max(0, tapLocation.x - zoomWidth / 2)
|
||||
let zoomY: CGFloat = max(0, tapLocation.y - zoomHeight / 2)
|
||||
let zoomRect: CGRect = CGRect(x: zoomX, y: zoomY, width: zoomWidth, height: zoomHeight)
|
||||
let translatedRect: CGRect = self.mediaView.convert(zoomRect, to: self.scrollView)
|
||||
|
||||
self.scrollView.zoom(to: translatedRect, animated: true)
|
||||
}
|
||||
|
||||
@objc public func didPressPlayBarButton() {
|
||||
self.playVideo()
|
||||
}
|
||||
|
||||
@objc public func didPressPauseBarButton() {
|
||||
self.pauseVideo()
|
||||
}
|
||||
|
||||
// MARK: - UIScrollViewDelegate
|
||||
|
||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
return self.mediaView
|
||||
}
|
||||
|
||||
private func centerMediaViewConstraints() {
|
||||
let scrollViewSize: CGSize = self.scrollView.bounds.size
|
||||
let imageViewSize: CGSize = self.mediaView.frame.size
|
||||
|
||||
// We want to modify the yOffset so the content remains centered on the screen (we can do this
|
||||
// by subtracting half the parentViewController's y position)
|
||||
//
|
||||
// Note: Due to weird partial-pixel value rendering behaviours we need to round the inset either
|
||||
// up or down depending on which direction the partial-pixel would end up rounded to make it
|
||||
// align correctly
|
||||
let halfHeightDiff: CGFloat = ((self.scrollView.bounds.size.height - self.mediaView.frame.size.height) / 2)
|
||||
let shouldRoundUp: Bool = (round(halfHeightDiff) - halfHeightDiff > 0)
|
||||
|
||||
let yOffset: CGFloat = (
|
||||
round((scrollViewSize.height - imageViewSize.height) / 2) -
|
||||
(shouldRoundUp ?
|
||||
ceil((self.parent?.view.frame.origin.y ?? 0) / 2) :
|
||||
floor((self.parent?.view.frame.origin.y ?? 0) / 2)
|
||||
)
|
||||
)
|
||||
|
||||
self.mediaViewTopConstraint?.constant = yOffset
|
||||
self.mediaViewBottomConstraint?.constant = yOffset
|
||||
|
||||
let xOffset: CGFloat = max(0, (scrollViewSize.width - imageViewSize.width) / 2)
|
||||
self.mediaViewLeadingConstraint?.constant = xOffset
|
||||
self.mediaViewTrailingConstraint?.constant = xOffset
|
||||
}
|
||||
|
||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
self.centerMediaViewConstraints()
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
private func resetMediaFrame() {
|
||||
// HACK: Setting the frame to itself *seems* like it should be a no-op, but
|
||||
// it ensures the content is drawn at the right frame. In particular I was
|
||||
// reproducibly seeing some images squished (they were EXIF rotated, maybe
|
||||
// related). similar to this report:
|
||||
// https://stackoverflow.com/questions/27961884/swift-uiimageview-stretched-aspect
|
||||
self.view.layoutIfNeeded()
|
||||
self.mediaView.frame = self.mediaView.frame
|
||||
}
|
||||
|
||||
// MARK: - Video Playback
|
||||
|
||||
@objc public func playVideo() {
|
||||
self.playVideoButton.isHidden = true
|
||||
self.videoPlayer?.play()
|
||||
self.delegate?.mediaDetailViewController(self, isPlayingVideo: true)
|
||||
}
|
||||
|
||||
private func pauseVideo() {
|
||||
self.videoPlayer?.pause()
|
||||
self.delegate?.mediaDetailViewController(self, isPlayingVideo: false)
|
||||
}
|
||||
|
||||
public func stopAnyVideo() {
|
||||
guard self.galleryItem.attachment.isVideo else { return }
|
||||
|
||||
self.stopVideo()
|
||||
}
|
||||
|
||||
private func stopVideo() {
|
||||
self.videoPlayer?.stop()
|
||||
self.playVideoButton.isHidden = false
|
||||
self.delegate?.mediaDetailViewController(self, isPlayingVideo: false)
|
||||
}
|
||||
|
||||
// MARK: - OWSVideoPlayerDelegate
|
||||
|
||||
func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) {
|
||||
self.stopVideo()
|
||||
}
|
||||
|
||||
// MARK: - PlayerProgressBarDelegate
|
||||
|
||||
func playerProgressBarDidStartScrubbing(_ playerProgressBar: PlayerProgressBar) {
|
||||
self.videoPlayer?.pause()
|
||||
}
|
||||
|
||||
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, scrubbedToTime time: CMTime) {
|
||||
self.videoPlayer?.seek(to: time)
|
||||
}
|
||||
|
||||
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, didFinishScrubbingAtTime time: CMTime, shouldResumePlayback: Bool) {
|
||||
self.videoPlayer?.seek(to: time)
|
||||
|
||||
if shouldResumePlayback {
|
||||
self.videoPlayer?.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MediaDetailViewControllerDelegate
|
||||
|
||||
protocol MediaDetailViewControllerDelegate: AnyObject {
|
||||
func mediaDetailViewController(_ mediaDetailViewController: MediaDetailViewController, isPlayingVideo: Bool)
|
||||
func mediaDetailViewControllerDidTapMedia(_ mediaDetailViewController: MediaDetailViewController)
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUIKit
|
||||
|
||||
class MediaGalleryNavigationController: OWSNavigationController {
|
||||
// HACK: Though we don't have an input accessory view, the VC we are presented above (ConversationVC) does.
|
||||
// If the app is backgrounded and then foregrounded, when OWSWindowManager calls mainWindow.makeKeyAndVisible
|
||||
// the ConversationVC's inputAccessoryView will appear *above* us unless we'd previously become first responder.
|
||||
override public var canBecomeFirstResponder: Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var backgroundView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = Colors.navigationBarBackground
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - View Lifecycle
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return (isLightMode ? .default : .lightContent)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
guard let navigationBar = self.navigationBar as? OWSNavigationBar else {
|
||||
owsFailDebug("navigationBar had unexpected class: \(self.navigationBar)")
|
||||
return
|
||||
}
|
||||
|
||||
view.backgroundColor = Colors.navigationBarBackground
|
||||
|
||||
navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default)
|
||||
navigationBar.shadowImage = UIImage()
|
||||
navigationBar.isTranslucent = false
|
||||
navigationBar.barTintColor = Colors.navigationBarBackground
|
||||
|
||||
// Insert a view to ensure the nav bar colour goes to the top of the screen
|
||||
relayoutBackgroundView()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
// If the user's device is already rotated, try to respect that by rotating to landscape now
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
}
|
||||
|
||||
// MARK: - Orientation
|
||||
|
||||
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
return .allButUpsideDown
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
private func relayoutBackgroundView() {
|
||||
guard !backgroundView.isHidden else {
|
||||
backgroundView.removeFromSuperview()
|
||||
return
|
||||
}
|
||||
|
||||
view.insertSubview(backgroundView, belowSubview: navigationBar)
|
||||
|
||||
backgroundView.pin(.top, to: .top, of: view)
|
||||
backgroundView.pin(.left, to: .left, of: navigationBar)
|
||||
backgroundView.pin(.right, to: .right, of: navigationBar)
|
||||
backgroundView.pin(.bottom, to: .bottom, of: navigationBar)
|
||||
}
|
||||
|
||||
override func setNavigationBarHidden(_ hidden: Bool, animated: Bool) {
|
||||
super.setNavigationBarHidden(hidden, animated: animated)
|
||||
|
||||
backgroundView.isHidden = hidden
|
||||
relayoutBackgroundView()
|
||||
}
|
||||
}
|
|
@ -1,903 +0,0 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum GalleryDirection {
|
||||
case before, after, around
|
||||
}
|
||||
|
||||
class MediaGalleryAlbum {
|
||||
|
||||
private var originalItems: [MediaGalleryItem]
|
||||
var items: [MediaGalleryItem] {
|
||||
get {
|
||||
guard let mediaGalleryDataSource = self.mediaGalleryDataSource else {
|
||||
owsFailDebug("mediaGalleryDataSource was unexpectedly nil")
|
||||
return originalItems
|
||||
}
|
||||
|
||||
return originalItems.filter { !mediaGalleryDataSource.deletedGalleryItems.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
weak var mediaGalleryDataSource: MediaGalleryDataSource?
|
||||
|
||||
init(items: [MediaGalleryItem]) {
|
||||
self.originalItems = items
|
||||
}
|
||||
|
||||
func add(item: MediaGalleryItem) {
|
||||
guard !originalItems.contains(item) else {
|
||||
return
|
||||
}
|
||||
|
||||
originalItems.append(item)
|
||||
originalItems.sort { (lhs, rhs) -> Bool in
|
||||
return lhs.albumIndex < rhs.albumIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class MediaGalleryItem: Equatable, Hashable {
|
||||
let message: TSMessage
|
||||
let attachmentStream: TSAttachmentStream
|
||||
let galleryDate: GalleryDate
|
||||
let captionForDisplay: String?
|
||||
let albumIndex: Int
|
||||
var album: MediaGalleryAlbum?
|
||||
let orderingKey: MediaGalleryItemOrderingKey
|
||||
|
||||
init(message: TSMessage, attachmentStream: TSAttachmentStream) {
|
||||
self.message = message
|
||||
self.attachmentStream = attachmentStream
|
||||
self.captionForDisplay = attachmentStream.caption?.filterForDisplay
|
||||
self.galleryDate = GalleryDate(message: message)
|
||||
self.albumIndex = message.attachmentIds.index(of: attachmentStream.uniqueId!)
|
||||
self.orderingKey = MediaGalleryItemOrderingKey(messageSortKey: message.sortId, attachmentSortKey: albumIndex)
|
||||
}
|
||||
|
||||
var isVideo: Bool {
|
||||
return attachmentStream.isVideo
|
||||
}
|
||||
|
||||
var isAnimated: Bool {
|
||||
return attachmentStream.isAnimated
|
||||
}
|
||||
|
||||
var isImage: Bool {
|
||||
return attachmentStream.isImage
|
||||
}
|
||||
|
||||
var imageSize: CGSize {
|
||||
return attachmentStream.imageSize()
|
||||
}
|
||||
|
||||
public typealias AsyncThumbnailBlock = (UIImage) -> Void
|
||||
func thumbnailImage(async:@escaping AsyncThumbnailBlock) -> UIImage? {
|
||||
return attachmentStream.thumbnailImageSmall(success: async, failure: {})
|
||||
}
|
||||
|
||||
// MARK: Equatable
|
||||
|
||||
public static func == (lhs: MediaGalleryItem, rhs: MediaGalleryItem) -> Bool {
|
||||
return lhs.attachmentStream.uniqueId == rhs.attachmentStream.uniqueId
|
||||
}
|
||||
|
||||
// MARK: Hashable
|
||||
|
||||
public var hashValue: Int {
|
||||
return attachmentStream.uniqueId?.hashValue ?? attachmentStream.hashValue
|
||||
}
|
||||
|
||||
// MARK: Sorting
|
||||
|
||||
struct MediaGalleryItemOrderingKey: Comparable {
|
||||
let messageSortKey: UInt64
|
||||
let attachmentSortKey: Int
|
||||
|
||||
// MARK: Comparable
|
||||
|
||||
static func < (lhs: MediaGalleryItem.MediaGalleryItemOrderingKey, rhs: MediaGalleryItem.MediaGalleryItemOrderingKey) -> Bool {
|
||||
if lhs.messageSortKey < rhs.messageSortKey {
|
||||
return true
|
||||
}
|
||||
|
||||
if lhs.messageSortKey == rhs.messageSortKey {
|
||||
if lhs.attachmentSortKey < rhs.attachmentSortKey {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct GalleryDate: Hashable, Comparable, Equatable {
|
||||
let year: Int
|
||||
let month: Int
|
||||
|
||||
init(message: TSMessage) {
|
||||
let date = message.dateForUI()
|
||||
|
||||
self.year = Calendar.current.component(.year, from: date)
|
||||
self.month = Calendar.current.component(.month, from: date)
|
||||
}
|
||||
|
||||
init(year: Int, month: Int) {
|
||||
assert(month >= 1 && month <= 12)
|
||||
|
||||
self.year = year
|
||||
self.month = month
|
||||
}
|
||||
|
||||
private var isThisMonth: Bool {
|
||||
let now = Date()
|
||||
let year = Calendar.current.component(.year, from: now)
|
||||
let month = Calendar.current.component(.month, from: now)
|
||||
let thisMonth = GalleryDate(year: year, month: month)
|
||||
|
||||
return self == thisMonth
|
||||
}
|
||||
|
||||
public var date: Date {
|
||||
var components = DateComponents()
|
||||
components.month = self.month
|
||||
components.year = self.year
|
||||
|
||||
return Calendar.current.date(from: components)!
|
||||
}
|
||||
|
||||
private var isThisYear: Bool {
|
||||
let now = Date()
|
||||
let thisYear = Calendar.current.component(.year, from: now)
|
||||
|
||||
return self.year == thisYear
|
||||
}
|
||||
|
||||
static let thisYearFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM"
|
||||
|
||||
return formatter
|
||||
}()
|
||||
|
||||
static let olderFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
|
||||
// FIXME localize for RTL, or is there a built in way to do this?
|
||||
formatter.dateFormat = "MMMM yyyy"
|
||||
|
||||
return formatter
|
||||
}()
|
||||
|
||||
var localizedString: String {
|
||||
if isThisMonth {
|
||||
return NSLocalizedString("MEDIA_GALLERY_THIS_MONTH_HEADER", comment: "Section header in media gallery collection view")
|
||||
} else if isThisYear {
|
||||
return type(of: self).thisYearFormatter.string(from: self.date)
|
||||
} else {
|
||||
return type(of: self).olderFormatter.string(from: self.date)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Hashable
|
||||
|
||||
public var hashValue: Int {
|
||||
return month.hashValue ^ year.hashValue
|
||||
}
|
||||
|
||||
// MARK: Comparable
|
||||
|
||||
public static func < (lhs: GalleryDate, rhs: GalleryDate) -> Bool {
|
||||
if lhs.year != rhs.year {
|
||||
return lhs.year < rhs.year
|
||||
} else if lhs.month != rhs.month {
|
||||
return lhs.month < rhs.month
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Equatable
|
||||
|
||||
public static func == (lhs: GalleryDate, rhs: GalleryDate) -> Bool {
|
||||
return lhs.month == rhs.month && lhs.year == rhs.year
|
||||
}
|
||||
}
|
||||
|
||||
protocol MediaGalleryDataSource: class {
|
||||
var hasFetchedOldest: Bool { get }
|
||||
var hasFetchedMostRecent: Bool { get }
|
||||
|
||||
var galleryItems: [MediaGalleryItem] { get }
|
||||
var galleryItemCount: Int { get }
|
||||
|
||||
var sections: [GalleryDate: [MediaGalleryItem]] { get }
|
||||
var sectionDates: [GalleryDate] { get }
|
||||
|
||||
var deletedAttachments: Set<TSAttachment> { get }
|
||||
var deletedGalleryItems: Set<MediaGalleryItem> { get }
|
||||
|
||||
func ensureGalleryItemsLoaded(_ direction: GalleryDirection, item: MediaGalleryItem, amount: UInt, completion: ((IndexSet, [IndexPath]) -> Void)?)
|
||||
|
||||
func galleryItem(before currentItem: MediaGalleryItem) -> MediaGalleryItem?
|
||||
func galleryItem(after currentItem: MediaGalleryItem) -> MediaGalleryItem?
|
||||
|
||||
func showAllMedia(focusedItem: MediaGalleryItem)
|
||||
func dismissMediaDetailViewController(_ mediaDetailViewController: MediaPageViewController, animated isAnimated: Bool, completion: (() -> Void)?)
|
||||
|
||||
func delete(items: [MediaGalleryItem], initiatedBy: AnyObject)
|
||||
}
|
||||
|
||||
protocol MediaGalleryDataSourceDelegate: class {
|
||||
func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject)
|
||||
func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, deletedSections: IndexSet, deletedItems: [IndexPath])
|
||||
}
|
||||
|
||||
class MediaGalleryNavigationController: OWSNavigationController {
|
||||
|
||||
var retainUntilDismissed: MediaGallery?
|
||||
|
||||
// HACK: Though we don't have an input accessory view, the VC we are presented above (ConversationVC) does.
|
||||
// If the app is backgrounded and then foregrounded, when OWSWindowManager calls mainWindow.makeKeyAndVisible
|
||||
// the ConversationVC's inputAccessoryView will appear *above* us unless we'd previously become first responder.
|
||||
override public var canBecomeFirstResponder: Bool {
|
||||
Logger.debug("")
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: View Lifecycle
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return isLightMode ? .default : .lightContent
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
guard let navigationBar = self.navigationBar as? OWSNavigationBar else {
|
||||
owsFailDebug("navigationBar had unexpected class: \(self.navigationBar)")
|
||||
return
|
||||
}
|
||||
|
||||
view.backgroundColor = Colors.navigationBarBackground
|
||||
|
||||
navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default)
|
||||
navigationBar.shadowImage = UIImage()
|
||||
navigationBar.isTranslucent = false
|
||||
navigationBar.barTintColor = Colors.navigationBarBackground
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
// If the user's device is already rotated, try to respect that by rotating to landscape now
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
}
|
||||
|
||||
// MARK: Orientation
|
||||
|
||||
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
return .allButUpsideDown
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
class MediaGallery: NSObject, MediaGalleryDataSource, MediaTileViewControllerDelegate {
|
||||
|
||||
@objc
|
||||
weak public var navigationController: MediaGalleryNavigationController!
|
||||
|
||||
var deletedAttachments: Set<TSAttachment> = Set()
|
||||
var deletedGalleryItems: Set<MediaGalleryItem> = Set()
|
||||
|
||||
private var pageViewController: MediaPageViewController?
|
||||
|
||||
private var uiDatabaseConnection: YapDatabaseConnection {
|
||||
return OWSPrimaryStorage.shared().uiDatabaseConnection
|
||||
}
|
||||
|
||||
private let editingDatabaseConnection: YapDatabaseConnection
|
||||
private let mediaGalleryFinder: OWSMediaGalleryFinder
|
||||
|
||||
private var initialDetailItem: MediaGalleryItem?
|
||||
private let thread: TSThread
|
||||
private let options: MediaGalleryOption
|
||||
|
||||
// we start with a small range size for quick loading.
|
||||
private let fetchRangeSize: UInt = 10
|
||||
|
||||
deinit {
|
||||
Logger.debug("")
|
||||
}
|
||||
|
||||
@objc
|
||||
init(thread: TSThread, options: MediaGalleryOption = []) {
|
||||
self.thread = thread
|
||||
|
||||
self.editingDatabaseConnection = OWSPrimaryStorage.shared().newDatabaseConnection()
|
||||
|
||||
self.options = options
|
||||
self.mediaGalleryFinder = OWSMediaGalleryFinder(thread: thread)
|
||||
super.init()
|
||||
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(uiDatabaseDidUpdate),
|
||||
name: .OWSUIDatabaseConnectionDidUpdate,
|
||||
object: OWSPrimaryStorage.shared().dbNotificationObject)
|
||||
}
|
||||
|
||||
// MARK: Present/Dismiss
|
||||
|
||||
private var currentItem: MediaGalleryItem {
|
||||
return self.pageViewController!.currentItem
|
||||
}
|
||||
|
||||
@objc
|
||||
public func presentDetailView(fromViewController: UIViewController, mediaAttachment: TSAttachment) {
|
||||
var galleryItem: MediaGalleryItem?
|
||||
uiDatabaseConnection.read { transaction in
|
||||
galleryItem = self.buildGalleryItem(attachment: mediaAttachment, transaction: transaction)
|
||||
}
|
||||
|
||||
guard let initialDetailItem = galleryItem else {
|
||||
return
|
||||
}
|
||||
|
||||
presentDetailView(fromViewController: fromViewController, initialDetailItem: initialDetailItem)
|
||||
}
|
||||
|
||||
public func presentDetailView(fromViewController: UIViewController, initialDetailItem: MediaGalleryItem) {
|
||||
// For a speedy load, we only fetch a few items on either side of
|
||||
// the initial message
|
||||
ensureGalleryItemsLoaded(.around, item: initialDetailItem, amount: 10)
|
||||
|
||||
// We lazily load media into the gallery, but with large albums, we want to be sure
|
||||
// we load all the media required to render the album's media rail.
|
||||
ensureAlbumEntirelyLoaded(galleryItem: initialDetailItem)
|
||||
|
||||
self.initialDetailItem = initialDetailItem
|
||||
|
||||
let pageViewController = MediaPageViewController(initialItem: initialDetailItem, mediaGalleryDataSource: self, uiDatabaseConnection: self.uiDatabaseConnection, options: self.options)
|
||||
self.addDataSourceDelegate(pageViewController)
|
||||
|
||||
self.pageViewController = pageViewController
|
||||
|
||||
let navController = MediaGalleryNavigationController()
|
||||
self.navigationController = navController
|
||||
navController.retainUntilDismissed = self
|
||||
|
||||
navigationController.setViewControllers([pageViewController], animated: false)
|
||||
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
navigationController.modalTransitionStyle = .crossDissolve
|
||||
|
||||
fromViewController.present(navigationController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// If we're using a navigationController other than self to present the views
|
||||
// e.g. the conversation settings view controller
|
||||
var fromNavController: OWSNavigationController?
|
||||
|
||||
@objc
|
||||
func pushTileView(fromNavController: OWSNavigationController) {
|
||||
var mostRecentItem: MediaGalleryItem?
|
||||
self.uiDatabaseConnection.read { transaction in
|
||||
if let attachment = self.mediaGalleryFinder.mostRecentMediaAttachment(transaction: transaction) {
|
||||
mostRecentItem = self.buildGalleryItem(attachment: attachment, transaction: transaction)
|
||||
}
|
||||
}
|
||||
|
||||
if let mostRecentItem = mostRecentItem {
|
||||
mediaTileViewController.focusedItem = mostRecentItem
|
||||
ensureGalleryItemsLoaded(.around, item: mostRecentItem, amount: 100)
|
||||
}
|
||||
self.fromNavController = fromNavController
|
||||
fromNavController.pushViewController(mediaTileViewController, animated: true)
|
||||
}
|
||||
|
||||
func showAllMedia(focusedItem: MediaGalleryItem) {
|
||||
// TODO fancy animation - zoom media item into it's tile in the all media grid
|
||||
ensureGalleryItemsLoaded(.around, item: focusedItem, amount: 100)
|
||||
|
||||
if let fromNavController = self.fromNavController {
|
||||
// If from conversation settings view, we've already pushed
|
||||
fromNavController.popViewController(animated: true)
|
||||
} else {
|
||||
// If from conversation view
|
||||
mediaTileViewController.focusedItem = focusedItem
|
||||
navigationController.pushViewController(mediaTileViewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: MediaTileViewControllerDelegate
|
||||
|
||||
func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryItem) {
|
||||
if self.fromNavController != nil {
|
||||
// If we got to the gallery via conversation settings, present the detail view
|
||||
// on top of the tile view
|
||||
//
|
||||
// == ViewController Schematic ==
|
||||
//
|
||||
// [DetailView] <--,
|
||||
// [TileView] -----'
|
||||
// [ConversationSettingsView]
|
||||
// [ConversationView]
|
||||
//
|
||||
|
||||
self.presentDetailView(fromViewController: mediaTileViewController, initialDetailItem: mediaGalleryItem)
|
||||
} else {
|
||||
// If we got to the gallery via the conversation view, pop the tile view
|
||||
// to return to the detail view
|
||||
//
|
||||
// == ViewController Schematic ==
|
||||
//
|
||||
// [TileView] -----,
|
||||
// [DetailView] <--'
|
||||
// [ConversationView]
|
||||
//
|
||||
|
||||
guard let pageViewController = self.pageViewController else {
|
||||
owsFailDebug("pageViewController was unexpectedly nil")
|
||||
self.navigationController.dismiss(animated: true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pageViewController.setCurrentItem(mediaGalleryItem, direction: .forward, animated: false)
|
||||
pageViewController.willBePresentedAgain()
|
||||
|
||||
// TODO fancy zoom animation
|
||||
self.navigationController.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
public func dismissMediaDetailViewController(_ mediaPageViewController: MediaPageViewController, animated isAnimated: Bool, completion completionParam: (() -> Void)?) {
|
||||
|
||||
guard let presentingViewController = self.navigationController.presentingViewController else {
|
||||
owsFailDebug("presentingController was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
let completion = {
|
||||
completionParam?()
|
||||
UIApplication.shared.isStatusBarHidden = false
|
||||
presentingViewController.setNeedsStatusBarAppearanceUpdate()
|
||||
}
|
||||
|
||||
navigationController.view.isUserInteractionEnabled = false
|
||||
|
||||
presentingViewController.dismiss(animated: true, completion: completion)
|
||||
}
|
||||
|
||||
// MARK: - Database Notifications
|
||||
|
||||
@objc
|
||||
func uiDatabaseDidUpdate(notification: Notification) {
|
||||
guard let notifications = notification.userInfo?[OWSUIDatabaseConnectionNotificationsKey] as? [Notification] else {
|
||||
owsFailDebug("notifications was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
guard mediaGalleryFinder.hasMediaChanges(in: notifications, dbConnection: uiDatabaseConnection) else {
|
||||
Logger.verbose("no changes for thread: \(thread)")
|
||||
return
|
||||
}
|
||||
|
||||
let rowChanges = extractRowChanges(notifications: notifications)
|
||||
assert(rowChanges.count > 0)
|
||||
|
||||
process(rowChanges: rowChanges)
|
||||
}
|
||||
|
||||
func extractRowChanges(notifications: [Notification]) -> [YapDatabaseViewRowChange] {
|
||||
return notifications.flatMap { notification -> [YapDatabaseViewRowChange] in
|
||||
guard let userInfo = notification.userInfo else {
|
||||
owsFailDebug("userInfo was unexpectedly nil")
|
||||
return []
|
||||
}
|
||||
|
||||
guard let extensionChanges = userInfo["extensions"] as? [AnyHashable: Any] else {
|
||||
owsFailDebug("extensionChanges was unexpectedly nil")
|
||||
return []
|
||||
}
|
||||
|
||||
guard let galleryData = extensionChanges[OWSMediaGalleryFinder.databaseExtensionName()] as? [AnyHashable: Any] else {
|
||||
owsFailDebug("galleryData was unexpectedly nil")
|
||||
return []
|
||||
}
|
||||
|
||||
guard let galleryChanges = galleryData["changes"] as? [Any] else {
|
||||
owsFailDebug("gallerlyChanges was unexpectedly nil")
|
||||
return []
|
||||
}
|
||||
|
||||
return galleryChanges.compactMap { $0 as? YapDatabaseViewRowChange }
|
||||
}
|
||||
}
|
||||
|
||||
func process(rowChanges: [YapDatabaseViewRowChange]) {
|
||||
let deleteChanges = rowChanges.filter { $0.type == .delete }
|
||||
|
||||
let deletedItems: [MediaGalleryItem] = deleteChanges.compactMap { (deleteChange: YapDatabaseViewRowChange) -> MediaGalleryItem? in
|
||||
guard let deletedItem = self.galleryItems.first(where: { galleryItem in
|
||||
galleryItem.attachmentStream.uniqueId == deleteChange.collectionKey.key
|
||||
}) else {
|
||||
Logger.debug("deletedItem was never loaded - no need to remove.")
|
||||
return nil
|
||||
}
|
||||
|
||||
return deletedItem
|
||||
}
|
||||
|
||||
self.delete(items: deletedItems, initiatedBy: self)
|
||||
}
|
||||
|
||||
// MARK: - MediaGalleryDataSource
|
||||
|
||||
lazy var mediaTileViewController: MediaTileViewController = {
|
||||
let vc = MediaTileViewController(mediaGalleryDataSource: self, uiDatabaseConnection: self.uiDatabaseConnection)
|
||||
vc.delegate = self
|
||||
|
||||
self.addDataSourceDelegate(vc)
|
||||
|
||||
return vc
|
||||
}()
|
||||
|
||||
var galleryItems: [MediaGalleryItem] = []
|
||||
var sections: [GalleryDate: [MediaGalleryItem]] = [:]
|
||||
var sectionDates: [GalleryDate] = []
|
||||
var hasFetchedOldest = false
|
||||
var hasFetchedMostRecent = false
|
||||
|
||||
func buildGalleryItem(attachment: TSAttachment, transaction: YapDatabaseReadTransaction) -> MediaGalleryItem? {
|
||||
guard let attachmentStream = attachment as? TSAttachmentStream else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let message = attachmentStream.fetchAlbumMessage(with: transaction) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let galleryItem = MediaGalleryItem(message: message, attachmentStream: attachmentStream)
|
||||
galleryItem.album = getAlbum(item: galleryItem)
|
||||
|
||||
return galleryItem
|
||||
}
|
||||
|
||||
func ensureAlbumEntirelyLoaded(galleryItem: MediaGalleryItem) {
|
||||
ensureGalleryItemsLoaded(.before, item: galleryItem, amount: UInt(galleryItem.albumIndex))
|
||||
|
||||
let followingCount = galleryItem.message.attachmentIds.count - 1 - galleryItem.albumIndex
|
||||
guard followingCount >= 0 else {
|
||||
return
|
||||
}
|
||||
ensureGalleryItemsLoaded(.after, item: galleryItem, amount: UInt(followingCount))
|
||||
}
|
||||
|
||||
var galleryAlbums: [String: MediaGalleryAlbum] = [:]
|
||||
func getAlbum(item: MediaGalleryItem) -> MediaGalleryAlbum? {
|
||||
guard let albumMessageId = item.attachmentStream.albumMessageId else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let existingAlbum = galleryAlbums[albumMessageId] else {
|
||||
let newAlbum = MediaGalleryAlbum(items: [item])
|
||||
galleryAlbums[albumMessageId] = newAlbum
|
||||
newAlbum.mediaGalleryDataSource = self
|
||||
return newAlbum
|
||||
}
|
||||
|
||||
existingAlbum.add(item: item)
|
||||
return existingAlbum
|
||||
}
|
||||
|
||||
// Range instead of indexSet since it's contiguous?
|
||||
var fetchedIndexSet = IndexSet() {
|
||||
didSet {
|
||||
Logger.debug("\(oldValue) -> \(fetchedIndexSet)")
|
||||
}
|
||||
}
|
||||
|
||||
enum MediaGalleryError: Error {
|
||||
case itemNoLongerExists
|
||||
}
|
||||
|
||||
func ensureGalleryItemsLoaded(_ direction: GalleryDirection, item: MediaGalleryItem, amount: UInt, completion: ((IndexSet, [IndexPath]) -> Void)? = nil ) {
|
||||
|
||||
var galleryItems: [MediaGalleryItem] = self.galleryItems
|
||||
var sections: [GalleryDate: [MediaGalleryItem]] = self.sections
|
||||
var sectionDates: [GalleryDate] = self.sectionDates
|
||||
|
||||
var newGalleryItems: [MediaGalleryItem] = []
|
||||
var newDates: [GalleryDate] = []
|
||||
|
||||
do {
|
||||
try Bench(title: "fetching gallery items") {
|
||||
try self.uiDatabaseConnection.read { transaction in
|
||||
guard let index = self.mediaGalleryFinder.mediaIndex(attachment: item.attachmentStream, transaction: transaction) else {
|
||||
throw MediaGalleryError.itemNoLongerExists
|
||||
}
|
||||
let initialIndex: Int = index.intValue
|
||||
let mediaCount: Int = Int(self.mediaGalleryFinder.mediaCount(transaction: transaction))
|
||||
|
||||
let requestRange: Range<Int> = { () -> Range<Int> in
|
||||
let range: Range<Int> = { () -> Range<Int> in
|
||||
switch direction {
|
||||
case .around:
|
||||
// To keep it simple, this isn't exactly *amount* sized if `message` window overlaps the end or
|
||||
// beginning of the view. Still, we have sufficient buffer to fetch more as the user swipes.
|
||||
let start: Int = initialIndex - Int(amount) / 2
|
||||
let end: Int = initialIndex + Int(amount) / 2 + 1
|
||||
|
||||
return start..<end
|
||||
case .before:
|
||||
let start: Int = initialIndex - Int(amount)
|
||||
let end: Int = initialIndex
|
||||
|
||||
return start..<end
|
||||
case .after:
|
||||
let start: Int = initialIndex
|
||||
let end: Int = initialIndex + Int(amount) + 1
|
||||
|
||||
return start..<end
|
||||
}
|
||||
}()
|
||||
|
||||
return range.clamped(to: 0..<mediaCount)
|
||||
}()
|
||||
|
||||
let requestSet = IndexSet(integersIn: requestRange)
|
||||
guard !self.fetchedIndexSet.contains(integersIn: requestSet) else {
|
||||
Logger.debug("all requested messages have already been loaded.")
|
||||
return
|
||||
}
|
||||
|
||||
let unfetchedSet = requestSet.subtracting(self.fetchedIndexSet)
|
||||
|
||||
// For perf we only want to fetch a substantially full batch...
|
||||
let isSubstantialRequest = unfetchedSet.count > (requestSet.count / 2)
|
||||
// ...but we always fulfill even small requests if we're getting just the tail end of a gallery.
|
||||
let isFetchingEdgeOfGallery = (self.fetchedIndexSet.count - unfetchedSet.count) < requestSet.count
|
||||
|
||||
guard isSubstantialRequest || isFetchingEdgeOfGallery else {
|
||||
Logger.debug("ignoring small fetch request: \(unfetchedSet.count)")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.debug("fetching set: \(unfetchedSet)")
|
||||
let nsRange: NSRange = NSRange(location: unfetchedSet.min()!, length: unfetchedSet.count)
|
||||
self.mediaGalleryFinder.enumerateMediaAttachments(range: nsRange, transaction: transaction) { (attachment: TSAttachment) in
|
||||
|
||||
guard !self.deletedAttachments.contains(attachment) else {
|
||||
Logger.debug("skipping \(attachment) which has been deleted.")
|
||||
return
|
||||
}
|
||||
|
||||
guard let item: MediaGalleryItem = self.buildGalleryItem(attachment: attachment, transaction: transaction) else {
|
||||
owsFailDebug("unexpectedly failed to buildGalleryItem")
|
||||
return
|
||||
}
|
||||
|
||||
let date = item.galleryDate
|
||||
|
||||
galleryItems.append(item)
|
||||
if sections[date] != nil {
|
||||
sections[date]!.append(item)
|
||||
|
||||
// so we can update collectionView
|
||||
newGalleryItems.append(item)
|
||||
} else {
|
||||
sectionDates.append(date)
|
||||
sections[date] = [item]
|
||||
|
||||
// so we can update collectionView
|
||||
newDates.append(date)
|
||||
newGalleryItems.append(item)
|
||||
}
|
||||
}
|
||||
|
||||
self.fetchedIndexSet = self.fetchedIndexSet.union(unfetchedSet)
|
||||
self.hasFetchedOldest = self.fetchedIndexSet.min() == 0
|
||||
self.hasFetchedMostRecent = self.fetchedIndexSet.max() == mediaCount - 1
|
||||
}
|
||||
}
|
||||
} catch MediaGalleryError.itemNoLongerExists {
|
||||
Logger.debug("Ignoring reload, since item no longer exists.")
|
||||
return
|
||||
} catch {
|
||||
owsFailDebug("unexpected error: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO only sort if changed
|
||||
var sortedSections: [GalleryDate: [MediaGalleryItem]] = [:]
|
||||
|
||||
Bench(title: "sorting gallery items") {
|
||||
galleryItems.sort { lhs, rhs -> Bool in
|
||||
return lhs.orderingKey < rhs.orderingKey
|
||||
}
|
||||
sectionDates.sort()
|
||||
|
||||
for (date, galleryItems) in sections {
|
||||
sortedSections[date] = galleryItems.sorted { lhs, rhs -> Bool in
|
||||
return lhs.orderingKey < rhs.orderingKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.galleryItems = galleryItems
|
||||
self.sections = sortedSections
|
||||
self.sectionDates = sectionDates
|
||||
|
||||
if let completionBlock = completion {
|
||||
Bench(title: "calculating changes for collectionView") {
|
||||
// FIXME can we avoid this index offset?
|
||||
let dateIndices = newDates.map { sectionDates.firstIndex(of: $0)! + 1 }
|
||||
let addedSections: IndexSet = IndexSet(dateIndices)
|
||||
|
||||
let addedItems: [IndexPath] = newGalleryItems.map { galleryItem in
|
||||
let sectionIdx = sectionDates.firstIndex(of: galleryItem.galleryDate)!
|
||||
let section = sections[galleryItem.galleryDate]!
|
||||
let itemIdx = section.firstIndex(of: galleryItem)!
|
||||
|
||||
// FIXME can we avoid this index offset?
|
||||
return IndexPath(item: itemIdx, section: sectionIdx + 1)
|
||||
}
|
||||
|
||||
completionBlock(addedSections, addedItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var dataSourceDelegates: [Weak<MediaGalleryDataSourceDelegate>] = []
|
||||
func addDataSourceDelegate(_ dataSourceDelegate: MediaGalleryDataSourceDelegate) {
|
||||
dataSourceDelegates.append(Weak(value: dataSourceDelegate))
|
||||
}
|
||||
|
||||
func delete(items: [MediaGalleryItem], initiatedBy: AnyObject) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
Logger.info("with items: \(items.map { ($0.attachmentStream, $0.message.timestamp) })")
|
||||
|
||||
deletedGalleryItems.formUnion(items)
|
||||
dataSourceDelegates.forEach { $0.value?.mediaGalleryDataSource(self, willDelete: items, initiatedBy: initiatedBy) }
|
||||
|
||||
for item in items {
|
||||
self.deletedAttachments.insert(item.attachmentStream)
|
||||
}
|
||||
|
||||
self.editingDatabaseConnection.asyncReadWrite { transaction in
|
||||
for item in items {
|
||||
let message = item.message
|
||||
let attachment = item.attachmentStream
|
||||
message.removeAttachment(attachment, transaction: transaction)
|
||||
if message.attachmentIds.count == 0 {
|
||||
Logger.debug("removing message after removing last media attachment")
|
||||
message.remove(with: transaction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var deletedSections: IndexSet = IndexSet()
|
||||
var deletedIndexPaths: [IndexPath] = []
|
||||
let originalSections = self.sections
|
||||
let originalSectionDates = self.sectionDates
|
||||
|
||||
for item in items {
|
||||
guard let itemIndex = galleryItems.firstIndex(of: item) else {
|
||||
owsFailDebug("removing unknown item.")
|
||||
return
|
||||
}
|
||||
|
||||
self.galleryItems.remove(at: itemIndex)
|
||||
|
||||
guard let sectionIndex = sectionDates.firstIndex(where: { $0 == item.galleryDate }) else {
|
||||
owsFailDebug("item with unknown date.")
|
||||
return
|
||||
}
|
||||
|
||||
guard var sectionItems = self.sections[item.galleryDate] else {
|
||||
owsFailDebug("item with unknown section")
|
||||
return
|
||||
}
|
||||
|
||||
guard let sectionRowIndex = sectionItems.firstIndex(of: item) else {
|
||||
owsFailDebug("item with unknown sectionRowIndex")
|
||||
return
|
||||
}
|
||||
|
||||
// We need to calculate the index of the deleted item with respect to it's original position.
|
||||
guard let originalSectionIndex = originalSectionDates.firstIndex(where: { $0 == item.galleryDate }) else {
|
||||
owsFailDebug("item with unknown date.")
|
||||
return
|
||||
}
|
||||
|
||||
guard let originalSectionItems = originalSections[item.galleryDate] else {
|
||||
owsFailDebug("item with unknown section")
|
||||
return
|
||||
}
|
||||
|
||||
guard let originalSectionRowIndex = originalSectionItems.firstIndex(of: item) else {
|
||||
owsFailDebug("item with unknown sectionRowIndex")
|
||||
return
|
||||
}
|
||||
|
||||
if sectionItems == [item] {
|
||||
// Last item in section. Delete section.
|
||||
self.sections[item.galleryDate] = nil
|
||||
self.sectionDates.remove(at: sectionIndex)
|
||||
|
||||
deletedSections.insert(originalSectionIndex + 1)
|
||||
deletedIndexPaths.append(IndexPath(row: originalSectionRowIndex, section: originalSectionIndex + 1))
|
||||
} else {
|
||||
sectionItems.remove(at: sectionRowIndex)
|
||||
self.sections[item.galleryDate] = sectionItems
|
||||
|
||||
deletedIndexPaths.append(IndexPath(row: originalSectionRowIndex, section: originalSectionIndex + 1))
|
||||
}
|
||||
}
|
||||
|
||||
dataSourceDelegates.forEach { $0.value?.mediaGalleryDataSource(self, deletedSections: deletedSections, deletedItems: deletedIndexPaths) }
|
||||
}
|
||||
|
||||
let kGallerySwipeLoadBatchSize: UInt = 5
|
||||
|
||||
internal func galleryItem(after currentItem: MediaGalleryItem) -> MediaGalleryItem? {
|
||||
Logger.debug("")
|
||||
|
||||
self.ensureGalleryItemsLoaded(.after, item: currentItem, amount: kGallerySwipeLoadBatchSize)
|
||||
|
||||
guard let currentIndex = galleryItems.firstIndex(of: currentItem) else {
|
||||
owsFailDebug("currentIndex was unexpectedly nil")
|
||||
return nil
|
||||
}
|
||||
|
||||
let index: Int = galleryItems.index(after: currentIndex)
|
||||
guard let nextItem = galleryItems[safe: index] else {
|
||||
// already at last item
|
||||
return nil
|
||||
}
|
||||
|
||||
guard !deletedGalleryItems.contains(nextItem) else {
|
||||
Logger.debug("nextItem was deleted - Recursing.")
|
||||
return galleryItem(after: nextItem)
|
||||
}
|
||||
|
||||
return nextItem
|
||||
}
|
||||
|
||||
internal func galleryItem(before currentItem: MediaGalleryItem) -> MediaGalleryItem? {
|
||||
Logger.debug("")
|
||||
|
||||
self.ensureGalleryItemsLoaded(.before, item: currentItem, amount: kGallerySwipeLoadBatchSize)
|
||||
|
||||
guard let currentIndex = galleryItems.firstIndex(of: currentItem) else {
|
||||
owsFailDebug("currentIndex was unexpectedly nil")
|
||||
return nil
|
||||
}
|
||||
|
||||
let index: Int = galleryItems.index(before: currentIndex)
|
||||
guard let previousItem = galleryItems[safe: index] else {
|
||||
// already at first item
|
||||
return nil
|
||||
}
|
||||
|
||||
guard !deletedGalleryItems.contains(previousItem) else {
|
||||
Logger.debug("previousItem was deleted - Recursing.")
|
||||
return galleryItem(before: previousItem)
|
||||
}
|
||||
|
||||
return previousItem
|
||||
}
|
||||
|
||||
var galleryItemCount: Int {
|
||||
var count: UInt = 0
|
||||
self.uiDatabaseConnection.read { (transaction: YapDatabaseReadTransaction) in
|
||||
count = self.mediaGalleryFinder.mediaCount(transaction: transaction)
|
||||
}
|
||||
return Int(count) - deletedAttachments.count
|
||||
}
|
||||
}
|
|
@ -0,0 +1,574 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public class MediaGalleryViewModel {
|
||||
public typealias SectionModel = ArraySection<Section, Item>
|
||||
|
||||
// MARK: - Section
|
||||
|
||||
public enum Section: Differentiable, Equatable, Comparable, Hashable {
|
||||
case emptyGallery
|
||||
case loadOlder
|
||||
case galleryMonth(date: GalleryDate)
|
||||
case loadNewer
|
||||
}
|
||||
|
||||
// MARK: - Variables
|
||||
|
||||
public let threadId: String
|
||||
public let threadVariant: SessionThread.Variant
|
||||
private var focusedAttachmentId: String?
|
||||
public private(set) var focusedIndexPath: IndexPath?
|
||||
|
||||
/// This value is the current state of an album view
|
||||
private var cachedInteractionIdBefore: Atomic<[Int64: Int64]> = Atomic([:])
|
||||
private var cachedInteractionIdAfter: Atomic<[Int64: Int64]> = Atomic([:])
|
||||
|
||||
public var interactionIdBefore: [Int64: Int64] { cachedInteractionIdBefore.wrappedValue }
|
||||
public var interactionIdAfter: [Int64: Int64] { cachedInteractionIdAfter.wrappedValue }
|
||||
public private(set) var albumData: [Int64: [Item]] = [:]
|
||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<Attachment, Item>?
|
||||
|
||||
/// This value is the current state of a gallery view
|
||||
private var unobservedGalleryDataChanges: [SectionModel]?
|
||||
public private(set) var galleryData: [SectionModel] = []
|
||||
public var onGalleryChange: (([SectionModel]) -> ())? {
|
||||
didSet {
|
||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
||||
// data was changed while we weren't observing
|
||||
if let unobservedGalleryDataChanges: [SectionModel] = self.unobservedGalleryDataChanges {
|
||||
onGalleryChange?(unobservedGalleryDataChanges)
|
||||
self.unobservedGalleryDataChanges = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
threadId: String,
|
||||
threadVariant: SessionThread.Variant,
|
||||
isPagedData: Bool,
|
||||
pageSize: Int = 1,
|
||||
focusedAttachmentId: String? = nil,
|
||||
performInitialQuerySync: Bool = false
|
||||
) {
|
||||
self.threadId = threadId
|
||||
self.threadVariant = threadVariant
|
||||
self.focusedAttachmentId = focusedAttachmentId
|
||||
self.pagedDataObserver = nil
|
||||
|
||||
guard isPagedData else { return }
|
||||
|
||||
// Note: Since this references self we need to finish initializing before setting it, we
|
||||
// also want to skip the initial query and trigger it async so that the push animation
|
||||
// doesn't stutter (it should load basically immediately but without this there is a
|
||||
// distinct stutter)
|
||||
self.pagedDataObserver = PagedDatabaseObserver(
|
||||
pagedTable: Attachment.self,
|
||||
pageSize: pageSize,
|
||||
idColumn: .id,
|
||||
observedChanges: [
|
||||
PagedData.ObservedChanges(
|
||||
table: Attachment.self,
|
||||
columns: [.isValid]
|
||||
)
|
||||
],
|
||||
joinSQL: Item.joinSQL,
|
||||
filterSQL: Item.filterSQL(threadId: threadId),
|
||||
orderSQL: Item.galleryOrderSQL,
|
||||
dataQuery: Item.baseQuery(orderSQL: Item.galleryOrderSQL),
|
||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||
guard let updatedGalleryData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
|
||||
return
|
||||
}
|
||||
|
||||
// If we have the 'onGalleryChange' callback then trigger it, otherwise just store the changes
|
||||
// to be sent to the callback if we ever start observing again (when we have the callback it needs
|
||||
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
|
||||
// correct order)
|
||||
guard let onGalleryChange: (([SectionModel]) -> ()) = self?.onGalleryChange else {
|
||||
self?.unobservedGalleryDataChanges = updatedGalleryData
|
||||
return
|
||||
}
|
||||
|
||||
onGalleryChange(updatedGalleryData)
|
||||
}
|
||||
)
|
||||
|
||||
// Run the initial query on a backgorund thread so we don't block the push transition
|
||||
let loadInitialData: () -> () = { [weak self] in
|
||||
// If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query
|
||||
// from a `0` offset)
|
||||
guard let initialFocusedId: String = focusedAttachmentId else {
|
||||
self?.pagedDataObserver?.load(.pageBefore)
|
||||
return
|
||||
}
|
||||
|
||||
self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId))
|
||||
}
|
||||
|
||||
// We have a custom transition when going from an attachment detail screen to the tile gallery
|
||||
// so in that case we want to perform the initial query synchronously so that we have the content
|
||||
// to do the transition (we don't clear the 'unobservedGalleryDataChanges' after setting it as
|
||||
// we don't want to mess with the initial view controller behaviour)
|
||||
guard !performInitialQuerySync else {
|
||||
loadInitialData()
|
||||
updateGalleryData(self.unobservedGalleryDataChanges ?? [])
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.global(qos: .default).async {
|
||||
loadInitialData()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data
|
||||
|
||||
public struct GalleryDate: Differentiable, Equatable, Comparable, Hashable {
|
||||
private static let thisYearFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM"
|
||||
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let olderFormatter: DateFormatter = {
|
||||
// FIXME: localize for RTL, or is there a built in way to do this?
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM yyyy"
|
||||
|
||||
return formatter
|
||||
}()
|
||||
|
||||
let year: Int
|
||||
let month: Int
|
||||
|
||||
private var date: Date? {
|
||||
var components = DateComponents()
|
||||
components.month = self.month
|
||||
components.year = self.year
|
||||
|
||||
return Calendar.current.date(from: components)
|
||||
}
|
||||
|
||||
var localizedString: String {
|
||||
let isSameMonth: Bool = (self.month == Calendar.current.component(.month, from: Date()))
|
||||
let isCurrentYear: Bool = (self.year == Calendar.current.component(.year, from: Date()))
|
||||
let galleryDate: Date = (self.date ?? Date())
|
||||
|
||||
switch (isSameMonth, isCurrentYear) {
|
||||
case (true, true): return "MEDIA_GALLERY_THIS_MONTH_HEADER".localized()
|
||||
case (false, true): return GalleryDate.thisYearFormatter.string(from: galleryDate)
|
||||
default: return GalleryDate.olderFormatter.string(from: galleryDate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - --Initialization
|
||||
|
||||
init(messageDate: Date) {
|
||||
self.year = Calendar.current.component(.year, from: messageDate)
|
||||
self.month = Calendar.current.component(.month, from: messageDate)
|
||||
}
|
||||
|
||||
// MARK: - --Comparable
|
||||
|
||||
public static func < (lhs: GalleryDate, rhs: GalleryDate) -> Bool {
|
||||
switch ((lhs.year != rhs.year), (lhs.month != rhs.month)) {
|
||||
case (true, _): return lhs.year < rhs.year
|
||||
case (_, true): return lhs.month < rhs.month
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct Item: FetchableRecordWithRowId, Decodable, Identifiable, Differentiable, Equatable, Hashable {
|
||||
fileprivate static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue)
|
||||
fileprivate static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue)
|
||||
fileprivate static let interactionAuthorIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionAuthorId.stringValue)
|
||||
fileprivate static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue)
|
||||
fileprivate static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue)
|
||||
fileprivate static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue)
|
||||
fileprivate static let attachmentAlbumIndexKey: SQL = SQL(stringLiteral: CodingKeys.attachmentAlbumIndex.stringValue)
|
||||
|
||||
fileprivate static let attachmentString: String = CodingKeys.attachment.stringValue
|
||||
|
||||
public var id: String { attachment.id }
|
||||
public var differenceIdentifier: String { attachment.id }
|
||||
|
||||
let interactionId: Int64
|
||||
let interactionVariant: Interaction.Variant
|
||||
let interactionAuthorId: String
|
||||
let interactionTimestampMs: Int64
|
||||
|
||||
public var rowId: Int64
|
||||
let attachmentAlbumIndex: Int
|
||||
let attachment: Attachment
|
||||
|
||||
var galleryDate: GalleryDate {
|
||||
GalleryDate(
|
||||
messageDate: Date(timeIntervalSince1970: (Double(interactionTimestampMs) / 1000))
|
||||
)
|
||||
}
|
||||
|
||||
var isVideo: Bool { attachment.isVideo }
|
||||
var isAnimated: Bool { attachment.isAnimated }
|
||||
var isImage: Bool { attachment.isImage }
|
||||
|
||||
var imageSize: CGSize {
|
||||
guard let width: UInt = attachment.width, let height: UInt = attachment.height else {
|
||||
return .zero
|
||||
}
|
||||
|
||||
return CGSize(width: Int(width), height: Int(height))
|
||||
}
|
||||
|
||||
var captionForDisplay: String? { attachment.caption?.filterForDisplay }
|
||||
|
||||
// MARK: - Query
|
||||
|
||||
fileprivate static let joinSQL: SQL = {
|
||||
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||
|
||||
return """
|
||||
JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id])
|
||||
JOIN \(Interaction.self) ON \(interaction[.id]) = \(interactionAttachment[.interactionId])
|
||||
"""
|
||||
}()
|
||||
|
||||
fileprivate static func filterSQL(threadId: String) -> SQL {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||
|
||||
return SQL("""
|
||||
\(attachment[.isVisualMedia]) = true AND
|
||||
\(attachment[.isValid]) = true AND
|
||||
\(interaction[.threadId]) = \(threadId)
|
||||
""")
|
||||
}
|
||||
|
||||
fileprivate static let galleryOrderSQL: SQL = {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||
|
||||
/// **Note:** This **MUST** match the desired sort behaviour for the screen otherwise paging will be
|
||||
/// very broken
|
||||
return SQL("\(interaction[.timestampMs].desc), \(interactionAttachment[.albumIndex])")
|
||||
}()
|
||||
|
||||
fileprivate static let galleryReverseOrderSQL: SQL = {
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||
|
||||
/// **Note:** This **MUST** match the desired sort behaviour for the screen otherwise paging will be
|
||||
/// very broken
|
||||
return SQL("\(interaction[.timestampMs]), \(interactionAttachment[.albumIndex].desc)")
|
||||
}()
|
||||
|
||||
fileprivate static func baseQuery(orderSQL: SQL, customFilters: SQL? = nil) -> (([Int64]) -> AdaptedFetchRequest<SQLRequest<Item>>) {
|
||||
return { rowIds -> AdaptedFetchRequest<SQLRequest<Item>> in
|
||||
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||
|
||||
let numColumnsBeforeLinkedRecords: Int = 6
|
||||
let finalFilterSQL: SQL = {
|
||||
guard let customFilters: SQL = customFilters else {
|
||||
return """
|
||||
WHERE \(attachment.alias[Column.rowID]) IN \(rowIds)
|
||||
"""
|
||||
}
|
||||
|
||||
return """
|
||||
WHERE (
|
||||
\(customFilters)
|
||||
)
|
||||
"""
|
||||
}()
|
||||
let request: SQLRequest<Item> = """
|
||||
SELECT
|
||||
\(interaction[.id]) AS \(Item.interactionIdKey),
|
||||
\(interaction[.variant]) AS \(Item.interactionVariantKey),
|
||||
\(interaction[.authorId]) AS \(Item.interactionAuthorIdKey),
|
||||
\(interaction[.timestampMs]) AS \(Item.interactionTimestampMsKey),
|
||||
|
||||
\(attachment.alias[Column.rowID]) AS \(Item.rowIdKey),
|
||||
\(interactionAttachment[.albumIndex]) AS \(Item.attachmentAlbumIndexKey),
|
||||
\(Item.attachmentKey).*
|
||||
FROM \(Attachment.self)
|
||||
\(joinSQL)
|
||||
\(finalFilterSQL)
|
||||
ORDER BY \(orderSQL)
|
||||
"""
|
||||
|
||||
return request.adapted { db in
|
||||
let adapters = try splittingRowAdapters(columnCounts: [
|
||||
numColumnsBeforeLinkedRecords,
|
||||
Attachment.numberOfSelectedColumns(db)
|
||||
])
|
||||
|
||||
return ScopeAdapter([
|
||||
Item.attachmentString: adapters[1]
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate static func baseQuery(orderSQL: SQL, customFilters: SQL) -> AdaptedFetchRequest<SQLRequest<Item>> {
|
||||
return Item.baseQuery(orderSQL: orderSQL, customFilters: customFilters)([])
|
||||
}
|
||||
|
||||
func thumbnailImage(async: @escaping (UIImage) -> ()) {
|
||||
attachment.thumbnail(size: .small, success: { image, _ in async(image) }, failure: {})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Album
|
||||
|
||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
||||
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||
///
|
||||
/// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static
|
||||
/// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
|
||||
///
|
||||
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
|
||||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
||||
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||
public typealias AlbumObservation = ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<[Item]>>>
|
||||
public lazy var observableAlbumData: AlbumObservation = buildAlbumObservation(for: nil)
|
||||
|
||||
private func buildAlbumObservation(for interactionId: Int64?) -> AlbumObservation {
|
||||
return ValueObservation
|
||||
.trackingConstantRegion { db -> [Item] in
|
||||
guard let interactionId: Int64 = interactionId else { return [] }
|
||||
|
||||
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||
|
||||
return try Item
|
||||
.baseQuery(
|
||||
orderSQL: SQL(interactionAttachment[.albumIndex]),
|
||||
customFilters: SQL("""
|
||||
\(attachment[.isValid]) = true AND
|
||||
\(interaction[.id]) = \(interactionId)
|
||||
""")
|
||||
)
|
||||
.fetchAll(db)
|
||||
}
|
||||
.removeDuplicates()
|
||||
}
|
||||
|
||||
@discardableResult public func loadAndCacheAlbumData(for interactionId: Int64) -> [Item] {
|
||||
typealias AlbumInfo = (albumData: [Item], interactionIdBefore: Int64?, interactionIdAfter: Int64?)
|
||||
|
||||
// Note: It's possible we already have cached album data for this interaction
|
||||
// but to avoid displaying stale data we re-fetch from the database anyway
|
||||
let maybeAlbumInfo: AlbumInfo? = Storage.shared.read { db -> AlbumInfo in
|
||||
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
|
||||
|
||||
let newAlbumData: [Item] = try Item
|
||||
.baseQuery(
|
||||
orderSQL: SQL(interactionAttachment[.albumIndex]),
|
||||
customFilters: SQL("""
|
||||
\(attachment[.isValid]) = true AND
|
||||
\(interaction[.id]) = \(interactionId)
|
||||
""")
|
||||
)
|
||||
.fetchAll(db)
|
||||
|
||||
guard let albumTimestampMs: Int64 = newAlbumData.first?.interactionTimestampMs else {
|
||||
return (newAlbumData, nil, nil)
|
||||
}
|
||||
|
||||
let itemBefore: Item? = try Item
|
||||
.baseQuery(
|
||||
orderSQL: Item.galleryReverseOrderSQL,
|
||||
customFilters: SQL("\(interaction[.timestampMs]) > \(albumTimestampMs)")
|
||||
)
|
||||
.fetchOne(db)
|
||||
let itemAfter: Item? = try Item
|
||||
.baseQuery(
|
||||
orderSQL: Item.galleryOrderSQL,
|
||||
customFilters: SQL("\(interaction[.timestampMs]) < \(albumTimestampMs)")
|
||||
)
|
||||
.fetchOne(db)
|
||||
|
||||
return (newAlbumData, itemBefore?.interactionId, itemAfter?.interactionId)
|
||||
}
|
||||
|
||||
guard let newAlbumInfo: AlbumInfo = maybeAlbumInfo else { return [] }
|
||||
|
||||
// Cache the album info for the new interactionId
|
||||
self.updateAlbumData(newAlbumInfo.albumData, for: interactionId)
|
||||
self.cachedInteractionIdBefore.mutate { $0[interactionId] = newAlbumInfo.interactionIdBefore }
|
||||
self.cachedInteractionIdAfter.mutate { $0[interactionId] = newAlbumInfo.interactionIdAfter }
|
||||
|
||||
return newAlbumInfo.albumData
|
||||
}
|
||||
|
||||
public func replaceAlbumObservation(toObservationFor interactionId: Int64) {
|
||||
self.observableAlbumData = self.buildAlbumObservation(for: interactionId)
|
||||
}
|
||||
|
||||
public func updateAlbumData(_ updatedData: [Item], for interactionId: Int64) {
|
||||
self.albumData[interactionId] = updatedData
|
||||
}
|
||||
|
||||
// MARK: - Gallery
|
||||
|
||||
private func process(data: [Item], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
||||
let galleryData: [SectionModel] = data
|
||||
.grouped(by: \.galleryDate)
|
||||
.mapValues { sectionItems -> [Item] in
|
||||
sectionItems
|
||||
.sorted { lhs, rhs -> Bool in
|
||||
if lhs.interactionTimestampMs == rhs.interactionTimestampMs {
|
||||
// Start of album first
|
||||
return (lhs.attachmentAlbumIndex < rhs.attachmentAlbumIndex)
|
||||
}
|
||||
|
||||
// Newer interactions first
|
||||
return (lhs.interactionTimestampMs > rhs.interactionTimestampMs)
|
||||
}
|
||||
}
|
||||
.map { galleryDate, items in
|
||||
SectionModel(model: .galleryMonth(date: galleryDate), elements: items)
|
||||
}
|
||||
|
||||
// Remove and re-add the custom sections as needed
|
||||
return [
|
||||
(data.isEmpty ? [SectionModel(section: .emptyGallery)] : []),
|
||||
(!data.isEmpty && pageInfo.pageOffset > 0 ? [SectionModel(section: .loadNewer)] : []),
|
||||
galleryData,
|
||||
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
|
||||
[SectionModel(section: .loadOlder)] :
|
||||
[]
|
||||
)
|
||||
]
|
||||
.flatMap { $0 }
|
||||
.sorted { lhs, rhs -> Bool in (lhs.model > rhs.model) }
|
||||
}
|
||||
|
||||
public func updateGalleryData(_ updatedData: [SectionModel]) {
|
||||
self.galleryData = updatedData
|
||||
|
||||
// If we have a focused attachment id then we need to make sure the 'focusedIndexPath'
|
||||
// is updated to be accurate
|
||||
if let focusedAttachmentId: String = focusedAttachmentId {
|
||||
self.focusedIndexPath = nil
|
||||
|
||||
for (section, sectionData) in updatedData.enumerated() {
|
||||
for (index, item) in sectionData.elements.enumerated() {
|
||||
if item.attachment.id == focusedAttachmentId {
|
||||
self.focusedIndexPath = IndexPath(item: index, section: section)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if self.focusedIndexPath != nil { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func updateFocusedItem(attachmentId: String, indexPath: IndexPath) {
|
||||
// Note: We need to set both of these as the 'focusedIndexPath' is usually
|
||||
// derived and if the data changes it will be regenerated using the
|
||||
// 'focusedAttachmentId' value
|
||||
self.focusedAttachmentId = attachmentId
|
||||
self.focusedIndexPath = indexPath
|
||||
}
|
||||
|
||||
// MARK: - Creation Functions
|
||||
|
||||
public static func createDetailViewController(
|
||||
for threadId: String,
|
||||
threadVariant: SessionThread.Variant,
|
||||
interactionId: Int64,
|
||||
selectedAttachmentId: String,
|
||||
options: [MediaGalleryOption]
|
||||
) -> UIViewController? {
|
||||
// Load the data for the album immediately (needed before pushing to the screen so
|
||||
// transitions work nicely)
|
||||
let viewModel: MediaGalleryViewModel = MediaGalleryViewModel(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
isPagedData: false
|
||||
)
|
||||
viewModel.loadAndCacheAlbumData(for: interactionId)
|
||||
viewModel.replaceAlbumObservation(toObservationFor: interactionId)
|
||||
|
||||
guard
|
||||
!viewModel.albumData.isEmpty,
|
||||
let initialItem: Item = viewModel.albumData[interactionId]?.first(where: { item -> Bool in
|
||||
item.attachment.id == selectedAttachmentId
|
||||
})
|
||||
else { return nil }
|
||||
|
||||
let pageViewController: MediaPageViewController = MediaPageViewController(
|
||||
viewModel: viewModel,
|
||||
initialItem: initialItem,
|
||||
options: options
|
||||
)
|
||||
let navController: MediaGalleryNavigationController = MediaGalleryNavigationController()
|
||||
navController.viewControllers = [pageViewController]
|
||||
navController.modalPresentationStyle = .fullScreen
|
||||
navController.transitioningDelegate = pageViewController
|
||||
|
||||
return navController
|
||||
}
|
||||
|
||||
public static func createTileViewController(
|
||||
threadId: String,
|
||||
threadVariant: SessionThread.Variant,
|
||||
focusedAttachmentId: String?,
|
||||
performInitialQuerySync: Bool = false
|
||||
) -> MediaTileViewController {
|
||||
let viewModel: MediaGalleryViewModel = MediaGalleryViewModel(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
isPagedData: true,
|
||||
pageSize: MediaTileViewController.itemPageSize,
|
||||
focusedAttachmentId: focusedAttachmentId,
|
||||
performInitialQuerySync: performInitialQuerySync
|
||||
)
|
||||
|
||||
return MediaTileViewController(
|
||||
viewModel: viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Objective-C Support
|
||||
|
||||
// FIXME: Remove when we can
|
||||
|
||||
@objc(SNMediaGallery)
|
||||
public class SNMediaGallery: NSObject {
|
||||
@objc(pushTileViewWithSliderEnabledForThreadId:isClosedGroup:isOpenGroup:fromNavController:)
|
||||
static func pushTileView(threadId: String, isClosedGroup: Bool, isOpenGroup: Bool, fromNavController: OWSNavigationController) {
|
||||
fromNavController.pushViewController(
|
||||
MediaGalleryViewModel.createTileViewController(
|
||||
threadId: threadId,
|
||||
threadVariant: {
|
||||
if isClosedGroup { return .closedGroup }
|
||||
if isOpenGroup { return .openGroup }
|
||||
|
||||
return .contact
|
||||
}(),
|
||||
focusedAttachmentId: nil
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue