mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
Merge branch 'dev' into add-documents-section
This commit is contained in:
commit
d840204bc2
129 changed files with 24540 additions and 806 deletions
646
Scripts/EmojiGenerator.swift
Executable file
646
Scripts/EmojiGenerator.swift
Executable file
|
@ -0,0 +1,646 @@
|
|||
#!/usr/bin/env xcrun --sdk macosx swift
|
||||
|
||||
import Foundation
|
||||
|
||||
// OWSAssertionError but for this script
|
||||
|
||||
enum EmojiError: Error {
|
||||
case assertion(String)
|
||||
init(_ string: String) {
|
||||
self = .assertion(string)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Remote Model
|
||||
// These definitions are kept fairly lightweight since we don't control their format
|
||||
// All processing of remote data is done by converting RemoteModel items to EmojiModel items
|
||||
|
||||
enum RemoteModel {
|
||||
struct EmojiItem: Codable {
|
||||
let name: String
|
||||
let shortName: String
|
||||
let unified: String
|
||||
let sortOrder: UInt
|
||||
let category: EmojiCategory
|
||||
let skinVariations: [String: SkinVariation]?
|
||||
let shortNames: [String]?
|
||||
}
|
||||
|
||||
struct SkinVariation: Codable {
|
||||
let unified: String
|
||||
}
|
||||
|
||||
enum EmojiCategory: String, Codable, Equatable {
|
||||
case smileys = "Smileys & Emotion"
|
||||
case people = "People & Body"
|
||||
|
||||
// This category is not provided in the data set, but is actually
|
||||
// a merger of the categories of `smileys` and `people`
|
||||
case smileysAndPeople = "Smileys & People"
|
||||
|
||||
case animals = "Animals & Nature"
|
||||
case food = "Food & Drink"
|
||||
case activities = "Activities"
|
||||
case travel = "Travel & Places"
|
||||
case objects = "Objects"
|
||||
case symbols = "Symbols"
|
||||
case flags = "Flags"
|
||||
case components = "Component"
|
||||
}
|
||||
|
||||
static func fetchEmojiData() throws -> Data {
|
||||
// let remoteSourceUrl = URL(string: "https://unicodey.com/emoji-data/emoji.json")!
|
||||
// This URL has been unavailable the past couple of weeks. If you're seeing failures here, try this other one:
|
||||
let remoteSourceUrl = URL(string: "https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji.json")!
|
||||
return try Data(contentsOf: remoteSourceUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local Model
|
||||
|
||||
struct EmojiModel {
|
||||
let definitions: [EmojiDefinition]
|
||||
|
||||
struct EmojiDefinition {
|
||||
let category: RemoteModel.EmojiCategory
|
||||
let rawName: String
|
||||
let enumName: String
|
||||
var shortNames: Set<String>
|
||||
let variants: [Emoji]
|
||||
var baseEmoji: Character { variants[0].base }
|
||||
|
||||
struct Emoji: Comparable {
|
||||
let emojiChar: Character
|
||||
|
||||
let base: Character
|
||||
let skintoneSequence: SkinToneSequence
|
||||
|
||||
static func <(lhs: Self, rhs: Self) -> Bool {
|
||||
for (leftElement, rightElement) in zip(lhs.skintoneSequence, rhs.skintoneSequence) {
|
||||
if leftElement.sortId != rightElement.sortId {
|
||||
return leftElement.sortId < rightElement.sortId
|
||||
}
|
||||
}
|
||||
if lhs.skintoneSequence.count != rhs.skintoneSequence.count {
|
||||
return lhs.skintoneSequence.count < rhs.skintoneSequence.count
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(parsingRemoteItem remoteItem: RemoteModel.EmojiItem) throws {
|
||||
category = remoteItem.category
|
||||
rawName = remoteItem.name
|
||||
enumName = Self.parseEnumNameFromRemoteItem(remoteItem)
|
||||
shortNames = Set((remoteItem.shortNames ?? []))
|
||||
shortNames.insert(rawName.lowercased())
|
||||
shortNames.insert(enumName.lowercased())
|
||||
|
||||
let baseEmojiChar = try Self.codePointsToCharacter(Self.parseCodePointString(remoteItem.unified))
|
||||
let baseEmoji = Emoji(emojiChar: baseEmojiChar, base: baseEmojiChar, skintoneSequence: .none)
|
||||
|
||||
let toneVariants: [Emoji]
|
||||
if let skinVariations = remoteItem.skinVariations {
|
||||
toneVariants = try skinVariations.map { key, value in
|
||||
let modifier = SkinTone.sequence(from: Self.parseCodePointString(key))
|
||||
let parsedEmoji = try Self.codePointsToCharacter(Self.parseCodePointString(value.unified))
|
||||
return Emoji(emojiChar: parsedEmoji, base: baseEmojiChar, skintoneSequence: modifier)
|
||||
}.sorted()
|
||||
} else {
|
||||
toneVariants = []
|
||||
}
|
||||
|
||||
variants = [baseEmoji] + toneVariants
|
||||
try postInitValidation()
|
||||
}
|
||||
|
||||
func postInitValidation() throws {
|
||||
guard variants.count > 0 else {
|
||||
throw EmojiError("Expecting at least one variant")
|
||||
}
|
||||
|
||||
guard variants.allSatisfy({ $0.base == baseEmoji }) else {
|
||||
// All emoji variants must have a common base emoji
|
||||
throw EmojiError("Inconsistent base emoji: \(baseEmoji)")
|
||||
}
|
||||
|
||||
let hasMultipleComponents = variants.first(where: { $0.skintoneSequence.count > 1 }) != nil
|
||||
if hasMultipleComponents, skinToneComponents == nil {
|
||||
// If you hit this, this means a new emoji was added where a skintone modifier sequence specifies multiple
|
||||
// skin tones for multiple emoji components: e.g. 👫 -> 🧍♀️+🧍♂️
|
||||
// These are defined in `skinToneComponents`. You'll need to add a new case.
|
||||
throw EmojiError("\(baseEmoji):\(enumName) definition has variants with multiple skintone modifiers but no component emojis defined")
|
||||
}
|
||||
}
|
||||
|
||||
static func parseEnumNameFromRemoteItem(_ item: RemoteModel.EmojiItem) -> String {
|
||||
// some names don't play nice with swift, so we special case them
|
||||
switch item.shortName {
|
||||
case "+1": return "plusOne"
|
||||
case "-1": return "negativeOne"
|
||||
case "8ball": return "eightBall"
|
||||
case "repeat": return "`repeat`"
|
||||
case "100": return "oneHundred"
|
||||
case "1234": return "oneTwoThreeFour"
|
||||
case "couplekiss": return "personKissPerson"
|
||||
case "couple_with_heart": return "personHeartPerson"
|
||||
default:
|
||||
let uppperCamelCase = item.shortName
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.titlecase
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
|
||||
return uppperCamelCase.first!.lowercased() + uppperCamelCase.dropFirst()
|
||||
}
|
||||
}
|
||||
|
||||
var skinToneComponents: String? {
|
||||
// There's no great way to do this except manually. Some emoji have multiple skin tones.
|
||||
// In the picker, we need to use one emoji to represent each person. For now, we manually
|
||||
// specify this. Hopefully, in the future, the data set will contain this information.
|
||||
switch enumName {
|
||||
case "peopleHoldingHands": return "[.standingPerson, .standingPerson]"
|
||||
case "twoWomenHoldingHands": return "[.womanStanding, .womanStanding]"
|
||||
case "manAndWomanHoldingHands": return "[.womanStanding, .manStanding]"
|
||||
case "twoMenHoldingHands": return "[.manStanding, .manStanding]"
|
||||
case "personKissPerson": return "[.adult, .adult]"
|
||||
case "womanKissMan": return "[.woman, .man]"
|
||||
case "manKissMan": return "[.man, .man]"
|
||||
case "womanKissWoman": return "[.woman, .woman]"
|
||||
case "personHeartPerson": return "[.adult, .adult]"
|
||||
case "womanHeartMan": return "[.woman, .man]"
|
||||
case "manHeartMan": return "[.man, .man]"
|
||||
case "womanHeartWoman": return "[.woman, .woman]"
|
||||
case "handshake": return "[.rightwardsHand, .leftwardsHand]"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var isNormalized: Bool { enumName == normalizedEnumName }
|
||||
var normalizedEnumName: String {
|
||||
switch enumName {
|
||||
// flagUm (US Minor Outlying Islands) looks identical to the
|
||||
// US flag. We don't present it as a sendable reaction option
|
||||
// This matches the iOS keyboard behavior.
|
||||
case "flagUm": return "us"
|
||||
default: return enumName
|
||||
}
|
||||
}
|
||||
|
||||
static func parseCodePointString(_ pointString: String) -> [UnicodeScalar] {
|
||||
return pointString
|
||||
.components(separatedBy: "-")
|
||||
.map { Int($0, radix: 16)! }
|
||||
.map { UnicodeScalar($0)! }
|
||||
}
|
||||
|
||||
static func codePointsToCharacter(_ codepoints: [UnicodeScalar]) throws -> Character {
|
||||
let result = codepoints.map { String($0) }.joined()
|
||||
if result.count != 1 {
|
||||
throw EmojiError("Invalid number of chars for codepoint sequence: \(codepoints)")
|
||||
}
|
||||
return result.first!
|
||||
}
|
||||
}
|
||||
|
||||
init(rawJSONData jsonData: Data) throws {
|
||||
let jsonDecoder = JSONDecoder()
|
||||
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
definitions = try jsonDecoder
|
||||
.decode([RemoteModel.EmojiItem].self, from: jsonData)
|
||||
.sorted { $0.sortOrder < $1.sortOrder }
|
||||
.map { try EmojiDefinition(parsingRemoteItem: $0) }
|
||||
|
||||
}
|
||||
|
||||
typealias SkinToneSequence = [EmojiModel.SkinTone]
|
||||
enum SkinTone: UnicodeScalar, CaseIterable, Equatable {
|
||||
case light = "🏻"
|
||||
case mediumLight = "🏼"
|
||||
case medium = "🏽"
|
||||
case mediumDark = "🏾"
|
||||
case dark = "🏿"
|
||||
|
||||
var sortId: Int { return SkinTone.allCases.firstIndex(of: self)! }
|
||||
|
||||
static func sequence(from codepoints: [UnicodeScalar]) -> SkinToneSequence {
|
||||
codepoints
|
||||
.map { SkinTone(rawValue: $0)! }
|
||||
.reduce(into: [SkinTone]()) { result, skinTone in
|
||||
guard !result.contains(skinTone) else { return }
|
||||
result.append(skinTone)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EmojiModel.SkinToneSequence {
|
||||
static var none: EmojiModel.SkinToneSequence = []
|
||||
}
|
||||
|
||||
// MARK: - File Writers
|
||||
|
||||
extension EmojiGenerator {
|
||||
static func writePrimaryFile(from emojiModel: EmojiModel) {
|
||||
// Main enum: Create a string enum defining our enumNames equal to the baseEmoji string
|
||||
// e.g. case grinning = "😀"
|
||||
writeBlock(fileName: "Emoji.swift") { fileHandle in
|
||||
fileHandle.writeLine("// swiftlint:disable all")
|
||||
fileHandle.writeLine("")
|
||||
fileHandle.writeLine("/// A sorted representation of all available emoji")
|
||||
fileHandle.writeLine("enum Emoji: String, CaseIterable, Equatable {")
|
||||
fileHandle.indent {
|
||||
emojiModel.definitions.forEach {
|
||||
fileHandle.writeLine("case \($0.enumName) = \"\($0.baseEmoji)\"")
|
||||
}
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
fileHandle.writeLine("// swiftlint:disable all")
|
||||
}
|
||||
}
|
||||
|
||||
static func writeStringConversionsFile(from emojiModel: EmojiModel) {
|
||||
// Inline helpers:
|
||||
var firstItem = true
|
||||
func conditionalCheckForEmojiItem(_ item: EmojiModel.EmojiDefinition.Emoji) -> String {
|
||||
let isFirst = (firstItem == true)
|
||||
firstItem = false
|
||||
|
||||
let prefix = isFirst ? "" : "} else "
|
||||
let suffix = "if rawValue == \"\(item.emojiChar)\" {"
|
||||
return prefix + suffix
|
||||
}
|
||||
func conversionForEmojiItem(_ item: EmojiModel.EmojiDefinition.Emoji, definition: EmojiModel.EmojiDefinition) -> String {
|
||||
let skinToneString: String
|
||||
if item.skintoneSequence.isEmpty {
|
||||
skinToneString = "nil"
|
||||
} else {
|
||||
skinToneString = "[\(item.skintoneSequence.map { ".\($0)" }.joined(separator: ", "))]"
|
||||
}
|
||||
return "self.init(baseEmoji: .\(definition.enumName), skinTones: \(skinToneString))"
|
||||
}
|
||||
|
||||
// Conversion from String: Creates an initializer mapping a single character emoji string to an EmojiWithSkinTones
|
||||
// e.g.
|
||||
// if rawValue == "😀" { self.init(baseEmoji: .grinning, skinTones: nil) }
|
||||
// else if rawValue == "🦻🏻" { self.init(baseEmoji: .earWithHearingAid, skinTones: [.light])
|
||||
writeBlock(fileName: "EmojiWithSkinTones+String.swift") { fileHandle in
|
||||
fileHandle.writeLine("extension EmojiWithSkinTones {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("init?(rawValue: String) {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("guard rawValue.isSingleEmoji else { return nil }")
|
||||
|
||||
emojiModel.definitions.forEach { definition in
|
||||
definition.variants.forEach { emoji in
|
||||
fileHandle.writeLine(conditionalCheckForEmojiItem(emoji))
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine(conversionForEmojiItem(emoji, definition: definition))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileHandle.writeLine("} else {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("self.init(unsupportedValue: rawValue)")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
}
|
||||
|
||||
static func writeSkinToneLookupFile(from emojiModel: EmojiModel) {
|
||||
writeBlock(fileName: "Emoji+SkinTones.swift") { fileHandle in
|
||||
fileHandle.writeLine("extension Emoji {")
|
||||
fileHandle.indent {
|
||||
// SkinTone enum
|
||||
fileHandle.writeLine("enum SkinTone: String, CaseIterable, Equatable {")
|
||||
fileHandle.indent {
|
||||
for skinTone in EmojiModel.SkinTone.allCases {
|
||||
fileHandle.writeLine("case \(skinTone) = \"\(skinTone.rawValue)\"")
|
||||
}
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
fileHandle.writeLine("")
|
||||
|
||||
// skin tone helpers
|
||||
fileHandle.writeLine("var hasSkinTones: Bool { return emojiPerSkinTonePermutation != nil }")
|
||||
fileHandle.writeLine("var allowsMultipleSkinTones: Bool { return hasSkinTones && skinToneComponentEmoji != nil }")
|
||||
fileHandle.writeLine("")
|
||||
|
||||
// Start skinToneComponentEmoji
|
||||
fileHandle.writeLine("var skinToneComponentEmoji: [Emoji]? {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("switch self {")
|
||||
emojiModel.definitions.forEach { emojiDef in
|
||||
if let components = emojiDef.skinToneComponents {
|
||||
fileHandle.writeLine("case .\(emojiDef.enumName): return \(components)")
|
||||
}
|
||||
}
|
||||
|
||||
fileHandle.writeLine("default: return nil")
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
fileHandle.writeLine("")
|
||||
|
||||
// Start emojiPerSkinTonePermutation
|
||||
fileHandle.writeLine("var emojiPerSkinTonePermutation: [[SkinTone]: String]? {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("switch self {")
|
||||
emojiModel.definitions.forEach { emojiDef in
|
||||
let skintoneVariants = emojiDef.variants.filter({ $0.skintoneSequence != .none})
|
||||
if skintoneVariants.isEmpty {
|
||||
// None of our variants have a skintone, nothing to do
|
||||
return
|
||||
}
|
||||
|
||||
fileHandle.writeLine("case .\(emojiDef.enumName):")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("return [")
|
||||
fileHandle.indent {
|
||||
skintoneVariants.forEach {
|
||||
let skintoneSequenceKey = $0.skintoneSequence.map({ ".\($0)" }).joined(separator: ", ")
|
||||
fileHandle.writeLine("[\(skintoneSequenceKey)]: \"\($0.emojiChar)\",")
|
||||
}
|
||||
}
|
||||
fileHandle.writeLine("]")
|
||||
}
|
||||
}
|
||||
fileHandle.writeLine("default: return nil")
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
}
|
||||
|
||||
static func writeCategoryLookupFile(from emojiModel: EmojiModel) {
|
||||
let outputCategories: [RemoteModel.EmojiCategory] = [
|
||||
.smileysAndPeople,
|
||||
.animals,
|
||||
.food,
|
||||
.activities,
|
||||
.travel,
|
||||
.objects,
|
||||
.symbols,
|
||||
.flags
|
||||
]
|
||||
|
||||
writeBlock(fileName: "Emoji+Category.swift") { fileHandle in
|
||||
fileHandle.writeLine("extension Emoji {")
|
||||
fileHandle.indent {
|
||||
|
||||
// Category enum
|
||||
fileHandle.writeLine("enum Category: String, CaseIterable, Equatable {")
|
||||
fileHandle.indent {
|
||||
// Declare cases
|
||||
for category in outputCategories {
|
||||
fileHandle.writeLine("case \(category) = \"\(category.rawValue)\"")
|
||||
}
|
||||
fileHandle.writeLine("")
|
||||
|
||||
// Localized name for category
|
||||
fileHandle.writeLine("var localizedName: String {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("switch self {")
|
||||
for category in outputCategories {
|
||||
fileHandle.writeLine("case .\(category):")
|
||||
fileHandle.indent {
|
||||
let stringKey = "EMOJI_CATEGORY_\("\(category)".uppercased())_NAME"
|
||||
let stringComment = "The name for the emoji category '\(category.rawValue)'"
|
||||
|
||||
fileHandle.writeLine("return NSLocalizedString(\"\(stringKey)\", comment: \"\(stringComment)\")")
|
||||
}
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
fileHandle.writeLine("")
|
||||
|
||||
// Emoji lookup per category
|
||||
fileHandle.writeLine("var normalizedEmoji: [Emoji] {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("switch self {")
|
||||
|
||||
let normalizedEmojiPerCategory: [RemoteModel.EmojiCategory: [EmojiModel.EmojiDefinition]]
|
||||
normalizedEmojiPerCategory = emojiModel.definitions.reduce(into: [:]) { result, emojiDef in
|
||||
if emojiDef.isNormalized {
|
||||
var categoryList = result[emojiDef.category] ?? []
|
||||
categoryList.append(emojiDef)
|
||||
result[emojiDef.category] = categoryList
|
||||
}
|
||||
}
|
||||
|
||||
for category in outputCategories {
|
||||
let emoji: [EmojiModel.EmojiDefinition] = {
|
||||
switch category {
|
||||
case .smileysAndPeople:
|
||||
// Merge smileys & people. It's important we initially bucket these separately,
|
||||
// because we want the emojis to be sorted smileys followed by people
|
||||
return normalizedEmojiPerCategory[.smileys]! + normalizedEmojiPerCategory[.people]!
|
||||
default:
|
||||
return normalizedEmojiPerCategory[category]!
|
||||
}
|
||||
}()
|
||||
|
||||
fileHandle.writeLine("case .\(category):")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("return [")
|
||||
fileHandle.indent {
|
||||
emoji.compactMap { $0.enumName }.forEach { name in
|
||||
fileHandle.writeLine(".\(name),")
|
||||
}
|
||||
}
|
||||
fileHandle.writeLine("]")
|
||||
}
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
fileHandle.writeLine("")
|
||||
|
||||
// Category lookup per emoji
|
||||
fileHandle.writeLine("var category: Category {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("switch self {")
|
||||
for emojiDef in emojiModel.definitions {
|
||||
let category = [.smileys, .people].contains(emojiDef.category) ? .smileysAndPeople : emojiDef.category
|
||||
if category != .components {
|
||||
fileHandle.writeLine("case .\(emojiDef.enumName): return .\(category)")
|
||||
}
|
||||
}
|
||||
// Write a default case, because this enum is too long for the compiler to validate it's exhaustive
|
||||
fileHandle.writeLine("default: fatalError(\"Unexpected case \\(self)\")")
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
fileHandle.writeLine("")
|
||||
|
||||
// Normalized variant mapping
|
||||
fileHandle.writeLine("var isNormalized: Bool { normalized == self }")
|
||||
fileHandle.writeLine("var normalized: Emoji {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("switch self {")
|
||||
emojiModel.definitions.filter { !$0.isNormalized }.forEach {
|
||||
fileHandle.writeLine("case .\($0.enumName): return .\($0.normalizedEnumName)")
|
||||
}
|
||||
fileHandle.writeLine("default: return self")
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
}
|
||||
|
||||
static func writeNameLookupFile(from emojiModel: EmojiModel) {
|
||||
// Name lookup: Create a computed property mapping an Emoji enum element to the raw Emoji name string
|
||||
// e.g. case .grinning: return "GRINNING FACE"
|
||||
writeBlock(fileName: "Emoji+Name.swift") { fileHandle in
|
||||
fileHandle.writeLine("extension Emoji {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("var name: String {")
|
||||
fileHandle.indent {
|
||||
fileHandle.writeLine("switch self {")
|
||||
emojiModel.definitions.forEach {
|
||||
fileHandle.writeLine("case .\($0.enumName): return \"\($0.shortNames.joined(separator:", "))\"")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
fileHandle.writeLine("}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File I/O Helpers
|
||||
|
||||
class WriteHandle {
|
||||
static let emojiDirectory = URL(
|
||||
fileURLWithPath: "../Session/Emoji",
|
||||
isDirectory: true,
|
||||
relativeTo: EmojiGenerator.pathToFolderContainingThisScript!)
|
||||
|
||||
let handle: FileHandle
|
||||
|
||||
var indentDepth: Int = 0
|
||||
var hasBeenClosed = false
|
||||
|
||||
func indent(_ block: () -> Void) {
|
||||
indentDepth += 1
|
||||
block()
|
||||
indentDepth -= 1
|
||||
}
|
||||
|
||||
func writeLine(_ body: String) {
|
||||
let spaces = indentDepth * 4
|
||||
let prefix = String(repeating: " ", count: spaces)
|
||||
let suffix = "\n"
|
||||
|
||||
let line = prefix + body + suffix
|
||||
handle.write(line.data(using: .utf8)!)
|
||||
}
|
||||
|
||||
init(fileName: String) {
|
||||
// Create directory if necessary
|
||||
if !FileManager.default.fileExists(atPath: Self.emojiDirectory.path) {
|
||||
try! FileManager.default.createDirectory(at: Self.emojiDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
|
||||
// Delete old file and create anew
|
||||
let url = URL(fileURLWithPath: fileName, relativeTo: Self.emojiDirectory)
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
try! FileManager.default.removeItem(at: url)
|
||||
}
|
||||
FileManager.default.createFile(atPath: url.path, contents: nil, attributes: nil)
|
||||
handle = try! FileHandle(forWritingTo: url)
|
||||
}
|
||||
|
||||
deinit {
|
||||
precondition(hasBeenClosed, "File handle still open at de-init")
|
||||
}
|
||||
|
||||
func close() {
|
||||
handle.closeFile()
|
||||
hasBeenClosed = true
|
||||
}
|
||||
}
|
||||
|
||||
extension EmojiGenerator {
|
||||
static func writeBlock(fileName: String, block: (WriteHandle) -> Void) {
|
||||
let fileHandle = WriteHandle(fileName: fileName)
|
||||
defer { fileHandle.close() }
|
||||
|
||||
fileHandle.writeLine("")
|
||||
fileHandle.writeLine("// This file is generated by EmojiGenerator.swift, do not manually edit it.")
|
||||
fileHandle.writeLine("")
|
||||
|
||||
block(fileHandle)
|
||||
}
|
||||
|
||||
// from http://stackoverflow.com/a/31480534/255489
|
||||
static var pathToFolderContainingThisScript: URL? = {
|
||||
let cwd = FileManager.default.currentDirectoryPath
|
||||
|
||||
let script = CommandLine.arguments[0]
|
||||
|
||||
if script.hasPrefix("/") { // absolute
|
||||
let path = (script as NSString).deletingLastPathComponent
|
||||
return URL(fileURLWithPath: path)
|
||||
} else { // relative
|
||||
let urlCwd = URL(fileURLWithPath: cwd)
|
||||
|
||||
if let urlPath = URL(string: script, relativeTo: urlCwd) {
|
||||
let path = (urlPath.path as NSString).deletingLastPathComponent
|
||||
return URL(fileURLWithPath: path)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
}
|
||||
|
||||
// MARK: - Misc
|
||||
|
||||
extension String {
|
||||
var titlecase: String {
|
||||
components(separatedBy: " ")
|
||||
.map { $0.first!.uppercased() + $0.dropFirst().lowercased() }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
class EmojiGenerator {
|
||||
static func run() throws {
|
||||
let remoteData = try RemoteModel.fetchEmojiData()
|
||||
let model = try EmojiModel(rawJSONData: remoteData)
|
||||
|
||||
writePrimaryFile(from: model)
|
||||
writeStringConversionsFile(from: model)
|
||||
writeSkinToneLookupFile(from: model)
|
||||
writeCategoryLookupFile(from: model)
|
||||
writeNameLookupFile(from: model)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try EmojiGenerator.run()
|
||||
} catch {
|
||||
print("Failed to generate emoji data: \(error)")
|
||||
let errorCode = (error as? CustomNSError)?.errorCode ?? -1
|
||||
exit(Int32(errorCode))
|
||||
}
|
|
@ -121,21 +121,39 @@
|
|||
7B1581E4271FC59D00848B49 /* CallModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E3271FC59C00848B49 /* CallModal.swift */; };
|
||||
7B1581E6271FD2A100848B49 /* VideoPreviewVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E5271FD2A100848B49 /* VideoPreviewVC.swift */; };
|
||||
7B1581E827210ECC00848B49 /* RenderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E727210ECC00848B49 /* RenderView.swift */; };
|
||||
7B1B52D828580C6D006069F2 /* EmojiPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1B52D728580C6D006069F2 /* EmojiPickerSheet.swift */; };
|
||||
7B1B52DF28580D51006069F2 /* EmojiPickerCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1B52DB28580D50006069F2 /* EmojiPickerCollectionView.swift */; };
|
||||
7B1B52E028580D51006069F2 /* EmojiSkinTonePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1B52DC28580D50006069F2 /* EmojiSkinTonePicker.swift */; };
|
||||
7B1D74AA27BCC16E0030B423 /* NSENotificationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */; };
|
||||
7B1D74AC27BDE7510030B423 /* Promise+Timeout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */; };
|
||||
7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74AF27C365960030B423 /* Timer+MainThread.swift */; };
|
||||
7B46AAAF28766DF4001AF2DC /* AllMediaViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B46AAAE28766DF4001AF2DC /* AllMediaViewController.swift */; };
|
||||
7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */; };
|
||||
7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */; };
|
||||
7B7037432834B81F000DCF35 /* ReactionContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7037422834B81F000DCF35 /* ReactionContainerView.swift */; };
|
||||
7B7037452834BCC0000DCF35 /* ReactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7037442834BCC0000DCF35 /* ReactionView.swift */; };
|
||||
7B7CB189270430D20079FF93 /* CallMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB188270430D20079FF93 /* CallMessageView.swift */; };
|
||||
7B7CB18B270591630079FF93 /* ShareLogsModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB18A270591630079FF93 /* ShareLogsModal.swift */; };
|
||||
7B7CB18E270D066F0079FF93 /* IncomingCallBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB18D270D066F0079FF93 /* IncomingCallBanner.swift */; };
|
||||
7B7CB190270FB2150079FF93 /* MiniCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB18F270FB2150079FF93 /* MiniCallView.swift */; };
|
||||
7B7CB192271508AD0079FF93 /* CallRingTonePlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB191271508AD0079FF93 /* CallRingTonePlayer.swift */; };
|
||||
7B81682328A4C1210069F315 /* UpdateTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B81682228A4C1210069F315 /* UpdateTypes.swift */; };
|
||||
7B81682828B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B81682728B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift */; };
|
||||
7B81682A28B6F1420069F315 /* ReactionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B81682928B6F1420069F315 /* ReactionResponse.swift */; };
|
||||
7B81682C28B72F480069F315 /* PendingChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B81682B28B72F480069F315 /* PendingChange.swift */; };
|
||||
7B8D5FC428332600008324D9 /* VisibleMessage+Reaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8D5FC328332600008324D9 /* VisibleMessage+Reaction.swift */; };
|
||||
7B93D06A27CF173D00811CB6 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06927CF173D00811CB6 /* MessageRequestsViewController.swift */; };
|
||||
7B93D07027CF194000811CB6 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06E27CF194000811CB6 /* ConfigurationMessage+Convenience.swift */; };
|
||||
7B93D07127CF194000811CB6 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06F27CF194000811CB6 /* MessageRequestResponse.swift */; };
|
||||
7B93D07727CF1A8A00811CB6 /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */; };
|
||||
7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71C828470667006DFE7B /* ReactionListSheet.swift */; };
|
||||
7B9F71D02852EEE2006DFE7B /* Emoji+Category.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71CB2852EEE2006DFE7B /* Emoji+Category.swift */; };
|
||||
7B9F71D12852EEE2006DFE7B /* EmojiWithSkinTones+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71CC2852EEE2006DFE7B /* EmojiWithSkinTones+String.swift */; };
|
||||
7B9F71D22852EEE2006DFE7B /* Emoji+SkinTones.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71CD2852EEE2006DFE7B /* Emoji+SkinTones.swift */; };
|
||||
7B9F71D32852EEE2006DFE7B /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71CE2852EEE2006DFE7B /* Emoji.swift */; };
|
||||
7B9F71D42852EEE2006DFE7B /* Emoji+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71CF2852EEE2006DFE7B /* Emoji+Name.swift */; };
|
||||
7B9F71D72853100A006DFE7B /* Emoji+Available.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71D528531009006DFE7B /* Emoji+Available.swift */; };
|
||||
7B9F71D82853100A006DFE7B /* EmojiWithSkinTones.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71D628531009006DFE7B /* EmojiWithSkinTones.swift */; };
|
||||
7BA68909272A27BE00EFC32F /* SessionCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA68908272A27BE00EFC32F /* SessionCall.swift */; };
|
||||
7BA6890D27325CCC00EFC32F /* SessionCallManager+CXCallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA6890C27325CCC00EFC32F /* SessionCallManager+CXCallController.swift */; };
|
||||
7BA6890F27325CE300EFC32F /* SessionCallManager+CXProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA6890E27325CE300EFC32F /* SessionCallManager+CXProvider.swift */; };
|
||||
|
@ -148,6 +166,7 @@
|
|||
7BAF54D827ACD0E3003D12F8 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */; };
|
||||
7BAF54DC27ACD12B003D12F8 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54DB27ACD12B003D12F8 /* UIColor+Extensions.swift */; };
|
||||
7BBBDC462875600700747E59 /* DocumentTitleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBBDC452875600700747E59 /* DocumentTitleViewController.swift */; };
|
||||
7BBBDC44286EAD2D00747E59 /* TappableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBBDC43286EAD2D00747E59 /* TappableLabel.swift */; };
|
||||
7BC01A3E241F40AB00BC7C55 /* NotificationServiceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC01A3D241F40AB00BC7C55 /* NotificationServiceExtension.swift */; };
|
||||
7BC01A42241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 7BC01A3B241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
7BC707F227290ACB002817AD /* SessionCallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC707F127290ACB002817AD /* SessionCallManager.swift */; };
|
||||
|
@ -156,6 +175,7 @@
|
|||
7BD477B027F526FF004E2822 /* BlockListUIUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD477AF27F526FF004E2822 /* BlockListUIUtils.swift */; };
|
||||
7BDCFC08242186E700641C39 /* NotificationServiceExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BDCFC07242186E700641C39 /* NotificationServiceExtensionContext.swift */; };
|
||||
7BDCFC0B2421EB7600641C39 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = B6F509951AA53F760068F56A /* Localizable.strings */; };
|
||||
7BFA8AE32831D0D4001876F3 /* ContextMenuVC+EmojiReactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFA8AE22831D0D4001876F3 /* ContextMenuVC+EmojiReactsView.swift */; };
|
||||
7BFD1A8A2745C4F000FB91B9 /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFD1A892745C4F000FB91B9 /* Permissions.swift */; };
|
||||
7BFD1A8C2747150E00FB91B9 /* TurnServerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFD1A8B2747150E00FB91B9 /* TurnServerInfo.swift */; };
|
||||
7BFD1A972747689000FB91B9 /* Session-Turn-Server in Resources */ = {isa = PBXBuildFile; fileRef = 7BFD1A962747689000FB91B9 /* Session-Turn-Server */; };
|
||||
|
@ -182,7 +202,6 @@
|
|||
B81D25C526157F40004D1FE1 /* storage-seed-1.crt in Resources */ = {isa = PBXBuildFile; fileRef = B81D25B726157F20004D1FE1 /* storage-seed-1.crt */; };
|
||||
B81D25C626157F40004D1FE1 /* public-loki-foundation.crt in Resources */ = {isa = PBXBuildFile; fileRef = B81D25B826157F20004D1FE1 /* public-loki-foundation.crt */; };
|
||||
B821494625D4D6FF009C0F2A /* URLModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B821494525D4D6FF009C0F2A /* URLModal.swift */; };
|
||||
B821494F25D4E163009C0F2A /* BodyTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B821494E25D4E163009C0F2A /* BodyTextView.swift */; };
|
||||
B82149B825D60393009C0F2A /* BlockedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82149B725D60393009C0F2A /* BlockedModal.swift */; };
|
||||
B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82149C025D605C6009C0F2A /* InfoBanner.swift */; };
|
||||
B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D2825C7A4B400488AB4 /* InputView.swift */; };
|
||||
|
@ -304,8 +323,6 @@
|
|||
C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAFD255A580600E217F9 /* LRUCache.swift */; };
|
||||
C32C5DDB256DD9FF003C73A2 /* ContentProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB68255A580F00E217F9 /* ContentProxy.swift */; };
|
||||
C32C5E0C256DDAFA003C73A2 /* NSRegularExpression+SSK.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA7A255A57FB00E217F9 /* NSRegularExpression+SSK.swift */; };
|
||||
C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */; };
|
||||
C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB77255A581000E217F9 /* NSUserDefaults+OWS.m */; };
|
||||
C32C6018256E07F9003C73A2 /* NSUserDefaults+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB51255A580D00E217F9 /* NSUserDefaults+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
C33100082558FF6D00070591 /* NewConversationButtonSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83F2B85240C7B8F000A54AB /* NewConversationButtonSet.swift */; };
|
||||
|
@ -587,6 +604,9 @@
|
|||
FD09799727FFA84A00936362 /* RecipientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799627FFA84900936362 /* RecipientState.swift */; };
|
||||
FD09799927FFC1A300936362 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799827FFC1A300936362 /* Attachment.swift */; };
|
||||
FD09799B27FFC82D00936362 /* Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799A27FFC82D00936362 /* Quote.swift */; };
|
||||
FD09B7E328865FDA00ED0B66 /* HighlightMentionBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */; };
|
||||
FD09B7E5288670BB00ED0B66 /* _008_EmojiReacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E4288670BB00ED0B66 /* _008_EmojiReacts.swift */; };
|
||||
FD09B7E7288670FD00ED0B66 /* Reaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E6288670FD00ED0B66 /* Reaction.swift */; };
|
||||
FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E1282212B3000CE219 /* JobDependencies.swift */; };
|
||||
FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */; };
|
||||
FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E728264937000CE219 /* MediaDetailViewController.swift */; };
|
||||
|
@ -663,6 +683,8 @@
|
|||
FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; };
|
||||
FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */; };
|
||||
FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; };
|
||||
FD52090028AF6153006098F6 /* OWSBackgroundTask.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */; };
|
||||
FD52090128AF61BA006098F6 /* OWSBackgroundTask.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F6284F0E560029977D /* MessageReceiver+ReadReceipts.swift */; };
|
||||
FD5C72F9284F0E880029977D /* MessageReceiver+TypingIndicators.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */; };
|
||||
FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */; };
|
||||
|
@ -782,12 +804,10 @@
|
|||
FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; };
|
||||
FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; };
|
||||
FDCDB8E42817819600352A0C /* (null) in Sources */ = {isa = PBXBuildFile; };
|
||||
FDCDB8F12817ABE600352A0C /* Optional+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8F02817ABE600352A0C /* Optional+Utilities.swift */; };
|
||||
FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */; };
|
||||
FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */; };
|
||||
FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */; };
|
||||
FDE72118286C156E0093DF33 /* ChatSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE72117286C156E0093DF33 /* ChatSettingsViewController.swift */; };
|
||||
FDE72154287FE4470093DF33 /* HighlightMentionBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE72153287FE4470093DF33 /* HighlightMentionBackgroundView.swift */; };
|
||||
FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; };
|
||||
FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */; };
|
||||
FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; };
|
||||
|
@ -1144,6 +1164,9 @@
|
|||
7B1581E3271FC59C00848B49 /* CallModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallModal.swift; sourceTree = "<group>"; };
|
||||
7B1581E5271FD2A100848B49 /* VideoPreviewVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPreviewVC.swift; sourceTree = "<group>"; };
|
||||
7B1581E727210ECC00848B49 /* RenderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderView.swift; sourceTree = "<group>"; };
|
||||
7B1B52D728580C6D006069F2 /* EmojiPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerSheet.swift; sourceTree = "<group>"; };
|
||||
7B1B52DB28580D50006069F2 /* EmojiPickerCollectionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmojiPickerCollectionView.swift; sourceTree = "<group>"; };
|
||||
7B1B52DC28580D50006069F2 /* EmojiSkinTonePicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmojiSkinTonePicker.swift; sourceTree = "<group>"; };
|
||||
7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSENotificationPresenter.swift; sourceTree = "<group>"; };
|
||||
7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promise+Timeout.swift"; sourceTree = "<group>"; };
|
||||
7B1D74AF27C365960030B423 /* Timer+MainThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timer+MainThread.swift"; sourceTree = "<group>"; };
|
||||
|
@ -1151,15 +1174,30 @@
|
|||
7B46AAAE28766DF4001AF2DC /* AllMediaViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllMediaViewController.swift; sourceTree = "<group>"; };
|
||||
7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsendRequest.swift; sourceTree = "<group>"; };
|
||||
7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedMessageView.swift; sourceTree = "<group>"; };
|
||||
7B7037422834B81F000DCF35 /* ReactionContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionContainerView.swift; sourceTree = "<group>"; };
|
||||
7B7037442834BCC0000DCF35 /* ReactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionView.swift; sourceTree = "<group>"; };
|
||||
7B7CB188270430D20079FF93 /* CallMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMessageView.swift; sourceTree = "<group>"; };
|
||||
7B7CB18A270591630079FF93 /* ShareLogsModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLogsModal.swift; sourceTree = "<group>"; };
|
||||
7B7CB18D270D066F0079FF93 /* IncomingCallBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallBanner.swift; sourceTree = "<group>"; };
|
||||
7B7CB18F270FB2150079FF93 /* MiniCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniCallView.swift; sourceTree = "<group>"; };
|
||||
7B7CB191271508AD0079FF93 /* CallRingTonePlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallRingTonePlayer.swift; sourceTree = "<group>"; };
|
||||
7B81682228A4C1210069F315 /* UpdateTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateTypes.swift; sourceTree = "<group>"; };
|
||||
7B81682728B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _007_HomeQueryOptimisationIndexes.swift; sourceTree = "<group>"; };
|
||||
7B81682928B6F1420069F315 /* ReactionResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionResponse.swift; sourceTree = "<group>"; };
|
||||
7B81682B28B72F480069F315 /* PendingChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChange.swift; sourceTree = "<group>"; };
|
||||
7B8D5FC328332600008324D9 /* VisibleMessage+Reaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Reaction.swift"; sourceTree = "<group>"; };
|
||||
7B93D06927CF173D00811CB6 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = "<group>"; };
|
||||
7B93D06E27CF194000811CB6 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = "<group>"; };
|
||||
7B93D06F27CF194000811CB6 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = "<group>"; };
|
||||
7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = "<group>"; };
|
||||
7B9F71C828470667006DFE7B /* ReactionListSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionListSheet.swift; sourceTree = "<group>"; };
|
||||
7B9F71CB2852EEE2006DFE7B /* Emoji+Category.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Emoji+Category.swift"; sourceTree = "<group>"; };
|
||||
7B9F71CC2852EEE2006DFE7B /* EmojiWithSkinTones+String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "EmojiWithSkinTones+String.swift"; sourceTree = "<group>"; };
|
||||
7B9F71CD2852EEE2006DFE7B /* Emoji+SkinTones.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Emoji+SkinTones.swift"; sourceTree = "<group>"; };
|
||||
7B9F71CE2852EEE2006DFE7B /* Emoji.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; };
|
||||
7B9F71CF2852EEE2006DFE7B /* Emoji+Name.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Emoji+Name.swift"; sourceTree = "<group>"; };
|
||||
7B9F71D528531009006DFE7B /* Emoji+Available.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Emoji+Available.swift"; sourceTree = "<group>"; };
|
||||
7B9F71D628531009006DFE7B /* EmojiWithSkinTones.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmojiWithSkinTones.swift; sourceTree = "<group>"; };
|
||||
7BA68908272A27BE00EFC32F /* SessionCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCall.swift; sourceTree = "<group>"; };
|
||||
7BA6890C27325CCC00EFC32F /* SessionCallManager+CXCallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionCallManager+CXCallController.swift"; sourceTree = "<group>"; };
|
||||
7BA6890E27325CE300EFC32F /* SessionCallManager+CXProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionCallManager+CXProvider.swift"; sourceTree = "<group>"; };
|
||||
|
@ -1172,6 +1210,7 @@
|
|||
7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = "<group>"; };
|
||||
7BAF54DB27ACD12B003D12F8 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = "<group>"; };
|
||||
7BBBDC452875600700747E59 /* DocumentTitleViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentTitleViewController.swift; sourceTree = "<group>"; };
|
||||
7BBBDC43286EAD2D00747E59 /* TappableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TappableLabel.swift; sourceTree = "<group>"; };
|
||||
7BC01A3B241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionNotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
7BC01A3D241F40AB00BC7C55 /* NotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtension.swift; sourceTree = "<group>"; };
|
||||
7BC01A3F241F40AB00BC7C55 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
|
@ -1182,6 +1221,7 @@
|
|||
7BD477AF27F526FF004E2822 /* BlockListUIUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.swift; sourceTree = "<group>"; };
|
||||
7BDCFC0424206E7300641C39 /* SessionNotificationServiceExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SessionNotificationServiceExtension.entitlements; sourceTree = "<group>"; };
|
||||
7BDCFC07242186E700641C39 /* NotificationServiceExtensionContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtensionContext.swift; sourceTree = "<group>"; };
|
||||
7BFA8AE22831D0D4001876F3 /* ContextMenuVC+EmojiReactsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContextMenuVC+EmojiReactsView.swift"; sourceTree = "<group>"; };
|
||||
7BFD1A892745C4F000FB91B9 /* Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Permissions.swift; sourceTree = "<group>"; };
|
||||
7BFD1A8B2747150E00FB91B9 /* TurnServerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TurnServerInfo.swift; sourceTree = "<group>"; };
|
||||
7BFD1A962747689000FB91B9 /* Session-Turn-Server */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "Session-Turn-Server"; sourceTree = "<group>"; };
|
||||
|
@ -1217,7 +1257,6 @@
|
|||
B81D25B826157F20004D1FE1 /* public-loki-foundation.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "public-loki-foundation.crt"; sourceTree = "<group>"; };
|
||||
B81D25B926157F20004D1FE1 /* storage-seed-3.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "storage-seed-3.crt"; sourceTree = "<group>"; };
|
||||
B821494525D4D6FF009C0F2A /* URLModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLModal.swift; sourceTree = "<group>"; };
|
||||
B821494E25D4E163009C0F2A /* BodyTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BodyTextView.swift; sourceTree = "<group>"; };
|
||||
B82149B725D60393009C0F2A /* BlockedModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedModal.swift; sourceTree = "<group>"; };
|
||||
B82149C025D605C6009C0F2A /* InfoBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoBanner.swift; sourceTree = "<group>"; };
|
||||
B8269D2825C7A4B400488AB4 /* InputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputView.swift; sourceTree = "<group>"; };
|
||||
|
@ -1656,6 +1695,9 @@
|
|||
FD09799627FFA84900936362 /* RecipientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipientState.swift; sourceTree = "<group>"; };
|
||||
FD09799827FFC1A300936362 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
|
||||
FD09799A27FFC82D00936362 /* Quote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quote.swift; sourceTree = "<group>"; };
|
||||
FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightMentionBackgroundView.swift; sourceTree = "<group>"; };
|
||||
FD09B7E4288670BB00ED0B66 /* _008_EmojiReacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _008_EmojiReacts.swift; sourceTree = "<group>"; };
|
||||
FD09B7E6288670FD00ED0B66 /* Reaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reaction.swift; sourceTree = "<group>"; };
|
||||
FD09C5E1282212B3000CE219 /* JobDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobDependencies.swift; sourceTree = "<group>"; };
|
||||
FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewModel.swift; sourceTree = "<group>"; };
|
||||
FD09C5E728264937000CE219 /* MediaDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDetailViewController.swift; sourceTree = "<group>"; };
|
||||
|
@ -1695,6 +1737,7 @@
|
|||
FD37EA0E28AB3330003AE748 /* _006_FixHiddenModAdminSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _006_FixHiddenModAdminSupport.swift; sourceTree = "<group>"; };
|
||||
FD37EA1028AB34B3003AE748 /* TypedTableAlteration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedTableAlteration.swift; sourceTree = "<group>"; };
|
||||
FD37EA1428AB42CB003AE748 /* IdentitySpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentitySpec.swift; sourceTree = "<group>"; };
|
||||
FD37EA1A28ACB51F003AE748 /* _007_HomeQueryOptimisationIndexes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _007_HomeQueryOptimisationIndexes.swift; sourceTree = "<group>"; };
|
||||
FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = "<group>"; };
|
||||
FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRequestInfoSpec.swift; sourceTree = "<group>"; };
|
||||
FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadResponseSpec.swift; sourceTree = "<group>"; };
|
||||
|
@ -1820,14 +1863,12 @@
|
|||
FDC6D75F2862B3F600B04575 /* Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = "<group>"; };
|
||||
FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Differentiable+Utilities.swift"; sourceTree = "<group>"; };
|
||||
FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||
FDCDB8F02817ABE600352A0C /* Optional+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Utilities.swift"; sourceTree = "<group>"; };
|
||||
FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DifferenceKit+Utilities.swift"; sourceTree = "<group>"; };
|
||||
FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarbageCollectionJob.swift; sourceTree = "<group>"; };
|
||||
FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryNavigationController.swift; sourceTree = "<group>"; };
|
||||
FDE72117286C156E0093DF33 /* ChatSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatSettingsViewController.swift; sourceTree = "<group>"; };
|
||||
FDE7214F287E50D50093DF33 /* ProtoWrappers.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = ProtoWrappers.py; sourceTree = "<group>"; };
|
||||
FDE72150287E50D50093DF33 /* LintLocalizableStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LintLocalizableStrings.swift; sourceTree = "<group>"; };
|
||||
FDE72153287FE4470093DF33 /* HighlightMentionBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightMentionBackgroundView.swift; sourceTree = "<group>"; };
|
||||
FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerError.swift; sourceTree = "<group>"; };
|
||||
FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlMessageProcessRecord.swift; sourceTree = "<group>"; };
|
||||
FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UICollectionView+ReusableView.swift"; sourceTree = "<group>"; };
|
||||
|
@ -2140,6 +2181,16 @@
|
|||
path = Utilities;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7B1B52BD2851ADE1006069F2 /* Emoji Picker */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7B1B52DB28580D50006069F2 /* EmojiPickerCollectionView.swift */,
|
||||
7B1B52DC28580D50006069F2 /* EmojiSkinTonePicker.swift */,
|
||||
7B1B52D728580C6D006069F2 /* EmojiPickerSheet.swift */,
|
||||
);
|
||||
path = "Emoji Picker";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7B7CB18C270D06350079FF93 /* Views & Modals */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2152,6 +2203,14 @@
|
|||
path = "Views & Modals";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7B81682428B30BEC0069F315 /* Recovered References */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FD37EA1A28ACB51F003AE748 /* _007_HomeQueryOptimisationIndexes.swift */,
|
||||
);
|
||||
name = "Recovered References";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7B93D06827CF173D00811CB6 /* Message Requests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2162,6 +2221,20 @@
|
|||
path = "Message Requests";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7B9F71CA2852EEE2006DFE7B /* Emoji */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7B9F71D528531009006DFE7B /* Emoji+Available.swift */,
|
||||
7B9F71D628531009006DFE7B /* EmojiWithSkinTones.swift */,
|
||||
7B9F71CB2852EEE2006DFE7B /* Emoji+Category.swift */,
|
||||
7B9F71CC2852EEE2006DFE7B /* EmojiWithSkinTones+String.swift */,
|
||||
7B9F71CD2852EEE2006DFE7B /* Emoji+SkinTones.swift */,
|
||||
7B9F71CE2852EEE2006DFE7B /* Emoji.swift */,
|
||||
7B9F71CF2852EEE2006DFE7B /* Emoji+Name.swift */,
|
||||
);
|
||||
path = Emoji;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7BA68907272A279900EFC32F /* Call Management */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2227,6 +2300,8 @@
|
|||
B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */,
|
||||
7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */,
|
||||
7B7CB188270430D20079FF93 /* CallMessageView.swift */,
|
||||
7B7037422834B81F000DCF35 /* ReactionContainerView.swift */,
|
||||
7B7037442834BCC0000DCF35 /* ReactionView.swift */,
|
||||
);
|
||||
path = "Content Views";
|
||||
sourceTree = "<group>";
|
||||
|
@ -2253,7 +2328,6 @@
|
|||
B821494525D4D6FF009C0F2A /* URLModal.swift */,
|
||||
B8AF4BB326A5204600583500 /* SendSeedModal.swift */,
|
||||
B8758859264503A6000E60D0 /* JoinOpenGroupModal.swift */,
|
||||
B821494E25D4E163009C0F2A /* BodyTextView.swift */,
|
||||
B82149B725D60393009C0F2A /* BlockedModal.swift */,
|
||||
C374EEE125DA26740073A857 /* LinkPreviewModal.swift */,
|
||||
B82149C025D605C6009C0F2A /* InfoBanner.swift */,
|
||||
|
@ -2262,6 +2336,7 @@
|
|||
FD4B200D283492210034334B /* InsetLockableTableView.swift */,
|
||||
7B1581E3271FC59C00848B49 /* CallModal.swift */,
|
||||
7BFFB33B27D02F5800BEA04E /* CallPermissionRequestModal.swift */,
|
||||
7B9F71C828470667006DFE7B /* ReactionListSheet.swift */,
|
||||
);
|
||||
path = "Views & Modals";
|
||||
sourceTree = "<group>";
|
||||
|
@ -2273,6 +2348,7 @@
|
|||
B835247725C38D190089A44F /* Message Cells */,
|
||||
C328252E25CA54F70062D0A7 /* Context Menu */,
|
||||
B821493625D4D6A7009C0F2A /* Views & Modals */,
|
||||
7B1B52BD2851ADE1006069F2 /* Emoji Picker */,
|
||||
C302094625DCDFD3001F572D /* Settings */,
|
||||
FDF222062818CECF000A4995 /* ConversationViewModel.swift */,
|
||||
B835246D25C38ABF0089A44F /* ConversationVC.swift */,
|
||||
|
@ -2449,12 +2525,10 @@
|
|||
B8CCF63B239757C10091D419 /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FD4B200A283367350034334B /* Models */,
|
||||
4CA46F4B219CCC630038ABDE /* CaptionView.swift */,
|
||||
4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */,
|
||||
45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */,
|
||||
34386A53207D271C009F5D9C /* NeverClearView.swift */,
|
||||
FDE72153287FE4470093DF33 /* HighlightMentionBackgroundView.swift */,
|
||||
34F308A01ECB469700BB7697 /* OWSBezierPathView.h */,
|
||||
34F308A11ECB469700BB7697 /* OWSBezierPathView.m */,
|
||||
34330AA11E79686200DF2FB9 /* OWSProgressView.h */,
|
||||
|
@ -2515,6 +2589,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
C3C2A74C2553A39700C340D1 /* VisibleMessage.swift */,
|
||||
7B8D5FC328332600008324D9 /* VisibleMessage+Reaction.swift */,
|
||||
C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */,
|
||||
C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */,
|
||||
C3C2A75E2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift */,
|
||||
|
@ -2603,6 +2678,7 @@
|
|||
C328253F25CA55880062D0A7 /* ContextMenuVC.swift */,
|
||||
C328254825CA60E60062D0A7 /* ContextMenuVC+Action.swift */,
|
||||
C328255125CA64470062D0A7 /* ContextMenuVC+ActionView.swift */,
|
||||
7BFA8AE22831D0D4001876F3 /* ContextMenuVC+EmojiReactsView.swift */,
|
||||
);
|
||||
path = "Context Menu";
|
||||
sourceTree = "<group>";
|
||||
|
@ -2715,6 +2791,8 @@
|
|||
B8CCF638239721E20091D419 /* TabBar.swift */,
|
||||
B8BB82B423947F2D00BA5194 /* TextField.swift */,
|
||||
C3C3CF8824D8EED300E1CCE7 /* TextView.swift */,
|
||||
7BBBDC43286EAD2D00747E59 /* TappableLabel.swift */,
|
||||
FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
|
@ -3063,8 +3141,6 @@
|
|||
C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */,
|
||||
C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */,
|
||||
C38EF281255B6D84007E1867 /* OWSAudioSession.swift */,
|
||||
C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */,
|
||||
C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */,
|
||||
FDF0B75D280AAF35004C14C5 /* Preferences.swift */,
|
||||
C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */,
|
||||
C38EF306255B6DBE007E1867 /* OWSWindowManager.m */,
|
||||
|
@ -3132,7 +3208,6 @@
|
|||
B8A582AE258C65D000AFD84C /* Networking */,
|
||||
B8A582AD258C655E00AFD84C /* PromiseKit */,
|
||||
FD09796527F6B0A800936362 /* Utilities */,
|
||||
FDCDB8EF2817ABCE00352A0C /* Utilities */,
|
||||
C3D9E43025676D3D0040E4F3 /* Configuration.swift */,
|
||||
);
|
||||
path = SessionUtilitiesKit;
|
||||
|
@ -3329,6 +3404,7 @@
|
|||
D221A08C169C9E5E00537ABF /* Frameworks */,
|
||||
D221A08A169C9E5E00537ABF /* Products */,
|
||||
2BADBA206E0B8D297E313FBA /* Pods */,
|
||||
7B81682428B30BEC0069F315 /* Recovered References */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
|
@ -3405,6 +3481,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
C3F0A58F255C8E3D007BE2A3 /* Meta */,
|
||||
7B9F71CA2852EEE2006DFE7B /* Emoji */,
|
||||
B8B558ED26C4B55F00693325 /* Calls */,
|
||||
C360969C25AD18BA008B62B2 /* Closed Groups */,
|
||||
B835246C25C38AA20089A44F /* Conversations */,
|
||||
|
@ -3432,6 +3509,8 @@
|
|||
FD09797127FAA2F500936362 /* Optional+Utilities.swift */,
|
||||
FD09797C27FBDB2000936362 /* Notification+Utilities.swift */,
|
||||
FDF222082818D2B0000A4995 /* NSAttributedString+Utilities.swift */,
|
||||
C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */,
|
||||
C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */,
|
||||
);
|
||||
path = Utilities;
|
||||
sourceTree = "<group>";
|
||||
|
@ -3457,6 +3536,7 @@
|
|||
FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */,
|
||||
FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */,
|
||||
FD5C7308285007920029977D /* BlindedIdLookup.swift */,
|
||||
FD09B7E6288670FD00ED0B66 /* Reaction.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
|
@ -3470,6 +3550,8 @@
|
|||
FDF40CDD2897A1BC006A0CC4 /* _004_RemoveLegacyYDB.swift */,
|
||||
FD37EA0C28AB2A45003AE748 /* _005_FixDeletedMessageReadState.swift */,
|
||||
FD37EA0E28AB3330003AE748 /* _006_FixHiddenModAdminSupport.swift */,
|
||||
7B81682728B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift */,
|
||||
FD09B7E4288670BB00ED0B66 /* _008_EmojiReacts.swift */,
|
||||
);
|
||||
path = Migrations;
|
||||
sourceTree = "<group>";
|
||||
|
@ -3653,13 +3735,6 @@
|
|||
path = "Shared Models";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FD4B200A283367350034334B /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FD716E6F28505E5100C96BF4 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -3788,6 +3863,8 @@
|
|||
FDC438A327BB107F00C60D73 /* UserBanRequest.swift */,
|
||||
FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */,
|
||||
FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */,
|
||||
7B81682928B6F1420069F315 /* ReactionResponse.swift */,
|
||||
7B81682B28B72F480069F315 /* PendingChange.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
|
@ -3815,6 +3892,7 @@
|
|||
FDC4384E27B4804F00C60D73 /* Header.swift */,
|
||||
FDC4385027B4807400C60D73 /* QueryParam.swift */,
|
||||
FD83B9CD27D17A04005E1583 /* Request.swift */,
|
||||
7B81682228A4C1210069F315 /* UpdateTypes.swift */,
|
||||
);
|
||||
path = "Common Networking";
|
||||
sourceTree = "<group>";
|
||||
|
@ -3890,14 +3968,6 @@
|
|||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FDCDB8EF2817ABCE00352A0C /* Utilities */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FDCDB8F02817ABE600352A0C /* Optional+Utilities.swift */,
|
||||
);
|
||||
path = Utilities;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FDE7214E287E50D50093DF33 /* Scripts */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -4012,6 +4082,7 @@
|
|||
C3C2A67D255388CC00C340D1 /* SessionUtilitiesKit.h in Headers */,
|
||||
C32C6018256E07F9003C73A2 /* NSUserDefaults+OWS.h in Headers */,
|
||||
B8856D8D256F1502001CE70E /* UIView+OWS.h in Headers */,
|
||||
FD52090128AF61BA006098F6 /* OWSBackgroundTask.h in Headers */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -4021,7 +4092,6 @@
|
|||
files = (
|
||||
C3C2A6F425539DE700C340D1 /* SessionMessagingKit.h in Headers */,
|
||||
C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */,
|
||||
C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */,
|
||||
FD716E732850647900C96BF4 /* NSData+messagePadding.h in Headers */,
|
||||
B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */,
|
||||
B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */,
|
||||
|
@ -4855,6 +4925,8 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
C331FF972558FA6B00070591 /* Fonts.swift in Sources */,
|
||||
7BBBDC44286EAD2D00747E59 /* TappableLabel.swift in Sources */,
|
||||
FD09B7E328865FDA00ED0B66 /* HighlightMentionBackgroundView.swift in Sources */,
|
||||
C331FF9B2558FA6B00070591 /* Gradients.swift in Sources */,
|
||||
C331FFB82558FA8D00070591 /* DeviceUtilities.swift in Sources */,
|
||||
C331FFE72558FB0000070591 /* TextField.swift in Sources */,
|
||||
|
@ -5028,7 +5100,6 @@
|
|||
FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */,
|
||||
C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */,
|
||||
FD09797B27FBB25900936362 /* Updatable.swift in Sources */,
|
||||
FDCDB8F12817ABE600352A0C /* Optional+Utilities.swift in Sources */,
|
||||
7B7CB192271508AD0079FF93 /* CallRingTonePlayer.swift in Sources */,
|
||||
C3C2ABD22553C6C900C340D1 /* Data+SecureRandom.swift in Sources */,
|
||||
FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */,
|
||||
|
@ -5074,6 +5145,7 @@
|
|||
FD17D7B827F51ECA00122BE0 /* Migration.swift in Sources */,
|
||||
FD7728982849E8110018502F /* UITableView+ReusableView.swift in Sources */,
|
||||
7B0EFDEE274F598600FFAAE7 /* TimestampUtils.swift in Sources */,
|
||||
FD52090028AF6153006098F6 /* OWSBackgroundTask.m in Sources */,
|
||||
C32C5DDB256DD9FF003C73A2 /* ContentProxy.swift in Sources */,
|
||||
C3A71F892558BA9F0043A11F /* Mnemonic.swift in Sources */,
|
||||
B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */,
|
||||
|
@ -5118,6 +5190,7 @@
|
|||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7B81682828B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift in Sources */,
|
||||
FD86585828507B24008B6CF9 /* NSData+messagePadding.m in Sources */,
|
||||
FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */,
|
||||
B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */,
|
||||
|
@ -5129,18 +5202,21 @@
|
|||
C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */,
|
||||
FDF40CDE2897A1BC006A0CC4 /* _004_RemoveLegacyYDB.swift in Sources */,
|
||||
FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */,
|
||||
7B81682C28B72F480069F315 /* PendingChange.swift in Sources */,
|
||||
FD77289A284AF1BD0018502F /* Sodium+Utilities.swift in Sources */,
|
||||
FD5C7309285007920029977D /* BlindedIdLookup.swift in Sources */,
|
||||
7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */,
|
||||
C300A5F22554B09800555489 /* MessageSender.swift in Sources */,
|
||||
B8B558FF26C4E05E00693325 /* WebRTCSession+MessageHandling.swift in Sources */,
|
||||
C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */,
|
||||
FD09B7E7288670FD00ED0B66 /* Reaction.swift in Sources */,
|
||||
FD245C58285065F700B966DD /* OpenGroupServerIdLookup.swift in Sources */,
|
||||
FD245C5A2850660100B966DD /* LinkPreviewDraft.swift in Sources */,
|
||||
FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */,
|
||||
FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */,
|
||||
FD716E6A2850327900C96BF4 /* EndCallMode.swift in Sources */,
|
||||
FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */,
|
||||
7B81682A28B6F1420069F315 /* ReactionResponse.swift in Sources */,
|
||||
FD09799727FFA84A00936362 /* RecipientState.swift in Sources */,
|
||||
FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */,
|
||||
FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */,
|
||||
|
@ -5153,13 +5229,19 @@
|
|||
FD245C6A2850666F00B966DD /* FileServerAPI.swift in Sources */,
|
||||
FDC4386927B4E6B800C60D73 /* String+Utlities.swift in Sources */,
|
||||
FD716E6628502EE200C96BF4 /* CurrentCallProtocol.swift in Sources */,
|
||||
FD09B7E5288670BB00ED0B66 /* _008_EmojiReacts.swift in Sources */,
|
||||
FDC4385F27B4C4A200C60D73 /* PinnedMessage.swift in Sources */,
|
||||
7BFD1A8C2747150E00FB91B9 /* TurnServerInfo.swift in Sources */,
|
||||
C3D9E3BF25676AD70040E4F3 /* (null) in Sources */,
|
||||
B8BF43BA26CC95FB007828D1 /* WebRTC+Utilities.swift in Sources */,
|
||||
7B8D5FC428332600008324D9 /* VisibleMessage+Reaction.swift in Sources */,
|
||||
C3BBE0B52554F0E10050F1E3 /* (null) in Sources */,
|
||||
FDC4386527B4DE7600C60D73 /* RoomPollInfo.swift in Sources */,
|
||||
FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */,
|
||||
FD37EA0F28AB3330003AE748 /* _006_FixHiddenModAdminSupport.swift in Sources */,
|
||||
C3D9E3BF25676AD70040E4F3 /* (null) in Sources */,
|
||||
B8BF43BA26CC95FB007828D1 /* WebRTC+Utilities.swift in Sources */,
|
||||
7B81682328A4C1210069F315 /* UpdateTypes.swift in Sources */,
|
||||
FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */,
|
||||
C3BBE0B52554F0E10050F1E3 /* (null) in Sources */,
|
||||
FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */,
|
||||
|
@ -5247,7 +5329,6 @@
|
|||
FD245C5C2850660A00B966DD /* ConfigurationMessage.swift in Sources */,
|
||||
FD5C7301284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift in Sources */,
|
||||
C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */,
|
||||
C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */,
|
||||
FD245C642850664F00B966DD /* Threading.swift in Sources */,
|
||||
FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */,
|
||||
C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */,
|
||||
|
@ -5293,6 +5374,7 @@
|
|||
FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */,
|
||||
4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */,
|
||||
34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */,
|
||||
7BFA8AE32831D0D4001876F3 /* ContextMenuVC+EmojiReactsView.swift in Sources */,
|
||||
7B13E1EB2811138200BD4F64 /* PrivacySettingsTableViewController.swift in Sources */,
|
||||
C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */,
|
||||
7BAF54D027ACCEEC003D12F8 /* EmptySearchResultCell.swift in Sources */,
|
||||
|
@ -5312,6 +5394,7 @@
|
|||
EF764C351DB67CC5000D9A87 /* UIViewController+Permissions.m in Sources */,
|
||||
45CD81EF1DC030E7004C9430 /* SyncPushTokensJob.swift in Sources */,
|
||||
B83524A525C3BA4B0089A44F /* InfoMessageCell.swift in Sources */,
|
||||
7B9F71D82853100A006DFE7B /* EmojiWithSkinTones.swift in Sources */,
|
||||
FDE72118286C156E0093DF33 /* ChatSettingsViewController.swift in Sources */,
|
||||
B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */,
|
||||
34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */,
|
||||
|
@ -5321,9 +5404,9 @@
|
|||
7B1581E6271FD2A100848B49 /* VideoPreviewVC.swift in Sources */,
|
||||
B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */,
|
||||
3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */,
|
||||
FDE72154287FE4470093DF33 /* HighlightMentionBackgroundView.swift in Sources */,
|
||||
340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */,
|
||||
4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */,
|
||||
7B9F71D12852EEE2006DFE7B /* EmojiWithSkinTones+String.swift in Sources */,
|
||||
34F308A21ECB469700BB7697 /* OWSBezierPathView.m in Sources */,
|
||||
B886B4A92398BA1500211ABE /* QRCode.swift in Sources */,
|
||||
3496955D219B605E00DCFE74 /* PhotoCollectionPickerController.swift in Sources */,
|
||||
|
@ -5335,7 +5418,9 @@
|
|||
3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */,
|
||||
C3A76A8D25DB83F90074CB90 /* PermissionMissingModal.swift in Sources */,
|
||||
340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */,
|
||||
7B1B52E028580D51006069F2 /* EmojiSkinTonePicker.swift in Sources */,
|
||||
B849789625D4A2F500D0D0B3 /* LinkPreviewView.swift in Sources */,
|
||||
7B1B52DF28580D51006069F2 /* EmojiPickerCollectionView.swift in Sources */,
|
||||
C3D0972B2510499C00F6E3E4 /* BackgroundPoller.swift in Sources */,
|
||||
C3548F0624456447009433A8 /* PNModeVC.swift in Sources */,
|
||||
B80A579F23DFF1F300876683 /* NewClosedGroupVC.swift in Sources */,
|
||||
|
@ -5364,6 +5449,7 @@
|
|||
45A6DAD61EBBF85500893231 /* ReminderView.swift in Sources */,
|
||||
B82B408E239DC00D00A248E7 /* DisplayNameVC.swift in Sources */,
|
||||
B835246E25C38ABF0089A44F /* ConversationVC.swift in Sources */,
|
||||
7B7037432834B81F000DCF35 /* ReactionContainerView.swift in Sources */,
|
||||
B8AF4BB426A5204600583500 /* SendSeedModal.swift in Sources */,
|
||||
B821494625D4D6FF009C0F2A /* URLModal.swift in Sources */,
|
||||
7BBBDC462875600700747E59 /* DocumentTitleViewController.swift in Sources */,
|
||||
|
@ -5392,11 +5478,14 @@
|
|||
4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */,
|
||||
B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */,
|
||||
C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */,
|
||||
7B9F71D02852EEE2006DFE7B /* Emoji+Category.swift in Sources */,
|
||||
7BAADFCC27B0EF23007BCF92 /* CallVideoView.swift in Sources */,
|
||||
B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */,
|
||||
FD848B9828422F1A000E298B /* Date+Utilities.swift in Sources */,
|
||||
B85357C323A1BD1200AAF6CD /* SeedVC.swift in Sources */,
|
||||
45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */,
|
||||
7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */,
|
||||
7B7037452834BCC0000DCF35 /* ReactionView.swift in Sources */,
|
||||
B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */,
|
||||
7B7CB190270FB2150079FF93 /* MiniCallView.swift in Sources */,
|
||||
B875885A264503A6000E60D0 /* JoinOpenGroupModal.swift in Sources */,
|
||||
|
@ -5412,14 +5501,17 @@
|
|||
B8041A9525C8FA1D003C2166 /* MediaLoaderView.swift in Sources */,
|
||||
45F32C232057297A00A300D5 /* MediaPageViewController.swift in Sources */,
|
||||
34D2CCDA2062E7D000CB1A14 /* OWSScreenLockUI.m in Sources */,
|
||||
7B9F71D42852EEE2006DFE7B /* Emoji+Name.swift in Sources */,
|
||||
4CA46F4C219CCC630038ABDE /* CaptionView.swift in Sources */,
|
||||
C328253025CA55370062D0A7 /* ContextMenuWindow.swift in Sources */,
|
||||
340FC8B7204DAC8D007AEB0F /* OWSConversationSettingsViewController.m in Sources */,
|
||||
7B1581E4271FC59D00848B49 /* CallModal.swift in Sources */,
|
||||
34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */,
|
||||
B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */,
|
||||
7B9F71D72853100A006DFE7B /* Emoji+Available.swift in Sources */,
|
||||
FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */,
|
||||
7B7CB189270430D20079FF93 /* CallMessageView.swift in Sources */,
|
||||
7B9F71D32852EEE2006DFE7B /* Emoji.swift in Sources */,
|
||||
C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */,
|
||||
B82B4090239DD75000A248E7 /* RestoreVC.swift in Sources */,
|
||||
3488F9362191CC4000E524CC /* MediaView.swift in Sources */,
|
||||
|
@ -5440,11 +5532,11 @@
|
|||
C31A6C5A247F214E001123EF /* UIView+Glow.swift in Sources */,
|
||||
4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */,
|
||||
340FC8AC204DAC8D007AEB0F /* PrivacySettingsTableViewController.m in Sources */,
|
||||
7B9F71D22852EEE2006DFE7B /* Emoji+SkinTones.swift in Sources */,
|
||||
7B7CB18E270D066F0079FF93 /* IncomingCallBanner.swift in Sources */,
|
||||
B8569AE325CBB19A00DBA3DB /* DocumentView.swift in Sources */,
|
||||
7BFD1A8A2745C4F000FB91B9 /* Permissions.swift in Sources */,
|
||||
B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */,
|
||||
B821494F25D4E163009C0F2A /* BodyTextView.swift in Sources */,
|
||||
FD716E6C28505E1C00C96BF4 /* MessageRequestsViewModel.swift in Sources */,
|
||||
C35E8AAE2485E51D00ACB629 /* IP2Country.swift in Sources */,
|
||||
B835249B25C3AB650089A44F /* VisibleMessageCell.swift in Sources */,
|
||||
|
@ -5452,6 +5544,7 @@
|
|||
B8D0A25025E3678700C1835E /* LinkDeviceVC.swift in Sources */,
|
||||
B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */,
|
||||
7B93D07727CF1A8A00811CB6 /* MockDataGenerator.swift in Sources */,
|
||||
7B1B52D828580C6D006069F2 /* EmojiPickerSheet.swift in Sources */,
|
||||
7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */,
|
||||
FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */,
|
||||
FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */,
|
||||
|
@ -5707,7 +5800,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 348;
|
||||
CURRENT_PROJECT_VERSION = 374;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
|
||||
|
@ -5732,7 +5825,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.13.0;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
@ -5780,7 +5873,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 348;
|
||||
CURRENT_PROJECT_VERSION = 374;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
|
@ -5810,7 +5903,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.13.0;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
@ -5846,7 +5939,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 348;
|
||||
CURRENT_PROJECT_VERSION = 374;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
|
||||
|
@ -5869,7 +5962,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.13.0;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
|
||||
|
@ -5920,7 +6013,7 @@
|
|||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 348;
|
||||
CURRENT_PROJECT_VERSION = 374;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
|
@ -5948,7 +6041,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.13.0;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
|
||||
|
@ -6858,7 +6951,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 371;
|
||||
CURRENT_PROJECT_VERSION = 374;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -6897,7 +6990,7 @@
|
|||
"$(SRCROOT)",
|
||||
);
|
||||
LLVM_LTO = NO;
|
||||
MARKETING_VERSION = 2.0.3;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
OTHER_LDFLAGS = "$(inherited)";
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
|
||||
|
@ -6930,7 +7023,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 371;
|
||||
CURRENT_PROJECT_VERSION = 374;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -6969,7 +7062,7 @@
|
|||
"$(SRCROOT)",
|
||||
);
|
||||
LLVM_LTO = NO;
|
||||
MARKETING_VERSION = 2.0.3;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
OTHER_LDFLAGS = "$(inherited)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
|
||||
PRODUCT_NAME = Session;
|
||||
|
|
|
@ -7,75 +7,107 @@ extension ContextMenuVC {
|
|||
struct Action {
|
||||
let icon: UIImage?
|
||||
let title: String
|
||||
let isEmojiAction: Bool
|
||||
let isEmojiPlus: Bool
|
||||
let isDismissAction: Bool
|
||||
let work: () -> Void
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
icon: UIImage? = nil,
|
||||
title: String = "",
|
||||
isEmojiAction: Bool = false,
|
||||
isEmojiPlus: Bool = false,
|
||||
isDismissAction: Bool = false,
|
||||
work: @escaping () -> Void
|
||||
) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self.isEmojiAction = isEmojiAction
|
||||
self.isEmojiPlus = isEmojiPlus
|
||||
self.isDismissAction = isDismissAction
|
||||
self.work = work
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_reply"),
|
||||
title: "context_menu_reply".localized(),
|
||||
isDismissAction: false
|
||||
title: "context_menu_reply".localized()
|
||||
) { delegate?.reply(cellViewModel) }
|
||||
}
|
||||
|
||||
static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_copy"),
|
||||
title: "copy".localized(),
|
||||
isDismissAction: false
|
||||
title: "copy".localized()
|
||||
) { delegate?.copy(cellViewModel) }
|
||||
}
|
||||
|
||||
static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_copy"),
|
||||
title: "vc_conversation_settings_copy_session_id_button_title".localized(),
|
||||
isDismissAction: false
|
||||
title: "vc_conversation_settings_copy_session_id_button_title".localized()
|
||||
) { delegate?.copySessionID(cellViewModel) }
|
||||
}
|
||||
|
||||
static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_trash"),
|
||||
title: "TXT_DELETE_TITLE".localized(),
|
||||
isDismissAction: false
|
||||
title: "TXT_DELETE_TITLE".localized()
|
||||
) { delegate?.delete(cellViewModel) }
|
||||
}
|
||||
|
||||
static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_download"),
|
||||
title: "context_menu_save".localized(),
|
||||
isDismissAction: false
|
||||
title: "context_menu_save".localized()
|
||||
) { delegate?.save(cellViewModel) }
|
||||
}
|
||||
|
||||
static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_block"),
|
||||
title: "context_menu_ban_user".localized(),
|
||||
isDismissAction: false
|
||||
title: "context_menu_ban_user".localized()
|
||||
) { delegate?.ban(cellViewModel) }
|
||||
}
|
||||
|
||||
static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: UIImage(named: "ic_block"),
|
||||
title: "context_menu_ban_and_delete_all".localized(),
|
||||
isDismissAction: false
|
||||
title: "context_menu_ban_and_delete_all".localized()
|
||||
) { delegate?.banAndDeleteAllMessages(cellViewModel) }
|
||||
}
|
||||
|
||||
static func react(_ cellViewModel: MessageViewModel, _ emoji: EmojiWithSkinTones, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
title: emoji.rawValue,
|
||||
isEmojiAction: true
|
||||
) { delegate?.react(cellViewModel, with: emoji) }
|
||||
}
|
||||
|
||||
static func emojiPlusButton(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
isEmojiPlus: true
|
||||
) { delegate?.showFullEmojiKeyboard(cellViewModel) }
|
||||
}
|
||||
|
||||
static func dismiss(_ delegate: ContextMenuActionDelegate?) -> Action {
|
||||
return Action(
|
||||
icon: nil,
|
||||
title: "",
|
||||
isDismissAction: true
|
||||
) { delegate?.contextMenuDismissed() }
|
||||
}
|
||||
}
|
||||
|
||||
static func actions(for cellViewModel: MessageViewModel, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? {
|
||||
static func actions(
|
||||
for cellViewModel: MessageViewModel,
|
||||
recentEmojis: [EmojiWithSkinTones],
|
||||
currentUserIsOpenGroupModerator: Bool,
|
||||
currentThreadIsMessageRequest: Bool,
|
||||
delegate: ContextMenuActionDelegate?
|
||||
) -> [Action]? {
|
||||
// No context items for info messages
|
||||
guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else {
|
||||
return nil
|
||||
|
@ -118,13 +150,21 @@ extension ContextMenuVC {
|
|||
)
|
||||
let canDelete: Bool = (
|
||||
cellViewModel.threadVariant != .openGroup ||
|
||||
currentUserIsOpenGroupModerator
|
||||
currentUserIsOpenGroupModerator ||
|
||||
cellViewModel.state == .failed
|
||||
)
|
||||
let canBan: Bool = (
|
||||
cellViewModel.threadVariant == .openGroup &&
|
||||
currentUserIsOpenGroupModerator
|
||||
)
|
||||
|
||||
let shouldShowEmojiActions: Bool = {
|
||||
if cellViewModel.threadVariant == .openGroup {
|
||||
return OpenGroupManager.isOpenGroupSupport(.reactions, on: cellViewModel.threadOpenGroupServer)
|
||||
}
|
||||
return !currentThreadIsMessageRequest
|
||||
}()
|
||||
|
||||
let generatedActions: [Action] = [
|
||||
(canReply ? Action.reply(cellViewModel, delegate) : nil),
|
||||
(canCopy ? Action.copy(cellViewModel, delegate) : nil),
|
||||
|
@ -132,8 +172,10 @@ extension ContextMenuVC {
|
|||
(canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil),
|
||||
(canDelete ? Action.delete(cellViewModel, delegate) : nil),
|
||||
(canBan ? Action.ban(cellViewModel, delegate) : nil),
|
||||
(canBan ? Action.banAndDeleteAllMessages(cellViewModel, delegate) : nil)
|
||||
(canBan ? Action.banAndDeleteAllMessages(cellViewModel, delegate) : nil),
|
||||
]
|
||||
.appending(contentsOf: (shouldShowEmojiActions ? recentEmojis : []).map { Action.react(cellViewModel, $0, delegate) })
|
||||
.appending(Action.emojiPlusButton(cellViewModel, delegate))
|
||||
.compactMap { $0 }
|
||||
|
||||
guard !generatedActions.isEmpty else { return [] }
|
||||
|
@ -152,5 +194,7 @@ protocol ContextMenuActionDelegate {
|
|||
func save(_ cellViewModel: MessageViewModel)
|
||||
func ban(_ cellViewModel: MessageViewModel)
|
||||
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel)
|
||||
func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones)
|
||||
func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel)
|
||||
func contextMenuDismissed()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
extension ContextMenuVC {
|
||||
final class EmojiReactsView: UIView {
|
||||
private let action: Action
|
||||
private let dismiss: () -> Void
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
private static let size: CGFloat = 40
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(for action: Action, dismiss: @escaping () -> Void) {
|
||||
self.action = action
|
||||
self.dismiss = dismiss
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(for:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(for:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
let emojiLabel = UILabel()
|
||||
emojiLabel.text = self.action.title
|
||||
emojiLabel.font = .systemFont(ofSize: Values.veryLargeFontSize)
|
||||
emojiLabel.set(.height, to: ContextMenuVC.EmojiReactsView.size)
|
||||
addSubview(emojiLabel)
|
||||
emojiLabel.pin(to: self)
|
||||
|
||||
// Tap gesture recognizer
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func handleTap() {
|
||||
action.work()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
final class EmojiPlusButton: UIView {
|
||||
private let action: Action?
|
||||
private let dismiss: () -> Void
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
public static let size: CGFloat = 28
|
||||
private let iconSize: CGFloat = 14
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(action: Action?, dismiss: @escaping () -> Void) {
|
||||
self.action = action
|
||||
self.dismiss = dismiss
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(for:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(for:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
// Icon image
|
||||
let iconImageView = UIImageView(image: #imageLiteral(resourceName: "ic_plus_24").withRenderingMode(.alwaysTemplate))
|
||||
iconImageView.tintColor = Colors.text
|
||||
iconImageView.set(.width, to: iconSize)
|
||||
iconImageView.set(.height, to: iconSize)
|
||||
iconImageView.contentMode = .scaleAspectFit
|
||||
addSubview(iconImageView)
|
||||
iconImageView.center(in: self)
|
||||
|
||||
// Background
|
||||
isUserInteractionEnabled = true
|
||||
backgroundColor = Colors.sessionEmojiPlusButtonBackground
|
||||
|
||||
// Tap gesture recognizer
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func handleTap() {
|
||||
dismiss()
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: { [weak self] in
|
||||
self?.action?.work()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,10 +13,34 @@ final class ContextMenuVC: UIViewController {
|
|||
private let cellViewModel: MessageViewModel
|
||||
private let actions: [Action]
|
||||
private let dismiss: () -> Void
|
||||
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var blurView: UIVisualEffectView = UIVisualEffectView(effect: nil)
|
||||
|
||||
private lazy var emojiBar: UIView = {
|
||||
let result = UIView()
|
||||
result.layer.shadowColor = UIColor.black.cgColor
|
||||
result.layer.shadowOffset = CGSize.zero
|
||||
result.layer.shadowOpacity = 0.4
|
||||
result.layer.shadowRadius = 4
|
||||
result.set(.height, to: ContextMenuVC.actionViewHeight)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var emojiPlusButton: EmojiPlusButton = {
|
||||
let result = EmojiPlusButton(
|
||||
action: self.actions.first(where: { $0.isEmojiPlus }),
|
||||
dismiss: snDismiss
|
||||
)
|
||||
result.set(.width, to: EmojiPlusButton.size)
|
||||
result.set(.height, to: EmojiPlusButton.size)
|
||||
result.layer.cornerRadius = EmojiPlusButton.size / 2
|
||||
result.layer.masksToBounds = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var menuView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
|
@ -85,11 +109,6 @@ final class ContextMenuVC: UIViewController {
|
|||
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)
|
||||
|
@ -101,6 +120,35 @@ final class ContextMenuVC: UIViewController {
|
|||
timestampLabel.pin(.left, to: .right, of: snapshot, withInset: Values.smallSpacing)
|
||||
}
|
||||
|
||||
// Emoji reacts
|
||||
let emojiBarBackgroundView = UIView()
|
||||
emojiBarBackgroundView.backgroundColor = Colors.receivedMessageBackground
|
||||
emojiBarBackgroundView.layer.cornerRadius = ContextMenuVC.actionViewHeight / 2
|
||||
emojiBarBackgroundView.layer.masksToBounds = true
|
||||
emojiBar.addSubview(emojiBarBackgroundView)
|
||||
emojiBarBackgroundView.pin(to: emojiBar)
|
||||
|
||||
emojiBar.addSubview(emojiPlusButton)
|
||||
emojiPlusButton.pin(.right, to: .right, of: emojiBar, withInset: -Values.smallSpacing)
|
||||
emojiPlusButton.center(.vertical, in: emojiBar)
|
||||
|
||||
let emojiBarStackView = UIStackView(
|
||||
arrangedSubviews: actions
|
||||
.filter { $0.isEmojiAction }
|
||||
.map { action -> EmojiReactsView in EmojiReactsView(for: action, dismiss: snDismiss) }
|
||||
)
|
||||
emojiBarStackView.axis = .horizontal
|
||||
emojiBarStackView.spacing = Values.smallSpacing
|
||||
emojiBarStackView.layoutMargins = UIEdgeInsets(top: 0, left: Values.smallSpacing, bottom: 0, right: Values.smallSpacing)
|
||||
emojiBarStackView.isLayoutMarginsRelativeArrangement = true
|
||||
emojiBar.addSubview(emojiBarStackView)
|
||||
emojiBarStackView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: emojiBar)
|
||||
emojiBarStackView.pin(.right, to: .left, of: emojiPlusButton)
|
||||
|
||||
// Hide the emoji bar if we have no emoji actions
|
||||
emojiBar.isHidden = emojiBarStackView.arrangedSubviews.isEmpty
|
||||
view.addSubview(emojiBar)
|
||||
|
||||
// Menu
|
||||
let menuBackgroundView = UIView()
|
||||
menuBackgroundView.backgroundColor = Colors.receivedMessageBackground
|
||||
|
@ -111,7 +159,7 @@ final class ContextMenuVC: UIViewController {
|
|||
|
||||
let menuStackView = UIStackView(
|
||||
arrangedSubviews: actions
|
||||
.filter { !$0.isDismissAction }
|
||||
.filter { !$0.isEmojiAction && !$0.isEmojiPlus && !$0.isDismissAction }
|
||||
.map { action -> ActionView in ActionView(for: action, dismiss: snDismiss) }
|
||||
)
|
||||
menuStackView.axis = .vertical
|
||||
|
@ -119,21 +167,27 @@ final class ContextMenuVC: UIViewController {
|
|||
menuStackView.pin(to: menuView)
|
||||
view.addSubview(menuView)
|
||||
|
||||
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)
|
||||
// Constrains
|
||||
let menuHeight: CGFloat = CGFloat(menuStackView.arrangedSubviews.count) * ContextMenuVC.actionViewHeight
|
||||
let spacing: CGFloat = Values.smallSpacing
|
||||
let targetFrame: CGRect = calculateFrame(menuHeight: menuHeight, spacing: spacing)
|
||||
|
||||
snapshot.pin(.left, to: .left, of: view, withInset: targetFrame.origin.x)
|
||||
snapshot.pin(.top, to: .top, of: view, withInset: targetFrame.origin.y)
|
||||
snapshot.set(.width, to: targetFrame.width)
|
||||
snapshot.set(.height, to: targetFrame.height)
|
||||
emojiBar.pin(.bottom, to: .top, of: snapshot, withInset: -spacing)
|
||||
menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing)
|
||||
|
||||
if frame.maxY + spacing + menuHeight > UIScreen.main.bounds.height - margin {
|
||||
menuView.pin(.bottom, to: .top, of: snapshot, withInset: -spacing)
|
||||
}
|
||||
else {
|
||||
menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing)
|
||||
}
|
||||
|
||||
switch cellViewModel.variant {
|
||||
case .standardOutgoing: menuView.pin(.right, to: .right, of: snapshot)
|
||||
case .standardIncoming: menuView.pin(.left, to: .left, of: snapshot)
|
||||
case .standardOutgoing:
|
||||
menuView.pin(.right, to: .right, of: snapshot)
|
||||
emojiBar.pin(.right, to: .right, of: snapshot)
|
||||
|
||||
case .standardIncoming:
|
||||
menuView.pin(.left, to: .left, of: snapshot)
|
||||
emojiBar.pin(.left, to: .left, of: snapshot)
|
||||
|
||||
default: break // Should never occur
|
||||
}
|
||||
|
||||
|
@ -150,6 +204,40 @@ final class ContextMenuVC: UIViewController {
|
|||
self.menuView.alpha = 1
|
||||
}
|
||||
}
|
||||
|
||||
func calculateFrame(menuHeight: CGFloat, spacing: CGFloat) -> CGRect {
|
||||
var finalFrame: CGRect = frame
|
||||
let ratio: CGFloat = (frame.width / frame.height)
|
||||
|
||||
// FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement)
|
||||
let topMargin = max(UIApplication.shared.keyWindow!.safeAreaInsets.top, Values.mediumSpacing)
|
||||
let bottomMargin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing)
|
||||
let diffY = finalFrame.height + menuHeight + Self.actionViewHeight + 2 * spacing + topMargin + bottomMargin - UIScreen.main.bounds.height
|
||||
|
||||
if diffY > 0 {
|
||||
// The screenshot needs to be shrinked. Menu + emoji bar + screenshot will fill the entire screen.
|
||||
finalFrame.size.height -= diffY
|
||||
let newWidth = ratio * finalFrame.size.height
|
||||
if cellViewModel.variant == .standardOutgoing {
|
||||
finalFrame.origin.x += finalFrame.size.width - newWidth
|
||||
}
|
||||
finalFrame.size.width = newWidth
|
||||
finalFrame.origin.y = UIScreen.main.bounds.height - finalFrame.size.height - menuHeight - bottomMargin - spacing
|
||||
}
|
||||
else {
|
||||
// The screenshot does NOT need to be shrinked.
|
||||
if finalFrame.origin.y - Self.actionViewHeight - spacing < topMargin {
|
||||
// Needs to move down
|
||||
finalFrame.origin.y = topMargin + Self.actionViewHeight + spacing
|
||||
}
|
||||
if finalFrame.origin.y + finalFrame.size.height + spacing + menuHeight + bottomMargin > UIScreen.main.bounds.height {
|
||||
// Needs to move up
|
||||
finalFrame.origin.y = UIScreen.main.bounds.height - bottomMargin - menuHeight - spacing - finalFrame.size.height
|
||||
}
|
||||
}
|
||||
|
||||
return finalFrame
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
|
@ -160,6 +248,10 @@ final class ContextMenuVC: UIViewController {
|
|||
roundedRect: menuView.bounds,
|
||||
cornerRadius: ContextMenuVC.menuCornerRadius
|
||||
).cgPath
|
||||
emojiBar.layer.shadowPath = UIBezierPath(
|
||||
roundedRect: emojiBar.bounds,
|
||||
cornerRadius: (ContextMenuVC.actionViewHeight / 2)
|
||||
).cgPath
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
@ -174,6 +266,7 @@ final class ContextMenuVC: UIViewController {
|
|||
animations: { [weak self] in
|
||||
self?.blurView.effect = nil
|
||||
self?.menuView.alpha = 0
|
||||
self?.emojiBar.alpha = 0
|
||||
self?.snapshot.alpha = 0
|
||||
self?.timestampLabel.alpha = 0
|
||||
},
|
||||
|
|
|
@ -73,8 +73,7 @@ extension ConversationVC:
|
|||
|
||||
let callVC = CallVC(for: call)
|
||||
callVC.conversationVC = self
|
||||
self.inputAccessoryView?.isHidden = true
|
||||
self.inputAccessoryView?.alpha = 0
|
||||
hideInputAccessoryView()
|
||||
|
||||
present(callVC, animated: true, completion: nil)
|
||||
}
|
||||
|
@ -85,7 +84,7 @@ extension ConversationVC:
|
|||
self.showBlockedModalIfNeeded()
|
||||
}
|
||||
|
||||
func showBlockedModalIfNeeded() -> Bool {
|
||||
@discardableResult func showBlockedModalIfNeeded() -> Bool {
|
||||
guard self.viewModel.threadData.threadIsBlocked == true else { return false }
|
||||
|
||||
let blockedModal = BlockedModal(publicKey: viewModel.threadData.threadId)
|
||||
|
@ -642,7 +641,12 @@ extension ConversationVC:
|
|||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
func hideInputAccessoryView() {
|
||||
self.inputAccessoryView?.isHidden = true
|
||||
self.inputAccessoryView?.alpha = 0
|
||||
}
|
||||
|
||||
func showInputAccessoryView() {
|
||||
UIView.animate(withDuration: 0.25, animations: {
|
||||
self.inputAccessoryView?.isHidden = false
|
||||
|
@ -667,11 +671,13 @@ extension ConversationVC:
|
|||
contextMenuWindow == nil,
|
||||
let actions: [ContextMenuVC.Action] = ContextMenuVC.actions(
|
||||
for: cellViewModel,
|
||||
recentEmojis: (self.viewModel.threadData.recentReactionEmoji ?? []).compactMap { EmojiWithSkinTones(rawValue: $0) },
|
||||
currentUserIsOpenGroupModerator: OpenGroupManager.isUserModeratorOrAdmin(
|
||||
self.viewModel.threadData.currentUserPublicKey,
|
||||
for: self.viewModel.threadData.openGroupRoomToken,
|
||||
on: self.viewModel.threadData.openGroupServer
|
||||
),
|
||||
currentThreadIsMessageRequest: (self.viewModel.threadData.threadIsMessageRequest == true),
|
||||
delegate: self
|
||||
)
|
||||
else { return }
|
||||
|
@ -697,6 +703,7 @@ extension ConversationVC:
|
|||
|
||||
self.contextMenuWindow?.backgroundColor = .clear
|
||||
self.contextMenuWindow?.rootViewController = self.contextMenuVC
|
||||
self.contextMenuWindow?.overrideUserInterfaceStyle = (isDarkMode ? .dark : .light)
|
||||
self.contextMenuWindow?.makeKeyAndVisible()
|
||||
}
|
||||
|
||||
|
@ -948,10 +955,346 @@ extension ConversationVC:
|
|||
guard let threadId: String = targetThreadId else { return }
|
||||
|
||||
let conversationVC: ConversationVC = ConversationVC(threadId: threadId, threadVariant: .contact)
|
||||
|
||||
self.navigationController?.pushViewController(conversationVC, animated: true)
|
||||
}
|
||||
|
||||
func showReactionList(_ cellViewModel: MessageViewModel, selectedReaction: EmojiWithSkinTones?) {
|
||||
guard
|
||||
cellViewModel.reactionInfo?.isEmpty == false &&
|
||||
(
|
||||
self.viewModel.threadData.threadVariant == .closedGroup ||
|
||||
self.viewModel.threadData.threadVariant == .openGroup
|
||||
),
|
||||
let allMessages: [MessageViewModel] = self.viewModel.interactionData
|
||||
.first(where: { $0.model == .messages })?
|
||||
.elements
|
||||
else { return }
|
||||
|
||||
let reactionListSheet: ReactionListSheet = ReactionListSheet(for: cellViewModel.id) { [weak self] in
|
||||
self?.currentReactionListSheet = nil
|
||||
}
|
||||
reactionListSheet.delegate = self
|
||||
reactionListSheet.handleInteractionUpdates(
|
||||
allMessages,
|
||||
selectedReaction: selectedReaction,
|
||||
initialLoad: true,
|
||||
shouldShowClearAllButton: OpenGroupManager.isUserModeratorOrAdmin(
|
||||
self.viewModel.threadData.currentUserPublicKey,
|
||||
for: self.viewModel.threadData.openGroupRoomToken,
|
||||
on: self.viewModel.threadData.openGroupServer
|
||||
)
|
||||
)
|
||||
reactionListSheet.modalPresentationStyle = .overFullScreen
|
||||
present(reactionListSheet, animated: true, completion: nil)
|
||||
|
||||
// Store so we can updated the content based on the current VC
|
||||
self.currentReactionListSheet = reactionListSheet
|
||||
}
|
||||
|
||||
func needsLayout(for cellViewModel: MessageViewModel, expandingReactions: Bool) {
|
||||
guard
|
||||
let messageSectionIndex: Int = self.viewModel.interactionData
|
||||
.firstIndex(where: { $0.model == .messages }),
|
||||
let targetMessageIndex = self.viewModel.interactionData[messageSectionIndex]
|
||||
.elements
|
||||
.firstIndex(where: { $0.id == cellViewModel.id })
|
||||
else { return }
|
||||
|
||||
if expandingReactions {
|
||||
self.viewModel.expandReactions(for: cellViewModel.id)
|
||||
}
|
||||
else {
|
||||
self.viewModel.collapseReactions(for: cellViewModel.id)
|
||||
}
|
||||
|
||||
UIView.setAnimationsEnabled(false)
|
||||
tableView.reloadRows(
|
||||
at: [IndexPath(row: targetMessageIndex, section: messageSectionIndex)],
|
||||
with: .none
|
||||
)
|
||||
UIView.setAnimationsEnabled(true)
|
||||
}
|
||||
|
||||
func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones) {
|
||||
react(cellViewModel, with: emoji.rawValue, remove: false)
|
||||
}
|
||||
|
||||
func removeReact(_ cellViewModel: MessageViewModel, for emoji: EmojiWithSkinTones) {
|
||||
react(cellViewModel, with: emoji.rawValue, remove: true)
|
||||
}
|
||||
|
||||
func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) {
|
||||
guard cellViewModel.threadVariant == .openGroup else { return }
|
||||
|
||||
Storage.shared
|
||||
.read { db -> Promise<Void> in
|
||||
guard
|
||||
let openGroup: OpenGroup = try? OpenGroup
|
||||
.fetchOne(db, id: cellViewModel.threadId),
|
||||
let openGroupServerMessageId: Int64 = try? Interaction
|
||||
.select(.openGroupServerMessageId)
|
||||
.filter(id: cellViewModel.id)
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchOne(db)
|
||||
else {
|
||||
return Promise(error: StorageError.objectNotFound)
|
||||
}
|
||||
|
||||
let pendingChange = OpenGroupManager
|
||||
.addPendingReaction(
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server,
|
||||
type: .removeAll
|
||||
)
|
||||
|
||||
return OpenGroupAPI
|
||||
.reactionDeleteAll(
|
||||
db,
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _, response in
|
||||
OpenGroupManager
|
||||
.updatePendingChange(
|
||||
pendingChange,
|
||||
seqNo: response.seqNo
|
||||
)
|
||||
}
|
||||
}
|
||||
.done { _ in
|
||||
Storage.shared.writeAsync { db in
|
||||
_ = try Reaction
|
||||
.filter(Reaction.Columns.interactionId == cellViewModel.id)
|
||||
.filter(Reaction.Columns.emoji == emoji)
|
||||
.deleteAll(db)
|
||||
}
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
func react(_ cellViewModel: MessageViewModel, with emoji: String, remove: Bool) {
|
||||
guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else {
|
||||
return
|
||||
}
|
||||
|
||||
let threadIsMessageRequest: Bool = (self.viewModel.threadData.threadIsMessageRequest == true)
|
||||
guard !threadIsMessageRequest else { return }
|
||||
|
||||
// Perform local rate limiting (don't allow more than 20 reactions within 60 seconds)
|
||||
let sentTimestamp: Int64 = Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
let recentReactionTimestamps: [Int64] = General.cache.wrappedValue.recentReactionTimestamps
|
||||
|
||||
guard
|
||||
recentReactionTimestamps.count < 20 ||
|
||||
(sentTimestamp - (recentReactionTimestamps.first ?? sentTimestamp)) > (60 * 1000)
|
||||
else { return }
|
||||
|
||||
General.cache.mutate {
|
||||
$0.recentReactionTimestamps = Array($0.recentReactionTimestamps
|
||||
.suffix(19))
|
||||
.appending(sentTimestamp)
|
||||
}
|
||||
|
||||
// Perform the sending logic
|
||||
Storage.shared.writeAsync(
|
||||
updates: { db in
|
||||
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: cellViewModel.threadId) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Update the thread to be visible
|
||||
_ = try SessionThread
|
||||
.filter(id: thread.id)
|
||||
.updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true))
|
||||
|
||||
let pendingReaction: Reaction? = {
|
||||
if remove {
|
||||
return try? Reaction
|
||||
.filter(Reaction.Columns.interactionId == cellViewModel.id)
|
||||
.filter(Reaction.Columns.authorId == cellViewModel.currentUserPublicKey)
|
||||
.filter(Reaction.Columns.emoji == emoji)
|
||||
.fetchOne(db)
|
||||
} else {
|
||||
let sortId = Reaction.getSortId(
|
||||
db,
|
||||
interactionId: cellViewModel.id,
|
||||
emoji: emoji
|
||||
)
|
||||
|
||||
return Reaction(
|
||||
interactionId: cellViewModel.id,
|
||||
serverHash: nil,
|
||||
timestampMs: sentTimestamp,
|
||||
authorId: cellViewModel.currentUserPublicKey,
|
||||
emoji: emoji,
|
||||
count: 1,
|
||||
sortId: sortId
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
// Update the database
|
||||
if remove {
|
||||
try Reaction
|
||||
.filter(Reaction.Columns.interactionId == cellViewModel.id)
|
||||
.filter(Reaction.Columns.authorId == cellViewModel.currentUserPublicKey)
|
||||
.filter(Reaction.Columns.emoji == emoji)
|
||||
.deleteAll(db)
|
||||
}
|
||||
else {
|
||||
try pendingReaction?.insert(db)
|
||||
|
||||
// Add it to the recent list
|
||||
Emoji.addRecent(db, emoji: emoji)
|
||||
}
|
||||
|
||||
if let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: cellViewModel.threadId),
|
||||
OpenGroupManager.isOpenGroupSupport(.reactions, on: openGroup.server)
|
||||
{
|
||||
// Send reaction to open groups
|
||||
guard
|
||||
let openGroupServerMessageId: Int64 = try? Interaction
|
||||
.select(.openGroupServerMessageId)
|
||||
.filter(id: cellViewModel.id)
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchOne(db)
|
||||
else { return }
|
||||
|
||||
if remove {
|
||||
let pendingChange = OpenGroupManager
|
||||
.addPendingReaction(
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server,
|
||||
type: .remove
|
||||
)
|
||||
OpenGroupAPI
|
||||
.reactionDelete(
|
||||
db,
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _, response in
|
||||
OpenGroupManager
|
||||
.updatePendingChange(
|
||||
pendingChange,
|
||||
seqNo: response.seqNo
|
||||
)
|
||||
}
|
||||
.catch { [weak self] _ in
|
||||
OpenGroupManager.removePendingChange(pendingChange)
|
||||
|
||||
self?.handleReactionSentFailure(
|
||||
pendingReaction,
|
||||
remove: remove
|
||||
)
|
||||
|
||||
}
|
||||
.retainUntilComplete()
|
||||
} else {
|
||||
let pendingChange = OpenGroupManager
|
||||
.addPendingReaction(
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server,
|
||||
type: .add
|
||||
)
|
||||
OpenGroupAPI
|
||||
.reactionAdd(
|
||||
db,
|
||||
emoji: emoji,
|
||||
id: openGroupServerMessageId,
|
||||
in: openGroup.roomToken,
|
||||
on: openGroup.server
|
||||
)
|
||||
.map { _, response in
|
||||
OpenGroupManager
|
||||
.updatePendingChange(
|
||||
pendingChange,
|
||||
seqNo: response.seqNo
|
||||
)
|
||||
}
|
||||
.catch { [weak self] _ in
|
||||
OpenGroupManager.removePendingChange(pendingChange)
|
||||
|
||||
self?.handleReactionSentFailure(
|
||||
pendingReaction,
|
||||
remove: remove
|
||||
)
|
||||
}
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
||||
} else {
|
||||
// Send the actual message
|
||||
try MessageSender.send(
|
||||
db,
|
||||
message: VisibleMessage(
|
||||
sentTimestamp: UInt64(sentTimestamp),
|
||||
text: nil,
|
||||
reaction: VisibleMessage.VMReaction(
|
||||
timestamp: UInt64(cellViewModel.timestampMs),
|
||||
publicKey: {
|
||||
guard cellViewModel.variant == .standardIncoming else {
|
||||
return cellViewModel.currentUserPublicKey
|
||||
}
|
||||
|
||||
return cellViewModel.authorId
|
||||
}(),
|
||||
emoji: emoji,
|
||||
kind: (remove ? .remove : .react)
|
||||
)
|
||||
),
|
||||
interactionId: cellViewModel.id,
|
||||
in: thread
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func handleReactionSentFailure(_ pendingReaction: Reaction?, remove: Bool) {
|
||||
guard let pendingReaction = pendingReaction else { return }
|
||||
Storage.shared.writeAsync { db in
|
||||
// Reverse the database
|
||||
if remove {
|
||||
try pendingReaction.insert(db)
|
||||
}
|
||||
else {
|
||||
try Reaction
|
||||
.filter(Reaction.Columns.interactionId == pendingReaction.interactionId)
|
||||
.filter(Reaction.Columns.authorId == pendingReaction.authorId)
|
||||
.filter(Reaction.Columns.emoji == pendingReaction.emoji)
|
||||
.deleteAll(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel) {
|
||||
hideInputAccessoryView()
|
||||
|
||||
let emojiPicker = EmojiPickerSheet(
|
||||
completionHandler: { [weak self] emoji in
|
||||
guard let emoji: EmojiWithSkinTones = emoji else { return }
|
||||
|
||||
self?.react(cellViewModel, with: emoji)
|
||||
},
|
||||
dismissHandler: { [weak self] in
|
||||
self?.showInputAccessoryView()
|
||||
}
|
||||
)
|
||||
emojiPicker.modalPresentationStyle = .overFullScreen
|
||||
present(emojiPicker, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func contextMenuDismissed() {
|
||||
recoverInputView()
|
||||
}
|
||||
|
@ -1132,7 +1475,60 @@ extension ConversationVC:
|
|||
on: openGroup.server
|
||||
)
|
||||
)
|
||||
else { return }
|
||||
else {
|
||||
// If the message hasn't been sent yet then just delete locally
|
||||
guard cellViewModel.state == .sending || cellViewModel.state == .failed else {
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve any message send jobs for this interaction
|
||||
let jobs: [Job] = Storage.shared
|
||||
.read { db in
|
||||
try? Job
|
||||
.filter(Job.Columns.variant == Job.Variant.messageSend)
|
||||
.filter(Job.Columns.interactionId == cellViewModel.id)
|
||||
.fetchAll(db)
|
||||
}
|
||||
.defaulting(to: [])
|
||||
|
||||
// If the job is currently running then wait until it's done before triggering
|
||||
// the deletion
|
||||
let targetJob: Job? = jobs.first(where: { JobRunner.isCurrentlyRunning($0) })
|
||||
|
||||
guard targetJob == nil else {
|
||||
JobRunner.afterCurrentlyRunningJob(targetJob) { [weak self] result in
|
||||
switch result {
|
||||
// If it succeeded then we'll need to delete from the server so re-run
|
||||
// this function (if we still don't have the server id for some reason
|
||||
// then this would result in a local-only deletion which should be fine
|
||||
case .succeeded: self?.delete(cellViewModel)
|
||||
|
||||
// Otherwise we just need to cancel the pending job (in case it retries)
|
||||
// and delete the interaction
|
||||
default:
|
||||
JobRunner.removePendingJob(targetJob)
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
_ = try Interaction
|
||||
.filter(id: cellViewModel.id)
|
||||
.deleteAll(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If it's not currently running then remove any pending jobs (just to be safe) and
|
||||
// delete the interaction locally
|
||||
jobs.forEach { JobRunner.removePendingJob($0) }
|
||||
|
||||
Storage.shared.writeAsync { db in
|
||||
_ = try Interaction
|
||||
.filter(id: cellViewModel.id)
|
||||
.deleteAll(db)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the message from the open group
|
||||
deleteRemotely(
|
||||
|
|
|
@ -53,6 +53,10 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
var didFinishInitialLayout = false
|
||||
var scrollDistanceToBottomBeforeUpdate: CGFloat?
|
||||
var baselineKeyboardHeight: CGFloat = 0
|
||||
|
||||
// Reaction
|
||||
var currentReactionListSheet: ReactionListSheet?
|
||||
var reactionExpandedMessageIds: Set<String> = []
|
||||
|
||||
/// This flag is used to temporarily prevent the ConversationVC from becoming the first responder (primarily used with
|
||||
/// custom transitions from preventing them from being buggy
|
||||
|
@ -642,6 +646,11 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
return
|
||||
}
|
||||
|
||||
// Update the ReactionListSheet (if one exists)
|
||||
if let messageUpdates: [MessageViewModel] = updatedData.first(where: { $0.model == .messages })?.elements {
|
||||
self.currentReactionListSheet?.handleInteractionUpdates(messageUpdates)
|
||||
}
|
||||
|
||||
// Store the 'sentMessageBeforeUpdate' state locally
|
||||
let didSendMessageBeforeUpdate: Bool = self.viewModel.sentMessageBeforeUpdate
|
||||
self.viewModel.sentMessageBeforeUpdate = false
|
||||
|
@ -688,6 +697,17 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
let wasLoadingMore: Bool = self.isLoadingMore
|
||||
let wasOffsetCloseToBottom: Bool = self.isCloseToBottom
|
||||
let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count }
|
||||
let didSwapAllContent: Bool = (updatedData
|
||||
.first(where: { $0.model == .messages })?
|
||||
.elements
|
||||
.contains(where: {
|
||||
$0.id == self.viewModel.interactionData
|
||||
.first(where: { $0.model == .messages })?
|
||||
.elements
|
||||
.first?
|
||||
.id
|
||||
}))
|
||||
.defaulting(to: false)
|
||||
let itemChangeInfo: ItemChangeInfo? = {
|
||||
guard
|
||||
isInsert,
|
||||
|
@ -720,7 +740,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
)
|
||||
}()
|
||||
|
||||
guard !isInsert || wasLoadingMore || itemChangeInfo?.isInsertAtTop == true else {
|
||||
guard !isInsert || itemChangeInfo?.isInsertAtTop == true else {
|
||||
self.viewModel.updateInteractionData(updatedData)
|
||||
self.tableView.reloadData()
|
||||
|
||||
|
@ -729,16 +749,27 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
if let focusedInteractionId: Int64 = self.focusedInteractionId {
|
||||
// If we had a focusedInteractionId then scroll to it (and hide the search
|
||||
// result bar loading indicator)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in
|
||||
let delay: DispatchTime = (didSwapAllContent ?
|
||||
.now() :
|
||||
(.now() + .milliseconds(100))
|
||||
)
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: delay) { [weak self] in
|
||||
self?.searchController.resultsBar.stopLoading()
|
||||
self?.scrollToInteractionIfNeeded(
|
||||
with: focusedInteractionId,
|
||||
isAnimated: true,
|
||||
highlight: (self?.shouldHighlightNextScrollToInteraction == true)
|
||||
)
|
||||
|
||||
if wasLoadingMore {
|
||||
// Complete page loading
|
||||
self?.isLoadingMore = false
|
||||
self?.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
else if wasOffsetCloseToBottom {
|
||||
else if wasOffsetCloseToBottom && !wasLoadingMore {
|
||||
// Scroll to the bottom if an interaction was just inserted and we either
|
||||
// just sent a message or are close enough to the bottom (wait a tiny fraction
|
||||
// to avoid buggy animation behaviour)
|
||||
|
@ -746,6 +777,11 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
self?.scrollToBottom(isAnimated: true)
|
||||
}
|
||||
}
|
||||
else if wasLoadingMore {
|
||||
// Complete page loading
|
||||
self.isLoadingMore = false
|
||||
self.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -755,7 +791,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
///
|
||||
/// Unfortunately the UITableView also does some weird things when updating (where it won't have updated it's internal data until
|
||||
/// after it performs the next layout); the below code checks a condition on layout and if it passes it calls a closure
|
||||
if let itemChangeInfo: ItemChangeInfo = itemChangeInfo, (itemChangeInfo.isInsertAtTop || wasLoadingMore) {
|
||||
if let itemChangeInfo: ItemChangeInfo = itemChangeInfo, itemChangeInfo.isInsertAtTop {
|
||||
let oldCellHeight: CGFloat = self.tableView.rectForRow(at: itemChangeInfo.oldVisibleIndexPath).height
|
||||
|
||||
// The the user triggered the 'scrollToTop' animation (by tapping in the nav bar) then we
|
||||
|
@ -789,7 +825,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
.rectForRow(at: itemChangeInfo.visibleIndexPath)
|
||||
.height
|
||||
let heightDiff: CGFloat = (oldCellHeight - (newTargetHeight ?? oldCellHeight))
|
||||
|
||||
|
||||
self?.tableView.contentOffset.y += (calculatedRowHeights - heightDiff)
|
||||
}
|
||||
|
||||
|
@ -805,14 +841,38 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Complete page loading
|
||||
self?.isLoadingMore = false
|
||||
self?.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
)
|
||||
}
|
||||
else if wasLoadingMore {
|
||||
if let focusedInteractionId: Int64 = self.focusedInteractionId {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
// If we had a focusedInteractionId then scroll to it (and hide the search
|
||||
// result bar loading indicator)
|
||||
self?.searchController.resultsBar.stopLoading()
|
||||
self?.scrollToInteractionIfNeeded(
|
||||
with: focusedInteractionId,
|
||||
isAnimated: true,
|
||||
highlight: (self?.shouldHighlightNextScrollToInteraction == true)
|
||||
)
|
||||
|
||||
// Complete page loading
|
||||
self?.isLoadingMore = false
|
||||
self?.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Complete page loading
|
||||
self.isLoadingMore = false
|
||||
self.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
// Update the messages
|
||||
self.tableView.reload(
|
||||
using: changeset,
|
||||
deleteSectionsAnimation: .none,
|
||||
|
@ -827,6 +887,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
|
||||
private func performInitialScrollIfNeeded() {
|
||||
guard !hasPerformedInitialScroll && hasLoadedInitialThreadData && hasLoadedInitialInteractionData else {
|
||||
return
|
||||
|
@ -837,13 +899,13 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
// the screen will scroll to the bottom instead of the first unread message
|
||||
if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId {
|
||||
self.scrollToInteractionIfNeeded(with: focusedInteractionId, isAnimated: false, highlight: true)
|
||||
self.unreadCountView.alpha = self.scrollButton.alpha
|
||||
}
|
||||
else {
|
||||
self.scrollToBottom(isAnimated: false)
|
||||
}
|
||||
|
||||
self.scrollButton.alpha = self.getScrollButtonOpacity()
|
||||
self.unreadCountView.alpha = self.scrollButton.alpha
|
||||
self.hasPerformedInitialScroll = true
|
||||
|
||||
// Now that the data has loaded we need to check if either of the "load more" sections are
|
||||
|
@ -1018,6 +1080,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
|
||||
let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0)
|
||||
self?.scrollButton.alpha = scrollButtonOpacity
|
||||
self?.unreadCountView.alpha = scrollButtonOpacity
|
||||
|
||||
self?.view.setNeedsLayout()
|
||||
self?.view.layoutIfNeeded()
|
||||
|
@ -1132,6 +1195,8 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
cell.dynamicUpdate(with: cellViewModel, playbackInfo: updatedInfo)
|
||||
}
|
||||
},
|
||||
showExpandedReactions: viewModel.reactionExpandedInteractionIds
|
||||
.contains(cellViewModel.id),
|
||||
lastSearchText: viewModel.lastSearchedText
|
||||
)
|
||||
cell.delegate = self
|
||||
|
@ -1225,6 +1290,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
self.scrollToInteractionIfNeeded(
|
||||
with: lastInteractionId,
|
||||
position: .bottom,
|
||||
isJumpingToLastInteraction: true,
|
||||
isAnimated: true
|
||||
)
|
||||
return
|
||||
|
@ -1283,7 +1349,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
let contentOffsetY = tableView.contentOffset.y
|
||||
let x = (lastPageTop - ConversationVC.bottomInset - contentOffsetY).clamp(0, .greatestFiniteMagnitude)
|
||||
let a = 1 / (ConversationVC.scrollButtonFullVisibilityThreshold - ConversationVC.scrollButtonNoVisibilityThreshold)
|
||||
return a * x
|
||||
return max(0, min(1, a * x))
|
||||
}
|
||||
|
||||
// MARK: - Search
|
||||
|
@ -1394,6 +1460,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
func scrollToInteractionIfNeeded(
|
||||
with interactionId: Int64,
|
||||
position: UITableView.ScrollPosition = .middle,
|
||||
isJumpingToLastInteraction: Bool = false,
|
||||
isAnimated: Bool = true,
|
||||
highlight: Bool = false
|
||||
) {
|
||||
|
@ -1417,10 +1484,18 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers
|
|||
self.searchController.resultsBar.startLoading()
|
||||
|
||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||
self?.viewModel.pagedDataObserver?.load(.untilInclusive(
|
||||
id: interactionId,
|
||||
padding: 5
|
||||
))
|
||||
if isJumpingToLastInteraction {
|
||||
self?.viewModel.pagedDataObserver?.load(.jumpTo(
|
||||
id: interactionId,
|
||||
paddingForInclusive: 5
|
||||
))
|
||||
}
|
||||
else {
|
||||
self?.viewModel.pagedDataObserver?.load(.untilInclusive(
|
||||
id: interactionId,
|
||||
padding: 5
|
||||
))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
@ -132,10 +132,13 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
return ValueObservation
|
||||
.trackingConstantRegion { db -> SessionThreadViewModel? in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
return try SessionThreadViewModel
|
||||
let recentReactionEmoji: [String] = try Emoji.getRecent(db, withDefaultEmoji: true)
|
||||
let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
||||
.conversationQuery(threadId: threadId, userPublicKey: userPublicKey)
|
||||
.fetchOne(db)
|
||||
|
||||
return threadViewModel
|
||||
.map { $0.with(recentReactionEmoji: recentReactionEmoji) }
|
||||
}
|
||||
.removeDuplicates()
|
||||
}
|
||||
|
@ -148,6 +151,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
|
||||
public private(set) var unobservedInteractionDataChanges: [SectionModel]?
|
||||
public private(set) var interactionData: [SectionModel] = []
|
||||
public private(set) var reactionExpandedInteractionIds: Set<Int64> = []
|
||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<Interaction, MessageViewModel>?
|
||||
|
||||
public var onInteractionChange: (([SectionModel]) -> ())? {
|
||||
|
@ -215,6 +219,18 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
joinToPagedType: MessageViewModel.AttachmentInteractionInfo.joinToViewModelQuerySQL,
|
||||
associateData: MessageViewModel.AttachmentInteractionInfo.createAssociateDataClosure()
|
||||
),
|
||||
AssociatedRecord<MessageViewModel.ReactionInfo, MessageViewModel>(
|
||||
trackedAgainst: Reaction.self,
|
||||
observedChanges: [
|
||||
PagedData.ObservedChanges(
|
||||
table: Reaction.self,
|
||||
columns: [.count]
|
||||
)
|
||||
],
|
||||
dataQuery: MessageViewModel.ReactionInfo.baseQuery,
|
||||
joinToPagedType: MessageViewModel.ReactionInfo.joinToViewModelQuerySQL,
|
||||
associateData: MessageViewModel.ReactionInfo.createAssociateDataClosure()
|
||||
),
|
||||
AssociatedRecord<MessageViewModel.TypingIndicatorInfo, MessageViewModel>(
|
||||
trackedAgainst: ThreadTypingIndicator.self,
|
||||
observedChanges: [
|
||||
|
@ -294,6 +310,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
self.interactionData = updatedData
|
||||
}
|
||||
|
||||
public func expandReactions(for interactionId: Int64) {
|
||||
reactionExpandedInteractionIds.insert(interactionId)
|
||||
}
|
||||
|
||||
public func collapseReactions(for interactionId: Int64) {
|
||||
reactionExpandedInteractionIds.remove(interactionId)
|
||||
}
|
||||
|
||||
// MARK: - Mentions
|
||||
|
||||
public struct MentionInfo: FetchableRecord, Decodable {
|
||||
|
|
|
@ -0,0 +1,360 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
protocol EmojiPickerCollectionViewDelegate: AnyObject {
|
||||
func emojiPicker(_ emojiPicker: EmojiPickerCollectionView?, didSelectEmoji emoji: EmojiWithSkinTones)
|
||||
func emojiPickerWillBeginDragging(_ emojiPicker: EmojiPickerCollectionView)
|
||||
}
|
||||
|
||||
class EmojiPickerCollectionView: UICollectionView {
|
||||
let layout: UICollectionViewFlowLayout
|
||||
|
||||
weak var pickerDelegate: EmojiPickerCollectionViewDelegate?
|
||||
|
||||
private var recentEmoji: [EmojiWithSkinTones] = []
|
||||
var hasRecentEmoji: Bool { !recentEmoji.isEmpty }
|
||||
|
||||
private var allSendableEmojiByCategory: [Emoji.Category: [EmojiWithSkinTones]] = [:]
|
||||
private lazy var allSendableEmoji: [EmojiWithSkinTones] = {
|
||||
return Array(allSendableEmojiByCategory.values).flatMap({$0})
|
||||
}()
|
||||
|
||||
static let emojiWidth: CGFloat = 38
|
||||
static let margins: CGFloat = 16
|
||||
static let minimumSpacing: CGFloat = 10
|
||||
|
||||
public var searchText: String? {
|
||||
didSet {
|
||||
searchWithText(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
private var emojiSearchResults: [EmojiWithSkinTones] = []
|
||||
|
||||
public var isSearching: Bool {
|
||||
if let searchText = searchText, searchText.count != 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
lazy var tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissSkinTonePicker))
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
layout = UICollectionViewFlowLayout()
|
||||
layout.itemSize = CGSize(width: Self.emojiWidth, height: Self.emojiWidth)
|
||||
layout.minimumInteritemSpacing = EmojiPickerCollectionView.minimumSpacing
|
||||
layout.sectionInset = UIEdgeInsets(top: 0, leading: EmojiPickerCollectionView.margins, bottom: 0, trailing: EmojiPickerCollectionView.margins)
|
||||
|
||||
super.init(frame: .zero, collectionViewLayout: layout)
|
||||
|
||||
delegate = self
|
||||
dataSource = self
|
||||
|
||||
register(EmojiCell.self, forCellWithReuseIdentifier: EmojiCell.reuseIdentifier)
|
||||
register(
|
||||
EmojiSectionHeader.self,
|
||||
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
|
||||
withReuseIdentifier: EmojiSectionHeader.reuseIdentifier
|
||||
)
|
||||
|
||||
backgroundColor = .clear
|
||||
|
||||
let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
|
||||
panGestureRecognizer.require(toFail: longPressGesture)
|
||||
addGestureRecognizer(longPressGesture)
|
||||
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
tapGestureRecognizer.delegate = self
|
||||
|
||||
// Fetch the emoji data from the database
|
||||
let maybeEmojiData: (recent: [EmojiWithSkinTones], allGrouped: [Emoji.Category: [EmojiWithSkinTones]])? = Storage.shared.read { db in
|
||||
// Some emoji have two different code points but identical appearances. Let's remove them!
|
||||
// If we normalize to a different emoji than the one currently in our array, we want to drop
|
||||
// the non-normalized variant if the normalized variant already exists. Otherwise, map to the
|
||||
// normalized variant.
|
||||
let recentEmoji: [EmojiWithSkinTones] = try Emoji.getRecent(db, withDefaultEmoji: false)
|
||||
.compactMap { EmojiWithSkinTones(rawValue: $0) }
|
||||
.reduce(into: [EmojiWithSkinTones]()) { result, emoji in
|
||||
guard !emoji.isNormalized else {
|
||||
result.append(emoji)
|
||||
return
|
||||
}
|
||||
guard !result.contains(emoji.normalized) else { return }
|
||||
|
||||
result.append(emoji.normalized)
|
||||
}
|
||||
let allSendableEmojiByCategory: [Emoji.Category: [EmojiWithSkinTones]] = Emoji.allSendableEmojiByCategoryWithPreferredSkinTones(db)
|
||||
|
||||
return (recentEmoji, allSendableEmojiByCategory)
|
||||
}
|
||||
|
||||
if let emojiData: (recent: [EmojiWithSkinTones], allGrouped: [Emoji.Category: [EmojiWithSkinTones]]) = maybeEmojiData {
|
||||
self.recentEmoji = emojiData.recent
|
||||
self.allSendableEmojiByCategory = emojiData.allGrouped
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// This is not an exact calculation, but is simple and works for our purposes.
|
||||
var numberOfColumns: Int {
|
||||
Int((self.width()) / (EmojiPickerCollectionView.emojiWidth + EmojiPickerCollectionView.minimumSpacing))
|
||||
}
|
||||
|
||||
// At max, we show 3 rows of recent emoji
|
||||
private var maxRecentEmoji: Int { numberOfColumns * 3 }
|
||||
private var categoryIndexOffset: Int { hasRecentEmoji ? 1 : 0}
|
||||
|
||||
func emojiForSection(_ section: Int) -> [EmojiWithSkinTones] {
|
||||
guard section > 0 || !hasRecentEmoji else { return Array(recentEmoji[0..<min(maxRecentEmoji, recentEmoji.count)]) }
|
||||
|
||||
guard let category = Emoji.Category.allCases[safe: section - categoryIndexOffset] else {
|
||||
owsFailDebug("Unexpectedly missing category for section \(section)")
|
||||
return []
|
||||
}
|
||||
|
||||
guard let categoryEmoji = allSendableEmojiByCategory[category] else {
|
||||
owsFailDebug("Unexpectedly missing emoji for category \(category)")
|
||||
return []
|
||||
}
|
||||
|
||||
return categoryEmoji
|
||||
}
|
||||
|
||||
func emojiForIndexPath(_ indexPath: IndexPath) -> EmojiWithSkinTones? {
|
||||
return isSearching ? emojiSearchResults[safe: indexPath.row] : emojiForSection(indexPath.section)[safe: indexPath.row]
|
||||
}
|
||||
|
||||
func nameForSection(_ section: Int) -> String? {
|
||||
guard section > 0 || !hasRecentEmoji else {
|
||||
return NSLocalizedString("EMOJI_CATEGORY_RECENTS_NAME",
|
||||
comment: "The name for the emoji category 'Recents'")
|
||||
}
|
||||
|
||||
guard let category = Emoji.Category.allCases[safe: section - categoryIndexOffset] else {
|
||||
owsFailDebug("Unexpectedly missing category for section \(section)")
|
||||
return nil
|
||||
}
|
||||
|
||||
return category.localizedName
|
||||
}
|
||||
|
||||
// MARK: - Search
|
||||
|
||||
func searchWithText(_ searchText: String?) {
|
||||
if let searchText = searchText {
|
||||
emojiSearchResults = allSendableEmoji.filter { emoji in
|
||||
return emoji.baseEmoji?.name.range(of: searchText, options: [.caseInsensitive]) != nil
|
||||
}
|
||||
} else {
|
||||
emojiSearchResults = []
|
||||
}
|
||||
|
||||
reloadData()
|
||||
}
|
||||
|
||||
var scrollingToSection: Int?
|
||||
func scrollToSectionHeader(_ section: Int, animated: Bool) {
|
||||
guard let attributes = layoutAttributesForSupplementaryElement(
|
||||
ofKind: UICollectionView.elementKindSectionHeader,
|
||||
at: IndexPath(item: 0, section: section)
|
||||
) else { return }
|
||||
scrollingToSection = section
|
||||
setContentOffset(CGPoint(x: 0, y: (attributes.frame.minY - contentInset.top)), animated: animated)
|
||||
}
|
||||
|
||||
private weak var currentSkinTonePicker: EmojiSkinTonePicker?
|
||||
|
||||
@objc
|
||||
func handleLongPress(sender: UILongPressGestureRecognizer) {
|
||||
|
||||
switch sender.state {
|
||||
case .began:
|
||||
let point = sender.location(in: self)
|
||||
guard let indexPath = indexPathForItem(at: point) else { return }
|
||||
guard let emoji = emojiForIndexPath(indexPath) else { return }
|
||||
guard let cell = cellForItem(at: indexPath) else { return }
|
||||
|
||||
currentSkinTonePicker?.dismiss()
|
||||
currentSkinTonePicker = EmojiSkinTonePicker.present(referenceView: cell, emoji: emoji) { [weak self] emoji in
|
||||
if let emoji: EmojiWithSkinTones = emoji {
|
||||
Storage.shared.writeAsync { db in
|
||||
emoji.baseEmoji?.setPreferredSkinTones(
|
||||
db,
|
||||
preferredSkinTonePermutation: emoji.skinTones
|
||||
)
|
||||
}
|
||||
|
||||
self?.pickerDelegate?.emojiPicker(self, didSelectEmoji: emoji)
|
||||
}
|
||||
|
||||
self?.currentSkinTonePicker?.dismiss()
|
||||
self?.currentSkinTonePicker = nil
|
||||
}
|
||||
case .changed:
|
||||
currentSkinTonePicker?.didChangeLongPress(sender)
|
||||
case .ended:
|
||||
currentSkinTonePicker?.didEndLongPress(sender)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func dismissSkinTonePicker() {
|
||||
currentSkinTonePicker?.dismiss()
|
||||
currentSkinTonePicker = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension EmojiPickerCollectionView: UIGestureRecognizerDelegate {
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer == tapGestureRecognizer {
|
||||
return currentSkinTonePicker != nil
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension EmojiPickerCollectionView: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let emoji = emojiForIndexPath(indexPath) else {
|
||||
return owsFailDebug("Missing emoji for indexPath \(indexPath)")
|
||||
}
|
||||
|
||||
pickerDelegate?.emojiPicker(self, didSelectEmoji: emoji)
|
||||
}
|
||||
}
|
||||
|
||||
extension EmojiPickerCollectionView: UICollectionViewDataSource {
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
return isSearching ? emojiSearchResults.count : emojiForSection(section).count
|
||||
}
|
||||
|
||||
func numberOfSections(in collectionView: UICollectionView) -> Int {
|
||||
return isSearching ? 1 : Emoji.Category.allCases.count + categoryIndexOffset
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let cell = dequeueReusableCell(withReuseIdentifier: EmojiCell.reuseIdentifier, for: indexPath)
|
||||
|
||||
guard let emojiCell = cell as? EmojiCell else {
|
||||
owsFailDebug("unexpected cell type")
|
||||
return cell
|
||||
}
|
||||
|
||||
guard let emoji = emojiForIndexPath(indexPath) else {
|
||||
owsFailDebug("unexpected indexPath")
|
||||
return cell
|
||||
}
|
||||
|
||||
emojiCell.configure(emoji: emoji)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
|
||||
|
||||
let supplementaryView = dequeueReusableSupplementaryView(
|
||||
ofKind: kind,
|
||||
withReuseIdentifier: EmojiSectionHeader.reuseIdentifier,
|
||||
for: indexPath
|
||||
)
|
||||
|
||||
guard let sectionHeader = supplementaryView as? EmojiSectionHeader else {
|
||||
owsFailDebug("unexpected supplementary view type")
|
||||
return supplementaryView
|
||||
}
|
||||
|
||||
sectionHeader.label.text = nameForSection(indexPath.section)
|
||||
|
||||
return sectionHeader
|
||||
}
|
||||
}
|
||||
|
||||
extension EmojiPickerCollectionView: UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView,
|
||||
layout collectionViewLayout: UICollectionViewLayout,
|
||||
referenceSizeForHeaderInSection section: Int) -> CGSize {
|
||||
guard !isSearching else {
|
||||
return CGSize.zero
|
||||
}
|
||||
|
||||
let measureCell = EmojiSectionHeader()
|
||||
measureCell.label.text = nameForSection(section)
|
||||
return measureCell.sizeThatFits(CGSize(width: self.width(), height: .greatestFiniteMagnitude))
|
||||
}
|
||||
}
|
||||
|
||||
private class EmojiCell: UICollectionViewCell {
|
||||
static let reuseIdentifier = "EmojiCell"
|
||||
|
||||
let emojiLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
backgroundColor = .clear
|
||||
|
||||
emojiLabel.font = .boldSystemFont(ofSize: 32)
|
||||
contentView.addSubview(emojiLabel)
|
||||
emojiLabel.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
// For whatever reason, some emoji glyphs occasionally have different typographic widths on certain devices
|
||||
// e.g. 👩🦰: 36x38.19, 👱♀️: 40x38. (See: commit message for more info)
|
||||
// To workaround this, we can clip the label instead of truncating. It appears to only clip the additional
|
||||
// typographic space. In either case, it's better than truncating and seeing an ellipsis.
|
||||
emojiLabel.lineBreakMode = .byClipping
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(emoji: EmojiWithSkinTones) {
|
||||
emojiLabel.text = emoji.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
private class EmojiSectionHeader: UICollectionReusableView {
|
||||
static let reuseIdentifier = "EmojiSectionHeader"
|
||||
|
||||
let label = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
layoutMargins = UIEdgeInsets(
|
||||
top: 16,
|
||||
leading: EmojiPickerCollectionView.margins,
|
||||
bottom: 6,
|
||||
trailing: EmojiPickerCollectionView.margins
|
||||
)
|
||||
|
||||
label.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
label.textColor = Colors.text
|
||||
addSubview(label)
|
||||
label.autoPinEdgesToSuperviewMargins()
|
||||
label.setCompressionResistanceHigh()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
||||
var labelSize = label.sizeThatFits(size)
|
||||
labelSize.width += layoutMargins.left + layoutMargins.right
|
||||
labelSize.height += layoutMargins.top + layoutMargins.bottom
|
||||
return labelSize
|
||||
}
|
||||
}
|
138
Session/Conversations/Emoji Picker/EmojiPickerSheet.swift
Normal file
138
Session/Conversations/Emoji Picker/EmojiPickerSheet.swift
Normal file
|
@ -0,0 +1,138 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
class EmojiPickerSheet: BaseVC {
|
||||
let completionHandler: (EmojiWithSkinTones?) -> Void
|
||||
let dismissHandler: () -> Void
|
||||
|
||||
// MARK: Components
|
||||
|
||||
private lazy var contentView: UIView = {
|
||||
let result = UIView()
|
||||
let line = UIView()
|
||||
line.set(.height, to: 0.5)
|
||||
line.backgroundColor = Colors.border.withAlphaComponent(0.5)
|
||||
result.addSubview(line)
|
||||
line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top ], to: result)
|
||||
result.backgroundColor = Colors.modalBackground
|
||||
return result
|
||||
}()
|
||||
|
||||
private let collectionView = EmojiPickerCollectionView()
|
||||
|
||||
private lazy var searchBar: SearchBar = {
|
||||
let result = SearchBar()
|
||||
result.tintColor = Colors.text
|
||||
result.backgroundColor = .clear
|
||||
result.delegate = self
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Lifecycle
|
||||
|
||||
init(completionHandler: @escaping (EmojiWithSkinTones?) -> Void, dismissHandler: @escaping () -> Void) {
|
||||
self.completionHandler = completionHandler
|
||||
self.dismissHandler = dismissHandler
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
public required init() {
|
||||
fatalError("init() has not been implemented")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override public func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
view.addSubview(contentView)
|
||||
contentView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.bottom ], to: view)
|
||||
contentView.set(.height, to: 440)
|
||||
populateContentView()
|
||||
}
|
||||
|
||||
private func populateContentView() {
|
||||
let topStackView = UIStackView()
|
||||
topStackView.axis = .horizontal
|
||||
topStackView.isLayoutMarginsRelativeArrangement = true
|
||||
topStackView.spacing = 8
|
||||
|
||||
topStackView.addArrangedSubview(searchBar)
|
||||
|
||||
contentView.addSubview(topStackView)
|
||||
|
||||
topStackView.autoPinWidthToSuperview()
|
||||
topStackView.autoPinEdge(toSuperviewEdge: .top)
|
||||
|
||||
contentView.addSubview(collectionView)
|
||||
collectionView.autoPinEdge(.top, to: .bottom, of: searchBar)
|
||||
collectionView.autoPinEdge(.bottom, to: .bottom, of: contentView)
|
||||
collectionView.autoPinWidthToSuperview()
|
||||
collectionView.pickerDelegate = self
|
||||
collectionView.alwaysBounceVertical = true
|
||||
}
|
||||
|
||||
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
super.viewWillTransition(to: size, with: coordinator)
|
||||
coordinator.animate(alongsideTransition: { _ in
|
||||
self.collectionView.reloadData()
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
public override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
// Ensure the scrollView's layout has completed
|
||||
// as we're about to use its bounds to calculate
|
||||
// the masking view and contentOffset.
|
||||
contentView.layoutIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
let touch = touches.first!
|
||||
let location = touch.location(in: view)
|
||||
if contentView.frame.contains(location) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func close() {
|
||||
dismiss(animated: true, completion: dismissHandler)
|
||||
}
|
||||
}
|
||||
|
||||
extension EmojiPickerSheet: EmojiPickerCollectionViewDelegate {
|
||||
func emojiPickerWillBeginDragging(_ emojiPicker: EmojiPickerCollectionView) {
|
||||
searchBar.resignFirstResponder()
|
||||
}
|
||||
|
||||
func emojiPicker(_ emojiPicker: EmojiPickerCollectionView?, didSelectEmoji emoji: EmojiWithSkinTones) {
|
||||
completionHandler(emoji)
|
||||
dismiss(animated: true, completion: dismissHandler)
|
||||
}
|
||||
}
|
||||
|
||||
extension EmojiPickerSheet: UISearchBarDelegate {
|
||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
collectionView.searchText = searchText
|
||||
}
|
||||
|
||||
func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
|
||||
searchBar.showsCancelButton = true
|
||||
return true
|
||||
}
|
||||
|
||||
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
||||
searchBar.showsCancelButton = false
|
||||
searchBar.resignFirstResponder()
|
||||
}
|
||||
}
|
||||
|
305
Session/Conversations/Emoji Picker/EmojiSkinTonePicker.swift
Normal file
305
Session/Conversations/Emoji Picker/EmojiSkinTonePicker.swift
Normal file
|
@ -0,0 +1,305 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
class EmojiSkinTonePicker: UIView {
|
||||
let emoji: Emoji
|
||||
let preferredSkinTonePermutation: [Emoji.SkinTone]?
|
||||
let completion: (EmojiWithSkinTones?) -> Void
|
||||
|
||||
private let referenceOverlay = UIView()
|
||||
private let containerView = UIView()
|
||||
|
||||
class func present(
|
||||
referenceView: UIView,
|
||||
emoji: EmojiWithSkinTones,
|
||||
completion: @escaping (EmojiWithSkinTones?) -> Void
|
||||
) -> EmojiSkinTonePicker? {
|
||||
guard let baseEmoji = emoji.baseEmoji, baseEmoji.hasSkinTones else { return nil }
|
||||
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
|
||||
let picker = EmojiSkinTonePicker(emoji: emoji, completion: completion)
|
||||
|
||||
guard let superview = referenceView.superview else {
|
||||
owsFailDebug("reference is missing superview")
|
||||
return nil
|
||||
}
|
||||
|
||||
superview.addSubview(picker)
|
||||
|
||||
picker.referenceOverlay.autoMatch(.width, to: .width, of: referenceView)
|
||||
picker.referenceOverlay.autoMatch(.height, to: .height, of: referenceView, withOffset: 30)
|
||||
picker.referenceOverlay.autoPinEdge(.leading, to: .leading, of: referenceView)
|
||||
|
||||
let leadingConstraint = picker.autoPinEdge(toSuperviewEdge: .leading)
|
||||
|
||||
picker.layoutIfNeeded()
|
||||
|
||||
let halfWidth = picker.width() / 2
|
||||
let margin: CGFloat = 8
|
||||
|
||||
if (halfWidth + margin) > referenceView.center.x {
|
||||
leadingConstraint.constant = margin
|
||||
} else if (halfWidth + margin) > (superview.width() - referenceView.center.x) {
|
||||
leadingConstraint.constant = superview.width() - picker.width() - margin
|
||||
} else {
|
||||
leadingConstraint.constant = referenceView.center.x - halfWidth
|
||||
}
|
||||
|
||||
let distanceFromTop = referenceView.frame.minY - superview.bounds.minY
|
||||
if distanceFromTop > picker.containerView.height() {
|
||||
picker.containerView.autoPinEdge(toSuperviewEdge: .top)
|
||||
picker.referenceOverlay.autoPinEdge(.top, to: .bottom, of: picker.containerView, withOffset: -20)
|
||||
picker.referenceOverlay.autoPinEdge(toSuperviewEdge: .bottom)
|
||||
picker.autoPinEdge(.bottom, to: .bottom, of: referenceView)
|
||||
} else {
|
||||
picker.containerView.autoPinEdge(toSuperviewEdge: .bottom)
|
||||
picker.referenceOverlay.autoPinEdge(.bottom, to: .top, of: picker.containerView, withOffset: 20)
|
||||
picker.referenceOverlay.autoPinEdge(toSuperviewEdge: .top)
|
||||
picker.autoPinEdge(.top, to: .top, of: referenceView)
|
||||
}
|
||||
|
||||
picker.alpha = 0
|
||||
UIView.animate(withDuration: 0.12) { picker.alpha = 1 }
|
||||
|
||||
return picker
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
UIView.animate(withDuration: 0.12, animations: { self.alpha = 0 }) { _ in
|
||||
self.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
func didChangeLongPress(_ sender: UILongPressGestureRecognizer) {
|
||||
guard let singleSelectionButtons = singleSelectionButtons else { return }
|
||||
|
||||
if referenceOverlay.frame.contains(sender.location(in: self)) {
|
||||
singleSelectionButtons.forEach { $0.isSelected = false }
|
||||
} else {
|
||||
let point = sender.location(in: containerView)
|
||||
let previouslySelectedButton = singleSelectionButtons.first { $0.isSelected }
|
||||
singleSelectionButtons.forEach { $0.isSelected = $0.frame.insetBy(dx: -3, dy: -80).contains(point) }
|
||||
let selectedButton = singleSelectionButtons.first { $0.isSelected }
|
||||
|
||||
if let selectedButton = selectedButton, selectedButton != previouslySelectedButton {
|
||||
SelectionHapticFeedback().selectionChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func didEndLongPress(_ sender: UILongPressGestureRecognizer) {
|
||||
guard let singleSelectionButtons = singleSelectionButtons else { return }
|
||||
|
||||
let point = sender.location(in: containerView)
|
||||
if referenceOverlay.frame.contains(sender.location(in: self)) {
|
||||
// Do nothing.
|
||||
} else if let selectedButton = singleSelectionButtons.first(where: {
|
||||
$0.frame.insetBy(dx: -3, dy: -80).contains(point)
|
||||
}) {
|
||||
selectedButton.sendActions(for: .touchUpInside)
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
init(emoji: EmojiWithSkinTones, completion: @escaping (EmojiWithSkinTones?) -> Void) {
|
||||
owsAssertDebug(emoji.baseEmoji!.hasSkinTones)
|
||||
|
||||
self.emoji = emoji.baseEmoji!
|
||||
self.preferredSkinTonePermutation = emoji.skinTones
|
||||
self.completion = completion
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
layer.shadowOffset = .zero
|
||||
layer.shadowOpacity = 0.25
|
||||
layer.shadowRadius = 4
|
||||
|
||||
referenceOverlay.backgroundColor = Colors.modalBackground
|
||||
referenceOverlay.layer.cornerRadius = 9
|
||||
addSubview(referenceOverlay)
|
||||
|
||||
containerView.layoutMargins = UIEdgeInsets(top: 9, leading: 16, bottom: 9, trailing: 16)
|
||||
containerView.backgroundColor = Colors.modalBackground
|
||||
containerView.layer.cornerRadius = 11
|
||||
addSubview(containerView)
|
||||
containerView.autoPinWidthToSuperview()
|
||||
containerView.setCompressionResistanceHigh()
|
||||
|
||||
if emoji.baseEmoji!.allowsMultipleSkinTones {
|
||||
prepareForMultipleSkinTones()
|
||||
} else {
|
||||
prepareForSingleSkinTone()
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Single Skin Tone
|
||||
|
||||
private lazy var yellowEmoji = EmojiWithSkinTones(baseEmoji: emoji, skinTones: nil)
|
||||
private lazy var yellowButton = button(for: yellowEmoji) { [weak self] emojiWithSkinTone in
|
||||
self?.completion(emojiWithSkinTone)
|
||||
}
|
||||
|
||||
private var singleSelectionButtons: [UIButton]?
|
||||
private func prepareForSingleSkinTone() {
|
||||
let hStack = UIStackView()
|
||||
hStack.axis = .horizontal
|
||||
hStack.spacing = 8
|
||||
containerView.addSubview(hStack)
|
||||
hStack.autoPinEdgesToSuperviewMargins()
|
||||
|
||||
hStack.addArrangedSubview(yellowButton)
|
||||
|
||||
hStack.addArrangedSubview(.spacer(withWidth: 2))
|
||||
|
||||
let divider = UIView()
|
||||
divider.autoSetDimension(.width, toSize: 1)
|
||||
divider.backgroundColor = isDarkMode ? .ows_gray75 : .ows_gray05
|
||||
hStack.addArrangedSubview(divider)
|
||||
|
||||
hStack.addArrangedSubview(.spacer(withWidth: 2))
|
||||
|
||||
let skinToneButtons = self.skinToneButtons(for: emoji) { [weak self] emojiWithSkinTone in
|
||||
self?.completion(emojiWithSkinTone)
|
||||
}
|
||||
|
||||
singleSelectionButtons = skinToneButtons.map { $0.button }
|
||||
singleSelectionButtons?.forEach { hStack.addArrangedSubview($0) }
|
||||
singleSelectionButtons?.append(yellowButton)
|
||||
}
|
||||
|
||||
// MARK: - Multiple Skin Tones
|
||||
|
||||
private lazy var skinToneComponentEmoji: [Emoji] = {
|
||||
guard let skinToneComponentEmoji = emoji.skinToneComponentEmoji else {
|
||||
owsFailDebug("missing skin tone component emoji \(emoji)")
|
||||
return []
|
||||
}
|
||||
return skinToneComponentEmoji
|
||||
}()
|
||||
|
||||
private var buttonsPerComponentEmojiIndex = [Int: [(Emoji.SkinTone, UIButton)]]()
|
||||
private lazy var skinToneButton = button(for: EmojiWithSkinTones(
|
||||
baseEmoji: emoji,
|
||||
skinTones: .init(repeating: .medium, count: skinToneComponentEmoji.count)
|
||||
)) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
guard self.selectedSkinTones.count == self.skinToneComponentEmoji.count else { return }
|
||||
self.completion(EmojiWithSkinTones(baseEmoji: self.emoji, skinTones: self.selectedSkinTones))
|
||||
}
|
||||
|
||||
private var selectedSkinTones = [Emoji.SkinTone]() {
|
||||
didSet {
|
||||
if selectedSkinTones.count == skinToneComponentEmoji.count {
|
||||
skinToneButton.setTitle(
|
||||
EmojiWithSkinTones(
|
||||
baseEmoji: emoji,
|
||||
skinTones: selectedSkinTones
|
||||
).rawValue,
|
||||
for: .normal
|
||||
)
|
||||
skinToneButton.isEnabled = true
|
||||
skinToneButton.alpha = 1
|
||||
} else {
|
||||
skinToneButton.setTitle(
|
||||
EmojiWithSkinTones(
|
||||
baseEmoji: emoji,
|
||||
skinTones: [.medium]
|
||||
).rawValue,
|
||||
for: .normal
|
||||
)
|
||||
skinToneButton.isEnabled = false
|
||||
skinToneButton.alpha = 0.2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var skinTonePerComponentEmojiIndex = [Int: Emoji.SkinTone]() {
|
||||
didSet {
|
||||
var selectedSkinTones = [Emoji.SkinTone]()
|
||||
for idx in skinToneComponentEmoji.indices {
|
||||
for (skinTone, button) in buttonsPerComponentEmojiIndex[idx] ?? [] {
|
||||
if skinTonePerComponentEmojiIndex[idx] == skinTone {
|
||||
selectedSkinTones.append(skinTone)
|
||||
button.isSelected = true
|
||||
} else {
|
||||
button.isSelected = false
|
||||
}
|
||||
}
|
||||
}
|
||||
self.selectedSkinTones = selectedSkinTones
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareForMultipleSkinTones() {
|
||||
let vStack = UIStackView()
|
||||
vStack.axis = .vertical
|
||||
vStack.spacing = 6
|
||||
containerView.addSubview(vStack)
|
||||
vStack.autoPinEdgesToSuperviewMargins()
|
||||
|
||||
for (idx, emoji) in skinToneComponentEmoji.enumerated() {
|
||||
let skinToneButtons = self.skinToneButtons(for: emoji) { [weak self] emojiWithSkinTone in
|
||||
self?.skinTonePerComponentEmojiIndex[idx] = emojiWithSkinTone.skinTones?.first
|
||||
}
|
||||
buttonsPerComponentEmojiIndex[idx] = skinToneButtons
|
||||
|
||||
let hStack = UIStackView(arrangedSubviews: skinToneButtons.map { $0.button })
|
||||
hStack.axis = .horizontal
|
||||
hStack.spacing = 6
|
||||
vStack.addArrangedSubview(hStack)
|
||||
|
||||
skinTonePerComponentEmojiIndex[idx] = preferredSkinTonePermutation?[safe: idx]
|
||||
|
||||
// If there's only one preferred skin tone, all the component emoji use it.
|
||||
if preferredSkinTonePermutation?.count == 1 {
|
||||
skinTonePerComponentEmojiIndex[idx] = preferredSkinTonePermutation?.first
|
||||
} else {
|
||||
skinTonePerComponentEmojiIndex[idx] = preferredSkinTonePermutation?[safe: idx]
|
||||
}
|
||||
}
|
||||
|
||||
let divider = UIView()
|
||||
divider.autoSetDimension(.height, toSize: 1)
|
||||
divider.backgroundColor = isDarkMode ? .ows_gray75 : .ows_gray05
|
||||
vStack.addArrangedSubview(divider)
|
||||
|
||||
let leftSpacer = UIView.hStretchingSpacer()
|
||||
let middleSpacer = UIView.hStretchingSpacer()
|
||||
let rightSpacer = UIView.hStretchingSpacer()
|
||||
|
||||
let hStack = UIStackView(arrangedSubviews: [leftSpacer, yellowButton, middleSpacer, skinToneButton, rightSpacer])
|
||||
hStack.axis = .horizontal
|
||||
vStack.addArrangedSubview(hStack)
|
||||
|
||||
leftSpacer.autoMatch(.width, to: .width, of: rightSpacer)
|
||||
middleSpacer.autoMatch(.width, to: .width, of: rightSpacer)
|
||||
}
|
||||
|
||||
// MARK: - Button Helpers
|
||||
|
||||
func skinToneButtons(for emoji: Emoji, handler: @escaping (EmojiWithSkinTones) -> Void) -> [(skinTone: Emoji.SkinTone, button: UIButton)] {
|
||||
var buttons = [(Emoji.SkinTone, UIButton)]()
|
||||
for skinTone in Emoji.SkinTone.allCases {
|
||||
let emojiWithSkinTone = EmojiWithSkinTones(baseEmoji: emoji, skinTones: [skinTone])
|
||||
buttons.append((skinTone, button(for: emojiWithSkinTone, handler: handler)))
|
||||
}
|
||||
return buttons
|
||||
}
|
||||
|
||||
func button(for emoji: EmojiWithSkinTones, handler: @escaping (EmojiWithSkinTones) -> Void) -> UIButton {
|
||||
let button = OWSButton { handler(emoji) }
|
||||
button.titleLabel?.font = .boldSystemFont(ofSize: 32)
|
||||
button.setTitle(emoji.rawValue, for: .normal)
|
||||
button.setBackgroundImage(UIImage(color: isDarkMode ? .ows_gray60 : .ows_gray25), for: .selected)
|
||||
button.layer.cornerRadius = 6
|
||||
button.clipsToBounds = true
|
||||
button.autoSetDimensions(to: CGSize(width: 38, height: 38))
|
||||
return button
|
||||
}
|
||||
}
|
|
@ -397,7 +397,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
inputTextView.becomeFirstResponder()
|
||||
}
|
||||
|
||||
func handleLongPress() {
|
||||
func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
// Not relevant in this case
|
||||
}
|
||||
|
||||
|
@ -455,6 +455,10 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView) {
|
||||
delegate?.handleMentionSelected(mentionInfo, from: view)
|
||||
}
|
||||
|
||||
func tapableLabel(_ label: TappableLabel, didTapUrl url: String, atRange range: NSRange) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
|
|
|
@ -102,6 +102,7 @@ final class CallMessageCell: MessageCell {
|
|||
with cellViewModel: MessageViewModel,
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||
showExpandedReactions: Bool,
|
||||
lastSearchText: String?
|
||||
) {
|
||||
guard
|
||||
|
|
|
@ -48,7 +48,7 @@ final class LinkPreviewView: UIView {
|
|||
return result
|
||||
}()
|
||||
|
||||
private lazy var bodyTextViewContainer: UIView = UIView()
|
||||
private lazy var bodyTappableLabelContainer: UIView = UIView()
|
||||
|
||||
private lazy var hStackViewContainer: UIView = UIView()
|
||||
|
||||
|
@ -67,8 +67,8 @@ final class LinkPreviewView: UIView {
|
|||
|
||||
return result
|
||||
}()
|
||||
|
||||
var bodyTextView: UITextView?
|
||||
|
||||
var bodyTappableLabel: TappableLabel?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
|
@ -110,7 +110,7 @@ final class LinkPreviewView: UIView {
|
|||
hStackView.pin(to: hStackViewContainer)
|
||||
|
||||
// Vertical stack view
|
||||
let vStackView = UIStackView(arrangedSubviews: [ hStackViewContainer, bodyTextViewContainer ])
|
||||
let vStackView = UIStackView(arrangedSubviews: [ hStackViewContainer, bodyTappableLabelContainer ])
|
||||
vStackView.axis = .vertical
|
||||
addSubview(vStackView)
|
||||
vStackView.pin(to: self)
|
||||
|
@ -129,7 +129,7 @@ final class LinkPreviewView: UIView {
|
|||
public func update(
|
||||
with state: LinkPreviewState,
|
||||
isOutgoing: Bool,
|
||||
delegate: (UITextViewDelegate & BodyTextViewDelegate)? = nil,
|
||||
delegate: TappableLabelDelegate? = nil,
|
||||
cellViewModel: MessageViewModel? = nil,
|
||||
bodyLabelTextColor: UIColor? = nil,
|
||||
lastSearchText: String? = nil
|
||||
|
@ -184,10 +184,10 @@ final class LinkPreviewView: UIView {
|
|||
}
|
||||
|
||||
// Body text view
|
||||
bodyTextViewContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
bodyTappableLabelContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
|
||||
if let cellViewModel: MessageViewModel = cellViewModel {
|
||||
let bodyTextView = VisibleMessageCell.getBodyTextView(
|
||||
let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel(
|
||||
for: cellViewModel,
|
||||
with: maxWidth,
|
||||
textColor: (bodyLabelTextColor ?? sentLinkPreviewTextColor),
|
||||
|
@ -195,9 +195,9 @@ final class LinkPreviewView: UIView {
|
|||
delegate: delegate
|
||||
)
|
||||
|
||||
self.bodyTextView = bodyTextView
|
||||
bodyTextViewContainer.addSubview(bodyTextView)
|
||||
bodyTextView.pin(to: bodyTextViewContainer, withInset: 12)
|
||||
self.bodyTappableLabel = bodyTappableLabel
|
||||
bodyTappableLabelContainer.addSubview(bodyTappableLabel)
|
||||
bodyTappableLabel.pin(to: bodyTappableLabelContainer, withInset: 12)
|
||||
}
|
||||
|
||||
if state is LinkPreview.DraftState {
|
||||
|
|
|
@ -0,0 +1,171 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
final class ReactionContainerView: UIView {
|
||||
var showingAllReactions = false
|
||||
private var showNumbers = true
|
||||
private var maxEmojisPerLine = isIPhone6OrSmaller ? 5 : 6
|
||||
|
||||
var reactions: [ReactionViewModel] = []
|
||||
var reactionViews: [ReactionButton] = []
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var mainStackView: UIStackView = {
|
||||
let result = UIStackView(arrangedSubviews: [ reactionContainerView ])
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.smallSpacing
|
||||
result.alignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var reactionContainerView: UIStackView = {
|
||||
let result = UIStackView()
|
||||
result.axis = .vertical
|
||||
result.spacing = Values.smallSpacing
|
||||
result.alignment = .leading
|
||||
return result
|
||||
}()
|
||||
|
||||
var expandButton: ExpandingReactionButton?
|
||||
|
||||
var collapseButton: UIStackView = {
|
||||
let arrow = UIImageView(image: UIImage(named: "ic_chevron_up")?.resizedImage(to: CGSize(width: 15, height: 13))?.withRenderingMode(.alwaysTemplate))
|
||||
arrow.tintColor = Colors.text
|
||||
|
||||
let textLabel = UILabel()
|
||||
textLabel.text = "Show less"
|
||||
textLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
textLabel.textColor = Colors.text
|
||||
|
||||
let result = UIStackView(arrangedSubviews: [ UIView.hStretchingSpacer(), arrow, textLabel, UIView.hStretchingSpacer() ])
|
||||
result.spacing = Values.verySmallSpacing
|
||||
result.alignment = .center
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init() {
|
||||
super.init(frame: CGRect.zero)
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
addSubview(mainStackView)
|
||||
|
||||
mainStackView.pin(to: self)
|
||||
}
|
||||
|
||||
public func update(_ reactions: [ReactionViewModel], showNumbers: Bool) {
|
||||
self.reactions = reactions
|
||||
self.showNumbers = showNumbers
|
||||
|
||||
prepareForUpdate()
|
||||
|
||||
if showingAllReactions {
|
||||
updateAllReactions()
|
||||
}
|
||||
else {
|
||||
updateCollapsedReactions(reactions)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCollapsedReactions(_ reactions: [ReactionViewModel]) {
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.spacing = Values.smallSpacing
|
||||
stackView.alignment = .center
|
||||
|
||||
var displayedReactions: [ReactionViewModel]
|
||||
var expandButtonReactions: [EmojiWithSkinTones]
|
||||
|
||||
if reactions.count > maxEmojisPerLine {
|
||||
displayedReactions = Array(reactions[0...(maxEmojisPerLine - 3)])
|
||||
expandButtonReactions = Array(reactions[(maxEmojisPerLine - 2)...maxEmojisPerLine])
|
||||
.map { $0.emoji }
|
||||
}
|
||||
else {
|
||||
displayedReactions = reactions
|
||||
expandButtonReactions = []
|
||||
}
|
||||
|
||||
for reaction in displayedReactions {
|
||||
let reactionView = ReactionButton(viewModel: reaction, showNumber: showNumbers)
|
||||
stackView.addArrangedSubview(reactionView)
|
||||
reactionViews.append(reactionView)
|
||||
}
|
||||
|
||||
if expandButtonReactions.count > 0 {
|
||||
let expandButton: ExpandingReactionButton = ExpandingReactionButton(emojis: expandButtonReactions)
|
||||
stackView.addArrangedSubview(expandButton)
|
||||
|
||||
self.expandButton = expandButton
|
||||
}
|
||||
else {
|
||||
expandButton = nil
|
||||
}
|
||||
|
||||
reactionContainerView.addArrangedSubview(stackView)
|
||||
}
|
||||
|
||||
private func updateAllReactions() {
|
||||
var reactions = self.reactions
|
||||
var numberOfLines = 0
|
||||
|
||||
while reactions.count > 0 {
|
||||
var line: [ReactionViewModel] = []
|
||||
|
||||
while reactions.count > 0 && line.count < maxEmojisPerLine {
|
||||
line.append(reactions.removeFirst())
|
||||
}
|
||||
|
||||
updateCollapsedReactions(line)
|
||||
numberOfLines += 1
|
||||
}
|
||||
|
||||
if numberOfLines > 1 {
|
||||
mainStackView.addArrangedSubview(collapseButton)
|
||||
}
|
||||
else {
|
||||
showingAllReactions = false
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareForUpdate() {
|
||||
for subview in reactionContainerView.arrangedSubviews {
|
||||
reactionContainerView.removeArrangedSubview(subview)
|
||||
subview.removeFromSuperview()
|
||||
}
|
||||
|
||||
mainStackView.removeArrangedSubview(collapseButton)
|
||||
collapseButton.removeFromSuperview()
|
||||
reactionViews = []
|
||||
}
|
||||
|
||||
public func showAllEmojis() {
|
||||
guard !showingAllReactions else { return }
|
||||
|
||||
showingAllReactions = true
|
||||
update(reactions, showNumbers: showNumbers)
|
||||
}
|
||||
|
||||
public func showLessEmojis() {
|
||||
guard showingAllReactions else { return }
|
||||
|
||||
showingAllReactions = false
|
||||
update(reactions, showNumbers: showNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
public struct ReactionViewModel: Hashable {
|
||||
let emoji: EmojiWithSkinTones
|
||||
let number: Int
|
||||
let showBorder: Bool
|
||||
}
|
||||
|
||||
final class ReactionButton: UIView {
|
||||
let viewModel: ReactionViewModel
|
||||
let showNumber: Bool
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
private var height: CGFloat = 22
|
||||
private var fontSize: CGFloat = Values.verySmallFontSize
|
||||
private var spacing: CGFloat = Values.verySmallSpacing
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(viewModel: ReactionViewModel, showNumber: Bool = true) {
|
||||
self.viewModel = viewModel
|
||||
self.showNumber = showNumber
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
let emojiLabel = UILabel()
|
||||
emojiLabel.text = viewModel.emoji.rawValue
|
||||
emojiLabel.font = .systemFont(ofSize: fontSize)
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: [ emojiLabel ])
|
||||
stackView.axis = .horizontal
|
||||
stackView.spacing = spacing
|
||||
stackView.alignment = .center
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, left: Values.smallSpacing, bottom: 0, right: Values.smallSpacing)
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
addSubview(stackView)
|
||||
stackView.pin(to: self)
|
||||
|
||||
set(.height, to: self.height)
|
||||
backgroundColor = Colors.receivedMessageBackground
|
||||
layer.cornerRadius = self.height / 2
|
||||
|
||||
if viewModel.showBorder {
|
||||
self.addBorder(with: Colors.accent)
|
||||
}
|
||||
|
||||
if showNumber || viewModel.number > 1 {
|
||||
let numberLabel = UILabel()
|
||||
numberLabel.text = viewModel.number < 1000 ? "\(viewModel.number)" : String(format: "%.1f", Float(viewModel.number) / 1000) + "k"
|
||||
numberLabel.font = .systemFont(ofSize: fontSize)
|
||||
numberLabel.textColor = Colors.text
|
||||
stackView.addArrangedSubview(numberLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ExpandingReactionButton: UIView {
|
||||
private let emojis: [EmojiWithSkinTones]
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
private let size: CGFloat = 22
|
||||
private let margin: CGFloat = 15
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(emojis: [EmojiWithSkinTones]) {
|
||||
self.emojis = emojis
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(viewItem:textColor:) instead.")
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
var rightMargin: CGFloat = 0
|
||||
|
||||
for emoji in self.emojis.reversed() {
|
||||
let container = UIView()
|
||||
container.set(.width, to: size)
|
||||
container.set(.height, to: size)
|
||||
container.backgroundColor = Colors.receivedMessageBackground
|
||||
container.layer.cornerRadius = size / 2
|
||||
container.layer.borderWidth = 1
|
||||
// FIXME: This is going to have issues when swapping between light/dark mode
|
||||
container.layer.borderColor = (isDarkMode ? UIColor.black.cgColor : UIColor.white.cgColor)
|
||||
|
||||
let emojiLabel = UILabel()
|
||||
emojiLabel.text = emoji.rawValue
|
||||
emojiLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||
|
||||
container.addSubview(emojiLabel)
|
||||
emojiLabel.center(in: container)
|
||||
|
||||
addSubview(container)
|
||||
container.pin([ UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self)
|
||||
container.pin(.right, to: .right, of: self, withInset: -rightMargin)
|
||||
rightMargin += margin
|
||||
}
|
||||
|
||||
set(.width, to: rightMargin - margin + size)
|
||||
}
|
||||
}
|
|
@ -52,7 +52,13 @@ final class InfoMessageCell: MessageCell {
|
|||
|
||||
// MARK: - Updating
|
||||
|
||||
override func update(with cellViewModel: MessageViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
|
||||
override func update(
|
||||
with cellViewModel: MessageViewModel,
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||
showExpandedReactions: Bool,
|
||||
lastSearchText: String?
|
||||
) {
|
||||
guard cellViewModel.variant.isInfoMessage else { return }
|
||||
|
||||
self.viewModel = cellViewModel
|
||||
|
|
|
@ -43,7 +43,13 @@ public class MessageCell: UITableViewCell {
|
|||
|
||||
// MARK: - Updating
|
||||
|
||||
func update(with cellViewModel: MessageViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
|
||||
func update(
|
||||
with cellViewModel: MessageViewModel,
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||
showExpandedReactions: Bool,
|
||||
lastSearchText: String?
|
||||
) {
|
||||
preconditionFailure("Must be overridden by subclasses.")
|
||||
}
|
||||
|
||||
|
@ -75,7 +81,7 @@ public class MessageCell: UITableViewCell {
|
|||
|
||||
// MARK: - MessageCellDelegate
|
||||
|
||||
protocol MessageCellDelegate: AnyObject {
|
||||
protocol MessageCellDelegate: ReactionDelegate {
|
||||
func handleItemLongPressed(_ cellViewModel: MessageViewModel)
|
||||
func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer)
|
||||
func handleItemDoubleTapped(_ cellViewModel: MessageViewModel)
|
||||
|
@ -84,4 +90,6 @@ protocol MessageCellDelegate: AnyObject {
|
|||
func handleReplyButtonTapped(for cellViewModel: MessageViewModel)
|
||||
func showUserDetails(for profile: Profile)
|
||||
func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?)
|
||||
func showReactionList(_ cellViewModel: MessageViewModel, selectedReaction: EmojiWithSkinTones?)
|
||||
func needsLayout(for cellViewModel: MessageViewModel, expandingReactions: Bool)
|
||||
}
|
||||
|
|
|
@ -39,7 +39,13 @@ final class TypingIndicatorCell: MessageCell {
|
|||
|
||||
// MARK: - Updating
|
||||
|
||||
override func update(with cellViewModel: MessageViewModel, mediaCache: NSCache<NSString, AnyObject>, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) {
|
||||
override func update(
|
||||
with cellViewModel: MessageViewModel,
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||
showExpandedReactions: Bool,
|
||||
lastSearchText: String?
|
||||
) {
|
||||
guard cellViewModel.cellType == .typingIndicator else { return }
|
||||
|
||||
self.viewModel = cellViewModel
|
||||
|
|
|
@ -5,12 +5,13 @@ import SignalUtilitiesKit
|
|||
import SessionUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
|
||||
final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDelegate {
|
||||
final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||
private var isHandlingLongPress: Bool = false
|
||||
private var unloadContent: (() -> Void)?
|
||||
private var previousX: CGFloat = 0
|
||||
|
||||
var albumView: MediaAlbumView?
|
||||
var bodyTextView: UITextView?
|
||||
var bodyTappableLabel: TappableLabel?
|
||||
var voiceMessageView: VoiceMessageView?
|
||||
var audioStateChanged: ((TimeInterval, Bool) -> ())?
|
||||
|
||||
|
@ -19,14 +20,20 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0)
|
||||
private lazy var profilePictureViewLeftConstraint = profilePictureView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.groupThreadHSpacing)
|
||||
private lazy var profilePictureViewWidthConstraint = profilePictureView.set(.width, to: Values.verySmallProfilePictureSize)
|
||||
|
||||
private lazy var bubbleViewLeftConstraint1 = bubbleView.pin(.left, to: .right, of: profilePictureView, withInset: VisibleMessageCell.groupThreadHSpacing)
|
||||
private lazy var bubbleViewLeftConstraint2 = bubbleView.leftAnchor.constraint(greaterThanOrEqualTo: leftAnchor, constant: VisibleMessageCell.gutterSize)
|
||||
private lazy var bubbleViewTopConstraint = bubbleView.pin(.top, to: .bottom, of: authorLabel, withInset: VisibleMessageCell.authorLabelBottomSpacing)
|
||||
private lazy var bubbleViewRightConstraint1 = bubbleView.pin(.right, to: .right, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing)
|
||||
private lazy var bubbleViewRightConstraint2 = bubbleView.rightAnchor.constraint(lessThanOrEqualTo: rightAnchor, constant: -VisibleMessageCell.gutterSize)
|
||||
private lazy var messageStatusImageViewTopConstraint = messageStatusImageView.pin(.top, to: .bottom, of: bubbleView, withInset: 0)
|
||||
|
||||
private lazy var reactionContainerViewLeftConstraint = reactionContainerView.pin(.left, to: .left, of: bubbleView)
|
||||
private lazy var reactionContainerViewRightConstraint = reactionContainerView.pin(.right, to: .right, of: bubbleView)
|
||||
|
||||
private lazy var messageStatusImageViewTopConstraint = messageStatusImageView.pin(.top, to: .bottom, of: reactionContainerView, withInset: 0)
|
||||
private lazy var messageStatusImageViewWidthConstraint = messageStatusImageView.set(.width, to: VisibleMessageCell.messageStatusImageViewSize)
|
||||
private lazy var messageStatusImageViewHeightConstraint = messageStatusImageView.set(.height, to: VisibleMessageCell.messageStatusImageViewSize)
|
||||
|
||||
private lazy var timerViewOutgoingMessageConstraint = timerView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing)
|
||||
private lazy var timerViewIncomingMessageConstraint = timerView.pin(.right, to: .right, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing)
|
||||
|
||||
|
@ -44,7 +51,8 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
profilePictureView,
|
||||
replyButton,
|
||||
timerView,
|
||||
messageStatusImageView
|
||||
messageStatusImageView,
|
||||
reactionContainerView
|
||||
]
|
||||
|
||||
private lazy var profilePictureView: ProfilePictureView = {
|
||||
|
@ -80,7 +88,8 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
}()
|
||||
|
||||
private lazy var snContentView = UIView()
|
||||
|
||||
private lazy var reactionContainerView = ReactionContainerView()
|
||||
|
||||
internal lazy var messageStatusImageView: UIImageView = {
|
||||
let result = UIImageView()
|
||||
result.contentMode = .scaleAspectFit
|
||||
|
@ -161,7 +170,6 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
addSubview(profilePictureView)
|
||||
profilePictureViewLeftConstraint.isActive = true
|
||||
profilePictureViewWidthConstraint.isActive = true
|
||||
profilePictureView.pin(.bottom, to: .bottom, of: self, withInset: -1)
|
||||
|
||||
// Moderator icon image view
|
||||
moderatorIconImageView.set(.width, to: 20)
|
||||
|
@ -178,6 +186,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
bubbleViewLeftConstraint1.isActive = true
|
||||
bubbleViewTopConstraint.isActive = true
|
||||
bubbleViewRightConstraint1.isActive = true
|
||||
bubbleView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: -1)
|
||||
bubbleBackgroundView.pin(to: bubbleView)
|
||||
|
||||
// Timer view
|
||||
|
@ -189,6 +198,11 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
bubbleView.addSubview(snContentView)
|
||||
snContentView.pin(to: bubbleView)
|
||||
|
||||
// Reaction view
|
||||
addSubview(reactionContainerView)
|
||||
reactionContainerView.pin(.top, to: .bottom, of: bubbleView, withInset: Values.verySmallSpacing)
|
||||
reactionContainerViewLeftConstraint.isActive = true
|
||||
|
||||
// Message status image view
|
||||
addSubview(messageStatusImageView)
|
||||
messageStatusImageViewTopConstraint.isActive = true
|
||||
|
@ -228,6 +242,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
with cellViewModel: MessageViewModel,
|
||||
mediaCache: NSCache<NSString, AnyObject>,
|
||||
playbackInfo: ConversationViewModel.PlaybackInfo?,
|
||||
showExpandedReactions: Bool,
|
||||
lastSearchText: String?
|
||||
) {
|
||||
self.viewModel = cellViewModel
|
||||
|
@ -280,6 +295,12 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
lastSearchText: lastSearchText
|
||||
)
|
||||
|
||||
// Reaction view
|
||||
reactionContainerView.isHidden = (cellViewModel.reactionInfo?.isEmpty == true)
|
||||
reactionContainerViewLeftConstraint.isActive = (cellViewModel.variant == .standardIncoming)
|
||||
reactionContainerViewRightConstraint.isActive = (cellViewModel.variant == .standardOutgoing)
|
||||
populateReaction(for: cellViewModel, showExpandedReactions: showExpandedReactions)
|
||||
|
||||
// Date break
|
||||
headerViewTopConstraint.constant = (shouldInsetHeader ? Values.mediumSpacing : 1)
|
||||
headerView.subviews.forEach { $0.removeFromSuperview() }
|
||||
|
@ -389,7 +410,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
|
||||
snContentView.subviews.forEach { $0.removeFromSuperview() }
|
||||
albumView = nil
|
||||
bodyTextView = nil
|
||||
bodyTappableLabel = nil
|
||||
|
||||
// Handle the deleted state first (it's much simpler than the others)
|
||||
guard cellViewModel.variant != .standardIncomingDeleted else {
|
||||
|
@ -431,7 +452,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
)
|
||||
snContentView.addSubview(linkPreviewView)
|
||||
linkPreviewView.pin(to: snContentView)
|
||||
self.bodyTextView = linkPreviewView.bodyTextView
|
||||
self.bodyTappableLabel = linkPreviewView.bodyTappableLabel
|
||||
|
||||
case .openGroupInvitation:
|
||||
let openGroupInvitationView: OpenGroupInvitationView = OpenGroupInvitationView(
|
||||
|
@ -474,15 +495,15 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
}
|
||||
|
||||
// Body text view
|
||||
let bodyTextView = VisibleMessageCell.getBodyTextView(
|
||||
let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel(
|
||||
for: cellViewModel,
|
||||
with: maxWidth,
|
||||
textColor: bodyLabelTextColor,
|
||||
searchText: lastSearchText,
|
||||
delegate: self
|
||||
)
|
||||
self.bodyTextView = bodyTextView
|
||||
stackView.addArrangedSubview(bodyTextView)
|
||||
self.bodyTappableLabel = bodyTappableLabel
|
||||
stackView.addArrangedSubview(bodyTappableLabel)
|
||||
|
||||
// Constraints
|
||||
snContentView.addSubview(stackView)
|
||||
|
@ -516,7 +537,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
if let body: String = cellViewModel.body, !body.isEmpty {
|
||||
let inset: CGFloat = 12
|
||||
let maxWidth: CGFloat = (size.width - (2 * inset))
|
||||
let bodyTextView = VisibleMessageCell.getBodyTextView(
|
||||
let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel(
|
||||
for: cellViewModel,
|
||||
with: maxWidth,
|
||||
textColor: bodyLabelTextColor,
|
||||
|
@ -524,8 +545,8 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
delegate: self
|
||||
)
|
||||
|
||||
self.bodyTextView = bodyTextView
|
||||
stackView.addArrangedSubview(UIView(wrapping: bodyTextView, withInsets: UIEdgeInsets(top: 0, left: inset, bottom: inset, right: inset)))
|
||||
self.bodyTappableLabel = bodyTappableLabel
|
||||
stackView.addArrangedSubview(UIView(wrapping: bodyTappableLabel, withInsets: UIEdgeInsets(top: 0, left: inset, bottom: inset, right: inset)))
|
||||
}
|
||||
unloadContent = { albumView.unloadMedia() }
|
||||
|
||||
|
@ -568,7 +589,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
|
||||
// Body text view
|
||||
if let body: String = cellViewModel.body, !body.isEmpty { // delegate should always be set at this point
|
||||
let bodyTextView = VisibleMessageCell.getBodyTextView(
|
||||
let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel(
|
||||
for: cellViewModel,
|
||||
with: maxWidth,
|
||||
textColor: bodyLabelTextColor,
|
||||
|
@ -576,8 +597,8 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
delegate: self
|
||||
)
|
||||
|
||||
self.bodyTextView = bodyTextView
|
||||
stackView.addArrangedSubview(bodyTextView)
|
||||
self.bodyTappableLabel = bodyTappableLabel
|
||||
stackView.addArrangedSubview(bodyTappableLabel)
|
||||
}
|
||||
|
||||
// Constraints
|
||||
|
@ -585,7 +606,48 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
stackView.pin(to: snContentView, withInset: inset)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func populateReaction(for cellViewModel: MessageViewModel, showExpandedReactions: Bool) {
|
||||
let reactions: OrderedDictionary<EmojiWithSkinTones, ReactionViewModel> = (cellViewModel.reactionInfo ?? [])
|
||||
.reduce(into: OrderedDictionary()) { result, reactionInfo in
|
||||
guard let emoji: EmojiWithSkinTones = EmojiWithSkinTones(rawValue: reactionInfo.reaction.emoji) else {
|
||||
return
|
||||
}
|
||||
|
||||
let isSelfSend: Bool = (reactionInfo.reaction.authorId == cellViewModel.currentUserPublicKey)
|
||||
|
||||
if let value: ReactionViewModel = result.value(forKey: emoji) {
|
||||
result.replace(
|
||||
key: emoji,
|
||||
value: ReactionViewModel(
|
||||
emoji: emoji,
|
||||
number: (value.number + Int(reactionInfo.reaction.count)),
|
||||
showBorder: (value.showBorder || isSelfSend)
|
||||
)
|
||||
)
|
||||
}
|
||||
else {
|
||||
result.append(
|
||||
key: emoji,
|
||||
value: ReactionViewModel(
|
||||
emoji: emoji,
|
||||
number: Int(reactionInfo.reaction.count),
|
||||
showBorder: isSelfSend
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
reactionContainerView.showingAllReactions = showExpandedReactions
|
||||
reactionContainerView.update(
|
||||
reactions.orderedValues,
|
||||
showNumbers: (
|
||||
cellViewModel.threadVariant == .closedGroup ||
|
||||
cellViewModel.threadVariant == .openGroup
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
updateBubbleViewCorners()
|
||||
|
@ -638,10 +700,10 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
// MARK: - Interaction
|
||||
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
if let bodyTextView = bodyTextView {
|
||||
let pointInBodyTextViewCoordinates = convert(point, to: bodyTextView)
|
||||
if bodyTextView.bounds.contains(pointInBodyTextViewCoordinates) {
|
||||
return bodyTextView
|
||||
if let bodyTappableLabel = bodyTappableLabel {
|
||||
let btIngetBodyTappableLabelCoordinates = convert(point, to: bodyTappableLabel)
|
||||
if bodyTappableLabel.bounds.contains(btIngetBodyTappableLabelCoordinates) {
|
||||
return bodyTappableLabel
|
||||
}
|
||||
}
|
||||
return super.hitTest(point, with: event)
|
||||
|
@ -687,11 +749,31 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func handleLongPress() {
|
||||
guard let cellViewModel: MessageViewModel = self.viewModel else { return }
|
||||
|
||||
@objc func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
if [ .ended, .cancelled, .failed ].contains(gestureRecognizer.state) {
|
||||
isHandlingLongPress = false
|
||||
return
|
||||
}
|
||||
guard !isHandlingLongPress, let cellViewModel: MessageViewModel = self.viewModel else { return }
|
||||
|
||||
delegate?.handleItemLongPressed(cellViewModel)
|
||||
let location = gestureRecognizer.location(in: self)
|
||||
|
||||
if reactionContainerView.frame.contains(location) {
|
||||
let convertedLocation = reactionContainerView.convert(location, from: self)
|
||||
|
||||
for reactionView in reactionContainerView.reactionViews {
|
||||
if reactionContainerView.convert(reactionView.frame, from: reactionView.superview).contains(convertedLocation) {
|
||||
delegate?.showReactionList(cellViewModel, selectedReaction: reactionView.viewModel.emoji)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
delegate?.handleItemLongPressed(cellViewModel)
|
||||
}
|
||||
|
||||
isHandlingLongPress = true
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||
|
@ -722,6 +804,32 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
||||
reply()
|
||||
}
|
||||
else if reactionContainerView.frame.contains(location) {
|
||||
let convertedLocation = reactionContainerView.convert(location, from: self)
|
||||
|
||||
for reactionView in reactionContainerView.reactionViews {
|
||||
if reactionContainerView.convert(reactionView.frame, from: reactionView.superview).contains(convertedLocation) {
|
||||
|
||||
if reactionView.viewModel.showBorder {
|
||||
delegate?.removeReact(cellViewModel, for: reactionView.viewModel.emoji)
|
||||
}
|
||||
else {
|
||||
delegate?.react(cellViewModel, with: reactionView.viewModel.emoji)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let expandButton = reactionContainerView.expandButton, expandButton.frame.contains(convertedLocation) {
|
||||
reactionContainerView.showAllEmojis()
|
||||
delegate?.needsLayout(for: cellViewModel, expandingReactions: true)
|
||||
}
|
||||
|
||||
if reactionContainerView.collapseButton.frame.contains(convertedLocation) {
|
||||
reactionContainerView.showLessEmojis()
|
||||
delegate?.needsLayout(for: cellViewModel, expandingReactions: false)
|
||||
}
|
||||
}
|
||||
else if bubbleView.frame.contains(location) {
|
||||
delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer)
|
||||
}
|
||||
|
@ -770,20 +878,11 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func textView(_ textView: UITextView, shouldInteractWith url: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
|
||||
delegate?.openUrl(url.absoluteString)
|
||||
return false
|
||||
|
||||
func tapableLabel(_ label: TappableLabel, didTapUrl url: String, atRange range: NSRange) {
|
||||
delegate?.openUrl(url)
|
||||
}
|
||||
|
||||
func textViewDidChangeSelection(_ textView: UITextView) {
|
||||
// Note: We can't just set 'isSelectable' to false otherwise the link detection/selection
|
||||
// stops working (do a null check to avoid an infinite loop on older iOS versions)
|
||||
if textView.selectedTextRange != nil {
|
||||
textView.selectedTextRange = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func resetReply() {
|
||||
UIView.animate(withDuration: 0.25) { [weak self] in
|
||||
self?.viewsToMoveForReply.forEach { $0.transform = .identity }
|
||||
|
@ -801,19 +900,7 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
// MARK: - Convenience
|
||||
|
||||
private func getCornersToRound() -> UIRectCorner {
|
||||
guard viewModel?.isOnlyMessageInCluster == false else { return .allCorners }
|
||||
|
||||
let direction: Direction = (viewModel?.variant == .standardOutgoing ? .outgoing : .incoming)
|
||||
|
||||
switch (viewModel?.positionInCluster, direction) {
|
||||
case (.top, .outgoing): return [ .bottomLeft, .topLeft, .topRight ]
|
||||
case (.middle, .outgoing): return [ .bottomLeft, .topLeft ]
|
||||
case (.bottom, .outgoing): return [ .bottomRight, .bottomLeft, .topLeft ]
|
||||
case (.top, .incoming): return [ .topLeft, .topRight, .bottomRight ]
|
||||
case (.middle, .incoming): return [ .topRight, .bottomRight ]
|
||||
case (.bottom, .incoming): return [ .topRight, .bottomRight, .bottomLeft ]
|
||||
case (.none, _): return .allCorners
|
||||
}
|
||||
return .allCorners
|
||||
}
|
||||
|
||||
private func getCornerMask(from rectCorner: UIRectCorner) -> CACornerMask {
|
||||
|
@ -935,24 +1022,16 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
default: preconditionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
static func getBodyTextView(
|
||||
|
||||
static func getBodyTappableLabel(
|
||||
for cellViewModel: MessageViewModel,
|
||||
with availableWidth: CGFloat,
|
||||
textColor: UIColor,
|
||||
searchText: String?,
|
||||
delegate: (UITextViewDelegate & BodyTextViewDelegate)?
|
||||
) -> UITextView {
|
||||
// Take care of:
|
||||
// • Highlighting mentions
|
||||
// • Linkification
|
||||
// • Highlighting search results
|
||||
//
|
||||
// Note: We can't just set 'isSelectable' to false otherwise the link detection/selection
|
||||
// stops working
|
||||
delegate: TappableLabelDelegate?
|
||||
) -> TappableLabel {
|
||||
let result = TappableLabel()
|
||||
let isOutgoing: Bool = (cellViewModel.variant == .standardOutgoing)
|
||||
let result: BodyTextView = BodyTextView(snDelegate: delegate)
|
||||
result.isEditable = false
|
||||
|
||||
let attributedText: NSMutableAttributedString = NSMutableAttributedString(
|
||||
attributedString: MentionUtilities.highlightMentions(
|
||||
|
@ -968,6 +1047,55 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
)
|
||||
)
|
||||
|
||||
// Custom handle links
|
||||
let links: [String: NSRange] = {
|
||||
guard
|
||||
let body: String = cellViewModel.body,
|
||||
let detector: NSDataDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
else { return [:] }
|
||||
|
||||
var links: [String: NSRange] = [:]
|
||||
let matches = detector.matches(
|
||||
in: body,
|
||||
options: [],
|
||||
range: NSRange(location: 0, length: body.count)
|
||||
)
|
||||
|
||||
for match in matches {
|
||||
guard let matchURL = match.url else { continue }
|
||||
|
||||
/// If the URL entered didn't have a scheme it will default to 'http', we want to catch this and
|
||||
/// set the scheme to 'https' instead as we don't load previews for 'http' so this will result
|
||||
/// in more previews actually getting loaded without forcing the user to enter 'https://' before
|
||||
/// every URL they enter
|
||||
let urlString: String = (matchURL.absoluteString == "http://\(body)" ?
|
||||
"https://\(body)" :
|
||||
matchURL.absoluteString
|
||||
)
|
||||
|
||||
if URL(string: urlString) != nil {
|
||||
links[urlString] = (body as NSString).range(of: urlString)
|
||||
}
|
||||
}
|
||||
|
||||
return links
|
||||
}()
|
||||
|
||||
for (urlString, range) in links {
|
||||
guard let url: URL = URL(string: urlString) else { continue }
|
||||
|
||||
attributedText.addAttributes(
|
||||
[
|
||||
.font: UIFont.systemFont(ofSize: getFontSize(for: cellViewModel)),
|
||||
.foregroundColor: textColor,
|
||||
.underlineColor: textColor,
|
||||
.underlineStyle: NSUnderlineStyle.single.rawValue,
|
||||
.attachment: url
|
||||
],
|
||||
range: range
|
||||
)
|
||||
}
|
||||
|
||||
// If there is a valid search term then highlight each part that matched
|
||||
if let searchText = searchText, searchText.count >= ConversationSearchController.minimumSearchTextLength {
|
||||
let normalizedBody: String = attributedText.string.lowercased()
|
||||
|
@ -998,19 +1126,10 @@ final class VisibleMessageCell: MessageCell, UITextViewDelegate, BodyTextViewDel
|
|||
}
|
||||
|
||||
result.attributedText = attributedText
|
||||
result.dataDetectorTypes = .link
|
||||
result.backgroundColor = .clear
|
||||
result.isOpaque = false
|
||||
result.textContainerInset = UIEdgeInsets.zero
|
||||
result.contentInset = UIEdgeInsets.zero
|
||||
result.textContainer.lineFragmentPadding = 0
|
||||
result.isScrollEnabled = false
|
||||
result.isUserInteractionEnabled = true
|
||||
result.delegate = delegate
|
||||
result.linkTextAttributes = [
|
||||
.foregroundColor: textColor,
|
||||
.underlineStyle: NSUnderlineStyle.single.rawValue
|
||||
]
|
||||
|
||||
let availableSpace = CGSize(width: availableWidth, height: .greatestFiniteMagnitude)
|
||||
let size = result.sizeThatFits(availableSpace)
|
||||
|
|
|
@ -1,75 +0,0 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
// Requirements:
|
||||
// • 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 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?) {
|
||||
self.snDelegate = snDelegate
|
||||
|
||||
super.init(frame: CGRect.zero, textContainer: nil)
|
||||
|
||||
self.clipsToBounds = false // Needed for the 'HighlightMentionBackgroundView'
|
||||
addSubview(highlightedMentionBackgroundView)
|
||||
|
||||
setUpGestureRecognizers()
|
||||
}
|
||||
|
||||
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
||||
preconditionFailure("Use init(snDelegate:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(snDelegate:) instead.")
|
||||
}
|
||||
|
||||
private func setUpGestureRecognizers() {
|
||||
let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
|
||||
addGestureRecognizer(longPressGestureRecognizer)
|
||||
let doubleTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap))
|
||||
doubleTapGestureRecognizer.numberOfTapsRequired = 2
|
||||
addGestureRecognizer(doubleTapGestureRecognizer)
|
||||
}
|
||||
|
||||
@objc private func handleLongPress() {
|
||||
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 {
|
||||
|
||||
func handleLongPress()
|
||||
}
|
588
Session/Conversations/Views & Modals/ReactionListSheet.swift
Normal file
588
Session/Conversations/Views & Modals/ReactionListSheet.swift
Normal file
|
@ -0,0 +1,588 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class ReactionListSheet: BaseVC {
|
||||
public struct ReactionSummary: Hashable, Differentiable {
|
||||
let emoji: EmojiWithSkinTones
|
||||
let number: Int
|
||||
let isSelected: Bool
|
||||
|
||||
var description: String {
|
||||
return "\(emoji.rawValue) · \(number)"
|
||||
}
|
||||
}
|
||||
|
||||
private let interactionId: Int64
|
||||
private let onDismiss: (() -> ())?
|
||||
private var messageViewModel: MessageViewModel = MessageViewModel()
|
||||
private var reactionSummaries: [ReactionSummary] = []
|
||||
private var selectedReactionUserList: [MessageViewModel.ReactionInfo] = []
|
||||
private var lastSelectedReactionIndex: Int = 0
|
||||
public var delegate: ReactionDelegate?
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private lazy var contentView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
result.backgroundColor = Colors.modalBackground
|
||||
|
||||
let line: UIView = UIView()
|
||||
line.backgroundColor = Colors.border.withAlphaComponent(0.5)
|
||||
result.addSubview(line)
|
||||
|
||||
line.set(.height, to: 0.5)
|
||||
line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top ], to: result)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var layout: UICollectionViewFlowLayout = {
|
||||
let result: UICollectionViewFlowLayout = UICollectionViewFlowLayout()
|
||||
result.scrollDirection = .horizontal
|
||||
result.sectionInset = UIEdgeInsets(
|
||||
top: 0,
|
||||
leading: Values.smallSpacing,
|
||||
bottom: 0,
|
||||
trailing: Values.smallSpacing
|
||||
)
|
||||
result.minimumLineSpacing = Values.smallSpacing
|
||||
result.minimumInteritemSpacing = Values.smallSpacing
|
||||
result.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var reactionContainer: UICollectionView = {
|
||||
let result: UICollectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
|
||||
result.register(view: Cell.self)
|
||||
result.set(.height, to: 48)
|
||||
result.backgroundColor = .clear
|
||||
result.isScrollEnabled = true
|
||||
result.showsHorizontalScrollIndicator = false
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var detailInfoLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
result.textColor = Colors.grey.withAlphaComponent(0.8)
|
||||
result.set(.height, to: 32)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var clearAllButton: Button = {
|
||||
let result: Button = Button(style: .destructiveOutline, size: .small)
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal)
|
||||
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
|
||||
result.layer.borderWidth = 0
|
||||
result.isHidden = true
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var userListView: UITableView = {
|
||||
let result: UITableView = UITableView()
|
||||
result.dataSource = self
|
||||
result.delegate = self
|
||||
result.register(view: UserCell.self)
|
||||
result.register(view: FooterCell.self)
|
||||
result.separatorStyle = .none
|
||||
result.backgroundColor = .clear
|
||||
result.showsVerticalScrollIndicator = false
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(for interactionId: Int64, onDismiss: (() -> ())? = nil) {
|
||||
self.interactionId = interactionId
|
||||
self.onDismiss = onDismiss
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
override init(nibName: String?, bundle: Bundle?) {
|
||||
preconditionFailure("Use init(for:) instead.")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
preconditionFailure("Use init(for:) instead.")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = .clear
|
||||
|
||||
let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(close))
|
||||
swipeGestureRecognizer.direction = .down
|
||||
view.addGestureRecognizer(swipeGestureRecognizer)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
reactionContainer.scrollToItem(
|
||||
at: IndexPath(item: lastSelectedReactionIndex, section: 0),
|
||||
at: .centeredHorizontally,
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
self.onDismiss?()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
view.addSubview(contentView)
|
||||
contentView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.bottom ], to: view)
|
||||
// Emoji collectionView height + seleted emoji detail height + 5 × user cell height + footer cell height + bottom safe area inset
|
||||
let contentViewHeight: CGFloat = 100 + 5 * 65 + 45 + UIApplication.shared.keyWindow!.safeAreaInsets.bottom
|
||||
contentView.set(.height, to: contentViewHeight)
|
||||
populateContentView()
|
||||
}
|
||||
|
||||
private func populateContentView() {
|
||||
// Reactions container
|
||||
contentView.addSubview(reactionContainer)
|
||||
reactionContainer.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: contentView)
|
||||
reactionContainer.pin(.top, to: .top, of: contentView, withInset: Values.verySmallSpacing)
|
||||
|
||||
// Seperator
|
||||
let seperator = UIView()
|
||||
seperator.backgroundColor = Colors.border.withAlphaComponent(0.1)
|
||||
seperator.set(.height, to: 0.5)
|
||||
contentView.addSubview(seperator)
|
||||
seperator.pin(.leading, to: .leading, of: contentView, withInset: Values.smallSpacing)
|
||||
seperator.pin(.trailing, to: .trailing, of: contentView, withInset: -Values.smallSpacing)
|
||||
seperator.pin(.top, to: .bottom, of: reactionContainer, withInset: Values.verySmallSpacing)
|
||||
|
||||
// Detail info & clear all
|
||||
let stackView = UIStackView(arrangedSubviews: [ detailInfoLabel, clearAllButton ])
|
||||
contentView.addSubview(stackView)
|
||||
stackView.pin(.top, to: .bottom, of: seperator, withInset: Values.smallSpacing)
|
||||
stackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing)
|
||||
stackView.pin(.trailing, to: .trailing, of: contentView, withInset: -Values.mediumSpacing)
|
||||
|
||||
// Line
|
||||
let line = UIView()
|
||||
line.set(.height, to: 0.5)
|
||||
line.backgroundColor = Colors.border.withAlphaComponent(0.5)
|
||||
contentView.addSubview(line)
|
||||
line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: contentView)
|
||||
line.pin(.top, to: .bottom, of: stackView, withInset: Values.smallSpacing)
|
||||
|
||||
// Reactor list
|
||||
contentView.addSubview(userListView)
|
||||
userListView.pin([ UIView.HorizontalEdge.trailing, UIView.HorizontalEdge.leading, UIView.VerticalEdge.bottom ], to: contentView)
|
||||
userListView.pin(.top, to: .bottom, of: line, withInset: 0)
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
public func handleInteractionUpdates(
|
||||
_ allMessages: [MessageViewModel],
|
||||
selectedReaction: EmojiWithSkinTones? = nil,
|
||||
updatedReactionIndex: Int? = nil,
|
||||
initialLoad: Bool = false,
|
||||
shouldShowClearAllButton: Bool = false
|
||||
) {
|
||||
guard let cellViewModel: MessageViewModel = allMessages.first(where: { $0.id == self.interactionId }) else {
|
||||
return
|
||||
}
|
||||
|
||||
// If we have no more reactions (eg. the user removed the last one) then closed the list sheet
|
||||
guard cellViewModel.reactionInfo?.isEmpty == false else {
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
// Generated the updated data
|
||||
let updatedReactionInfo: OrderedDictionary<EmojiWithSkinTones, [MessageViewModel.ReactionInfo]> = (cellViewModel.reactionInfo ?? [])
|
||||
.reduce(into: OrderedDictionary<EmojiWithSkinTones, [MessageViewModel.ReactionInfo]>()) {
|
||||
result, reactionInfo in
|
||||
guard let emoji: EmojiWithSkinTones = EmojiWithSkinTones(rawValue: reactionInfo.reaction.emoji) else {
|
||||
return
|
||||
}
|
||||
|
||||
guard var updatedValue: [MessageViewModel.ReactionInfo] = result.value(forKey: emoji) else {
|
||||
result.append(key: emoji, value: [reactionInfo])
|
||||
return
|
||||
}
|
||||
|
||||
if reactionInfo.reaction.authorId == cellViewModel.currentUserPublicKey {
|
||||
updatedValue.insert(reactionInfo, at: 0)
|
||||
}
|
||||
else {
|
||||
updatedValue.append(reactionInfo)
|
||||
}
|
||||
|
||||
result.replace(key: emoji, value: updatedValue)
|
||||
}
|
||||
let oldSelectedReactionIndex: Int = self.lastSelectedReactionIndex
|
||||
let updatedSelectedReactionIndex: Int = updatedReactionIndex
|
||||
.defaulting(
|
||||
to: {
|
||||
// If we explicitly provided a 'selectedReaction' value then try to use that
|
||||
if selectedReaction != nil, let targetIndex: Int = updatedReactionInfo.orderedKeys.firstIndex(where: { $0 == selectedReaction }) {
|
||||
return targetIndex
|
||||
}
|
||||
|
||||
// Otherwise try to maintain the index of the currently selected index
|
||||
guard
|
||||
!self.reactionSummaries.isEmpty,
|
||||
let emoji: EmojiWithSkinTones = self.reactionSummaries[safe: oldSelectedReactionIndex]?.emoji,
|
||||
let targetIndex: Int = updatedReactionInfo.orderedKeys.firstIndex(of: emoji)
|
||||
else { return 0 }
|
||||
|
||||
return targetIndex
|
||||
}()
|
||||
)
|
||||
let updatedSummaries: [ReactionSummary] = updatedReactionInfo
|
||||
.orderedKeys
|
||||
.enumerated()
|
||||
.map { index, emoji in
|
||||
ReactionSummary(
|
||||
emoji: emoji,
|
||||
number: updatedReactionInfo.value(forKey: emoji)
|
||||
.defaulting(to: [])
|
||||
.map { Int($0.reaction.count) }
|
||||
.reduce(0, +),
|
||||
isSelected: (index == updatedSelectedReactionIndex)
|
||||
)
|
||||
}
|
||||
|
||||
// Update the general UI
|
||||
self.detailInfoLabel.text = updatedSummaries[safe: updatedSelectedReactionIndex]?.description
|
||||
|
||||
// Update general properties
|
||||
self.messageViewModel = cellViewModel
|
||||
self.lastSelectedReactionIndex = updatedSelectedReactionIndex
|
||||
|
||||
// Ensure the first load or a load when returning from a child screen runs without animations (if
|
||||
// we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition)
|
||||
guard !initialLoad else {
|
||||
self.reactionSummaries = updatedSummaries
|
||||
self.selectedReactionUserList = updatedReactionInfo
|
||||
.orderedKeys[safe: updatedSelectedReactionIndex]
|
||||
.map { updatedReactionInfo.value(forKey: $0) }
|
||||
.defaulting(to: [])
|
||||
|
||||
// Update clear all button visibility
|
||||
self.clearAllButton.isHidden = !shouldShowClearAllButton
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
self.reactionContainer.reloadData()
|
||||
self.userListView.reloadData()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Update the collection view content
|
||||
let collectionViewChangeset: StagedChangeset<[ReactionSummary]> = StagedChangeset(
|
||||
source: self.reactionSummaries,
|
||||
target: updatedSummaries
|
||||
)
|
||||
|
||||
// If there are changes then we want to reload both the collection and table views
|
||||
self.reactionContainer.reload(
|
||||
using: collectionViewChangeset,
|
||||
interrupt: { $0.changeCount > 1 }
|
||||
) { [weak self] updatedData in
|
||||
self?.reactionSummaries = updatedData
|
||||
}
|
||||
|
||||
// If we changed the selected index then no need to reload the changes
|
||||
guard
|
||||
oldSelectedReactionIndex == updatedSelectedReactionIndex &&
|
||||
self.reactionSummaries[safe: oldSelectedReactionIndex]?.emoji == updatedSummaries[safe: updatedSelectedReactionIndex]?.emoji
|
||||
else {
|
||||
self.selectedReactionUserList = updatedReactionInfo
|
||||
.orderedKeys[safe: updatedSelectedReactionIndex]
|
||||
.map { updatedReactionInfo.value(forKey: $0) }
|
||||
.defaulting(to: [])
|
||||
self.userListView.reloadData()
|
||||
return
|
||||
}
|
||||
|
||||
let tableChangeset: StagedChangeset<[MessageViewModel.ReactionInfo]> = StagedChangeset(
|
||||
source: self.selectedReactionUserList,
|
||||
target: updatedReactionInfo
|
||||
.orderedKeys[safe: updatedSelectedReactionIndex]
|
||||
.map { updatedReactionInfo.value(forKey: $0) }
|
||||
.defaulting(to: [])
|
||||
)
|
||||
|
||||
self.userListView.reload(
|
||||
using: tableChangeset,
|
||||
deleteSectionsAnimation: .none,
|
||||
insertSectionsAnimation: .none,
|
||||
reloadSectionsAnimation: .none,
|
||||
deleteRowsAnimation: .none,
|
||||
insertRowsAnimation: .none,
|
||||
reloadRowsAnimation: .none,
|
||||
interrupt: { [weak self] changeset in
|
||||
/// This is the case where there were 6 reactors in total and locally we only have 5 including current user,
|
||||
/// and current user remove the reaction. There would be 4 reactors locally and we need to show more
|
||||
/// reactors cell at this moment. After update from sogs, we'll get the all 5 reactors and update the table
|
||||
/// with 5 reactors and not showing the more reactors cell.
|
||||
changeset.elementInserted.count == 1 && self?.selectedReactionUserList.count == 4 ||
|
||||
/// This is the case where there were 5 reactors without current user, and current user reacted. Before we got
|
||||
/// the update from sogs, we'll have 6 reactors locally and not showing the more reactors cell. After the update,
|
||||
/// we'll need to update the table and show 5 reactors with the more reactors cell.
|
||||
changeset.elementDeleted.count == 1 && self?.selectedReactionUserList.count == 6 ||
|
||||
/// To many changes to make
|
||||
changeset.changeCount > 100
|
||||
}
|
||||
) { [weak self] updatedData in
|
||||
self?.selectedReactionUserList = updatedData
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch: UITouch = touches.first, contentView.frame.contains(touch.location(in: view)) else {
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
super.touchesBegan(touches, with: event)
|
||||
}
|
||||
|
||||
@objc func close() {
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc private func clearAllTapped() {
|
||||
guard let selectedReaction: EmojiWithSkinTones = self.reactionSummaries.first(where: { $0.isSelected })?.emoji else { return }
|
||||
|
||||
delegate?.removeAllReactions(messageViewModel, for: selectedReaction.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UICollectionView
|
||||
|
||||
extension ReactionListSheet: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||
// MARK: Data Source
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
return self.reactionSummaries.count
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let cell: Cell = collectionView.dequeue(type: Cell.self, for: indexPath)
|
||||
let summary: ReactionSummary = self.reactionSummaries[indexPath.item]
|
||||
|
||||
cell.update(
|
||||
with: summary.emoji.rawValue,
|
||||
count: summary.number,
|
||||
isCurrentSelection: summary.isSelected
|
||||
)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
self.handleInteractionUpdates([messageViewModel], updatedReactionIndex: indexPath.item)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate & UITableViewDataSource
|
||||
|
||||
extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
let moreReactorCount = self.reactionSummaries[lastSelectedReactionIndex].number - self.selectedReactionUserList.count
|
||||
return moreReactorCount > 0 ? self.selectedReactionUserList.count + 1 : self.selectedReactionUserList.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard indexPath.row < self.selectedReactionUserList.count else {
|
||||
let moreReactorCount = self.reactionSummaries[lastSelectedReactionIndex].number - self.selectedReactionUserList.count
|
||||
let footerCell: FooterCell = tableView.dequeue(type: FooterCell.self, for: indexPath)
|
||||
footerCell.update(
|
||||
moreReactorCount: moreReactorCount,
|
||||
emoji: self.reactionSummaries[lastSelectedReactionIndex].emoji.rawValue
|
||||
)
|
||||
footerCell.selectionStyle = .none
|
||||
|
||||
return footerCell
|
||||
}
|
||||
|
||||
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
|
||||
let cellViewModel: MessageViewModel.ReactionInfo = self.selectedReactionUserList[indexPath.row]
|
||||
cell.update(
|
||||
with: cellViewModel.reaction.authorId,
|
||||
profile: cellViewModel.profile,
|
||||
isZombie: false,
|
||||
mediumFont: true,
|
||||
accessory: (cellViewModel.reaction.authorId == self.messageViewModel.currentUserPublicKey ?
|
||||
.x :
|
||||
.none
|
||||
)
|
||||
)
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
|
||||
guard indexPath.row < self.selectedReactionUserList.count else { return }
|
||||
|
||||
let cellViewModel: MessageViewModel.ReactionInfo = self.selectedReactionUserList[indexPath.row]
|
||||
|
||||
guard
|
||||
let selectedReaction: EmojiWithSkinTones = self.reactionSummaries
|
||||
.first(where: { $0.isSelected })?
|
||||
.emoji,
|
||||
selectedReaction.rawValue == cellViewModel.reaction.emoji,
|
||||
cellViewModel.reaction.authorId == self.messageViewModel.currentUserPublicKey
|
||||
else { return }
|
||||
|
||||
delegate?.removeReact(self.messageViewModel, for: selectedReaction)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cell
|
||||
|
||||
extension ReactionListSheet {
|
||||
fileprivate final class Cell: UICollectionViewCell {
|
||||
// MARK: - UI
|
||||
|
||||
private static var contentViewHeight: CGFloat = 32
|
||||
private static var contentViewCornerRadius: CGFloat { contentViewHeight / 2 }
|
||||
|
||||
private lazy var snContentView: UIView = {
|
||||
let result = UIView()
|
||||
result.backgroundColor = Colors.receivedMessageBackground
|
||||
result.set(.height, to: Cell.contentViewHeight)
|
||||
result.layer.cornerRadius = Cell.contentViewCornerRadius
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var emojiLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
return result
|
||||
}()
|
||||
|
||||
private lazy var numberLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textColor = Colors.text
|
||||
result.font = .systemFont(ofSize: Values.mediumFontSize)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
setUpViewHierarchy()
|
||||
}
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
addSubview(snContentView)
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: [ emojiLabel, numberLabel ])
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
|
||||
let spacing = Values.smallSpacing + 2
|
||||
stackView.spacing = spacing
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 0, left: spacing, bottom: 0, right: spacing)
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
snContentView.addSubview(stackView)
|
||||
stackView.pin(to: snContentView)
|
||||
snContentView.pin(to: self)
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
fileprivate func update(
|
||||
with emoji: String,
|
||||
count: Int,
|
||||
isCurrentSelection: Bool
|
||||
) {
|
||||
snContentView.addBorder(
|
||||
with: (isCurrentSelection == true ? Colors.accent : .clear)
|
||||
)
|
||||
|
||||
emojiLabel.text = emoji
|
||||
numberLabel.text = (count < 1000 ?
|
||||
"\(count)" :
|
||||
String(format: "%.1fk", Float(count) / 1000)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate final class FooterCell: UITableViewCell {
|
||||
|
||||
private lazy var label: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textAlignment = .center
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.grey.withAlphaComponent(0.8)
|
||||
return result
|
||||
}()
|
||||
|
||||
// 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() {
|
||||
// Background color
|
||||
backgroundColor = Colors.cellBackground
|
||||
|
||||
contentView.addSubview(label)
|
||||
label.pin(to: contentView)
|
||||
label.set(.height, to: 45)
|
||||
}
|
||||
|
||||
func update(moreReactorCount: Int, emoji: String) {
|
||||
label.text = (moreReactorCount == 1) ?
|
||||
String(format: "EMOJI_REACTS_MORE_REACTORS_ONE".localized(), "\(emoji)") :
|
||||
String(format: "EMOJI_REACTS_MORE_REACTORS_MUTIPLE".localized(), "\(moreReactorCount)" ,"\(emoji)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delegate
|
||||
|
||||
protocol ReactionDelegate: AnyObject {
|
||||
func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones)
|
||||
func removeReact(_ cellViewModel: MessageViewModel, for emoji: EmojiWithSkinTones)
|
||||
func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String)
|
||||
}
|
111
Session/Emoji/Emoji+Available.swift
Normal file
111
Session/Emoji/Emoji+Available.swift
Normal file
|
@ -0,0 +1,111 @@
|
|||
import Foundation
|
||||
|
||||
extension Emoji {
|
||||
private static let availableCache: Atomic<[Emoji:Bool]> = Atomic([:])
|
||||
private static let iosVersionKey = "iosVersion"
|
||||
private static let cacheUrl = URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath())
|
||||
.appendingPathComponent("Library")
|
||||
.appendingPathComponent("Caches")
|
||||
.appendingPathComponent("emoji.plist")
|
||||
|
||||
static func warmAvailableCache() {
|
||||
owsAssertDebug(!Thread.isMainThread)
|
||||
|
||||
guard CurrentAppContext().isMainAppAndActive else { return }
|
||||
|
||||
var availableCache = [Emoji: Bool]()
|
||||
var uncachedEmoji = [Emoji]()
|
||||
|
||||
let iosVersion = UIDevice.current.systemVersion
|
||||
|
||||
// Use an NSMutableDictionary for built-in plist serialization and heterogeneous values.
|
||||
var availableMap = NSMutableDictionary()
|
||||
do {
|
||||
availableMap = try NSMutableDictionary(contentsOf: Self.cacheUrl, error: ())
|
||||
} catch {
|
||||
Logger.info("Re-building emoji availability cache. Cache could not be loaded. \(error)")
|
||||
uncachedEmoji = Emoji.allCases
|
||||
}
|
||||
|
||||
let lastIosVersion = availableMap[iosVersionKey] as? String
|
||||
if lastIosVersion == iosVersion {
|
||||
Logger.debug("Loading emoji availability cache (expect \(Emoji.allCases.count) items, found \(availableMap.count - 1)).")
|
||||
for emoji in Emoji.allCases {
|
||||
if let available = availableMap[emoji.rawValue] as? Bool {
|
||||
availableCache[emoji] = available
|
||||
} else {
|
||||
Logger.warn("Emoji unexpectedly missing from cache: \(emoji).")
|
||||
uncachedEmoji.append(emoji)
|
||||
}
|
||||
}
|
||||
} else if uncachedEmoji.isEmpty {
|
||||
Logger.info("Re-building emoji availability cache. iOS version upgraded from \(lastIosVersion ?? "(none)") -> \(iosVersion)")
|
||||
uncachedEmoji = Emoji.allCases
|
||||
}
|
||||
|
||||
if !uncachedEmoji.isEmpty {
|
||||
Logger.info("Checking emoji availability for \(uncachedEmoji.count) uncached emoji")
|
||||
uncachedEmoji.forEach {
|
||||
let available = isEmojiAvailable($0)
|
||||
availableMap[$0.rawValue] = available
|
||||
availableCache[$0] = available
|
||||
}
|
||||
|
||||
availableMap[iosVersionKey] = iosVersion
|
||||
do {
|
||||
// Use FileManager.createDirectory directly because OWSFileSystem.ensureDirectoryExists
|
||||
// can modify the protection, and this is a system-managed directory.
|
||||
try FileManager.default.createDirectory(at: Self.cacheUrl.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try availableMap.write(to: Self.cacheUrl)
|
||||
} catch {
|
||||
Logger.warn("Failed to save emoji availability cache; it will be recomputed next time! \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info("Warmed emoji availability cache with \(availableCache.lazy.filter { $0.value }.count) available emoji for iOS \(iosVersion)")
|
||||
|
||||
Self.availableCache.mutate{ $0 = availableCache }
|
||||
}
|
||||
|
||||
private static func isEmojiAvailable(_ emoji: Emoji) -> Bool {
|
||||
return emoji.rawValue.isUnicodeStringAvailable
|
||||
}
|
||||
|
||||
/// Indicates whether the given emoji is available on this iOS
|
||||
/// version. We cache the availability in memory.
|
||||
var available: Bool {
|
||||
guard let available = Self.availableCache.wrappedValue[self] else {
|
||||
let available = Self.isEmojiAvailable(self)
|
||||
Self.availableCache.mutate{ $0[self] = available }
|
||||
return available
|
||||
}
|
||||
return available
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
/// A known undefined unicode character for comparison
|
||||
private static let unknownUnicodeStringPng = "\u{1fff}".unicodeStringPngRepresentation
|
||||
|
||||
// Based on https://stackoverflow.com/a/41393387
|
||||
// Check if an emoji is available on the current iOS version
|
||||
// by verifying its image is different than the "unknown"
|
||||
// reference image
|
||||
var isUnicodeStringAvailable: Bool {
|
||||
guard self.isSingleEmoji else { return false }
|
||||
return String.unknownUnicodeStringPng != unicodeStringPngRepresentation
|
||||
}
|
||||
|
||||
var unicodeStringPngRepresentation: Data? {
|
||||
let attributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 8)]
|
||||
let size = (self as NSString).size(withAttributes: attributes)
|
||||
|
||||
UIGraphicsBeginImageContext(size)
|
||||
defer { UIGraphicsEndImageContext() }
|
||||
(self as NSString).draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
|
||||
|
||||
guard let unicodeImage = UIGraphicsGetImageFromCurrentImageContext() else { return nil }
|
||||
return unicodeImage.pngData()
|
||||
}
|
||||
}
|
3776
Session/Emoji/Emoji+Category.swift
Normal file
3776
Session/Emoji/Emoji+Category.swift
Normal file
File diff suppressed because it is too large
Load diff
1863
Session/Emoji/Emoji+Name.swift
Normal file
1863
Session/Emoji/Emoji+Name.swift
Normal file
File diff suppressed because it is too large
Load diff
2724
Session/Emoji/Emoji+SkinTones.swift
Normal file
2724
Session/Emoji/Emoji+SkinTones.swift
Normal file
File diff suppressed because it is too large
Load diff
1863
Session/Emoji/Emoji.swift
Normal file
1863
Session/Emoji/Emoji.swift
Normal file
File diff suppressed because it is too large
Load diff
7269
Session/Emoji/EmojiWithSkinTones+String.swift
Normal file
7269
Session/Emoji/EmojiWithSkinTones+String.swift
Normal file
File diff suppressed because it is too large
Load diff
138
Session/Emoji/EmojiWithSkinTones.swift
Normal file
138
Session/Emoji/EmojiWithSkinTones.swift
Normal file
|
@ -0,0 +1,138 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionMessagingKit
|
||||
|
||||
public struct EmojiWithSkinTones: Hashable, Equatable, ContentEquatable, ContentIdentifiable {
|
||||
let baseEmoji: Emoji?
|
||||
let skinTones: [Emoji.SkinTone]?
|
||||
let unsupportedValue: String?
|
||||
|
||||
init(baseEmoji: Emoji, skinTones: [Emoji.SkinTone]? = nil) {
|
||||
self.baseEmoji = baseEmoji
|
||||
|
||||
// Deduplicate skin tones, while preserving order. This allows for
|
||||
// multi-skin tone emoji, where if you have for example the permutation
|
||||
// [.dark, .dark], it is consolidated to just [.dark], to be initialized
|
||||
// with either variant and result in the correct emoji.
|
||||
self.skinTones = skinTones?.reduce(into: [Emoji.SkinTone]()) { result, skinTone in
|
||||
guard !result.contains(skinTone) else { return }
|
||||
result.append(skinTone)
|
||||
}
|
||||
self.unsupportedValue = nil
|
||||
}
|
||||
|
||||
init(unsupportedValue: String) {
|
||||
self.unsupportedValue = unsupportedValue
|
||||
self.baseEmoji = nil
|
||||
self.skinTones = nil
|
||||
}
|
||||
|
||||
var rawValue: String {
|
||||
if let baseEmoji = baseEmoji {
|
||||
if let skinTones = skinTones {
|
||||
return baseEmoji.emojiPerSkinTonePermutation?[skinTones] ?? baseEmoji.rawValue
|
||||
} else {
|
||||
return baseEmoji.rawValue
|
||||
}
|
||||
}
|
||||
if let unsupportedValue = unsupportedValue {
|
||||
return unsupportedValue
|
||||
}
|
||||
return "" // Should not happen
|
||||
}
|
||||
|
||||
var normalized: EmojiWithSkinTones {
|
||||
if let baseEmoji = baseEmoji, baseEmoji.normalized != baseEmoji {
|
||||
return EmojiWithSkinTones(baseEmoji: baseEmoji.normalized)
|
||||
}
|
||||
return self
|
||||
}
|
||||
|
||||
var isNormalized: Bool { self == normalized }
|
||||
}
|
||||
|
||||
extension Emoji {
|
||||
static func getRecent(_ db: Database, withDefaultEmoji: Bool) throws -> [String] {
|
||||
let recentReactionEmoji: [String] = (db[.recentReactionEmoji]?
|
||||
.components(separatedBy: ","))
|
||||
.defaulting(to: [])
|
||||
|
||||
// No need to continue if we don't want the default emoji to pad out the list
|
||||
guard withDefaultEmoji else { return recentReactionEmoji }
|
||||
|
||||
// Add in our default emoji if desired
|
||||
let defaultEmoji = ["😂", "🥰", "😢", "😡", "😮", "😈"]
|
||||
.filter { !recentReactionEmoji.contains($0) }
|
||||
|
||||
return Array(recentReactionEmoji
|
||||
.appending(contentsOf: defaultEmoji)
|
||||
.prefix(6))
|
||||
}
|
||||
|
||||
static func addRecent(_ db: Database, emoji: String) {
|
||||
// Add/move the emoji to the start of the most recent list
|
||||
db[.recentReactionEmoji] = (db[.recentReactionEmoji]?
|
||||
.components(separatedBy: ","))
|
||||
.defaulting(to: [])
|
||||
.filter { $0 != emoji }
|
||||
.inserting(emoji, at: 0)
|
||||
.prefix(6)
|
||||
.joined(separator: ",")
|
||||
}
|
||||
|
||||
static func allSendableEmojiByCategoryWithPreferredSkinTones(_ db: Database) -> [Category: [EmojiWithSkinTones]] {
|
||||
return Category.allCases
|
||||
.reduce(into: [Category: [EmojiWithSkinTones]]()) { result, category in
|
||||
result[category] = category.normalizedEmoji
|
||||
.filter { $0.available }
|
||||
.map { $0.withPreferredSkinTones(db) }
|
||||
}
|
||||
}
|
||||
|
||||
private func withPreferredSkinTones(_ db: Database) -> EmojiWithSkinTones {
|
||||
guard let rawSkinTones: String = db[.emojiPreferredSkinTones(emoji: rawValue)] else {
|
||||
return EmojiWithSkinTones(baseEmoji: self, skinTones: nil)
|
||||
}
|
||||
|
||||
return EmojiWithSkinTones(
|
||||
baseEmoji: self,
|
||||
skinTones: rawSkinTones
|
||||
.split(separator: ",")
|
||||
.compactMap { SkinTone(rawValue: String($0)) }
|
||||
)
|
||||
}
|
||||
|
||||
func setPreferredSkinTones(_ db: Database, preferredSkinTonePermutation: [SkinTone]?) {
|
||||
db[.emojiPreferredSkinTones(emoji: rawValue)] = preferredSkinTonePermutation
|
||||
.map { preferredSkinTonePermutation in
|
||||
preferredSkinTonePermutation
|
||||
.map { $0.rawValue }
|
||||
.joined(separator: ",")
|
||||
}
|
||||
}
|
||||
|
||||
init?(_ string: String) {
|
||||
guard let emojiWithSkinTonePermutation = EmojiWithSkinTones(rawValue: string) else { return nil }
|
||||
if let baseEmoji = emojiWithSkinTonePermutation.baseEmoji {
|
||||
self = baseEmoji
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
extension String {
|
||||
// This is slightly more accurate than String.isSingleEmoji,
|
||||
// but slower.
|
||||
//
|
||||
// * This will reject "lone modifiers".
|
||||
// * This will reject certain edge cases such as 🌈️.
|
||||
var isSingleEmojiUsingEmojiWithSkinTones: Bool {
|
||||
EmojiWithSkinTones(rawValue: self) != nil
|
||||
}
|
||||
}
|
|
@ -214,9 +214,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
}
|
||||
|
||||
// Onion request path countries cache
|
||||
DispatchQueue.global(qos: .utility).sync {
|
||||
let _ = IP2Country.shared.populateCacheIfNeeded()
|
||||
}
|
||||
IP2Country.shared.populateCacheIfNeededAsync()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
|
|
|
@ -43,8 +43,7 @@ public class HomeViewModel {
|
|||
// MARK: - Initialization
|
||||
|
||||
init() {
|
||||
self.state = Storage.shared.read { db in try HomeViewModel.retrieveState(db) }
|
||||
.defaulting(to: State())
|
||||
self.state = State()
|
||||
self.pagedDataObserver = nil
|
||||
|
||||
// Note: Since this references self we need to finish initializing before setting it, we
|
||||
|
@ -135,18 +134,18 @@ public class HomeViewModel {
|
|||
joinToPagedType: {
|
||||
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
|
||||
|
||||
return SQL("LEFT JOIN \(typingIndicator[.threadId]) = \(thread[.id])")
|
||||
return SQL("LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])")
|
||||
}()
|
||||
)
|
||||
],
|
||||
/// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query
|
||||
/// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query but differs
|
||||
/// from the JOINs that are actually used for performance reasons as the basic logic can be simpler for where it's used
|
||||
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
|
||||
),
|
||||
|
@ -194,8 +193,9 @@ public class HomeViewModel {
|
|||
let hasHiddenMessageRequests: Bool = db[.hasHiddenMessageRequests]
|
||||
let userProfile: Profile = Profile.fetchOrCreateCurrentUser(db)
|
||||
let unreadMessageRequestThreadCount: Int = try SessionThread
|
||||
.unreadMessageRequestsThreadIdQuery(userPublicKey: userProfile.id)
|
||||
.fetchCount(db)
|
||||
.unreadMessageRequestsCountQuery(userPublicKey: userProfile.id)
|
||||
.fetchOne(db)
|
||||
.defaulting(to: 0)
|
||||
|
||||
return State(
|
||||
showViewedSeedBanner: !hasViewedSeed,
|
||||
|
@ -219,7 +219,8 @@ public class HomeViewModel {
|
|||
else { return }
|
||||
|
||||
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
|
||||
let currentData: [SessionThreadViewModel] = self.threadData.flatMap { $0.elements }
|
||||
let currentData: [SessionThreadViewModel] = (self.unobservedThreadDataChanges ?? self.threadData)
|
||||
.flatMap { $0.elements }
|
||||
let updatedThreadData: [SectionModel] = self.process(data: currentData, for: currentPageInfo)
|
||||
|
||||
guard let onThreadChange: (([SectionModel]) -> ()) = self.onThreadChange else {
|
||||
|
|
|
@ -86,14 +86,14 @@ public class MessageRequestsViewModel {
|
|||
}()
|
||||
)
|
||||
],
|
||||
/// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query
|
||||
/// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query but differs
|
||||
/// from the JOINs that are actually used for performance reasons as the basic logic can be simpler for where it's used
|
||||
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
|
||||
),
|
||||
|
|
|
@ -388,7 +388,7 @@ public class MediaGalleryViewModel {
|
|||
.removeDuplicates()
|
||||
}
|
||||
|
||||
@discardableResult public func loadAndCacheAlbumData(for interactionId: Int64) -> [Item] {
|
||||
@discardableResult public func loadAndCacheAlbumData(for interactionId: Int64, in threadId: String) -> [Item] {
|
||||
typealias AlbumInfo = (albumData: [Item], interactionIdBefore: Int64?, interactionIdAfter: Int64?)
|
||||
|
||||
// Note: It's possible we already have cached album data for this interaction
|
||||
|
@ -415,13 +415,19 @@ public class MediaGalleryViewModel {
|
|||
let itemBefore: Item? = try Item
|
||||
.baseQuery(
|
||||
orderSQL: Item.galleryReverseOrderSQL,
|
||||
customFilters: SQL("\(interaction[.timestampMs]) > \(albumTimestampMs)")
|
||||
customFilters: SQL("""
|
||||
\(interaction[.timestampMs]) > \(albumTimestampMs) AND
|
||||
\(interaction[.threadId]) = \(threadId)
|
||||
""")
|
||||
)
|
||||
.fetchOne(db)
|
||||
let itemAfter: Item? = try Item
|
||||
.baseQuery(
|
||||
orderSQL: Item.galleryOrderSQL,
|
||||
customFilters: SQL("\(interaction[.timestampMs]) < \(albumTimestampMs)")
|
||||
customFilters: SQL("""
|
||||
\(interaction[.timestampMs]) < \(albumTimestampMs) AND
|
||||
\(interaction[.threadId]) = \(threadId)
|
||||
""")
|
||||
)
|
||||
.fetchOne(db)
|
||||
|
||||
|
@ -527,7 +533,7 @@ public class MediaGalleryViewModel {
|
|||
isPagedData: false,
|
||||
mediaType: .media
|
||||
)
|
||||
viewModel.loadAndCacheAlbumData(for: interactionId)
|
||||
viewModel.loadAndCacheAlbumData(for: interactionId, in: threadId)
|
||||
viewModel.replaceAlbumObservation(toObservationFor: interactionId)
|
||||
|
||||
guard
|
||||
|
|
|
@ -681,10 +681,15 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
}
|
||||
|
||||
// Then check if there is an interaction before the current album interaction
|
||||
guard let interactionIdAfter: Int64 = self.viewModel.interactionIdAfter[interactionId] else { return nil }
|
||||
guard let interactionIdAfter: Int64 = self.viewModel.interactionIdAfter[interactionId] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cache and retrieve the new album items
|
||||
let newAlbumItems: [MediaGalleryViewModel.Item] = viewModel.loadAndCacheAlbumData(for: interactionIdAfter)
|
||||
let newAlbumItems: [MediaGalleryViewModel.Item] = viewModel.loadAndCacheAlbumData(
|
||||
for: interactionIdAfter,
|
||||
in: self.viewModel.threadId
|
||||
)
|
||||
|
||||
guard
|
||||
!newAlbumItems.isEmpty,
|
||||
|
@ -723,10 +728,15 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
}
|
||||
|
||||
// Then check if there is an interaction before the current album interaction
|
||||
guard let interactionIdBefore: Int64 = self.viewModel.interactionIdBefore[interactionId] else { return nil }
|
||||
guard let interactionIdBefore: Int64 = self.viewModel.interactionIdBefore[interactionId] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cache and retrieve the new album items
|
||||
let newAlbumItems: [MediaGalleryViewModel.Item] = viewModel.loadAndCacheAlbumData(for: interactionIdBefore)
|
||||
let newAlbumItems: [MediaGalleryViewModel.Item] = viewModel.loadAndCacheAlbumData(
|
||||
for: interactionIdBefore,
|
||||
in: self.viewModel.threadId
|
||||
)
|
||||
|
||||
guard
|
||||
!newAlbumItems.isEmpty,
|
||||
|
|
|
@ -133,10 +133,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
// NOTE: Fix an edge case where user taps on the callkit notification
|
||||
// but answers the call on another device
|
||||
stopPollers(shouldStopUserPoller: !self.hasIncomingCallWaiting())
|
||||
JobRunner.stopAndClearPendingJobs()
|
||||
|
||||
// Suspend database
|
||||
NotificationCenter.default.post(name: Database.suspendNotification, object: self)
|
||||
// Stop all jobs except for message sending and when completed suspend the database
|
||||
JobRunner.stopAndClearPendingJobs(exceptForVariant: .messageSend) {
|
||||
NotificationCenter.default.post(name: Database.suspendNotification, object: self)
|
||||
}
|
||||
}
|
||||
|
||||
func applicationDidReceiveMemoryWarning(_ application: UIApplication) {
|
||||
|
|
|
@ -44,7 +44,6 @@
|
|||
#import <SessionUtilitiesKit/NSData+Image.h>
|
||||
#import <SessionUtilitiesKit/NSNotificationCenter+OWS.h>
|
||||
#import <SessionUtilitiesKit/NSString+SSK.h>
|
||||
#import <SessionMessagingKit/OWSBackgroundTask.h>
|
||||
#import <SignalUtilitiesKit/OWSDispatch.h>
|
||||
#import <SignalUtilitiesKit/OWSError.h>
|
||||
#import <SessionUtilitiesKit/OWSFileSystem.h>
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -689,3 +689,24 @@
|
|||
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
|
||||
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
|
||||
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
|
||||
/* The name for the emoji category 'Activities' */
|
||||
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
|
||||
/* The name for the emoji category 'Animals & Nature' */
|
||||
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
|
||||
/* The name for the emoji category 'Flags' */
|
||||
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
|
||||
/* The name for the emoji category 'Food & Drink' */
|
||||
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
|
||||
/* The name for the emoji category 'Objects' */
|
||||
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
|
||||
/* The name for the emoji category 'Recents' */
|
||||
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
|
||||
/* The name for the emoji category 'Smileys & People' */
|
||||
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
|
||||
/* The name for the emoji category 'Symbols' */
|
||||
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
|
||||
/* The name for the emoji category 'Travel & Places' */
|
||||
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
|
||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
|
||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||
|
|
|
@ -82,10 +82,6 @@ extension AppNotificationAction {
|
|||
}
|
||||
}
|
||||
|
||||
// Delay notification of incoming messages when it's a background polling to
|
||||
// avoid too many notifications fired at the same time
|
||||
let kNotificationDelayForBackgroumdPoll: TimeInterval = 5
|
||||
|
||||
let kAudioNotificationsThrottleCount = 2
|
||||
let kAudioNotificationsThrottleInterval: TimeInterval = 5
|
||||
|
||||
|
@ -93,14 +89,48 @@ protocol NotificationPresenterAdaptee: AnyObject {
|
|||
|
||||
func registerNotificationSettings() -> Promise<Void>
|
||||
|
||||
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?)
|
||||
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?, replacingIdentifier: String?)
|
||||
func notify(
|
||||
category: AppNotificationCategory,
|
||||
title: String?,
|
||||
body: String,
|
||||
userInfo: [AnyHashable: Any],
|
||||
previewType: Preferences.NotificationPreviewType,
|
||||
sound: Preferences.Sound?,
|
||||
threadVariant: SessionThread.Variant,
|
||||
threadName: String,
|
||||
replacingIdentifier: String?
|
||||
)
|
||||
|
||||
func cancelNotifications(threadId: String)
|
||||
func cancelNotifications(identifiers: [String])
|
||||
func clearAllNotifications()
|
||||
}
|
||||
|
||||
extension NotificationPresenterAdaptee {
|
||||
func notify(
|
||||
category: AppNotificationCategory,
|
||||
title: String?,
|
||||
body: String,
|
||||
userInfo: [AnyHashable: Any],
|
||||
previewType: Preferences.NotificationPreviewType,
|
||||
sound: Preferences.Sound?,
|
||||
threadVariant: SessionThread.Variant,
|
||||
threadName: String
|
||||
) {
|
||||
notify(
|
||||
category: category,
|
||||
title: title,
|
||||
body: body,
|
||||
userInfo: userInfo,
|
||||
previewType: previewType,
|
||||
sound: sound,
|
||||
threadVariant: threadVariant,
|
||||
threadName: threadName,
|
||||
replacingIdentifier: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@objc(OWSNotificationPresenter)
|
||||
public class NotificationPresenter: NSObject, NotificationsProtocol {
|
||||
|
||||
|
@ -141,7 +171,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
return adaptee.registerNotificationSettings()
|
||||
}
|
||||
|
||||
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) {
|
||||
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) {
|
||||
let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
|
||||
|
||||
// Ensure we should be showing a notification for the thread
|
||||
|
@ -149,7 +179,10 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
return
|
||||
}
|
||||
|
||||
let identifier: String = interaction.notificationIdentifier(isBackgroundPoll: isBackgroundPoll)
|
||||
// Try to group notifications for interactions from open groups
|
||||
let identifier: String = interaction.notificationIdentifier(
|
||||
shouldGroupMessagesForThread: (thread.variant == .openGroup)
|
||||
)
|
||||
|
||||
// While batch processing, some of the necessary changes have not been commited.
|
||||
let rawMessageText = interaction.previewText(db)
|
||||
|
@ -166,6 +199,18 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
let senderName = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant)
|
||||
let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType]
|
||||
.defaulting(to: .nameAndPreview)
|
||||
let groupName: String = SessionThread.displayName(
|
||||
threadId: thread.id,
|
||||
variant: thread.variant,
|
||||
closedGroupName: try? thread.closedGroup
|
||||
.select(.name)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db),
|
||||
openGroupName: try? thread.openGroup
|
||||
.select(.name)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db)
|
||||
)
|
||||
|
||||
switch previewType {
|
||||
case .noNameNoPreview:
|
||||
|
@ -177,26 +222,10 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
notificationTitle = (isMessageRequest ? "Session" : senderName)
|
||||
|
||||
case .closedGroup, .openGroup:
|
||||
let groupName: String = SessionThread
|
||||
.displayName(
|
||||
threadId: thread.id,
|
||||
variant: thread.variant,
|
||||
closedGroupName: try? thread.closedGroup
|
||||
.select(.name)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db),
|
||||
openGroupName: try? thread.openGroup
|
||||
.select(.name)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db)
|
||||
)
|
||||
|
||||
notificationTitle = (isBackgroundPoll ? groupName :
|
||||
String(
|
||||
format: NotificationStrings.incomingGroupMessageTitleFormat,
|
||||
senderName,
|
||||
groupName
|
||||
)
|
||||
notificationTitle = String(
|
||||
format: NotificationStrings.incomingGroupMessageTitleFormat,
|
||||
senderName,
|
||||
groupName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -230,22 +259,31 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
threadId: thread.id,
|
||||
threadVariant: thread.variant
|
||||
)
|
||||
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
||||
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let sound: Preferences.Sound? = self.requestSound(
|
||||
thread: thread,
|
||||
fallbackSound: fallbackSound
|
||||
)
|
||||
|
||||
notificationBody = MentionUtilities.highlightMentions(
|
||||
in: (notificationBody ?? ""),
|
||||
threadVariant: thread.variant,
|
||||
currentUserPublicKey: userPublicKey,
|
||||
currentUserBlindedPublicKey: userBlindedKey
|
||||
)
|
||||
let sound: Preferences.Sound? = self.requestSound(thread: thread)
|
||||
|
||||
self.adaptee.notify(
|
||||
category: category,
|
||||
title: notificationTitle,
|
||||
body: notificationBody ?? "",
|
||||
body: (notificationBody ?? ""),
|
||||
userInfo: userInfo,
|
||||
previewType: previewType,
|
||||
sound: sound,
|
||||
threadVariant: thread.variant,
|
||||
threadName: groupName,
|
||||
replacingIdentifier: identifier
|
||||
)
|
||||
}
|
||||
|
@ -268,35 +306,102 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
guard messageInfo.state == .missed || messageInfo.state == .permissionDenied else { return }
|
||||
|
||||
let category = AppNotificationCategory.errorMessage
|
||||
|
||||
let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType]
|
||||
.defaulting(to: .nameAndPreview)
|
||||
|
||||
let userInfo = [
|
||||
AppNotificationUserInfoKey.threadId: thread.id
|
||||
]
|
||||
|
||||
let notificationTitle = interaction.previewText(db)
|
||||
let notificationTitle: String = interaction.previewText(db)
|
||||
let threadName: String = SessionThread.displayName(
|
||||
threadId: thread.id,
|
||||
variant: thread.variant,
|
||||
closedGroupName: nil, // Not supported
|
||||
openGroupName: nil // Not supported
|
||||
)
|
||||
var notificationBody: String?
|
||||
|
||||
if messageInfo.state == .permissionDenied {
|
||||
notificationBody = String(
|
||||
format: "modal_call_missed_tips_explanation".localized(),
|
||||
SessionThread.displayName(
|
||||
threadId: thread.id,
|
||||
variant: thread.variant,
|
||||
closedGroupName: nil, // Not supported
|
||||
openGroupName: nil // Not supported
|
||||
)
|
||||
threadName
|
||||
)
|
||||
}
|
||||
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
||||
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let sound = self.requestSound(thread: thread)
|
||||
let sound = self.requestSound(
|
||||
thread: thread,
|
||||
fallbackSound: fallbackSound
|
||||
)
|
||||
|
||||
self.adaptee.notify(
|
||||
category: category,
|
||||
title: notificationTitle,
|
||||
body: notificationBody ?? "",
|
||||
body: (notificationBody ?? ""),
|
||||
userInfo: userInfo,
|
||||
previewType: previewType,
|
||||
sound: sound,
|
||||
threadVariant: thread.variant,
|
||||
threadName: threadName,
|
||||
replacingIdentifier: UUID().uuidString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread) {
|
||||
let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true)
|
||||
|
||||
// No reaction notifications for muted, group threads or message requests
|
||||
guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return }
|
||||
guard thread.variant != .closedGroup && thread.variant != .openGroup else { return }
|
||||
guard !isMessageRequest else { return }
|
||||
|
||||
let senderName: String = Profile.displayName(db, id: reaction.authorId, threadVariant: thread.variant)
|
||||
let notificationTitle = "Session"
|
||||
var notificationBody = String(format: "EMOJI_REACTS_NOTIFICATION".localized(), senderName, reaction.emoji)
|
||||
|
||||
// Title & body
|
||||
let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType]
|
||||
.defaulting(to: .nameAndPreview)
|
||||
|
||||
switch previewType {
|
||||
case .nameAndPreview: break
|
||||
default: notificationBody = NotificationStrings.incomingMessageBody
|
||||
}
|
||||
|
||||
let category = AppNotificationCategory.incomingMessage
|
||||
|
||||
let userInfo = [
|
||||
AppNotificationUserInfoKey.threadId: thread.id
|
||||
]
|
||||
|
||||
let threadName: String = SessionThread.displayName(
|
||||
threadId: thread.id,
|
||||
variant: thread.variant,
|
||||
closedGroupName: nil, // Not supported
|
||||
openGroupName: nil // Not supported
|
||||
)
|
||||
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
||||
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let sound = self.requestSound(
|
||||
thread: thread,
|
||||
fallbackSound: fallbackSound
|
||||
)
|
||||
|
||||
self.adaptee.notify(
|
||||
category: category,
|
||||
title: notificationTitle,
|
||||
body: notificationBody,
|
||||
userInfo: userInfo,
|
||||
previewType: previewType,
|
||||
sound: sound,
|
||||
threadVariant: thread.variant,
|
||||
threadName: threadName,
|
||||
replacingIdentifier: UUID().uuidString
|
||||
)
|
||||
}
|
||||
|
@ -306,24 +411,24 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
let notificationTitle: String?
|
||||
let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType]
|
||||
.defaulting(to: .nameAndPreview)
|
||||
let threadName: String = SessionThread.displayName(
|
||||
threadId: thread.id,
|
||||
variant: thread.variant,
|
||||
closedGroupName: try? thread.closedGroup
|
||||
.select(.name)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db),
|
||||
openGroupName: try? thread.openGroup
|
||||
.select(.name)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db),
|
||||
isNoteToSelf: (thread.isNoteToSelf(db) == true),
|
||||
profile: try? Profile.fetchOne(db, id: thread.id)
|
||||
)
|
||||
|
||||
switch previewType {
|
||||
case .noNameNoPreview: notificationTitle = nil
|
||||
case .nameNoPreview, .nameAndPreview:
|
||||
notificationTitle = SessionThread.displayName(
|
||||
threadId: thread.id,
|
||||
variant: thread.variant,
|
||||
closedGroupName: try? thread.closedGroup
|
||||
.select(.name)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db),
|
||||
openGroupName: try? thread.openGroup
|
||||
.select(.name)
|
||||
.asRequest(of: String.self)
|
||||
.fetchOne(db),
|
||||
isNoteToSelf: (thread.isNoteToSelf(db) == true),
|
||||
profile: try? Profile.fetchOne(db, id: thread.id)
|
||||
)
|
||||
case .nameNoPreview, .nameAndPreview: notificationTitle = threadName
|
||||
}
|
||||
|
||||
let notificationBody = NotificationStrings.failedToSendBody
|
||||
|
@ -331,16 +436,24 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
let userInfo = [
|
||||
AppNotificationUserInfoKey.threadId: thread.id
|
||||
]
|
||||
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
||||
.defaulting(to: Preferences.Sound.defaultNotificationSound)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let sound: Preferences.Sound? = self.requestSound(thread: thread)
|
||||
let sound: Preferences.Sound? = self.requestSound(
|
||||
thread: thread,
|
||||
fallbackSound: fallbackSound
|
||||
)
|
||||
|
||||
self.adaptee.notify(
|
||||
category: .errorMessage,
|
||||
title: notificationTitle,
|
||||
body: notificationBody,
|
||||
userInfo: userInfo,
|
||||
sound: sound
|
||||
previewType: previewType,
|
||||
sound: sound,
|
||||
threadVariant: thread.variant,
|
||||
threadName: threadName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -366,12 +479,12 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
|
|||
|
||||
var mostRecentNotifications = TruncatedList<UInt64>(maxLength: kAudioNotificationsThrottleCount)
|
||||
|
||||
private func requestSound(thread: SessionThread) -> Preferences.Sound? {
|
||||
private func requestSound(thread: SessionThread, fallbackSound: Preferences.Sound) -> Preferences.Sound? {
|
||||
guard checkIfShouldPlaySound() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return thread.notificationSound
|
||||
|
||||
return (thread.notificationSound ?? fallbackSound)
|
||||
}
|
||||
|
||||
private func checkIfShouldPlaySound() -> Bool {
|
||||
|
|
|
@ -57,8 +57,9 @@ class UserNotificationPresenterAdaptee: NSObject, UNUserNotificationCenterDelega
|
|||
|
||||
override init() {
|
||||
self.notificationCenter = UNUserNotificationCenter.current()
|
||||
|
||||
super.init()
|
||||
notificationCenter.delegate = self
|
||||
|
||||
SwiftSingletons.register(self)
|
||||
}
|
||||
}
|
||||
|
@ -86,29 +87,37 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee {
|
|||
}
|
||||
}
|
||||
|
||||
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?) {
|
||||
AssertIsOnMainThread()
|
||||
notify(category: category, title: title, body: body, userInfo: userInfo, sound: sound, replacingIdentifier: nil)
|
||||
}
|
||||
|
||||
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: Preferences.Sound?, replacingIdentifier: String?) {
|
||||
func notify(
|
||||
category: AppNotificationCategory,
|
||||
title: String?,
|
||||
body: String,
|
||||
userInfo: [AnyHashable: Any],
|
||||
previewType: Preferences.NotificationPreviewType,
|
||||
sound: Preferences.Sound?,
|
||||
threadVariant: SessionThread.Variant,
|
||||
threadName: String,
|
||||
replacingIdentifier: String?
|
||||
) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
let threadIdentifier: String? = (userInfo[AppNotificationUserInfoKey.threadId] as? String)
|
||||
let content = UNMutableNotificationContent()
|
||||
content.categoryIdentifier = category.identifier
|
||||
content.userInfo = userInfo
|
||||
let isReplacingNotification = replacingIdentifier != nil
|
||||
var isBackgroudPoll = false
|
||||
if let threadIdentifier = userInfo[AppNotificationUserInfoKey.threadId] as? String {
|
||||
content.threadIdentifier = threadIdentifier
|
||||
isBackgroudPoll = replacingIdentifier == threadIdentifier
|
||||
}
|
||||
content.threadIdentifier = (threadIdentifier ?? content.threadIdentifier)
|
||||
|
||||
let shouldGroupNotification: Bool = (
|
||||
threadVariant == .openGroup &&
|
||||
replacingIdentifier == threadIdentifier
|
||||
)
|
||||
let isAppActive = UIApplication.shared.applicationState == .active
|
||||
if let sound = sound, sound != .none {
|
||||
content.sound = sound.notificationSound(isQuiet: isAppActive)
|
||||
}
|
||||
|
||||
let notificationIdentifier = isReplacingNotification ? replacingIdentifier! : UUID().uuidString
|
||||
let notificationIdentifier: String = (replacingIdentifier ?? UUID().uuidString)
|
||||
let isReplacingNotification: Bool = (notifications[notificationIdentifier] != nil)
|
||||
var trigger: UNNotificationTrigger?
|
||||
|
||||
if shouldPresentNotification(category: category, userInfo: userInfo) {
|
||||
if let displayableTitle = title?.filterForDisplay {
|
||||
|
@ -117,30 +126,50 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee {
|
|||
if let displayableBody = body.filterForDisplay {
|
||||
content.body = displayableBody
|
||||
}
|
||||
} else {
|
||||
|
||||
if shouldGroupNotification {
|
||||
trigger = UNTimeIntervalNotificationTrigger(
|
||||
timeInterval: Notifications.delayForGroupedNotifications,
|
||||
repeats: false
|
||||
)
|
||||
|
||||
let numberExistingNotifications: Int? = notifications[notificationIdentifier]?
|
||||
.content
|
||||
.userInfo[AppNotificationUserInfoKey.threadNotificationCounter]
|
||||
.asType(Int.self)
|
||||
var numberOfNotifications: Int = (numberExistingNotifications ?? 1)
|
||||
|
||||
if numberExistingNotifications != nil {
|
||||
numberOfNotifications += 1 // Add one for the current notification
|
||||
|
||||
content.title = (previewType == .noNameNoPreview ?
|
||||
content.title :
|
||||
threadName
|
||||
)
|
||||
content.body = String(
|
||||
format: NotificationStrings.incomingCollapsedMessagesBody,
|
||||
"\(numberOfNotifications)"
|
||||
)
|
||||
}
|
||||
|
||||
content.userInfo[AppNotificationUserInfoKey.threadNotificationCounter] = numberOfNotifications
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Play sound and vibrate, but without a `body` no banner will show.
|
||||
Logger.debug("supressing notification body")
|
||||
}
|
||||
|
||||
let trigger: UNNotificationTrigger?
|
||||
if isBackgroudPoll {
|
||||
trigger = UNTimeIntervalNotificationTrigger(timeInterval: kNotificationDelayForBackgroumdPoll, repeats: false)
|
||||
let numberOfNotifications: Int
|
||||
if let lastRequest = notifications[notificationIdentifier], let counter = lastRequest.content.userInfo[AppNotificationUserInfoKey.threadNotificationCounter] as? Int {
|
||||
numberOfNotifications = counter + 1
|
||||
content.body = String(format: NotificationStrings.incomingCollapsedMessagesBody, "\(numberOfNotifications)")
|
||||
} else {
|
||||
numberOfNotifications = 1
|
||||
}
|
||||
content.userInfo[AppNotificationUserInfoKey.threadNotificationCounter] = numberOfNotifications
|
||||
} else {
|
||||
trigger = nil
|
||||
}
|
||||
|
||||
let request = UNNotificationRequest(identifier: notificationIdentifier, content: content, trigger: trigger)
|
||||
let request = UNNotificationRequest(
|
||||
identifier: notificationIdentifier,
|
||||
content: content,
|
||||
trigger: trigger
|
||||
)
|
||||
|
||||
Logger.debug("presenting notification with identifier: \(notificationIdentifier)")
|
||||
|
||||
if isReplacingNotification { cancelNotifications(identifiers: [notificationIdentifier]) }
|
||||
|
||||
notificationCenter.add(request)
|
||||
notifications[notificationIdentifier] = request
|
||||
}
|
||||
|
@ -196,7 +225,7 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee {
|
|||
guard let conversationViewController = UIApplication.shared.frontmostViewController as? ConversationVC else {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/// Show notifications for any **other** threads
|
||||
return (conversationViewController.viewModel.threadData.threadId != notificationThreadId)
|
||||
}
|
||||
|
|
|
@ -65,7 +65,7 @@ final class LandingVC: BaseVC {
|
|||
linkButtonContainer.set(.height, to: Values.onboardingButtonBottomOffset)
|
||||
linkButtonContainer.addSubview(linkButton)
|
||||
linkButton.center(.horizontal, in: linkButtonContainer)
|
||||
let isIPhoneX = (UIApplication.shared.keyWindow!.safeAreaInsets.bottom > 0)
|
||||
let isIPhoneX = ((UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0) > 0)
|
||||
linkButton.centerYAnchor.constraint(equalTo: linkButtonContainer.centerYAnchor, constant: isIPhoneX ? -4 : 0).isActive = true
|
||||
// Button stack view
|
||||
let buttonStackView = UIStackView(arrangedSubviews: [ registerButton, restoreButton ])
|
||||
|
|
|
@ -164,12 +164,14 @@ public final class FullConversationCell: UITableViewCell {
|
|||
|
||||
// Unread count view
|
||||
unreadCountView.addSubview(unreadCountLabel)
|
||||
unreadCountLabel.setCompressionResistanceHigh()
|
||||
unreadCountLabel.pin([ VerticalEdge.top, VerticalEdge.bottom ], to: unreadCountView)
|
||||
unreadCountView.pin(.leading, to: .leading, of: unreadCountLabel, withInset: -4)
|
||||
unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4)
|
||||
|
||||
// Has mention view
|
||||
hasMentionView.addSubview(hasMentionLabel)
|
||||
hasMentionLabel.setCompressionResistanceHigh()
|
||||
hasMentionLabel.pin(to: hasMentionView)
|
||||
|
||||
// Label stack view
|
||||
|
|
|
@ -11,6 +11,7 @@ final class UserCell: UITableViewCell {
|
|||
case none
|
||||
case lock
|
||||
case tick(isSelected: Bool)
|
||||
case x
|
||||
}
|
||||
|
||||
// MARK: - Components
|
||||
|
@ -101,13 +102,14 @@ final class UserCell: UITableViewCell {
|
|||
to: contentView
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Updating
|
||||
|
||||
func update(
|
||||
with publicKey: String,
|
||||
profile: Profile?,
|
||||
isZombie: Bool,
|
||||
mediumFont: Bool = false,
|
||||
accessory: Accessory
|
||||
) {
|
||||
profilePictureView.update(
|
||||
|
@ -116,11 +118,19 @@ final class UserCell: UITableViewCell {
|
|||
threadVariant: .contact
|
||||
)
|
||||
|
||||
displayNameLabel.text = Profile.displayName(
|
||||
for: .contact,
|
||||
id: publicKey,
|
||||
name: profile?.name,
|
||||
nickname: profile?.nickname
|
||||
displayNameLabel.font = (mediumFont ?
|
||||
.systemFont(ofSize: Values.mediumFontSize) :
|
||||
.boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
)
|
||||
|
||||
displayNameLabel.text = (getUserHexEncodedPublicKey() == publicKey ?
|
||||
"MEDIA_GALLERY_SENDER_NAME_YOU".localized() :
|
||||
Profile.displayName(
|
||||
for: .contact,
|
||||
id: publicKey,
|
||||
name: profile?.name,
|
||||
nickname: profile?.nickname
|
||||
)
|
||||
)
|
||||
|
||||
switch accessory {
|
||||
|
@ -136,6 +146,11 @@ final class UserCell: UITableViewCell {
|
|||
accessoryImageView.isHidden = false
|
||||
accessoryImageView.image = icon.withRenderingMode(.alwaysTemplate)
|
||||
accessoryImageView.tintColor = Colors.text
|
||||
case .x:
|
||||
accessoryImageView.isHidden = false
|
||||
accessoryImageView.image = #imageLiteral(resourceName: "X").withRenderingMode(.alwaysTemplate)
|
||||
accessoryImageView.contentMode = .center
|
||||
accessoryImageView.tintColor = Colors.text
|
||||
}
|
||||
|
||||
let alpha: CGFloat = (isZombie ? 0.5 : 1)
|
||||
|
|
|
@ -34,7 +34,7 @@ public final class BackgroundPoller {
|
|||
poller.stop()
|
||||
|
||||
return poller.poll(
|
||||
isBackgroundPoll: true,
|
||||
calledFromBackgroundPoller: true,
|
||||
isBackgroundPollerValid: { BackgroundPoller.isValid },
|
||||
isPostCapabilitiesRetry: false
|
||||
)
|
||||
|
@ -82,7 +82,7 @@ public final class BackgroundPoller {
|
|||
groupPublicKey,
|
||||
on: DispatchQueue.main,
|
||||
maxRetryCount: 0,
|
||||
isBackgroundPoll: true,
|
||||
calledFromBackgroundPoller: true,
|
||||
isBackgroundPollValid: { BackgroundPoller.isValid }
|
||||
)
|
||||
}
|
||||
|
@ -134,7 +134,7 @@ public final class BackgroundPoller {
|
|||
threadId: threadId,
|
||||
details: MessageReceiveJob.Details(
|
||||
messages: threadMessages.map { $0.messageInfo },
|
||||
isBackgroundPoll: true
|
||||
calledFromBackgroundPoller: true
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import Foundation
|
||||
import DifferenceKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
public extension ArraySection {
|
||||
init(section: Model, elements: [Element] = []) {
|
||||
|
|
|
@ -50,8 +50,8 @@ final class IP2Country {
|
|||
|
||||
@objc func populateCacheIfNeededAsync() {
|
||||
// This has to be sync since the `countryNamesCache` dict doesn't like async access
|
||||
IP2Country.workQueue.sync {
|
||||
let _ = self.populateCacheIfNeeded()
|
||||
IP2Country.workQueue.sync { [weak self] in
|
||||
_ = self?.populateCacheIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,4 +9,7 @@ enum QueryParam: String {
|
|||
case required = "required"
|
||||
case limit // For messages - number between 1 and 256 (default is 100)
|
||||
case platform // For file server session version check
|
||||
case updateTypes = "t" // String indicating the types of updates that the client supports
|
||||
|
||||
case reactors = "reactors"
|
||||
}
|
||||
|
|
7
SessionMessagingKit/Common Networking/UpdateTypes.swift
Normal file
7
SessionMessagingKit/Common Networking/UpdateTypes.swift
Normal file
|
@ -0,0 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum UpdateTypes: String {
|
||||
case reaction = "r"
|
||||
}
|
|
@ -18,7 +18,11 @@ public enum SNMessagingKit { // Just to make the external API nice
|
|||
],
|
||||
[
|
||||
_005_FixDeletedMessageReadState.self,
|
||||
_006_FixHiddenModAdminSupport.self
|
||||
_006_FixHiddenModAdminSupport.self,
|
||||
_007_HomeQueryOptimisationIndexes.self
|
||||
],
|
||||
[
|
||||
_008_EmojiReacts.self
|
||||
]
|
||||
]
|
||||
)
|
||||
|
|
|
@ -1250,7 +1250,7 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
threadId: processedMessage.threadId,
|
||||
details: MessageReceiveJob.Details(
|
||||
messages: [processedMessage.messageInfo],
|
||||
isBackgroundPoll: legacyJob.isBackgroundPoll
|
||||
calledFromBackgroundPoller: legacyJob.isBackgroundPoll
|
||||
)
|
||||
)?.inserted(db)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
/// This migration adds an index to the interaction table in order to improve the performance of retrieving the number of unread interactions
|
||||
enum _007_HomeQueryOptimisationIndexes: Migration {
|
||||
static let target: TargetMigrations.Identifier = .messagingKit
|
||||
static let identifier: String = "HomeQueryOptimisationIndexes"
|
||||
static let needsConfigSync: Bool = false
|
||||
static let minExpectedRunDuration: TimeInterval = 0.1
|
||||
|
||||
static func migrate(_ db: Database) throws {
|
||||
try db.create(
|
||||
index: "interaction_on_wasRead_and_hasMention_and_threadId",
|
||||
on: Interaction.databaseTableName,
|
||||
columns: [
|
||||
Interaction.Columns.wasRead.name,
|
||||
Interaction.Columns.hasMention.name,
|
||||
Interaction.Columns.threadId.name
|
||||
]
|
||||
)
|
||||
|
||||
try db.create(
|
||||
index: "interaction_on_threadId_and_timestampMs_and_variant",
|
||||
on: Interaction.databaseTableName,
|
||||
columns: [
|
||||
Interaction.Columns.threadId.name,
|
||||
Interaction.Columns.timestampMs.name,
|
||||
Interaction.Columns.variant.name
|
||||
]
|
||||
)
|
||||
|
||||
Storage.update(progress: 1, for: self, in: target) // In case this is the last migration
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
/// This migration adds the new types needed for Emoji Reacts
|
||||
enum _008_EmojiReacts: Migration {
|
||||
static let target: TargetMigrations.Identifier = .messagingKit
|
||||
static let identifier: String = "EmojiReacts"
|
||||
static let needsConfigSync: Bool = false
|
||||
static let minExpectedRunDuration: TimeInterval = 0.1
|
||||
|
||||
static func migrate(_ db: Database) throws {
|
||||
try db.create(table: Reaction.self) { t in
|
||||
t.column(.interactionId, .numeric)
|
||||
.notNull()
|
||||
.indexed() // Quicker querying
|
||||
.references(Interaction.self, onDelete: .cascade) // Delete if Interaction deleted
|
||||
t.column(.serverHash, .text)
|
||||
t.column(.timestampMs, .text)
|
||||
.notNull()
|
||||
t.column(.authorId, .text)
|
||||
.notNull()
|
||||
.indexed() // Quicker querying
|
||||
t.column(.emoji, .text)
|
||||
.notNull()
|
||||
.indexed() // Quicker querying
|
||||
t.column(.count, .integer)
|
||||
.notNull()
|
||||
.defaults(to: 0)
|
||||
t.column(.sortId, .integer)
|
||||
.notNull()
|
||||
.defaults(to: 0)
|
||||
|
||||
/// A specific author should only be able to have a single instance of each emoji on a particular interaction
|
||||
t.uniqueKey([.interactionId, .emoji, .authorId])
|
||||
}
|
||||
|
||||
Storage.update(progress: 1, for: self, in: target) // In case this is the last migration
|
||||
}
|
||||
}
|
|
@ -16,11 +16,12 @@ public struct Capability: Codable, FetchableRecord, PersistableRecord, TableReco
|
|||
|
||||
public enum Variant: Equatable, Hashable, CaseIterable, Codable, DatabaseValueConvertible {
|
||||
public static var allCases: [Variant] {
|
||||
[.sogs, .blind]
|
||||
[.sogs, .blind, .reactions]
|
||||
}
|
||||
|
||||
case sogs
|
||||
case blind
|
||||
case reactions
|
||||
|
||||
/// Fallback case if the capability isn't supported by this version of the app
|
||||
case unsupported(String)
|
||||
|
|
|
@ -262,7 +262,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
|
|||
self.body = body
|
||||
self.timestampMs = timestampMs
|
||||
self.receivedAtTimestampMs = receivedAtTimestampMs
|
||||
self.wasRead = (wasRead && variant.canBeUnread)
|
||||
self.wasRead = (wasRead || !variant.canBeUnread)
|
||||
self.hasMention = hasMention
|
||||
self.expiresInSeconds = expiresInSeconds
|
||||
self.expiresStartedAtMs = expiresStartedAtMs
|
||||
|
@ -304,7 +304,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
|
|||
default: return timestampMs
|
||||
}
|
||||
}()
|
||||
self.wasRead = (wasRead && variant.canBeUnread)
|
||||
self.wasRead = (wasRead || !variant.canBeUnread)
|
||||
self.hasMention = hasMention
|
||||
self.expiresInSeconds = expiresInSeconds
|
||||
self.expiresStartedAtMs = expiresStartedAtMs
|
||||
|
@ -348,10 +348,14 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
|
|||
).insert(db)
|
||||
|
||||
case .closedGroup:
|
||||
guard
|
||||
let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db),
|
||||
let members: [GroupMember] = try? closedGroup.members.fetchAll(db)
|
||||
else {
|
||||
let closedGroupMemberIds: Set<String> = (try? GroupMember
|
||||
.select(.profileId)
|
||||
.filter(GroupMember.Columns.groupId == thread.id)
|
||||
.asRequest(of: String.self)
|
||||
.fetchSet(db))
|
||||
.defaulting(to: [])
|
||||
|
||||
guard !closedGroupMemberIds.isEmpty else {
|
||||
SNLog("Inserted an interaction but couldn't find it's associated thread members")
|
||||
return
|
||||
}
|
||||
|
@ -359,12 +363,12 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
|
|||
// Exclude the current user when creating recipient states (as they will never
|
||||
// receive the message resulting in the message getting flagged as failed)
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
try members
|
||||
.filter { member -> Bool in member.profileId != userPublicKey }
|
||||
.forEach { member in
|
||||
try closedGroupMemberIds
|
||||
.filter { memberId -> Bool in memberId != userPublicKey }
|
||||
.forEach { memberId in
|
||||
try RecipientState(
|
||||
interactionId: interactionId,
|
||||
recipientId: member.profileId,
|
||||
recipientId: memberId,
|
||||
state: .sending
|
||||
).insert(db)
|
||||
}
|
||||
|
@ -409,7 +413,7 @@ public extension Interaction {
|
|||
body: (body ?? self.body),
|
||||
timestampMs: (timestampMs ?? self.timestampMs),
|
||||
receivedAtTimestampMs: self.receivedAtTimestampMs,
|
||||
wasRead: (wasRead ?? self.wasRead),
|
||||
wasRead: ((wasRead ?? self.wasRead) || !self.variant.canBeUnread),
|
||||
hasMention: (hasMention ?? self.hasMention),
|
||||
expiresInSeconds: (expiresInSeconds ?? self.expiresInSeconds),
|
||||
expiresStartedAtMs: (expiresStartedAtMs ?? self.expiresStartedAtMs),
|
||||
|
@ -453,6 +457,23 @@ public extension Interaction {
|
|||
)
|
||||
)
|
||||
|
||||
// Clear out any notifications for the interactions we mark as read
|
||||
Environment.shared?.notificationsManager.wrappedValue?.cancelNotifications(
|
||||
identifiers: interactionIds
|
||||
.map { interactionId in
|
||||
Interaction.notificationIdentifier(
|
||||
for: interactionId,
|
||||
threadId: threadId,
|
||||
shouldGroupMessagesForThread: false
|
||||
)
|
||||
}
|
||||
.appending(Interaction.notificationIdentifier(
|
||||
for: 0,
|
||||
threadId: threadId,
|
||||
shouldGroupMessagesForThread: true
|
||||
))
|
||||
)
|
||||
|
||||
// If we want to send read receipts then try to add the 'SendReadReceiptsJob'
|
||||
if trySendReadReceipt {
|
||||
JobRunner.upsert(
|
||||
|
@ -573,18 +594,27 @@ public extension Interaction {
|
|||
|
||||
var notificationIdentifiers: [String] {
|
||||
[
|
||||
notificationIdentifier(isBackgroundPoll: true),
|
||||
notificationIdentifier(isBackgroundPoll: false)
|
||||
notificationIdentifier(shouldGroupMessagesForThread: true),
|
||||
notificationIdentifier(shouldGroupMessagesForThread: false)
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
func notificationIdentifier(isBackgroundPoll: Bool) -> String {
|
||||
func notificationIdentifier(shouldGroupMessagesForThread: Bool) -> String {
|
||||
// When the app is in the background we want the notifications to be grouped to prevent spam
|
||||
guard isBackgroundPoll else { return threadId }
|
||||
return Interaction.notificationIdentifier(
|
||||
for: (id ?? 0),
|
||||
threadId: threadId,
|
||||
shouldGroupMessagesForThread: shouldGroupMessagesForThread
|
||||
)
|
||||
}
|
||||
|
||||
fileprivate static func notificationIdentifier(for id: Int64, threadId: String, shouldGroupMessagesForThread: Bool) -> String {
|
||||
// When the app is in the background we want the notifications to be grouped to prevent spam
|
||||
guard !shouldGroupMessagesForThread else { return threadId }
|
||||
|
||||
return "\(threadId)-\(id ?? 0)"
|
||||
return "\(threadId)-\(id)"
|
||||
}
|
||||
|
||||
func markingAsDeleted() -> Interaction {
|
||||
|
@ -598,7 +628,7 @@ public extension Interaction {
|
|||
body: nil,
|
||||
timestampMs: timestampMs,
|
||||
receivedAtTimestampMs: receivedAtTimestampMs,
|
||||
wasRead: (wasRead && Variant.standardIncomingDeleted.canBeUnread),
|
||||
wasRead: (wasRead || !Variant.standardIncomingDeleted.canBeUnread),
|
||||
hasMention: hasMention,
|
||||
expiresInSeconds: expiresInSeconds,
|
||||
expiresStartedAtMs: expiresStartedAtMs,
|
||||
|
|
134
SessionMessagingKit/Database/Models/Reaction.swift
Normal file
134
SessionMessagingKit/Database/Models/Reaction.swift
Normal file
|
@ -0,0 +1,134 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public struct Reaction: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
||||
public static var databaseTableName: String { "reaction" }
|
||||
internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id])
|
||||
internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id])
|
||||
private static let profile = hasOne(Profile.self, using: profileForeignKey)
|
||||
internal static let interaction = belongsTo(Interaction.self, using: interactionForeignKey)
|
||||
|
||||
public typealias Columns = CodingKeys
|
||||
public enum CodingKeys: String, CodingKey, ColumnExpression {
|
||||
case interactionId
|
||||
case serverHash
|
||||
case timestampMs
|
||||
case authorId
|
||||
case emoji
|
||||
case count
|
||||
case sortId
|
||||
}
|
||||
|
||||
/// The id for the interaction this reaction belongs to
|
||||
public let interactionId: Int64
|
||||
|
||||
/// The server hash for this reaction in the swarm
|
||||
///
|
||||
/// **Note:** This value will be `null` for reactions in open groups
|
||||
public let serverHash: String?
|
||||
|
||||
/// When the reaction was created in milliseconds since epoch
|
||||
public let timestampMs: Int64
|
||||
|
||||
/// The id for the user who made this reaction
|
||||
public let authorId: String
|
||||
|
||||
/// The emoji for this reaction
|
||||
public let emoji: String
|
||||
|
||||
/// The number of times this emoji was used
|
||||
///
|
||||
/// **Note:** This value will always be `1` for 1-1 messages and closed groups, but will be either `0` or
|
||||
/// the total number of emoji's used in open groups (this allows us to `SUM` this column to get the official total
|
||||
/// regardless of the type of conversation)
|
||||
public let count: Int64
|
||||
|
||||
/// The first timestamp that an emoji is added
|
||||
public let sortId: Int64
|
||||
|
||||
// MARK: - Relationships
|
||||
|
||||
public var interaction: QueryInterfaceRequest<Interaction> {
|
||||
request(for: Reaction.interaction)
|
||||
}
|
||||
|
||||
public var profile: QueryInterfaceRequest<Profile> {
|
||||
request(for: Reaction.profile)
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(
|
||||
interactionId: Int64,
|
||||
serverHash: String?,
|
||||
timestampMs: Int64,
|
||||
authorId: String,
|
||||
emoji: String,
|
||||
count: Int64,
|
||||
sortId: Int64
|
||||
) {
|
||||
self.interactionId = interactionId
|
||||
self.serverHash = serverHash
|
||||
self.timestampMs = timestampMs
|
||||
self.authorId = authorId
|
||||
self.emoji = emoji
|
||||
self.count = count
|
||||
self.sortId = sortId
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mutation
|
||||
|
||||
public extension Reaction {
|
||||
func with(
|
||||
interactionId: Int64? = nil,
|
||||
serverHash: String? = nil,
|
||||
authorId: String? = nil,
|
||||
count: Int64? = nil,
|
||||
sortId: Int64? = nil
|
||||
) -> Reaction {
|
||||
return Reaction(
|
||||
interactionId: (interactionId ?? self.interactionId),
|
||||
serverHash: (serverHash ?? self.serverHash),
|
||||
timestampMs: self.timestampMs,
|
||||
authorId: (authorId ?? self.authorId),
|
||||
emoji: self.emoji,
|
||||
count: (count ?? self.count),
|
||||
sortId: (sortId ?? self.sortId)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SortId
|
||||
|
||||
public extension Reaction {
|
||||
static func getSortId(
|
||||
_ db: Database,
|
||||
interactionId: Int64,
|
||||
emoji: String
|
||||
) -> Int64 {
|
||||
if let existingSortId: Int64 = try? Reaction
|
||||
.select(Columns.sortId)
|
||||
.filter(Columns.interactionId == interactionId)
|
||||
.filter(Columns.emoji == emoji)
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchOne(db)
|
||||
{
|
||||
return existingSortId
|
||||
}
|
||||
|
||||
if let existingLargestSortId: Int64 = try? Reaction
|
||||
.select(max(Columns.sortId))
|
||||
.filter(Columns.interactionId == interactionId)
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchOne(db)
|
||||
{
|
||||
return existingLargestSortId + 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
}
|
|
@ -202,23 +202,24 @@ public extension SessionThread {
|
|||
"""
|
||||
}
|
||||
|
||||
static func unreadMessageRequestsThreadIdQuery(userPublicKey: String, includeNonVisible: Bool = false) -> SQLRequest<String> {
|
||||
static func unreadMessageRequestsCountQuery(userPublicKey: String, includeNonVisible: Bool = false) -> SQLRequest<Int> {
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
|
||||
return """
|
||||
SELECT \(thread[.id])
|
||||
FROM \(SessionThread.self)
|
||||
JOIN \(Interaction.self) ON (
|
||||
\(interaction[.threadId]) = \(thread[.id]) AND
|
||||
\(interaction[.wasRead]) = false
|
||||
SELECT COUNT(DISTINCT id) FROM (
|
||||
SELECT \(thread[.id]) AS id
|
||||
FROM \(SessionThread.self)
|
||||
JOIN \(Interaction.self) ON (
|
||||
\(interaction[.threadId]) = \(thread[.id]) AND
|
||||
\(interaction[.wasRead]) = false
|
||||
)
|
||||
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])
|
||||
WHERE (
|
||||
\(SessionThread.isMessageRequest(userPublicKey: userPublicKey, includeNonVisible: includeNonVisible))
|
||||
)
|
||||
)
|
||||
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])
|
||||
WHERE (
|
||||
\(SessionThread.isMessageRequest(userPublicKey: userPublicKey, includeNonVisible: includeNonVisible))
|
||||
)
|
||||
GROUP BY \(thread[.id])
|
||||
"""
|
||||
}
|
||||
|
||||
|
@ -276,8 +277,8 @@ public extension SessionThread {
|
|||
// all the other message request threads have been read
|
||||
if !hasHiddenMessageRequests {
|
||||
let numUnreadMessageRequestThreads: Int = (try? SessionThread
|
||||
.unreadMessageRequestsThreadIdQuery(userPublicKey: userPublicKey, includeNonVisible: true)
|
||||
.fetchCount(db))
|
||||
.unreadMessageRequestsCountQuery(userPublicKey: userPublicKey, includeNonVisible: true)
|
||||
.fetchOne(db))
|
||||
.defaulting(to: 1)
|
||||
|
||||
guard numUnreadMessageRequestThreads == 1 else { return false }
|
||||
|
|
|
@ -37,8 +37,7 @@ public enum MessageReceiveJob: JobExecutor {
|
|||
db,
|
||||
message: messageInfo.message,
|
||||
associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData),
|
||||
openGroupId: nil,
|
||||
isBackgroundPoll: details.isBackgroundPoll
|
||||
openGroupId: nil
|
||||
)
|
||||
}
|
||||
catch {
|
||||
|
@ -76,7 +75,7 @@ public enum MessageReceiveJob: JobExecutor {
|
|||
.with(
|
||||
details: Details(
|
||||
messages: remainingMessagesToProcess,
|
||||
isBackgroundPoll: details.isBackgroundPoll
|
||||
calledFromBackgroundPoller: details.calledFromBackgroundPoller
|
||||
)
|
||||
)
|
||||
.defaulting(to: job)
|
||||
|
@ -164,14 +163,18 @@ extension MessageReceiveJob {
|
|||
}
|
||||
|
||||
public let messages: [MessageInfo]
|
||||
public let isBackgroundPoll: Bool
|
||||
private let isBackgroundPoll: Bool
|
||||
|
||||
// Renamed variable for clarity (and didn't want to migrate old MessageReceiveJob
|
||||
// values so didn't rename the original)
|
||||
public var calledFromBackgroundPoller: Bool { isBackgroundPoll }
|
||||
|
||||
public init(
|
||||
messages: [MessageInfo],
|
||||
isBackgroundPoll: Bool
|
||||
calledFromBackgroundPoller: Bool
|
||||
) {
|
||||
self.messages = messages
|
||||
self.isBackgroundPoll = isBackgroundPoll
|
||||
self.isBackgroundPoll = calledFromBackgroundPoller
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import Foundation
|
||||
import GRDB
|
||||
import SessionSnodeKit
|
||||
import SessionUtilitiesKit
|
||||
|
||||
/// Abstract base class for `VisibleMessage` and `ControlMessage`.
|
||||
public class Message: Codable {
|
||||
|
@ -291,10 +292,10 @@ public extension Message {
|
|||
dependencies: SMKDependencies = SMKDependencies()
|
||||
) throws -> ProcessedMessage? {
|
||||
// Need a sender in order to process the message
|
||||
guard let sender: String = message.sender else { return nil }
|
||||
guard let sender: String = message.sender, let timestamp = message.posted else { return nil }
|
||||
|
||||
// Note: The `posted` value is in seconds but all messages in the database use milliseconds for timestamps
|
||||
let envelopeBuilder = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(message.posted * 1000)))
|
||||
let envelopeBuilder = SNProtoEnvelope.builder(type: .sessionMessage, timestamp: UInt64(floor(timestamp * 1000)))
|
||||
envelopeBuilder.setContent(data)
|
||||
envelopeBuilder.setSource(sender)
|
||||
|
||||
|
@ -348,6 +349,123 @@ public extension Message {
|
|||
)
|
||||
}
|
||||
|
||||
static func processRawReceivedReactions(
|
||||
_ db: Database,
|
||||
openGroupId: String,
|
||||
message: OpenGroupAPI.Message,
|
||||
associatedPendingChanges: [OpenGroupAPI.PendingChange],
|
||||
dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> [Reaction] {
|
||||
var results: [Reaction] = []
|
||||
guard let reactions = message.reactions else { return results }
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let blindedUserPublicKey: String? = SessionThread
|
||||
.getUserHexEncodedBlindedKey(
|
||||
threadId: openGroupId,
|
||||
threadVariant: .openGroup
|
||||
)
|
||||
for (encodedEmoji, rawReaction) in reactions {
|
||||
if let decodedEmoji = encodedEmoji.removingPercentEncoding,
|
||||
rawReaction.count > 0,
|
||||
let reactors = rawReaction.reactors
|
||||
{
|
||||
// Decide whether we need to ignore all reactions
|
||||
let pendingChangeRemoveAllReaction: Bool = associatedPendingChanges.contains { pendingChange in
|
||||
if case .reaction(_, let emoji, let action) = pendingChange.metadata {
|
||||
return emoji == decodedEmoji && action == .removeAll
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Decide whether we need to add an extra reaction from current user
|
||||
let pendingChangeSelfReaction: Bool? = {
|
||||
// Find the newest 'PendingChange' entry with a matching emoji, if one exists, and
|
||||
// set the "self reaction" value based on it's action
|
||||
let maybePendingChange: OpenGroupAPI.PendingChange? = associatedPendingChanges
|
||||
.sorted(by: { lhs, rhs -> Bool in (lhs.seqNo ?? Int64.max) >= (rhs.seqNo ?? Int64.max) })
|
||||
.first { pendingChange in
|
||||
if case .reaction(_, let emoji, _) = pendingChange.metadata {
|
||||
return emoji == decodedEmoji
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// If there is no pending change for this reaction then return nil
|
||||
guard
|
||||
let pendingChange: OpenGroupAPI.PendingChange = maybePendingChange,
|
||||
case .reaction(_, _, let action) = pendingChange.metadata
|
||||
else { return nil }
|
||||
|
||||
// Otherwise add/remove accordingly
|
||||
return action == .add
|
||||
}()
|
||||
let shouldAddSelfReaction: Bool = (
|
||||
pendingChangeSelfReaction ??
|
||||
((rawReaction.you || reactors.contains(userPublicKey)) && !pendingChangeRemoveAllReaction)
|
||||
)
|
||||
|
||||
let count: Int64 = rawReaction.you ? rawReaction.count - 1 : rawReaction.count
|
||||
|
||||
let timestampMs: Int64 = Int64(floor((Date().timeIntervalSince1970 * 1000)))
|
||||
let maxLength: Int = shouldAddSelfReaction ? 4 : 5
|
||||
let desiredReactorIds: [String] = reactors
|
||||
.filter { $0 != blindedUserPublicKey && $0 != userPublicKey } // Remove current user for now, will add back if needed
|
||||
.prefix(maxLength)
|
||||
.map{ $0 }
|
||||
|
||||
results = results
|
||||
.appending( // Add the first reaction (with the count)
|
||||
pendingChangeRemoveAllReaction ?
|
||||
nil :
|
||||
desiredReactorIds.first
|
||||
.map { reactor in
|
||||
Reaction(
|
||||
interactionId: message.id,
|
||||
serverHash: nil,
|
||||
timestampMs: timestampMs,
|
||||
authorId: reactor,
|
||||
emoji: decodedEmoji,
|
||||
count: count,
|
||||
sortId: rawReaction.index
|
||||
)
|
||||
}
|
||||
)
|
||||
.appending( // Add all other reactions
|
||||
contentsOf: desiredReactorIds.count <= 1 || pendingChangeRemoveAllReaction ?
|
||||
[] :
|
||||
desiredReactorIds
|
||||
.suffix(from: 1)
|
||||
.map { reactor in
|
||||
Reaction(
|
||||
interactionId: message.id,
|
||||
serverHash: nil,
|
||||
timestampMs: timestampMs,
|
||||
authorId: reactor,
|
||||
emoji: decodedEmoji,
|
||||
count: 0, // Only want this on the first reaction
|
||||
sortId: rawReaction.index
|
||||
)
|
||||
}
|
||||
)
|
||||
.appending( // Add the current user reaction (if applicable and not already included)
|
||||
!shouldAddSelfReaction ?
|
||||
nil :
|
||||
Reaction(
|
||||
interactionId: message.id,
|
||||
serverHash: nil,
|
||||
timestampMs: timestampMs,
|
||||
authorId: userPublicKey,
|
||||
emoji: decodedEmoji,
|
||||
count: 1,
|
||||
sortId: rawReaction.index
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private static func processRawReceivedMessage(
|
||||
_ db: Database,
|
||||
envelope: SNProtoEnvelope,
|
||||
|
|
|
@ -35,7 +35,7 @@ public extension VisibleMessage {
|
|||
}
|
||||
|
||||
public func toProto() -> SNProtoDataMessageQuote? {
|
||||
preconditionFailure("Use toProto(using:) instead.")
|
||||
preconditionFailure("Use toProto(_:) instead.")
|
||||
}
|
||||
|
||||
public func toProto(_ db: Database) -> SNProtoDataMessageQuote? {
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import GRDB
|
||||
import SessionUtilitiesKit
|
||||
|
||||
public extension VisibleMessage {
|
||||
struct VMReaction: Codable {
|
||||
/// This is the timestamp (in milliseconds since epoch) when the interaction this reaction belongs to was sent
|
||||
public var timestamp: UInt64
|
||||
|
||||
/// This is the public key of the sender of the interaction this reaction belongs to
|
||||
public var publicKey: String
|
||||
|
||||
/// This is the emoji for the reaction
|
||||
public var emoji: String
|
||||
|
||||
/// This is the behaviour for the reaction
|
||||
public var kind: Kind
|
||||
|
||||
public var isValid: Bool { true }
|
||||
|
||||
// MARK: - Kind
|
||||
|
||||
public enum Kind: Int, Codable {
|
||||
case react
|
||||
case remove
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .react: return "react"
|
||||
case .remove: return "remove"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(protoAction: SNProtoDataMessageReaction.SNProtoDataMessageReactionAction) {
|
||||
switch protoAction {
|
||||
case .react: self = .react
|
||||
case .remove: self = .remove
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Proto Conversion
|
||||
|
||||
func toProto() -> SNProtoDataMessageReaction.SNProtoDataMessageReactionAction {
|
||||
switch self {
|
||||
case .react: return .react
|
||||
case .remove: return .remove
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(timestamp: UInt64, publicKey: String, emoji: String, kind: Kind) {
|
||||
self.timestamp = timestamp
|
||||
self.publicKey = publicKey
|
||||
self.emoji = emoji
|
||||
self.kind = kind
|
||||
}
|
||||
|
||||
// MARK: - Proto Conversion
|
||||
|
||||
public static func fromProto(_ proto: SNProtoDataMessageReaction) -> VMReaction? {
|
||||
guard let emoji: String = proto.emoji else { return nil }
|
||||
|
||||
return VMReaction(
|
||||
timestamp: proto.id,
|
||||
publicKey: proto.author,
|
||||
emoji: emoji,
|
||||
kind: Kind(protoAction: proto.action)
|
||||
)
|
||||
}
|
||||
|
||||
public func toProto() -> SNProtoDataMessageReaction? {
|
||||
let reactionProto = SNProtoDataMessageReaction.builder(
|
||||
id: self.timestamp,
|
||||
author: self.publicKey,
|
||||
action: self.kind.toProto()
|
||||
)
|
||||
reactionProto.setEmoji(self.emoji)
|
||||
|
||||
do {
|
||||
return try reactionProto.build()
|
||||
} catch {
|
||||
SNLog("Couldn't construct quote proto from: \(self).")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Description
|
||||
|
||||
public var description: String {
|
||||
"""
|
||||
Reaction(
|
||||
timestamp: \(timestamp),
|
||||
publicKey: \(publicKey),
|
||||
emoji: \(emoji),
|
||||
kind: \(kind.description)
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,6 +13,7 @@ public final class VisibleMessage: Message {
|
|||
case linkPreview
|
||||
case profile
|
||||
case openGroupInvitation
|
||||
case reaction
|
||||
}
|
||||
|
||||
/// In the case of a sync message, the public key of the person the message was targeted at.
|
||||
|
@ -25,6 +26,7 @@ public final class VisibleMessage: Message {
|
|||
public let linkPreview: VMLinkPreview?
|
||||
public var profile: VMProfile?
|
||||
public let openGroupInvitation: VMOpenGroupInvitation?
|
||||
public let reaction: VMReaction?
|
||||
|
||||
public override var isSelfSendValid: Bool { true }
|
||||
|
||||
|
@ -34,6 +36,7 @@ public final class VisibleMessage: Message {
|
|||
guard super.isValid else { return false }
|
||||
if !attachmentIds.isEmpty { return true }
|
||||
if openGroupInvitation != nil { return true }
|
||||
if reaction != nil { return true }
|
||||
if let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { return true }
|
||||
return false
|
||||
}
|
||||
|
@ -50,7 +53,8 @@ public final class VisibleMessage: Message {
|
|||
quote: VMQuote? = nil,
|
||||
linkPreview: VMLinkPreview? = nil,
|
||||
profile: VMProfile? = nil,
|
||||
openGroupInvitation: VMOpenGroupInvitation? = nil
|
||||
openGroupInvitation: VMOpenGroupInvitation? = nil,
|
||||
reaction: VMReaction? = nil
|
||||
) {
|
||||
self.syncTarget = syncTarget
|
||||
self.text = text
|
||||
|
@ -59,6 +63,7 @@ public final class VisibleMessage: Message {
|
|||
self.linkPreview = linkPreview
|
||||
self.profile = profile
|
||||
self.openGroupInvitation = openGroupInvitation
|
||||
self.reaction = reaction
|
||||
|
||||
super.init(
|
||||
sentTimestamp: sentTimestamp,
|
||||
|
@ -79,6 +84,7 @@ public final class VisibleMessage: Message {
|
|||
linkPreview = try? container.decode(VMLinkPreview.self, forKey: .linkPreview)
|
||||
profile = try? container.decode(VMProfile.self, forKey: .profile)
|
||||
openGroupInvitation = try? container.decode(VMOpenGroupInvitation.self, forKey: .openGroupInvitation)
|
||||
reaction = try? container.decode(VMReaction.self, forKey: .reaction)
|
||||
|
||||
try super.init(from: decoder)
|
||||
}
|
||||
|
@ -95,6 +101,7 @@ public final class VisibleMessage: Message {
|
|||
try container.encodeIfPresent(linkPreview, forKey: .linkPreview)
|
||||
try container.encodeIfPresent(profile, forKey: .profile)
|
||||
try container.encodeIfPresent(openGroupInvitation, forKey: .openGroupInvitation)
|
||||
try container.encodeIfPresent(reaction, forKey: .reaction)
|
||||
}
|
||||
|
||||
// MARK: - Proto Conversion
|
||||
|
@ -109,7 +116,8 @@ public final class VisibleMessage: Message {
|
|||
quote: dataMessage.quote.map { VMQuote.fromProto($0) },
|
||||
linkPreview: dataMessage.preview.first.map { VMLinkPreview.fromProto($0) },
|
||||
profile: VMProfile.fromProto(dataMessage),
|
||||
openGroupInvitation: dataMessage.openGroupInvitation.map { VMOpenGroupInvitation.fromProto($0) }
|
||||
openGroupInvitation: dataMessage.openGroupInvitation.map { VMOpenGroupInvitation.fromProto($0) },
|
||||
reaction: dataMessage.reaction.map { VMReaction.fromProto($0) }
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -168,6 +176,11 @@ public final class VisibleMessage: Message {
|
|||
dataMessage.setOpenGroupInvitation(openGroupInvitationProto)
|
||||
}
|
||||
|
||||
// Emoji react
|
||||
if let reaction = reaction, let reactionProto = reaction.toProto() {
|
||||
dataMessage.setReaction(reactionProto)
|
||||
}
|
||||
|
||||
// Group context
|
||||
do {
|
||||
try setGroupContextIfNeeded(db, on: dataMessage)
|
||||
|
@ -175,10 +188,12 @@ public final class VisibleMessage: Message {
|
|||
SNLog("Couldn't construct visible message proto from: \(self).")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sync target
|
||||
if let syncTarget = syncTarget {
|
||||
dataMessage.setSyncTarget(syncTarget)
|
||||
}
|
||||
|
||||
// Build
|
||||
do {
|
||||
proto.setDataMessage(try dataMessage.build())
|
||||
|
@ -198,8 +213,9 @@ public final class VisibleMessage: Message {
|
|||
attachmentIds: \(attachmentIds),
|
||||
quote: \(quote?.description ?? "null"),
|
||||
linkPreview: \(linkPreview?.description ?? "null"),
|
||||
profile: \(profile?.description ?? "null")
|
||||
"openGroupInvitation": \(openGroupInvitation?.description ?? "null")
|
||||
profile: \(profile?.description ?? "null"),
|
||||
reaction: \(reaction?.description ?? "null"),
|
||||
openGroupInvitation: \(openGroupInvitation?.description ?? "null")
|
||||
)
|
||||
"""
|
||||
}
|
||||
|
@ -239,7 +255,8 @@ public extension VisibleMessage {
|
|||
db,
|
||||
linkPreview: linkPreview
|
||||
)
|
||||
}
|
||||
},
|
||||
reaction: nil // Reactions are custom messages sent separately
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,5 +6,4 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[];
|
|||
#import <SessionMessagingKit/AppReadiness.h>
|
||||
#import <SessionMessagingKit/NSData+messagePadding.h>
|
||||
#import <SessionMessagingKit/OWSAudioPlayer.h>
|
||||
#import <SessionMessagingKit/OWSBackgroundTask.h>
|
||||
#import <SessionMessagingKit/OWSWindowManager.h>
|
||||
|
|
47
SessionMessagingKit/Open Groups/Models/PendingChange.swift
Normal file
47
SessionMessagingKit/Open Groups/Models/PendingChange.swift
Normal file
|
@ -0,0 +1,47 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension OpenGroupAPI {
|
||||
public struct PendingChange: Equatable {
|
||||
public enum ChangeType {
|
||||
case reaction
|
||||
}
|
||||
|
||||
public enum ReactAction: Equatable {
|
||||
case add
|
||||
case remove
|
||||
case removeAll
|
||||
}
|
||||
|
||||
enum Metadata {
|
||||
case reaction(messageId: Int64, emoji: String, action: ReactAction)
|
||||
}
|
||||
|
||||
let server: String
|
||||
let room: String
|
||||
let changeType: ChangeType
|
||||
var seqNo: Int64?
|
||||
let metadata: Metadata
|
||||
|
||||
public static func == (lhs: OpenGroupAPI.PendingChange, rhs: OpenGroupAPI.PendingChange) -> Bool {
|
||||
guard lhs.server == rhs.server &&
|
||||
lhs.room == rhs.room &&
|
||||
lhs.changeType == rhs.changeType &&
|
||||
lhs.seqNo == rhs.seqNo
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
switch lhs.changeType {
|
||||
case .reaction:
|
||||
if case .reaction(let lhsMessageId, let lhsEmoji, let lhsAction) = lhs.metadata,
|
||||
case .reaction(let rhsMessageId, let rhsEmoji, let rhsAction) = rhs.metadata {
|
||||
return lhsMessageId == rhsMessageId && lhsEmoji == rhsEmoji && lhsAction == rhsAction
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension OpenGroupAPI {
|
||||
public struct ReactionAddResponse: Codable, Equatable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case added
|
||||
case seqNo = "seqno"
|
||||
}
|
||||
|
||||
/// This field indicates whether the reaction was added (true) or already present (false).
|
||||
public let added: Bool
|
||||
|
||||
/// The seqNo after the reaction is added.
|
||||
public let seqNo: Int64?
|
||||
}
|
||||
|
||||
public struct ReactionRemoveResponse: Codable, Equatable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case removed
|
||||
case seqNo = "seqno"
|
||||
}
|
||||
|
||||
/// This field indicates whether the reaction was removed (true) or was not present to begin with (false).
|
||||
public let removed: Bool
|
||||
|
||||
/// The seqNo after the reaction is removed.
|
||||
public let seqNo: Int64?
|
||||
}
|
||||
|
||||
public struct ReactionRemoveAllResponse: Codable, Equatable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case removed
|
||||
case seqNo = "seqno"
|
||||
}
|
||||
|
||||
/// This field shows the total number of reactions that were deleted.
|
||||
public let removed: Int64
|
||||
|
||||
/// The seqNo after the reactions is all removed.
|
||||
public let seqNo: Int64?
|
||||
}
|
||||
}
|
|
@ -18,11 +18,13 @@ extension OpenGroupAPI {
|
|||
|
||||
case base64EncodedData = "data"
|
||||
case base64EncodedSignature = "signature"
|
||||
|
||||
case reactions = "reactions"
|
||||
}
|
||||
|
||||
public let id: Int64
|
||||
public let sender: String?
|
||||
public let posted: TimeInterval
|
||||
public let posted: TimeInterval?
|
||||
public let edited: TimeInterval?
|
||||
public let deleted: Bool?
|
||||
public let seqNo: Int64
|
||||
|
@ -32,6 +34,22 @@ extension OpenGroupAPI {
|
|||
|
||||
public let base64EncodedData: String?
|
||||
public let base64EncodedSignature: String?
|
||||
|
||||
public struct Reaction: Codable, Equatable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case count
|
||||
case reactors
|
||||
case you
|
||||
case index
|
||||
}
|
||||
|
||||
public let count: Int64
|
||||
public let reactors: [String]?
|
||||
public let you: Bool
|
||||
public let index: Int64
|
||||
}
|
||||
|
||||
public let reactions: [String:Reaction]?
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -44,6 +62,7 @@ extension OpenGroupAPI.Message {
|
|||
let maybeSender: String? = try? container.decode(String.self, forKey: .sender)
|
||||
let maybeBase64EncodedData: String? = try? container.decode(String.self, forKey: .base64EncodedData)
|
||||
let maybeBase64EncodedSignature: String? = try? container.decode(String.self, forKey: .base64EncodedSignature)
|
||||
let maybeReactions: [String:Reaction]? = try? container.decode([String:Reaction].self, forKey: .reactions)
|
||||
|
||||
// If we have data and a signature (ie. the message isn't a deletion) then validate the signature
|
||||
if let base64EncodedData: String = maybeBase64EncodedData, let base64EncodedSignature: String = maybeBase64EncodedSignature {
|
||||
|
@ -79,7 +98,7 @@ extension OpenGroupAPI.Message {
|
|||
self = OpenGroupAPI.Message(
|
||||
id: try container.decode(Int64.self, forKey: .id),
|
||||
sender: try? container.decode(String.self, forKey: .sender),
|
||||
posted: try container.decode(TimeInterval.self, forKey: .posted),
|
||||
posted: try? container.decode(TimeInterval.self, forKey: .posted),
|
||||
edited: try? container.decode(TimeInterval.self, forKey: .edited),
|
||||
deleted: try? container.decode(Bool.self, forKey: .deleted),
|
||||
seqNo: try container.decode(Int64.self, forKey: .seqNo),
|
||||
|
@ -87,7 +106,21 @@ extension OpenGroupAPI.Message {
|
|||
whisperMods: ((try? container.decode(Bool.self, forKey: .whisperMods)) ?? false),
|
||||
whisperTo: try? container.decode(String.self, forKey: .whisperTo),
|
||||
base64EncodedData: maybeBase64EncodedData,
|
||||
base64EncodedSignature: maybeBase64EncodedSignature
|
||||
base64EncodedSignature: maybeBase64EncodedSignature,
|
||||
reactions: !container.contains(.reactions) ? nil : (maybeReactions ?? [:])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenGroupAPI.Message.Reaction {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self = OpenGroupAPI.Message.Reaction(
|
||||
count: try container.decode(Int64.self, forKey: .count),
|
||||
reactors: try? container.decode([String].self, forKey: .reactors),
|
||||
you: (try? container.decode(Bool.self, forKey: .you)) ?? false,
|
||||
index: (try container.decode(Int64.self, forKey: .index))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -96,7 +96,11 @@ public enum OpenGroupAPI {
|
|||
endpoint: (shouldRetrieveRecentMessages ?
|
||||
.roomMessagesRecent(openGroup.roomToken) :
|
||||
.roomMessagesSince(openGroup.roomToken, seqNo: openGroup.sequenceNumber)
|
||||
)
|
||||
),
|
||||
queryParameters: [
|
||||
.updateTypes: UpdateTypes.reaction.rawValue,
|
||||
.reactors: "5"
|
||||
]
|
||||
),
|
||||
responseType: [Failable<Message>].self
|
||||
)
|
||||
|
@ -618,7 +622,11 @@ public enum OpenGroupAPI {
|
|||
db,
|
||||
request: Request<NoBody, Endpoint>(
|
||||
server: server,
|
||||
endpoint: .roomMessagesSince(roomToken, seqNo: seqNo)
|
||||
endpoint: .roomMessagesSince(roomToken, seqNo: seqNo),
|
||||
queryParameters: [
|
||||
.updateTypes: UpdateTypes.reaction.rawValue,
|
||||
.reactors: "20"
|
||||
]
|
||||
),
|
||||
using: dependencies
|
||||
)
|
||||
|
@ -657,6 +665,116 @@ public enum OpenGroupAPI {
|
|||
)
|
||||
}
|
||||
|
||||
// MARK: - Reactions
|
||||
|
||||
public static func reactors(
|
||||
_ db: Database,
|
||||
emoji: String,
|
||||
id: Int64,
|
||||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<OnionRequestResponseInfoType> {
|
||||
/// URL(String:) won't convert raw emojis, so need to do a little encoding here.
|
||||
/// The raw emoji will come back when calling url.path
|
||||
guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
||||
return Promise(error: OpenGroupAPIError.invalidEmoji)
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
request: Request<NoBody, Endpoint>(
|
||||
method: .get,
|
||||
server: server,
|
||||
endpoint: .reactors(roomToken, id: id, emoji: encodedEmoji)
|
||||
),
|
||||
using: dependencies
|
||||
)
|
||||
.map { responseInfo, _ in responseInfo }
|
||||
}
|
||||
|
||||
public static func reactionAdd(
|
||||
_ db: Database,
|
||||
emoji: String,
|
||||
id: Int64,
|
||||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, ReactionAddResponse)> {
|
||||
/// URL(String:) won't convert raw emojis, so need to do a little encoding here.
|
||||
/// The raw emoji will come back when calling url.path
|
||||
guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
||||
return Promise(error: OpenGroupAPIError.invalidEmoji)
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
request: Request<NoBody, Endpoint>(
|
||||
method: .put,
|
||||
server: server,
|
||||
endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji)
|
||||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: ReactionAddResponse.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
}
|
||||
|
||||
public static func reactionDelete(
|
||||
_ db: Database,
|
||||
emoji: String,
|
||||
id: Int64,
|
||||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, ReactionRemoveResponse)> {
|
||||
/// URL(String:) won't convert raw emojis, so need to do a little encoding here.
|
||||
/// The raw emoji will come back when calling url.path
|
||||
guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
||||
return Promise(error: OpenGroupAPIError.invalidEmoji)
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
request: Request<NoBody, Endpoint>(
|
||||
method: .delete,
|
||||
server: server,
|
||||
endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji)
|
||||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: ReactionRemoveResponse.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
}
|
||||
|
||||
public static func reactionDeleteAll(
|
||||
_ db: Database,
|
||||
emoji: String,
|
||||
id: Int64,
|
||||
in roomToken: String,
|
||||
on server: String,
|
||||
using dependencies: SMKDependencies = SMKDependencies()
|
||||
) -> Promise<(OnionRequestResponseInfoType, ReactionRemoveAllResponse)> {
|
||||
/// URL(String:) won't convert raw emojis, so need to do a little encoding here.
|
||||
/// The raw emoji will come back when calling url.path
|
||||
guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
||||
return Promise(error: OpenGroupAPIError.invalidEmoji)
|
||||
}
|
||||
|
||||
return OpenGroupAPI
|
||||
.send(
|
||||
db,
|
||||
request: Request<NoBody, Endpoint>(
|
||||
method: .delete,
|
||||
server: server,
|
||||
endpoint: .reactionDelete(roomToken, id: id, emoji: encodedEmoji)
|
||||
),
|
||||
using: dependencies
|
||||
)
|
||||
.decoded(as: ReactionRemoveAllResponse.self, on: OpenGroupAPI.workQueue, using: dependencies)
|
||||
}
|
||||
|
||||
// MARK: - Pinning
|
||||
|
||||
/// Adds a pinned message to this room
|
||||
|
|
|
@ -19,6 +19,8 @@ public protocol OGMCacheType {
|
|||
var hasPerformedInitialPoll: [String: Bool] { get set }
|
||||
var timeSinceLastPoll: [String: TimeInterval] { get set }
|
||||
|
||||
var pendingChanges: [OpenGroupAPI.PendingChange] { get set }
|
||||
|
||||
func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval
|
||||
}
|
||||
|
||||
|
@ -53,6 +55,8 @@ public final class OpenGroupManager: NSObject {
|
|||
_timeSinceLastOpen = dependencies.date.timeIntervalSince(lastOpen)
|
||||
return dependencies.date.timeIntervalSince(lastOpen)
|
||||
}
|
||||
|
||||
public var pendingChanges: [OpenGroupAPI.PendingChange] = []
|
||||
}
|
||||
|
||||
// MARK: - Variables
|
||||
|
@ -512,7 +516,6 @@ public final class OpenGroupManager: NSObject {
|
|||
messages: [OpenGroupAPI.Message],
|
||||
for roomToken: String,
|
||||
on server: String,
|
||||
isBackgroundPoll: Bool,
|
||||
dependencies: OGMDependencies = OGMDependencies()
|
||||
) {
|
||||
// Sorting the messages by server ID before importing them fixes an issue where messages
|
||||
|
@ -530,56 +533,95 @@ public final class OpenGroupManager: NSObject {
|
|||
.filter { $0.deleted == true }
|
||||
.map { $0.id }
|
||||
|
||||
// Update the 'openGroupSequenceNumber' value (Note: SOGS V4 uses the 'seqNo' instead of the 'serverId')
|
||||
if let seqNo: Int64 = seqNo {
|
||||
// Update the 'openGroupSequenceNumber' value (Note: SOGS V4 uses the 'seqNo' instead of the 'serverId')
|
||||
_ = try? OpenGroup
|
||||
.filter(id: openGroup.id)
|
||||
.updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: seqNo))
|
||||
|
||||
// Update pendingChange cache
|
||||
dependencies.mutableCache.mutate {
|
||||
$0.pendingChanges = $0.pendingChanges
|
||||
.filter { $0.seqNo == nil || $0.seqNo! > seqNo }
|
||||
}
|
||||
}
|
||||
|
||||
// Process the messages
|
||||
sortedMessages.forEach { message in
|
||||
guard
|
||||
let base64EncodedString: String = message.base64EncodedData,
|
||||
let data = Data(base64Encoded: base64EncodedString)
|
||||
else {
|
||||
// FIXME: Once the SOGS Emoji Reacts update is live we should remove this line (deprecated by the `deleted` flag)
|
||||
if message.base64EncodedData == nil && message.reactions == nil {
|
||||
messageServerIdsToRemove.append(Int64(message.id))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let processedMessage: ProcessedMessage? = try Message.processReceivedOpenGroupMessage(
|
||||
db,
|
||||
openGroupId: openGroup.id,
|
||||
openGroupServerPublicKey: openGroup.publicKey,
|
||||
message: message,
|
||||
data: data,
|
||||
dependencies: dependencies
|
||||
)
|
||||
|
||||
if let messageInfo: MessageReceiveJob.Details.MessageInfo = processedMessage?.messageInfo {
|
||||
try MessageReceiver.handle(
|
||||
// Handle messages
|
||||
if let base64EncodedString: String = message.base64EncodedData,
|
||||
let data = Data(base64Encoded: base64EncodedString)
|
||||
{
|
||||
do {
|
||||
let processedMessage: ProcessedMessage? = try Message.processReceivedOpenGroupMessage(
|
||||
db,
|
||||
message: messageInfo.message,
|
||||
associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData),
|
||||
openGroupId: openGroup.id,
|
||||
isBackgroundPoll: isBackgroundPoll,
|
||||
openGroupServerPublicKey: openGroup.publicKey,
|
||||
message: message,
|
||||
data: data,
|
||||
dependencies: dependencies
|
||||
)
|
||||
|
||||
if let messageInfo: MessageReceiveJob.Details.MessageInfo = processedMessage?.messageInfo {
|
||||
try MessageReceiver.handle(
|
||||
db,
|
||||
message: messageInfo.message,
|
||||
associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData),
|
||||
openGroupId: openGroup.id,
|
||||
dependencies: dependencies
|
||||
)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
switch error {
|
||||
// Ignore duplicate & selfSend message errors (and don't bother logging
|
||||
// them as there will be a lot since we each service node duplicates messages)
|
||||
case DatabaseError.SQLITE_CONSTRAINT_UNIQUE,
|
||||
MessageReceiverError.duplicateMessage,
|
||||
MessageReceiverError.duplicateControlMessage,
|
||||
MessageReceiverError.selfSend:
|
||||
break
|
||||
|
||||
default: SNLog("Couldn't receive open group message due to error: \(error).")
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
switch error {
|
||||
// Ignore duplicate & selfSend message errors (and don't bother logging
|
||||
// them as there will be a lot since we each service node duplicates messages)
|
||||
case DatabaseError.SQLITE_CONSTRAINT_UNIQUE,
|
||||
MessageReceiverError.duplicateMessage,
|
||||
MessageReceiverError.duplicateControlMessage,
|
||||
MessageReceiverError.selfSend:
|
||||
break
|
||||
|
||||
// Handle reactions
|
||||
if message.reactions != nil {
|
||||
do {
|
||||
let reactions: [Reaction] = Message.processRawReceivedReactions(
|
||||
db,
|
||||
openGroupId: openGroup.id,
|
||||
message: message,
|
||||
associatedPendingChanges: dependencies.cache.pendingChanges
|
||||
.filter {
|
||||
guard $0.server == server && $0.room == roomToken && $0.changeType == .reaction else {
|
||||
return false
|
||||
}
|
||||
|
||||
if case .reaction(let messageId, _, _) = $0.metadata {
|
||||
return messageId == message.id
|
||||
}
|
||||
return false
|
||||
},
|
||||
dependencies: dependencies
|
||||
)
|
||||
|
||||
default: SNLog("Couldn't receive open group message due to error: \(error).")
|
||||
try MessageReceiver.handleOpenGroupReactions(
|
||||
db,
|
||||
threadId: openGroup.threadId,
|
||||
openGroupMessageServerId: message.id,
|
||||
openGroupReactions: reactions
|
||||
)
|
||||
}
|
||||
catch {
|
||||
SNLog("Couldn't handle open group reactions due to error: \(error).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -588,6 +630,7 @@ public final class OpenGroupManager: NSObject {
|
|||
guard !messageServerIdsToRemove.isEmpty else { return }
|
||||
|
||||
_ = try? Interaction
|
||||
.filter(Interaction.Columns.threadId == openGroup.threadId)
|
||||
.filter(messageServerIdsToRemove.contains(Interaction.Columns.openGroupServerMessageId))
|
||||
.deleteAll(db)
|
||||
}
|
||||
|
@ -597,7 +640,6 @@ public final class OpenGroupManager: NSObject {
|
|||
messages: [OpenGroupAPI.DirectMessage],
|
||||
fromOutbox: Bool,
|
||||
on server: String,
|
||||
isBackgroundPoll: Bool,
|
||||
dependencies: OGMDependencies = OGMDependencies()
|
||||
) {
|
||||
// Don't need to do anything if we have no messages (it's a valid case)
|
||||
|
@ -694,7 +736,6 @@ public final class OpenGroupManager: NSObject {
|
|||
message: messageInfo.message,
|
||||
associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData),
|
||||
openGroupId: nil, // Intentionally nil as they are technically not open group messages
|
||||
isBackgroundPoll: isBackgroundPoll,
|
||||
dependencies: dependencies
|
||||
)
|
||||
}
|
||||
|
@ -718,6 +759,78 @@ public final class OpenGroupManager: NSObject {
|
|||
|
||||
// MARK: - Convenience
|
||||
|
||||
public static func addPendingReaction(
|
||||
emoji: String,
|
||||
id: Int64,
|
||||
in roomToken: String,
|
||||
on server: String,
|
||||
type: OpenGroupAPI.PendingChange.ReactAction,
|
||||
using dependencies: OGMDependencies = OGMDependencies()
|
||||
) -> OpenGroupAPI.PendingChange {
|
||||
let pendingChange = OpenGroupAPI.PendingChange(
|
||||
server: server,
|
||||
room: roomToken,
|
||||
changeType: .reaction,
|
||||
metadata: .reaction(
|
||||
messageId: id,
|
||||
emoji: emoji,
|
||||
action: type
|
||||
)
|
||||
)
|
||||
|
||||
dependencies.mutableCache.mutate {
|
||||
$0.pendingChanges.append(pendingChange)
|
||||
}
|
||||
|
||||
return pendingChange
|
||||
}
|
||||
|
||||
public static func updatePendingChange(
|
||||
_ pendingChange: OpenGroupAPI.PendingChange,
|
||||
seqNo: Int64?,
|
||||
using dependencies: OGMDependencies = OGMDependencies()
|
||||
) {
|
||||
dependencies.mutableCache.mutate {
|
||||
if let index = $0.pendingChanges.firstIndex(of: pendingChange) {
|
||||
$0.pendingChanges[index].seqNo = seqNo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func removePendingChange(
|
||||
_ pendingChange: OpenGroupAPI.PendingChange,
|
||||
using dependencies: OGMDependencies = OGMDependencies()
|
||||
) {
|
||||
dependencies.mutableCache.mutate {
|
||||
if let index = $0.pendingChanges.firstIndex(of: pendingChange) {
|
||||
$0.pendingChanges.remove(at: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This method specifies if the given capability is supported on a specified Open Group
|
||||
public static func isOpenGroupSupport(
|
||||
_ capability: Capability.Variant,
|
||||
on server: String?,
|
||||
using dependencies: OGMDependencies = OGMDependencies()
|
||||
) -> Bool {
|
||||
guard let server: String = server else { return false }
|
||||
|
||||
return dependencies.storage
|
||||
.read { db in
|
||||
let capabilities: [Capability.Variant] = (try? Capability
|
||||
.select(.variant)
|
||||
.filter(Capability.Columns.openGroupServer == server)
|
||||
.filter(Capability.Columns.isMissing == false)
|
||||
.asRequest(of: Capability.Variant.self)
|
||||
.fetchAll(db))
|
||||
.defaulting(to: [])
|
||||
|
||||
return capabilities.contains(capability)
|
||||
}
|
||||
.defaulting(to: false)
|
||||
}
|
||||
|
||||
/// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group
|
||||
public static func isUserModeratorOrAdmin(
|
||||
_ publicKey: String,
|
||||
|
@ -997,10 +1110,10 @@ public final class OpenGroupManager: NSObject {
|
|||
|
||||
extension OpenGroupManager {
|
||||
public class OGMDependencies: SMKDependencies {
|
||||
internal var _mutableCache: Atomic<OGMCacheType>?
|
||||
internal var _mutableCache: Atomic<Atomic<OGMCacheType>?>
|
||||
public var mutableCache: Atomic<OGMCacheType> {
|
||||
get { Dependencies.getValueSettingIfNull(&_mutableCache) { OpenGroupManager.shared.mutableCache } }
|
||||
set { _mutableCache = newValue }
|
||||
set { _mutableCache.mutate { $0 = newValue } }
|
||||
}
|
||||
|
||||
public var cache: OGMCacheType { return mutableCache.wrappedValue }
|
||||
|
@ -1021,7 +1134,7 @@ extension OpenGroupManager {
|
|||
standardUserDefaults: UserDefaultsType? = nil,
|
||||
date: Date? = nil
|
||||
) {
|
||||
_mutableCache = cache
|
||||
_mutableCache = Atomic(cache)
|
||||
|
||||
super.init(
|
||||
onionApi: onionApi,
|
||||
|
|
|
@ -6,12 +6,14 @@ public enum OpenGroupAPIError: LocalizedError {
|
|||
case decryptionFailed
|
||||
case signingFailed
|
||||
case noPublicKey
|
||||
case invalidEmoji
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .decryptionFailed: return "Couldn't decrypt response."
|
||||
case .signingFailed: return "Couldn't sign message."
|
||||
case .noPublicKey: return "Couldn't find server public key."
|
||||
case .invalidEmoji: return "The emoji is invalid."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,12 @@ extension OpenGroupAPI {
|
|||
case roomMessagesSince(String, seqNo: Int64)
|
||||
case roomDeleteMessages(String, sessionId: String)
|
||||
|
||||
// Reactions
|
||||
|
||||
case reactionDelete(String, id: Int64, emoji: String)
|
||||
case reaction(String, id: Int64, emoji: String)
|
||||
case reactors(String, id: Int64, emoji: String)
|
||||
|
||||
// Pinning
|
||||
|
||||
case roomPinMessage(String, id: Int64)
|
||||
|
@ -86,6 +92,17 @@ extension OpenGroupAPI {
|
|||
|
||||
case .roomDeleteMessages(let roomToken, let sessionId):
|
||||
return "room/\(roomToken)/all/\(sessionId)"
|
||||
|
||||
// Reactions
|
||||
|
||||
case .reactionDelete(let roomToken, let messageId, let emoji):
|
||||
return "room/\(roomToken)/reactions/\(messageId)/\(emoji)"
|
||||
|
||||
case .reaction(let roomToken, let messageId, let emoji):
|
||||
return "room/\(roomToken)/reaction/\(messageId)/\(emoji)"
|
||||
|
||||
case .reactors(let roomToken, let messageId, let emoji):
|
||||
return "room/\(roomToken)/reactors/\(messageId)/\(emoji)"
|
||||
|
||||
// Pinning
|
||||
|
||||
|
|
|
@ -1633,6 +1633,171 @@ extension SNProtoDataMessagePreview.SNProtoDataMessagePreviewBuilder {
|
|||
|
||||
#endif
|
||||
|
||||
// MARK: - SNProtoDataMessageReaction
|
||||
|
||||
@objc public class SNProtoDataMessageReaction: NSObject {
|
||||
|
||||
// MARK: - SNProtoDataMessageReactionAction
|
||||
|
||||
@objc public enum SNProtoDataMessageReactionAction: Int32 {
|
||||
case react = 0
|
||||
case remove = 1
|
||||
}
|
||||
|
||||
private class func SNProtoDataMessageReactionActionWrap(_ value: SessionProtos_DataMessage.Reaction.Action) -> SNProtoDataMessageReactionAction {
|
||||
switch value {
|
||||
case .react: return .react
|
||||
case .remove: return .remove
|
||||
}
|
||||
}
|
||||
|
||||
private class func SNProtoDataMessageReactionActionUnwrap(_ value: SNProtoDataMessageReactionAction) -> SessionProtos_DataMessage.Reaction.Action {
|
||||
switch value {
|
||||
case .react: return .react
|
||||
case .remove: return .remove
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SNProtoDataMessageReactionBuilder
|
||||
|
||||
@objc public class func builder(id: UInt64, author: String, action: SNProtoDataMessageReactionAction) -> SNProtoDataMessageReactionBuilder {
|
||||
return SNProtoDataMessageReactionBuilder(id: id, author: author, action: action)
|
||||
}
|
||||
|
||||
// asBuilder() constructs a builder that reflects the proto's contents.
|
||||
@objc public func asBuilder() -> SNProtoDataMessageReactionBuilder {
|
||||
let builder = SNProtoDataMessageReactionBuilder(id: id, author: author, action: action)
|
||||
if let _value = emoji {
|
||||
builder.setEmoji(_value)
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
@objc public class SNProtoDataMessageReactionBuilder: NSObject {
|
||||
|
||||
private var proto = SessionProtos_DataMessage.Reaction()
|
||||
|
||||
@objc fileprivate override init() {}
|
||||
|
||||
@objc fileprivate init(id: UInt64, author: String, action: SNProtoDataMessageReactionAction) {
|
||||
super.init()
|
||||
|
||||
setId(id)
|
||||
setAuthor(author)
|
||||
setAction(action)
|
||||
}
|
||||
|
||||
@objc public func setId(_ valueParam: UInt64) {
|
||||
proto.id = valueParam
|
||||
}
|
||||
|
||||
@objc public func setAuthor(_ valueParam: String) {
|
||||
proto.author = valueParam
|
||||
}
|
||||
|
||||
@objc public func setEmoji(_ valueParam: String) {
|
||||
proto.emoji = valueParam
|
||||
}
|
||||
|
||||
@objc public func setAction(_ valueParam: SNProtoDataMessageReactionAction) {
|
||||
proto.action = SNProtoDataMessageReactionActionUnwrap(valueParam)
|
||||
}
|
||||
|
||||
@objc public func build() throws -> SNProtoDataMessageReaction {
|
||||
return try SNProtoDataMessageReaction.parseProto(proto)
|
||||
}
|
||||
|
||||
@objc public func buildSerializedData() throws -> Data {
|
||||
return try SNProtoDataMessageReaction.parseProto(proto).serializedData()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate let proto: SessionProtos_DataMessage.Reaction
|
||||
|
||||
@objc public let id: UInt64
|
||||
|
||||
@objc public let author: String
|
||||
|
||||
@objc public let action: SNProtoDataMessageReactionAction
|
||||
|
||||
@objc public var emoji: String? {
|
||||
guard proto.hasEmoji else {
|
||||
return nil
|
||||
}
|
||||
return proto.emoji
|
||||
}
|
||||
@objc public var hasEmoji: Bool {
|
||||
return proto.hasEmoji
|
||||
}
|
||||
|
||||
private init(proto: SessionProtos_DataMessage.Reaction,
|
||||
id: UInt64,
|
||||
author: String,
|
||||
action: SNProtoDataMessageReactionAction) {
|
||||
self.proto = proto
|
||||
self.id = id
|
||||
self.author = author
|
||||
self.action = action
|
||||
}
|
||||
|
||||
@objc
|
||||
public func serializedData() throws -> Data {
|
||||
return try self.proto.serializedData()
|
||||
}
|
||||
|
||||
@objc public class func parseData(_ serializedData: Data) throws -> SNProtoDataMessageReaction {
|
||||
let proto = try SessionProtos_DataMessage.Reaction(serializedData: serializedData)
|
||||
return try parseProto(proto)
|
||||
}
|
||||
|
||||
fileprivate class func parseProto(_ proto: SessionProtos_DataMessage.Reaction) throws -> SNProtoDataMessageReaction {
|
||||
guard proto.hasID else {
|
||||
throw SNProtoError.invalidProtobuf(description: "\(logTag) missing required field: id")
|
||||
}
|
||||
let id = proto.id
|
||||
|
||||
guard proto.hasAuthor else {
|
||||
throw SNProtoError.invalidProtobuf(description: "\(logTag) missing required field: author")
|
||||
}
|
||||
let author = proto.author
|
||||
|
||||
guard proto.hasAction else {
|
||||
throw SNProtoError.invalidProtobuf(description: "\(logTag) missing required field: action")
|
||||
}
|
||||
let action = SNProtoDataMessageReactionActionWrap(proto.action)
|
||||
|
||||
// MARK: - Begin Validation Logic for SNProtoDataMessageReaction -
|
||||
|
||||
// MARK: - End Validation Logic for SNProtoDataMessageReaction -
|
||||
|
||||
let result = SNProtoDataMessageReaction(proto: proto,
|
||||
id: id,
|
||||
author: author,
|
||||
action: action)
|
||||
return result
|
||||
}
|
||||
|
||||
@objc public override var debugDescription: String {
|
||||
return "\(proto)"
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
extension SNProtoDataMessageReaction {
|
||||
@objc public func serializedDataIgnoringErrors() -> Data? {
|
||||
return try! self.serializedData()
|
||||
}
|
||||
}
|
||||
|
||||
extension SNProtoDataMessageReaction.SNProtoDataMessageReactionBuilder {
|
||||
@objc public func buildIgnoringErrors() -> SNProtoDataMessageReaction? {
|
||||
return try! self.build()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
// MARK: - SNProtoDataMessageLokiProfile
|
||||
|
||||
@objc public class SNProtoDataMessageLokiProfile: NSObject {
|
||||
|
@ -2269,6 +2434,9 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr
|
|||
builder.setQuote(_value)
|
||||
}
|
||||
builder.setPreview(preview)
|
||||
if let _value = reaction {
|
||||
builder.setReaction(_value)
|
||||
}
|
||||
if let _value = profile {
|
||||
builder.setProfile(_value)
|
||||
}
|
||||
|
@ -2338,6 +2506,10 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr
|
|||
proto.preview = wrappedItems.map { $0.proto }
|
||||
}
|
||||
|
||||
@objc public func setReaction(_ valueParam: SNProtoDataMessageReaction) {
|
||||
proto.reaction = valueParam.proto
|
||||
}
|
||||
|
||||
@objc public func setProfile(_ valueParam: SNProtoDataMessageLokiProfile) {
|
||||
proto.profile = valueParam.proto
|
||||
}
|
||||
|
@ -2373,6 +2545,8 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr
|
|||
|
||||
@objc public let preview: [SNProtoDataMessagePreview]
|
||||
|
||||
@objc public let reaction: SNProtoDataMessageReaction?
|
||||
|
||||
@objc public let profile: SNProtoDataMessageLokiProfile?
|
||||
|
||||
@objc public let openGroupInvitation: SNProtoDataMessageOpenGroupInvitation?
|
||||
|
@ -2435,6 +2609,7 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr
|
|||
group: SNProtoGroupContext?,
|
||||
quote: SNProtoDataMessageQuote?,
|
||||
preview: [SNProtoDataMessagePreview],
|
||||
reaction: SNProtoDataMessageReaction?,
|
||||
profile: SNProtoDataMessageLokiProfile?,
|
||||
openGroupInvitation: SNProtoDataMessageOpenGroupInvitation?,
|
||||
closedGroupControlMessage: SNProtoDataMessageClosedGroupControlMessage?) {
|
||||
|
@ -2443,6 +2618,7 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr
|
|||
self.group = group
|
||||
self.quote = quote
|
||||
self.preview = preview
|
||||
self.reaction = reaction
|
||||
self.profile = profile
|
||||
self.openGroupInvitation = openGroupInvitation
|
||||
self.closedGroupControlMessage = closedGroupControlMessage
|
||||
|
@ -2475,6 +2651,11 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr
|
|||
var preview: [SNProtoDataMessagePreview] = []
|
||||
preview = try proto.preview.map { try SNProtoDataMessagePreview.parseProto($0) }
|
||||
|
||||
var reaction: SNProtoDataMessageReaction? = nil
|
||||
if proto.hasReaction {
|
||||
reaction = try SNProtoDataMessageReaction.parseProto(proto.reaction)
|
||||
}
|
||||
|
||||
var profile: SNProtoDataMessageLokiProfile? = nil
|
||||
if proto.hasProfile {
|
||||
profile = try SNProtoDataMessageLokiProfile.parseProto(proto.profile)
|
||||
|
@ -2499,6 +2680,7 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr
|
|||
group: group,
|
||||
quote: quote,
|
||||
preview: preview,
|
||||
reaction: reaction,
|
||||
profile: profile,
|
||||
openGroupInvitation: openGroupInvitation,
|
||||
closedGroupControlMessage: closedGroupControlMessage)
|
||||
|
|
|
@ -599,6 +599,15 @@ struct SessionProtos_DataMessage {
|
|||
set {_uniqueStorage()._preview = newValue}
|
||||
}
|
||||
|
||||
var reaction: SessionProtos_DataMessage.Reaction {
|
||||
get {return _storage._reaction ?? SessionProtos_DataMessage.Reaction()}
|
||||
set {_uniqueStorage()._reaction = newValue}
|
||||
}
|
||||
/// Returns true if `reaction` has been explicitly set.
|
||||
var hasReaction: Bool {return _storage._reaction != nil}
|
||||
/// Clears the value of `reaction`. Subsequent reads from it will return its default value.
|
||||
mutating func clearReaction() {_uniqueStorage()._reaction = nil}
|
||||
|
||||
var profile: SessionProtos_DataMessage.LokiProfile {
|
||||
get {return _storage._profile ?? SessionProtos_DataMessage.LokiProfile()}
|
||||
set {_uniqueStorage()._profile = newValue}
|
||||
|
@ -821,6 +830,86 @@ struct SessionProtos_DataMessage {
|
|||
fileprivate var _image: SessionProtos_AttachmentPointer? = nil
|
||||
}
|
||||
|
||||
struct Reaction {
|
||||
// SwiftProtobuf.Message conformance is added in an extension below. See the
|
||||
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
||||
// methods supported on all messages.
|
||||
|
||||
/// @required
|
||||
var id: UInt64 {
|
||||
get {return _id ?? 0}
|
||||
set {_id = newValue}
|
||||
}
|
||||
/// Returns true if `id` has been explicitly set.
|
||||
var hasID: Bool {return self._id != nil}
|
||||
/// Clears the value of `id`. Subsequent reads from it will return its default value.
|
||||
mutating func clearID() {self._id = nil}
|
||||
|
||||
/// @required
|
||||
var author: String {
|
||||
get {return _author ?? String()}
|
||||
set {_author = newValue}
|
||||
}
|
||||
/// Returns true if `author` has been explicitly set.
|
||||
var hasAuthor: Bool {return self._author != nil}
|
||||
/// Clears the value of `author`. Subsequent reads from it will return its default value.
|
||||
mutating func clearAuthor() {self._author = nil}
|
||||
|
||||
var emoji: String {
|
||||
get {return _emoji ?? String()}
|
||||
set {_emoji = newValue}
|
||||
}
|
||||
/// Returns true if `emoji` has been explicitly set.
|
||||
var hasEmoji: Bool {return self._emoji != nil}
|
||||
/// Clears the value of `emoji`. Subsequent reads from it will return its default value.
|
||||
mutating func clearEmoji() {self._emoji = nil}
|
||||
|
||||
/// @required
|
||||
var action: SessionProtos_DataMessage.Reaction.Action {
|
||||
get {return _action ?? .react}
|
||||
set {_action = newValue}
|
||||
}
|
||||
/// Returns true if `action` has been explicitly set.
|
||||
var hasAction: Bool {return self._action != nil}
|
||||
/// Clears the value of `action`. Subsequent reads from it will return its default value.
|
||||
mutating func clearAction() {self._action = nil}
|
||||
|
||||
var unknownFields = SwiftProtobuf.UnknownStorage()
|
||||
|
||||
enum Action: SwiftProtobuf.Enum {
|
||||
typealias RawValue = Int
|
||||
case react // = 0
|
||||
case remove // = 1
|
||||
|
||||
init() {
|
||||
self = .react
|
||||
}
|
||||
|
||||
init?(rawValue: Int) {
|
||||
switch rawValue {
|
||||
case 0: self = .react
|
||||
case 1: self = .remove
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var rawValue: Int {
|
||||
switch self {
|
||||
case .react: return 0
|
||||
case .remove: return 1
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
fileprivate var _id: UInt64? = nil
|
||||
fileprivate var _author: String? = nil
|
||||
fileprivate var _emoji: String? = nil
|
||||
fileprivate var _action: SessionProtos_DataMessage.Reaction.Action? = nil
|
||||
}
|
||||
|
||||
struct LokiProfile {
|
||||
// SwiftProtobuf.Message conformance is added in an extension below. See the
|
||||
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
||||
|
@ -1052,6 +1141,10 @@ extension SessionProtos_DataMessage.Quote.QuotedAttachment.Flags: CaseIterable {
|
|||
// Support synthesized by the compiler.
|
||||
}
|
||||
|
||||
extension SessionProtos_DataMessage.Reaction.Action: CaseIterable {
|
||||
// Support synthesized by the compiler.
|
||||
}
|
||||
|
||||
extension SessionProtos_DataMessage.ClosedGroupControlMessage.TypeEnum: CaseIterable {
|
||||
// Support synthesized by the compiler.
|
||||
}
|
||||
|
@ -2094,6 +2187,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa
|
|||
7: .same(proto: "timestamp"),
|
||||
8: .same(proto: "quote"),
|
||||
10: .same(proto: "preview"),
|
||||
11: .same(proto: "reaction"),
|
||||
101: .same(proto: "profile"),
|
||||
102: .same(proto: "openGroupInvitation"),
|
||||
104: .same(proto: "closedGroupControlMessage"),
|
||||
|
@ -2110,6 +2204,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa
|
|||
var _timestamp: UInt64? = nil
|
||||
var _quote: SessionProtos_DataMessage.Quote? = nil
|
||||
var _preview: [SessionProtos_DataMessage.Preview] = []
|
||||
var _reaction: SessionProtos_DataMessage.Reaction? = nil
|
||||
var _profile: SessionProtos_DataMessage.LokiProfile? = nil
|
||||
var _openGroupInvitation: SessionProtos_DataMessage.OpenGroupInvitation? = nil
|
||||
var _closedGroupControlMessage: SessionProtos_DataMessage.ClosedGroupControlMessage? = nil
|
||||
|
@ -2129,6 +2224,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa
|
|||
_timestamp = source._timestamp
|
||||
_quote = source._quote
|
||||
_preview = source._preview
|
||||
_reaction = source._reaction
|
||||
_profile = source._profile
|
||||
_openGroupInvitation = source._openGroupInvitation
|
||||
_closedGroupControlMessage = source._closedGroupControlMessage
|
||||
|
@ -2149,6 +2245,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa
|
|||
if let v = _storage._group, !v.isInitialized {return false}
|
||||
if let v = _storage._quote, !v.isInitialized {return false}
|
||||
if !SwiftProtobuf.Internal.areAllInitialized(_storage._preview) {return false}
|
||||
if let v = _storage._reaction, !v.isInitialized {return false}
|
||||
if let v = _storage._openGroupInvitation, !v.isInitialized {return false}
|
||||
if let v = _storage._closedGroupControlMessage, !v.isInitialized {return false}
|
||||
return true
|
||||
|
@ -2172,6 +2269,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa
|
|||
case 7: try { try decoder.decodeSingularUInt64Field(value: &_storage._timestamp) }()
|
||||
case 8: try { try decoder.decodeSingularMessageField(value: &_storage._quote) }()
|
||||
case 10: try { try decoder.decodeRepeatedMessageField(value: &_storage._preview) }()
|
||||
case 11: try { try decoder.decodeSingularMessageField(value: &_storage._reaction) }()
|
||||
case 101: try { try decoder.decodeSingularMessageField(value: &_storage._profile) }()
|
||||
case 102: try { try decoder.decodeSingularMessageField(value: &_storage._openGroupInvitation) }()
|
||||
case 104: try { try decoder.decodeSingularMessageField(value: &_storage._closedGroupControlMessage) }()
|
||||
|
@ -2211,6 +2309,9 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa
|
|||
if !_storage._preview.isEmpty {
|
||||
try visitor.visitRepeatedMessageField(value: _storage._preview, fieldNumber: 10)
|
||||
}
|
||||
if let v = _storage._reaction {
|
||||
try visitor.visitSingularMessageField(value: v, fieldNumber: 11)
|
||||
}
|
||||
if let v = _storage._profile {
|
||||
try visitor.visitSingularMessageField(value: v, fieldNumber: 101)
|
||||
}
|
||||
|
@ -2241,6 +2342,7 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa
|
|||
if _storage._timestamp != rhs_storage._timestamp {return false}
|
||||
if _storage._quote != rhs_storage._quote {return false}
|
||||
if _storage._preview != rhs_storage._preview {return false}
|
||||
if _storage._reaction != rhs_storage._reaction {return false}
|
||||
if _storage._profile != rhs_storage._profile {return false}
|
||||
if _storage._openGroupInvitation != rhs_storage._openGroupInvitation {return false}
|
||||
if _storage._closedGroupControlMessage != rhs_storage._closedGroupControlMessage {return false}
|
||||
|
@ -2428,6 +2530,70 @@ extension SessionProtos_DataMessage.Preview: SwiftProtobuf.Message, SwiftProtobu
|
|||
}
|
||||
}
|
||||
|
||||
extension SessionProtos_DataMessage.Reaction: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
||||
static let protoMessageName: String = SessionProtos_DataMessage.protoMessageName + ".Reaction"
|
||||
static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
||||
1: .same(proto: "id"),
|
||||
2: .same(proto: "author"),
|
||||
3: .same(proto: "emoji"),
|
||||
4: .same(proto: "action"),
|
||||
]
|
||||
|
||||
public var isInitialized: Bool {
|
||||
if self._id == nil {return false}
|
||||
if self._author == nil {return false}
|
||||
if self._action == nil {return false}
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
|
||||
while let fieldNumber = try decoder.nextFieldNumber() {
|
||||
// The use of inline closures is to circumvent an issue where the compiler
|
||||
// allocates stack space for every case branch when no optimizations are
|
||||
// enabled. https://github.com/apple/swift-protobuf/issues/1034
|
||||
switch fieldNumber {
|
||||
case 1: try { try decoder.decodeSingularUInt64Field(value: &self._id) }()
|
||||
case 2: try { try decoder.decodeSingularStringField(value: &self._author) }()
|
||||
case 3: try { try decoder.decodeSingularStringField(value: &self._emoji) }()
|
||||
case 4: try { try decoder.decodeSingularEnumField(value: &self._action) }()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
|
||||
if let v = self._id {
|
||||
try visitor.visitSingularUInt64Field(value: v, fieldNumber: 1)
|
||||
}
|
||||
if let v = self._author {
|
||||
try visitor.visitSingularStringField(value: v, fieldNumber: 2)
|
||||
}
|
||||
if let v = self._emoji {
|
||||
try visitor.visitSingularStringField(value: v, fieldNumber: 3)
|
||||
}
|
||||
if let v = self._action {
|
||||
try visitor.visitSingularEnumField(value: v, fieldNumber: 4)
|
||||
}
|
||||
try unknownFields.traverse(visitor: &visitor)
|
||||
}
|
||||
|
||||
static func ==(lhs: SessionProtos_DataMessage.Reaction, rhs: SessionProtos_DataMessage.Reaction) -> Bool {
|
||||
if lhs._id != rhs._id {return false}
|
||||
if lhs._author != rhs._author {return false}
|
||||
if lhs._emoji != rhs._emoji {return false}
|
||||
if lhs._action != rhs._action {return false}
|
||||
if lhs.unknownFields != rhs.unknownFields {return false}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension SessionProtos_DataMessage.Reaction.Action: SwiftProtobuf._ProtoNameProviding {
|
||||
static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
||||
0: .same(proto: "REACT"),
|
||||
1: .same(proto: "REMOVE"),
|
||||
]
|
||||
}
|
||||
|
||||
extension SessionProtos_DataMessage.LokiProfile: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
||||
static let protoMessageName: String = SessionProtos_DataMessage.protoMessageName + ".LokiProfile"
|
||||
static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
|
||||
|
|
|
@ -133,6 +133,20 @@ message DataMessage {
|
|||
optional AttachmentPointer image = 3;
|
||||
}
|
||||
|
||||
message Reaction {
|
||||
enum Action {
|
||||
REACT = 0;
|
||||
REMOVE = 1;
|
||||
}
|
||||
// @required
|
||||
required uint64 id = 1; // Message timestamp
|
||||
// @required
|
||||
required string author = 2;
|
||||
optional string emoji = 3;
|
||||
// @required
|
||||
required Action action = 4;
|
||||
}
|
||||
|
||||
message LokiProfile {
|
||||
optional string displayName = 1;
|
||||
optional string profilePicture = 2;
|
||||
|
@ -184,6 +198,7 @@ message DataMessage {
|
|||
optional uint64 timestamp = 7;
|
||||
optional Quote quote = 8;
|
||||
repeated Preview preview = 10;
|
||||
optional Reaction reaction = 11;
|
||||
optional LokiProfile profile = 101;
|
||||
optional OpenGroupInvitation openGroupInvitation = 102;
|
||||
optional ClosedGroupControlMessage closedGroupControlMessage = 104;
|
||||
|
|
|
@ -21,7 +21,11 @@ extension MessageReceiver {
|
|||
case .screenshot: return .infoScreenshotNotification
|
||||
case .mediaSaved: return .infoMediaSavedNotification
|
||||
}
|
||||
}()
|
||||
}(),
|
||||
timestampMs: (
|
||||
message.sentTimestamp.map { Int64($0) } ??
|
||||
Int64(floor(Date().timeIntervalSince1970 * 1000))
|
||||
)
|
||||
).inserted(db)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,6 @@ extension MessageReceiver {
|
|||
message: VisibleMessage,
|
||||
associatedWithProto proto: SNProtoContent,
|
||||
openGroupId: String?,
|
||||
isBackgroundPoll: Bool,
|
||||
dependencies: Dependencies = Dependencies()
|
||||
) throws -> Int64 {
|
||||
guard let sender: String = message.sender, let dataMessage = proto.dataMessage else {
|
||||
|
@ -88,6 +87,11 @@ extension MessageReceiver {
|
|||
}
|
||||
}()
|
||||
|
||||
// Handle emoji reacts first (otherwise it's essentially an invalid message)
|
||||
if let interactionId: Int64 = try handleEmojiReactIfNeeded(db, message: message, associatedWithProto: proto, sender: sender, messageSentTimestamp: messageSentTimestamp, openGroupId: openGroupId, thread: thread) {
|
||||
return interactionId
|
||||
}
|
||||
|
||||
// Retrieve the disappearing messages config to set the 'expiresInSeconds' value
|
||||
// accoring to the config
|
||||
let disappearingMessagesConfiguration: DisappearingMessagesConfiguration = (try? thread.disappearingMessagesConfiguration.fetchOne(db))
|
||||
|
@ -285,13 +289,75 @@ extension MessageReceiver {
|
|||
.notifyUser(
|
||||
db,
|
||||
for: interaction,
|
||||
in: thread,
|
||||
isBackgroundPoll: isBackgroundPoll
|
||||
in: thread
|
||||
)
|
||||
|
||||
return interactionId
|
||||
}
|
||||
|
||||
private static func handleEmojiReactIfNeeded(
|
||||
_ db: Database,
|
||||
message: VisibleMessage,
|
||||
associatedWithProto proto: SNProtoContent,
|
||||
sender: String,
|
||||
messageSentTimestamp: TimeInterval,
|
||||
openGroupId: String?,
|
||||
thread: SessionThread
|
||||
) throws -> Int64? {
|
||||
guard
|
||||
let reaction: VisibleMessage.VMReaction = message.reaction,
|
||||
proto.dataMessage?.reaction != nil
|
||||
else { return nil }
|
||||
|
||||
let maybeInteractionId: Int64? = try? Interaction
|
||||
.select(.id)
|
||||
.filter(Interaction.Columns.threadId == thread.id)
|
||||
.filter(Interaction.Columns.timestampMs == reaction.timestamp)
|
||||
.filter(Interaction.Columns.authorId == reaction.publicKey)
|
||||
.filter(Interaction.Columns.variant != Interaction.Variant.standardIncomingDeleted)
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchOne(db)
|
||||
|
||||
guard let interactionId: Int64 = maybeInteractionId else {
|
||||
throw StorageError.objectNotFound
|
||||
}
|
||||
|
||||
let sortId = Reaction.getSortId(
|
||||
db,
|
||||
interactionId: interactionId,
|
||||
emoji: reaction.emoji
|
||||
)
|
||||
|
||||
switch reaction.kind {
|
||||
case .react:
|
||||
let reaction = Reaction(
|
||||
interactionId: interactionId,
|
||||
serverHash: message.serverHash,
|
||||
timestampMs: Int64(messageSentTimestamp * 1000),
|
||||
authorId: sender,
|
||||
emoji: reaction.emoji,
|
||||
count: 1,
|
||||
sortId: sortId
|
||||
)
|
||||
try reaction.insert(db)
|
||||
Environment.shared?.notificationsManager.wrappedValue?
|
||||
.notifyUser(
|
||||
db,
|
||||
forReaction: reaction,
|
||||
in: thread
|
||||
)
|
||||
|
||||
case .remove:
|
||||
try Reaction
|
||||
.filter(Reaction.Columns.interactionId == interactionId)
|
||||
.filter(Reaction.Columns.authorId == sender)
|
||||
.filter(Reaction.Columns.emoji == reaction.emoji)
|
||||
.deleteAll(db)
|
||||
}
|
||||
|
||||
return interactionId
|
||||
}
|
||||
|
||||
private static func updateRecipientAndReadStates(
|
||||
_ db: Database,
|
||||
thread: SessionThread,
|
||||
|
|
|
@ -180,7 +180,6 @@ public enum MessageReceiver {
|
|||
message: Message,
|
||||
associatedWithProto proto: SNProtoContent,
|
||||
openGroupId: String?,
|
||||
isBackgroundPoll: Bool,
|
||||
dependencies: SMKDependencies = SMKDependencies()
|
||||
) throws {
|
||||
switch message {
|
||||
|
@ -206,7 +205,7 @@ public enum MessageReceiver {
|
|||
try MessageReceiver.handleUnsendRequest(db, message: message)
|
||||
|
||||
case let message as CallMessage:
|
||||
try MessageReceiver.handleCallMessage(db, message: message)
|
||||
try MessageReceiver.handleCallMessage(db, message: message)
|
||||
|
||||
case let message as MessageRequestResponse:
|
||||
try MessageReceiver.handleMessageRequestResponse(db, message: message, dependencies: dependencies)
|
||||
|
@ -216,8 +215,7 @@ public enum MessageReceiver {
|
|||
db,
|
||||
message: message,
|
||||
associatedWithProto: proto,
|
||||
openGroupId: openGroupId,
|
||||
isBackgroundPoll: isBackgroundPoll
|
||||
openGroupId: openGroupId
|
||||
)
|
||||
|
||||
default: fatalError()
|
||||
|
@ -249,6 +247,31 @@ public enum MessageReceiver {
|
|||
}
|
||||
}
|
||||
|
||||
public static func handleOpenGroupReactions(
|
||||
_ db: Database,
|
||||
threadId: String,
|
||||
openGroupMessageServerId: Int64,
|
||||
openGroupReactions: [Reaction]
|
||||
) throws {
|
||||
guard let interactionId: Int64 = try? Interaction
|
||||
.select(.id)
|
||||
.filter(Interaction.Columns.threadId == threadId)
|
||||
.filter(Interaction.Columns.openGroupServerMessageId == openGroupMessageServerId)
|
||||
.asRequest(of: Int64.self)
|
||||
.fetchOne(db)
|
||||
else {
|
||||
throw MessageReceiverError.invalidMessage
|
||||
}
|
||||
|
||||
_ = try Reaction
|
||||
.filter(Reaction.Columns.interactionId == interactionId)
|
||||
.deleteAll(db)
|
||||
|
||||
for reaction in openGroupReactions {
|
||||
try reaction.with(interactionId: interactionId).insert(db)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience
|
||||
|
||||
internal static func threadInfo(_ db: Database, message: Message, openGroupId: String?) -> (id: String, variant: SessionThread.Variant)? {
|
||||
|
|
|
@ -433,7 +433,8 @@ public final class MessageSender {
|
|||
)
|
||||
.done(on: DispatchQueue.global(qos: .default)) { responseInfo, data in
|
||||
message.openGroupServerMessageId = UInt64(data.id)
|
||||
|
||||
let serverTimestampMs: UInt64? = data.posted.map { UInt64(floor($0 * 1000)) }
|
||||
|
||||
dependencies.storage.write { db in
|
||||
// The `posted` value is in seconds but we sent it in ms so need that for de-duping
|
||||
try MessageSender.handleSuccessfulMessageSend(
|
||||
|
@ -441,7 +442,7 @@ public final class MessageSender {
|
|||
message: message,
|
||||
to: destination,
|
||||
interactionId: interactionId,
|
||||
serverTimestampMs: UInt64(floor(data.posted * 1000))
|
||||
serverTimestampMs: serverTimestampMs
|
||||
)
|
||||
seal.fulfill(())
|
||||
}
|
||||
|
@ -575,39 +576,51 @@ public final class MessageSender {
|
|||
serverTimestampMs: UInt64? = nil,
|
||||
isSyncMessage: Bool = false
|
||||
) throws {
|
||||
let interaction: Interaction? = try interaction(db, for: message, interactionId: interactionId)
|
||||
|
||||
// Get the visible message if possible
|
||||
if let interaction: Interaction = interaction {
|
||||
// When the sync message is successfully sent, the hash value of this TSOutgoingMessage
|
||||
// will be replaced by the hash value of the sync message. Since the hash value of the
|
||||
// real message has no use when we delete a message. It is OK to let it be.
|
||||
try interaction.with(
|
||||
serverHash: message.serverHash,
|
||||
// If the message was a reaction then we want to update the reaction instead of the original
|
||||
// interaciton (which the 'interactionId' is pointing to
|
||||
if let visibleMessage: VisibleMessage = message as? VisibleMessage, let reaction: VisibleMessage.VMReaction = visibleMessage.reaction {
|
||||
try Reaction
|
||||
.filter(Reaction.Columns.interactionId == interactionId)
|
||||
.filter(Reaction.Columns.authorId == reaction.publicKey)
|
||||
.filter(Reaction.Columns.emoji == reaction.emoji)
|
||||
.updateAll(db, Reaction.Columns.serverHash.set(to: message.serverHash))
|
||||
}
|
||||
else {
|
||||
// Otherwise we do want to try and update the referenced interaction
|
||||
let interaction: Interaction? = try interaction(db, for: message, interactionId: interactionId)
|
||||
|
||||
// Get the visible message if possible
|
||||
if let interaction: Interaction = interaction {
|
||||
// When the sync message is successfully sent, the hash value of this TSOutgoingMessage
|
||||
// will be replaced by the hash value of the sync message. Since the hash value of the
|
||||
// real message has no use when we delete a message. It is OK to let it be.
|
||||
try interaction.with(
|
||||
serverHash: message.serverHash,
|
||||
|
||||
// Track the open group server message ID and update server timestamp (use server
|
||||
// timestamp for open group messages otherwise the quote messages may not be able
|
||||
// to be found by the timestamp on other devices
|
||||
timestampMs: (message.openGroupServerMessageId == nil ?
|
||||
nil :
|
||||
serverTimestampMs.map { Int64($0) }
|
||||
),
|
||||
openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) }
|
||||
).update(db)
|
||||
|
||||
// Track the open group server message ID and update server timestamp (use server
|
||||
// timestamp for open group messages otherwise the quote messages may not be able
|
||||
// to be found by the timestamp on other devices
|
||||
timestampMs: (message.openGroupServerMessageId == nil ?
|
||||
nil :
|
||||
serverTimestampMs.map { Int64($0) }
|
||||
),
|
||||
openGroupServerMessageId: message.openGroupServerMessageId.map { Int64($0) }
|
||||
).update(db)
|
||||
|
||||
// Mark the message as sent
|
||||
try interaction.recipientStates
|
||||
.updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.sent))
|
||||
|
||||
// Start the disappearing messages timer if needed
|
||||
JobRunner.upsert(
|
||||
db,
|
||||
job: DisappearingMessagesJob.updateNextRunIfNeeded(
|
||||
// Mark the message as sent
|
||||
try interaction.recipientStates
|
||||
.updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.sent))
|
||||
|
||||
// Start the disappearing messages timer if needed
|
||||
JobRunner.upsert(
|
||||
db,
|
||||
interaction: interaction,
|
||||
startedAtMs: (Date().timeIntervalSince1970 * 1000)
|
||||
job: DisappearingMessagesJob.updateNextRunIfNeeded(
|
||||
db,
|
||||
interaction: interaction,
|
||||
startedAtMs: (Date().timeIntervalSince1970 * 1000)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent ControlMessages from being handled multiple times if not supported
|
||||
|
@ -652,6 +665,11 @@ public final class MessageSender {
|
|||
with error: MessageSenderError,
|
||||
interactionId: Int64?
|
||||
) {
|
||||
// TODO: Revert the local database change
|
||||
// If the message was a reaction then we don't want to do anything to the original
|
||||
// interaciton (which the 'interactionId' is pointing to
|
||||
guard (message as? VisibleMessage)?.reaction == nil else { return }
|
||||
|
||||
// Check if we need to mark any "sending" recipients as "failed"
|
||||
//
|
||||
// Note: The 'db' could be either read-only or writeable so we determine
|
||||
|
|
|
@ -4,8 +4,15 @@ import Foundation
|
|||
import GRDB
|
||||
|
||||
public protocol NotificationsProtocol {
|
||||
func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool)
|
||||
func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread)
|
||||
func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread)
|
||||
func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread)
|
||||
func cancelNotifications(identifiers: [String])
|
||||
func clearAllNotifications()
|
||||
}
|
||||
|
||||
public enum Notifications {
|
||||
/// Delay notification of incoming messages when we want to group them (eg. during background polling) to avoid
|
||||
/// firing too many notifications at the same time
|
||||
public static let delayForGroupedNotifications: TimeInterval = 5
|
||||
}
|
||||
|
|
|
@ -145,7 +145,7 @@ public final class ClosedGroupPoller {
|
|||
_ groupPublicKey: String,
|
||||
on queue: DispatchQueue = SessionSnodeKit.Threading.workQueue,
|
||||
maxRetryCount: UInt = 0,
|
||||
isBackgroundPoll: Bool = false,
|
||||
calledFromBackgroundPoller: Bool = false,
|
||||
isBackgroundPollValid: @escaping (() -> Bool) = { true },
|
||||
poller: ClosedGroupPoller? = nil
|
||||
) -> Promise<Void> {
|
||||
|
@ -156,7 +156,7 @@ public final class ClosedGroupPoller {
|
|||
|
||||
return attempt(maxRetryCount: maxRetryCount, recoveringOn: queue) {
|
||||
guard
|
||||
(isBackgroundPoll && isBackgroundPollValid()) ||
|
||||
(calledFromBackgroundPoller && isBackgroundPollValid()) ||
|
||||
poller?.isPolling.wrappedValue[groupPublicKey] == true
|
||||
else { return Promise(error: Error.pollingCanceled) }
|
||||
|
||||
|
@ -178,7 +178,7 @@ public final class ClosedGroupPoller {
|
|||
return when(resolved: promises)
|
||||
.then(on: queue) { messageResults -> Promise<Void> in
|
||||
guard
|
||||
(isBackgroundPoll && isBackgroundPollValid()) ||
|
||||
(calledFromBackgroundPoller && isBackgroundPollValid()) ||
|
||||
poller?.isPolling.wrappedValue[groupPublicKey] == true
|
||||
else { return Promise.value(()) }
|
||||
|
||||
|
@ -195,7 +195,7 @@ public final class ClosedGroupPoller {
|
|||
|
||||
// No need to do anything if there are no messages
|
||||
guard !allMessages.isEmpty else {
|
||||
if !isBackgroundPoll {
|
||||
if !calledFromBackgroundPoller {
|
||||
SNLog("Received no new messages in closed group with public key: \(groupPublicKey)")
|
||||
}
|
||||
return Promise.value(())
|
||||
|
@ -221,7 +221,7 @@ public final class ClosedGroupPoller {
|
|||
// In the background ignore 'SQLITE_ABORT' (it generally means
|
||||
// the BackgroundPoller has timed out
|
||||
case DatabaseError.SQLITE_ABORT:
|
||||
guard !isBackgroundPoll else { break }
|
||||
guard !calledFromBackgroundPoller else { break }
|
||||
|
||||
SNLog("Failed to the database being suspended (running in background with no background task).")
|
||||
break
|
||||
|
@ -241,16 +241,16 @@ public final class ClosedGroupPoller {
|
|||
threadId: groupPublicKey,
|
||||
details: MessageReceiveJob.Details(
|
||||
messages: processedMessages.map { $0.messageInfo },
|
||||
isBackgroundPoll: isBackgroundPoll
|
||||
calledFromBackgroundPoller: calledFromBackgroundPoller
|
||||
)
|
||||
)
|
||||
|
||||
// If we are force-polling then add to the JobRunner so they are persistent and will retry on
|
||||
// the next app run if they fail but don't let them auto-start
|
||||
JobRunner.add(db, job: jobToRun, canStartJob: !isBackgroundPoll)
|
||||
JobRunner.add(db, job: jobToRun, canStartJob: !calledFromBackgroundPoller)
|
||||
}
|
||||
|
||||
if isBackgroundPoll {
|
||||
if calledFromBackgroundPoller {
|
||||
// We want to try to handle the receive jobs immediately in the background
|
||||
promises = promises.appending(
|
||||
jobToRun.map { job -> Promise<Void> in
|
||||
|
@ -278,7 +278,7 @@ public final class ClosedGroupPoller {
|
|||
}
|
||||
}
|
||||
|
||||
if !isBackgroundPoll {
|
||||
if !calledFromBackgroundPoller {
|
||||
promise.catch2 { error in
|
||||
SNLog("Polling failed for closed group with public key: \(groupPublicKey) due to error: \(error).")
|
||||
}
|
||||
|
|
|
@ -67,12 +67,12 @@ extension OpenGroupAPI {
|
|||
|
||||
@discardableResult
|
||||
public func poll(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) -> Promise<Void> {
|
||||
return poll(isBackgroundPoll: false, isPostCapabilitiesRetry: false, using: dependencies)
|
||||
return poll(calledFromBackgroundPoller: false, isPostCapabilitiesRetry: false, using: dependencies)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func poll(
|
||||
isBackgroundPoll: Bool,
|
||||
calledFromBackgroundPoller: Bool,
|
||||
isBackgroundPollerValid: @escaping (() -> Bool) = { true },
|
||||
isPostCapabilitiesRetry: Bool,
|
||||
using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()
|
||||
|
@ -107,7 +107,7 @@ extension OpenGroupAPI {
|
|||
.map(on: OpenGroupAPI.workQueue) { (failureCount, $0) }
|
||||
}
|
||||
.done(on: OpenGroupAPI.workQueue) { [weak self] failureCount, response in
|
||||
guard !isBackgroundPoll || isBackgroundPollerValid() else {
|
||||
guard !calledFromBackgroundPoller || isBackgroundPollerValid() else {
|
||||
// If this was a background poll and the background poll is no longer valid
|
||||
// then just stop
|
||||
self?.isPolling = false
|
||||
|
@ -119,7 +119,6 @@ extension OpenGroupAPI {
|
|||
self?.handlePollResponse(
|
||||
response,
|
||||
failureCount: failureCount,
|
||||
isBackgroundPoll: isBackgroundPoll,
|
||||
using: dependencies
|
||||
)
|
||||
|
||||
|
@ -133,7 +132,7 @@ extension OpenGroupAPI {
|
|||
seal.fulfill(())
|
||||
}
|
||||
.catch(on: OpenGroupAPI.workQueue) { [weak self] error in
|
||||
guard !isBackgroundPoll || isBackgroundPollerValid() else {
|
||||
guard !calledFromBackgroundPoller || isBackgroundPollerValid() else {
|
||||
// If this was a background poll and the background poll is no longer valid
|
||||
// then just stop
|
||||
self?.isPolling = false
|
||||
|
@ -145,7 +144,8 @@ extension OpenGroupAPI {
|
|||
// method will always resolve)
|
||||
self?.updateCapabilitiesAndRetryIfNeeded(
|
||||
server: server,
|
||||
isBackgroundPoll: isBackgroundPoll,
|
||||
calledFromBackgroundPoller: calledFromBackgroundPoller,
|
||||
isBackgroundPollerValid: isBackgroundPollerValid,
|
||||
isPostCapabilitiesRetry: isPostCapabilitiesRetry,
|
||||
error: error
|
||||
)
|
||||
|
@ -186,7 +186,8 @@ extension OpenGroupAPI {
|
|||
|
||||
private func updateCapabilitiesAndRetryIfNeeded(
|
||||
server: String,
|
||||
isBackgroundPoll: Bool,
|
||||
calledFromBackgroundPoller: Bool,
|
||||
isBackgroundPollerValid: @escaping (() -> Bool) = { true },
|
||||
isPostCapabilitiesRetry: Bool,
|
||||
error: Error,
|
||||
using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()
|
||||
|
@ -233,7 +234,8 @@ extension OpenGroupAPI {
|
|||
// Regardless of the outcome we can just resolve this
|
||||
// immediately as it'll handle it's own response
|
||||
return strongSelf.poll(
|
||||
isBackgroundPoll: isBackgroundPoll,
|
||||
calledFromBackgroundPoller: calledFromBackgroundPoller,
|
||||
isBackgroundPollerValid: isBackgroundPollerValid,
|
||||
isPostCapabilitiesRetry: true,
|
||||
using: dependencies
|
||||
)
|
||||
|
@ -251,7 +253,6 @@ extension OpenGroupAPI {
|
|||
private func handlePollResponse(
|
||||
_ response: PollResponse,
|
||||
failureCount: Int64,
|
||||
isBackgroundPoll: Bool,
|
||||
using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()
|
||||
) {
|
||||
let server: String = self.server
|
||||
|
@ -440,7 +441,6 @@ extension OpenGroupAPI {
|
|||
messages: responseBody.compactMap { $0.value },
|
||||
for: roomToken,
|
||||
on: server,
|
||||
isBackgroundPoll: isBackgroundPoll,
|
||||
dependencies: dependencies
|
||||
)
|
||||
|
||||
|
@ -464,7 +464,6 @@ extension OpenGroupAPI {
|
|||
messages: messages,
|
||||
fromOutbox: fromOutbox,
|
||||
on: server,
|
||||
isBackgroundPoll: isBackgroundPoll,
|
||||
dependencies: dependencies
|
||||
)
|
||||
|
||||
|
|
|
@ -89,7 +89,7 @@ public final class Poller {
|
|||
|
||||
private func pollNextSnode(seal: Resolver<Void>) {
|
||||
let userPublicKey = getUserHexEncodedPublicKey()
|
||||
let swarm = SnodeAPI.swarmCache[userPublicKey] ?? []
|
||||
let swarm = SnodeAPI.swarmCache.wrappedValue[userPublicKey] ?? []
|
||||
let unusedSnodes = swarm.subtracting(usedSnodes)
|
||||
|
||||
guard !unusedSnodes.isEmpty else {
|
||||
|
@ -173,7 +173,7 @@ public final class Poller {
|
|||
threadId: threadId,
|
||||
details: MessageReceiveJob.Details(
|
||||
messages: threadMessages.map { $0.messageInfo },
|
||||
isBackgroundPoll: false
|
||||
calledFromBackgroundPoller: false
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue