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>
<CodeCoverageTargets>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D221A088169C9E5E00537ABF"
BuildableName = "Session.app"
BlueprintName = "Session"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7BC01A3A241F40AB00BC7C55"
BuildableName = "SessionNotificationServiceExtension.appex"
BlueprintName = "SessionNotificationServiceExtension"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "453518671FC635DD00210559"
BuildableName = "SessionShareExtension.appex"
BlueprintName = "SessionShareExtension"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
BuildableName = "SessionMessagingKit.framework"
BlueprintName = "SessionMessagingKit"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C3C2A59E255385C100C340D1"
BuildableName = "SessionSnodeKit.framework"
BlueprintName = "SessionSnodeKit"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C331FF1A2558F9D300070591"
BuildableName = "SessionUIKit.framework"
BlueprintName = "SessionUIKit"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
BuildableName = "SessionUtilitiesKit.framework"
BlueprintName = "SessionUtilitiesKit"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C33FD9AA255A548A00E217F9"
BuildableName = "SignalUtilitiesKit.framework"
BlueprintName = "SignalUtilitiesKit"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</CodeCoverageTargets>
<Testables>
<TestableReference
skipped = "NO">
skipped = "NO"
parallelizable = "YES"
testExecutionOrdering = "random">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D221A0A9169C9E5F00537ABF"
BuildableName = "SignalTests.xctest"
BlueprintName = "SignalTests"
BlueprintIdentifier = "FDC4388D27B9FFC700C60D73"
BuildableName = "SessionMessagingKitTests.xctest"
BlueprintName = "SessionMessagingKitTests"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
skipped = "NO"
parallelizable = "YES"
testExecutionOrdering = "random">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B772E882F193AA2F25932C514BBF0805"
BuildableName = "SignalServiceKit-Unit-Tests.xctest"
BlueprintName = "SignalServiceKit-Unit-Tests"
ReferencedContainer = "container:Pods/Pods.xcodeproj">
</BuildableReference>
<SkippedTests>
<Test
Identifier = "ContactSortingTest">
</Test>
<Test
Identifier = "DeviceNamesTest">
</Test>
<Test
Identifier = "JobQueueTest">
</Test>
<Test
Identifier = "MessageSenderJobQueueTest">
</Test>
<Test
Identifier = "OWSAnalyticsTests">
</Test>
<Test
Identifier = "OWSDeviceProvisionerTest">
</Test>
<Test
Identifier = "OWSDisappearingMessageFinderTest">
</Test>
<Test
Identifier = "OWSDisappearingMessagesConfigurationTest">
</Test>
<Test
Identifier = "OWSDisappearingMessagesJobTest">
</Test>
<Test
Identifier = "OWSFingerprintTest">
</Test>
<Test
Identifier = "OWSIncomingMessageFinderTest">
</Test>
<Test
Identifier = "OWSLinkPreviewTest">
</Test>
<Test
Identifier = "OWSMessageManagerTest">
</Test>
<Test
Identifier = "OWSMessageSenderTest">
</Test>
<Test
Identifier = "OWSProvisioningCipherTest">
</Test>
<Test
Identifier = "OWSSignalAddressTest">
</Test>
<Test
Identifier = "OWSUDManagerTest">
</Test>
<Test
Identifier = "PhoneNumberTest">
</Test>
<Test
Identifier = "PhoneNumberUtilTest">
</Test>
<Test
Identifier = "SSKBaseTestObjC">
</Test>
<Test
Identifier = "SSKBaseTestSwift">
</Test>
<Test
Identifier = "SSKMessageSenderJobRecordTest">
</Test>
<Test
Identifier = "SignalRecipientTest">
</Test>
<Test
Identifier = "SignedPreKeyDeletionTests">
</Test>
<Test
Identifier = "TSContactThreadTest">
</Test>
<Test
Identifier = "TSGroupThreadTest">
</Test>
<Test
Identifier = "TSMessageStorageTests">
</Test>
<Test
Identifier = "TSMessageTest">
</Test>
<Test
Identifier = "TSOutgoingMessageTest">
</Test>
<Test
Identifier = "TSStorageIdentityKeyStoreTests">
</Test>
<Test
Identifier = "TSStoragePreKeyStoreTests">
</Test>
<Test
Identifier = "TSThreadTest">
</Test>
</SkippedTests>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5C9F6BA9ADC4724B2612C9F20FBE2076"
BuildableName = "SignalCoreKit-Unit-Tests.xctest"
BlueprintName = "SignalCoreKit-Unit-Tests"
ReferencedContainer = "container:Pods/Pods.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF2BCB29C9D47F15FB156F1EC64E5CC2"
BuildableName = "AxolotlKit-Unit-Tests.xctest"
BlueprintName = "AxolotlKit-Unit-Tests"
ReferencedContainer = "container:Pods/Pods.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "78DE33AED82B26B4B8D899CC403003AF"
BuildableName = "Curve25519Kit-Unit-Tests.xctest"
BlueprintName = "Curve25519Kit-Unit-Tests"
ReferencedContainer = "container:Pods/Pods.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AF7FC2C93AA68E33600807F168BD483A"
BuildableName = "HKDFKit-Unit-Tests.xctest"
BlueprintName = "HKDFKit-Unit-Tests"
ReferencedContainer = "container:Pods/Pods.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B086B0C72F8A5814FF48795531F21635"
BuildableName = "SignalMetadataKit-Unit-Tests.xctest"
BlueprintName = "SignalMetadataKit-Unit-Tests"
ReferencedContainer = "container:Pods/Pods.xcodeproj">
BlueprintIdentifier = "FD83B9AE27CF200A005E1583"
BuildableName = "SessionUtilitiesKitTests.xctest"
BlueprintName = "SessionUtilitiesKitTests"
ReferencedContainer = "container:Session.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>

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

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 let call = currentCall else { return }
if let reason = reason {
self.provider?.reportCall(with: call.callID, endedAt: nil, reason: reason)
switch (reason) {
case .answeredElsewhere: call.updateCallMessage(mode: .answeredElsewhere)
case .unanswered: call.updateCallMessage(mode: .unanswered)
case .declinedElsewhere: call.updateCallMessage(mode: .local)
default: call.updateCallMessage(mode: .remote)
guard Thread.isMainThread else {
DispatchQueue.main.async {
self.reportCurrentCallEnded(reason: reason)
}
} else {
return
}
guard let call = currentCall else { return }
if let reason = reason {
self.provider?.reportCall(with: call.callId, endedAt: nil, reason: reason)
switch (reason) {
case .answeredElsewhere: call.updateCallMessage(mode: .answeredElsewhere)
case .unanswered: call.updateCallMessage(mode: .unanswered)
case .declinedElsewhere: call.updateCallMessage(mode: .local)
default: call.updateCallMessage(mode: .remote)
}
}
else {
call.updateCallMessage(mode: .local)
}
call.webRTCSession.dropConnection()
self.currentCall = nil
WebRTCSession.current = nil
UserDefaults(suiteName: "group.com.loki-project.loki-messenger")?.set(false, forKey: "isCallOngoing")
UserDefaults.sharedLokiProject?.set(false, forKey: "isCallOngoing")
}
// MARK: Util
// MARK: - Util
private func disableUnsupportedFeatures(callUpdate: CXCallUpdate) {
// Call Holding is failing to restart audio when "swapping" calls on the CallKit screen
// until user returns to in-app call screen.
@ -136,17 +172,67 @@ public final class SessionCallManager: NSObject {
callUpdate.supportsDTMF = false
}
public func handleIncomingCallOfferInBusyState(offerMessage: CallMessage, using transaction: YapDatabaseReadWriteTransaction) {
guard let caller = offerMessage.sender, let thread = TSContactThread.fetch(for: caller, using: transaction) else { return }
let message = CallMessage()
message.uuid = offerMessage.uuid
message.kind = .endCall
SNLog("[Calls] Sending end call message because there is an ongoing call.")
MessageSender.sendNonDurably(message, in: thread, using: transaction).retainUntilComplete()
let infoMessage = TSInfoMessage.from(offerMessage, associatedWith: thread)
infoMessage.updateCallInfoMessage(.missed, using: transaction)
// MARK: - UI
public func showCallUIForCall(caller: String, uuid: String, mode: CallMode, interactionId: Int64?) {
guard Thread.isMainThread else {
DispatchQueue.main.async {
self.showCallUIForCall(caller: caller, uuid: uuid, mode: mode, interactionId: interactionId)
}
return
}
guard let call: SessionCall = Storage.shared.read({ db in SessionCall(db, for: caller, uuid: uuid, mode: mode) }) else {
return
}
call.callInteractionId = interactionId
call.reportIncomingCallIfNeeded { error in
if let error = error {
SNLog("[Calls] Failed to report incoming call to CallKit due to error: \(error)")
return
}
guard CurrentAppContext().isMainAppAndActive else { return }
guard let presentingVC = CurrentAppContext().frontmostViewController() else {
preconditionFailure() // FIXME: Handle more gracefully
}
if let conversationVC: ConversationVC = presentingVC as? ConversationVC, conversationVC.viewModel.threadData.threadId == call.sessionId {
let callVC = CallVC(for: call)
callVC.conversationVC = conversationVC
conversationVC.inputAccessoryView?.isHidden = true
conversationVC.inputAccessoryView?.alpha = 0
presentingVC.present(callVC, animated: true, completion: nil)
}
else if !Preferences.isCallKitSupported {
let incomingCallBanner = IncomingCallBanner(for: call)
incomingCallBanner.show()
}
}
}
public func handleAnswerMessage(_ message: CallMessage) {
guard Thread.isMainThread else {
DispatchQueue.main.async {
self.handleAnswerMessage(message)
}
return
}
(CurrentAppContext().frontmostViewController() as? CallVC)?.handleAnswerMessage(message)
}
public func dismissAllCallUI() {
guard Thread.isMainThread else {
DispatchQueue.main.async {
self.dismissAllCallUI()
}
return
}
IncomingCallBanner.current?.dismiss()
(CurrentAppContext().frontmostViewController() as? CallVC)?.handleEndCallMessage()
MiniCallView.current?.dismiss()
}
}

View File

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

View File

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

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

View File

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

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

View File

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

@ -1,18 +1,18 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class MediaPlaceholderView : UIView {
private let viewItem: ConversationViewItem
private let textColor: UIColor
// MARK: Settings
import UIKit
import SessionMessagingKit
final class MediaPlaceholderView: UIView {
private static let iconSize: CGFloat = 24
private static let iconImageViewSize: CGFloat = 40
// MARK: Lifecycle
init(viewItem: ConversationViewItem, textColor: UIColor) {
self.viewItem = viewItem
self.textColor = textColor
// MARK: - Lifecycle
init(cellViewModel: MessageViewModel, textColor: UIColor) {
super.init(frame: CGRect.zero)
setUpViewHierarchy()
setUpViewHierarchy(cellViewModel: cellViewModel, textColor: textColor)
}
override init(frame: CGRect) {
@ -23,32 +23,47 @@ final class MediaPlaceholderView : UIView {
preconditionFailure("Use init(viewItem:textColor:) instead.")
}
private func setUpViewHierarchy() {
private func setUpViewHierarchy(
cellViewModel: MessageViewModel,
textColor: UIColor
) {
let (iconName, attachmentDescription): (String, String) = {
guard let message = viewItem.interaction as? TSIncomingMessage else { return ("actionsheet_document_black", "file") } // Should never occur
var attachments: [TSAttachment] = []
Storage.read { transaction in
attachments = message.attachments(with: transaction)
guard
cellViewModel.variant == .standardIncoming,
let attachment: Attachment = cellViewModel.attachments?.first
else {
return ("actionsheet_document_black", "file") // Should never occur
}
guard let contentType = attachments.first?.contentType else { return ("actionsheet_document_black", "file") } // Should never occur
if MIMETypeUtil.isAudio(contentType) { return ("attachment_audio", "audio") }
if MIMETypeUtil.isImage(contentType) || MIMETypeUtil.isVideo(contentType) { return ("actionsheet_camera_roll_black", "media") }
if attachment.isAudio { return ("attachment_audio", "audio") }
if attachment.isImage || attachment.isVideo { return ("actionsheet_camera_roll_black", "media") }
return ("actionsheet_document_black", "file")
}()
// Image view
let iconSize = MediaPlaceholderView.iconSize
let icon = UIImage(named: iconName)?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
let imageView = UIImageView(image: icon)
let imageView = UIImageView(
image: UIImage(named: iconName)?
.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,13 +1,13 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import UIKit
import YYImage
import SessionUIKit
import SessionMessagingKit
@objc(OWSMediaView)
public class MediaView: UIView {
static let contentMode: UIView.ContentMode = .scaleAspectFill
private enum MediaError {
case missing
case invalid
@ -17,8 +17,7 @@ public class MediaView: UIView {
// MARK: -
private let mediaCache: NSCache<NSString, AnyObject>
@objc
public let attachment: TSAttachment
public let attachment: Attachment
private let isOutgoing: Bool
private let maxMessageWidth: CGFloat
private var loadBlock: (() -> Void)?
@ -42,50 +41,16 @@ public class MediaView: UIView {
case failed
}
// Thread-safe access to load state.
//
// We use a "box" class so that we can capture a reference
// to this box (rather than self) and a) safely access
// if off the main thread b) not prevent deallocation of
// self.
private class ThreadSafeLoadState {
private var value: LoadState
required init(_ value: LoadState) {
self.value = value
}
func get() -> LoadState {
objc_sync_enter(self)
let valueCopy = value
objc_sync_exit(self)
return valueCopy
}
func set(_ newValue: LoadState) {
objc_sync_enter(self)
value = newValue
objc_sync_exit(self)
}
}
private let threadSafeLoadState = ThreadSafeLoadState(.unloaded)
// Convenience accessors.
private var loadState: LoadState {
get {
return threadSafeLoadState.get()
}
set {
threadSafeLoadState.set(newValue)
}
}
private let loadState: Atomic<LoadState> = Atomic(.unloaded)
// MARK: - Initializers
@objc
public required init(mediaCache: NSCache<NSString, AnyObject>,
attachment: TSAttachment,
isOutgoing: Bool,
maxMessageWidth: CGFloat) {
public required init(
mediaCache: NSCache<NSString, AnyObject>,
attachment: Attachment,
isOutgoing: Bool,
maxMessageWidth: CGFloat
) {
self.mediaCache = mediaCache
self.attachment = attachment
self.isOutgoing = isOutgoing
@ -105,9 +70,7 @@ public class MediaView: UIView {
}
deinit {
AssertIsOnMainThread()
loadState = .unloaded
loadState.mutate { $0 = .unloaded }
}
// MARK: -
@ -115,41 +78,45 @@ public class MediaView: UIView {
private func createContents() {
AssertIsOnMainThread()
guard let attachmentStream = attachment as? TSAttachmentStream else {
guard attachment.state != .pendingDownload && attachment.state != .downloading else {
addDownloadProgressIfNecessary()
return
}
guard !isFailedDownload else {
guard attachment.state != .failedDownload else {
configure(forError: .failed)
return
}
if attachmentStream.isAnimated {
configureForAnimatedImage(attachmentStream: attachmentStream)
} else if attachmentStream.isImage {
configureForStillImage(attachmentStream: attachmentStream)
} else if attachmentStream.isVideo {
configureForVideo(attachmentStream: attachmentStream)
} else {
guard attachment.isValid else {
configure(forError: .invalid)
return
}
if attachment.isAnimated {
configureForAnimatedImage(attachment: attachment)
}
else if attachment.isImage {
configureForStillImage(attachment: attachment)
}
else if attachment.isVideo {
configureForVideo(attachment: attachment)
}
else {
owsFailDebug("Attachment has unexpected type.")
configure(forError: .invalid)
}
}
private func addDownloadProgressIfNecessary() {
guard !isFailedDownload else {
guard attachment.state != .failedDownload else {
configure(forError: .failed)
return
}
guard let attachmentPointer = attachment as? TSAttachmentPointer else {
owsFailDebug("Attachment has unexpected type.")
configure(forError: .invalid)
return
}
guard attachmentPointer.pointerType == .incoming else {
guard attachment.state != .uploading && attachment.state != .uploaded else {
// TODO: Show "restoring" indicator and possibly progress.
configure(forError: .missing)
return
}
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
let loader = MediaLoaderView()
addSubview(loader)
@ -158,65 +125,71 @@ 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)
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))")
return
}
animatedImageView.image = image
},
cacheKey: cacheKey)
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: attachment.id
)
}
unloadBlock = {
AssertIsOnMainThread()
@ -225,23 +198,21 @@ public class MediaView: UIView {
}
}
private func configureForStillImage(attachmentStream: TSAttachmentStream) {
guard let cacheKey = attachmentStream.uniqueId else {
owsFailDebug("Attachment stream missing unique ID.")
return
}
private func configureForStillImage(attachment: Attachment) {
let stillImageView = UIImageView()
// We need to specify a contentMode since the size of the image
// might not match the aspect ratio of the view.
stillImageView.contentMode = .scaleAspectFill
stillImageView.contentMode = MediaView.contentMode
// Use trilinear filters for better scaling quality at
// some performance cost.
stillImageView.layer.minificationFilter = .trilinear
stillImageView.layer.magnificationFilter = .trilinear
stillImageView.backgroundColor = Colors.unimportant
stillImageView.isHidden = !attachment.isValid
addSubview(stillImageView)
stillImageView.autoPinEdgesToSuperviewEdges()
_ = addUploadProgressIfNecessary(stillImageView)
loadBlock = { [weak self] in
AssertIsOnMainThread()
@ -249,29 +220,35 @@ public class MediaView: UIView {
owsFailDebug("Unexpectedly already loaded.")
return
}
self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
guard attachmentStream.isValidImage else {
Logger.warn("Ignoring invalid attachment.")
return nil
}
return attachmentStream.thumbnailImageLarge(success: { (image) in
self?.tryToLoadMedia(
loadMediaBlock: { applyMediaBlock in
guard attachment.isValid else {
self?.configure(forError: .invalid)
return
}
attachment.thumbnail(
size: .large,
success: { image, _ in applyMediaBlock(image) },
failure: {
Logger.error("Could not load thumbnail")
self?.configure(forError: .invalid)
}
)
},
applyMediaBlock: { media in
AssertIsOnMainThread()
guard let image: UIImage = media as? UIImage else {
owsFailDebug("Media has unexpected type: \(type(of: media))")
self?.configure(forError: .invalid)
return
}
stillImageView.image = image
}, failure: {
Logger.error("Could not load thumbnail")
})
},
applyMediaBlock: { (media) in
AssertIsOnMainThread()
guard let image = media as? UIImage else {
owsFailDebug("Media has unexpected type: \(type(of: media))")
return
}
stillImageView.image = image
},
cacheKey: cacheKey)
},
cacheKey: attachment.id
)
}
unloadBlock = {
AssertIsOnMainThread()
@ -280,20 +257,17 @@ public class MediaView: UIView {
}
}
private func configureForVideo(attachmentStream: TSAttachmentStream) {
guard let cacheKey = attachmentStream.uniqueId else {
owsFailDebug("Attachment stream missing unique ID.")
return
}
private func configureForVideo(attachment: Attachment) {
let stillImageView = UIImageView()
// We need to specify a contentMode since the size of the image
// might not match the aspect ratio of the view.
stillImageView.contentMode = .scaleAspectFill
stillImageView.contentMode = MediaView.contentMode
// Use trilinear filters for better scaling quality at
// some performance cost.
stillImageView.layer.minificationFilter = .trilinear
stillImageView.layer.magnificationFilter = .trilinear
stillImageView.backgroundColor = Colors.unimportant
stillImageView.isHidden = !attachment.isValid
addSubview(stillImageView)
stillImageView.autoPinEdgesToSuperviewEdges()
@ -314,29 +288,35 @@ public class MediaView: UIView {
owsFailDebug("Unexpectedly already loaded.")
return
}
self?.tryToLoadMedia(loadMediaBlock: { () -> AnyObject? in
guard attachmentStream.isValidVideo else {
Logger.warn("Ignoring invalid attachment.")
return nil
}
return attachmentStream.thumbnailImageMedium(success: { (image) in
self?.tryToLoadMedia(
loadMediaBlock: { applyMediaBlock in
guard attachment.isValid else {
self?.configure(forError: .invalid)
return
}
attachment.thumbnail(
size: .medium,
success: { image, _ in applyMediaBlock(image) },
failure: {
Logger.error("Could not load thumbnail")
self?.configure(forError: .invalid)
}
)
},
applyMediaBlock: { media in
AssertIsOnMainThread()
guard let image: UIImage = media as? UIImage else {
owsFailDebug("Media has unexpected type: \(type(of: media))")
self?.configure(forError: .invalid)
return
}
stillImageView.image = image
}, failure: {
Logger.error("Could not load thumbnail")
})
},
applyMediaBlock: { (media) in
AssertIsOnMainThread()
guard let image = media as? UIImage else {
owsFailDebug("Media has unexpected type: \(type(of: media))")
return
}
stillImageView.image = image
},
cacheKey: cacheKey)
},
cacheKey: attachment.id
)
}
unloadBlock = {
AssertIsOnMainThread()
@ -345,100 +325,115 @@ public class MediaView: UIView {
}
}
private var isFailedDownload: Bool {
guard let attachmentPointer = attachment as? TSAttachmentPointer else {
return false
}
return attachmentPointer.state == .failed
}
private func configure(forError error: MediaError) {
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
let icon: UIImage
switch error {
case .failed:
guard let asset = UIImage(named: "media_retry") else {
owsFailDebug("Missing image")
return
// When there is a failure in the 'loadMediaBlock' closure this can be called
// on a background thread - rather than dispatching in every 'loadMediaBlock'
// usage we just do so here
guard Thread.isMainThread else {
DispatchQueue.main.async {
self.configure(forError: error)
}
icon = asset
case .invalid:
guard let asset = UIImage(named: "media_invalid") else {
owsFailDebug("Missing image")
return
}
icon = asset
case .missing:
return
}
let icon: UIImage
switch error {
case .failed:
guard let asset = UIImage(named: "media_retry") else {
owsFailDebug("Missing image")
return
}
icon = asset
case .invalid:
guard let asset = UIImage(named: "media_invalid") else {
owsFailDebug("Missing image")
return
}
icon = asset
case .missing: return
}
backgroundColor = (isDarkMode ? .ows_gray90 : .ows_gray05)
// For failed ougoing messages add an overlay to make the icon more visible
if isOutgoing {
let attachmentOverlayView: UIView = UIView()
attachmentOverlayView.backgroundColor = Colors.navigationBarBackground
.withAlphaComponent(Values.lowOpacity)
addSubview(attachmentOverlayView)
attachmentOverlayView.pin(to: self)
}
let iconView = UIImageView(image: icon.withRenderingMode(.alwaysTemplate))
iconView.tintColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
iconView.tintColor = Colors.text
.withAlphaComponent(Values.mediumOpacity)
addSubview(iconView)
iconView.autoCenterInSuperview()
}
private func tryToLoadMedia(loadMediaBlock: @escaping () -> AnyObject?,
applyMediaBlock: @escaping (AnyObject) -> Void,
cacheKey: String) {
AssertIsOnMainThread()
private func tryToLoadMedia(
loadMediaBlock: @escaping (@escaping (AnyObject?) -> Void) -> Void,
applyMediaBlock: @escaping (AnyObject) -> Void,
cacheKey: String
) {
// It's critical that we update loadState once
// our load attempt is complete.
let loadCompletion: (AnyObject?) -> Void = { [weak self] (possibleMedia) in
AssertIsOnMainThread()
guard let strongSelf = self else {
return
}
guard strongSelf.loadState == .loading else {
let loadCompletion: (AnyObject?) -> Void = { [weak self] possibleMedia in
guard self?.loadState.wrappedValue == .loading else {
Logger.verbose("Skipping obsolete load.")
return
}
guard let media = possibleMedia else {
strongSelf.loadState = .failed
guard let media: AnyObject = possibleMedia else {
self?.loadState.mutate { $0 = .failed }
// TODO:
// [self showAttachmentErrorViewWithMediaView:mediaView];
return
}
applyMediaBlock(media)
strongSelf.loadState = .loaded
self?.mediaCache.setObject(media, forKey: cacheKey as NSString)
self?.loadState.mutate { $0 = .loaded }
}
guard loadState == .loading else {
guard loadState.wrappedValue == .loading else {
owsFailDebug("Unexpected load state: \(loadState)")
return
}
let mediaCache = self.mediaCache
if let media = mediaCache.object(forKey: cacheKey as NSString) {
if let media: AnyObject = self.mediaCache.object(forKey: cacheKey as NSString) {
Logger.verbose("media cache hit")
guard Thread.isMainThread else {
DispatchQueue.main.async {
loadCompletion(media)
}
return
}
loadCompletion(media)
return
}
Logger.verbose("media cache miss")
let threadSafeLoadState = self.threadSafeLoadState
MediaView.loadQueue.async {
guard threadSafeLoadState.get() == .loading else {
MediaView.loadQueue.async { [weak self] in
guard self?.loadState.wrappedValue == .loading else {
Logger.verbose("Skipping obsolete load.")
return
}
guard let media = loadMediaBlock() else {
Logger.error("Failed to load media.")
DispatchQueue.main.async {
loadCompletion(nil)
loadMediaBlock { media in
guard Thread.isMainThread else {
DispatchQueue.main.async {
loadCompletion(media)
}
return
}
return
}
DispatchQueue.main.async {
mediaCache.setObject(media, forKey: cacheKey as NSString)
loadCompletion(media)
}
}
@ -459,32 +454,18 @@ public class MediaView: UIView {
// "skip rate" of obsolete loads.
private static let loadQueue = ReverseDispatchQueue(label: "org.signal.asyncMediaLoadQueue")
@objc
public func loadMedia() {
AssertIsOnMainThread()
switch loadState {
case .unloaded:
loadState = .loading
guard let loadBlock = loadBlock else {
return
}
loadBlock()
case .loading, .loaded, .failed:
break
switch loadState.wrappedValue {
case .unloaded:
loadState.mutate { $0 = .loading }
loadBlock?()
case .loading, .loaded, .failed: break
}
}
@objc
public func unloadMedia() {
AssertIsOnMainThread()
loadState = .unloaded
guard let unloadBlock = unloadBlock else {
return
}
unloadBlock()
loadState.mutate { $0 = .unloaded }
unloadBlock?()
}
}

View File

@ -1,30 +1,24 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class OpenGroupInvitationView : UIView {
private let name: String
private let rawURL: String
private let textColor: UIColor
private let isOutgoing: Bool
private lazy var url: String = {
if let range = rawURL.range(of: "?public_key=") {
return String(rawURL[..<range.lowerBound])
} else {
return rawURL
}
}()
// MARK: Settings
import UIKit
import SessionUIKit
import SessionMessagingKit
final class OpenGroupInvitationView: UIView {
private static let iconSize: CGFloat = 24
private static let iconImageViewSize: CGFloat = 48
// MARK: Lifecycle
// MARK: - Lifecycle
init(name: String, url: String, textColor: UIColor, isOutgoing: Bool) {
self.name = name
self.rawURL = url
self.textColor = textColor
self.isOutgoing = isOutgoing
super.init(frame: CGRect.zero)
setUpViewHierarchy()
setUpViewHierarchy(
name: name,
rawUrl: url,
textColor: textColor,
isOutgoing: isOutgoing
)
}
override init(frame: CGRect) {
@ -35,41 +29,56 @@ final class OpenGroupInvitationView : UIView {
preconditionFailure("Use init(name:url:textColor:) instead.")
}
private func setUpViewHierarchy() {
private func setUpViewHierarchy(name: String, rawUrl: String, textColor: UIColor, isOutgoing: Bool) {
// Title
let titleLabel = UILabel()
titleLabel.lineBreakMode = .byTruncatingTail
titleLabel.text = name
titleLabel.textColor = textColor
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
// Subtitle
let subtitleLabel = UILabel()
subtitleLabel.lineBreakMode = .byTruncatingTail
subtitleLabel.text = NSLocalizedString("view_open_group_invitation_description", comment: "")
subtitleLabel.textColor = textColor
subtitleLabel.font = .systemFont(ofSize: Values.smallFontSize)
// URL
let urlLabel = UILabel()
urlLabel.lineBreakMode = .byCharWrapping
urlLabel.text = url
urlLabel.text = {
if let range = rawUrl.range(of: "?public_key=") {
return String(rawUrl[..<range.lowerBound])
}
return rawUrl
}()
urlLabel.textColor = textColor
urlLabel.numberOfLines = 0
urlLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
// Label stack
let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, UIView.vSpacer(2), subtitleLabel, UIView.vSpacer(4), urlLabel ])
labelStackView.axis = .vertical
// Icon
let iconSize = OpenGroupInvitationView.iconSize
let iconName = isOutgoing ? "Globe" : "Plus"
let icon = UIImage(named: iconName)?.withTint(.white)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
let iconName = (isOutgoing ? "Globe" : "Plus")
let iconImageViewSize = OpenGroupInvitationView.iconImageViewSize
let iconImageView = UIImageView(image: icon)
let iconImageView = UIImageView(
image: UIImage(named: iconName)?
.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.
final class QuoteView : UIView {
private let mode: Mode
private let thread: TSThread
private let direction: Direction
private let hInset: CGFloat
private let maxWidth: CGFloat
private let delegate: QuoteViewDelegate?
import UIKit
import SessionUIKit
import SessionMessagingKit
private var maxBodyLabelHeight: CGFloat {
switch mode {
case .regular: return 60
case .draft: return 40
}
}
private var attachments: [OWSAttachmentInfo] {
switch mode {
case .regular(let viewItem): return (viewItem.interaction as? TSMessage)?.quotedMessage!.quotedAttachments ?? []
case .draft(let model): return given(model.attachmentStream) { [ OWSAttachmentInfo(attachmentStream: $0) ] } ?? []
}
}
private var thumbnail: UIImage? {
switch mode {
case .regular(let viewItem): return viewItem.quotedReply!.thumbnailImage
case .draft(let model): return model.thumbnailImage
}
}
private var body: String? {
switch mode {
case .regular(let viewItem): return (viewItem.interaction as? TSMessage)?.quotedMessage!.body
case .draft(let model): return model.body
}
}
private var authorID: String {
switch mode {
case .regular(let viewItem): return viewItem.quotedReply!.authorId
case .draft(let model): return model.authorId
}
}
private var lineColor: UIColor {
switch (mode, AppModeManager.shared.currentAppMode) {
case (.regular, .light), (.draft, .light): return .black
case (.regular, .dark): return (direction == .outgoing) ? .black : Colors.accent
case (.draft, .dark): return Colors.accent
}
}
private var textColor: UIColor {
if case .draft = mode { return Colors.text }
switch (direction, AppModeManager.shared.currentAppMode) {
case (.outgoing, .dark), (.incoming, .light): return .black
default: return .white
}
}
// MARK: Mode
enum Mode {
case regular(ConversationViewItem)
case draft(OWSQuotedReplyModel)
}
// MARK: Direction
enum Direction { case incoming, outgoing }
// MARK: Settings
final class QuoteView: UIView {
static let thumbnailSize: CGFloat = 48
static let iconSize: CGFloat = 24
static let labelStackViewSpacing: CGFloat = 2
static let labelStackViewVMargin: CGFloat = 4
static let cancelButtonSize: CGFloat = 33
// MARK: Lifecycle
init(for viewItem: ConversationViewItem, in thread: TSThread?, direction: Direction, hInset: CGFloat, maxWidth: CGFloat) {
self.mode = .regular(viewItem)
self.thread = thread ?? TSThread.fetch(uniqueId: viewItem.interaction.uniqueThreadId)!
self.maxWidth = maxWidth
self.direction = direction
self.hInset = hInset
self.delegate = nil
super.init(frame: CGRect.zero)
setUpViewHierarchy()
enum Mode {
case regular
case draft
}
enum Direction { case incoming, outgoing }
// MARK: - Variables
private let onCancel: (() -> ())?
init(for model: OWSQuotedReplyModel, direction: Direction, hInset: CGFloat, maxWidth: CGFloat, delegate: QuoteViewDelegate) {
self.mode = .draft(model)
self.thread = TSThread.fetch(uniqueId: model.threadId)!
self.maxWidth = maxWidth
self.direction = direction
self.hInset = hInset
self.delegate = delegate
// MARK: - Lifecycle
init(
for mode: Mode,
authorId: String,
quotedText: String?,
threadVariant: SessionThread.Variant,
currentUserPublicKey: String?,
currentUserBlindedPublicKey: String?,
direction: Direction,
attachment: Attachment?,
hInset: CGFloat,
maxWidth: CGFloat,
onCancel: (() -> ())? = nil
) {
self.onCancel = onCancel
super.init(frame: CGRect.zero)
setUpViewHierarchy()
setUpViewHierarchy(
mode: mode,
authorId: authorId,
quotedText: quotedText,
threadVariant: threadVariant,
currentUserPublicKey: currentUserPublicKey,
currentUserBlindedPublicKey: currentUserBlindedPublicKey,
direction: direction,
attachment: attachment,
hInset: hInset,
maxWidth: maxWidth
)
}
override init(frame: CGRect) {
@ -105,14 +62,24 @@ final class QuoteView : UIView {
preconditionFailure("Use init(for:maxMessageWidth:) instead.")
}
private func setUpViewHierarchy() {
private func setUpViewHierarchy(
mode: Mode,
authorId: String,
quotedText: String?,
threadVariant: SessionThread.Variant,
currentUserPublicKey: String?,
currentUserBlindedPublicKey: String?,
direction: Direction,
attachment: Attachment?,
hInset: CGFloat,
maxWidth: CGFloat
) {
// There's quite a bit of calculation going on here. It's a bit complex so don't make changes
// if you don't need to. If you do then test:
// Quoted text in both private chats and group chats
// Quoted images and videos in both private chats and group chats
// Quoted voice messages and documents in both private chats and group chats
// All of the above in both dark mode and light mode
let hasAttachments = !attachments.isEmpty
let thumbnailSize = QuoteView.thumbnailSize
let iconSize = QuoteView.iconSize
let labelStackViewSpacing = QuoteView.labelStackViewSpacing
@ -120,18 +87,23 @@ final class QuoteView : UIView {
let smallSpacing = Values.smallSpacing
let cancelButtonSize = QuoteView.cancelButtonSize
var availableWidth: CGFloat
// Subtract smallSpacing twice; once for the spacing in between the stack view elements and
// once for the trailing margin.
if !hasAttachments {
if attachment == nil {
availableWidth = maxWidth - 2 * hInset - Values.accentLineThickness - 2 * smallSpacing
} else {
}
else {
availableWidth = maxWidth - 2 * hInset - thumbnailSize - 2 * smallSpacing
}
if case .draft = mode {
availableWidth -= cancelButtonSize
}
let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude)
var body = self.body
var body: String? = quotedText
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [])
mainStackView.axis = .horizontal
@ -139,49 +111,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 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import NVActivityIndicatorView
import SessionUIKit
import SessionMessagingKit
@objc(SNVoiceMessageView)
public final class VoiceMessageView : UIView {
private let viewItem: ConversationViewItem
private var isShowingSpeedUpLabel = false
@objc var progress: Int = 0 { didSet { handleProgressChanged() } }
@objc var isPlaying = false { didSet { handleIsPlayingChanged() } }
public final class VoiceMessageView: UIView {
private static let width: CGFloat = 160
private static let toggleContainerSize: CGFloat = 20
private static let inset = Values.smallSpacing
// MARK: - UI
private lazy var progressViewRightConstraint = progressView.pin(.right, to: .right, of: self, withInset: -VoiceMessageView.width)
private var attachment: TSAttachment? { viewItem.attachmentStream ?? viewItem.attachmentPointer }
private var duration: Int { Int(viewItem.audioDurationSeconds) }
// MARK: UI Components
private lazy var progressView: UIView = {
let result = UIView()
let result: UIView = UIView()
result.backgroundColor = UIColor.black.withAlphaComponent(0.2)
return result
}()
private lazy var toggleImageView: UIImageView = {
let result = UIImageView(image: UIImage(named: "Play"))
let result: UIImageView = UIImageView(image: UIImage(named: "Play"))
result.contentMode = .scaleAspectFit
result.set(.width, to: 8)
result.set(.height, to: 8)
result.contentMode = .scaleAspectFit
return result
}()
private lazy var loader: NVActivityIndicatorView = {
let result = NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: Colors.text, padding: nil)
let result: NVActivityIndicatorView = NVActivityIndicatorView(
frame: .zero,
type: .circleStrokeSpin,
color: Colors.text,
padding: nil
)
result.set(.width, to: VoiceMessageView.toggleContainerSize + 2)
result.set(.height, to: VoiceMessageView.toggleContainerSize + 2)
return result
}()
private lazy var countdownLabelContainer: UIView = {
let result = UIView()
let result: UIView = UIView()
result.backgroundColor = .white
result.layer.masksToBounds = true
result.set(.height, to: VoiceMessageView.toggleContainerSize)
result.set(.width, to: 44)
return result
}()
private lazy var countdownLabel: UILabel = {
let result = UILabel()
let result: UILabel = UILabel()
result.textColor = .black
result.font = .systemFont(ofSize: Values.smallFontSize)
result.text = "0:00"
return result
}()
private lazy var speedUpLabel: UILabel = {
let result = UILabel()
let result: UILabel = UILabel()
result.textColor = .black
result.font = .systemFont(ofSize: Values.smallFontSize)
result.alpha = 0
result.text = "1.5x"
result.textAlignment = .center
return result
}()
// MARK: Settings
private static let width: CGFloat = 160
private static let toggleContainerSize: CGFloat = 20
private static let inset = Values.smallSpacing
// MARK: Lifecycle
init(viewItem: ConversationViewItem) {
self.viewItem = viewItem
self.progress = Int(viewItem.audioProgressSeconds)
// MARK: - Lifecycle
init() {
super.init(frame: CGRect.zero)
setUpViewHierarchy()
handleProgressChanged()
}
override init(frame: CGRect) {
@ -86,27 +92,33 @@ public final class VoiceMessageView : UIView {
private func setUpViewHierarchy() {
let toggleContainerSize = VoiceMessageView.toggleContainerSize
let inset = VoiceMessageView.inset
// Width & height
set(.width, to: VoiceMessageView.width)
// Toggle
let toggleContainer = UIView()
let toggleContainer: UIView = UIView()
toggleContainer.backgroundColor = .white
toggleContainer.set(.width, to: toggleContainerSize)
toggleContainer.set(.height, to: toggleContainerSize)
toggleContainer.addSubview(toggleImageView)
toggleImageView.center(in: toggleContainer)
toggleContainer.layer.cornerRadius = toggleContainerSize / 2
toggleContainer.layer.cornerRadius = (toggleContainerSize / 2)
toggleContainer.layer.masksToBounds = true
// Line
let lineView = UIView()
lineView.backgroundColor = .white
lineView.set(.height, to: 1)
// Countdown label
countdownLabelContainer.addSubview(countdownLabel)
countdownLabel.center(in: countdownLabelContainer)
// Speed up label
countdownLabelContainer.addSubview(speedUpLabel)
speedUpLabel.center(in: countdownLabelContainer)
// Constraints
addSubview(progressView)
progressView.pin(.left, to: .left, of: self)
@ -114,60 +126,73 @@ public final class VoiceMessageView : UIView {
progressViewRightConstraint.isActive = true
progressView.pin(.bottom, to: .bottom, of: self)
addSubview(toggleContainer)
toggleContainer.pin(.left, to: .left, of: self, withInset: inset)
toggleContainer.pin(.top, to: .top, of: self, withInset: inset)
toggleContainer.pin(.bottom, to: .bottom, of: self, withInset: -inset)
addSubview(lineView)
lineView.pin(.left, to: .right, of: toggleContainer)
lineView.center(.vertical, in: self)
addSubview(countdownLabelContainer)
countdownLabelContainer.pin(.left, to: .right, of: lineView)
countdownLabelContainer.pin(.right, to: .right, of: self, withInset: -inset)
countdownLabelContainer.center(.vertical, in: self)
addSubview(loader)
loader.center(in: toggleContainer)
}
// MARK: Updating
public override func layoutSubviews() {
super.layoutSubviews()
countdownLabelContainer.layer.cornerRadius = countdownLabelContainer.bounds.height / 2
countdownLabelContainer.layer.cornerRadius = (countdownLabelContainer.bounds.height / 2)
}
private func handleIsPlayingChanged() {
toggleImageView.image = isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play")
if !isPlaying { progress = 0 }
}
private func handleProgressChanged() {
let isDownloaded = (attachment?.isDownloaded == true)
loader.isHidden = isDownloaded
if isDownloaded { loader.stopAnimating() } else if !loader.isAnimating { loader.startAnimating() }
guard isDownloaded else { return }
countdownLabel.text = OWSFormat.formatDurationSeconds(duration - progress)
guard viewItem.audioProgressSeconds > 0 && viewItem.audioDurationSeconds > 0 else {
return progressViewRightConstraint.constant = -VoiceMessageView.width
}
let fraction = viewItem.audioProgressSeconds / viewItem.audioDurationSeconds
progressViewRightConstraint.constant = -(VoiceMessageView.width * (1 - fraction))
}
func showSpeedUpLabel() {
guard !isShowingSpeedUpLabel else { return }
isShowingSpeedUpLabel = true
UIView.animate(withDuration: 0.25) { [weak self] in
guard let self = self else { return }
self.countdownLabel.alpha = 0
self.speedUpLabel.alpha = 1
}
Timer.scheduledTimer(withTimeInterval: 1.25, repeats: false) { [weak self] _ in
UIView.animate(withDuration: 0.25, animations: {
guard let self = self else { return }
self.countdownLabel.alpha = 1
self.speedUpLabel.alpha = 0
}, completion: { _ in
self?.isShowingSpeedUpLabel = false
})
// MARK: - Updating
public func update(
with attachment: Attachment,
isPlaying: Bool,
progress: TimeInterval,
playbackRate: Double,
oldPlaybackRate: Double
) {
switch attachment.state {
case .downloaded, .uploaded:
loader.isHidden = true
loader.stopAnimating()
toggleImageView.image = (isPlaying ? UIImage(named: "Pause") : UIImage(named: "Play"))
countdownLabel.text = OWSFormat.formatDurationSeconds(max(0, Int(floor(attachment.duration.defaulting(to: 0) - progress))))
guard let duration: TimeInterval = attachment.duration, duration > 0, progress > 0 else {
return progressViewRightConstraint.constant = -VoiceMessageView.width
}
let fraction: Double = (progress / duration)
progressViewRightConstraint.constant = -(VoiceMessageView.width * (1 - fraction))
// If the playback rate changed then show the 'speedUpLabel' briefly
guard playbackRate > oldPlaybackRate else { return }
UIView.animate(withDuration: 0.25) { [weak self] in
self?.countdownLabel.alpha = 0
self?.speedUpLabel.alpha = 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1250)) {
UIView.animate(withDuration: 0.25) { [weak self] in
self?.countdownLabel.alpha = 1
self?.speedUpLabel.alpha = 0
}
}
default:
if !loader.isAnimating {
loader.startAnimating()
}
}
}
}

View File

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

View File

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

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 {
private var positionInCluster: Position? {
guard let viewItem = viewItem else { return nil }
if viewItem.isFirstInCluster { return .top }
if viewItem.isLastInCluster { return .bottom }
return .middle
}
final class TypingIndicatorCell: MessageCell {
// MARK: - UI
private var isOnlyMessageInCluster: Bool { viewItem?.isFirstInCluster == true && viewItem?.isLastInCluster == true }
// MARK: UI Components
private lazy var bubbleView: UIView = {
let result = UIView()
let result: UIView = UIView()
result.layer.cornerRadius = VisibleMessageCell.smallCornerRadius
result.backgroundColor = Colors.receivedMessageBackground
return result
}()
private let bubbleViewMaskLayer = CAShapeLayer()
private let bubbleViewMaskLayer: CAShapeLayer = CAShapeLayer()
private lazy var typingIndicatorView = TypingIndicatorView()
private lazy var typingIndicatorView: TypingIndicatorView = TypingIndicatorView()
// MARK: Settings
override class var identifier: String { "TypingIndicatorCell" }
// MARK: Direction & Position
enum Position { case top, middle, bottom }
// MARK: Lifecycle
// MARK: - Lifecycle
override func setUpViewHierarchy() {
super.setUpViewHierarchy()
// Bubble view
addSubview(bubbleView)
bubbleView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing)
bubbleView.pin(.top, to: .top, of: self, withInset: 1)
// Typing indicator view
bubbleView.addSubview(typingIndicatorView)
typingIndicatorView.pin(to: bubbleView, withInset: 12)
}
// MARK: Updating
override func update() {
guard let viewItem = viewItem, viewItem.interaction is TypingIndicatorInteraction else { return }
// MARK: - Updating
override func update(with cellViewModel: MessageViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
guard cellViewModel.cellType == .typingIndicator else { return }
self.viewModel = cellViewModel
// Bubble view
updateBubbleViewCorners()
// Typing indicator view
typingIndicatorView.startAnimation()
}
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
}
override func layoutSubviews() {
super.layoutSubviews()
updateBubbleViewCorners()
}
private func updateBubbleViewCorners() {
let maskPath = UIBezierPath(roundedRect: bubbleView.bounds, byRoundingCorners: getCornersToRound(),
cornerRadii: CGSize(width: VisibleMessageCell.largeCornerRadius, height: VisibleMessageCell.largeCornerRadius))
let maskPath = UIBezierPath(
roundedRect: bubbleView.bounds,
byRoundingCorners: getCornersToRound(),
cornerRadii: CGSize(
width: VisibleMessageCell.largeCornerRadius,
height: VisibleMessageCell.largeCornerRadius)
)
bubbleViewMaskLayer.path = maskPath.cgPath
bubbleView.layer.mask = bubbleViewMaskLayer
}
override func prepareForReuse() {
super.prepareForReuse()
typingIndicatorView.stopAnimation()
}
// MARK: Convenience
// MARK: - Convenience
private func getCornersToRound() -> UIRectCorner {
guard !isOnlyMessageInCluster else { return .allCorners }
let result: UIRectCorner
switch positionInCluster {
case .top: result = [ .topLeft, .topRight, .bottomRight ]
case .middle: result = [ .topRight, .bottomRight ]
case .bottom: result = [ .topRight, .bottomRight, .bottomLeft ]
case nil: result = .allCorners
guard viewModel?.isOnlyMessageInCluster == false else { return .allCorners }
switch viewModel?.positionInCluster {
case .top: return [ .topLeft, .topRight, .bottomRight ]
case .middle: return [ .topRight, .bottomRight ]
case .bottom: return [ .topRight, .bottomRight, .bottomLeft ]
case .none: return .allCorners
}
return result
}
}

File diff suppressed because it is too large Load Diff

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

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

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
}
contact.isBlocked = false
Storage.shared.setContact(contact, using: transaction as Any)
},
completion: {
MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete()
}
)
Storage.shared.writeAsync { db in
try Contact
.filter(id: publicKey)
.updateAll(db, Contact.Columns.isBlocked.set(to: false))
try MessageSender
.syncConfiguration(db, forceSyncNow: true)
.retainUntilComplete()
}
presentingViewController?.dismiss(animated: true, completion: nil)
}

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,15 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class LinkPreviewModal : Modal {
import UIKit
import GRDB
import SessionUIKit
import SessionMessagingKit
final class LinkPreviewModal: Modal {
private let onLinkPreviewsEnabled: () -> Void
// 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 @@
final class ScrollToBottomButton : UIView {
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
final class ScrollToBottomButton: UIView {
private weak var delegate: ScrollToBottomButtonDelegate?
// MARK: Settings
// MARK: - Settings
private static let size: CGFloat = 40
private static let iconSize: CGFloat = 16
// MARK: Lifecycle
// MARK: - Lifecycle
init(delegate: ScrollToBottomButtonDelegate) {
self.delegate = delegate
super.init(frame: CGRect.zero)
@ -55,13 +60,15 @@ final class ScrollToBottomButton : UIView {
addGestureRecognizer(tapGestureRecognizer)
}
// MARK: Interaction
// MARK: - Interaction
@objc private func handleTap() {
delegate?.handleScrollToBottomButtonTapped()
}
}
protocol ScrollToBottomButtonDelegate : class {
// MARK: - ScrollToBottomButtonDelegate
protocol ScrollToBottomButtonDelegate: AnyObject {
func handleScrollToBottomButtonTapped()
}

View File

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

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,38 +134,57 @@ final class NewDMVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControll
}
fileprivate func startNewDMIfPossible(with onsNameOrPublicKey: String) {
if ECKeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) {
let maybeSessionId: SessionId? = SessionId(from: onsNameOrPublicKey)
if ECKeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) && maybeSessionId?.prefix == .standard {
startNewDM(with: onsNameOrPublicKey)
} else {
// This could be an ONS name
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
SnodeAPI.getSessionID(for: onsNameOrPublicKey).done { sessionID in
modalActivityIndicator.dismiss {
self?.startNewDM(with: sessionID)
}
}.catch { error in
modalActivityIndicator.dismiss {
var messageOrNil: String?
if let error = error as? SnodeAPI.Error {
switch error {
case .decryptionFailed, .hashingFailed, .validationFailed: messageOrNil = error.errorDescription
return
}
// This could be an ONS name
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
SnodeAPI.getSessionID(for: onsNameOrPublicKey).done { sessionID in
modalActivityIndicator.dismiss {
self?.startNewDM(with: sessionID)
}
}.catch { error in
modalActivityIndicator.dismiss {
var messageOrNil: String?
if let error = error as? SnodeAPIError {
switch error {
case .decryptionFailed, .hashingFailed, .validationFailed:
messageOrNil = error.errorDescription
default: break
}
}
let message = messageOrNil ?? "Please check the Session ID or ONS name and try again"
let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
self?.presentAlert(alert)
}
let message: String = {
if let messageOrNil: String = messageOrNil {
return messageOrNil
}
return (maybeSessionId?.prefix == .blinded ?
"You can only send messages to Blinded IDs from within an Open Group" :
"Please check the Session ID or ONS name and try again"
)
}()
let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil))
self?.presentAlert(alert)
}
}
}
}
private func startNewDM(with sessionID: String) {
let thread = TSContactThread.getOrCreateThread(contactSessionID: sessionID)
private func startNewDM(with sessionId: String) {
let maybeThread: SessionThread? = Storage.shared.write { db in
try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact)
}
guard maybeThread != nil else { return }
presentingViewController?.dismiss(animated: true, completion: nil)
SignalApp.shared().presentConversation(for: thread, action: .compose, animated: false)
SessionApp.presentConversation(for: sessionId, action: .compose, animated: false)
}
}

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -41,18 +41,13 @@ class PhotoCapture: NSObject {
self.session = AVCaptureSession()
self.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.autoPinToSquareAspectRatio()
button.layer.shadowOffset = CGSize.zero
button.layer.shadowOpacity = 0.35
button.layer.shadowRadius = 4
@ -600,20 +595,6 @@ class RecordingTimerView: UIView {
return icon
}()
// MARK: - Overrides //
override func sizeThatFits(_ size: CGSize) -> CGSize {
if #available(iOS 10, *) {
return super.sizeThatFits(size)
} else {
// iOS9 manual layout sizing required for items in the navigation bar
var baseSize = label.frame.size
baseSize.width = baseSize.width + stackViewSpacing + RecordingTimerView.iconWidth + layoutMargins.left + layoutMargins.right
baseSize.height = baseSize.height + layoutMargins.top + layoutMargins.bottom
return baseSize
}
}
// MARK: -
var recordingStartTime: TimeInterval?
@ -662,10 +643,5 @@ class RecordingTimerView: UIView {
Logger.verbose("recordingDuration: \(recordingDuration)")
let durationDate = Date(timeIntervalSinceReferenceDate: recordingDuration)
label.text = timeFormatter.string(from: durationDate)
if #available(iOS 10, *) {
// do nothing
} else {
label.sizeToFit()
}
}
}

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

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