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

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

49
Podfile
View File

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

View File

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

View File

@ -0,0 +1,251 @@
#!/usr/bin/xcrun --sdk macosx swift
//
// ListLocalizableStrings.swift
// Archa
//
// Created by Morgan Pretty on 18/5/20.
// Copyright © 2020 Archa. All rights reserved.
//
// This script is based on https://github.com/ginowu7/CleanSwiftLocalizableExample the main difference
// is canges to the localized usage regex
import Foundation
let fileManager = FileManager.default
let currentPath = (
ProcessInfo.processInfo.environment["PROJECT_DIR"] ?? fileManager.currentDirectoryPath
)
/// List of files in currentPath - recursive
var pathFiles: [String] = {
guard let enumerator = fileManager.enumerator(atPath: currentPath), let files = enumerator.allObjects as? [String] else {
fatalError("Could not locate files in path directory: \(currentPath)")
}
return files
}()
/// List of localizable files - not including Localizable files in the Pods
var localizableFiles: [String] = {
return pathFiles
.filter {
$0.hasSuffix("Localizable.strings") &&
!$0.contains(".app/") && // Exclude Built Localizable.strings files
!$0.contains("Pods") // Exclude Pods
}
}()
/// List of executable files
var executableFiles: [String] = {
return pathFiles.filter {
!$0.localizedCaseInsensitiveContains("test") && // Exclude test files
!$0.contains(".app/") && // Exclude Built Localizable.strings files
!$0.contains("Pods") && // Exclude Pods
(
NSString(string: $0).pathExtension == "swift" ||
NSString(string: $0).pathExtension == "m"
)
}
}()
/// Reads contents in path
///
/// - Parameter path: path of file
/// - Returns: content in file
func contents(atPath path: String) -> String {
print("Path: \(path)")
guard let data = fileManager.contents(atPath: path), let content = String(data: data, encoding: .utf8) else {
fatalError("Could not read from path: \(path)")
}
return content
}
/// Returns a list of strings that match regex pattern from content
///
/// - Parameters:
/// - pattern: regex pattern
/// - content: content to match
/// - Returns: list of results
func regexFor(_ pattern: String, content: String) -> [String] {
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
fatalError("Regex not formatted correctly: \(pattern)")
}
let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: content.utf16.count))
return matches.map {
guard let range = Range($0.range(at: 0), in: content) else {
fatalError("Incorrect range match")
}
return String(content[range])
}
}
func create() -> [LocalizationStringsFile] {
return localizableFiles.map(LocalizationStringsFile.init(path:))
}
///
///
/// - Returns: A list of LocalizationCodeFile - contains path of file and all keys in it
func localizedStringsInCode() -> [LocalizationCodeFile] {
return executableFiles.compactMap {
let content = contents(atPath: $0)
// Note: Need to exclude escaped quotation marks from strings
let matchesOld = regexFor("(?<=NSLocalizedString\\()\\s*\"(?!.*?%d)(.*?)\"", content: content)
let matchesNew = regexFor("\"(?!.*?%d)([^(\\\")]*?)\"(?=\\s*)(?=\\.localized)", content: content)
let allMatches = (matchesOld + matchesNew)
return allMatches.isEmpty ? nil : LocalizationCodeFile(path: $0, keys: Set(allMatches))
}
}
/// Throws error if ALL localizable files does not have matching keys
///
/// - Parameter files: list of localizable files to validate
func validateMatchKeys(_ files: [LocalizationStringsFile]) {
print("------------ Validating keys match in all localizable files ------------")
guard let base = files.first, files.count > 1 else { return }
let files = Array(files.dropFirst())
files.forEach {
guard let extraKey = Set(base.keys).symmetricDifference($0.keys).first else { return }
let incorrectFile = $0.keys.contains(extraKey) ? $0 : base
printPretty("error: Found extra key: \(extraKey) in file: \(incorrectFile.path)")
}
}
/// Throws error if localizable files are missing keys
///
/// - Parameters:
/// - codeFiles: Array of LocalizationCodeFile
/// - localizationFiles: Array of LocalizableStringFiles
func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
print("------------ Checking for missing keys -----------")
guard let baseFile = localizationFiles.first else {
fatalError("Could not locate base localization file")
}
let baseKeys = Set(baseFile.keys)
codeFiles.forEach {
let extraKeys = $0.keys.subtracting(baseKeys)
if !extraKeys.isEmpty {
printPretty("error: Found keys in code missing in strings file: \(extraKeys) from \($0.path)")
}
}
}
/// Throws warning if keys exist in localizable file but are not being used
///
/// - Parameters:
/// - codeFiles: Array of LocalizationCodeFile
/// - localizationFiles: Array of LocalizableStringFiles
func validateDeadKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
print("------------ Checking for any dead keys in localizable file -----------")
guard let baseFile = localizationFiles.first else {
fatalError("Could not locate base localization file")
}
let baseKeys: Set<String> = Set(baseFile.keys)
let allCodeFileKeys: [String] = codeFiles.flatMap { $0.keys }
let deadKeys: [String] = Array(baseKeys.subtracting(allCodeFileKeys))
.sorted()
.map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) }
if !deadKeys.isEmpty {
printPretty("warning: \(deadKeys) - Suggest cleaning dead keys")
}
}
protocol Pathable {
var path: String { get }
}
struct LocalizationStringsFile: Pathable {
let path: String
let kv: [String: String]
var keys: [String] {
return Array(kv.keys)
}
init(path: String) {
self.path = path
self.kv = ContentParser.parse(path)
}
/// Writes back to localizable file with sorted keys and removed whitespaces and new lines
func cleanWrite() {
print("------------ Sort and remove whitespaces: \(path) ------------")
let content = kv.keys.sorted().map { "\($0) = \(kv[$0]!);" }.joined(separator: "\n")
try! content.write(toFile: path, atomically: true, encoding: .utf8)
}
}
struct LocalizationCodeFile: Pathable {
let path: String
let keys: Set<String>
}
struct ContentParser {
/// Parses contents of a file to localizable keys and values - Throws error if localizable file have duplicated keys
///
/// - Parameter path: Localizable file paths
/// - Returns: localizable key and value for content at path
static func parse(_ path: String) -> [String: String] {
print("------------ Checking for duplicate keys: \(path) ------------")
let content = contents(atPath: path)
let trimmed = content
.replacingOccurrences(of: "\n+", with: "", options: .regularExpression, range: nil)
.trimmingCharacters(in: .whitespacesAndNewlines)
let keys = regexFor("\"([^\"]*?)\"(?= =)", content: trimmed)
let values = regexFor("(?<== )\"(.*?)\"(?=;)", content: trimmed)
if keys.count != values.count {
fatalError("Error parsing contents: Make sure all keys and values are in correct format (this could be due to extra spaces between keys and values)")
}
return zip(keys, values).reduce(into: [String: String]()) { results, keyValue in
if results[keyValue.0] != nil {
printPretty("error: Found duplicate key: \(keyValue.0) in file: \(path)")
abort()
}
results[keyValue.0] = keyValue.1
}
}
}
func printPretty(_ string: String) {
print(string.replacingOccurrences(of: "\\", with: ""))
}
let stringFiles = create()
if !stringFiles.isEmpty {
print("------------ Found \(stringFiles.count) file(s) ------------")
stringFiles.forEach { print($0.path) }
validateMatchKeys(stringFiles)
// Note: Uncomment the below file to clean out all comments from the localizable file (we don't want this because comments make it readable...)
// stringFiles.forEach { $0.cleanWrite() }
let codeFiles = localizedStringsInCode()
validateMissingKeys(codeFiles, localizationFiles: stringFiles)
validateDeadKeys(codeFiles, localizationFiles: stringFiles)
}
print("------------ SUCCESS ------------")

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<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>
<Testables>
<TestableReference
skipped = "NO">
<CodeCoverageTargets>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D221A0A9169C9E5F00537ABF"
BuildableName = "SignalTests.xctest"
BlueprintName = "SignalTests"
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"
parallelizable = "YES"
testExecutionOrdering = "random">
<BuildableReference
BuildableIdentifier = "primary"
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>

View File

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1320"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
BuildableName = "SessionMessagingKit.framework"
BlueprintName = "SessionMessagingKit"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
codeCoverageEnabled = "YES"
onlyGenerateCoverageForSpecifiedTargets = "YES">
<CodeCoverageTargets>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
BuildableName = "SessionMessagingKit.framework"
BlueprintName = "SessionMessagingKit"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</CodeCoverageTargets>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES"
testExecutionOrdering = "random">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FDC4388D27B9FFC700C60D73"
BuildableName = "SessionMessagingKitTests.xctest"
BlueprintName = "SessionMessagingKitTests"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "App Store Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
BuildableName = "SessionMessagingKit.framework"
BlueprintName = "SessionMessagingKit"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "App Store Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<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">

View File

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

View File

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1320"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
BuildableName = "SessionUtilitiesKit.framework"
BlueprintName = "SessionUtilitiesKit"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
codeCoverageEnabled = "YES"
onlyGenerateCoverageForSpecifiedTargets = "YES">
<CodeCoverageTargets>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
BuildableName = "SessionUtilitiesKit.framework"
BlueprintName = "SessionUtilitiesKit"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</CodeCoverageTargets>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES"
testExecutionOrdering = "random">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FD83B9AE27CF200A005E1583"
BuildableName = "SessionUtilitiesKitTests.xctest"
BlueprintName = "SessionUtilitiesKitTests"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "App Store Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
BuildableName = "SessionUtilitiesKit.framework"
BlueprintName = "SessionUtilitiesKit"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "App Store Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,13 @@
import UIKit
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
@objc
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)

View File

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

View File

@ -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() } }
private struct GroupMemberDisplayInfo: FetchableRecord, Decodable {
let profileId: String
let role: GroupMember.Role
let profile: Profile?
}
private let threadId: String
private var originalName: String = ""
private var originalMembersAndZombieIds: Set<String> = []
private var name: String = ""
private var hasContactsToAdd: Bool = false
private var userPublicKey: String = ""
private var membersAndZombies: [GroupMemberDisplayInfo] = []
private var adminIds: Set<String> = []
private var isEditingGroupName = false { didSet { handleIsEditingGroupNameChanged() } }
private var tableViewHeightConstraint: NSLayoutConstraint!
private lazy var groupPublicKey: String = {
let groupID = thread.groupModel.groupId
return LKGroupUtilities.getDecodedGroupID(groupID)
}()
// MARK: - Components
// MARK: Components
private lazy var groupNameLabel: UILabel = {
let result = UILabel()
let result: UILabel = UILabel()
result.textColor = Colors.text
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
result.lineBreakMode = .byTruncatingTail
result.textAlignment = .center
return result
}()
private lazy var groupNameTextField: TextField = {
let result = TextField(placeholder: "Enter a group name", usesDefaultHeight: false)
let result: TextField = TextField(placeholder: "Enter a group name", usesDefaultHeight: false)
result.textAlignment = .center
return result
}()
private lazy var addMembersButton: Button = {
let result = Button(style: .prominentOutline, size: .large)
let result: Button = Button(style: .prominentOutline, size: .large)
result.setTitle("Add Members", for: UIControl.State.normal)
result.addTarget(self, action: #selector(addMembers), for: UIControl.Event.touchUpInside)
result.contentEdgeInsets = UIEdgeInsets(top: 0, leading: Values.mediumSpacing, bottom: 0, trailing: Values.mediumSpacing)
return result
}()
@objc private lazy var tableView: UITableView = {
let result = UITableView()
let result: UITableView = UITableView()
result.dataSource = self
result.delegate = self
result.register(UserCell.self, forCellReuseIdentifier: "UserCell")
result.separatorStyle = .none
result.backgroundColor = .clear
result.isScrollEnabled = false
result.register(view: UserCell.self)
return result
}()
// MARK: Lifecycle
@objc(initWithThreadID:)
init(with threadID: String) {
var thread: TSGroupThread!
Storage.read { transaction in
thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction)!
}
self.thread = thread
// MARK: - Lifecycle
@objc(initWithThreadId:)
init(with threadId: String) {
self.threadId = threadId
super.init(nibName: nil, bundle: nil)
}
@ -67,27 +81,62 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
override func viewDidLoad() {
super.viewDidLoad()
setUpGradientBackground()
setUpNavBarStyle()
setNavBarTitle("Edit Group")
let backButton = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil)
backButton.tintColor = Colors.text
navigationItem.backBarButtonItem = backButton
func getDisplayName(for publicKey: String) -> String {
return Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey
let threadId: String = self.threadId
Storage.shared.read { [weak self] db in
self?.userPublicKey = getUserHexEncodedPublicKey(db)
self?.name = try ClosedGroup
.select(.name)
.filter(id: threadId)
.asRequest(of: String.self)
.fetchOne(db)
.defaulting(to: "Group")
self?.originalName = (self?.name ?? "")
let profileAlias: TypedTableAlias<Profile> = TypedTableAlias()
let allGroupMembers: [GroupMemberDisplayInfo] = try GroupMember
.filter(GroupMember.Columns.groupId == threadId)
.including(optional: GroupMember.profile.aliased(profileAlias))
.order(
(GroupMember.Columns.role == GroupMember.Role.zombie), // Non-zombies at the top
profileAlias[.nickname],
profileAlias[.name],
GroupMember.Columns.profileId
)
.asRequest(of: GroupMemberDisplayInfo.self)
.fetchAll(db)
self?.membersAndZombies = allGroupMembers
.filter { $0.role == .standard || $0.role == .zombie }
self?.adminIds = allGroupMembers
.filter { $0.role == .admin }
.map { $0.profileId }
.asSet()
let uniqueGroupMemberIds: Set<String> = allGroupMembers
.map { $0.profileId }
.asSet()
self?.originalMembersAndZombieIds = uniqueGroupMemberIds
self?.hasContactsToAdd = ((try Profile.fetchCount(db) - uniqueGroupMemberIds.count) > 0)
}
setUpViewHierarchy()
// Always show zombies at the bottom
zombies = Storage.shared.getZombieMembers(for: groupPublicKey)
membersAndZombies = GroupUtilities.getClosedGroupMembers(thread).sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
+ zombies.sorted { getDisplayName(for: $0) < getDisplayName(for: $1) }
updateNavigationBarButtons()
name = thread.groupModel.groupName!
handleMembersChanged()
}
private func setUpViewHierarchy() {
// Group name container
groupNameLabel.text = thread.groupModel.groupName
groupNameLabel.text = name
let groupNameContainer = UIView()
groupNameContainer.addSubview(groupNameLabel)
groupNameLabel.pin(to: groupNameContainer)
@ -95,6 +144,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
groupNameTextField.pin(to: groupNameContainer)
groupNameContainer.set(.height, to: 40)
groupNameTextField.alpha = 0
// Top container
let topContainer = UIView()
topContainer.addSubview(groupNameContainer)
@ -102,19 +152,21 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
topContainer.set(.height, to: 40)
let topContainerTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(showEditGroupNameUI))
topContainer.addGestureRecognizer(topContainerTapGestureRecognizer)
// Members label
let membersLabel = UILabel()
membersLabel.textColor = Colors.text
membersLabel.font = .systemFont(ofSize: Values.mediumFontSize)
membersLabel.text = "Members"
// Add members button
let hasContactsToAdd = !Set(ContactUtilities.getAllContacts()).subtracting(self.membersAndZombies).isEmpty
if (!hasContactsToAdd) {
if !self.hasContactsToAdd {
addMembersButton.isUserInteractionEnabled = false
let disabledColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
addMembersButton.layer.borderColor = disabledColor.cgColor
addMembersButton.setTitleColor(disabledColor, for: UIControl.State.normal)
}
// Middle stack view
let middleStackView = UIStackView(arrangedSubviews: [ membersLabel, addMembersButton ])
middleStackView.axis = .horizontal
@ -122,8 +174,10 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
middleStackView.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, leading: Values.mediumSpacing, bottom: Values.smallSpacing, trailing: Values.mediumSpacing)
middleStackView.isLayoutMarginsRelativeArrangement = true
middleStackView.set(.height, to: Values.largeButtonHeight + Values.smallSpacing * 2)
// Table view
tableViewHeightConstraint = tableView.set(.height, to: 0)
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [
UIView.vSpacer(Values.veryLargeSpacing),
@ -137,6 +191,7 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
mainStackView.axis = .vertical
mainStackView.alignment = .fill
mainStackView.set(.width, to: UIScreen.main.bounds.width)
// Scroll view
let scrollView = UIScrollView()
scrollView.showsVerticalScrollIndicator = false
@ -152,41 +207,49 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell
let publicKey = membersAndZombies[indexPath.row]
cell.publicKey = publicKey
cell.isZombie = zombies.contains(publicKey)
let userPublicKey = getUserHexEncodedPublicKey()
let isCurrentUserAdmin = thread.groupModel.groupAdminIds.contains(userPublicKey)
cell.accessory = !isCurrentUserAdmin ? .lock : .none
cell.update()
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
cell.update(
with: membersAndZombies[indexPath.row].profileId,
profile: membersAndZombies[indexPath.row].profile,
isZombie: (membersAndZombies[indexPath.row].role == .zombie),
accessory: (adminIds.contains(userPublicKey) ?
.none :
.lock
)
)
return cell
}
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
let userPublicKey = getUserHexEncodedPublicKey()
return thread.groupModel.groupAdminIds.contains(userPublicKey)
return adminIds.contains(userPublicKey)
}
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
let publicKey = membersAndZombies[indexPath.row]
let profileId: String = self.membersAndZombies[indexPath.row].profileId
let removeAction = UITableViewRowAction(style: .destructive, title: "Remove") { [weak self] _, _ in
guard let self = self, let index = self.membersAndZombies.firstIndex(of: publicKey) else { return }
self.membersAndZombies.remove(at: index)
self?.adminIds.remove(profileId)
self?.membersAndZombies.remove(at: indexPath.row)
self?.handleMembersChanged()
}
removeAction.backgroundColor = Colors.destructive
return [ removeAction ]
}
// MARK: Updating
// MARK: - Updating
private func updateNavigationBarButtons() {
if isEditingGroupName {
let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(handleCancelGroupNameEditingButtonTapped))
cancelButton.tintColor = Colors.text
navigationItem.leftBarButtonItem = cancelButton
} else {
}
else {
navigationItem.leftBarButtonItem = nil
}
let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(handleDoneButtonTapped))
doneButton.tintColor = Colors.text
navigationItem.rightBarButtonItem = doneButton
@ -199,18 +262,22 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
private func handleIsEditingGroupNameChanged() {
updateNavigationBarButtons()
UIView.animate(withDuration: 0.25) {
self.groupNameLabel.alpha = self.isEditingGroupName ? 0 : 1
self.groupNameTextField.alpha = self.isEditingGroupName ? 1 : 0
}
if isEditingGroupName {
groupNameTextField.becomeFirstResponder()
} else {
}
else {
groupNameTextField.resignFirstResponder()
}
}
// MARK: Interaction
// MARK: - Interaction
@objc private func showEditGroupNameUI() {
isEditingGroupName = true
}
@ -222,93 +289,163 @@ final class EditClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelega
@objc private func handleDoneButtonTapped() {
if isEditingGroupName {
updateGroupName()
} else {
}
else {
commitChanges()
}
}
private func updateGroupName() {
let name = groupNameTextField.text!.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
guard !name.isEmpty else {
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_missing_error", comment: ""))
let updatedName: String = groupNameTextField.text
.defaulting(to: "")
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
guard !updatedName.isEmpty else {
return showError(title: "vc_create_closed_group_group_name_missing_error".lowercased())
}
guard name.count < 64 else {
return showError(title: NSLocalizedString("vc_create_closed_group_group_name_too_long_error", comment: ""))
guard updatedName.count < 64 else {
return showError(title: "vc_create_closed_group_group_name_too_long_error".localized())
}
isEditingGroupName = false
self.name = name
groupNameLabel.text = name
groupNameLabel.text = updatedName
self.name = updatedName
}
@objc private func addMembers() {
let title = "Add Members"
let userSelectionVC = UserSelectionVC(with: title, excluding: Set(membersAndZombies)) { [weak self] selectedUsers in
guard let self = self else { return }
var members = self.membersAndZombies
members.append(contentsOf: selectedUsers)
func getDisplayName(for publicKey: String) -> String {
return Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey
let userSelectionVC: UserSelectionVC = UserSelectionVC(
with: title,
excluding: membersAndZombies
.map { $0.profileId }
.asSet()
) { [weak self] selectedUserIds in
Storage.shared.read { [weak self] db in
let selectedGroupMembers: [GroupMemberDisplayInfo] = try Profile
.filter(selectedUserIds.contains(Profile.Columns.id))
.fetchAll(db)
.map { profile in
GroupMemberDisplayInfo(
profileId: profile.id,
role: .standard,
profile: profile
)
}
self.membersAndZombies = 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)
self?.membersAndZombies = (self?.membersAndZombies ?? [])
.appending(contentsOf: selectedGroupMembers)
.sorted(by: { lhs, rhs in
if lhs.role == .zombie && rhs.role != .zombie {
return false
}
navigationController!.pushViewController(userSelectionVC, animated: true, completion: nil)
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)
}
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)
}
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)
}
}, 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
return try MessageSender.update(
db,
groupPublicKey: threadId,
with: updatedMemberIds,
name: updatedName
)
}
.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)
}
}

View File

@ -1,7 +1,12 @@
// 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)
}
@ -15,106 +20,126 @@ private final class TableView : UITableView {
}
final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate {
private let contacts = ContactUtilities.getAllContacts()
private let contactProfiles: [Profile] = Profile.fetchAllContactProfiles(excludeCurrentUser: true)
private var selectedContacts: Set<String> = []
// 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
}
// MARK: Table View Data Source
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
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)
Storage.shared
.writeAsync { db in
try MessageSender.createClosedGroup(db, name: name, members: selectedContacts)
}
let _ = promise.done(on: DispatchQueue.main) { thread in
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
.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)
SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false)
SessionApp.presentConversation(for: thread.id, action: .compose, animated: false)
}
promise.catch(on: DispatchQueue.main) { _ in
.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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,333 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
@objc
public class ConversationMessageMapping: NSObject {
private let viewName: String
private let group: String?
// The desired number of the items to load BEFORE the pivot (see below).
@objc
public var desiredLength: UInt
typealias ItemId = String
// The list of currently loaded items.
private var itemIds = [ItemId]()
// When we enter a conversation, we want to load up to N interactions. This
// is the "initial load window".
//
// We subsequently expand the load window in two directions using two very
// different behaviors.
//
// * We expand the load window "upwards" (backwards in time) only when
// loadMore() is called, in "pages".
// * We auto-expand the load window "downwards" (forward in time) to include
// any new interactions created after the initial load.
//
// We define the "pivot" as the last item in the initial load window. This
// value is only set once.
//
// For example, if you enter a conversation with messages, 1..15:
//
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
//
// We initially load just the last 5 (if 5 is the initial desired length):
//
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// | pivot ^ | <-- load window
// pivot: 15, desired length=5.
//
// If a few more messages (16..18) are sent or received, we'll always load
// them immediately (they're after the pivot):
//
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// | pivot ^ | <-- load window
// pivot: 15, desired length=5.
//
// To load an additional page of items (perhaps due to user scrolling
// upward), we extend the desired length and thereby load more items
// before the pivot.
//
// 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// | pivot ^ | <-- load window
// pivot: 15, desired length=10.
//
// To reiterate:
//
// * The pivot doesn't move.
// * The desired length applies _before_ the pivot.
// * Everything after the pivot is auto-loaded.
//
// One last optimization:
//
// After an update, we _can sometimes_ move the pivot (for perf
// reasons), but we also adjust the "desired length" so that this
// no effect on the load behavior.
//
// And note: we use the pivot's sort id, not its uniqueId, which works
// even if the pivot itself is deleted.
private var pivotSortId: UInt64?
@objc
public var canLoadMore = false
@objc
public required init(group: String?, desiredLength: UInt) {
self.viewName = TSMessageDatabaseViewExtensionName
self.group = group
self.desiredLength = desiredLength
}
@objc
public func loadedUniqueIds() -> [String] {
return itemIds
}
@objc
public func contains(uniqueId: String) -> Bool {
return loadedUniqueIds().contains(uniqueId)
}
// This method can be used to extend the desired length
// and update.
@objc
public func update(withDesiredLength desiredLength: UInt, transaction: YapDatabaseReadTransaction) {
assert(desiredLength >= self.desiredLength)
self.desiredLength = desiredLength
update(transaction: transaction)
}
// This is the core method of the class. It updates the state to
// reflect the latest database state & the current desired length.
@objc
public func update(transaction: YapDatabaseReadTransaction) {
AssertIsOnMainThread()
guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else {
owsFailDebug("Could not load view.")
return
}
guard let group = group else {
owsFailDebug("No group.")
return
}
// Deserializing interactions is expensive, so we only
// do that when necessary.
let sortIdForItemId: (String) -> UInt64? = { (itemId) in
guard let interaction = TSInteraction.fetch(uniqueId: itemId, transaction: transaction) else {
owsFailDebug("Could not load interaction.")
return nil
}
return interaction.sortId
}
// If we have a "pivot", load all items AFTER the pivot and up to minDesiredLength items BEFORE the pivot.
// If we do not have a "pivot", load up to minDesiredLength BEFORE the pivot.
var newItemIds = [ItemId]()
var canLoadMore = false
let desiredLength = self.desiredLength
// Not all items "count" towards the desired length. On an initial load, all items count. Subsequently,
// only items above the pivot count.
var afterPivotCount: UInt = 0
var beforePivotCount: UInt = 0
// (void (^)(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop))block;
view.enumerateKeys(inGroup: group, with: NSEnumerationOptions.reverse) { (_, key, _, stop) in
let itemId = key
// Load "uncounted" items after the pivot if possible.
//
// As an optimization, we can skip this check (which requires
// deserializing the interaction) if beforePivotCount is non-zero,
// e.g. after we "pass" the pivot.
if beforePivotCount == 0,
let pivotSortId = self.pivotSortId {
if let sortId = sortIdForItemId(itemId) {
let isAfterPivot = sortId > pivotSortId
if isAfterPivot {
newItemIds.append(itemId)
afterPivotCount += 1
return
}
} else {
owsFailDebug("Could not determine sort id for interaction: \(itemId)")
}
}
// Load "counted" items unless the load window overflows.
if beforePivotCount >= desiredLength {
// Overflow
canLoadMore = true
stop.pointee = true
} else {
newItemIds.append(itemId)
beforePivotCount += 1
}
}
// The items need to be reversed, since we load them in reverse order.
self.itemIds = Array(newItemIds.reversed())
self.canLoadMore = canLoadMore
// Establish the pivot, if necessary and possible.
//
// Deserializing interactions is expensive. We only need to deserialize
// interactions that are "after" the pivot. So there would be performance
// benefits to moving the pivot after each update to the last loaded item.
//
// However, this would undesirable side effects. The desired length for
// conversations with very short disappearing message durations would
// continuously grow as messages appeared and disappeared.
//
// Therefore, we only move the pivot when we've accumulated N items after
// the pivot. This puts an upper bound on the number of interactions we
// have to deserialize while minimizing "load window size creep".
let kMaxItemCountAfterPivot = 32
let shouldSetPivot = (self.pivotSortId == nil ||
afterPivotCount > kMaxItemCountAfterPivot)
if shouldSetPivot {
if let newLastItemId = newItemIds.first {
// newItemIds is in reverse order, so its "first" element is actually last.
if let sortId = sortIdForItemId(newLastItemId) {
// Update the pivot.
if self.pivotSortId != nil {
self.desiredLength += afterPivotCount
}
self.pivotSortId = sortId
} else {
owsFailDebug("Could not determine sort id for interaction: \(newLastItemId)")
}
}
}
}
// Tries to ensure that the load window includes a given item.
// On success, returns the index path of that item.
// On failure, returns nil.
@objc(ensureLoadWindowContainsUniqueId:transaction:)
public func ensureLoadWindowContains(uniqueId: String,
transaction: YapDatabaseReadTransaction) -> IndexPath? {
if let oldIndex = loadedUniqueIds().firstIndex(of: uniqueId) {
return IndexPath(row: oldIndex, section: 0)
}
guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else {
SNLog("Could not load view.")
return nil
}
guard let group = group else {
SNLog("No group.")
return nil
}
let indexPtr: UnsafeMutablePointer<UInt> = UnsafeMutablePointer<UInt>.allocate(capacity: 1)
let wasFound = view.getGroup(nil, index: indexPtr, forKey: uniqueId, inCollection: TSInteraction.collection())
guard wasFound else {
SNLog("Could not find interaction.")
return nil
}
let index = indexPtr.pointee
let threadInteractionCount = view.numberOfItems(inGroup: group)
guard index < threadInteractionCount else {
SNLog("Invalid index.")
return nil
}
// This math doesn't take into account the number of items loaded _after_ the pivot.
// That's fine; it's okay to load too many interactions here.
let desiredWindowSize: UInt = threadInteractionCount - index
self.update(withDesiredLength: desiredWindowSize, transaction: transaction)
guard let newIndex = loadedUniqueIds().firstIndex(of: uniqueId) else {
SNLog("Couldn't find interaction.")
return nil
}
return IndexPath(row: newIndex, section: 0)
}
@objc
public class ConversationMessageMappingDiff: NSObject {
@objc
public let addedItemIds: Set<String>
@objc
public let removedItemIds: Set<String>
@objc
public let updatedItemIds: Set<String>
init(addedItemIds: Set<String>, removedItemIds: Set<String>, updatedItemIds: Set<String>) {
self.addedItemIds = addedItemIds
self.removedItemIds = removedItemIds
self.updatedItemIds = updatedItemIds
}
}
// Updates and then calculates which items were inserted, removed or modified.
@objc
public func updateAndCalculateDiff(transaction: YapDatabaseReadTransaction,
notifications: [NSNotification]) -> ConversationMessageMappingDiff? {
let oldItemIds = Set(self.itemIds)
self.update(transaction: transaction)
let newItemIds = Set(self.itemIds)
let removedItemIds = oldItemIds.subtracting(newItemIds)
let addedItemIds = newItemIds.subtracting(oldItemIds)
// We only notify for updated items that a) were previously loaded b) weren't also inserted or removed.
let updatedItemIds = (self.updatedItemIds(for: notifications)
.subtracting(addedItemIds)
.subtracting(removedItemIds)
.intersection(oldItemIds))
return ConversationMessageMappingDiff(addedItemIds: addedItemIds,
removedItemIds: removedItemIds,
updatedItemIds: updatedItemIds)
}
// For performance reasons, the database modification notifications are used
// to determine which items were modified. If YapDatabase ever changes the
// structure or semantics of these notifications, we'll need to update this
// code to reflect that.
private func updatedItemIds(for notifications: [NSNotification]) -> Set<String> {
var updatedItemIds = Set<String>()
for notification in notifications {
// Unpack the YDB notification, looking for row changes.
guard let userInfo =
notification.userInfo else {
owsFailDebug("Missing userInfo.")
continue
}
guard let viewChangesets =
userInfo[YapDatabaseExtensionsKey] as? NSDictionary else {
// No changes for any views, skip.
continue
}
guard let changeset =
viewChangesets[viewName] as? NSDictionary else {
// No changes for this view, skip.
continue
}
// This constant matches a private constant in YDB.
let changeset_key_changes: String = "changes"
guard let changesetChanges = changeset[changeset_key_changes] as? [Any] else {
owsFailDebug("Missing changeset changes.")
continue
}
for change in changesetChanges {
if change as? YapDatabaseViewSectionChange != nil {
// Ignore.
} else if let rowChange = change as? YapDatabaseViewRowChange {
updatedItemIds.insert(rowChange.collectionKey.key)
} else {
owsFailDebug("Invalid change: \(type(of: change)).")
continue
}
}
}
return updatedItemIds
}
}

View File

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

View File

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

View File

@ -1,165 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import <SessionMessagingKit/OWSAudioPlayer.h>
NS_ASSUME_NONNULL_BEGIN
extern NSString *const SNAudioDidFinishPlayingNotification;
typedef NS_ENUM(NSInteger, OWSMessageCellType) {
OWSMessageCellType_Unknown,
OWSMessageCellType_TextOnlyMessage,
OWSMessageCellType_Audio,
OWSMessageCellType_GenericAttachment,
OWSMessageCellType_MediaMessage,
OWSMessageCellType_OversizeTextDownloading,
OWSMessageCellType_DeletedMessage
};
NSString *NSStringForOWSMessageCellType(OWSMessageCellType cellType);
#pragma mark -
@class ContactShareViewModel;
@class ConversationViewCell;
@class DisplayableText;
@class SNVoiceMessageView;
@class OWSLinkPreview;
@class OWSQuotedReplyModel;
@class OWSUnreadIndicator;
@class TSAttachment;
@class TSAttachmentPointer;
@class TSAttachmentStream;
@class TSInteraction;
@class TSThread;
@class YapDatabaseReadTransaction;
@interface ConversationMediaAlbumItem : NSObject
@property (nonatomic, readonly) TSAttachment *attachment;
// This property will only be set if the attachment is downloaded.
@property (nonatomic, readonly, nullable) TSAttachmentStream *attachmentStream;
// This property will be non-zero if the attachment is valid.
@property (nonatomic, readonly) CGSize mediaSize;
@property (nonatomic, readonly, nullable) NSString *caption;
@property (nonatomic, readonly) BOOL isFailedDownload;
@end
#pragma mark -
@protocol ConversationViewItem <NSObject, OWSAudioPlayerDelegate>
@property (nonatomic, readonly) TSInteraction *interaction;
@property (nonatomic, readonly, nullable) OWSQuotedReplyModel *quotedReply;
@property (nonatomic, readonly) BOOL isGroupThread;
@property (nonatomic, readonly) BOOL userCanDeleteGroupMessage;
@property (nonatomic, readonly) BOOL userHasModerationPermission;
@property (nonatomic, readonly) BOOL hasBodyText;
@property (nonatomic, readonly) BOOL isQuotedReply;
@property (nonatomic, readonly) BOOL hasQuotedAttachment;
@property (nonatomic, readonly) BOOL hasQuotedText;
@property (nonatomic, readonly) BOOL hasCellHeader;
@property (nonatomic, readonly) BOOL isExpiringMessage;
@property (nonatomic) BOOL shouldShowDate;
@property (nonatomic) BOOL shouldShowSenderProfilePicture;
@property (nonatomic, nullable) NSAttributedString *senderName;
@property (nonatomic) BOOL shouldHideFooter;
@property (nonatomic) BOOL isFirstInCluster;
@property (nonatomic) BOOL isOnlyMessageInCluster;
@property (nonatomic) BOOL isLastInCluster;
@property (nonatomic) BOOL wasPreviousItemInfoMessage;
@property (nonatomic, nullable) OWSUnreadIndicator *unreadIndicator;
- (void)replaceInteraction:(TSInteraction *)interaction transaction:(YapDatabaseReadTransaction *)transaction;
- (void)clearCachedLayoutState;
@property (nonatomic, readonly) BOOL hasCachedLayoutState;
#pragma mark - Audio Playback
@property (nonatomic, weak) SNVoiceMessageView *lastAudioMessageView;
@property (nonatomic, readonly) CGFloat audioDurationSeconds;
@property (nonatomic, readonly) CGFloat audioProgressSeconds;
#pragma mark - View State Caching
// These methods only apply to text & attachment messages.
@property (nonatomic, readonly) OWSMessageCellType messageCellType;
@property (nonatomic, readonly, nullable) DisplayableText *displayableBodyText;
@property (nonatomic, readonly, nullable) TSAttachmentStream *attachmentStream;
@property (nonatomic, readonly, nullable) TSAttachmentPointer *attachmentPointer;
@property (nonatomic, readonly, nullable) NSArray<ConversationMediaAlbumItem *> *mediaAlbumItems;
@property (nonatomic, readonly, nullable) DisplayableText *displayableQuotedText;
@property (nonatomic, readonly, nullable) NSString *quotedAttachmentMimetype;
@property (nonatomic, readonly, nullable) NSString *quotedRecipientId;
// We don't want to try to load the media for this item (if any)
// if a load has previously failed.
@property (nonatomic) BOOL didCellMediaFailToLoad;
@property (nonatomic, readonly, nullable) ContactShareViewModel *contactShare;
@property (nonatomic, readonly, nullable) OWSLinkPreview *linkPreview;
@property (nonatomic, readonly, nullable) TSAttachment *linkPreviewAttachment;
@property (nonatomic, readonly, nullable) NSString *systemMessageText;
// NOTE: This property is only set for incoming messages.
@property (nonatomic, readonly, nullable) NSString *authorConversationColorName;
#pragma mark - MessageActions
@property (nonatomic, readonly) BOOL hasBodyTextActionContent;
@property (nonatomic, readonly) BOOL hasMediaActionContent;
- (void)copyMediaAction;
- (void)copyTextAction;
- (void)saveMediaAction;
- (void)deleteLocallyAction;
- (void)deleteRemotelyAction;
- (void)deleteAction; // Remove this after the unsend request is enabled
- (BOOL)canCopyMedia;
- (BOOL)canSaveMedia;
// For view items that correspond to interactions, this is the interaction's unique id.
// For other view views (like the typing indicator), this is a unique, stable string.
- (NSString *)itemId;
- (nullable TSAttachmentStream *)firstValidAlbumAttachment;
- (BOOL)mediaAlbumHasFailedAttachment;
@end
#pragma mark -
@interface ConversationInteractionViewItem
: NSObject <ConversationViewItem, OWSAudioPlayerDelegate>
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithInteraction:(TSInteraction *)interaction
isGroupThread:(BOOL)isGroupThread
transaction:(YapDatabaseReadTransaction *)transaction;
@end
NS_ASSUME_NONNULL_END

File diff suppressed because it is too large Load Diff

View File

@ -1,142 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@class ConversationStyle;
@class ConversationViewModel;
@class OWSQuotedReplyModel;
@class TSOutgoingMessage;
@class TSThread;
@class ThreadDynamicInteractions;
@protocol ConversationViewItem;
typedef NS_ENUM(NSUInteger, ConversationUpdateType) {
// No view items in the load window were effected.
ConversationUpdateType_Minor,
// A subset of view items in the load window were effected;
// the view should be updated using the update items.
ConversationUpdateType_Diff,
// Complicated or unexpected changes occurred in the load window;
// the view should be reloaded.
ConversationUpdateType_Reload,
};
#pragma mark -
typedef NS_ENUM(NSUInteger, ConversationUpdateItemType) {
ConversationUpdateItemType_Insert,
ConversationUpdateItemType_Delete,
ConversationUpdateItemType_Update,
};
#pragma mark -
@interface ConversationViewState : NSObject
@property (nonatomic, readonly) NSArray<id<ConversationViewItem>> *viewItems;
@property (nonatomic, readonly) NSDictionary<NSString *, NSNumber *> *interactionIndexMap;
// We have to track interactionIds separately. We can't just use interactionIndexMap.allKeys,
// as that won't preserve ordering.
@property (nonatomic, readonly) NSArray<NSString *> *interactionIds;
@property (nonatomic, readonly, nullable) NSNumber *unreadIndicatorIndex;
@end
#pragma mark -
@interface ConversationUpdateItem : NSObject
@property (nonatomic, readonly) ConversationUpdateItemType updateItemType;
// Only applies in the "delete" and "update" cases.
@property (nonatomic, readonly) NSUInteger oldIndex;
// Only applies in the "insert" and "update" cases.
@property (nonatomic, readonly) NSUInteger newIndex;
// Only applies in the "insert" and "update" cases.
@property (nonatomic, readonly, nullable) id<ConversationViewItem> viewItem;
@end
#pragma mark -
@interface ConversationUpdate : NSObject
@property (nonatomic, readonly) ConversationUpdateType conversationUpdateType;
// Only applies in the "diff" case.
@property (nonatomic, readonly, nullable) NSArray<ConversationUpdateItem *> *updateItems;
//// Only applies in the "diff" case.
@property (nonatomic, readonly) BOOL shouldAnimateUpdates;
@end
#pragma mark -
@protocol ConversationViewModelDelegate <NSObject>
- (void)conversationViewModelWillUpdate;
- (void)conversationViewModelDidUpdate:(ConversationUpdate *)conversationUpdate;
- (void)conversationViewModelWillLoadMoreItems;
- (void)conversationViewModelDidLoadMoreItems;
- (void)conversationViewModelDidLoadPrevPage;
- (void)conversationViewModelRangeDidChange;
// Called after the view model recovers from a severe error
// to prod the view to reset its scroll state, etc.
- (void)conversationViewModelDidReset;
@end
#pragma mark -
// Always load up to n messages when user arrives.
//
// The smaller this number is, the faster the conversation can display.
// To test, shrink you accessibility font as much as possible, then count how many 1-line system info messages (our
// shortest cells) can fit on screen at a time on an iPhoneX
//
// PERF: we could do less messages on shorter (older, slower) devices
// PERF: we could cache the cell height, since some messages will be much taller.
static const int kYapDatabasePageSize = 250;
// Never show more than n messages in conversation view when user arrives.
static const int kConversationInitialMaxRangeSize = 250;
// Never show more than n messages in conversation view at a time.
static const int kYapDatabaseRangeMaxLength = 250000;
#pragma mark -
@interface ConversationViewModel : NSObject
@property (nonatomic, readonly) ConversationViewState *viewState;
@property (nonatomic, nullable) NSString *focusMessageIdOnOpen;
@property (nonatomic, readonly, nullable) ThreadDynamicInteractions *dynamicInteractions;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithThread:(TSThread *)thread
focusMessageIdOnOpen:(nullable NSString *)focusMessageIdOnOpen
delegate:(id<ConversationViewModelDelegate>)delegate NS_DESIGNATED_INITIALIZER;
- (void)ensureDynamicInteractionsAndUpdateIfNecessary;
- (void)loadAnotherPageOfMessages;
- (void)viewDidResetContentAndLayout;
- (void)viewDidLoad;
- (BOOL)canLoadMoreItems;
- (nullable NSIndexPath *)ensureLoadWindowContainsQuotedReply:(OWSQuotedReplyModel *)quotedReply;
- (nullable NSIndexPath *)ensureLoadWindowContainsInteractionId:(NSString *)interactionId;
- (void)appendUnsavedOutgoingTextMessage:(TSOutgoingMessage *)outgoingMessage;
- (BOOL)reloadViewItems;
@end
NS_ASSUME_NONNULL_END

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,714 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import DifferenceKit
import SessionMessagingKit
import SessionUtilitiesKit
public class ConversationViewModel: OWSAudioPlayerDelegate {
public typealias SectionModel = ArraySection<Section, MessageViewModel>
// MARK: - Action
public enum Action {
case none
case compose
case audioCall
case videoCall
}
// MARK: - Section
public enum Section: Differentiable, Equatable, Comparable, Hashable {
case loadOlder
case messages
case loadNewer
}
// MARK: - Variables
public static let pageSize: Int = 50
private var threadId: String
public let initialThreadVariant: SessionThread.Variant
public var sentMessageBeforeUpdate: Bool = false
public var lastSearchedText: String?
public let focusedInteractionId: Int64? // Note: This is used for global search
public lazy var blockedBannerMessage: String = {
switch self.threadData.threadVariant {
case .contact:
let name: String = Profile.displayName(
id: self.threadData.threadId,
threadVariant: self.threadData.threadVariant
)
return "\(name) is blocked. Unblock them?"
default: return "Thread is blocked. Unblock it?"
}
}()
// MARK: - Initialization
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64?) {
// If we have a specified 'focusedInteractionId' then use that, otherwise retrieve the oldest
// unread interaction and start focused around that one
let targetInteractionId: Int64? = {
if let focusedInteractionId: Int64 = focusedInteractionId { return focusedInteractionId }
return Storage.shared.read { db in
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
return try Interaction
.select(.id)
.filter(interaction[.wasRead] == false)
.filter(interaction[.threadId] == threadId)
.order(interaction[.timestampMs].asc)
.asRequest(of: Int64.self)
.fetchOne(db)
}
}()
self.threadId = threadId
self.initialThreadVariant = threadVariant
self.focusedInteractionId = targetInteractionId
self.pagedDataObserver = nil
// Note: Since this references self we need to finish initializing before setting it, we
// also want to skip the initial query and trigger it async so that the push animation
// doesn't stutter (it should load basically immediately but without this there is a
// distinct stutter)
self.pagedDataObserver = self.setupPagedObserver(
for: threadId,
userPublicKey: getUserHexEncodedPublicKey()
)
// Run the initial query on a background thread so we don't block the push transition
DispatchQueue.global(qos: .default).async { [weak self] in
// If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query
// from a `0` offset)
guard let initialFocusedId: Int64 = targetInteractionId else {
self?.pagedDataObserver?.load(.pageBefore)
return
}
self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId))
}
}
// MARK: - Thread Data
/// This value is the current state of the view
public private(set) lazy var threadData: SessionThreadViewModel = SessionThreadViewModel(
threadId: self.threadId,
threadVariant: self.initialThreadVariant,
currentUserIsClosedGroupMember: (self.initialThreadVariant != .closedGroup ?
nil :
Storage.shared.read { db in
try GroupMember
.filter(GroupMember.Columns.groupId == self.threadId)
.filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db))
.filter(GroupMember.Columns.role == GroupMember.Role.standard)
.isNotEmpty(db)
}
)
)
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
///
/// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static
/// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
///
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
public lazy var observableThreadData: ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> = setupObservableThreadData(for: self.threadId)
private func setupObservableThreadData(for threadId: String) -> ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> {
return ValueObservation
.trackingConstantRegion { db -> SessionThreadViewModel? in
let userPublicKey: String = getUserHexEncodedPublicKey(db)
return try SessionThreadViewModel
.conversationQuery(threadId: threadId, userPublicKey: userPublicKey)
.fetchOne(db)
}
.removeDuplicates()
}
public func updateThreadData(_ updatedData: SessionThreadViewModel) {
self.threadData = updatedData
}
// MARK: - Interaction Data
public private(set) var unobservedInteractionDataChanges: [SectionModel]?
public private(set) var interactionData: [SectionModel] = []
public private(set) var pagedDataObserver: PagedDatabaseObserver<Interaction, MessageViewModel>?
public var onInteractionChange: (([SectionModel]) -> ())? {
didSet {
// When starting to observe interaction changes we want to trigger a UI update just in case the
// data was changed while we weren't observing
if let unobservedInteractionDataChanges: [SectionModel] = self.unobservedInteractionDataChanges {
onInteractionChange?(unobservedInteractionDataChanges)
self.unobservedInteractionDataChanges = nil
}
}
}
private func setupPagedObserver(for threadId: String, userPublicKey: String) -> PagedDatabaseObserver<Interaction, MessageViewModel> {
return PagedDatabaseObserver(
pagedTable: Interaction.self,
pageSize: ConversationViewModel.pageSize,
idColumn: .id,
observedChanges: [
PagedData.ObservedChanges(
table: Interaction.self,
columns: Interaction.Columns
.allCases
.filter { $0 != .wasRead }
),
PagedData.ObservedChanges(
table: Contact.self,
columns: [.isTrusted],
joinToPagedType: {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])")
}()
),
PagedData.ObservedChanges(
table: Profile.self,
columns: [.profilePictureFileName],
joinToPagedType: {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let profile: TypedTableAlias<Profile> = TypedTableAlias()
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
}()
)
],
filterSQL: MessageViewModel.filterSQL(threadId: threadId),
groupSQL: MessageViewModel.groupSQL,
orderSQL: MessageViewModel.orderSQL,
dataQuery: MessageViewModel.baseQuery(
userPublicKey: userPublicKey,
orderSQL: MessageViewModel.orderSQL,
groupSQL: MessageViewModel.groupSQL
),
associatedRecords: [
AssociatedRecord<MessageViewModel.AttachmentInteractionInfo, MessageViewModel>(
trackedAgainst: Attachment.self,
observedChanges: [
PagedData.ObservedChanges(
table: Attachment.self,
columns: [.state]
)
],
dataQuery: MessageViewModel.AttachmentInteractionInfo.baseQuery,
joinToPagedType: MessageViewModel.AttachmentInteractionInfo.joinToViewModelQuerySQL,
associateData: MessageViewModel.AttachmentInteractionInfo.createAssociateDataClosure()
),
AssociatedRecord<MessageViewModel.TypingIndicatorInfo, MessageViewModel>(
trackedAgainst: ThreadTypingIndicator.self,
observedChanges: [
PagedData.ObservedChanges(
table: ThreadTypingIndicator.self,
events: [.insert, .delete],
columns: []
)
],
dataQuery: MessageViewModel.TypingIndicatorInfo.baseQuery,
joinToPagedType: MessageViewModel.TypingIndicatorInfo.joinToViewModelQuerySQL,
associateData: MessageViewModel.TypingIndicatorInfo.createAssociateDataClosure()
)
],
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
guard let updatedInteractionData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
return
}
// If we have the 'onInteractionChanged' callback then trigger it, otherwise just store the changes
// to be sent to the callback if we ever start observing again (when we have the callback it needs
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
// correct order)
guard let onInteractionChange: (([SectionModel]) -> ()) = self?.onInteractionChange else {
self?.unobservedInteractionDataChanges = updatedInteractionData
return
}
onInteractionChange(updatedInteractionData)
}
)
}
private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true })
let sortedData: [MessageViewModel] = data
.filter { $0.isTypingIndicator != true }
.sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs }
// We load messages from newest to oldest so having a pageOffset larger than zero means
// there are newer pages to load
return [
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
[SectionModel(section: .loadOlder)] :
[]
),
[
SectionModel(
section: .messages,
elements: sortedData
.enumerated()
.map { index, cellViewModel -> MessageViewModel in
cellViewModel.withClusteringChanges(
prevModel: (index > 0 ? sortedData[index - 1] : nil),
nextModel: (index < (sortedData.count - 1) ? sortedData[index + 1] : nil),
isLast: (
// The database query sorts by timestampMs descending so the "last"
// interaction will actually have a 'pageOffset' of '0' even though
// it's the last element in the 'sortedData' array
index == (sortedData.count - 1) &&
pageInfo.pageOffset == 0
),
currentUserBlindedPublicKey: threadData.currentUserBlindedPublicKey
)
}
.appending(typingIndicator)
)
],
(!data.isEmpty && pageInfo.pageOffset > 0 ?
[SectionModel(section: .loadNewer)] :
[]
)
].flatMap { $0 }
}
public func updateInteractionData(_ updatedData: [SectionModel]) {
self.interactionData = updatedData
}
// MARK: - Mentions
public struct MentionInfo: FetchableRecord, Decodable {
fileprivate static let threadVariantKey = CodingKeys.threadVariant.stringValue
fileprivate static let openGroupServerKey = CodingKeys.openGroupServer.stringValue
fileprivate static let openGroupRoomTokenKey = CodingKeys.openGroupRoomToken.stringValue
let profile: Profile
let threadVariant: SessionThread.Variant
let openGroupServer: String?
let openGroupRoomToken: String?
}
public func mentions(for query: String = "") -> [MentionInfo] {
let threadData: SessionThreadViewModel = self.threadData
let results: [MentionInfo] = Storage.shared
.read { db -> [MentionInfo] in
let userPublicKey: String = getUserHexEncodedPublicKey(db)
switch threadData.threadVariant {
case .contact:
guard userPublicKey != threadData.threadId else { return [] }
return [Profile.fetchOrCreate(db, id: threadData.threadId)]
.map { profile in
MentionInfo(
profile: profile,
threadVariant: threadData.threadVariant,
openGroupServer: nil,
openGroupRoomToken: nil
)
}
.filter {
query.count < 2 ||
$0.profile.displayName(for: $0.threadVariant).contains(query)
}
case .closedGroup:
let profile: TypedTableAlias<Profile> = TypedTableAlias()
return try GroupMember
.select(
profile.allColumns(),
SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey)
)
.filter(GroupMember.Columns.groupId == threadData.threadId)
.filter(GroupMember.Columns.profileId != userPublicKey)
.filter(GroupMember.Columns.role == GroupMember.Role.standard)
.joining(
required: GroupMember.profile
.aliased(profile)
// Note: LIKE is case-insensitive in SQLite
.filter(
query.count < 2 || (
profile[.nickname] != nil &&
profile[.nickname].like("%\(query)%")
) || (
profile[.nickname] == nil &&
profile[.name].like("%\(query)%")
)
)
)
.asRequest(of: MentionInfo.self)
.fetchAll(db)
case .openGroup:
let profile: TypedTableAlias<Profile> = TypedTableAlias()
return try Interaction
.select(
profile.allColumns(),
SQL("\(threadData.threadVariant)").forKey(MentionInfo.threadVariantKey),
SQL("\(threadData.openGroupServer)").forKey(MentionInfo.openGroupServerKey),
SQL("\(threadData.openGroupRoomToken)").forKey(MentionInfo.openGroupRoomTokenKey)
)
.distinct()
.group(Interaction.Columns.authorId)
.filter(Interaction.Columns.threadId == threadData.threadId)
.filter(Interaction.Columns.authorId != userPublicKey)
.joining(
required: Interaction.profile
.aliased(profile)
// Note: LIKE is case-insensitive in SQLite
.filter(
query.count < 2 || (
profile[.nickname] != nil &&
profile[.nickname].like("%\(query)%")
) || (
profile[.nickname] == nil &&
profile[.name].like("%\(query)%")
)
)
)
.order(Interaction.Columns.timestampMs.desc)
.limit(20)
.asRequest(of: MentionInfo.self)
.fetchAll(db)
}
}
.defaulting(to: [])
guard query.count >= 2 else {
return results.sorted { lhs, rhs -> Bool in
lhs.profile.displayName(for: lhs.threadVariant) < rhs.profile.displayName(for: rhs.threadVariant)
}
}
return results
.sorted { lhs, rhs -> Bool in
let maybeLhsRange = lhs.profile.displayName(for: lhs.threadVariant).lowercased().range(of: query.lowercased())
let maybeRhsRange = rhs.profile.displayName(for: rhs.threadVariant).lowercased().range(of: query.lowercased())
guard let lhsRange: Range<String.Index> = maybeLhsRange, let rhsRange: Range<String.Index> = maybeRhsRange else {
return true
}
return (lhsRange.lowerBound < rhsRange.lowerBound)
}
}
// MARK: - Functions
public func updateDraft(to draft: String) {
let threadId: String = self.threadId
let currentDraft: String = Storage.shared
.read { db in
try SessionThread
.select(.messageDraft)
.filter(id: threadId)
.asRequest(of: String.self)
.fetchOne(db)
}
.defaulting(to: "")
// Only write the updated draft to the database if it's changed (avoid unnecessary writes)
guard draft != currentDraft else { return }
Storage.shared.writeAsync { db in
try SessionThread
.filter(id: threadId)
.updateAll(db, SessionThread.Columns.messageDraft.set(to: draft))
}
}
public func markAllAsRead() {
// Don't bother marking anything as read if there are no unread interactions (we can rely
// on the 'threadData.threadUnreadCount' to always be accurate)
guard
(self.threadData.threadUnreadCount ?? 0) > 0,
let lastInteractionId: Int64 = self.threadData.interactionId
else { return }
let threadId: String = self.threadData.threadId
let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false)
Storage.shared.writeAsync { db in
try Interaction.markAsRead(
db,
interactionId: lastInteractionId,
threadId: threadId,
includingOlder: true,
trySendReadReceipt: trySendReadReceipt
)
}
}
public func swapToThread(updatedThreadId: String) {
let oldestMessageId: Int64? = self.interactionData
.filter { $0.model == .messages }
.first?
.elements
.first?
.id
self.threadId = updatedThreadId
self.observableThreadData = self.setupObservableThreadData(for: updatedThreadId)
self.pagedDataObserver = self.setupPagedObserver(
for: updatedThreadId,
userPublicKey: getUserHexEncodedPublicKey()
)
// Try load everything up to the initial visible message, fallback to just the initial page of messages
// if we don't have one
switch oldestMessageId {
case .some(let id): self.pagedDataObserver?.load(.untilInclusive(id: id, padding: 0))
case .none: self.pagedDataObserver?.load(.pageBefore)
}
}
// MARK: - Audio Playback
public struct PlaybackInfo {
let state: AudioPlaybackState
let progress: TimeInterval
let playbackRate: Double
let oldPlaybackRate: Double
let updateCallback: (PlaybackInfo?, Error?) -> ()
public func with(
state: AudioPlaybackState? = nil,
progress: TimeInterval? = nil,
playbackRate: Double? = nil,
updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil
) -> PlaybackInfo {
return PlaybackInfo(
state: (state ?? self.state),
progress: (progress ?? self.progress),
playbackRate: (playbackRate ?? self.playbackRate),
oldPlaybackRate: self.playbackRate,
updateCallback: (updateCallback ?? self.updateCallback)
)
}
}
private var audioPlayer: Atomic<OWSAudioPlayer?> = Atomic(nil)
private var currentPlayingInteraction: Atomic<Int64?> = Atomic(nil)
private var playbackInfo: Atomic<[Int64: PlaybackInfo]> = Atomic([:])
public func playbackInfo(for viewModel: MessageViewModel, updateCallback: ((PlaybackInfo?, Error?) -> ())? = nil) -> PlaybackInfo? {
// Use the existing info if it already exists (update it's callback if provided as that means
// the cell was reloaded)
if let currentPlaybackInfo: PlaybackInfo = playbackInfo.wrappedValue[viewModel.id] {
let updatedPlaybackInfo: PlaybackInfo = currentPlaybackInfo
.with(updateCallback: updateCallback)
playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
return updatedPlaybackInfo
}
// Validate the item is a valid audio item
guard
let updateCallback: ((PlaybackInfo?, Error?) -> ()) = updateCallback,
let attachment: Attachment = viewModel.attachments?.first,
attachment.isAudio,
attachment.isValid,
let originalFilePath: String = attachment.originalFilePath,
FileManager.default.fileExists(atPath: originalFilePath)
else { return nil }
// Create the info with the update callback
let newPlaybackInfo: PlaybackInfo = PlaybackInfo(
state: .stopped,
progress: 0,
playbackRate: 1,
oldPlaybackRate: 1,
updateCallback: updateCallback
)
// Cache the info
playbackInfo.mutate { $0[viewModel.id] = newPlaybackInfo }
return newPlaybackInfo
}
public func playOrPauseAudio(for viewModel: MessageViewModel) {
guard
let attachment: Attachment = viewModel.attachments?.first,
let originalFilePath: String = attachment.originalFilePath,
FileManager.default.fileExists(atPath: originalFilePath)
else { return }
// If the user interacted with the currently playing item
guard currentPlayingInteraction.wrappedValue != viewModel.id else {
let currentPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[viewModel.id]
let updatedPlaybackInfo: PlaybackInfo? = currentPlaybackInfo?
.with(
state: (currentPlaybackInfo?.state != .playing ? .playing : .paused),
playbackRate: 1
)
audioPlayer.wrappedValue?.playbackRate = 1
switch currentPlaybackInfo?.state {
case .playing: audioPlayer.wrappedValue?.pause()
default: audioPlayer.wrappedValue?.play()
}
// Update the state and then update the UI with the updated state
playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
return
}
// First stop any existing audio
audioPlayer.wrappedValue?.stop()
// Then setup the state for the new audio
currentPlayingInteraction.mutate { $0 = viewModel.id }
audioPlayer.mutate { [weak self] player in
// Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer
// gets deallocated it triggers state changes which cause UI bugs when auto-playing
player?.delegate = nil
player = nil
let audioPlayer: OWSAudioPlayer = OWSAudioPlayer(
mediaUrl: URL(fileURLWithPath: originalFilePath),
audioBehavior: .audioMessagePlayback,
delegate: self
)
audioPlayer.play()
audioPlayer.setCurrentTime(playbackInfo.wrappedValue[viewModel.id]?.progress ?? 0)
player = audioPlayer
}
}
public func speedUpAudio(for viewModel: MessageViewModel) {
// If we aren't playing the specified item then just start playing it
guard viewModel.id == currentPlayingInteraction.wrappedValue else {
playOrPauseAudio(for: viewModel)
return
}
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[viewModel.id]?
.with(playbackRate: 1.5)
// Speed up the audio player
audioPlayer.wrappedValue?.playbackRate = 1.5
playbackInfo.mutate { $0[viewModel.id] = updatedPlaybackInfo }
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
}
public func stopAudio() {
audioPlayer.wrappedValue?.stop()
currentPlayingInteraction.mutate { $0 = nil }
audioPlayer.mutate {
// Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer
// gets deallocated it triggers state changes which cause UI bugs when auto-playing
$0?.delegate = nil
$0 = nil
}
}
// MARK: - OWSAudioPlayerDelegate
public func audioPlaybackState() -> AudioPlaybackState {
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return .stopped }
return (playbackInfo.wrappedValue[interactionId]?.state ?? .stopped)
}
public func setAudioPlaybackState(_ state: AudioPlaybackState) {
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
.with(state: state)
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
}
public func setAudioProgress(_ progress: CGFloat, duration: CGFloat) {
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
.with(progress: TimeInterval(progress))
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
}
public func audioPlayerDidFinishPlaying(_ player: OWSAudioPlayer, successfully: Bool) {
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
guard successfully else { return }
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
.with(
state: .stopped,
progress: 0,
playbackRate: 1
)
// Safe the changes and send one final update to the UI
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, nil)
// Clear out the currently playing record
currentPlayingInteraction.mutate { $0 = nil }
audioPlayer.mutate {
// Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer
// gets deallocated it triggers state changes which cause UI bugs when auto-playing
$0?.delegate = nil
$0 = nil
}
// If the next interaction is another voice message then autoplay it
guard
let messageSection: SectionModel = self.interactionData
.first(where: { $0.model == .messages }),
let currentIndex: Int = messageSection.elements
.firstIndex(where: { $0.id == interactionId }),
currentIndex < (messageSection.elements.count - 1),
messageSection.elements[currentIndex + 1].cellType == .audio
else { return }
let nextItem: MessageViewModel = messageSection.elements[currentIndex + 1]
playOrPauseAudio(for: nextItem)
}
public func showInvalidAudioFileAlert() {
guard let interactionId: Int64 = currentPlayingInteraction.wrappedValue else { return }
let updatedPlaybackInfo: PlaybackInfo? = playbackInfo.wrappedValue[interactionId]?
.with(
state: .stopped,
progress: 0,
playbackRate: 1
)
currentPlayingInteraction.mutate { $0 = nil }
playbackInfo.mutate { $0[interactionId] = updatedPlaybackInfo }
updatedPlaybackInfo?.updateCallback(updatedPlaybackInfo, AttachmentError.invalidData)
}
}

View File

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

View File

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

View File

@ -1,30 +1,44 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
import SessionMessagingKit
final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, QuoteViewDelegate, LinkPreviewViewDelegate, MentionSelectionViewDelegate {
enum MessageTypes {
case all
case textOnly
case none
}
final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate {
// MARK: - Variables
private static let linkPreviewViewInset: CGFloat = 6
private let threadVariant: SessionThread.Variant
private weak var delegate: InputViewDelegate?
var quoteDraftInfo: (model: OWSQuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } }
var linkPreviewInfo: (url: String, draft: OWSLinkPreviewDraft?)?
var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } }
var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)?
private var voiceMessageRecordingView: VoiceMessageRecordingView?
private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0)
private lazy var linkPreviewView: LinkPreviewView = {
let maxWidth = self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset
return LinkPreviewView(for: nil, maxWidth: maxWidth, delegate: self)
let maxWidth: CGFloat = (self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset)
return LinkPreviewView(maxWidth: maxWidth) { [weak self] in
self?.linkPreviewInfo = nil
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
}
}()
var text: String {
get { inputTextView.text }
get { inputTextView.text ?? "" }
set { inputTextView.text = newValue }
}
var enabledMessageTypes: MessageTypes = .all {
var selectedRange: NSRange {
get { inputTextView.selectedRange }
set { inputTextView.selectedRange = newValue }
}
var inputTextViewIsFirstResponder: Bool { inputTextView.isFirstResponder }
var enabledMessageTypes: MessageInputTypes = .all {
didSet {
setEnabledMessageTypes(enabledMessageTypes, message: nil)
}
@ -33,7 +47,7 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
override var intrinsicContentSize: CGSize { CGSize.zero }
var lastSearchedText: String? { nil }
// MARK: UI Components
// MARK: - UI
private var bottomStackView: UIStackView?
private lazy var attachmentsButton = ExpandingAttachmentsButton(delegate: delegate)
@ -45,7 +59,6 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
return result
}()
private lazy var sendButton: InputViewButton = {
let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self)
result.isHidden = true
@ -55,22 +68,25 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton)
private lazy var mentionsView: MentionSelectionView = {
let result = MentionSelectionView()
let result: MentionSelectionView = MentionSelectionView()
result.delegate = self
return result
}()
private lazy var mentionsViewContainer: UIView = {
let result = UIView()
let result: UIView = UIView()
let backgroundView = UIView()
backgroundView.backgroundColor = isLightMode ? .white : .black
backgroundView.backgroundColor = (isLightMode ? .white : .black)
backgroundView.alpha = Values.lowOpacity
result.addSubview(backgroundView)
backgroundView.pin(to: result)
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
let blurView: UIVisualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
result.addSubview(blurView)
blurView.pin(to: result)
result.alpha = 0
return result
}()
@ -97,13 +113,14 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
private lazy var additionalContentContainer = UIView()
// MARK: Settings
private static let linkPreviewViewInset: CGFloat = 6
// MARK: - Initialization
// MARK: Lifecycle
init(delegate: InputViewDelegate) {
init(threadVariant: SessionThread.Variant, delegate: InputViewDelegate) {
self.threadVariant = threadVariant
self.delegate = delegate
super.init(frame: CGRect.zero)
setUpViewHierarchy()
}
@ -117,31 +134,37 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
private func setUpViewHierarchy() {
autoresizingMask = .flexibleHeight
// Background & blur
let backgroundView = UIView()
backgroundView.backgroundColor = isLightMode ? .white : .black
backgroundView.alpha = Values.lowOpacity
addSubview(backgroundView)
backgroundView.pin(to: self)
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
addSubview(blurView)
blurView.pin(to: self)
// Separator
let separator = UIView()
separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
separator.set(.height, to: 1 / UIScreen.main.scale)
addSubview(separator)
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
// Bottom stack view
let bottomStackView = UIStackView(arrangedSubviews: [ attachmentsButton, inputTextView, container(for: sendButton) ])
bottomStackView.axis = .horizontal
bottomStackView.spacing = Values.smallSpacing
bottomStackView.alignment = .center
self.bottomStackView = bottomStackView
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ additionalContentContainer, bottomStackView ])
mainStackView.axis = .vertical
mainStackView.isLayoutMarginsRelativeArrangement = true
let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2
mainStackView.layoutMargins = UIEdgeInsets(top: 2, leading: Values.mediumSpacing - adjustment, bottom: 2, trailing: Values.mediumSpacing - adjustment)
addSubview(mainStackView)
@ -163,12 +186,14 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
mentionsViewContainer.addSubview(mentionsView)
mentionsView.pin(to: mentionsViewContainer)
mentionsViewHeightConstraint.isActive = true
// Voice message button
addSubview(voiceMessageButtonContainer)
voiceMessageButtonContainer.center(in: sendButton)
}
// MARK: Updating
// MARK: - Updating
func inputTextViewDidChangeSize(_ inputTextView: InputTextView) {
invalidateIntrinsicContentSize()
}
@ -192,11 +217,27 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
private func handleQuoteDraftChanged() {
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
linkPreviewInfo = nil
guard let quoteDraftInfo = quoteDraftInfo else { return }
let direction: QuoteView.Direction = quoteDraftInfo.isOutgoing ? .outgoing : .incoming
let hInset: CGFloat = 6 // Slight visual adjustment
let maxWidth = additionalContentContainer.bounds.width
let quoteView = QuoteView(for: quoteDraftInfo.model, direction: direction, hInset: hInset, maxWidth: maxWidth, delegate: self)
let quoteView: QuoteView = QuoteView(
for: .draft,
authorId: quoteDraftInfo.model.authorId,
quotedText: quoteDraftInfo.model.body,
threadVariant: threadVariant,
currentUserPublicKey: nil,
currentUserBlindedPublicKey: nil,
direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming),
attachment: quoteDraftInfo.model.attachment,
hInset: hInset,
maxWidth: maxWidth
) { [weak self] in
self?.quoteDraftInfo = nil
}
additionalContentContainer.addSubview(quoteView)
quoteView.pin(.left, to: .left, of: additionalContentContainer, withInset: hInset)
quoteView.pin(.top, to: .top, of: additionalContentContainer, withInset: 12)
@ -211,52 +252,66 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
// Suggest that the user enable link previews if they haven't already and we haven't
// told them about link previews yet
let text = inputTextView.text!
let userDefaults = UserDefaults.standard
if !OWSLinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && !SSKPreferences.areLinkPreviewsEnabled
&& !userDefaults[.hasSeenLinkPreviewSuggestion] {
let areLinkPreviewsEnabled: Bool = Storage.shared[.areLinkPreviewsEnabled]
if
!LinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty &&
!areLinkPreviewsEnabled &&
!UserDefaults.standard[.hasSeenLinkPreviewSuggestion]
{
delegate?.showLinkPreviewSuggestionModal()
userDefaults[.hasSeenLinkPreviewSuggestion] = true
UserDefaults.standard[.hasSeenLinkPreviewSuggestion] = true
return
}
// Check that link previews are enabled
guard SSKPreferences.areLinkPreviewsEnabled else { return }
guard areLinkPreviewsEnabled else { return }
// Proceed
autoGenerateLinkPreview()
}
func autoGenerateLinkPreview() {
// Check that a valid URL is present
guard let linkPreviewURL = OWSLinkPreview.previewUrl(forRawBodyText: text, selectedRange: inputTextView.selectedRange) else {
guard let linkPreviewURL = LinkPreview.previewUrl(for: text, selectedRange: inputTextView.selectedRange) else {
return
}
// Guard against obsolete updates
guard linkPreviewURL != self.linkPreviewInfo?.url else { return }
// Clear content container
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
quoteDraftInfo = nil
// Set the state to loading
linkPreviewInfo = (url: linkPreviewURL, draft: nil)
linkPreviewView.linkPreviewState = LinkPreviewLoading()
linkPreviewView.update(with: LinkPreview.LoadingState(), isOutgoing: false)
// Add the link preview view
additionalContentContainer.addSubview(linkPreviewView)
linkPreviewView.pin(.left, to: .left, of: additionalContentContainer, withInset: InputView.linkPreviewViewInset)
linkPreviewView.pin(.top, to: .top, of: additionalContentContainer, withInset: 10)
linkPreviewView.pin(.right, to: .right, of: additionalContentContainer)
linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4)
// Build the link preview
OWSLinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL).done { [weak self] draft in
guard let self = self else { return }
guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
self.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
self.linkPreviewView.linkPreviewState = LinkPreviewDraft(linkPreviewDraft: draft)
}.catch { _ in
guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
self.linkPreviewInfo = nil
self.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
}.retainUntilComplete()
LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL)
.done { [weak self] draft in
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
self?.linkPreviewView.update(with: LinkPreview.DraftState(linkPreviewDraft: draft), isOutgoing: false)
}
.catch { [weak self] _ in
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
self?.linkPreviewInfo = nil
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
}
.retainUntilComplete()
}
func setEnabledMessageTypes(_ messageTypes: MessageTypes, message: String?) {
func setEnabledMessageTypes(_ messageTypes: MessageInputTypes, message: String?) {
guard enabledMessageTypes != messageTypes else { return }
enabledMessageTypes = messageTypes
@ -279,32 +334,37 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
}
}
// MARK: Interaction
// MARK: - Interaction
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// Needed so that the user can tap the buttons when the expanding attachments button is expanded
let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton,
attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ]
let buttonContainer = buttonContainers.first { $0.superview!.convert($0.frame, to: self).contains(point) }
if let buttonContainer = buttonContainer {
if let buttonContainer: InputViewButton = buttonContainers.first(where: { $0.superview?.convert($0.frame, to: self).contains(point) == true }) {
return buttonContainer
} else {
return super.hitTest(point, with: event)
}
return super.hitTest(point, with: event)
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let buttonContainers = [ attachmentsButton.gifButtonContainer, attachmentsButton.documentButtonContainer,
attachmentsButton.libraryButtonContainer, attachmentsButton.cameraButtonContainer, attachmentsButton.mainButtonContainer ]
let isPointInsideAttachmentsButton = buttonContainers.contains { $0.superview!.convert($0.frame, to: self).contains(point) }
let isPointInsideAttachmentsButton = buttonContainers
.contains { $0.superview!.convert($0.frame, to: self).contains(point) }
if isPointInsideAttachmentsButton {
// Needed so that the user can tap the buttons when the expanding attachments button is expanded
return true
} else if mentionsViewContainer.frame.contains(point) {
}
if mentionsViewContainer.frame.contains(point) {
// Needed so that the user can tap mentions
return true
} else {
return super.point(inside: point, with: event)
}
return super.point(inside: point, with: event)
}
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) {
@ -329,21 +389,16 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
voiceMessageRecordingView.handleLongPressEnded(at: location)
}
func handleQuoteViewCancelButtonTapped() {
delegate?.handleQuoteViewCancelButtonTapped()
}
override func resignFirstResponder() -> Bool {
inputTextView.resignFirstResponder()
}
func handleLongPress() {
// Not relevant in this case
func inputTextViewBecomeFirstResponder() {
inputTextView.becomeFirstResponder()
}
func handleLinkPreviewCanceled() {
linkPreviewInfo = nil
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
func handleLongPress() {
// Not relevant in this case
}
@objc private func showVoiceMessageUI() {
@ -373,50 +428,53 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate,
}
func hideMentionsUI() {
UIView.animate(withDuration: 0.25, animations: {
self.mentionsViewContainer.alpha = 0
}, completion: { _ in
self.mentionsViewHeightConstraint.constant = 0
self.mentionsView.tableView.contentOffset = CGPoint.zero
})
UIView.animate(
withDuration: 0.25,
animations: { [weak self] in
self?.mentionsViewContainer.alpha = 0
},
completion: { [weak self] _ in
self?.mentionsViewHeightConstraint.constant = 0
self?.mentionsView.contentOffset = CGPoint.zero
}
)
}
func showMentionsUI(for candidates: [Mention], in thread: TSThread) {
if let openGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) {
mentionsView.openGroupServer = openGroupV2.server
mentionsView.openGroupRoom = openGroupV2.room
}
func showMentionsUI(for candidates: [ConversationViewModel.MentionInfo]) {
mentionsView.candidates = candidates
let mentionCellHeight = Values.smallProfilePictureSize + 2 * Values.smallSpacing
let mentionCellHeight = (Values.smallProfilePictureSize + 2 * Values.smallSpacing)
mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight
layoutIfNeeded()
UIView.animate(withDuration: 0.25) {
self.mentionsViewContainer.alpha = 1
}
}
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) {
delegate?.handleMentionSelected(mention, from: view)
func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView) {
delegate?.handleMentionSelected(mentionInfo, from: view)
}
// MARK: Convenience
// MARK: - Convenience
private func container(for button: InputViewButton) -> UIView {
let result = UIView()
let result: UIView = UIView()
result.addSubview(button)
result.set(.width, to: InputViewButton.expandedSize)
result.set(.height, to: InputViewButton.expandedSize)
button.center(in: result)
return result
}
}
// MARK: Delegate
protocol InputViewDelegate : AnyObject, ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate {
// MARK: - Delegate
protocol InputViewDelegate: ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate {
func showLinkPreviewSuggestionModal()
func handleSendButtonTapped()
func handleQuoteViewCancelButtonTapped()
func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView)
func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView)
func didPasteImageFromPasteboard(_ image: UIImage)
}

View File

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

View File

@ -1,36 +1,50 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
import SessionUtilitiesKit
import SignalUtilitiesKit
final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDelegate {
var candidates: [Mention] = [] {
var candidates: [ConversationViewModel.MentionInfo] = [] {
didSet {
tableView.isScrollEnabled = (candidates.count > 4)
tableView.reloadData()
}
}
var openGroupServer: String?
var openGroupChannel: UInt64?
var openGroupRoom: String?
weak var delegate: MentionSelectionViewDelegate?
// MARK: Components
lazy var tableView: UITableView = { // TODO: Make this private
let result = UITableView()
var contentOffset: CGPoint {
get { tableView.contentOffset }
set { tableView.contentOffset = newValue }
}
// MARK: - Components
private lazy var tableView: UITableView = {
let result: UITableView = UITableView()
result.dataSource = self
result.delegate = self
result.register(Cell.self, forCellReuseIdentifier: "Cell")
result.separatorStyle = .none
result.backgroundColor = .clear
result.showsVerticalScrollIndicator = false
result.register(view: Cell.self)
return result
}()
// MARK: Initialization
// MARK: - Initialization
override init(frame: CGRect) {
super.init(frame: frame)
setUpViewHierarchy()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setUpViewHierarchy()
}
@ -38,43 +52,54 @@ final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDel
// Table view
addSubview(tableView)
tableView.pin(to: self)
// Top separator
let topSeparator = UIView()
let topSeparator: UIView = UIView()
topSeparator.backgroundColor = Colors.separator
topSeparator.set(.height, to: Values.separatorThickness)
addSubview(topSeparator)
topSeparator.pin(.leading, to: .leading, of: self)
topSeparator.pin(.top, to: .top, of: self)
topSeparator.pin(.trailing, to: .trailing, of: self)
// Bottom separator
let bottomSeparator = UIView()
let bottomSeparator: UIView = UIView()
bottomSeparator.backgroundColor = Colors.separator
bottomSeparator.set(.height, to: Values.separatorThickness)
addSubview(bottomSeparator)
bottomSeparator.pin(.leading, to: .leading, of: self)
bottomSeparator.pin(.trailing, to: .trailing, of: self)
bottomSeparator.pin(.bottom, to: .bottom, of: self)
}
// MARK: Data
// MARK: - Data
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return candidates.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! Cell
let mentionCandidate = candidates[indexPath.row]
cell.mentionCandidate = mentionCandidate
cell.openGroupServer = openGroupServer
cell.openGroupChannel = openGroupChannel
cell.openGroupRoom = openGroupRoom
cell.separator.isHidden = (indexPath.row == (candidates.count - 1))
let cell: Cell = tableView.dequeue(type: Cell.self, for: indexPath)
cell.update(
with: candidates[indexPath.row].profile,
threadVariant: candidates[indexPath.row].threadVariant,
isUserModeratorOrAdmin: OpenGroupManager.isUserModeratorOrAdmin(
candidates[indexPath.row].profile.id,
for: candidates[indexPath.row].openGroupRoomToken,
on: candidates[indexPath.row].openGroupServer
),
isLast: (indexPath.row == (candidates.count - 1))
)
return cell
}
// MARK: Interaction
// MARK: - Interaction
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let mentionCandidate = candidates[indexPath.row]
delegate?.handleMentionSelected(mentionCandidate, from: self)
}
}
@ -82,56 +107,59 @@ final class MentionSelectionView : UIView, UITableViewDataSource, UITableViewDel
// MARK: - Cell
private extension MentionSelectionView {
final class Cell: UITableViewCell {
var mentionCandidate = Mention(publicKey: "", displayName: "") { didSet { update() } }
var openGroupServer: String?
var openGroupChannel: UInt64?
var openGroupRoom: String?
// MARK: - UI
// MARK: Components
private lazy var profilePictureView = ProfilePictureView()
private lazy var profilePictureView: 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)
}

View File

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

View File

@ -1,177 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
import SignalUtilitiesKit
@objc
public protocol LongTextViewDelegate {
@objc
func longTextViewMessageWasDeleted(_ longTextViewController: LongTextViewController)
}
@objc
public class LongTextViewController: OWSViewController {
// MARK: - Dependencies
var uiDatabaseConnection: YapDatabaseConnection {
return OWSPrimaryStorage.shared().uiDatabaseConnection
}
// MARK: - Properties
@objc
weak var delegate: LongTextViewDelegate?
let viewItem: ConversationViewItem
var messageTextView: UITextView!
var displayableText: DisplayableText? {
return viewItem.displayableBodyText
}
var fullText: String {
return displayableText?.fullText ?? ""
}
// MARK: Initializers
@available(*, unavailable, message:"use other constructor instead.")
public required init?(coder aDecoder: NSCoder) {
notImplemented()
}
@objc
public required init(viewItem: ConversationViewItem) {
self.viewItem = viewItem
super.init(nibName: nil, bundle: nil)
}
// MARK: View Lifecycle
public override func viewDidLoad() {
super.viewDidLoad()
ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: NSLocalizedString("LONG_TEXT_VIEW_TITLE", comment: ""), hasCustomBackButton: false)
createViews()
self.messageTextView.contentOffset = CGPoint(x: 0, y: self.messageTextView.contentInset.top)
NotificationCenter.default.addObserver(self,
selector: #selector(uiDatabaseDidUpdate),
name: .OWSUIDatabaseConnectionDidUpdate,
object: OWSPrimaryStorage.shared().dbNotificationObject)
}
// MARK: - DB
@objc internal func uiDatabaseDidUpdate(notification: NSNotification) {
AssertIsOnMainThread()
guard let notifications = notification.userInfo?[OWSUIDatabaseConnectionNotificationsKey] as? [Notification] else {
owsFailDebug("notifications was unexpectedly nil")
return
}
guard let uniqueId = self.viewItem.interaction.uniqueId else {
Logger.error("Message is missing uniqueId.")
return
}
guard self.uiDatabaseConnection.hasChange(forKey: uniqueId,
inCollection: TSInteraction.collection(),
in: notifications) else {
Logger.debug("No relevant changes.")
return
}
do {
try uiDatabaseConnection.read { transaction in
guard TSInteraction.fetch(uniqueId: uniqueId, transaction: transaction) != nil else {
Logger.error("Message was deleted")
throw LongTextViewError.messageWasDeleted
}
}
} catch LongTextViewError.messageWasDeleted {
DispatchQueue.main.async {
self.delegate?.longTextViewMessageWasDeleted(self)
}
} catch {
owsFailDebug("unexpected error: \(error)")
}
}
enum LongTextViewError: Error {
case messageWasDeleted
}
// MARK: - Create Views
private func createViews() {
view.backgroundColor = Colors.navigationBarBackground
let messageTextView = OWSTextView()
self.messageTextView = messageTextView
messageTextView.font = .systemFont(ofSize: Values.smallFontSize)
messageTextView.backgroundColor = .clear
messageTextView.isOpaque = true
messageTextView.isEditable = false
messageTextView.isSelectable = true
messageTextView.isScrollEnabled = true
messageTextView.showsHorizontalScrollIndicator = false
messageTextView.showsVerticalScrollIndicator = true
messageTextView.isUserInteractionEnabled = true
messageTextView.textColor = Colors.text
messageTextView.contentInset = UIEdgeInsets(top: Values.mediumSpacing, leading: 0, bottom: 0, trailing: 0)
if let displayableText = displayableText {
messageTextView.text = fullText
messageTextView.ensureShouldLinkifyText(displayableText.shouldAllowLinkification)
} else {
owsFailDebug("displayableText was unexpectedly nil")
messageTextView.text = ""
}
let linkTextAttributes: [NSAttributedString.Key: Any] = [
NSAttributedString.Key.foregroundColor: Colors.text,
NSAttributedString.Key.underlineColor: Colors.text,
NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue
]
messageTextView.linkTextAttributes = linkTextAttributes
view.addSubview(messageTextView)
messageTextView.autoPinEdge(toSuperviewEdge: .top)
messageTextView.autoPinEdge(toSuperviewEdge: .leading)
messageTextView.autoPinEdge(toSuperviewEdge: .trailing)
messageTextView.textContainerInset = UIEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
let footer = UIToolbar()
view.addSubview(footer)
footer.autoPinWidthToSuperview()
footer.autoPinEdge(.top, to: .bottom, of: messageTextView)
footer.autoPinEdge(toSuperviewSafeArea: .bottom)
footer.items = [
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(shareButtonPressed)),
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
]
}
// MARK: - Actions
@objc func shareButtonPressed() {
let shareVC = UIActivityViewController(activityItems: [ fullText ], applicationActivities: nil)
if UIDevice.current.isIPad {
shareVC.excludedActivityTypes = []
shareVC.popoverPresentationController?.permittedArrowDirections = []
shareVC.popoverPresentationController?.sourceView = self.view
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
}
self.present(shareVC, animated: true, completion: nil)
}
}

View File

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

View File

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

View File

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

View File

@ -1,17 +1,18 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
import SessionMessagingKit
final class DocumentView: UIView {
private let viewItem: ConversationViewItem
private let textColor: UIColor
// MARK: Settings
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

View File

@ -1,220 +1,138 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
extension CGPoint {
import UIKit
import SessionMessagingKit
public func offsetBy(dx: CGFloat) -> CGPoint {
return CGPoint(x: x + dx, y: y)
protocol LinkPreviewState {
var isLoaded: Bool { get }
var urlString: String? { get }
var title: String? { get }
var imageState: LinkPreview.ImageState { get }
var image: UIImage? { get }
}
public func offsetBy(dy: CGFloat) -> CGPoint {
return CGPoint(x: x, y: y + dy)
}
}
// MARK: -
@objc
public enum LinkPreviewImageState: Int {
public extension LinkPreview {
enum ImageState: Int {
case none
case loading
case loaded
case invalid
}
// MARK: -
// MARK: LoadingState
@objc
public protocol LinkPreviewState {
func isLoaded() -> Bool
func urlString() -> String?
func displayDomain() -> String?
func title() -> String?
func imageState() -> LinkPreviewImageState
func image() -> UIImage?
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: -
// MARK: DraftState
@objc
public class LinkPreviewLoading: NSObject, LinkPreviewState {
struct DraftState: LinkPreviewState {
var isLoaded: Bool { true }
var urlString: String? { linkPreviewDraft.urlString }
override init() {
}
var title: String? {
guard let value = linkPreviewDraft.title, value.count > 0 else { return nil }
public func isLoaded() -> Bool {
return false
}
public func urlString() -> String? {
return nil
}
public func displayDomain() -> String? {
return nil
}
public func title() -> String? {
return nil
}
public func imageState() -> LinkPreviewImageState {
return .none
}
public func image() -> UIImage? {
return nil
}
}
// MARK: -
@objc
public class LinkPreviewDraft: NSObject, LinkPreviewState {
private let linkPreviewDraft: OWSLinkPreviewDraft
@objc
public required init(linkPreviewDraft: OWSLinkPreviewDraft) {
self.linkPreviewDraft = linkPreviewDraft
}
public func isLoaded() -> Bool {
return true
}
public func urlString() -> String? {
return linkPreviewDraft.urlString
}
public func displayDomain() -> String? {
guard let displayDomain = linkPreviewDraft.displayDomain() else {
owsFailDebug("Missing display domain")
return nil
}
return displayDomain
}
public func title() -> String? {
guard let value = linkPreviewDraft.title,
value.count > 0 else {
return nil
}
return value
}
public func imageState() -> LinkPreviewImageState {
if linkPreviewDraft.jpegImageData != nil {
return .loaded
} else {
var imageState: LinkPreview.ImageState {
if linkPreviewDraft.jpegImageData != nil { return .loaded }
return .none
}
}
public func image() -> UIImage? {
guard let jpegImageData = linkPreviewDraft.jpegImageData else {
return nil
}
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
}
// MARK: - Type Specific
private let linkPreviewDraft: LinkPreviewDraft
// MARK: - Initialization
init(linkPreviewDraft: LinkPreviewDraft) {
self.linkPreviewDraft = linkPreviewDraft
}
}
// MARK: -
// MARK: SentState
@objc
public class LinkPreviewSent: NSObject, LinkPreviewState {
private let linkPreview: OWSLinkPreview
private let imageAttachment: TSAttachment?
struct SentState: LinkPreviewState {
var isLoaded: Bool { true }
var urlString: String? { linkPreview.url }
@objc
public var imageSize: CGSize {
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
return CGSize.zero
}
return attachmentStream.imageSize()
}
var title: String? {
guard let value = linkPreview.title, value.count > 0 else { return nil }
@objc
public required init(linkPreview: OWSLinkPreview,
imageAttachment: TSAttachment?) {
self.linkPreview = linkPreview
self.imageAttachment = imageAttachment
}
public func isLoaded() -> Bool {
return true
}
public func urlString() -> String? {
guard let urlString = linkPreview.urlString else {
owsFailDebug("Missing url")
return nil
}
return urlString
}
public func displayDomain() -> String? {
guard let displayDomain = linkPreview.displayDomain() else {
Logger.error("Missing display domain")
return nil
}
return displayDomain
}
public func title() -> String? {
guard let value = linkPreview.title,
value.count > 0 else {
return nil
}
return value
}
public func imageState() -> LinkPreviewImageState {
guard linkPreview.imageAttachmentId != nil else {
return .none
}
guard let imageAttachment = imageAttachment else {
var imageState: LinkPreview.ImageState {
guard linkPreview.attachmentId != nil else { return .none }
guard let imageAttachment: Attachment = imageAttachment else {
owsFailDebug("Missing imageAttachment.")
return .none
}
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
return .loading
}
guard attachmentStream.isImage,
attachmentStream.isValidImage else {
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
}
}
public func image() -> UIImage? {
guard let attachmentStream = imageAttachment as? TSAttachmentStream 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 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)")
guard let image = UIImage(data: imageData) else {
owsFailDebug("Could not load image: \(imageAttachment?.localRelativeFilePath ?? "unknown")")
return nil
}
return image
}
// MARK: - Type Specific
private let linkPreview: LinkPreview
private let imageAttachment: Attachment?
public var imageSize: CGSize {
guard let width: UInt = imageAttachment?.width, let height: UInt = imageAttachment?.height else {
return CGSize.zero
}
// MARK: -
@objc
public protocol LinkPreviewViewDraftDelegate {
func linkPreviewCanCancel() -> Bool
func linkPreviewDidCancel()
return CGSize(width: CGFloat(width), height: CGFloat(height))
}
// MARK: - Initialization
init(linkPreview: LinkPreview, imageAttachment: Attachment?) {
self.linkPreview = linkPreview
self.imageAttachment = imageAttachment
}
}
}

View File

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

View File

@ -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,18 +16,21 @@ public class MediaAlbumView: UIStackView {
notImplemented()
}
@objc
public required init(mediaCache: NSCache<NSString, AnyObject>,
items: [ConversationMediaAlbumItem],
public required init(
mediaCache: NSCache<NSString, AnyObject>,
items: [Attachment],
isOutgoing: Bool,
maxMessageWidth: CGFloat) {
maxMessageWidth: CGFloat
) {
self.items = items
self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items).map {
let result = MediaView(mediaCache: mediaCache,
attachment: $0.attachment,
self.itemViews = MediaAlbumView.itemsToDisplay(forItems: items)
.map {
MediaView(
mediaCache: mediaCache,
attachment: $0,
isOutgoing: isOutgoing,
maxMessageWidth: maxMessageWidth)
return result
maxMessageWidth: maxMessageWidth
)
}
super.init(frame: .zero)
@ -46,9 +43,8 @@ public class MediaAlbumView: UIStackView {
private func createContents(maxMessageWidth: CGFloat) {
switch itemViews.count {
case 0:
owsFailDebug("No item views.")
return
case 0: return owsFailDebug("No item views.")
case 1:
// X
guard let itemView = itemViews.first else {
@ -57,6 +53,7 @@ public class MediaAlbumView: UIStackView {
}
addSubview(itemView)
itemView.autoPinEdgesToSuperviewEdges()
case 2:
// X X
// side-by-side.
@ -66,7 +63,9 @@ public class MediaAlbumView: UIStackView {
addArrangedSubview(itemView)
}
self.axis = .horizontal
self.distribution = .fillEqually
self.spacing = MediaAlbumView.kSpacingPts
case 3:
// x
// X x
@ -82,11 +81,16 @@ public class MediaAlbumView: UIStackView {
addArrangedSubview(leftItemView)
let rightViews = Array(itemViews[1..<3])
addArrangedSubview(newRow(rowViews: rightViews,
addArrangedSubview(
newRow(
rowViews: rightViews,
axis: .vertical,
viewSize: smallImageSize))
viewSize: smallImageSize
)
)
self.axis = .horizontal
self.spacing = MediaAlbumView.kSpacingPts
case 4:
// X X
// X X
@ -94,17 +98,26 @@ public class MediaAlbumView: UIStackView {
let imageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts) / 2
let topViews = Array(itemViews[0..<2])
addArrangedSubview(newRow(rowViews: topViews,
addArrangedSubview(
newRow(
rowViews: topViews,
axis: .horizontal,
viewSize: imageSize))
viewSize: imageSize
)
)
let bottomViews = Array(itemViews[2..<4])
addArrangedSubview(newRow(rowViews: bottomViews,
addArrangedSubview(
newRow(
rowViews: bottomViews,
axis: .horizontal,
viewSize: imageSize))
viewSize: imageSize
)
)
self.axis = .vertical
self.spacing = MediaAlbumView.kSpacingPts
default:
// X X
// xxx
@ -113,14 +126,22 @@ public class MediaAlbumView: UIStackView {
let smallImageSize = (maxMessageWidth - MediaAlbumView.kSpacingPts * 2) / 3
let topViews = Array(itemViews[0..<2])
addArrangedSubview(newRow(rowViews: topViews,
addArrangedSubview(
newRow(
rowViews: topViews,
axis: .horizontal,
viewSize: bigImageSize))
viewSize: bigImageSize
)
)
let bottomViews = Array(itemViews[2..<5])
addArrangedSubview(newRow(rowViews: bottomViews,
addArrangedSubview(
newRow(
rowViews: bottomViews,
axis: .horizontal,
viewSize: smallImageSize))
viewSize: smallImageSize
)
)
self.axis = .vertical
self.spacing = MediaAlbumView.kSpacingPts
@ -140,8 +161,11 @@ public class MediaAlbumView: UIStackView {
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 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
@ -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],
private func newRow(
rowViews: [MediaView],
axis: NSLayoutConstraint.Axis,
viewSize: CGFloat) -> UIStackView {
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,10 +256,12 @@ 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
@ -242,11 +272,13 @@ public class MediaAlbumView: UIStackView {
// 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
@ -254,6 +286,7 @@ public class MediaAlbumView: UIStackView {
let smallImageSize = (maxMessageWidth - kSpacingPts * 2) / 3
let bigImageSize = smallImageSize * 2 + kSpacingPts
return CGSize(width: maxMessageWidth, height: bigImageSize)
default:
// X X
// xxx
@ -264,7 +297,6 @@ public class MediaAlbumView: UIStackView {
}
}
@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
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,78 +1,84 @@
import NVActivityIndicatorView
// 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() } }
private static let width: CGFloat = 160
private static let toggleContainerSize: CGFloat = 20
private static let inset = Values.smallSpacing
// MARK: - UI
private lazy var progressViewRightConstraint = progressView.pin(.right, to: .right, of: self, withInset: -VoiceMessageView.width)
private var attachment: TSAttachment? { viewItem.attachmentStream ?? viewItem.attachmentPointer }
private var duration: Int { Int(viewItem.audioDurationSeconds) }
// MARK: UI Components
private lazy var progressView: UIView = {
let result = UIView()
let result: UIView = UIView()
result.backgroundColor = UIColor.black.withAlphaComponent(0.2)
return result
}()
private lazy var toggleImageView: UIImageView = {
let result = UIImageView(image: UIImage(named: "Play"))
let result: UIImageView = UIImageView(image: UIImage(named: "Play"))
result.contentMode = .scaleAspectFit
result.set(.width, to: 8)
result.set(.height, to: 8)
result.contentMode = .scaleAspectFit
return result
}()
private lazy var loader: NVActivityIndicatorView = {
let result = NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: Colors.text, padding: nil)
let result: NVActivityIndicatorView = NVActivityIndicatorView(
frame: .zero,
type: .circleStrokeSpin,
color: Colors.text,
padding: nil
)
result.set(.width, to: VoiceMessageView.toggleContainerSize + 2)
result.set(.height, to: VoiceMessageView.toggleContainerSize + 2)
return result
}()
private lazy var countdownLabelContainer: UIView = {
let result = UIView()
let result: UIView = UIView()
result.backgroundColor = .white
result.layer.masksToBounds = true
result.set(.height, to: VoiceMessageView.toggleContainerSize)
result.set(.width, to: 44)
return result
}()
private lazy var countdownLabel: UILabel = {
let result = UILabel()
let result: UILabel = UILabel()
result.textColor = .black
result.font = .systemFont(ofSize: Values.smallFontSize)
result.text = "0:00"
return result
}()
private lazy var speedUpLabel: UILabel = {
let result = UILabel()
let result: UILabel = UILabel()
result.textColor = .black
result.font = .systemFont(ofSize: Values.smallFontSize)
result.alpha = 0
result.text = "1.5x"
result.textAlignment = .center
return result
}()
// MARK: Settings
private static let width: CGFloat = 160
private static let toggleContainerSize: CGFloat = 20
private static let inset = Values.smallSpacing
// MARK: - Lifecycle
// MARK: Lifecycle
init(viewItem: ConversationViewItem) {
self.viewItem = viewItem
self.progress = Int(viewItem.audioProgressSeconds)
init() {
super.init(frame: CGRect.zero)
setUpViewHierarchy()
handleProgressChanged()
}
override init(frame: CGRect) {
@ -86,27 +92,33 @@ public final class VoiceMessageView : UIView {
private func setUpViewHierarchy() {
let toggleContainerSize = VoiceMessageView.toggleContainerSize
let inset = VoiceMessageView.inset
// Width & height
set(.width, to: VoiceMessageView.width)
// Toggle
let toggleContainer = UIView()
let toggleContainer: UIView = UIView()
toggleContainer.backgroundColor = .white
toggleContainer.set(.width, to: toggleContainerSize)
toggleContainer.set(.height, to: toggleContainerSize)
toggleContainer.addSubview(toggleImageView)
toggleImageView.center(in: toggleContainer)
toggleContainer.layer.cornerRadius = toggleContainerSize / 2
toggleContainer.layer.cornerRadius = (toggleContainerSize / 2)
toggleContainer.layer.masksToBounds = true
// Line
let lineView = UIView()
lineView.backgroundColor = .white
lineView.set(.height, to: 1)
// Countdown label
countdownLabelContainer.addSubview(countdownLabel)
countdownLabel.center(in: countdownLabelContainer)
// Speed up label
countdownLabelContainer.addSubview(speedUpLabel)
speedUpLabel.center(in: countdownLabelContainer)
// Constraints
addSubview(progressView)
progressView.pin(.left, to: .left, of: self)
@ -114,60 +126,73 @@ public final class VoiceMessageView : UIView {
progressViewRightConstraint.isActive = true
progressView.pin(.bottom, to: .bottom, of: self)
addSubview(toggleContainer)
toggleContainer.pin(.left, to: .left, of: self, withInset: inset)
toggleContainer.pin(.top, to: .top, of: self, withInset: inset)
toggleContainer.pin(.bottom, to: .bottom, of: self, withInset: -inset)
addSubview(lineView)
lineView.pin(.left, to: .right, of: toggleContainer)
lineView.center(.vertical, in: self)
addSubview(countdownLabelContainer)
countdownLabelContainer.pin(.left, to: .right, of: lineView)
countdownLabelContainer.pin(.right, to: .right, of: self, withInset: -inset)
countdownLabelContainer.center(.vertical, in: self)
addSubview(loader)
loader.center(in: toggleContainer)
}
// MARK: Updating
public override func layoutSubviews() {
super.layoutSubviews()
countdownLabelContainer.layer.cornerRadius = countdownLabelContainer.bounds.height / 2
countdownLabelContainer.layer.cornerRadius = (countdownLabelContainer.bounds.height / 2)
}
private func handleIsPlayingChanged() {
toggleImageView.image = isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play")
if !isPlaying { progress = 0 }
}
// MARK: - Updating
private func handleProgressChanged() {
let isDownloaded = (attachment?.isDownloaded == true)
loader.isHidden = isDownloaded
if isDownloaded { loader.stopAnimating() } else if !loader.isAnimating { loader.startAnimating() }
guard isDownloaded else { return }
countdownLabel.text = OWSFormat.formatDurationSeconds(duration - progress)
guard viewItem.audioProgressSeconds > 0 && viewItem.audioDurationSeconds > 0 else {
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 = viewItem.audioProgressSeconds / viewItem.audioDurationSeconds
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
}
func showSpeedUpLabel() {
guard !isShowingSpeedUpLabel else { return }
isShowingSpeedUpLabel = true
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1250)) {
UIView.animate(withDuration: 0.25) { [weak self] in
guard let self = self else { return }
self.countdownLabel.alpha = 0
self.speedUpLabel.alpha = 1
self?.countdownLabel.alpha = 1
self?.speedUpLabel.alpha = 0
}
}
default:
if !loader.isAnimating {
loader.startAnimating()
}
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
})
}
}
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,13 +1,21 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
import SessionMessagingKit
@objc
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()
}

View File

@ -1,26 +1,35 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
final class ConversationTitleView: UIView {
private let thread: TSThread
weak var delegate: ConversationTitleViewDelegate?
private static let leftInset: CGFloat = 8
private static let leftInsetWithCallButton: CGFloat = 54
override var intrinsicContentSize: CGSize {
return UIView.layoutFittingExpandedSize
}
// MARK: UI Components
// MARK: - UI Components
private lazy var titleLabel: UILabel = {
let result = UILabel()
let result: UILabel = UILabel()
result.textColor = Colors.text
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
result.lineBreakMode = .byTruncatingTail
return result
}()
private lazy var subtitleLabel: UILabel = {
let result = UILabel()
let result: UILabel = UILabel()
result.textColor = Colors.text
result.font = .systemFont(ofSize: 13)
result.lineBreakMode = .byTruncatingTail
return result
}()
@ -29,114 +38,119 @@ final class ConversationTitleView : UIView {
result.axis = .vertical
result.alignment = .center
result.isLayoutMarginsRelativeArrangement = true
return result
}()
// MARK: Lifecycle
init(thread: TSThread) {
self.thread = thread
super.init(frame: CGRect.zero)
initialize()
}
// MARK: - Initialization
override init(frame: CGRect) {
preconditionFailure("Use init(thread:) instead.")
}
init() {
super.init(frame: .zero)
required init?(coder: NSCoder) {
preconditionFailure("Use init(coder:) instead.")
}
private func initialize() {
addSubview(stackView)
stackView.pin(to: self)
let shouldShowCallButton = SessionCall.isEnabled && !thread.isNoteToSelf() && !thread.isGroupThread()
let leftMargin: CGFloat = shouldShowCallButton ? 54 : 8 // Contact threads also have the call button to compensate for
stackView.layoutMargins = UIEdgeInsets(top: 0, left: leftMargin, bottom: 0, right: 0)
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
addGestureRecognizer(tapGestureRecognizer)
let notificationCenter = NotificationCenter.default
notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.groupThreadUpdated, object: nil)
notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.muteSettingUpdated, object: nil)
notificationCenter.addObserver(self, selector: #selector(update), name: Notification.Name.contactUpdated, object: nil)
update()
stackView.pin(to: self)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: Updating
@objc private func update() {
titleLabel.text = getTitle()
let subtitle = getSubtitle()
subtitleLabel.attributedText = subtitle
let titleFontSize = (subtitle != nil) ? Values.mediumFontSize : Values.veryLargeFontSize
titleLabel.font = .boldSystemFont(ofSize: titleFontSize)
required init?(coder: NSCoder) {
preconditionFailure("Use init() instead.")
}
// MARK: General
private func getTitle() -> String {
if let thread = thread as? TSGroupThread {
return thread.groupModel.groupName!
}
else if thread.isNoteToSelf() {
return "Note to Self"
}
else {
let sessionID = (thread as! TSContactThread).contactSessionID()
var result = sessionID
Storage.read { transaction in
let displayName: String = ((Storage.shared.getContact(with: sessionID)?.displayName(for: .regular)) ?? sessionID)
let middleTruncatedHexKey: String = "\(sessionID.prefix(4))...\(sessionID.suffix(4))"
result = (displayName == sessionID ? middleTruncatedHexKey : displayName)
}
return result
}
// 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)
)
}
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 {
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
}
// 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")
}
}
}
return nil
}
imageAttachment.bounds = CGRect(
x: 0,
y: -2,
width: Values.smallFontSize,
height: Values.smallFontSize
)
// MARK: Interaction
@objc private func handleTap() {
delegate?.handleTitleViewTapped()
}
return NSAttributedString(attachment: imageAttachment)
.appending(string: " ")
.appending(string: "view_conversation_title_notify_for_mentions_only".localized())
}
guard let userCount: Int = userCount else { return nil }
// MARK: Delegate
protocol ConversationTitleViewDelegate : AnyObject {
return NSAttributedString(string: "\(userCount) member\(userCount == 1 ? "" : "s")")
}()
func handleTitleViewTapped()
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
)
}
}

View File

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

View File

@ -0,0 +1,80 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
/// This custom UITableView gives us two convenience behaviours:
///
/// 1. It allows us to lock the contentOffset to a specific value - it's currently used to prevent the ConversationVC first
/// responder resignation from making the MediaGalleryDetailViewController transition from looking buggy (ie. the table
/// scrolls down with the resignation during the transition)
///
/// 2. It allows us to provode a callback which gets triggered if a condition closure returns true - it's currently used to prevent
/// the table view from jumping when inserting new pages at the top of a conversation screen
public class InsetLockableTableView: UITableView {
public var lockContentOffset: Bool = false {
didSet {
guard !lockContentOffset else { return }
self.contentOffset = newOffset
}
}
public var oldOffset: CGPoint = .zero
public var newOffset: CGPoint = .zero
private var callbackCondition: ((Int, [Int], CGSize) -> Bool)?
private var afterLayoutSubviewsCallback: (() -> ())?
public override func layoutSubviews() {
self.newOffset = self.contentOffset
// Store the callback locally to prevent infinite loops
var callback: (() -> ())?
if self.checkCallbackCondition() {
callback = self.afterLayoutSubviewsCallback
self.afterLayoutSubviewsCallback = nil
}
guard !lockContentOffset else {
self.contentOffset = CGPoint(
x: newOffset.x,
y: oldOffset.y
)
super.layoutSubviews()
callback?()
return
}
super.layoutSubviews()
callback?()
self.oldOffset = self.contentOffset
}
// MARK: - Functions
public func afterNextLayoutSubviews(
when condition: @escaping (Int, [Int], CGSize) -> Bool,
then callback: @escaping () -> ()
) {
self.callbackCondition = condition
self.afterLayoutSubviewsCallback = callback
}
private func checkCallbackCondition() -> Bool {
guard self.callbackCondition != nil else { return false }
let numSections: Int = self.numberOfSections
let numRowInSections: [Int] = (0..<numSections)
.map { self.numberOfRows(inSection: $0) }
// Store the layout info locally so if they pass we can clear the states before running to
// prevent layouts within the callbacks from triggering infinite loops
guard self.callbackCondition?(numSections, numRowInSections, self.contentSize) == true else {
return false
}
self.callbackCondition = nil
return true
}
}

View File

@ -1,12 +1,20 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
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
.writeAsync { db in
OpenGroupManager.shared.add(
db,
roomToken: room,
server: server,
publicKey: publicKey,
isConfigMessage: false
)
}
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)
.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()
}
}

View File

@ -1,8 +1,15 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
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()
}

View File

@ -1,24 +0,0 @@
final class MessagesTableView : UITableView {
override init(frame: CGRect, style: UITableView.Style) {
super.init(frame: frame, style: style)
initialize()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
initialize()
}
private func initialize() {
register(VisibleMessageCell.self, forCellReuseIdentifier: VisibleMessageCell.identifier)
register(InfoMessageCell.self, forCellReuseIdentifier: InfoMessageCell.identifier)
register(TypingIndicatorCell.self, forCellReuseIdentifier: TypingIndicatorCell.identifier)
register(CallMessageCell.self, forCellReuseIdentifier: CallMessageCell.identifier)
separatorStyle = .none
backgroundColor = .clear
showsVerticalScrollIndicator = false
contentInsetAdjustmentBehavior = .never
keyboardDismissMode = .interactive
}
}

View File

@ -1,12 +1,17 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
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()
}

View File

@ -1,8 +1,13 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
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)
})

View File

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

View File

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

View File

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

View File

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

View File

@ -1,32 +0,0 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
extension Storage{
private static let recentSearchResultDatabaseCollection = "RecentSearchResultDatabaseCollection"
private static let recentSearchResultKey = "RecentSearchResult"
public func getRecentSearchResults() -> [String] {
var result: [String]?
Storage.read { transaction in
result = transaction.object(forKey: Storage.recentSearchResultKey, inCollection: Storage.recentSearchResultDatabaseCollection) as? [String]
}
return result ?? []
}
public func clearRecentSearchResults() {
Storage.write { transaction in
transaction.removeObject(forKey: Storage.recentSearchResultKey, inCollection: Storage.recentSearchResultDatabaseCollection)
}
}
public func addSearchResults(threadID: String) -> [String] {
var recentSearchResults = getRecentSearchResults()
if recentSearchResults.count > 20 { recentSearchResults.remove(at: 0) } // Limit the size of the collection to 20
if let index = recentSearchResults.firstIndex(of: threadID) { recentSearchResults.remove(at: index) }
recentSearchResults.append(threadID)
Storage.write { transaction in
transaction.setObject(recentSearchResults, forKey: Storage.recentSearchResultKey, inCollection: Storage.recentSearchResultDatabaseCollection)
}
return recentSearchResults
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,302 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import DifferenceKit
import SignalUtilitiesKit
import SessionUtilitiesKit
public class HomeViewModel {
public typealias SectionModel = ArraySection<Section, SessionThreadViewModel>
// MARK: - Section
public enum Section: Differentiable {
case messageRequests
case threads
case loadMore
}
// MARK: - Variables
public static let pageSize: Int = 15
public struct State: Equatable {
let showViewedSeedBanner: Bool
let hasHiddenMessageRequests: Bool
let unreadMessageRequestThreadCount: Int
let userProfile: Profile?
init(
showViewedSeedBanner: Bool = !Storage.shared[.hasViewedSeed],
hasHiddenMessageRequests: Bool = Storage.shared[.hasHiddenMessageRequests],
unreadMessageRequestThreadCount: Int = 0,
userProfile: Profile? = nil
) {
self.showViewedSeedBanner = showViewedSeedBanner
self.hasHiddenMessageRequests = hasHiddenMessageRequests
self.unreadMessageRequestThreadCount = unreadMessageRequestThreadCount
self.userProfile = userProfile
}
}
// MARK: - Initialization
init() {
self.state = Storage.shared.read { db in try HomeViewModel.retrieveState(db) }
.defaulting(to: State())
self.pagedDataObserver = nil
// Note: Since this references self we need to finish initializing before setting it, we
// also want to skip the initial query and trigger it async so that the push animation
// doesn't stutter (it should load basically immediately but without this there is a
// distinct stutter)
let userPublicKey: String = getUserHexEncodedPublicKey()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
self.pagedDataObserver = PagedDatabaseObserver(
pagedTable: SessionThread.self,
pageSize: HomeViewModel.pageSize,
idColumn: .id,
observedChanges: [
PagedData.ObservedChanges(
table: SessionThread.self,
columns: [
.id,
.shouldBeVisible,
.isPinned,
.mutedUntilTimestamp,
.onlyNotifyForMentions
]
),
PagedData.ObservedChanges(
table: Interaction.self,
columns: [
.body,
.wasRead
],
joinToPagedType: {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: Contact.self,
columns: [.isBlocked],
joinToPagedType: {
let contact: TypedTableAlias<Contact> = TypedTableAlias()
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: Profile.self,
columns: [.name, .nickname, .profilePictureFileName],
joinToPagedType: {
let profile: TypedTableAlias<Profile> = TypedTableAlias()
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: ClosedGroup.self,
columns: [.name],
joinToPagedType: {
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
return SQL("LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: OpenGroup.self,
columns: [.name, .imageData],
joinToPagedType: {
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
return SQL("LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: RecipientState.self,
columns: [.state],
joinToPagedType: {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
return """
LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
"""
}()
),
PagedData.ObservedChanges(
table: ThreadTypingIndicator.self,
columns: [.threadId],
joinToPagedType: {
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
return SQL("LEFT JOIN \(typingIndicator[.threadId]) = \(thread[.id])")
}()
)
],
/// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query
joinSQL: SessionThreadViewModel.optimisedJoinSQL,
filterSQL: SessionThreadViewModel.homeFilterSQL(userPublicKey: userPublicKey),
groupSQL: SessionThreadViewModel.groupSQL,
orderSQL: SessionThreadViewModel.homeOrderSQL,
dataQuery: SessionThreadViewModel.baseQuery(
userPublicKey: userPublicKey,
filterSQL: SessionThreadViewModel.homeFilterSQL(userPublicKey: userPublicKey),
groupSQL: SessionThreadViewModel.groupSQL,
orderSQL: SessionThreadViewModel.homeOrderSQL
),
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
guard let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
return
}
// If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes
// to be sent to the callback if we ever start observing again (when we have the callback it needs
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
// correct order)
guard let onThreadChange: (([SectionModel]) -> ()) = self?.onThreadChange else {
self?.unobservedThreadDataChanges = updatedThreadData
return
}
onThreadChange(updatedThreadData)
}
)
// Run the initial query on the main thread so we prevent the app from leaving the loading screen
// until we have data (Note: the `.pageBefore` will query from a `0` offset loading the first page)
self.pagedDataObserver?.load(.pageBefore)
}
// MARK: - State
/// This value is the current state of the view
public private(set) var state: State
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
///
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
public lazy var observableState = ValueObservation
.trackingConstantRegion { db -> State in try HomeViewModel.retrieveState(db) }
.removeDuplicates()
private static func retrieveState(_ db: Database) throws -> State {
let hasViewedSeed: Bool = db[.hasViewedSeed]
let hasHiddenMessageRequests: Bool = db[.hasHiddenMessageRequests]
let userProfile: Profile = Profile.fetchOrCreateCurrentUser(db)
let unreadMessageRequestThreadCount: Int = try SessionThread
.unreadMessageRequestsThreadIdQuery(userPublicKey: userProfile.id)
.fetchCount(db)
return State(
showViewedSeedBanner: !hasViewedSeed,
hasHiddenMessageRequests: hasHiddenMessageRequests,
unreadMessageRequestThreadCount: unreadMessageRequestThreadCount,
userProfile: userProfile
)
}
public func updateState(_ updatedState: State) {
let oldState: State = self.state
self.state = updatedState
// If the messageRequest content changed then we need to re-process the thread data
guard
(
oldState.hasHiddenMessageRequests != updatedState.hasHiddenMessageRequests ||
oldState.unreadMessageRequestThreadCount != updatedState.unreadMessageRequestThreadCount
),
let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo.wrappedValue
else { return }
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
let currentData: [SessionThreadViewModel] = self.threadData.flatMap { $0.elements }
let updatedThreadData: [SectionModel] = self.process(data: currentData, for: currentPageInfo)
guard let onThreadChange: (([SectionModel]) -> ()) = self.onThreadChange else {
self.unobservedThreadDataChanges = updatedThreadData
return
}
onThreadChange(updatedThreadData)
}
// MARK: - Thread Data
public private(set) var unobservedThreadDataChanges: [SectionModel]?
public private(set) var threadData: [SectionModel] = []
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
public var onThreadChange: (([SectionModel]) -> ())? {
didSet {
// When starting to observe interaction changes we want to trigger a UI update just in case the
// data was changed while we weren't observing
if let unobservedThreadDataChanges: [SectionModel] = self.unobservedThreadDataChanges {
onThreadChange?(unobservedThreadDataChanges)
self.unobservedThreadDataChanges = nil
}
}
}
private func process(data: [SessionThreadViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
let finalUnreadMessageRequestCount: Int = (self.state.hasHiddenMessageRequests ?
0 :
self.state.unreadMessageRequestThreadCount
)
let groupedOldData: [String: [SessionThreadViewModel]] = (self.threadData
.first(where: { $0.model == .threads })?
.elements)
.defaulting(to: [])
.grouped(by: \.threadId)
return [
// If there are no unread message requests then hide the message request banner
(finalUnreadMessageRequestCount == 0 ?
[] :
[SectionModel(
section: .messageRequests,
elements: [
SessionThreadViewModel(unreadCount: UInt(finalUnreadMessageRequestCount))
]
)]
),
[
SectionModel(
section: .threads,
elements: data
.filter { $0.id != SessionThreadViewModel.invalidId }
.sorted { lhs, rhs -> Bool in
if lhs.threadIsPinned && !rhs.threadIsPinned { return true }
if !lhs.threadIsPinned && rhs.threadIsPinned { return false }
return lhs.lastInteractionDate > rhs.lastInteractionDate
}
.map { viewModel -> SessionThreadViewModel in
viewModel.populatingCurrentUserBlindedKey(
currentUserBlindedPublicKeyForThisThread: groupedOldData[viewModel.threadId]?
.first?
.currentUserBlindedPublicKey
)
}
)
],
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
[SectionModel(section: .loadMore)] :
[]
)
].flatMap { $0 }
}
public func updateThreadData(_ updatedData: [SectionModel]) {
self.threadData = updatedData
}
}

View File

@ -1,29 +1,37 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import GRDB
import DifferenceKit
import SessionUIKit
import SessionMessagingKit
import SignalUtilitiesKit
@objc
class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
private var threads: YapDatabaseViewMappings! = {
let result = YapDatabaseViewMappings(groups: [ TSMessageRequestGroup ], view: TSThreadDatabaseViewExtensionName)
result.setIsReversed(true, forGroup: TSMessageRequestGroup)
return result
}()
private var threadViewModelCache: [String: ThreadViewModel] = [:] // Thread ID to ThreadViewModel
private var tableViewTopConstraint: NSLayoutConstraint!
private static let loadingHeaderHeight: CGFloat = 20
private var messageRequestCount: UInt {
threads.numberOfItems(inGroup: TSMessageRequestGroup)
private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
private var dataChangeObservable: DatabaseCancellable?
private var hasLoadedInitialThreadData: Bool = false
private var isLoadingMore: Bool = false
private var isAutoLoadingNextPage: Bool = false
private var viewHasAppeared: Bool = false
// MARK: - Intialization
init() {
Storage.shared.addObserver(viewModel.pagedDataObserver)
super.init(nibName: nil, bundle: nil)
}
private lazy var dbConnection: YapDatabaseConnection = {
let result = OWSPrimaryStorage.shared().newDatabaseConnection()
result.objectCacheLimit = 500
required init?(coder: NSCoder) {
preconditionFailure("Use init() instead.")
}
return result
}()
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - UI
@ -32,8 +40,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
result.translatesAutoresizingMaskIntoConstraints = false
result.backgroundColor = .clear
result.separatorStyle = .none
result.register(MessageRequestsCell.self, forCellReuseIdentifier: MessageRequestsCell.reuseIdentifier)
result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier)
result.register(view: FullConversationCell.self)
result.dataSource = self
result.delegate = self
@ -41,6 +48,10 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
result.showsVerticalScrollIndicator = false
if #available(iOS 15.0, *) {
result.sectionHeaderTopPadding = 0
}
return result
}()
@ -87,7 +98,11 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
override func viewDidLoad() {
super.viewDidLoad()
ViewControllerUtilities.setUpDefaultSessionStyle(for: self, title: NSLocalizedString("MESSAGE_REQUESTS_TITLE", comment: ""), hasCustomBackButton: false)
ViewControllerUtilities.setUpDefaultSessionStyle(
for: self,
title: "MESSAGE_REQUESTS_TITLE".localized(),
hasCustomBackButton: false
)
// Add the UI (MUST be done after the thread freeze so the 'tableView' creation and setting
// the dataSource has the correct data)
@ -100,33 +115,44 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
// Notifications
NotificationCenter.default.addObserver(
self,
selector: #selector(handleYapDatabaseModifiedNotification(_:)),
name: .YapDatabaseModified,
object: OWSPrimaryStorage.shared().dbNotificationObject
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleProfileDidChangeNotification(_:)),
name: NSNotification.Name(rawValue: kNSNotificationName_OtherUsersProfileDidChange),
selector: #selector(applicationDidBecomeActive(_:)),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleBlockedContactsUpdatedNotification(_:)),
name: .blockedContactsUpdated,
object: nil
selector: #selector(applicationDidResignActive(_:)),
name: UIApplication.didEnterBackgroundNotification, object: nil
)
}
reload()
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
startObservingChanges()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
reload()
self.viewHasAppeared = true
self.autoLoadNextPageIfNeeded()
}
deinit {
NotificationCenter.default.removeObserver(self)
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// Stop observing database changes
dataChangeObservable?.cancel()
}
@objc func applicationDidBecomeActive(_ notification: Notification) {
startObservingChanges(didReturnFromBackground: true)
}
@objc func applicationDidResignActive(_ notification: Notification) {
// Stop observing database changes
dataChangeObservable?.cancel()
}
// MARK: - Layout
@ -158,111 +184,86 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
])
}
// MARK: - UITableViewDataSource
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return Int(messageRequestCount)
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
cell.threadViewModel = threadViewModel(at: indexPath.row)
return cell
}
// MARK: - Updating
private func reload() {
AssertIsOnMainThread()
dbConnection.beginLongLivedReadTransaction() // Jump to the latest commit
dbConnection.read { transaction in
self.threads.update(with: transaction)
}
threadViewModelCache.removeAll()
tableView.reloadData()
clearAllButton.isHidden = (messageRequestCount == 0)
emptyStateLabel.isHidden = (messageRequestCount != 0)
private func startObservingChanges(didReturnFromBackground: Bool = false) {
self.viewModel.onThreadChange = { [weak self] updatedThreadData in
self?.handleThreadUpdates(updatedThreadData)
}
@objc private func handleYapDatabaseModifiedNotification(_ yapDatabase: YapDatabase) {
// NOTE: This code is very finicky and crashes easily. Modify with care.
AssertIsOnMainThread()
// If we don't capture `threads` here, a race condition can occur where the
// `thread.snapshotOfLastUpdate != firstSnapshot - 1` check below evaluates to
// `false`, but `threads` then changes between that check and the
// `ext.getSectionChanges(&sectionChanges, rowChanges: &rowChanges, for: notifications, with: threads)`
// line. This causes `tableView.endUpdates()` to crash with an `NSInternalInconsistencyException`.
let threads = threads!
// Create a stable state for the connection and jump to the latest commit
let notifications = dbConnection.beginLongLivedReadTransaction()
guard !notifications.isEmpty else { return }
let ext = dbConnection.ext(TSThreadDatabaseViewExtensionName) as! YapDatabaseViewConnection
let hasChanges = ext.hasChanges(forGroup: TSMessageRequestGroup, in: notifications)
guard hasChanges else { return }
if let firstChangeSet = notifications[0].userInfo {
let firstSnapshot = firstChangeSet[YapDatabaseSnapshotKey] as! UInt64
if threads.snapshotOfLastUpdate != firstSnapshot - 1 {
return reload() // The code below will crash if we try to process multiple commits at once
// Note: When returning from the background we could have received notifications but the
// PagedDatabaseObserver won't have them so we need to force a re-fetch of the current
// data to ensure everything is up to date
if didReturnFromBackground {
self.viewModel.pagedDataObserver?.reload()
}
}
var sectionChanges = NSArray()
var rowChanges = NSArray()
ext.getSectionChanges(&sectionChanges, 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
switch rowChange.type {
case .move: tableView.moveRow(at: rowChange.indexPath!, to: rowChange.newIndexPath!)
default: break
}
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
}
tableView.endUpdates()
clearAllButton.isHidden = (messageRequestCount == 0)
emptyStateLabel.isHidden = (messageRequestCount != 0)
// Show the empty state if there is no data
clearAllButton.isHidden = updatedData.isEmpty
emptyStateLabel.isHidden = !updatedData.isEmpty
CATransaction.begin()
CATransaction.setCompletionBlock { [weak self] in
// Complete page loading
self?.isLoadingMore = false
self?.autoLoadNextPageIfNeeded()
}
@objc private func handleProfileDidChangeNotification(_ notification: Notification) {
tableView.reloadData() // TODO: Just reload the affected cell
// 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)
}
@objc private func handleBlockedContactsUpdatedNotification(_ notification: Notification) {
tableView.reloadData() // TODO: Just reload the affected cell
CATransaction.commit()
}
private func autoLoadNextPageIfNeeded() {
guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return }
self.isAutoLoadingNextPage = true
DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in
self?.isAutoLoadingNextPage = false
// Note: We sort the headers as we want to prioritise loading newer pages over older ones
let sections: [(MessageRequestsViewModel.Section, CGRect)] = (self?.viewModel.threadData
.enumerated()
.map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) })
.defaulting(to: [])
let shouldLoadMore: Bool = sections
.contains { section, headerRect in
section == .loadMore &&
headerRect != .zero &&
(self?.tableView.bounds.contains(headerRect) == true)
}
guard shouldLoadMore else { return }
self?.isLoadingMore = true
DispatchQueue.global(qos: .default).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(.pageAfter)
}
}
}
@objc override internal func handleAppModeChangedNotification(_ notification: Notification) {
@ -273,15 +274,96 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
tableView.reloadData()
}
// MARK: - UITableViewDataSource
func numberOfSections(in tableView: UITableView) -> Int {
return viewModel.threadData.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
return section.elements.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[indexPath.section]
switch section.model {
case .threads:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
cell.update(with: threadViewModel)
return cell
default: preconditionFailure("Other sections should have no content")
}
}
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
switch section.model {
case .loadMore:
let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
loadingIndicator.tintColor = Colors.text
loadingIndicator.alpha = 0.5
loadingIndicator.startAnimating()
let view: UIView = UIView()
view.addSubview(loadingIndicator)
loadingIndicator.center(in: view)
return view
default: return nil
}
}
// MARK: - UITableViewDelegate
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section]
switch section.model {
case .loadMore: return MessageRequestsViewController.loadingHeaderHeight
default: return 0
}
}
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
guard self.hasLoadedInitialThreadData && self.viewHasAppeared && !self.isLoadingMore else { return }
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[section]
switch section.model {
case .loadMore:
self.isLoadingMore = true
DispatchQueue.global(qos: .default).async { [weak self] in
self?.viewModel.pagedDataObserver?.load(.pageAfter)
}
default: break
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let thread = self.thread(at: indexPath.row) else { return }
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
let conversationVC = ConversationVC(thread: thread)
switch section.model {
case .threads:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
let conversationVC: ConversationVC = ConversationVC(
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant
)
self.navigationController?.pushViewController(conversationVC, animated: true)
default: break
}
}
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
@ -289,146 +371,98 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
}
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
guard let thread = self.thread(at: indexPath.row) else { return [] }
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
let delete = UITableViewRowAction(style: .destructive, title: NSLocalizedString("TXT_DELETE_TITLE", comment: "")) { [weak self] _, _ in
self?.delete(thread)
switch section.model {
case .threads:
let threadId: String = section.elements[indexPath.row].threadId
let delete = UITableViewRowAction(
style: .destructive,
title: "TXT_DELETE_TITLE".localized()
) { [weak self] _, _ in
self?.delete(threadId)
}
delete.backgroundColor = Colors.destructive
return [ delete ]
default: return []
}
}
// MARK: - Interaction
private func updateContactAndThread(thread: TSThread, with transaction: YapDatabaseReadWriteTransaction, onComplete: ((Bool) -> ())? = nil) {
guard let contactThread: TSContactThread = thread as? TSContactThread else {
onComplete?(false)
@objc private func clearAllTapped() {
guard viewModel.threadData.first(where: { $0.model == .threads })?.elements.isEmpty == false else {
return
}
var needsSync: Bool = false
// Update the contact
let sessionId: String = contactThread.contactSessionID()
if let contact: Contact = Storage.shared.getContact(with: sessionId), (contact.isApproved || !contact.isBlocked) {
contact.isApproved = false
contact.isBlocked = true
Storage.shared.setContact(contact, using: transaction)
needsSync = true
}
// Delete all thread content
thread.removeAllThreadInteractions(with: transaction)
thread.remove(with: transaction)
onComplete?(needsSync)
}
@objc private func clearAllTapped() {
let threadCount: Int = Int(messageRequestCount)
let threads: [TSThread] = (0..<threadCount).compactMap { self.thread(at: $0) }
var needsSync: Bool = false
let alertVC: UIAlertController = UIAlertController(title: NSLocalizedString("MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE", comment: ""), message: nil, preferredStyle: .actionSheet)
alertVC.addAction(UIAlertAction(title: NSLocalizedString("MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON", comment: ""), style: .destructive) { _ in
let threadIds: [String] = (viewModel.threadData
.first { $0.model == .threads }?
.elements
.map { $0.threadId })
.defaulting(to: [])
let alertVC: UIAlertController = UIAlertController(
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE".localized(),
message: nil,
preferredStyle: .actionSheet
)
alertVC.addAction(UIAlertAction(
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON".localized(),
style: .destructive
) { _ in
// Clear the requests
Storage.write(
with: { [weak self] transaction in
threads.forEach { thread in
if let uniqueId: String = thread.uniqueId {
Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction)
}
Storage.shared.write { db in
_ = try SessionThread
.filter(ids: threadIds)
.deleteAll(db)
self?.updateContactAndThread(thread: thread, with: transaction) { threadNeedsSync in
if threadNeedsSync {
needsSync = true
}
}
// Block the contact
if
let sessionId: String = (thread as? TSContactThread)?.contactSessionID(),
!thread.isBlocked(),
let contact: Contact = Storage.shared.getContact(with: sessionId, using: transaction)
{
contact.isBlocked = true
Storage.shared.setContact(contact, using: transaction)
needsSync = true
}
}
},
completion: {
// Force a config sync
if needsSync {
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
}
}
try threadIds.forEach { threadId in
_ = try Contact
.fetchOrCreate(db, id: threadId)
.with(
isApproved: false,
isBlocked: true
)
.saved(db)
}
// Force a config sync
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
}
})
alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .cancel, handler: nil))
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), 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()
}
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: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .cancel, handler: nil))
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
self.present(alertVC, animated: true, completion: nil)
}
// MARK: - Convenience
private func thread(at index: Int) -> TSThread? {
var thread: TSThread? = nil
dbConnection.read { transaction in
let ext: YapDatabaseViewTransaction? = transaction.ext(TSThreadDatabaseViewExtensionName) as? YapDatabaseViewTransaction
thread = ext?.object(atRow: UInt(index), inSection: 0, with: self.threads) as? TSThread
}
return thread
}
private func threadViewModel(at index: Int) -> ThreadViewModel? {
guard let thread = thread(at: index), let uniqueId: String = thread.uniqueId else { return nil }
if let cachedThreadViewModel = threadViewModelCache[uniqueId] {
return cachedThreadViewModel
}
else {
var threadViewModel: ThreadViewModel? = nil
dbConnection.read { transaction in
threadViewModel = ThreadViewModel(thread: thread, transaction: transaction)
}
threadViewModelCache[uniqueId] = threadViewModel
return threadViewModel
}
}
}

View File

@ -0,0 +1,174 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import DifferenceKit
import SignalUtilitiesKit
public class MessageRequestsViewModel {
public typealias SectionModel = ArraySection<Section, SessionThreadViewModel>
// MARK: - Section
public enum Section: Differentiable {
case threads
case loadMore
}
// MARK: - Variables
public static let pageSize: Int = 20
// MARK: - Initialization
init() {
self.pagedDataObserver = nil
// Note: Since this references self we need to finish initializing before setting it, we
// also want to skip the initial query and trigger it async so that the push animation
// doesn't stutter (it should load basically immediately but without this there is a
// distinct stutter)
let userPublicKey: String = getUserHexEncodedPublicKey()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
self.pagedDataObserver = PagedDatabaseObserver(
pagedTable: SessionThread.self,
pageSize: MessageRequestsViewModel.pageSize,
idColumn: .id,
observedChanges: [
PagedData.ObservedChanges(
table: SessionThread.self,
columns: [
.id,
.shouldBeVisible
]
),
PagedData.ObservedChanges(
table: Interaction.self,
columns: [
.body,
.wasRead
],
joinToPagedType: {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: Contact.self,
columns: [.isBlocked],
joinToPagedType: {
let contact: TypedTableAlias<Contact> = TypedTableAlias()
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: Profile.self,
columns: [.name, .nickname, .profilePictureFileName],
joinToPagedType: {
let profile: TypedTableAlias<Profile> = TypedTableAlias()
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
table: RecipientState.self,
columns: [.state],
joinToPagedType: {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
return """
LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
"""
}()
)
],
/// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query
joinSQL: SessionThreadViewModel.optimisedJoinSQL,
filterSQL: SessionThreadViewModel.messageRequestsFilterSQL(userPublicKey: userPublicKey),
groupSQL: SessionThreadViewModel.groupSQL,
orderSQL: SessionThreadViewModel.messageRequetsOrderSQL,
dataQuery: SessionThreadViewModel.baseQuery(
userPublicKey: userPublicKey,
filterSQL: SessionThreadViewModel.messageRequestsFilterSQL(userPublicKey: userPublicKey),
groupSQL: SessionThreadViewModel.groupSQL,
orderSQL: SessionThreadViewModel.messageRequetsOrderSQL
),
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
guard let updatedThreadData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
return
}
// If we have the 'onThreadChange' callback then trigger it, otherwise just store the changes
// to be sent to the callback if we ever start observing again (when we have the callback it needs
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
// correct order)
guard let onThreadChange: (([SectionModel]) -> ()) = self?.onThreadChange else {
self?.unobservedThreadDataChanges = updatedThreadData
return
}
onThreadChange(updatedThreadData)
}
)
// Run the initial query on a background thread so we don't block the push transition
DispatchQueue.global(qos: .default).async { [weak self] in
// The `.pageBefore` will query from a `0` offset loading the first page
self?.pagedDataObserver?.load(.pageBefore)
}
}
// MARK: - Thread Data
public private(set) var unobservedThreadDataChanges: [SectionModel]?
public private(set) var threadData: [SectionModel] = []
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
public var onThreadChange: (([SectionModel]) -> ())? {
didSet {
// When starting to observe interaction changes we want to trigger a UI update just in case the
// data was changed while we weren't observing
if let unobservedThreadDataChanges: [SectionModel] = self.unobservedThreadDataChanges {
onThreadChange?(unobservedThreadDataChanges)
self.unobservedThreadDataChanges = nil
}
}
}
private func process(data: [SessionThreadViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
let groupedOldData: [String: [SessionThreadViewModel]] = (self.threadData
.first(where: { $0.model == .threads })?
.elements)
.defaulting(to: [])
.grouped(by: \.threadId)
return [
[
SectionModel(
section: .threads,
elements: data
.sorted { lhs, rhs -> Bool in lhs.lastInteractionDate > rhs.lastInteractionDate }
.map { viewModel -> SessionThreadViewModel in
viewModel.populatingCurrentUserBlindedKey(
currentUserBlindedPublicKeyForThisThread: groupedOldData[viewModel.threadId]?
.first?
.currentUserBlindedPublicKey
)
}
)
],
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
[SectionModel(section: .loadMore)] :
[]
)
].flatMap { $0 }
}
public func updateThreadData(_ updatedData: [SectionModel]) {
self.threadData = updatedData
}
}

View File

@ -60,7 +60,7 @@ class MessageRequestsCell: UITableViewCell {
result.translatesAutoresizingMaskIntoConstraints = false
result.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),

View File

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

View File

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

View File

@ -5,6 +5,7 @@
import Foundation
import Photos
import PromiseKit
import SessionUIKit
protocol ImagePickerGridControllerDelegate: AnyObject {
func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController)
@ -47,6 +48,8 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = Colors.navigationBarBackground
library.add(delegate: self)
guard let collectionView = collectionView else {
@ -54,12 +57,11 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
return
}
collectionView.register(PhotoGridViewCell.self, forCellWithReuseIdentifier: PhotoGridViewCell.reuseIdentifier)
collectionView.register(view: PhotoGridViewCell.self)
// ensure images at the end of the list can be scrolled above the bottom buttons
let bottomButtonInset = -1 * SendMediaNavigationController.bottomButtonsCenterOffset + SendMediaNavigationController.bottomButtonWidth / 2 + 16
collectionView.contentInset.bottom = bottomButtonInset + 16
view.backgroundColor = .white
// The PhotoCaptureVC needs a shadow behind it's cancel button, so we use a custom icon.
// This VC has a visible navbar so doesn't need the shadow, but because the user can
@ -69,24 +71,16 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
let cancelImage = UIImage(imageLiteralResourceName: "X")
let cancelButton = UIBarButtonItem(image: cancelImage, style: .plain, target: self, action: #selector(didPressCancel))
cancelButton.tintColor = .black
cancelButton.tintColor = Colors.text
navigationItem.leftBarButtonItem = cancelButton
let titleView = TitleView()
titleView.delegate = self
titleView.text = photoCollection.localizedTitle()
if #available(iOS 11, *) {
// do nothing
} else {
// must assign titleView frame manually on older iOS
titleView.frame = CGRect(origin: .zero, size: titleView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize))
}
navigationItem.titleView = titleView
self.titleView = titleView
collectionView.backgroundColor = .white
collectionView.backgroundColor = Colors.navigationBarBackground
let selectionPanGesture = DirectionalPanGestureRecognizer(direction: [.horizontal], target: self, action: #selector(didPanSelection))
selectionPanGesture.delegate = self
@ -200,16 +194,15 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Loki: Set navigation bar background color
let navigationBar = navigationController!.navigationBar
navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default)
navigationBar.shadowImage = UIImage()
navigationBar.isTranslucent = false
navigationBar.barTintColor = .white
(navigationBar as! OWSNavigationBar).respectsTheme = false
navigationBar.backgroundColor = .white
let backgroundImage = UIImage(color: .white)
navigationBar.setBackgroundImage(backgroundImage, for: .default)
let backgroundImage: UIImage = UIImage(color: Colors.navigationBarBackground)
self.navigationItem.title = nil
self.navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default)
self.navigationController?.navigationBar.shadowImage = UIImage()
self.navigationController?.navigationBar.isTranslucent = false
self.navigationController?.navigationBar.barTintColor = Colors.navigationBarBackground
(self.navigationController?.navigationBar as? OWSNavigationBar)?.respectsTheme = true
self.navigationController?.navigationBar.backgroundColor = Colors.navigationBarBackground
self.navigationController?.navigationBar.setBackgroundImage(backgroundImage, for: .default)
// Determine the size of the thumbnails to request
let scale = UIScreen.main.scale
@ -268,11 +261,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
// MARK:
var lastPageYOffset: CGFloat {
var yOffset = collectionView.contentSize.height - collectionView.bounds.height + collectionView.adjustedContentInset.bottom
if #available(iOS 11.0, *) {
yOffset += view.safeAreaInsets.bottom
}
return yOffset
return (collectionView.contentSize.height - collectionView.bounds.height + collectionView.adjustedContentInset.bottom + view.safeAreaInsets.bottom)
}
func scrollToBottom(animated: Bool) {
@ -343,10 +332,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
static let kInterItemSpacing: CGFloat = 2
private class func buildLayout() -> UICollectionViewFlowLayout {
let layout = UICollectionViewFlowLayout()
if #available(iOS 11, *) {
layout.sectionInsetReference = .fromSafeArea
}
layout.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)))

View File

@ -1,54 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import <SignalUtilitiesKit/OWSViewController.h>
NS_ASSUME_NONNULL_BEGIN
@protocol ConversationViewItem;
@class GalleryItemBox;
@class MediaDetailViewController;
@class TSAttachment;
typedef NS_OPTIONS(NSInteger, MediaGalleryOption) {
MediaGalleryOptionSliderEnabled = 1 << 0,
MediaGalleryOptionShowAllMediaButton = 1 << 1
};
@protocol MediaDetailViewControllerDelegate <NSObject>
- (void)mediaDetailViewController:(MediaDetailViewController *)mediaDetailViewController
requestDeleteAttachment:(TSAttachment *)attachment;
- (void)mediaDetailViewController:(MediaDetailViewController *)mediaDetailViewController
isPlayingVideo:(BOOL)isPlayingVideo;
- (void)mediaDetailViewControllerDidTapMedia:(MediaDetailViewController *)mediaDetailViewController;
@end
@interface MediaDetailViewController : OWSViewController
@property (nonatomic, weak) id<MediaDetailViewControllerDelegate> delegate;
@property (nonatomic, readonly) GalleryItemBox *galleryItemBox;
// If viewItem is non-null, long press will show a menu controller.
- (instancetype)initWithGalleryItemBox:(GalleryItemBox *)galleryItemBox
viewItem:(nullable id<ConversationViewItem>)viewItem;
#pragma mark - Actions
- (void)didPressPlayBarButton:(id)sender;
- (void)didPressPauseBarButton:(id)sender;
- (void)playVideo;
// Stops playback and rewinds
- (void)stopAnyVideo;
- (void)setShouldHideToolbars:(BOOL)shouldHideToolbars;
- (void)zoomOutAnimated:(BOOL)isAnimated;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,500 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "MediaDetailViewController.h"
#import "ConversationViewItem.h"
#import "Session-Swift.h"
#import "TSAttachmentStream.h"
#import "TSInteraction.h"
#import "UIColor+OWS.h"
#import "UIUtil.h"
#import "UIView+OWS.h"
#import <AVKit/AVKit.h>
#import <MediaPlayer/MPMoviePlayerViewController.h>
#import <MediaPlayer/MediaPlayer.h>
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
#import <SessionUtilitiesKit/NSData+Image.h>
#import <SessionUIKit/SessionUIKit.h>
#import <YYImage/YYImage.h>
NS_ASSUME_NONNULL_BEGIN
#pragma mark -
@interface MediaDetailViewController () <UIScrollViewDelegate,
UIGestureRecognizerDelegate,
PlayerProgressBarDelegate,
OWSVideoPlayerDelegate>
@property (nonatomic) UIScrollView *scrollView;
@property (nonatomic) UIView *mediaView;
@property (nonatomic) UIView *presentationView;
@property (nonatomic) UIView *replacingView;
@property (nonatomic) UIButton *shareButton;
@property (nonatomic) TSAttachmentStream *attachmentStream;
@property (nonatomic, nullable) id<ConversationViewItem> viewItem;
@property (nonatomic, nullable) UIImage *image;
@property (nonatomic, nullable) OWSVideoPlayer *videoPlayer;
@property (nonatomic, nullable) UIButton *playVideoButton;
@property (nonatomic, nullable) PlayerProgressBar *videoProgressBar;
@property (nonatomic, nullable) UIBarButtonItem *videoPlayBarButton;
@property (nonatomic, nullable) UIBarButtonItem *videoPauseBarButton;
@property (nonatomic, nullable) NSArray<NSLayoutConstraint *> *presentationViewConstraints;
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewBottomConstraint;
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewLeadingConstraint;
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewTopConstraint;
@property (nonatomic, nullable) NSLayoutConstraint *mediaViewTrailingConstraint;
@end
#pragma mark -
@implementation MediaDetailViewController
- (void)dealloc
{
[self stopAnyVideo];
}
- (instancetype)initWithGalleryItemBox:(GalleryItemBox *)galleryItemBox
viewItem:(nullable id<ConversationViewItem>)viewItem
{
self = [super initWithNibName:nil bundle:nil];
if (!self) {
return self;
}
_galleryItemBox = galleryItemBox;
_viewItem = viewItem;
// We cache the image data in case the attachment stream is deleted.
__weak MediaDetailViewController *weakSelf = self;
_image = [galleryItemBox.attachmentStream
thumbnailImageLargeWithSuccess:^(UIImage *image) {
weakSelf.image = image;
[weakSelf updateContents];
[weakSelf updateMinZoomScale];
}
failure:^{
OWSLogWarn(@"Could not load media.");
}];
return self;
}
- (TSAttachmentStream *)attachmentStream
{
return self.galleryItemBox.attachmentStream;
}
- (BOOL)isAnimated
{
return self.attachmentStream.isAnimated;
}
- (BOOL)isVideo
{
return self.attachmentStream.isVideo;
}
- (void)viewDidLoad
{
[super viewDidLoad];
self.view.backgroundColor = LKColors.navigationBarBackground;
[self updateContents];
// Loki: Set navigation bar background color
UINavigationBar *navigationBar = self.navigationController.navigationBar;
[navigationBar setBackgroundImage:[UIImage new] forBarMetrics:UIBarMetricsDefault];
navigationBar.shadowImage = [UIImage new];
[navigationBar setTranslucent:NO];
navigationBar.barTintColor = LKColors.navigationBarBackground;
}
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
[self resetMediaFrame];
}
- (void)viewDidLayoutSubviews
{
[super viewDidLayoutSubviews];
[self updateMinZoomScale];
[self centerMediaViewConstraints];
}
- (void)updateMinZoomScale
{
if (!self.image) {
self.scrollView.minimumZoomScale = 1.f;
self.scrollView.maximumZoomScale = 1.f;
self.scrollView.zoomScale = 1.f;
return;
}
CGSize viewSize = self.scrollView.bounds.size;
UIImage *image = self.image;
OWSAssertDebug(image);
if (image.size.width == 0 || image.size.height == 0) {
OWSFailDebug(@"Invalid image dimensions. %@", NSStringFromCGSize(image.size));
return;
}
CGFloat scaleWidth = viewSize.width / image.size.width;
CGFloat scaleHeight = viewSize.height / image.size.height;
CGFloat minScale = MIN(scaleWidth, scaleHeight);
if (minScale != self.scrollView.minimumZoomScale) {
self.scrollView.minimumZoomScale = minScale;
self.scrollView.maximumZoomScale = minScale * 8;
self.scrollView.zoomScale = minScale;
}
}
- (void)zoomOutAnimated:(BOOL)isAnimated
{
if (self.scrollView.zoomScale != self.scrollView.minimumZoomScale) {
[self.scrollView setZoomScale:self.scrollView.minimumZoomScale animated:isAnimated];
}
}
#pragma mark - Initializers
- (void)updateContents
{
[self.mediaView removeFromSuperview];
[self.scrollView removeFromSuperview];
[self.playVideoButton removeFromSuperview];
[self.videoProgressBar removeFromSuperview];
UIScrollView *scrollView = [UIScrollView new];
[self.view addSubview:scrollView];
self.scrollView = scrollView;
scrollView.delegate = self;
scrollView.showsVerticalScrollIndicator = NO;
scrollView.showsHorizontalScrollIndicator = NO;
scrollView.decelerationRate = UIScrollViewDecelerationRateFast;
if (@available(iOS 11.0, *)) {
[scrollView contentInsetAdjustmentBehavior];
} else {
self.automaticallyAdjustsScrollViewInsets = NO;
}
[scrollView ows_autoPinToSuperviewEdges];
if (self.isAnimated) {
if (self.attachmentStream.isValidImage) {
YYImage *animatedGif = [YYImage imageWithContentsOfFile:self.attachmentStream.originalFilePath];
YYAnimatedImageView *animatedView = [YYAnimatedImageView new];
animatedView.image = animatedGif;
self.mediaView = animatedView;
} else {
self.mediaView = [UIView new];
self.mediaView.backgroundColor = LKColors.unimportant;
}
} else if (!self.image) {
// Still loading thumbnail.
self.mediaView = [UIView new];
self.mediaView.backgroundColor = LKColors.unimportant;
} else if (self.isVideo) {
if (self.attachmentStream.isValidVideo) {
self.mediaView = [self buildVideoPlayerView];
} else {
self.mediaView = [UIView new];
self.mediaView.backgroundColor = LKColors.unimportant;
}
} else {
// Present the static image using standard UIImageView
UIImageView *imageView = [[UIImageView alloc] initWithImage:self.image];
self.mediaView = imageView;
}
OWSAssertDebug(self.mediaView);
// We add these gestures to mediaView rather than
// the root view so that interacting with the video player
// progres bar doesn't trigger any of these gestures.
[self addGestureRecognizersToView:self.mediaView];
[scrollView addSubview:self.mediaView];
self.mediaViewLeadingConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeLeading];
self.mediaViewTopConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTop];
self.mediaViewTrailingConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeTrailing];
self.mediaViewBottomConstraint = [self.mediaView autoPinEdgeToSuperviewEdge:ALEdgeBottom];
self.mediaView.contentMode = UIViewContentModeScaleAspectFit;
self.mediaView.userInteractionEnabled = YES;
self.mediaView.clipsToBounds = YES;
self.mediaView.layer.allowsEdgeAntialiasing = YES;
self.mediaView.translatesAutoresizingMaskIntoConstraints = NO;
// Use trilinear filters for better scaling quality at
// some performance cost.
self.mediaView.layer.minificationFilter = kCAFilterTrilinear;
self.mediaView.layer.magnificationFilter = kCAFilterTrilinear;
if (self.isVideo) {
PlayerProgressBar *videoProgressBar = [PlayerProgressBar new];
videoProgressBar.delegate = self;
videoProgressBar.player = self.videoPlayer.avPlayer;
// We hide the progress bar until either:
// 1. Video completes playing
// 2. User taps the screen
videoProgressBar.hidden = YES;
self.videoProgressBar = videoProgressBar;
[self.view addSubview:videoProgressBar];
[videoProgressBar autoPinWidthToSuperview];
[videoProgressBar autoPinEdgeToSuperviewSafeArea:ALEdgeTop];
CGFloat kVideoProgressBarHeight = 44;
[videoProgressBar autoSetDimension:ALDimensionHeight toSize:kVideoProgressBarHeight];
UIButton *playVideoButton = [UIButton new];
self.playVideoButton = playVideoButton;
[playVideoButton addTarget:self action:@selector(playVideo) forControlEvents:UIControlEventTouchUpInside];
UIImage *playImage = [UIImage imageNamed:@"CirclePlay"];
[playVideoButton setBackgroundImage:playImage forState:UIControlStateNormal];
playVideoButton.contentMode = UIViewContentModeScaleAspectFill;
[self.view addSubview:playVideoButton];
CGFloat playVideoButtonWidth = 72.f;
[playVideoButton autoSetDimensionsToSize:CGSizeMake(playVideoButtonWidth, playVideoButtonWidth)];
[playVideoButton autoCenterInSuperview];
}
}
- (UIView *)buildVideoPlayerView
{
NSURL *_Nullable attachmentUrl = self.attachmentStream.originalMediaURL;
NSFileManager *fileManager = [NSFileManager defaultManager];
if (![fileManager fileExistsAtPath:[attachmentUrl path]]) {
OWSFailDebug(@"Missing video file");
}
OWSVideoPlayer *player = [[OWSVideoPlayer alloc] initWithUrl:attachmentUrl];
[player seekToTime:kCMTimeZero];
player.delegate = self;
self.videoPlayer = player;
VideoPlayerView *playerView = [VideoPlayerView new];
playerView.player = player.avPlayer;
[NSLayoutConstraint autoSetPriority:UILayoutPriorityDefaultLow
forConstraints:^{
[playerView autoSetDimensionsToSize:self.image.size];
}];
return playerView;
}
- (void)setShouldHideToolbars:(BOOL)shouldHideToolbars
{
self.videoProgressBar.hidden = shouldHideToolbars;
}
- (void)addGestureRecognizersToView:(UIView *)view
{
UITapGestureRecognizer *doubleTap =
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didDoubleTapImage:)];
doubleTap.numberOfTapsRequired = 2;
[view addGestureRecognizer:doubleTap];
UITapGestureRecognizer *singleTap =
[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didSingleTapImage:)];
[singleTap requireGestureRecognizerToFail:doubleTap];
[view addGestureRecognizer:singleTap];
}
#pragma mark - Gesture Recognizers
- (void)didSingleTapImage:(UITapGestureRecognizer *)gesture
{
[self.delegate mediaDetailViewControllerDidTapMedia:self];
}
- (void)didDoubleTapImage:(UITapGestureRecognizer *)gesture
{
OWSLogVerbose(@"did double tap image.");
if (self.scrollView.zoomScale == self.scrollView.minimumZoomScale) {
CGFloat kDoubleTapZoomScale = 2;
CGFloat zoomWidth = self.scrollView.width / kDoubleTapZoomScale;
CGFloat zoomHeight = self.scrollView.height / kDoubleTapZoomScale;
// center zoom rect around tapLocation
CGPoint tapLocation = [gesture locationInView:self.scrollView];
CGFloat zoomX = MAX(0, tapLocation.x - zoomWidth / 2);
CGFloat zoomY = MAX(0, tapLocation.y - zoomHeight / 2);
CGRect zoomRect = CGRectMake(zoomX, zoomY, zoomWidth, zoomHeight);
CGRect translatedRect = [self.mediaView convertRect:zoomRect fromView:self.scrollView];
[self.scrollView zoomToRect:translatedRect animated:YES];
} else {
// If already zoomed in at all, zoom out all the way.
[self zoomOutAnimated:YES];
}
}
- (void)didPressPlayBarButton:(id)sender
{
OWSAssertDebug(self.isVideo);
OWSAssertDebug(self.videoPlayer);
[self playVideo];
}
- (void)didPressPauseBarButton:(id)sender
{
OWSAssertDebug(self.isVideo);
OWSAssertDebug(self.videoPlayer);
[self pauseVideo];
}
#pragma mark - UIScrollViewDelegate
- (nullable UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView
{
return self.mediaView;
}
- (void)centerMediaViewConstraints
{
OWSAssertDebug(self.scrollView);
CGSize scrollViewSize = self.scrollView.bounds.size;
CGSize imageViewSize = self.mediaView.frame.size;
CGFloat yOffset = MAX(0, (scrollViewSize.height - imageViewSize.height) / 2);
self.mediaViewTopConstraint.constant = yOffset;
self.mediaViewBottomConstraint.constant = yOffset;
CGFloat xOffset = MAX(0, (scrollViewSize.width - imageViewSize.width) / 2);
self.mediaViewLeadingConstraint.constant = xOffset;
self.mediaViewTrailingConstraint.constant = xOffset;
}
- (void)scrollViewDidZoom:(UIScrollView *)scrollView
{
[self centerMediaViewConstraints];
[self.view layoutIfNeeded];
}
- (void)resetMediaFrame
{
// HACK: Setting the frame to itself *seems* like it should be a no-op, but
// it ensures the content is drawn at the right frame. In particular I was
// reproducibly seeing some images squished (they were EXIF rotated, maybe
// related). similar to this report:
// https://stackoverflow.com/questions/27961884/swift-uiimageview-stretched-aspect
[self.view layoutIfNeeded];
self.mediaView.frame = self.mediaView.frame;
}
#pragma mark - Video Playback
- (void)playVideo
{
OWSAssertDebug(self.videoPlayer);
self.playVideoButton.hidden = YES;
[self.videoPlayer play];
[self.delegate mediaDetailViewController:self isPlayingVideo:YES];
}
- (void)pauseVideo
{
OWSAssertDebug(self.isVideo);
OWSAssertDebug(self.videoPlayer);
[self.videoPlayer pause];
[self.delegate mediaDetailViewController:self isPlayingVideo:NO];
}
- (void)stopAnyVideo
{
if (self.isVideo) {
[self stopVideo];
}
}
- (void)stopVideo
{
OWSAssertDebug(self.isVideo);
OWSAssertDebug(self.videoPlayer);
[self.videoPlayer stop];
self.playVideoButton.hidden = NO;
[self.delegate mediaDetailViewController:self isPlayingVideo:NO];
}
#pragma mark - OWSVideoPlayer
- (void)videoPlayerDidPlayToCompletion:(OWSVideoPlayer *)videoPlayer
{
OWSAssertDebug(self.isVideo);
OWSAssertDebug(self.videoPlayer);
OWSLogVerbose(@"");
[self stopVideo];
}
#pragma mark - PlayerProgressBarDelegate
- (void)playerProgressBarDidStartScrubbing:(PlayerProgressBar *)playerProgressBar
{
OWSAssertDebug(self.videoPlayer);
[self.videoPlayer pause];
}
- (void)playerProgressBar:(PlayerProgressBar *)playerProgressBar scrubbedToTime:(CMTime)time
{
OWSAssertDebug(self.videoPlayer);
[self.videoPlayer seekToTime:time];
}
- (void)playerProgressBar:(PlayerProgressBar *)playerProgressBar
didFinishScrubbingAtTime:(CMTime)time
shouldResumePlayback:(BOOL)shouldResumePlayback
{
OWSAssertDebug(self.videoPlayer);
[self.videoPlayer seekToTime:time];
if (shouldResumePlayback) {
[self.videoPlayer play];
}
}
#pragma mark - Saving images to Camera Roll
- (void)image:(UIImage *)image didFinishSavingWithError:(NSError *)error contextInfo:(void *)contextInfo
{
if (error) {
OWSLogWarn(@"There was a problem saving <%@> to camera roll.", error.localizedDescription);
}
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,444 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import YYImage
import SessionUIKit
import SignalUtilitiesKit
import SessionMessagingKit
public enum MediaGalleryOption {
case sliderEnabled
case showAllMediaButton
}
class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVideoPlayerDelegate, PlayerProgressBarDelegate {
public let galleryItem: MediaGalleryViewModel.Item
public weak var delegate: MediaDetailViewControllerDelegate?
private var image: UIImage?
// MARK: - UI
private var mediaViewBottomConstraint: NSLayoutConstraint?
private var mediaViewLeadingConstraint: NSLayoutConstraint?
private var mediaViewTopConstraint: NSLayoutConstraint?
private var mediaViewTrailingConstraint: NSLayoutConstraint?
private lazy var scrollView: UIScrollView = {
let result: UIScrollView = UIScrollView()
result.showsVerticalScrollIndicator = false
result.showsHorizontalScrollIndicator = false
result.contentInsetAdjustmentBehavior = .never
result.decelerationRate = .fast
result.delegate = self
return result
}()
public var mediaView: UIView = UIView()
private var playVideoButton: UIButton = UIButton()
private var videoProgressBar: PlayerProgressBar = PlayerProgressBar()
private var videoPlayer: OWSVideoPlayer?
// MARK: - Initialization
init(
galleryItem: MediaGalleryViewModel.Item,
delegate: MediaDetailViewControllerDelegate? = nil
) {
self.galleryItem = galleryItem
self.delegate = delegate
super.init(nibName: nil, bundle: nil)
// We cache the image data in case the attachment stream is deleted.
galleryItem.attachment.thumbnail(
size: .large,
success: { [weak self] image, _ in
// Only reload the content if the view has already loaded (if it
// hasn't then it'll load with the image immediately)
let updateUICallback = {
self?.image = image
if self?.isViewLoaded == true {
self?.updateContents()
self?.updateMinZoomScale()
}
}
guard Thread.isMainThread else {
DispatchQueue.main.async {
updateUICallback()
}
return
}
updateUICallback()
},
failure: {
SNLog("Could not load media.")
}
)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.stopAnyVideo()
}
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
self.view.backgroundColor = Colors.navigationBarBackground
self.view.addSubview(scrollView)
scrollView.pin(to: self.view)
self.updateContents()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.resetMediaFrame()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if mediaView is YYAnimatedImageView {
// Add a slight delay before starting the gif animation to prevent it from looking
// buggy due to the custom transition
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { [weak self] in
(self?.mediaView as? YYAnimatedImageView)?.startAnimating()
}
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
self.updateMinZoomScale()
self.centerMediaViewConstraints()
}
// MARK: - Functions
private func updateMinZoomScale() {
let maybeImageSize: CGSize? = {
switch self.mediaView {
case let imageView as UIImageView: return (imageView.image?.size ?? .zero)
case let imageView as YYAnimatedImageView: return (imageView.image?.size ?? .zero)
default: return nil
}
}()
guard let imageSize: CGSize = maybeImageSize else {
self.scrollView.minimumZoomScale = 1
self.scrollView.maximumZoomScale = 1
self.scrollView.zoomScale = 1
return
}
let viewSize: CGSize = self.scrollView.bounds.size
guard imageSize.width > 0 && imageSize.height > 0 else {
SNLog("Invalid image dimensions (\(imageSize.width), \(imageSize.height))")
return
}
let scaleWidth: CGFloat = (viewSize.width / imageSize.width)
let scaleHeight: CGFloat = (viewSize.height / imageSize.height)
let minScale: CGFloat = min(scaleWidth, scaleHeight)
if minScale != self.scrollView.minimumZoomScale {
self.scrollView.minimumZoomScale = minScale
self.scrollView.maximumZoomScale = (minScale * 8)
self.scrollView.zoomScale = minScale
}
}
public func zoomOut(animated: Bool) {
if self.scrollView.zoomScale != self.scrollView.minimumZoomScale {
self.scrollView.setZoomScale(self.scrollView.minimumZoomScale, animated: animated)
}
}
// MARK: - Content
private func updateContents() {
self.mediaView.removeFromSuperview()
self.playVideoButton.removeFromSuperview()
self.videoProgressBar.removeFromSuperview()
self.scrollView.zoomScale = 1
if self.galleryItem.attachment.isAnimated {
if self.galleryItem.attachment.isValid, let originalFilePath: String = self.galleryItem.attachment.originalFilePath {
let animatedView: YYAnimatedImageView = YYAnimatedImageView()
animatedView.autoPlayAnimatedImage = false
animatedView.image = YYImage(contentsOfFile: originalFilePath)
self.mediaView = animatedView
}
else {
self.mediaView = UIView()
self.mediaView.backgroundColor = Colors.unimportant
}
}
else if self.image == nil {
// Still loading thumbnail.
self.mediaView = UIView()
self.mediaView.backgroundColor = Colors.unimportant
}
else if self.galleryItem.attachment.isVideo {
if self.galleryItem.attachment.isValid {
self.mediaView = self.buildVideoPlayerView()
}
else {
self.mediaView = UIView()
self.mediaView.backgroundColor = Colors.unimportant
}
}
else {
// Present the static image using standard UIImageView
self.mediaView = UIImageView(image: self.image)
}
// We add these gestures to mediaView rather than
// the root view so that interacting with the video player
// progres bar doesn't trigger any of these gestures.
self.addGestureRecognizers(to: self.mediaView)
self.scrollView.addSubview(self.mediaView)
self.mediaViewLeadingConstraint = self.mediaView.pin(.leading, to: .leading, of: self.scrollView)
self.mediaViewTopConstraint = self.mediaView.pin(.top, to: .top, of: self.scrollView)
self.mediaViewTrailingConstraint = self.mediaView.pin(.trailing, to: .trailing, of: self.scrollView)
self.mediaViewBottomConstraint = self.mediaView.pin(.bottom, to: .bottom, of: self.scrollView)
self.mediaView.contentMode = .scaleAspectFit
self.mediaView.isUserInteractionEnabled = true
self.mediaView.clipsToBounds = true
self.mediaView.layer.allowsEdgeAntialiasing = true
self.mediaView.translatesAutoresizingMaskIntoConstraints = false
// Use trilinear filters for better scaling quality at
// some performance cost.
self.mediaView.layer.minificationFilter = .trilinear
self.mediaView.layer.magnificationFilter = .trilinear
if self.galleryItem.attachment.isVideo {
self.videoProgressBar = PlayerProgressBar()
self.videoProgressBar.delegate = self
self.videoProgressBar.player = self.videoPlayer?.avPlayer
// We hide the progress bar until either:
// 1. Video completes playing
// 2. User taps the screen
self.videoProgressBar.isHidden = false
self.view.addSubview(self.videoProgressBar)
self.videoProgressBar.autoPinWidthToSuperview()
self.videoProgressBar.autoPinEdge(toSuperviewSafeArea: .top)
self.videoProgressBar.autoSetDimension(.height, toSize: 44)
self.playVideoButton = UIButton()
self.playVideoButton.contentMode = .scaleAspectFill
self.playVideoButton.setBackgroundImage(UIImage(named: "CirclePlay"), for: .normal)
self.playVideoButton.addTarget(self, action: #selector(playVideo), for: .touchUpInside)
self.view.addSubview(self.playVideoButton)
self.playVideoButton.set(.width, to: 72)
self.playVideoButton.set(.height, to: 72)
self.playVideoButton.center(in: self.view)
}
}
private func buildVideoPlayerView() -> UIView {
guard
let originalFilePath: String = self.galleryItem.attachment.originalFilePath,
FileManager.default.fileExists(atPath: originalFilePath)
else {
owsFailDebug("Missing video file")
return UIView()
}
self.videoPlayer = OWSVideoPlayer(url: URL(fileURLWithPath: originalFilePath))
self.videoPlayer?.seek(to: .zero)
self.videoPlayer?.delegate = self
let imageSize: CGSize = (self.image?.size ?? .zero)
let playerView: VideoPlayerView = VideoPlayerView()
playerView.player = self.videoPlayer?.avPlayer
NSLayoutConstraint.autoSetPriority(.defaultLow) {
playerView.autoSetDimensions(to: imageSize)
}
return playerView
}
public func setShouldHideToolbars(_ shouldHideToolbars: Bool) {
self.videoProgressBar.isHidden = shouldHideToolbars
}
private func addGestureRecognizers(to view: UIView) {
let doubleTap: UITapGestureRecognizer = UITapGestureRecognizer(
target: self,
action: #selector(didDoubleTapImage(_:))
)
doubleTap.numberOfTapsRequired = 2
view.addGestureRecognizer(doubleTap)
let singleTap: UITapGestureRecognizer = UITapGestureRecognizer(
target: self,
action: #selector(didSingleTapImage(_:))
)
singleTap.require(toFail: doubleTap)
view.addGestureRecognizer(singleTap)
}
// MARK: - Gesture Recognizers
@objc private func didSingleTapImage(_ gesture: UITapGestureRecognizer) {
self.delegate?.mediaDetailViewControllerDidTapMedia(self)
}
@objc private func didDoubleTapImage(_ gesture: UITapGestureRecognizer) {
guard self.scrollView.zoomScale == self.scrollView.minimumZoomScale else {
// If already zoomed in at all, zoom out all the way.
self.zoomOut(animated: true)
return
}
let doubleTapZoomScale: CGFloat = 2
let zoomWidth: CGFloat = (self.scrollView.bounds.width / doubleTapZoomScale)
let zoomHeight: CGFloat = (self.scrollView.bounds.height / doubleTapZoomScale)
// Center zoom rect around tapLocation
let tapLocation: CGPoint = gesture.location(in: self.scrollView)
let zoomX: CGFloat = max(0, tapLocation.x - zoomWidth / 2)
let zoomY: CGFloat = max(0, tapLocation.y - zoomHeight / 2)
let zoomRect: CGRect = CGRect(x: zoomX, y: zoomY, width: zoomWidth, height: zoomHeight)
let translatedRect: CGRect = self.mediaView.convert(zoomRect, to: self.scrollView)
self.scrollView.zoom(to: translatedRect, animated: true)
}
@objc public func didPressPlayBarButton() {
self.playVideo()
}
@objc public func didPressPauseBarButton() {
self.pauseVideo()
}
// MARK: - UIScrollViewDelegate
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return self.mediaView
}
private func centerMediaViewConstraints() {
let scrollViewSize: CGSize = self.scrollView.bounds.size
let imageViewSize: CGSize = self.mediaView.frame.size
// We want to modify the yOffset so the content remains centered on the screen (we can do this
// by subtracting half the parentViewController's y position)
//
// Note: Due to weird partial-pixel value rendering behaviours we need to round the inset either
// up or down depending on which direction the partial-pixel would end up rounded to make it
// align correctly
let halfHeightDiff: CGFloat = ((self.scrollView.bounds.size.height - self.mediaView.frame.size.height) / 2)
let shouldRoundUp: Bool = (round(halfHeightDiff) - halfHeightDiff > 0)
let yOffset: CGFloat = (
round((scrollViewSize.height - imageViewSize.height) / 2) -
(shouldRoundUp ?
ceil((self.parent?.view.frame.origin.y ?? 0) / 2) :
floor((self.parent?.view.frame.origin.y ?? 0) / 2)
)
)
self.mediaViewTopConstraint?.constant = yOffset
self.mediaViewBottomConstraint?.constant = yOffset
let xOffset: CGFloat = max(0, (scrollViewSize.width - imageViewSize.width) / 2)
self.mediaViewLeadingConstraint?.constant = xOffset
self.mediaViewTrailingConstraint?.constant = xOffset
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
self.centerMediaViewConstraints()
self.view.layoutIfNeeded()
}
private func resetMediaFrame() {
// HACK: Setting the frame to itself *seems* like it should be a no-op, but
// it ensures the content is drawn at the right frame. In particular I was
// reproducibly seeing some images squished (they were EXIF rotated, maybe
// related). similar to this report:
// https://stackoverflow.com/questions/27961884/swift-uiimageview-stretched-aspect
self.view.layoutIfNeeded()
self.mediaView.frame = self.mediaView.frame
}
// MARK: - Video Playback
@objc public func playVideo() {
self.playVideoButton.isHidden = true
self.videoPlayer?.play()
self.delegate?.mediaDetailViewController(self, isPlayingVideo: true)
}
private func pauseVideo() {
self.videoPlayer?.pause()
self.delegate?.mediaDetailViewController(self, isPlayingVideo: false)
}
public func stopAnyVideo() {
guard self.galleryItem.attachment.isVideo else { return }
self.stopVideo()
}
private func stopVideo() {
self.videoPlayer?.stop()
self.playVideoButton.isHidden = false
self.delegate?.mediaDetailViewController(self, isPlayingVideo: false)
}
// MARK: - OWSVideoPlayerDelegate
func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) {
self.stopVideo()
}
// MARK: - PlayerProgressBarDelegate
func playerProgressBarDidStartScrubbing(_ playerProgressBar: PlayerProgressBar) {
self.videoPlayer?.pause()
}
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, scrubbedToTime time: CMTime) {
self.videoPlayer?.seek(to: time)
}
func playerProgressBar(_ playerProgressBar: PlayerProgressBar, didFinishScrubbingAtTime time: CMTime, shouldResumePlayback: Bool) {
self.videoPlayer?.seek(to: time)
if shouldResumePlayback {
self.videoPlayer?.play()
}
}
}
// MARK: - MediaDetailViewControllerDelegate
protocol MediaDetailViewControllerDelegate: AnyObject {
func mediaDetailViewController(_ mediaDetailViewController: MediaDetailViewController, isPlayingVideo: Bool)
func mediaDetailViewControllerDidTapMedia(_ mediaDetailViewController: MediaDetailViewController)
}

View File

@ -0,0 +1,84 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SignalUtilitiesKit
import SessionUIKit
class MediaGalleryNavigationController: OWSNavigationController {
// HACK: Though we don't have an input accessory view, the VC we are presented above (ConversationVC) does.
// If the app is backgrounded and then foregrounded, when OWSWindowManager calls mainWindow.makeKeyAndVisible
// the ConversationVC's inputAccessoryView will appear *above* us unless we'd previously become first responder.
override public var canBecomeFirstResponder: Bool {
return true
}
// MARK: - UI
private lazy var backgroundView: UIView = {
let result: UIView = UIView()
result.backgroundColor = Colors.navigationBarBackground
return result
}()
// MARK: - View Lifecycle
override var preferredStatusBarStyle: UIStatusBarStyle {
return (isLightMode ? .default : .lightContent)
}
override func viewDidLoad() {
super.viewDidLoad()
guard let navigationBar = self.navigationBar as? OWSNavigationBar else {
owsFailDebug("navigationBar had unexpected class: \(self.navigationBar)")
return
}
view.backgroundColor = Colors.navigationBarBackground
navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default)
navigationBar.shadowImage = UIImage()
navigationBar.isTranslucent = false
navigationBar.barTintColor = Colors.navigationBarBackground
// Insert a view to ensure the nav bar colour goes to the top of the screen
relayoutBackgroundView()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// If the user's device is already rotated, try to respect that by rotating to landscape now
UIViewController.attemptRotationToDeviceOrientation()
}
// MARK: - Orientation
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
return .allButUpsideDown
}
// MARK: - Functions
private func relayoutBackgroundView() {
guard !backgroundView.isHidden else {
backgroundView.removeFromSuperview()
return
}
view.insertSubview(backgroundView, belowSubview: navigationBar)
backgroundView.pin(.top, to: .top, of: view)
backgroundView.pin(.left, to: .left, of: navigationBar)
backgroundView.pin(.right, to: .right, of: navigationBar)
backgroundView.pin(.bottom, to: .bottom, of: navigationBar)
}
override func setNavigationBarHidden(_ hidden: Bool, animated: Bool) {
super.setNavigationBarHidden(hidden, animated: animated)
backgroundView.isHidden = hidden
relayoutBackgroundView()
}
}

View File

@ -1,903 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
public enum GalleryDirection {
case before, after, around
}
class MediaGalleryAlbum {
private var originalItems: [MediaGalleryItem]
var items: [MediaGalleryItem] {
get {
guard let mediaGalleryDataSource = self.mediaGalleryDataSource else {
owsFailDebug("mediaGalleryDataSource was unexpectedly nil")
return originalItems
}
return originalItems.filter { !mediaGalleryDataSource.deletedGalleryItems.contains($0) }
}
}
weak var mediaGalleryDataSource: MediaGalleryDataSource?
init(items: [MediaGalleryItem]) {
self.originalItems = items
}
func add(item: MediaGalleryItem) {
guard !originalItems.contains(item) else {
return
}
originalItems.append(item)
originalItems.sort { (lhs, rhs) -> Bool in
return lhs.albumIndex < rhs.albumIndex
}
}
}
public class MediaGalleryItem: Equatable, Hashable {
let message: TSMessage
let attachmentStream: TSAttachmentStream
let galleryDate: GalleryDate
let captionForDisplay: String?
let albumIndex: Int
var album: MediaGalleryAlbum?
let orderingKey: MediaGalleryItemOrderingKey
init(message: TSMessage, attachmentStream: TSAttachmentStream) {
self.message = message
self.attachmentStream = attachmentStream
self.captionForDisplay = attachmentStream.caption?.filterForDisplay
self.galleryDate = GalleryDate(message: message)
self.albumIndex = message.attachmentIds.index(of: attachmentStream.uniqueId!)
self.orderingKey = MediaGalleryItemOrderingKey(messageSortKey: message.sortId, attachmentSortKey: albumIndex)
}
var isVideo: Bool {
return attachmentStream.isVideo
}
var isAnimated: Bool {
return attachmentStream.isAnimated
}
var isImage: Bool {
return attachmentStream.isImage
}
var imageSize: CGSize {
return attachmentStream.imageSize()
}
public typealias AsyncThumbnailBlock = (UIImage) -> Void
func thumbnailImage(async:@escaping AsyncThumbnailBlock) -> UIImage? {
return attachmentStream.thumbnailImageSmall(success: async, failure: {})
}
// MARK: Equatable
public static func == (lhs: MediaGalleryItem, rhs: MediaGalleryItem) -> Bool {
return lhs.attachmentStream.uniqueId == rhs.attachmentStream.uniqueId
}
// MARK: Hashable
public var hashValue: Int {
return attachmentStream.uniqueId?.hashValue ?? attachmentStream.hashValue
}
// MARK: Sorting
struct MediaGalleryItemOrderingKey: Comparable {
let messageSortKey: UInt64
let attachmentSortKey: Int
// MARK: Comparable
static func < (lhs: MediaGalleryItem.MediaGalleryItemOrderingKey, rhs: MediaGalleryItem.MediaGalleryItemOrderingKey) -> Bool {
if lhs.messageSortKey < rhs.messageSortKey {
return true
}
if lhs.messageSortKey == rhs.messageSortKey {
if lhs.attachmentSortKey < rhs.attachmentSortKey {
return true
}
}
return false
}
}
}
public struct GalleryDate: Hashable, Comparable, Equatable {
let year: Int
let month: Int
init(message: TSMessage) {
let date = message.dateForUI()
self.year = Calendar.current.component(.year, from: date)
self.month = Calendar.current.component(.month, from: date)
}
init(year: Int, month: Int) {
assert(month >= 1 && month <= 12)
self.year = year
self.month = month
}
private var isThisMonth: Bool {
let now = Date()
let year = Calendar.current.component(.year, from: now)
let month = Calendar.current.component(.month, from: now)
let thisMonth = GalleryDate(year: year, month: month)
return self == thisMonth
}
public var date: Date {
var components = DateComponents()
components.month = self.month
components.year = self.year
return Calendar.current.date(from: components)!
}
private var isThisYear: Bool {
let now = Date()
let thisYear = Calendar.current.component(.year, from: now)
return self.year == thisYear
}
static let thisYearFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM"
return formatter
}()
static let olderFormatter: DateFormatter = {
let formatter = DateFormatter()
// FIXME localize for RTL, or is there a built in way to do this?
formatter.dateFormat = "MMMM yyyy"
return formatter
}()
var localizedString: String {
if isThisMonth {
return NSLocalizedString("MEDIA_GALLERY_THIS_MONTH_HEADER", comment: "Section header in media gallery collection view")
} else if isThisYear {
return type(of: self).thisYearFormatter.string(from: self.date)
} else {
return type(of: self).olderFormatter.string(from: self.date)
}
}
// MARK: Hashable
public var hashValue: Int {
return month.hashValue ^ year.hashValue
}
// MARK: Comparable
public static func < (lhs: GalleryDate, rhs: GalleryDate) -> Bool {
if lhs.year != rhs.year {
return lhs.year < rhs.year
} else if lhs.month != rhs.month {
return lhs.month < rhs.month
} else {
return false
}
}
// MARK: Equatable
public static func == (lhs: GalleryDate, rhs: GalleryDate) -> Bool {
return lhs.month == rhs.month && lhs.year == rhs.year
}
}
protocol MediaGalleryDataSource: class {
var hasFetchedOldest: Bool { get }
var hasFetchedMostRecent: Bool { get }
var galleryItems: [MediaGalleryItem] { get }
var galleryItemCount: Int { get }
var sections: [GalleryDate: [MediaGalleryItem]] { get }
var sectionDates: [GalleryDate] { get }
var deletedAttachments: Set<TSAttachment> { get }
var deletedGalleryItems: Set<MediaGalleryItem> { get }
func ensureGalleryItemsLoaded(_ direction: GalleryDirection, item: MediaGalleryItem, amount: UInt, completion: ((IndexSet, [IndexPath]) -> Void)?)
func galleryItem(before currentItem: MediaGalleryItem) -> MediaGalleryItem?
func galleryItem(after currentItem: MediaGalleryItem) -> MediaGalleryItem?
func showAllMedia(focusedItem: MediaGalleryItem)
func dismissMediaDetailViewController(_ mediaDetailViewController: MediaPageViewController, animated isAnimated: Bool, completion: (() -> Void)?)
func delete(items: [MediaGalleryItem], initiatedBy: AnyObject)
}
protocol MediaGalleryDataSourceDelegate: class {
func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, willDelete items: [MediaGalleryItem], initiatedBy: AnyObject)
func mediaGalleryDataSource(_ mediaGalleryDataSource: MediaGalleryDataSource, deletedSections: IndexSet, deletedItems: [IndexPath])
}
class MediaGalleryNavigationController: OWSNavigationController {
var retainUntilDismissed: MediaGallery?
// HACK: Though we don't have an input accessory view, the VC we are presented above (ConversationVC) does.
// If the app is backgrounded and then foregrounded, when OWSWindowManager calls mainWindow.makeKeyAndVisible
// the ConversationVC's inputAccessoryView will appear *above* us unless we'd previously become first responder.
override public var canBecomeFirstResponder: Bool {
Logger.debug("")
return true
}
// MARK: View Lifecycle
override var preferredStatusBarStyle: UIStatusBarStyle {
return isLightMode ? .default : .lightContent
}
override func viewDidLoad() {
super.viewDidLoad()
guard let navigationBar = self.navigationBar as? OWSNavigationBar else {
owsFailDebug("navigationBar had unexpected class: \(self.navigationBar)")
return
}
view.backgroundColor = Colors.navigationBarBackground
navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default)
navigationBar.shadowImage = UIImage()
navigationBar.isTranslucent = false
navigationBar.barTintColor = Colors.navigationBarBackground
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// If the user's device is already rotated, try to respect that by rotating to landscape now
UIViewController.attemptRotationToDeviceOrientation()
}
// MARK: Orientation
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
return .allButUpsideDown
}
}
@objc
class MediaGallery: NSObject, MediaGalleryDataSource, MediaTileViewControllerDelegate {
@objc
weak public var navigationController: MediaGalleryNavigationController!
var deletedAttachments: Set<TSAttachment> = Set()
var deletedGalleryItems: Set<MediaGalleryItem> = Set()
private var pageViewController: MediaPageViewController?
private var uiDatabaseConnection: YapDatabaseConnection {
return OWSPrimaryStorage.shared().uiDatabaseConnection
}
private let editingDatabaseConnection: YapDatabaseConnection
private let mediaGalleryFinder: OWSMediaGalleryFinder
private var initialDetailItem: MediaGalleryItem?
private let thread: TSThread
private let options: MediaGalleryOption
// we start with a small range size for quick loading.
private let fetchRangeSize: UInt = 10
deinit {
Logger.debug("")
}
@objc
init(thread: TSThread, options: MediaGalleryOption = []) {
self.thread = thread
self.editingDatabaseConnection = OWSPrimaryStorage.shared().newDatabaseConnection()
self.options = options
self.mediaGalleryFinder = OWSMediaGalleryFinder(thread: thread)
super.init()
NotificationCenter.default.addObserver(self,
selector: #selector(uiDatabaseDidUpdate),
name: .OWSUIDatabaseConnectionDidUpdate,
object: OWSPrimaryStorage.shared().dbNotificationObject)
}
// MARK: Present/Dismiss
private var currentItem: MediaGalleryItem {
return self.pageViewController!.currentItem
}
@objc
public func presentDetailView(fromViewController: UIViewController, mediaAttachment: TSAttachment) {
var galleryItem: MediaGalleryItem?
uiDatabaseConnection.read { transaction in
galleryItem = self.buildGalleryItem(attachment: mediaAttachment, transaction: transaction)
}
guard let initialDetailItem = galleryItem else {
return
}
presentDetailView(fromViewController: fromViewController, initialDetailItem: initialDetailItem)
}
public func presentDetailView(fromViewController: UIViewController, initialDetailItem: MediaGalleryItem) {
// For a speedy load, we only fetch a few items on either side of
// the initial message
ensureGalleryItemsLoaded(.around, item: initialDetailItem, amount: 10)
// We lazily load media into the gallery, but with large albums, we want to be sure
// we load all the media required to render the album's media rail.
ensureAlbumEntirelyLoaded(galleryItem: initialDetailItem)
self.initialDetailItem = initialDetailItem
let pageViewController = MediaPageViewController(initialItem: initialDetailItem, mediaGalleryDataSource: self, uiDatabaseConnection: self.uiDatabaseConnection, options: self.options)
self.addDataSourceDelegate(pageViewController)
self.pageViewController = pageViewController
let navController = MediaGalleryNavigationController()
self.navigationController = navController
navController.retainUntilDismissed = self
navigationController.setViewControllers([pageViewController], animated: false)
navigationController.modalPresentationStyle = .fullScreen
navigationController.modalTransitionStyle = .crossDissolve
fromViewController.present(navigationController, animated: true, completion: nil)
}
// If we're using a navigationController other than self to present the views
// e.g. the conversation settings view controller
var fromNavController: OWSNavigationController?
@objc
func pushTileView(fromNavController: OWSNavigationController) {
var mostRecentItem: MediaGalleryItem?
self.uiDatabaseConnection.read { transaction in
if let attachment = self.mediaGalleryFinder.mostRecentMediaAttachment(transaction: transaction) {
mostRecentItem = self.buildGalleryItem(attachment: attachment, transaction: transaction)
}
}
if let mostRecentItem = mostRecentItem {
mediaTileViewController.focusedItem = mostRecentItem
ensureGalleryItemsLoaded(.around, item: mostRecentItem, amount: 100)
}
self.fromNavController = fromNavController
fromNavController.pushViewController(mediaTileViewController, animated: true)
}
func showAllMedia(focusedItem: MediaGalleryItem) {
// TODO fancy animation - zoom media item into it's tile in the all media grid
ensureGalleryItemsLoaded(.around, item: focusedItem, amount: 100)
if let fromNavController = self.fromNavController {
// If from conversation settings view, we've already pushed
fromNavController.popViewController(animated: true)
} else {
// If from conversation view
mediaTileViewController.focusedItem = focusedItem
navigationController.pushViewController(mediaTileViewController, animated: true)
}
}
// MARK: MediaTileViewControllerDelegate
func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryItem) {
if self.fromNavController != nil {
// If we got to the gallery via conversation settings, present the detail view
// on top of the tile view
//
// == ViewController Schematic ==
//
// [DetailView] <--,
// [TileView] -----'
// [ConversationSettingsView]
// [ConversationView]
//
self.presentDetailView(fromViewController: mediaTileViewController, initialDetailItem: mediaGalleryItem)
} else {
// If we got to the gallery via the conversation view, pop the tile view
// to return to the detail view
//
// == ViewController Schematic ==
//
// [TileView] -----,
// [DetailView] <--'
// [ConversationView]
//
guard let pageViewController = self.pageViewController else {
owsFailDebug("pageViewController was unexpectedly nil")
self.navigationController.dismiss(animated: true)
return
}
pageViewController.setCurrentItem(mediaGalleryItem, direction: .forward, animated: false)
pageViewController.willBePresentedAgain()
// TODO fancy zoom animation
self.navigationController.popViewController(animated: true)
}
}
public func dismissMediaDetailViewController(_ mediaPageViewController: MediaPageViewController, animated isAnimated: Bool, completion completionParam: (() -> Void)?) {
guard let presentingViewController = self.navigationController.presentingViewController else {
owsFailDebug("presentingController was unexpectedly nil")
return
}
let completion = {
completionParam?()
UIApplication.shared.isStatusBarHidden = false
presentingViewController.setNeedsStatusBarAppearanceUpdate()
}
navigationController.view.isUserInteractionEnabled = false
presentingViewController.dismiss(animated: true, completion: completion)
}
// MARK: - Database Notifications
@objc
func uiDatabaseDidUpdate(notification: Notification) {
guard let notifications = notification.userInfo?[OWSUIDatabaseConnectionNotificationsKey] as? [Notification] else {
owsFailDebug("notifications was unexpectedly nil")
return
}
guard mediaGalleryFinder.hasMediaChanges(in: notifications, dbConnection: uiDatabaseConnection) else {
Logger.verbose("no changes for thread: \(thread)")
return
}
let rowChanges = extractRowChanges(notifications: notifications)
assert(rowChanges.count > 0)
process(rowChanges: rowChanges)
}
func extractRowChanges(notifications: [Notification]) -> [YapDatabaseViewRowChange] {
return notifications.flatMap { notification -> [YapDatabaseViewRowChange] in
guard let userInfo = notification.userInfo else {
owsFailDebug("userInfo was unexpectedly nil")
return []
}
guard let extensionChanges = userInfo["extensions"] as? [AnyHashable: Any] else {
owsFailDebug("extensionChanges was unexpectedly nil")
return []
}
guard let galleryData = extensionChanges[OWSMediaGalleryFinder.databaseExtensionName()] as? [AnyHashable: Any] else {
owsFailDebug("galleryData was unexpectedly nil")
return []
}
guard let galleryChanges = galleryData["changes"] as? [Any] else {
owsFailDebug("gallerlyChanges was unexpectedly nil")
return []
}
return galleryChanges.compactMap { $0 as? YapDatabaseViewRowChange }
}
}
func process(rowChanges: [YapDatabaseViewRowChange]) {
let deleteChanges = rowChanges.filter { $0.type == .delete }
let deletedItems: [MediaGalleryItem] = deleteChanges.compactMap { (deleteChange: YapDatabaseViewRowChange) -> MediaGalleryItem? in
guard let deletedItem = self.galleryItems.first(where: { galleryItem in
galleryItem.attachmentStream.uniqueId == deleteChange.collectionKey.key
}) else {
Logger.debug("deletedItem was never loaded - no need to remove.")
return nil
}
return deletedItem
}
self.delete(items: deletedItems, initiatedBy: self)
}
// MARK: - MediaGalleryDataSource
lazy var mediaTileViewController: MediaTileViewController = {
let vc = MediaTileViewController(mediaGalleryDataSource: self, uiDatabaseConnection: self.uiDatabaseConnection)
vc.delegate = self
self.addDataSourceDelegate(vc)
return vc
}()
var galleryItems: [MediaGalleryItem] = []
var sections: [GalleryDate: [MediaGalleryItem]] = [:]
var sectionDates: [GalleryDate] = []
var hasFetchedOldest = false
var hasFetchedMostRecent = false
func buildGalleryItem(attachment: TSAttachment, transaction: YapDatabaseReadTransaction) -> MediaGalleryItem? {
guard let attachmentStream = attachment as? TSAttachmentStream else {
return nil
}
guard let message = attachmentStream.fetchAlbumMessage(with: transaction) else {
return nil
}
let galleryItem = MediaGalleryItem(message: message, attachmentStream: attachmentStream)
galleryItem.album = getAlbum(item: galleryItem)
return galleryItem
}
func ensureAlbumEntirelyLoaded(galleryItem: MediaGalleryItem) {
ensureGalleryItemsLoaded(.before, item: galleryItem, amount: UInt(galleryItem.albumIndex))
let followingCount = galleryItem.message.attachmentIds.count - 1 - galleryItem.albumIndex
guard followingCount >= 0 else {
return
}
ensureGalleryItemsLoaded(.after, item: galleryItem, amount: UInt(followingCount))
}
var galleryAlbums: [String: MediaGalleryAlbum] = [:]
func getAlbum(item: MediaGalleryItem) -> MediaGalleryAlbum? {
guard let albumMessageId = item.attachmentStream.albumMessageId else {
return nil
}
guard let existingAlbum = galleryAlbums[albumMessageId] else {
let newAlbum = MediaGalleryAlbum(items: [item])
galleryAlbums[albumMessageId] = newAlbum
newAlbum.mediaGalleryDataSource = self
return newAlbum
}
existingAlbum.add(item: item)
return existingAlbum
}
// Range instead of indexSet since it's contiguous?
var fetchedIndexSet = IndexSet() {
didSet {
Logger.debug("\(oldValue) -> \(fetchedIndexSet)")
}
}
enum MediaGalleryError: Error {
case itemNoLongerExists
}
func ensureGalleryItemsLoaded(_ direction: GalleryDirection, item: MediaGalleryItem, amount: UInt, completion: ((IndexSet, [IndexPath]) -> Void)? = nil ) {
var galleryItems: [MediaGalleryItem] = self.galleryItems
var sections: [GalleryDate: [MediaGalleryItem]] = self.sections
var sectionDates: [GalleryDate] = self.sectionDates
var newGalleryItems: [MediaGalleryItem] = []
var newDates: [GalleryDate] = []
do {
try Bench(title: "fetching gallery items") {
try self.uiDatabaseConnection.read { transaction in
guard let index = self.mediaGalleryFinder.mediaIndex(attachment: item.attachmentStream, transaction: transaction) else {
throw MediaGalleryError.itemNoLongerExists
}
let initialIndex: Int = index.intValue
let mediaCount: Int = Int(self.mediaGalleryFinder.mediaCount(transaction: transaction))
let requestRange: Range<Int> = { () -> Range<Int> in
let range: Range<Int> = { () -> Range<Int> in
switch direction {
case .around:
// To keep it simple, this isn't exactly *amount* sized if `message` window overlaps the end or
// beginning of the view. Still, we have sufficient buffer to fetch more as the user swipes.
let start: Int = initialIndex - Int(amount) / 2
let end: Int = initialIndex + Int(amount) / 2 + 1
return start..<end
case .before:
let start: Int = initialIndex - Int(amount)
let end: Int = initialIndex
return start..<end
case .after:
let start: Int = initialIndex
let end: Int = initialIndex + Int(amount) + 1
return start..<end
}
}()
return range.clamped(to: 0..<mediaCount)
}()
let requestSet = IndexSet(integersIn: requestRange)
guard !self.fetchedIndexSet.contains(integersIn: requestSet) else {
Logger.debug("all requested messages have already been loaded.")
return
}
let unfetchedSet = requestSet.subtracting(self.fetchedIndexSet)
// For perf we only want to fetch a substantially full batch...
let isSubstantialRequest = unfetchedSet.count > (requestSet.count / 2)
// ...but we always fulfill even small requests if we're getting just the tail end of a gallery.
let isFetchingEdgeOfGallery = (self.fetchedIndexSet.count - unfetchedSet.count) < requestSet.count
guard isSubstantialRequest || isFetchingEdgeOfGallery else {
Logger.debug("ignoring small fetch request: \(unfetchedSet.count)")
return
}
Logger.debug("fetching set: \(unfetchedSet)")
let nsRange: NSRange = NSRange(location: unfetchedSet.min()!, length: unfetchedSet.count)
self.mediaGalleryFinder.enumerateMediaAttachments(range: nsRange, transaction: transaction) { (attachment: TSAttachment) in
guard !self.deletedAttachments.contains(attachment) else {
Logger.debug("skipping \(attachment) which has been deleted.")
return
}
guard let item: MediaGalleryItem = self.buildGalleryItem(attachment: attachment, transaction: transaction) else {
owsFailDebug("unexpectedly failed to buildGalleryItem")
return
}
let date = item.galleryDate
galleryItems.append(item)
if sections[date] != nil {
sections[date]!.append(item)
// so we can update collectionView
newGalleryItems.append(item)
} else {
sectionDates.append(date)
sections[date] = [item]
// so we can update collectionView
newDates.append(date)
newGalleryItems.append(item)
}
}
self.fetchedIndexSet = self.fetchedIndexSet.union(unfetchedSet)
self.hasFetchedOldest = self.fetchedIndexSet.min() == 0
self.hasFetchedMostRecent = self.fetchedIndexSet.max() == mediaCount - 1
}
}
} catch MediaGalleryError.itemNoLongerExists {
Logger.debug("Ignoring reload, since item no longer exists.")
return
} catch {
owsFailDebug("unexpected error: \(error)")
return
}
// TODO only sort if changed
var sortedSections: [GalleryDate: [MediaGalleryItem]] = [:]
Bench(title: "sorting gallery items") {
galleryItems.sort { lhs, rhs -> Bool in
return lhs.orderingKey < rhs.orderingKey
}
sectionDates.sort()
for (date, galleryItems) in sections {
sortedSections[date] = galleryItems.sorted { lhs, rhs -> Bool in
return lhs.orderingKey < rhs.orderingKey
}
}
}
self.galleryItems = galleryItems
self.sections = sortedSections
self.sectionDates = sectionDates
if let completionBlock = completion {
Bench(title: "calculating changes for collectionView") {
// FIXME can we avoid this index offset?
let dateIndices = newDates.map { sectionDates.firstIndex(of: $0)! + 1 }
let addedSections: IndexSet = IndexSet(dateIndices)
let addedItems: [IndexPath] = newGalleryItems.map { galleryItem in
let sectionIdx = sectionDates.firstIndex(of: galleryItem.galleryDate)!
let section = sections[galleryItem.galleryDate]!
let itemIdx = section.firstIndex(of: galleryItem)!
// FIXME can we avoid this index offset?
return IndexPath(item: itemIdx, section: sectionIdx + 1)
}
completionBlock(addedSections, addedItems)
}
}
}
var dataSourceDelegates: [Weak<MediaGalleryDataSourceDelegate>] = []
func addDataSourceDelegate(_ dataSourceDelegate: MediaGalleryDataSourceDelegate) {
dataSourceDelegates.append(Weak(value: dataSourceDelegate))
}
func delete(items: [MediaGalleryItem], initiatedBy: AnyObject) {
AssertIsOnMainThread()
Logger.info("with items: \(items.map { ($0.attachmentStream, $0.message.timestamp) })")
deletedGalleryItems.formUnion(items)
dataSourceDelegates.forEach { $0.value?.mediaGalleryDataSource(self, willDelete: items, initiatedBy: initiatedBy) }
for item in items {
self.deletedAttachments.insert(item.attachmentStream)
}
self.editingDatabaseConnection.asyncReadWrite { transaction in
for item in items {
let message = item.message
let attachment = item.attachmentStream
message.removeAttachment(attachment, transaction: transaction)
if message.attachmentIds.count == 0 {
Logger.debug("removing message after removing last media attachment")
message.remove(with: transaction)
}
}
}
var deletedSections: IndexSet = IndexSet()
var deletedIndexPaths: [IndexPath] = []
let originalSections = self.sections
let originalSectionDates = self.sectionDates
for item in items {
guard let itemIndex = galleryItems.firstIndex(of: item) else {
owsFailDebug("removing unknown item.")
return
}
self.galleryItems.remove(at: itemIndex)
guard let sectionIndex = sectionDates.firstIndex(where: { $0 == item.galleryDate }) else {
owsFailDebug("item with unknown date.")
return
}
guard var sectionItems = self.sections[item.galleryDate] else {
owsFailDebug("item with unknown section")
return
}
guard let sectionRowIndex = sectionItems.firstIndex(of: item) else {
owsFailDebug("item with unknown sectionRowIndex")
return
}
// We need to calculate the index of the deleted item with respect to it's original position.
guard let originalSectionIndex = originalSectionDates.firstIndex(where: { $0 == item.galleryDate }) else {
owsFailDebug("item with unknown date.")
return
}
guard let originalSectionItems = originalSections[item.galleryDate] else {
owsFailDebug("item with unknown section")
return
}
guard let originalSectionRowIndex = originalSectionItems.firstIndex(of: item) else {
owsFailDebug("item with unknown sectionRowIndex")
return
}
if sectionItems == [item] {
// Last item in section. Delete section.
self.sections[item.galleryDate] = nil
self.sectionDates.remove(at: sectionIndex)
deletedSections.insert(originalSectionIndex + 1)
deletedIndexPaths.append(IndexPath(row: originalSectionRowIndex, section: originalSectionIndex + 1))
} else {
sectionItems.remove(at: sectionRowIndex)
self.sections[item.galleryDate] = sectionItems
deletedIndexPaths.append(IndexPath(row: originalSectionRowIndex, section: originalSectionIndex + 1))
}
}
dataSourceDelegates.forEach { $0.value?.mediaGalleryDataSource(self, deletedSections: deletedSections, deletedItems: deletedIndexPaths) }
}
let kGallerySwipeLoadBatchSize: UInt = 5
internal func galleryItem(after currentItem: MediaGalleryItem) -> MediaGalleryItem? {
Logger.debug("")
self.ensureGalleryItemsLoaded(.after, item: currentItem, amount: kGallerySwipeLoadBatchSize)
guard let currentIndex = galleryItems.firstIndex(of: currentItem) else {
owsFailDebug("currentIndex was unexpectedly nil")
return nil
}
let index: Int = galleryItems.index(after: currentIndex)
guard let nextItem = galleryItems[safe: index] else {
// already at last item
return nil
}
guard !deletedGalleryItems.contains(nextItem) else {
Logger.debug("nextItem was deleted - Recursing.")
return galleryItem(after: nextItem)
}
return nextItem
}
internal func galleryItem(before currentItem: MediaGalleryItem) -> MediaGalleryItem? {
Logger.debug("")
self.ensureGalleryItemsLoaded(.before, item: currentItem, amount: kGallerySwipeLoadBatchSize)
guard let currentIndex = galleryItems.firstIndex(of: currentItem) else {
owsFailDebug("currentIndex was unexpectedly nil")
return nil
}
let index: Int = galleryItems.index(before: currentIndex)
guard let previousItem = galleryItems[safe: index] else {
// already at first item
return nil
}
guard !deletedGalleryItems.contains(previousItem) else {
Logger.debug("previousItem was deleted - Recursing.")
return galleryItem(before: previousItem)
}
return previousItem
}
var galleryItemCount: Int {
var count: UInt = 0
self.uiDatabaseConnection.read { (transaction: YapDatabaseReadTransaction) in
count = self.mediaGalleryFinder.mediaCount(transaction: transaction)
}
return Int(count) - deletedAttachments.count
}
}

View File

@ -0,0 +1,574 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import DifferenceKit
import SignalUtilitiesKit
import SessionUtilitiesKit
public class MediaGalleryViewModel {
public typealias SectionModel = ArraySection<Section, Item>
// MARK: - Section
public enum Section: Differentiable, Equatable, Comparable, Hashable {
case emptyGallery
case loadOlder
case galleryMonth(date: GalleryDate)
case loadNewer
}
// MARK: - Variables
public let threadId: String
public let threadVariant: SessionThread.Variant
private var focusedAttachmentId: String?
public private(set) var focusedIndexPath: IndexPath?
/// This value is the current state of an album view
private var cachedInteractionIdBefore: Atomic<[Int64: Int64]> = Atomic([:])
private var cachedInteractionIdAfter: Atomic<[Int64: Int64]> = Atomic([:])
public var interactionIdBefore: [Int64: Int64] { cachedInteractionIdBefore.wrappedValue }
public var interactionIdAfter: [Int64: Int64] { cachedInteractionIdAfter.wrappedValue }
public private(set) var albumData: [Int64: [Item]] = [:]
public private(set) var pagedDataObserver: PagedDatabaseObserver<Attachment, Item>?
/// This value is the current state of a gallery view
private var unobservedGalleryDataChanges: [SectionModel]?
public private(set) var galleryData: [SectionModel] = []
public var onGalleryChange: (([SectionModel]) -> ())? {
didSet {
// When starting to observe interaction changes we want to trigger a UI update just in case the
// data was changed while we weren't observing
if let unobservedGalleryDataChanges: [SectionModel] = self.unobservedGalleryDataChanges {
onGalleryChange?(unobservedGalleryDataChanges)
self.unobservedGalleryDataChanges = nil
}
}
}
// MARK: - Initialization
init(
threadId: String,
threadVariant: SessionThread.Variant,
isPagedData: Bool,
pageSize: Int = 1,
focusedAttachmentId: String? = nil,
performInitialQuerySync: Bool = false
) {
self.threadId = threadId
self.threadVariant = threadVariant
self.focusedAttachmentId = focusedAttachmentId
self.pagedDataObserver = nil
guard isPagedData else { return }
// Note: Since this references self we need to finish initializing before setting it, we
// also want to skip the initial query and trigger it async so that the push animation
// doesn't stutter (it should load basically immediately but without this there is a
// distinct stutter)
self.pagedDataObserver = PagedDatabaseObserver(
pagedTable: Attachment.self,
pageSize: pageSize,
idColumn: .id,
observedChanges: [
PagedData.ObservedChanges(
table: Attachment.self,
columns: [.isValid]
)
],
joinSQL: Item.joinSQL,
filterSQL: Item.filterSQL(threadId: threadId),
orderSQL: Item.galleryOrderSQL,
dataQuery: Item.baseQuery(orderSQL: Item.galleryOrderSQL),
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
guard let updatedGalleryData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else {
return
}
// If we have the 'onGalleryChange' callback then trigger it, otherwise just store the changes
// to be sent to the callback if we ever start observing again (when we have the callback it needs
// to do the data updating as it's tied to UI updates and can cause crashes if not updated in the
// correct order)
guard let onGalleryChange: (([SectionModel]) -> ()) = self?.onGalleryChange else {
self?.unobservedGalleryDataChanges = updatedGalleryData
return
}
onGalleryChange(updatedGalleryData)
}
)
// Run the initial query on a backgorund thread so we don't block the push transition
let loadInitialData: () -> () = { [weak self] in
// If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query
// from a `0` offset)
guard let initialFocusedId: String = focusedAttachmentId else {
self?.pagedDataObserver?.load(.pageBefore)
return
}
self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId))
}
// We have a custom transition when going from an attachment detail screen to the tile gallery
// so in that case we want to perform the initial query synchronously so that we have the content
// to do the transition (we don't clear the 'unobservedGalleryDataChanges' after setting it as
// we don't want to mess with the initial view controller behaviour)
guard !performInitialQuerySync else {
loadInitialData()
updateGalleryData(self.unobservedGalleryDataChanges ?? [])
return
}
DispatchQueue.global(qos: .default).async {
loadInitialData()
}
}
// MARK: - Data
public struct GalleryDate: Differentiable, Equatable, Comparable, Hashable {
private static let thisYearFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM"
return formatter
}()
private static let olderFormatter: DateFormatter = {
// FIXME: localize for RTL, or is there a built in way to do this?
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
return formatter
}()
let year: Int
let month: Int
private var date: Date? {
var components = DateComponents()
components.month = self.month
components.year = self.year
return Calendar.current.date(from: components)
}
var localizedString: String {
let isSameMonth: Bool = (self.month == Calendar.current.component(.month, from: Date()))
let isCurrentYear: Bool = (self.year == Calendar.current.component(.year, from: Date()))
let galleryDate: Date = (self.date ?? Date())
switch (isSameMonth, isCurrentYear) {
case (true, true): return "MEDIA_GALLERY_THIS_MONTH_HEADER".localized()
case (false, true): return GalleryDate.thisYearFormatter.string(from: galleryDate)
default: return GalleryDate.olderFormatter.string(from: galleryDate)
}
}
// MARK: - --Initialization
init(messageDate: Date) {
self.year = Calendar.current.component(.year, from: messageDate)
self.month = Calendar.current.component(.month, from: messageDate)
}
// MARK: - --Comparable
public static func < (lhs: GalleryDate, rhs: GalleryDate) -> Bool {
switch ((lhs.year != rhs.year), (lhs.month != rhs.month)) {
case (true, _): return lhs.year < rhs.year
case (_, true): return lhs.month < rhs.month
default: return false
}
}
}
public struct Item: FetchableRecordWithRowId, Decodable, Identifiable, Differentiable, Equatable, Hashable {
fileprivate static let interactionIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionId.stringValue)
fileprivate static let interactionVariantKey: SQL = SQL(stringLiteral: CodingKeys.interactionVariant.stringValue)
fileprivate static let interactionAuthorIdKey: SQL = SQL(stringLiteral: CodingKeys.interactionAuthorId.stringValue)
fileprivate static let interactionTimestampMsKey: SQL = SQL(stringLiteral: CodingKeys.interactionTimestampMs.stringValue)
fileprivate static let rowIdKey: SQL = SQL(stringLiteral: CodingKeys.rowId.stringValue)
fileprivate static let attachmentKey: SQL = SQL(stringLiteral: CodingKeys.attachment.stringValue)
fileprivate static let attachmentAlbumIndexKey: SQL = SQL(stringLiteral: CodingKeys.attachmentAlbumIndex.stringValue)
fileprivate static let attachmentString: String = CodingKeys.attachment.stringValue
public var id: String { attachment.id }
public var differenceIdentifier: String { attachment.id }
let interactionId: Int64
let interactionVariant: Interaction.Variant
let interactionAuthorId: String
let interactionTimestampMs: Int64
public var rowId: Int64
let attachmentAlbumIndex: Int
let attachment: Attachment
var galleryDate: GalleryDate {
GalleryDate(
messageDate: Date(timeIntervalSince1970: (Double(interactionTimestampMs) / 1000))
)
}
var isVideo: Bool { attachment.isVideo }
var isAnimated: Bool { attachment.isAnimated }
var isImage: Bool { attachment.isImage }
var imageSize: CGSize {
guard let width: UInt = attachment.width, let height: UInt = attachment.height else {
return .zero
}
return CGSize(width: Int(width), height: Int(height))
}
var captionForDisplay: String? { attachment.caption?.filterForDisplay }
// MARK: - Query
fileprivate static let joinSQL: SQL = {
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
return """
JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id])
JOIN \(Interaction.self) ON \(interaction[.id]) = \(interactionAttachment[.interactionId])
"""
}()
fileprivate static func filterSQL(threadId: String) -> SQL {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
return SQL("""
\(attachment[.isVisualMedia]) = true AND
\(attachment[.isValid]) = true AND
\(interaction[.threadId]) = \(threadId)
""")
}
fileprivate static let galleryOrderSQL: SQL = {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
/// **Note:** This **MUST** match the desired sort behaviour for the screen otherwise paging will be
/// very broken
return SQL("\(interaction[.timestampMs].desc), \(interactionAttachment[.albumIndex])")
}()
fileprivate static let galleryReverseOrderSQL: SQL = {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
/// **Note:** This **MUST** match the desired sort behaviour for the screen otherwise paging will be
/// very broken
return SQL("\(interaction[.timestampMs]), \(interactionAttachment[.albumIndex].desc)")
}()
fileprivate static func baseQuery(orderSQL: SQL, customFilters: SQL? = nil) -> (([Int64]) -> AdaptedFetchRequest<SQLRequest<Item>>) {
return { rowIds -> AdaptedFetchRequest<SQLRequest<Item>> in
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
let numColumnsBeforeLinkedRecords: Int = 6
let finalFilterSQL: SQL = {
guard let customFilters: SQL = customFilters else {
return """
WHERE \(attachment.alias[Column.rowID]) IN \(rowIds)
"""
}
return """
WHERE (
\(customFilters)
)
"""
}()
let request: SQLRequest<Item> = """
SELECT
\(interaction[.id]) AS \(Item.interactionIdKey),
\(interaction[.variant]) AS \(Item.interactionVariantKey),
\(interaction[.authorId]) AS \(Item.interactionAuthorIdKey),
\(interaction[.timestampMs]) AS \(Item.interactionTimestampMsKey),
\(attachment.alias[Column.rowID]) AS \(Item.rowIdKey),
\(interactionAttachment[.albumIndex]) AS \(Item.attachmentAlbumIndexKey),
\(Item.attachmentKey).*
FROM \(Attachment.self)
\(joinSQL)
\(finalFilterSQL)
ORDER BY \(orderSQL)
"""
return request.adapted { db in
let adapters = try splittingRowAdapters(columnCounts: [
numColumnsBeforeLinkedRecords,
Attachment.numberOfSelectedColumns(db)
])
return ScopeAdapter([
Item.attachmentString: adapters[1]
])
}
}
}
fileprivate static func baseQuery(orderSQL: SQL, customFilters: SQL) -> AdaptedFetchRequest<SQLRequest<Item>> {
return Item.baseQuery(orderSQL: orderSQL, customFilters: customFilters)([])
}
func thumbnailImage(async: @escaping (UIImage) -> ()) {
attachment.thumbnail(size: .small, success: { image, _ in async(image) }, failure: {})
}
}
// MARK: - Album
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
///
/// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static
/// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
///
/// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`)
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
public typealias AlbumObservation = ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<[Item]>>>
public lazy var observableAlbumData: AlbumObservation = buildAlbumObservation(for: nil)
private func buildAlbumObservation(for interactionId: Int64?) -> AlbumObservation {
return ValueObservation
.trackingConstantRegion { db -> [Item] in
guard let interactionId: Int64 = interactionId else { return [] }
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
return try Item
.baseQuery(
orderSQL: SQL(interactionAttachment[.albumIndex]),
customFilters: SQL("""
\(attachment[.isValid]) = true AND
\(interaction[.id]) = \(interactionId)
""")
)
.fetchAll(db)
}
.removeDuplicates()
}
@discardableResult public func loadAndCacheAlbumData(for interactionId: Int64) -> [Item] {
typealias AlbumInfo = (albumData: [Item], interactionIdBefore: Int64?, interactionIdAfter: Int64?)
// Note: It's possible we already have cached album data for this interaction
// but to avoid displaying stale data we re-fetch from the database anyway
let maybeAlbumInfo: AlbumInfo? = Storage.shared.read { db -> AlbumInfo in
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
let newAlbumData: [Item] = try Item
.baseQuery(
orderSQL: SQL(interactionAttachment[.albumIndex]),
customFilters: SQL("""
\(attachment[.isValid]) = true AND
\(interaction[.id]) = \(interactionId)
""")
)
.fetchAll(db)
guard let albumTimestampMs: Int64 = newAlbumData.first?.interactionTimestampMs else {
return (newAlbumData, nil, nil)
}
let itemBefore: Item? = try Item
.baseQuery(
orderSQL: Item.galleryReverseOrderSQL,
customFilters: SQL("\(interaction[.timestampMs]) > \(albumTimestampMs)")
)
.fetchOne(db)
let itemAfter: Item? = try Item
.baseQuery(
orderSQL: Item.galleryOrderSQL,
customFilters: SQL("\(interaction[.timestampMs]) < \(albumTimestampMs)")
)
.fetchOne(db)
return (newAlbumData, itemBefore?.interactionId, itemAfter?.interactionId)
}
guard let newAlbumInfo: AlbumInfo = maybeAlbumInfo else { return [] }
// Cache the album info for the new interactionId
self.updateAlbumData(newAlbumInfo.albumData, for: interactionId)
self.cachedInteractionIdBefore.mutate { $0[interactionId] = newAlbumInfo.interactionIdBefore }
self.cachedInteractionIdAfter.mutate { $0[interactionId] = newAlbumInfo.interactionIdAfter }
return newAlbumInfo.albumData
}
public func replaceAlbumObservation(toObservationFor interactionId: Int64) {
self.observableAlbumData = self.buildAlbumObservation(for: interactionId)
}
public func updateAlbumData(_ updatedData: [Item], for interactionId: Int64) {
self.albumData[interactionId] = updatedData
}
// MARK: - Gallery
private func process(data: [Item], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
let galleryData: [SectionModel] = data
.grouped(by: \.galleryDate)
.mapValues { sectionItems -> [Item] in
sectionItems
.sorted { lhs, rhs -> Bool in
if lhs.interactionTimestampMs == rhs.interactionTimestampMs {
// Start of album first
return (lhs.attachmentAlbumIndex < rhs.attachmentAlbumIndex)
}
// Newer interactions first
return (lhs.interactionTimestampMs > rhs.interactionTimestampMs)
}
}
.map { galleryDate, items in
SectionModel(model: .galleryMonth(date: galleryDate), elements: items)
}
// Remove and re-add the custom sections as needed
return [
(data.isEmpty ? [SectionModel(section: .emptyGallery)] : []),
(!data.isEmpty && pageInfo.pageOffset > 0 ? [SectionModel(section: .loadNewer)] : []),
galleryData,
(!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ?
[SectionModel(section: .loadOlder)] :
[]
)
]
.flatMap { $0 }
.sorted { lhs, rhs -> Bool in (lhs.model > rhs.model) }
}
public func updateGalleryData(_ updatedData: [SectionModel]) {
self.galleryData = updatedData
// If we have a focused attachment id then we need to make sure the 'focusedIndexPath'
// is updated to be accurate
if let focusedAttachmentId: String = focusedAttachmentId {
self.focusedIndexPath = nil
for (section, sectionData) in updatedData.enumerated() {
for (index, item) in sectionData.elements.enumerated() {
if item.attachment.id == focusedAttachmentId {
self.focusedIndexPath = IndexPath(item: index, section: section)
break
}
}
if self.focusedIndexPath != nil { break }
}
}
}
public func updateFocusedItem(attachmentId: String, indexPath: IndexPath) {
// Note: We need to set both of these as the 'focusedIndexPath' is usually
// derived and if the data changes it will be regenerated using the
// 'focusedAttachmentId' value
self.focusedAttachmentId = attachmentId
self.focusedIndexPath = indexPath
}
// MARK: - Creation Functions
public static func createDetailViewController(
for threadId: String,
threadVariant: SessionThread.Variant,
interactionId: Int64,
selectedAttachmentId: String,
options: [MediaGalleryOption]
) -> UIViewController? {
// Load the data for the album immediately (needed before pushing to the screen so
// transitions work nicely)
let viewModel: MediaGalleryViewModel = MediaGalleryViewModel(
threadId: threadId,
threadVariant: threadVariant,
isPagedData: false
)
viewModel.loadAndCacheAlbumData(for: interactionId)
viewModel.replaceAlbumObservation(toObservationFor: interactionId)
guard
!viewModel.albumData.isEmpty,
let initialItem: Item = viewModel.albumData[interactionId]?.first(where: { item -> Bool in
item.attachment.id == selectedAttachmentId
})
else { return nil }
let pageViewController: MediaPageViewController = MediaPageViewController(
viewModel: viewModel,
initialItem: initialItem,
options: options
)
let navController: MediaGalleryNavigationController = MediaGalleryNavigationController()
navController.viewControllers = [pageViewController]
navController.modalPresentationStyle = .fullScreen
navController.transitioningDelegate = pageViewController
return navController
}
public static func createTileViewController(
threadId: String,
threadVariant: SessionThread.Variant,
focusedAttachmentId: String?,
performInitialQuerySync: Bool = false
) -> MediaTileViewController {
let viewModel: MediaGalleryViewModel = MediaGalleryViewModel(
threadId: threadId,
threadVariant: threadVariant,
isPagedData: true,
pageSize: MediaTileViewController.itemPageSize,
focusedAttachmentId: focusedAttachmentId,
performInitialQuerySync: performInitialQuerySync
)
return MediaTileViewController(
viewModel: viewModel
)
}
}
// MARK: - Objective-C Support
// FIXME: Remove when we can
@objc(SNMediaGallery)
public class SNMediaGallery: NSObject {
@objc(pushTileViewWithSliderEnabledForThreadId:isClosedGroup:isOpenGroup:fromNavController:)
static func pushTileView(threadId: String, isClosedGroup: Bool, isOpenGroup: Bool, fromNavController: OWSNavigationController) {
fromNavController.pushViewController(
MediaGalleryViewModel.createTileViewController(
threadId: threadId,
threadVariant: {
if isClosedGroup { return .closedGroup }
if isOpenGroup { return .openGroup }
return .contact
}(),
focusedAttachmentId: nil
),
animated: true
)
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,244 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import PromiseKit
class MediaDismissAnimationController: NSObject {
private let mediaItem: Media
public let interactionController: MediaInteractiveDismiss?
var fromView: UIView?
var transitionView: UIView?
var fromTransitionalOverlayView: UIView?
var toTransitionalOverlayView: UIView?
var fromMediaFrame: CGRect?
var pendingCompletion: (() -> ())?
init(galleryItem: MediaGalleryViewModel.Item, interactionController: MediaInteractiveDismiss? = nil) {
self.mediaItem = .gallery(galleryItem)
self.interactionController = interactionController
}
init(image: UIImage, interactionController: MediaInteractiveDismiss? = nil) {
self.mediaItem = .image(image)
self.interactionController = interactionController
}
}
extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.3
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let containerView = transitionContext.containerView
let fromContextProvider: MediaPresentationContextProvider
let toContextProvider: MediaPresentationContextProvider
guard let fromVC: UIViewController = transitionContext.viewController(forKey: .from) else {
transitionContext.completeTransition(false)
return
}
guard let toVC: UIViewController = transitionContext.viewController(forKey: .to) else {
transitionContext.completeTransition(false)
return
}
switch fromVC {
case let contextProvider as MediaPresentationContextProvider:
fromContextProvider = contextProvider
case let navController as UINavigationController:
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
transitionContext.completeTransition(false)
return
}
fromContextProvider = contextProvider
default:
transitionContext.completeTransition(false)
return
}
switch toVC {
case let contextProvider as MediaPresentationContextProvider:
toVC.view.layoutIfNeeded()
toContextProvider = contextProvider
case let navController as UINavigationController:
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
transitionContext.completeTransition(false)
return
}
toVC.view.layoutIfNeeded()
toContextProvider = contextProvider
default:
transitionContext.completeTransition(false)
return
}
guard let fromMediaContext: MediaPresentationContext = fromContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView) else {
transitionContext.completeTransition(false)
return
}
guard let presentationImage: UIImage = mediaItem.image else {
transitionContext.completeTransition(true)
return
}
// fromView will be nil if doing a presentation, in which case we don't want to add the view -
// it will automatically be added to the view hierarchy, in front of the VC we're presenting from
if let fromView: UIView = transitionContext.view(forKey: .from) {
self.fromView = fromView
containerView.addSubview(fromView)
}
// toView will be nil if doing a modal dismiss, in which case we don't want to add the view -
// it's already in the view hierarchy, behind the VC we're dismissing.
if let toView: UIView = transitionContext.view(forKey: .to) {
containerView.insertSubview(toView, at: 0)
}
let toMediaContext: MediaPresentationContext? = toContextProvider.mediaPresentationContext(mediaItem: mediaItem, in: containerView)
let duration: CGFloat = transitionDuration(using: transitionContext)
fromMediaContext.mediaView.alpha = 0
toMediaContext?.mediaView.alpha = 0
let transitionView = UIImageView(image: presentationImage)
transitionView.frame = fromMediaContext.presentationFrame
transitionView.contentMode = MediaView.contentMode
transitionView.layer.masksToBounds = true
transitionView.layer.cornerRadius = fromMediaContext.cornerRadius
transitionView.layer.maskedCorners = (toMediaContext?.cornerMask ?? fromMediaContext.cornerMask)
containerView.addSubview(transitionView)
// Add any UI elements which should appear above the media view
self.fromTransitionalOverlayView = {
guard let (overlayView, overlayViewFrame) = fromContextProvider.snapshotOverlayView(in: containerView) else {
return nil
}
overlayView.frame = overlayViewFrame
containerView.addSubview(overlayView)
return overlayView
}()
self.toTransitionalOverlayView = { [weak self] in
guard let (overlayView, overlayViewFrame) = toContextProvider.snapshotOverlayView(in: containerView) else {
return nil
}
// Only fade in the 'toTransitionalOverlayView' if it's bigger than the origin
// one (makes it look cleaner as you don't get the crossfade effect)
if (self?.fromTransitionalOverlayView?.frame.size.height ?? 0) > overlayViewFrame.height {
overlayView.alpha = 0
}
overlayView.frame = overlayViewFrame
if let fromTransitionalOverlayView = self?.fromTransitionalOverlayView {
containerView.insertSubview(overlayView, belowSubview: fromTransitionalOverlayView)
}
else {
containerView.addSubview(overlayView)
}
return overlayView
}()
self.transitionView = transitionView
self.fromMediaFrame = transitionView.frame
self.pendingCompletion = {
let destinationFromAlpha: CGFloat
let destinationFrame: CGRect
let destinationCornerRadius: CGFloat
if transitionContext.transitionWasCancelled {
destinationFromAlpha = 1
destinationFrame = fromMediaContext.presentationFrame
destinationCornerRadius = fromMediaContext.cornerRadius
}
else if let toMediaContext: MediaPresentationContext = toMediaContext {
destinationFromAlpha = 0
destinationFrame = toMediaContext.presentationFrame
destinationCornerRadius = toMediaContext.cornerRadius
}
else {
// `toMediaContext` can be nil if the target item is scrolled off of the
// contextProvider's screen, so we synthesize a context to dismiss the item
// off screen
destinationFromAlpha = 0
destinationFrame = fromMediaContext.presentationFrame
.offsetBy(dx: 0, dy: (containerView.bounds.height * 2))
destinationCornerRadius = fromMediaContext.cornerRadius
}
UIView.animate(
withDuration: duration,
delay: 0,
options: [.beginFromCurrentState, .curveEaseInOut],
animations: { [weak self] in
self?.fromTransitionalOverlayView?.alpha = destinationFromAlpha
self?.fromView?.alpha = destinationFromAlpha
self?.toTransitionalOverlayView?.alpha = (1.0 - destinationFromAlpha)
transitionView.frame = destinationFrame
transitionView.layer.cornerRadius = destinationCornerRadius
},
completion: { [weak self] _ in
self?.fromView?.alpha = 1
fromMediaContext.mediaView.alpha = 1
toMediaContext?.mediaView.alpha = 1
transitionView.removeFromSuperview()
self?.fromTransitionalOverlayView?.removeFromSuperview()
self?.toTransitionalOverlayView?.removeFromSuperview()
if transitionContext.transitionWasCancelled {
// The "to" view will be nil if we're doing a modal dismiss, in which case
// we wouldn't want to remove the toView.
transitionContext.view(forKey: .to)?.removeFromSuperview()
// Note: We shouldn't need to do this but for some reason it's not
// automatically getting re-enabled so we manually enable it
transitionContext.view(forKey: .from)?.isUserInteractionEnabled = true
}
else {
transitionContext.view(forKey: .from)?.removeFromSuperview()
// Note: We shouldn't need to do this but for some reason it's not
// automatically getting re-enabled so we manually enable it
transitionContext.view(forKey: .to)?.isUserInteractionEnabled = true
}
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
)
}
// The interactive transition will call the 'pendingCompletion' when it completes so don't call it here
guard !transitionContext.isInteractive else { return }
self.pendingCompletion?()
self.pendingCompletion = nil
}
}
extension MediaDismissAnimationController: InteractiveDismissDelegate {
func interactiveDismissUpdate(_ interactiveDismiss: UIPercentDrivenInteractiveTransition, didChangeTouchOffset offset: CGPoint) {
guard let transitionView: UIView = transitionView else { return } // Transition hasn't started yet
guard let fromMediaFrame: CGRect = fromMediaFrame else { return }
fromView?.alpha = (1.0 - interactiveDismiss.percentComplete)
transitionView.center = fromMediaFrame.offsetBy(dx: offset.x, dy: offset.y).center
}
func interactiveDismissDidFinish(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) {
self.pendingCompletion?()
self.pendingCompletion = nil
}
}

View File

@ -0,0 +1,126 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
// MARK: - InteractivelyDismissableViewController
protocol InteractivelyDismissableViewController: UIViewController {
func performInteractiveDismissal(animated: Bool)
}
// MARK: - InteractiveDismissDelegate
protocol InteractiveDismissDelegate: AnyObject {
func interactiveDismissUpdate(_ interactiveDismiss: UIPercentDrivenInteractiveTransition, didChangeTouchOffset offset: CGPoint)
func interactiveDismissDidFinish(_ interactiveDismiss: UIPercentDrivenInteractiveTransition)
}
// MARK: - MediaInteractiveDismiss
class MediaInteractiveDismiss: UIPercentDrivenInteractiveTransition {
var interactionInProgress = false
weak var interactiveDismissDelegate: InteractiveDismissDelegate?
private weak var targetViewController: InteractivelyDismissableViewController?
init(targetViewController: InteractivelyDismissableViewController) {
super.init()
self.targetViewController = targetViewController
}
public func addGestureRecognizer(to view: UIView) {
let gesture: DirectionalPanGestureRecognizer = DirectionalPanGestureRecognizer(direction: .vertical, target: self, action: #selector(handleGesture(_:)))
// Allow panning with trackpad
if #available(iOS 13.4, *) { gesture.allowedScrollTypesMask = .continuous }
view.addGestureRecognizer(gesture)
}
// MARK: - Private
private var fastEnoughToCompleteTransition = false
private var farEnoughToCompleteTransition = false
private var lastProgress: CGFloat = 0
private var lastIncreasedProgress: CGFloat = 0
private var shouldCompleteTransition: Bool {
if farEnoughToCompleteTransition { return true }
if fastEnoughToCompleteTransition { return true }
return false
}
@objc private func handleGesture(_ gestureRecognizer: UIScreenEdgePanGestureRecognizer) {
guard let coordinateSpace = gestureRecognizer.view?.superview else { return }
if case .began = gestureRecognizer.state {
gestureRecognizer.setTranslation(.zero, in: coordinateSpace)
}
let totalDistance: CGFloat = 100
let velocityThreshold: CGFloat = 500
switch gestureRecognizer.state {
case .began:
interactionInProgress = true
targetViewController?.performInteractiveDismissal(animated: true)
case .changed:
let velocity = abs(gestureRecognizer.velocity(in: coordinateSpace).y)
if velocity > velocityThreshold {
fastEnoughToCompleteTransition = true
}
let offset = gestureRecognizer.translation(in: coordinateSpace)
let progress = abs(offset.y) / totalDistance
// `farEnoughToCompleteTransition` is cancelable if the user reverses direction
farEnoughToCompleteTransition = (progress >= 0.5)
// If the user has reverted enough progress then we want to reset the velocity
// flag (don't want the user to start quickly, slowly drag it back end end up
// dismissing the screen)
if (lastIncreasedProgress - progress) > 0.2 || progress < 0.05 {
fastEnoughToCompleteTransition = false
}
update(progress)
lastIncreasedProgress = (progress > lastProgress ? progress : lastIncreasedProgress)
lastProgress = progress
interactiveDismissDelegate?.interactiveDismissUpdate(self, didChangeTouchOffset: offset)
case .cancelled:
interactiveDismissDelegate?.interactiveDismissDidFinish(self)
cancel()
interactionInProgress = false
farEnoughToCompleteTransition = false
fastEnoughToCompleteTransition = false
lastIncreasedProgress = 0
lastProgress = 0
case .ended:
if shouldCompleteTransition {
finish()
}
else {
cancel()
}
interactiveDismissDelegate?.interactiveDismissDidFinish(self)
interactionInProgress = false
farEnoughToCompleteTransition = false
fastEnoughToCompleteTransition = false
lastIncreasedProgress = 0
lastProgress = 0
default:
break
}
}
}

Some files were not shown because too many files have changed in this diff Show More