Merge branch 'dev' into add-silence-audio
This commit is contained in:
commit
c121f99b22
|
@ -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 */; };
|
||||
7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */; };
|
||||
7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */; };
|
||||
7B50D64D28AC7CF80086CCEC /* silence.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 7B50D64C28AC7CF80086CCEC /* silence.aiff */; };
|
||||
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 */; };
|
||||
|
@ -147,6 +165,7 @@
|
|||
7BAF54D427ACCF01003D12F8 /* SAEScreenLockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54D227ACCF01003D12F8 /* SAEScreenLockViewController.swift */; };
|
||||
7BAF54D827ACD0E3003D12F8 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */; };
|
||||
7BAF54DC27ACD12B003D12F8 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54DB27ACD12B003D12F8 /* UIColor+Extensions.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 */; };
|
||||
|
@ -155,6 +174,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 */; };
|
||||
|
@ -181,7 +201,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 */; };
|
||||
|
@ -303,8 +322,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 */; };
|
||||
|
@ -586,6 +603,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 */; };
|
||||
|
@ -662,6 +682,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 */; };
|
||||
|
@ -781,12 +803,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 */; };
|
||||
|
@ -1143,6 +1163,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>"; };
|
||||
|
@ -1150,15 +1173,30 @@
|
|||
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>"; };
|
||||
7B50D64C28AC7CF80086CCEC /* silence.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = silence.aiff; 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>"; };
|
||||
|
@ -1170,6 +1208,7 @@
|
|||
7BAF54D227ACCF01003D12F8 /* SAEScreenLockViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SAEScreenLockViewController.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -1180,6 +1219,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>"; };
|
||||
|
@ -1215,7 +1255,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>"; };
|
||||
|
@ -1654,6 +1693,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>"; };
|
||||
|
@ -1693,6 +1735,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>"; };
|
||||
|
@ -1818,14 +1861,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>"; };
|
||||
|
@ -2139,6 +2180,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 = (
|
||||
|
@ -2151,6 +2202,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 = (
|
||||
|
@ -2161,6 +2220,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 = (
|
||||
|
@ -2226,6 +2299,8 @@
|
|||
B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */,
|
||||
7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */,
|
||||
7B7CB188270430D20079FF93 /* CallMessageView.swift */,
|
||||
7B7037422834B81F000DCF35 /* ReactionContainerView.swift */,
|
||||
7B7037442834BCC0000DCF35 /* ReactionView.swift */,
|
||||
);
|
||||
path = "Content Views";
|
||||
sourceTree = "<group>";
|
||||
|
@ -2252,7 +2327,6 @@
|
|||
B821494525D4D6FF009C0F2A /* URLModal.swift */,
|
||||
B8AF4BB326A5204600583500 /* SendSeedModal.swift */,
|
||||
B8758859264503A6000E60D0 /* JoinOpenGroupModal.swift */,
|
||||
B821494E25D4E163009C0F2A /* BodyTextView.swift */,
|
||||
B82149B725D60393009C0F2A /* BlockedModal.swift */,
|
||||
C374EEE125DA26740073A857 /* LinkPreviewModal.swift */,
|
||||
B82149C025D605C6009C0F2A /* InfoBanner.swift */,
|
||||
|
@ -2261,6 +2335,7 @@
|
|||
FD4B200D283492210034334B /* InsetLockableTableView.swift */,
|
||||
7B1581E3271FC59C00848B49 /* CallModal.swift */,
|
||||
7BFFB33B27D02F5800BEA04E /* CallPermissionRequestModal.swift */,
|
||||
7B9F71C828470667006DFE7B /* ReactionListSheet.swift */,
|
||||
);
|
||||
path = "Views & Modals";
|
||||
sourceTree = "<group>";
|
||||
|
@ -2272,6 +2347,7 @@
|
|||
B835247725C38D190089A44F /* Message Cells */,
|
||||
C328252E25CA54F70062D0A7 /* Context Menu */,
|
||||
B821493625D4D6A7009C0F2A /* Views & Modals */,
|
||||
7B1B52BD2851ADE1006069F2 /* Emoji Picker */,
|
||||
C302094625DCDFD3001F572D /* Settings */,
|
||||
FDF222062818CECF000A4995 /* ConversationViewModel.swift */,
|
||||
B835246D25C38ABF0089A44F /* ConversationVC.swift */,
|
||||
|
@ -2448,12 +2524,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 */,
|
||||
|
@ -2514,6 +2588,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
C3C2A74C2553A39700C340D1 /* VisibleMessage.swift */,
|
||||
7B8D5FC328332600008324D9 /* VisibleMessage+Reaction.swift */,
|
||||
C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */,
|
||||
C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */,
|
||||
C3C2A75E2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift */,
|
||||
|
@ -2602,6 +2677,7 @@
|
|||
C328253F25CA55880062D0A7 /* ContextMenuVC.swift */,
|
||||
C328254825CA60E60062D0A7 /* ContextMenuVC+Action.swift */,
|
||||
C328255125CA64470062D0A7 /* ContextMenuVC+ActionView.swift */,
|
||||
7BFA8AE22831D0D4001876F3 /* ContextMenuVC+EmojiReactsView.swift */,
|
||||
);
|
||||
path = "Context Menu";
|
||||
sourceTree = "<group>";
|
||||
|
@ -2714,6 +2790,8 @@
|
|||
B8CCF638239721E20091D419 /* TabBar.swift */,
|
||||
B8BB82B423947F2D00BA5194 /* TextField.swift */,
|
||||
C3C3CF8824D8EED300E1CCE7 /* TextView.swift */,
|
||||
7BBBDC43286EAD2D00747E59 /* TappableLabel.swift */,
|
||||
FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
|
@ -3060,8 +3138,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 */,
|
||||
|
@ -3129,7 +3205,6 @@
|
|||
B8A582AE258C65D000AFD84C /* Networking */,
|
||||
B8A582AD258C655E00AFD84C /* PromiseKit */,
|
||||
FD09796527F6B0A800936362 /* Utilities */,
|
||||
FDCDB8EF2817ABCE00352A0C /* Utilities */,
|
||||
C3D9E43025676D3D0040E4F3 /* Configuration.swift */,
|
||||
);
|
||||
path = SessionUtilitiesKit;
|
||||
|
@ -3326,6 +3401,7 @@
|
|||
D221A08C169C9E5E00537ABF /* Frameworks */,
|
||||
D221A08A169C9E5E00537ABF /* Products */,
|
||||
2BADBA206E0B8D297E313FBA /* Pods */,
|
||||
7B81682428B30BEC0069F315 /* Recovered References */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
|
@ -3402,6 +3478,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
C3F0A58F255C8E3D007BE2A3 /* Meta */,
|
||||
7B9F71CA2852EEE2006DFE7B /* Emoji */,
|
||||
B8B558ED26C4B55F00693325 /* Calls */,
|
||||
C360969C25AD18BA008B62B2 /* Closed Groups */,
|
||||
B835246C25C38AA20089A44F /* Conversations */,
|
||||
|
@ -3429,6 +3506,8 @@
|
|||
FD09797127FAA2F500936362 /* Optional+Utilities.swift */,
|
||||
FD09797C27FBDB2000936362 /* Notification+Utilities.swift */,
|
||||
FDF222082818D2B0000A4995 /* NSAttributedString+Utilities.swift */,
|
||||
C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */,
|
||||
C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */,
|
||||
);
|
||||
path = Utilities;
|
||||
sourceTree = "<group>";
|
||||
|
@ -3454,6 +3533,7 @@
|
|||
FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */,
|
||||
FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */,
|
||||
FD5C7308285007920029977D /* BlindedIdLookup.swift */,
|
||||
FD09B7E6288670FD00ED0B66 /* Reaction.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
|
@ -3467,6 +3547,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>";
|
||||
|
@ -3650,13 +3732,6 @@
|
|||
path = "Shared Models";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FD4B200A283367350034334B /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FD716E6F28505E5100C96BF4 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -3785,6 +3860,8 @@
|
|||
FDC438A327BB107F00C60D73 /* UserBanRequest.swift */,
|
||||
FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */,
|
||||
FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */,
|
||||
7B81682928B6F1420069F315 /* ReactionResponse.swift */,
|
||||
7B81682B28B72F480069F315 /* PendingChange.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
|
@ -3812,6 +3889,7 @@
|
|||
FDC4384E27B4804F00C60D73 /* Header.swift */,
|
||||
FDC4385027B4807400C60D73 /* QueryParam.swift */,
|
||||
FD83B9CD27D17A04005E1583 /* Request.swift */,
|
||||
7B81682228A4C1210069F315 /* UpdateTypes.swift */,
|
||||
);
|
||||
path = "Common Networking";
|
||||
sourceTree = "<group>";
|
||||
|
@ -3887,14 +3965,6 @@
|
|||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FDCDB8EF2817ABCE00352A0C /* Utilities */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FDCDB8F02817ABE600352A0C /* Optional+Utilities.swift */,
|
||||
);
|
||||
path = Utilities;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FDE7214E287E50D50093DF33 /* Scripts */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -4009,6 +4079,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;
|
||||
};
|
||||
|
@ -4018,7 +4089,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 */,
|
||||
|
@ -4853,6 +4923,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 */,
|
||||
|
@ -5026,7 +5098,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 */,
|
||||
|
@ -5072,6 +5143,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 */,
|
||||
|
@ -5116,6 +5188,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 */,
|
||||
|
@ -5127,18 +5200,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 */,
|
||||
|
@ -5151,13 +5227,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 */,
|
||||
|
@ -5245,7 +5327,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 */,
|
||||
|
@ -5291,6 +5372,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 */,
|
||||
|
@ -5310,6 +5392,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 */,
|
||||
|
@ -5319,9 +5402,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 */,
|
||||
|
@ -5333,7 +5416,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 */,
|
||||
|
@ -5361,6 +5446,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 */,
|
||||
B877E24226CA12910007970A /* CallVC.swift in Sources */,
|
||||
|
@ -5388,11 +5474,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 */,
|
||||
|
@ -5408,14 +5497,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 */,
|
||||
|
@ -5436,11 +5528,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 */,
|
||||
|
@ -5448,6 +5540,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 */,
|
||||
|
@ -5703,7 +5796,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)";
|
||||
|
@ -5728,7 +5821,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)";
|
||||
|
@ -5776,7 +5869,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;
|
||||
|
@ -5806,7 +5899,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)";
|
||||
|
@ -5842,7 +5935,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)";
|
||||
|
@ -5865,7 +5958,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";
|
||||
|
@ -5916,7 +6009,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;
|
||||
|
@ -5944,7 +6037,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";
|
||||
|
@ -6854,7 +6947,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 369;
|
||||
CURRENT_PROJECT_VERSION = 374;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -6893,7 +6986,7 @@
|
|||
"$(SRCROOT)",
|
||||
);
|
||||
LLVM_LTO = NO;
|
||||
MARKETING_VERSION = 2.0.2;
|
||||
MARKETING_VERSION = 2.1.0;
|
||||
OTHER_LDFLAGS = "$(inherited)";
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
|
||||
|
@ -6926,7 +7019,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 369;
|
||||
CURRENT_PROJECT_VERSION = 374;
|
||||
DEVELOPMENT_TEAM = SUQ8J2PCT7;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
@ -6965,7 +7058,7 @@
|
|||
"$(SRCROOT)",
|
||||
);
|
||||
LLVM_LTO = NO;
|
||||
MARKETING_VERSION = 2.0.2;
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -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
|
||||
),
|
||||
|
|
|
@ -367,7 +367,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
|
||||
|
@ -394,13 +394,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)
|
||||
|
||||
|
@ -505,7 +511,7 @@ public class MediaGalleryViewModel {
|
|||
threadVariant: threadVariant,
|
||||
isPagedData: false
|
||||
)
|
||||
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>
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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.";
|
||||
|
|
|
@ -684,3 +684,24 @@
|
|||
"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send.";
|
||||
"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again.";
|
||||
"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again.";
|
||||
/* 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"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
]
|
||||
]
|
||||
)
|
||||
|
|
|
@ -175,7 +175,7 @@ enum _001_InitialSetupMigration: Migration {
|
|||
.notNull()
|
||||
}
|
||||
|
||||
try db.create(table: GroupMember.self) { t in
|
||||
try db.create(table: _006_FixHiddenModAdminSupport.PreMigrationGroupMember.self) { t in
|
||||
// Note: Since we don't know whether this will be stored against a 'ClosedGroup' or
|
||||
// an 'OpenGroup' we add the foreign key constraint against the thread itself (which
|
||||
// shares the same 'id' as the 'groupId') so we can cascade delete automatically
|
||||
|
|
|
@ -647,11 +647,10 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
}
|
||||
|
||||
try groupModel.groupMemberIds.forEach { memberId in
|
||||
try GroupMember(
|
||||
try _006_FixHiddenModAdminSupport.PreMigrationGroupMember(
|
||||
groupId: threadId,
|
||||
profileId: memberId,
|
||||
role: .standard,
|
||||
isHidden: false
|
||||
role: .standard
|
||||
).insert(db)
|
||||
|
||||
if !validProfileIds.contains(memberId) {
|
||||
|
@ -660,11 +659,10 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
}
|
||||
|
||||
try groupModel.groupAdminIds.forEach { adminId in
|
||||
try GroupMember(
|
||||
try _006_FixHiddenModAdminSupport.PreMigrationGroupMember(
|
||||
groupId: threadId,
|
||||
profileId: adminId,
|
||||
role: .admin,
|
||||
isHidden: false
|
||||
role: .admin
|
||||
).insert(db)
|
||||
|
||||
if !validProfileIds.contains(adminId) {
|
||||
|
@ -673,11 +671,10 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
}
|
||||
|
||||
try (closedGroupZombieMemberIds[legacyThread.uniqueId] ?? []).forEach { zombieId in
|
||||
try GroupMember(
|
||||
try _006_FixHiddenModAdminSupport.PreMigrationGroupMember(
|
||||
groupId: threadId,
|
||||
profileId: zombieId,
|
||||
role: .zombie,
|
||||
isHidden: false
|
||||
role: .zombie
|
||||
).insert(db)
|
||||
|
||||
if !validProfileIds.contains(zombieId) {
|
||||
|
@ -1253,7 +1250,7 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
threadId: processedMessage.threadId,
|
||||
details: MessageReceiveJob.Details(
|
||||
messages: [processedMessage.messageInfo],
|
||||
isBackgroundPoll: legacyJob.isBackgroundPoll
|
||||
calledFromBackgroundPoller: legacyJob.isBackgroundPoll
|
||||
)
|
||||
)?.inserted(db)
|
||||
}
|
||||
|
|
|
@ -28,3 +28,41 @@ enum _006_FixHiddenModAdminSupport: Migration {
|
|||
Storage.update(progress: 1, for: self, in: target) // In case this is the last migration
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pre-Migration Types
|
||||
|
||||
extension _006_FixHiddenModAdminSupport {
|
||||
internal struct PreMigrationGroupMember: Codable, PersistableRecord, TableRecord, ColumnExpressible {
|
||||
public static var databaseTableName: String { "groupMember" }
|
||||
|
||||
public typealias Columns = CodingKeys
|
||||
public enum CodingKeys: String, CodingKey, ColumnExpression {
|
||||
case groupId
|
||||
case profileId
|
||||
case role
|
||||
}
|
||||
|
||||
public enum Role: Int, Codable, DatabaseValueConvertible {
|
||||
case standard
|
||||
case zombie
|
||||
case moderator
|
||||
case admin
|
||||
}
|
||||
|
||||
public let groupId: String
|
||||
public let profileId: String
|
||||
public let role: Role
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
public init(
|
||||
groupId: String,
|
||||
profileId: String,
|
||||
role: Role
|
||||
) {
|
||||
self.groupId = groupId
|
||||
self.profileId = profileId
|
||||
self.role = role
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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).")
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue