Merge branch 'dev' into add-documents-section

This commit is contained in:
ryanzhao 2022-09-13 11:59:25 +10:00
commit d840204bc2
129 changed files with 24540 additions and 806 deletions

646
Scripts/EmojiGenerator.swift Executable file
View file

@ -0,0 +1,646 @@
#!/usr/bin/env xcrun --sdk macosx swift
import Foundation
// OWSAssertionError but for this script
enum EmojiError: Error {
case assertion(String)
init(_ string: String) {
self = .assertion(string)
}
}
// MARK: - Remote Model
// These definitions are kept fairly lightweight since we don't control their format
// All processing of remote data is done by converting RemoteModel items to EmojiModel items
enum RemoteModel {
struct EmojiItem: Codable {
let name: String
let shortName: String
let unified: String
let sortOrder: UInt
let category: EmojiCategory
let skinVariations: [String: SkinVariation]?
let shortNames: [String]?
}
struct SkinVariation: Codable {
let unified: String
}
enum EmojiCategory: String, Codable, Equatable {
case smileys = "Smileys & Emotion"
case people = "People & Body"
// This category is not provided in the data set, but is actually
// a merger of the categories of `smileys` and `people`
case smileysAndPeople = "Smileys & People"
case animals = "Animals & Nature"
case food = "Food & Drink"
case activities = "Activities"
case travel = "Travel & Places"
case objects = "Objects"
case symbols = "Symbols"
case flags = "Flags"
case components = "Component"
}
static func fetchEmojiData() throws -> Data {
// let remoteSourceUrl = URL(string: "https://unicodey.com/emoji-data/emoji.json")!
// This URL has been unavailable the past couple of weeks. If you're seeing failures here, try this other one:
let remoteSourceUrl = URL(string: "https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji.json")!
return try Data(contentsOf: remoteSourceUrl)
}
}
// MARK: - Local Model
struct EmojiModel {
let definitions: [EmojiDefinition]
struct EmojiDefinition {
let category: RemoteModel.EmojiCategory
let rawName: String
let enumName: String
var shortNames: Set<String>
let variants: [Emoji]
var baseEmoji: Character { variants[0].base }
struct Emoji: Comparable {
let emojiChar: Character
let base: Character
let skintoneSequence: SkinToneSequence
static func <(lhs: Self, rhs: Self) -> Bool {
for (leftElement, rightElement) in zip(lhs.skintoneSequence, rhs.skintoneSequence) {
if leftElement.sortId != rightElement.sortId {
return leftElement.sortId < rightElement.sortId
}
}
if lhs.skintoneSequence.count != rhs.skintoneSequence.count {
return lhs.skintoneSequence.count < rhs.skintoneSequence.count
} else {
return false
}
}
}
init(parsingRemoteItem remoteItem: RemoteModel.EmojiItem) throws {
category = remoteItem.category
rawName = remoteItem.name
enumName = Self.parseEnumNameFromRemoteItem(remoteItem)
shortNames = Set((remoteItem.shortNames ?? []))
shortNames.insert(rawName.lowercased())
shortNames.insert(enumName.lowercased())
let baseEmojiChar = try Self.codePointsToCharacter(Self.parseCodePointString(remoteItem.unified))
let baseEmoji = Emoji(emojiChar: baseEmojiChar, base: baseEmojiChar, skintoneSequence: .none)
let toneVariants: [Emoji]
if let skinVariations = remoteItem.skinVariations {
toneVariants = try skinVariations.map { key, value in
let modifier = SkinTone.sequence(from: Self.parseCodePointString(key))
let parsedEmoji = try Self.codePointsToCharacter(Self.parseCodePointString(value.unified))
return Emoji(emojiChar: parsedEmoji, base: baseEmojiChar, skintoneSequence: modifier)
}.sorted()
} else {
toneVariants = []
}
variants = [baseEmoji] + toneVariants
try postInitValidation()
}
func postInitValidation() throws {
guard variants.count > 0 else {
throw EmojiError("Expecting at least one variant")
}
guard variants.allSatisfy({ $0.base == baseEmoji }) else {
// All emoji variants must have a common base emoji
throw EmojiError("Inconsistent base emoji: \(baseEmoji)")
}
let hasMultipleComponents = variants.first(where: { $0.skintoneSequence.count > 1 }) != nil
if hasMultipleComponents, skinToneComponents == nil {
// If you hit this, this means a new emoji was added where a skintone modifier sequence specifies multiple
// skin tones for multiple emoji components: e.g. 👫 -> 🧍+🧍
// These are defined in `skinToneComponents`. You'll need to add a new case.
throw EmojiError("\(baseEmoji):\(enumName) definition has variants with multiple skintone modifiers but no component emojis defined")
}
}
static func parseEnumNameFromRemoteItem(_ item: RemoteModel.EmojiItem) -> String {
// some names don't play nice with swift, so we special case them
switch item.shortName {
case "+1": return "plusOne"
case "-1": return "negativeOne"
case "8ball": return "eightBall"
case "repeat": return "`repeat`"
case "100": return "oneHundred"
case "1234": return "oneTwoThreeFour"
case "couplekiss": return "personKissPerson"
case "couple_with_heart": return "personHeartPerson"
default:
let uppperCamelCase = item.shortName
.replacingOccurrences(of: "-", with: " ")
.replacingOccurrences(of: "_", with: " ")
.titlecase
.replacingOccurrences(of: " ", with: "")
return uppperCamelCase.first!.lowercased() + uppperCamelCase.dropFirst()
}
}
var skinToneComponents: String? {
// There's no great way to do this except manually. Some emoji have multiple skin tones.
// In the picker, we need to use one emoji to represent each person. For now, we manually
// specify this. Hopefully, in the future, the data set will contain this information.
switch enumName {
case "peopleHoldingHands": return "[.standingPerson, .standingPerson]"
case "twoWomenHoldingHands": return "[.womanStanding, .womanStanding]"
case "manAndWomanHoldingHands": return "[.womanStanding, .manStanding]"
case "twoMenHoldingHands": return "[.manStanding, .manStanding]"
case "personKissPerson": return "[.adult, .adult]"
case "womanKissMan": return "[.woman, .man]"
case "manKissMan": return "[.man, .man]"
case "womanKissWoman": return "[.woman, .woman]"
case "personHeartPerson": return "[.adult, .adult]"
case "womanHeartMan": return "[.woman, .man]"
case "manHeartMan": return "[.man, .man]"
case "womanHeartWoman": return "[.woman, .woman]"
case "handshake": return "[.rightwardsHand, .leftwardsHand]"
default:
return nil
}
}
var isNormalized: Bool { enumName == normalizedEnumName }
var normalizedEnumName: String {
switch enumName {
// flagUm (US Minor Outlying Islands) looks identical to the
// US flag. We don't present it as a sendable reaction option
// This matches the iOS keyboard behavior.
case "flagUm": return "us"
default: return enumName
}
}
static func parseCodePointString(_ pointString: String) -> [UnicodeScalar] {
return pointString
.components(separatedBy: "-")
.map { Int($0, radix: 16)! }
.map { UnicodeScalar($0)! }
}
static func codePointsToCharacter(_ codepoints: [UnicodeScalar]) throws -> Character {
let result = codepoints.map { String($0) }.joined()
if result.count != 1 {
throw EmojiError("Invalid number of chars for codepoint sequence: \(codepoints)")
}
return result.first!
}
}
init(rawJSONData jsonData: Data) throws {
let jsonDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
definitions = try jsonDecoder
.decode([RemoteModel.EmojiItem].self, from: jsonData)
.sorted { $0.sortOrder < $1.sortOrder }
.map { try EmojiDefinition(parsingRemoteItem: $0) }
}
typealias SkinToneSequence = [EmojiModel.SkinTone]
enum SkinTone: UnicodeScalar, CaseIterable, Equatable {
case light = "🏻"
case mediumLight = "🏼"
case medium = "🏽"
case mediumDark = "🏾"
case dark = "🏿"
var sortId: Int { return SkinTone.allCases.firstIndex(of: self)! }
static func sequence(from codepoints: [UnicodeScalar]) -> SkinToneSequence {
codepoints
.map { SkinTone(rawValue: $0)! }
.reduce(into: [SkinTone]()) { result, skinTone in
guard !result.contains(skinTone) else { return }
result.append(skinTone)
}
}
}
}
extension EmojiModel.SkinToneSequence {
static var none: EmojiModel.SkinToneSequence = []
}
// MARK: - File Writers
extension EmojiGenerator {
static func writePrimaryFile(from emojiModel: EmojiModel) {
// Main enum: Create a string enum defining our enumNames equal to the baseEmoji string
// e.g. case grinning = "😀"
writeBlock(fileName: "Emoji.swift") { fileHandle in
fileHandle.writeLine("// swiftlint:disable all")
fileHandle.writeLine("")
fileHandle.writeLine("/// A sorted representation of all available emoji")
fileHandle.writeLine("enum Emoji: String, CaseIterable, Equatable {")
fileHandle.indent {
emojiModel.definitions.forEach {
fileHandle.writeLine("case \($0.enumName) = \"\($0.baseEmoji)\"")
}
}
fileHandle.writeLine("}")
fileHandle.writeLine("// swiftlint:disable all")
}
}
static func writeStringConversionsFile(from emojiModel: EmojiModel) {
// Inline helpers:
var firstItem = true
func conditionalCheckForEmojiItem(_ item: EmojiModel.EmojiDefinition.Emoji) -> String {
let isFirst = (firstItem == true)
firstItem = false
let prefix = isFirst ? "" : "} else "
let suffix = "if rawValue == \"\(item.emojiChar)\" {"
return prefix + suffix
}
func conversionForEmojiItem(_ item: EmojiModel.EmojiDefinition.Emoji, definition: EmojiModel.EmojiDefinition) -> String {
let skinToneString: String
if item.skintoneSequence.isEmpty {
skinToneString = "nil"
} else {
skinToneString = "[\(item.skintoneSequence.map { ".\($0)" }.joined(separator: ", "))]"
}
return "self.init(baseEmoji: .\(definition.enumName), skinTones: \(skinToneString))"
}
// Conversion from String: Creates an initializer mapping a single character emoji string to an EmojiWithSkinTones
// e.g.
// if rawValue == "😀" { self.init(baseEmoji: .grinning, skinTones: nil) }
// else if rawValue == "🦻🏻" { self.init(baseEmoji: .earWithHearingAid, skinTones: [.light])
writeBlock(fileName: "EmojiWithSkinTones+String.swift") { fileHandle in
fileHandle.writeLine("extension EmojiWithSkinTones {")
fileHandle.indent {
fileHandle.writeLine("init?(rawValue: String) {")
fileHandle.indent {
fileHandle.writeLine("guard rawValue.isSingleEmoji else { return nil }")
emojiModel.definitions.forEach { definition in
definition.variants.forEach { emoji in
fileHandle.writeLine(conditionalCheckForEmojiItem(emoji))
fileHandle.indent {
fileHandle.writeLine(conversionForEmojiItem(emoji, definition: definition))
}
}
}
fileHandle.writeLine("} else {")
fileHandle.indent {
fileHandle.writeLine("self.init(unsupportedValue: rawValue)")
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
}
static func writeSkinToneLookupFile(from emojiModel: EmojiModel) {
writeBlock(fileName: "Emoji+SkinTones.swift") { fileHandle in
fileHandle.writeLine("extension Emoji {")
fileHandle.indent {
// SkinTone enum
fileHandle.writeLine("enum SkinTone: String, CaseIterable, Equatable {")
fileHandle.indent {
for skinTone in EmojiModel.SkinTone.allCases {
fileHandle.writeLine("case \(skinTone) = \"\(skinTone.rawValue)\"")
}
}
fileHandle.writeLine("}")
fileHandle.writeLine("")
// skin tone helpers
fileHandle.writeLine("var hasSkinTones: Bool { return emojiPerSkinTonePermutation != nil }")
fileHandle.writeLine("var allowsMultipleSkinTones: Bool { return hasSkinTones && skinToneComponentEmoji != nil }")
fileHandle.writeLine("")
// Start skinToneComponentEmoji
fileHandle.writeLine("var skinToneComponentEmoji: [Emoji]? {")
fileHandle.indent {
fileHandle.writeLine("switch self {")
emojiModel.definitions.forEach { emojiDef in
if let components = emojiDef.skinToneComponents {
fileHandle.writeLine("case .\(emojiDef.enumName): return \(components)")
}
}
fileHandle.writeLine("default: return nil")
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
fileHandle.writeLine("")
// Start emojiPerSkinTonePermutation
fileHandle.writeLine("var emojiPerSkinTonePermutation: [[SkinTone]: String]? {")
fileHandle.indent {
fileHandle.writeLine("switch self {")
emojiModel.definitions.forEach { emojiDef in
let skintoneVariants = emojiDef.variants.filter({ $0.skintoneSequence != .none})
if skintoneVariants.isEmpty {
// None of our variants have a skintone, nothing to do
return
}
fileHandle.writeLine("case .\(emojiDef.enumName):")
fileHandle.indent {
fileHandle.writeLine("return [")
fileHandle.indent {
skintoneVariants.forEach {
let skintoneSequenceKey = $0.skintoneSequence.map({ ".\($0)" }).joined(separator: ", ")
fileHandle.writeLine("[\(skintoneSequenceKey)]: \"\($0.emojiChar)\",")
}
}
fileHandle.writeLine("]")
}
}
fileHandle.writeLine("default: return nil")
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
}
static func writeCategoryLookupFile(from emojiModel: EmojiModel) {
let outputCategories: [RemoteModel.EmojiCategory] = [
.smileysAndPeople,
.animals,
.food,
.activities,
.travel,
.objects,
.symbols,
.flags
]
writeBlock(fileName: "Emoji+Category.swift") { fileHandle in
fileHandle.writeLine("extension Emoji {")
fileHandle.indent {
// Category enum
fileHandle.writeLine("enum Category: String, CaseIterable, Equatable {")
fileHandle.indent {
// Declare cases
for category in outputCategories {
fileHandle.writeLine("case \(category) = \"\(category.rawValue)\"")
}
fileHandle.writeLine("")
// Localized name for category
fileHandle.writeLine("var localizedName: String {")
fileHandle.indent {
fileHandle.writeLine("switch self {")
for category in outputCategories {
fileHandle.writeLine("case .\(category):")
fileHandle.indent {
let stringKey = "EMOJI_CATEGORY_\("\(category)".uppercased())_NAME"
let stringComment = "The name for the emoji category '\(category.rawValue)'"
fileHandle.writeLine("return NSLocalizedString(\"\(stringKey)\", comment: \"\(stringComment)\")")
}
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
fileHandle.writeLine("")
// Emoji lookup per category
fileHandle.writeLine("var normalizedEmoji: [Emoji] {")
fileHandle.indent {
fileHandle.writeLine("switch self {")
let normalizedEmojiPerCategory: [RemoteModel.EmojiCategory: [EmojiModel.EmojiDefinition]]
normalizedEmojiPerCategory = emojiModel.definitions.reduce(into: [:]) { result, emojiDef in
if emojiDef.isNormalized {
var categoryList = result[emojiDef.category] ?? []
categoryList.append(emojiDef)
result[emojiDef.category] = categoryList
}
}
for category in outputCategories {
let emoji: [EmojiModel.EmojiDefinition] = {
switch category {
case .smileysAndPeople:
// Merge smileys & people. It's important we initially bucket these separately,
// because we want the emojis to be sorted smileys followed by people
return normalizedEmojiPerCategory[.smileys]! + normalizedEmojiPerCategory[.people]!
default:
return normalizedEmojiPerCategory[category]!
}
}()
fileHandle.writeLine("case .\(category):")
fileHandle.indent {
fileHandle.writeLine("return [")
fileHandle.indent {
emoji.compactMap { $0.enumName }.forEach { name in
fileHandle.writeLine(".\(name),")
}
}
fileHandle.writeLine("]")
}
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
fileHandle.writeLine("")
// Category lookup per emoji
fileHandle.writeLine("var category: Category {")
fileHandle.indent {
fileHandle.writeLine("switch self {")
for emojiDef in emojiModel.definitions {
let category = [.smileys, .people].contains(emojiDef.category) ? .smileysAndPeople : emojiDef.category
if category != .components {
fileHandle.writeLine("case .\(emojiDef.enumName): return .\(category)")
}
}
// Write a default case, because this enum is too long for the compiler to validate it's exhaustive
fileHandle.writeLine("default: fatalError(\"Unexpected case \\(self)\")")
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
fileHandle.writeLine("")
// Normalized variant mapping
fileHandle.writeLine("var isNormalized: Bool { normalized == self }")
fileHandle.writeLine("var normalized: Emoji {")
fileHandle.indent {
fileHandle.writeLine("switch self {")
emojiModel.definitions.filter { !$0.isNormalized }.forEach {
fileHandle.writeLine("case .\($0.enumName): return .\($0.normalizedEnumName)")
}
fileHandle.writeLine("default: return self")
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
}
static func writeNameLookupFile(from emojiModel: EmojiModel) {
// Name lookup: Create a computed property mapping an Emoji enum element to the raw Emoji name string
// e.g. case .grinning: return "GRINNING FACE"
writeBlock(fileName: "Emoji+Name.swift") { fileHandle in
fileHandle.writeLine("extension Emoji {")
fileHandle.indent {
fileHandle.writeLine("var name: String {")
fileHandle.indent {
fileHandle.writeLine("switch self {")
emojiModel.definitions.forEach {
fileHandle.writeLine("case .\($0.enumName): return \"\($0.shortNames.joined(separator:", "))\"")
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
fileHandle.writeLine("}")
}
}
}
// MARK: - File I/O Helpers
class WriteHandle {
static let emojiDirectory = URL(
fileURLWithPath: "../Session/Emoji",
isDirectory: true,
relativeTo: EmojiGenerator.pathToFolderContainingThisScript!)
let handle: FileHandle
var indentDepth: Int = 0
var hasBeenClosed = false
func indent(_ block: () -> Void) {
indentDepth += 1
block()
indentDepth -= 1
}
func writeLine(_ body: String) {
let spaces = indentDepth * 4
let prefix = String(repeating: " ", count: spaces)
let suffix = "\n"
let line = prefix + body + suffix
handle.write(line.data(using: .utf8)!)
}
init(fileName: String) {
// Create directory if necessary
if !FileManager.default.fileExists(atPath: Self.emojiDirectory.path) {
try! FileManager.default.createDirectory(at: Self.emojiDirectory, withIntermediateDirectories: true, attributes: nil)
}
// Delete old file and create anew
let url = URL(fileURLWithPath: fileName, relativeTo: Self.emojiDirectory)
if FileManager.default.fileExists(atPath: url.path) {
try! FileManager.default.removeItem(at: url)
}
FileManager.default.createFile(atPath: url.path, contents: nil, attributes: nil)
handle = try! FileHandle(forWritingTo: url)
}
deinit {
precondition(hasBeenClosed, "File handle still open at de-init")
}
func close() {
handle.closeFile()
hasBeenClosed = true
}
}
extension EmojiGenerator {
static func writeBlock(fileName: String, block: (WriteHandle) -> Void) {
let fileHandle = WriteHandle(fileName: fileName)
defer { fileHandle.close() }
fileHandle.writeLine("")
fileHandle.writeLine("// This file is generated by EmojiGenerator.swift, do not manually edit it.")
fileHandle.writeLine("")
block(fileHandle)
}
// from http://stackoverflow.com/a/31480534/255489
static var pathToFolderContainingThisScript: URL? = {
let cwd = FileManager.default.currentDirectoryPath
let script = CommandLine.arguments[0]
if script.hasPrefix("/") { // absolute
let path = (script as NSString).deletingLastPathComponent
return URL(fileURLWithPath: path)
} else { // relative
let urlCwd = URL(fileURLWithPath: cwd)
if let urlPath = URL(string: script, relativeTo: urlCwd) {
let path = (urlPath.path as NSString).deletingLastPathComponent
return URL(fileURLWithPath: path)
}
}
return nil
}()
}
// MARK: - Misc
extension String {
var titlecase: String {
components(separatedBy: " ")
.map { $0.first!.uppercased() + $0.dropFirst().lowercased() }
.joined(separator: " ")
}
}
// MARK: - Lifecycle
class EmojiGenerator {
static func run() throws {
let remoteData = try RemoteModel.fetchEmojiData()
let model = try EmojiModel(rawJSONData: remoteData)
writePrimaryFile(from: model)
writeStringConversionsFile(from: model)
writeSkinToneLookupFile(from: model)
writeCategoryLookupFile(from: model)
writeNameLookupFile(from: model)
}
}
do {
try EmojiGenerator.run()
} catch {
print("Failed to generate emoji data: \(error)")
let errorCode = (error as? CustomNSError)?.errorCode ?? -1
exit(Int32(errorCode))
}

View file

@ -121,21 +121,39 @@
7B1581E4271FC59D00848B49 /* CallModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E3271FC59C00848B49 /* CallModal.swift */; };
7B1581E6271FD2A100848B49 /* VideoPreviewVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E5271FD2A100848B49 /* VideoPreviewVC.swift */; };
7B1581E827210ECC00848B49 /* RenderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E727210ECC00848B49 /* RenderView.swift */; };
7B1B52D828580C6D006069F2 /* EmojiPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1B52D728580C6D006069F2 /* EmojiPickerSheet.swift */; };
7B1B52DF28580D51006069F2 /* EmojiPickerCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1B52DB28580D50006069F2 /* EmojiPickerCollectionView.swift */; };
7B1B52E028580D51006069F2 /* EmojiSkinTonePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1B52DC28580D50006069F2 /* EmojiSkinTonePicker.swift */; };
7B1D74AA27BCC16E0030B423 /* NSENotificationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */; };
7B1D74AC27BDE7510030B423 /* Promise+Timeout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */; };
7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74AF27C365960030B423 /* Timer+MainThread.swift */; };
7B46AAAF28766DF4001AF2DC /* AllMediaViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B46AAAE28766DF4001AF2DC /* AllMediaViewController.swift */; };
7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */; };
7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */; };
7B7037432834B81F000DCF35 /* ReactionContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7037422834B81F000DCF35 /* ReactionContainerView.swift */; };
7B7037452834BCC0000DCF35 /* ReactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7037442834BCC0000DCF35 /* ReactionView.swift */; };
7B7CB189270430D20079FF93 /* CallMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB188270430D20079FF93 /* CallMessageView.swift */; };
7B7CB18B270591630079FF93 /* ShareLogsModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB18A270591630079FF93 /* ShareLogsModal.swift */; };
7B7CB18E270D066F0079FF93 /* IncomingCallBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB18D270D066F0079FF93 /* IncomingCallBanner.swift */; };
7B7CB190270FB2150079FF93 /* MiniCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB18F270FB2150079FF93 /* MiniCallView.swift */; };
7B7CB192271508AD0079FF93 /* CallRingTonePlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB191271508AD0079FF93 /* CallRingTonePlayer.swift */; };
7B81682328A4C1210069F315 /* UpdateTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B81682228A4C1210069F315 /* UpdateTypes.swift */; };
7B81682828B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B81682728B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift */; };
7B81682A28B6F1420069F315 /* ReactionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B81682928B6F1420069F315 /* ReactionResponse.swift */; };
7B81682C28B72F480069F315 /* PendingChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B81682B28B72F480069F315 /* PendingChange.swift */; };
7B8D5FC428332600008324D9 /* VisibleMessage+Reaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B8D5FC328332600008324D9 /* VisibleMessage+Reaction.swift */; };
7B93D06A27CF173D00811CB6 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06927CF173D00811CB6 /* MessageRequestsViewController.swift */; };
7B93D07027CF194000811CB6 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06E27CF194000811CB6 /* ConfigurationMessage+Convenience.swift */; };
7B93D07127CF194000811CB6 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D06F27CF194000811CB6 /* MessageRequestResponse.swift */; };
7B93D07727CF1A8A00811CB6 /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */; };
7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71C828470667006DFE7B /* ReactionListSheet.swift */; };
7B9F71D02852EEE2006DFE7B /* Emoji+Category.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71CB2852EEE2006DFE7B /* Emoji+Category.swift */; };
7B9F71D12852EEE2006DFE7B /* EmojiWithSkinTones+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71CC2852EEE2006DFE7B /* EmojiWithSkinTones+String.swift */; };
7B9F71D22852EEE2006DFE7B /* Emoji+SkinTones.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71CD2852EEE2006DFE7B /* Emoji+SkinTones.swift */; };
7B9F71D32852EEE2006DFE7B /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71CE2852EEE2006DFE7B /* Emoji.swift */; };
7B9F71D42852EEE2006DFE7B /* Emoji+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71CF2852EEE2006DFE7B /* Emoji+Name.swift */; };
7B9F71D72853100A006DFE7B /* Emoji+Available.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71D528531009006DFE7B /* Emoji+Available.swift */; };
7B9F71D82853100A006DFE7B /* EmojiWithSkinTones.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9F71D628531009006DFE7B /* EmojiWithSkinTones.swift */; };
7BA68909272A27BE00EFC32F /* SessionCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA68908272A27BE00EFC32F /* SessionCall.swift */; };
7BA6890D27325CCC00EFC32F /* SessionCallManager+CXCallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA6890C27325CCC00EFC32F /* SessionCallManager+CXCallController.swift */; };
7BA6890F27325CE300EFC32F /* SessionCallManager+CXProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA6890E27325CE300EFC32F /* SessionCallManager+CXProvider.swift */; };
@ -148,6 +166,7 @@
7BAF54D827ACD0E3003D12F8 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */; };
7BAF54DC27ACD12B003D12F8 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54DB27ACD12B003D12F8 /* UIColor+Extensions.swift */; };
7BBBDC462875600700747E59 /* DocumentTitleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBBDC452875600700747E59 /* DocumentTitleViewController.swift */; };
7BBBDC44286EAD2D00747E59 /* TappableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBBDC43286EAD2D00747E59 /* TappableLabel.swift */; };
7BC01A3E241F40AB00BC7C55 /* NotificationServiceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC01A3D241F40AB00BC7C55 /* NotificationServiceExtension.swift */; };
7BC01A42241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 7BC01A3B241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
7BC707F227290ACB002817AD /* SessionCallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC707F127290ACB002817AD /* SessionCallManager.swift */; };
@ -156,6 +175,7 @@
7BD477B027F526FF004E2822 /* BlockListUIUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD477AF27F526FF004E2822 /* BlockListUIUtils.swift */; };
7BDCFC08242186E700641C39 /* NotificationServiceExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BDCFC07242186E700641C39 /* NotificationServiceExtensionContext.swift */; };
7BDCFC0B2421EB7600641C39 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = B6F509951AA53F760068F56A /* Localizable.strings */; };
7BFA8AE32831D0D4001876F3 /* ContextMenuVC+EmojiReactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFA8AE22831D0D4001876F3 /* ContextMenuVC+EmojiReactsView.swift */; };
7BFD1A8A2745C4F000FB91B9 /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFD1A892745C4F000FB91B9 /* Permissions.swift */; };
7BFD1A8C2747150E00FB91B9 /* TurnServerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFD1A8B2747150E00FB91B9 /* TurnServerInfo.swift */; };
7BFD1A972747689000FB91B9 /* Session-Turn-Server in Resources */ = {isa = PBXBuildFile; fileRef = 7BFD1A962747689000FB91B9 /* Session-Turn-Server */; };
@ -182,7 +202,6 @@
B81D25C526157F40004D1FE1 /* storage-seed-1.crt in Resources */ = {isa = PBXBuildFile; fileRef = B81D25B726157F20004D1FE1 /* storage-seed-1.crt */; };
B81D25C626157F40004D1FE1 /* public-loki-foundation.crt in Resources */ = {isa = PBXBuildFile; fileRef = B81D25B826157F20004D1FE1 /* public-loki-foundation.crt */; };
B821494625D4D6FF009C0F2A /* URLModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B821494525D4D6FF009C0F2A /* URLModal.swift */; };
B821494F25D4E163009C0F2A /* BodyTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B821494E25D4E163009C0F2A /* BodyTextView.swift */; };
B82149B825D60393009C0F2A /* BlockedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82149B725D60393009C0F2A /* BlockedModal.swift */; };
B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82149C025D605C6009C0F2A /* InfoBanner.swift */; };
B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D2825C7A4B400488AB4 /* InputView.swift */; };
@ -304,8 +323,6 @@
C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDAFD255A580600E217F9 /* LRUCache.swift */; };
C32C5DDB256DD9FF003C73A2 /* ContentProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB68255A580F00E217F9 /* ContentProxy.swift */; };
C32C5E0C256DDAFA003C73A2 /* NSRegularExpression+SSK.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA7A255A57FB00E217F9 /* NSRegularExpression+SSK.swift */; };
C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */; };
C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */; settings = {ATTRIBUTES = (Public, ); }; };
C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB77255A581000E217F9 /* NSUserDefaults+OWS.m */; };
C32C6018256E07F9003C73A2 /* NSUserDefaults+OWS.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB51255A580D00E217F9 /* NSUserDefaults+OWS.h */; settings = {ATTRIBUTES = (Public, ); }; };
C33100082558FF6D00070591 /* NewConversationButtonSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83F2B85240C7B8F000A54AB /* NewConversationButtonSet.swift */; };
@ -587,6 +604,9 @@
FD09799727FFA84A00936362 /* RecipientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799627FFA84900936362 /* RecipientState.swift */; };
FD09799927FFC1A300936362 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799827FFC1A300936362 /* Attachment.swift */; };
FD09799B27FFC82D00936362 /* Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799A27FFC82D00936362 /* Quote.swift */; };
FD09B7E328865FDA00ED0B66 /* HighlightMentionBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */; };
FD09B7E5288670BB00ED0B66 /* _008_EmojiReacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E4288670BB00ED0B66 /* _008_EmojiReacts.swift */; };
FD09B7E7288670FD00ED0B66 /* Reaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E6288670FD00ED0B66 /* Reaction.swift */; };
FD09C5E2282212B3000CE219 /* JobDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E1282212B3000CE219 /* JobDependencies.swift */; };
FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */; };
FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E728264937000CE219 /* MediaDetailViewController.swift */; };
@ -663,6 +683,8 @@
FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; };
FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */; };
FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; };
FD52090028AF6153006098F6 /* OWSBackgroundTask.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */; };
FD52090128AF61BA006098F6 /* OWSBackgroundTask.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */; settings = {ATTRIBUTES = (Public, ); }; };
FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F6284F0E560029977D /* MessageReceiver+ReadReceipts.swift */; };
FD5C72F9284F0E880029977D /* MessageReceiver+TypingIndicators.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72F8284F0E880029977D /* MessageReceiver+TypingIndicators.swift */; };
FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5C72FA284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift */; };
@ -782,12 +804,10 @@
FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; };
FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; };
FDCDB8E42817819600352A0C /* (null) in Sources */ = {isa = PBXBuildFile; };
FDCDB8F12817ABE600352A0C /* Optional+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8F02817ABE600352A0C /* Optional+Utilities.swift */; };
FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */; };
FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */; };
FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */; };
FDE72118286C156E0093DF33 /* ChatSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE72117286C156E0093DF33 /* ChatSettingsViewController.swift */; };
FDE72154287FE4470093DF33 /* HighlightMentionBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE72153287FE4470093DF33 /* HighlightMentionBackgroundView.swift */; };
FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; };
FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */; };
FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; };
@ -1144,6 +1164,9 @@
7B1581E3271FC59C00848B49 /* CallModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallModal.swift; sourceTree = "<group>"; };
7B1581E5271FD2A100848B49 /* VideoPreviewVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPreviewVC.swift; sourceTree = "<group>"; };
7B1581E727210ECC00848B49 /* RenderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderView.swift; sourceTree = "<group>"; };
7B1B52D728580C6D006069F2 /* EmojiPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerSheet.swift; sourceTree = "<group>"; };
7B1B52DB28580D50006069F2 /* EmojiPickerCollectionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmojiPickerCollectionView.swift; sourceTree = "<group>"; };
7B1B52DC28580D50006069F2 /* EmojiSkinTonePicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmojiSkinTonePicker.swift; sourceTree = "<group>"; };
7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSENotificationPresenter.swift; sourceTree = "<group>"; };
7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promise+Timeout.swift"; sourceTree = "<group>"; };
7B1D74AF27C365960030B423 /* Timer+MainThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timer+MainThread.swift"; sourceTree = "<group>"; };
@ -1151,15 +1174,30 @@
7B46AAAE28766DF4001AF2DC /* AllMediaViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllMediaViewController.swift; sourceTree = "<group>"; };
7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsendRequest.swift; sourceTree = "<group>"; };
7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedMessageView.swift; sourceTree = "<group>"; };
7B7037422834B81F000DCF35 /* ReactionContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionContainerView.swift; sourceTree = "<group>"; };
7B7037442834BCC0000DCF35 /* ReactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionView.swift; sourceTree = "<group>"; };
7B7CB188270430D20079FF93 /* CallMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMessageView.swift; sourceTree = "<group>"; };
7B7CB18A270591630079FF93 /* ShareLogsModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLogsModal.swift; sourceTree = "<group>"; };
7B7CB18D270D066F0079FF93 /* IncomingCallBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallBanner.swift; sourceTree = "<group>"; };
7B7CB18F270FB2150079FF93 /* MiniCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniCallView.swift; sourceTree = "<group>"; };
7B7CB191271508AD0079FF93 /* CallRingTonePlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallRingTonePlayer.swift; sourceTree = "<group>"; };
7B81682228A4C1210069F315 /* UpdateTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateTypes.swift; sourceTree = "<group>"; };
7B81682728B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = _007_HomeQueryOptimisationIndexes.swift; sourceTree = "<group>"; };
7B81682928B6F1420069F315 /* ReactionResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionResponse.swift; sourceTree = "<group>"; };
7B81682B28B72F480069F315 /* PendingChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingChange.swift; sourceTree = "<group>"; };
7B8D5FC328332600008324D9 /* VisibleMessage+Reaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Reaction.swift"; sourceTree = "<group>"; };
7B93D06927CF173D00811CB6 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = "<group>"; };
7B93D06E27CF194000811CB6 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = "<group>"; };
7B93D06F27CF194000811CB6 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = "<group>"; };
7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockDataGenerator.swift; sourceTree = "<group>"; };
7B9F71C828470667006DFE7B /* ReactionListSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionListSheet.swift; sourceTree = "<group>"; };
7B9F71CB2852EEE2006DFE7B /* Emoji+Category.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Emoji+Category.swift"; sourceTree = "<group>"; };
7B9F71CC2852EEE2006DFE7B /* EmojiWithSkinTones+String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "EmojiWithSkinTones+String.swift"; sourceTree = "<group>"; };
7B9F71CD2852EEE2006DFE7B /* Emoji+SkinTones.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Emoji+SkinTones.swift"; sourceTree = "<group>"; };
7B9F71CE2852EEE2006DFE7B /* Emoji.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; };
7B9F71CF2852EEE2006DFE7B /* Emoji+Name.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Emoji+Name.swift"; sourceTree = "<group>"; };
7B9F71D528531009006DFE7B /* Emoji+Available.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Emoji+Available.swift"; sourceTree = "<group>"; };
7B9F71D628531009006DFE7B /* EmojiWithSkinTones.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmojiWithSkinTones.swift; sourceTree = "<group>"; };
7BA68908272A27BE00EFC32F /* SessionCall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCall.swift; sourceTree = "<group>"; };
7BA6890C27325CCC00EFC32F /* SessionCallManager+CXCallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionCallManager+CXCallController.swift"; sourceTree = "<group>"; };
7BA6890E27325CE300EFC32F /* SessionCallManager+CXProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionCallManager+CXProvider.swift"; sourceTree = "<group>"; };
@ -1172,6 +1210,7 @@
7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = "<group>"; };
7BAF54DB27ACD12B003D12F8 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = "<group>"; };
7BBBDC452875600700747E59 /* DocumentTitleViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentTitleViewController.swift; sourceTree = "<group>"; };
7BBBDC43286EAD2D00747E59 /* TappableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TappableLabel.swift; sourceTree = "<group>"; };
7BC01A3B241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionNotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
7BC01A3D241F40AB00BC7C55 /* NotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtension.swift; sourceTree = "<group>"; };
7BC01A3F241F40AB00BC7C55 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@ -1182,6 +1221,7 @@
7BD477AF27F526FF004E2822 /* BlockListUIUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.swift; sourceTree = "<group>"; };
7BDCFC0424206E7300641C39 /* SessionNotificationServiceExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SessionNotificationServiceExtension.entitlements; sourceTree = "<group>"; };
7BDCFC07242186E700641C39 /* NotificationServiceExtensionContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtensionContext.swift; sourceTree = "<group>"; };
7BFA8AE22831D0D4001876F3 /* ContextMenuVC+EmojiReactsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContextMenuVC+EmojiReactsView.swift"; sourceTree = "<group>"; };
7BFD1A892745C4F000FB91B9 /* Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Permissions.swift; sourceTree = "<group>"; };
7BFD1A8B2747150E00FB91B9 /* TurnServerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TurnServerInfo.swift; sourceTree = "<group>"; };
7BFD1A962747689000FB91B9 /* Session-Turn-Server */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "Session-Turn-Server"; sourceTree = "<group>"; };
@ -1217,7 +1257,6 @@
B81D25B826157F20004D1FE1 /* public-loki-foundation.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "public-loki-foundation.crt"; sourceTree = "<group>"; };
B81D25B926157F20004D1FE1 /* storage-seed-3.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "storage-seed-3.crt"; sourceTree = "<group>"; };
B821494525D4D6FF009C0F2A /* URLModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLModal.swift; sourceTree = "<group>"; };
B821494E25D4E163009C0F2A /* BodyTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BodyTextView.swift; sourceTree = "<group>"; };
B82149B725D60393009C0F2A /* BlockedModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedModal.swift; sourceTree = "<group>"; };
B82149C025D605C6009C0F2A /* InfoBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoBanner.swift; sourceTree = "<group>"; };
B8269D2825C7A4B400488AB4 /* InputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputView.swift; sourceTree = "<group>"; };
@ -1656,6 +1695,9 @@
FD09799627FFA84900936362 /* RecipientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipientState.swift; sourceTree = "<group>"; };
FD09799827FFC1A300936362 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
FD09799A27FFC82D00936362 /* Quote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quote.swift; sourceTree = "<group>"; };
FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightMentionBackgroundView.swift; sourceTree = "<group>"; };
FD09B7E4288670BB00ED0B66 /* _008_EmojiReacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _008_EmojiReacts.swift; sourceTree = "<group>"; };
FD09B7E6288670FD00ED0B66 /* Reaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reaction.swift; sourceTree = "<group>"; };
FD09C5E1282212B3000CE219 /* JobDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobDependencies.swift; sourceTree = "<group>"; };
FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewModel.swift; sourceTree = "<group>"; };
FD09C5E728264937000CE219 /* MediaDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDetailViewController.swift; sourceTree = "<group>"; };
@ -1695,6 +1737,7 @@
FD37EA0E28AB3330003AE748 /* _006_FixHiddenModAdminSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _006_FixHiddenModAdminSupport.swift; sourceTree = "<group>"; };
FD37EA1028AB34B3003AE748 /* TypedTableAlteration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypedTableAlteration.swift; sourceTree = "<group>"; };
FD37EA1428AB42CB003AE748 /* IdentitySpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentitySpec.swift; sourceTree = "<group>"; };
FD37EA1A28ACB51F003AE748 /* _007_HomeQueryOptimisationIndexes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _007_HomeQueryOptimisationIndexes.swift; sourceTree = "<group>"; };
FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = "<group>"; };
FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchRequestInfoSpec.swift; sourceTree = "<group>"; };
FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadResponseSpec.swift; sourceTree = "<group>"; };
@ -1820,14 +1863,12 @@
FDC6D75F2862B3F600B04575 /* Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = "<group>"; };
FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Differentiable+Utilities.swift"; sourceTree = "<group>"; };
FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
FDCDB8F02817ABE600352A0C /* Optional+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Utilities.swift"; sourceTree = "<group>"; };
FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DifferenceKit+Utilities.swift"; sourceTree = "<group>"; };
FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarbageCollectionJob.swift; sourceTree = "<group>"; };
FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryNavigationController.swift; sourceTree = "<group>"; };
FDE72117286C156E0093DF33 /* ChatSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatSettingsViewController.swift; sourceTree = "<group>"; };
FDE7214F287E50D50093DF33 /* ProtoWrappers.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = ProtoWrappers.py; sourceTree = "<group>"; };
FDE72150287E50D50093DF33 /* LintLocalizableStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LintLocalizableStrings.swift; sourceTree = "<group>"; };
FDE72153287FE4470093DF33 /* HighlightMentionBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightMentionBackgroundView.swift; sourceTree = "<group>"; };
FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerError.swift; sourceTree = "<group>"; };
FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlMessageProcessRecord.swift; sourceTree = "<group>"; };
FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UICollectionView+ReusableView.swift"; sourceTree = "<group>"; };
@ -2140,6 +2181,16 @@
path = Utilities;
sourceTree = "<group>";
};
7B1B52BD2851ADE1006069F2 /* Emoji Picker */ = {
isa = PBXGroup;
children = (
7B1B52DB28580D50006069F2 /* EmojiPickerCollectionView.swift */,
7B1B52DC28580D50006069F2 /* EmojiSkinTonePicker.swift */,
7B1B52D728580C6D006069F2 /* EmojiPickerSheet.swift */,
);
path = "Emoji Picker";
sourceTree = "<group>";
};
7B7CB18C270D06350079FF93 /* Views & Modals */ = {
isa = PBXGroup;
children = (
@ -2152,6 +2203,14 @@
path = "Views & Modals";
sourceTree = "<group>";
};
7B81682428B30BEC0069F315 /* Recovered References */ = {
isa = PBXGroup;
children = (
FD37EA1A28ACB51F003AE748 /* _007_HomeQueryOptimisationIndexes.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
};
7B93D06827CF173D00811CB6 /* Message Requests */ = {
isa = PBXGroup;
children = (
@ -2162,6 +2221,20 @@
path = "Message Requests";
sourceTree = "<group>";
};
7B9F71CA2852EEE2006DFE7B /* Emoji */ = {
isa = PBXGroup;
children = (
7B9F71D528531009006DFE7B /* Emoji+Available.swift */,
7B9F71D628531009006DFE7B /* EmojiWithSkinTones.swift */,
7B9F71CB2852EEE2006DFE7B /* Emoji+Category.swift */,
7B9F71CC2852EEE2006DFE7B /* EmojiWithSkinTones+String.swift */,
7B9F71CD2852EEE2006DFE7B /* Emoji+SkinTones.swift */,
7B9F71CE2852EEE2006DFE7B /* Emoji.swift */,
7B9F71CF2852EEE2006DFE7B /* Emoji+Name.swift */,
);
path = Emoji;
sourceTree = "<group>";
};
7BA68907272A279900EFC32F /* Call Management */ = {
isa = PBXGroup;
children = (
@ -2227,6 +2300,8 @@
B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */,
7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */,
7B7CB188270430D20079FF93 /* CallMessageView.swift */,
7B7037422834B81F000DCF35 /* ReactionContainerView.swift */,
7B7037442834BCC0000DCF35 /* ReactionView.swift */,
);
path = "Content Views";
sourceTree = "<group>";
@ -2253,7 +2328,6 @@
B821494525D4D6FF009C0F2A /* URLModal.swift */,
B8AF4BB326A5204600583500 /* SendSeedModal.swift */,
B8758859264503A6000E60D0 /* JoinOpenGroupModal.swift */,
B821494E25D4E163009C0F2A /* BodyTextView.swift */,
B82149B725D60393009C0F2A /* BlockedModal.swift */,
C374EEE125DA26740073A857 /* LinkPreviewModal.swift */,
B82149C025D605C6009C0F2A /* InfoBanner.swift */,
@ -2262,6 +2336,7 @@
FD4B200D283492210034334B /* InsetLockableTableView.swift */,
7B1581E3271FC59C00848B49 /* CallModal.swift */,
7BFFB33B27D02F5800BEA04E /* CallPermissionRequestModal.swift */,
7B9F71C828470667006DFE7B /* ReactionListSheet.swift */,
);
path = "Views & Modals";
sourceTree = "<group>";
@ -2273,6 +2348,7 @@
B835247725C38D190089A44F /* Message Cells */,
C328252E25CA54F70062D0A7 /* Context Menu */,
B821493625D4D6A7009C0F2A /* Views & Modals */,
7B1B52BD2851ADE1006069F2 /* Emoji Picker */,
C302094625DCDFD3001F572D /* Settings */,
FDF222062818CECF000A4995 /* ConversationViewModel.swift */,
B835246D25C38ABF0089A44F /* ConversationVC.swift */,
@ -2449,12 +2525,10 @@
B8CCF63B239757C10091D419 /* Shared */ = {
isa = PBXGroup;
children = (
FD4B200A283367350034334B /* Models */,
4CA46F4B219CCC630038ABDE /* CaptionView.swift */,
4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */,
45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */,
34386A53207D271C009F5D9C /* NeverClearView.swift */,
FDE72153287FE4470093DF33 /* HighlightMentionBackgroundView.swift */,
34F308A01ECB469700BB7697 /* OWSBezierPathView.h */,
34F308A11ECB469700BB7697 /* OWSBezierPathView.m */,
34330AA11E79686200DF2FB9 /* OWSProgressView.h */,
@ -2515,6 +2589,7 @@
isa = PBXGroup;
children = (
C3C2A74C2553A39700C340D1 /* VisibleMessage.swift */,
7B8D5FC328332600008324D9 /* VisibleMessage+Reaction.swift */,
C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */,
C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */,
C3C2A75E2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift */,
@ -2603,6 +2678,7 @@
C328253F25CA55880062D0A7 /* ContextMenuVC.swift */,
C328254825CA60E60062D0A7 /* ContextMenuVC+Action.swift */,
C328255125CA64470062D0A7 /* ContextMenuVC+ActionView.swift */,
7BFA8AE22831D0D4001876F3 /* ContextMenuVC+EmojiReactsView.swift */,
);
path = "Context Menu";
sourceTree = "<group>";
@ -2715,6 +2791,8 @@
B8CCF638239721E20091D419 /* TabBar.swift */,
B8BB82B423947F2D00BA5194 /* TextField.swift */,
C3C3CF8824D8EED300E1CCE7 /* TextView.swift */,
7BBBDC43286EAD2D00747E59 /* TappableLabel.swift */,
FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */,
);
path = Components;
sourceTree = "<group>";
@ -3063,8 +3141,6 @@
C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */,
C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */,
C38EF281255B6D84007E1867 /* OWSAudioSession.swift */,
C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */,
C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */,
FDF0B75D280AAF35004C14C5 /* Preferences.swift */,
C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */,
C38EF306255B6DBE007E1867 /* OWSWindowManager.m */,
@ -3132,7 +3208,6 @@
B8A582AE258C65D000AFD84C /* Networking */,
B8A582AD258C655E00AFD84C /* PromiseKit */,
FD09796527F6B0A800936362 /* Utilities */,
FDCDB8EF2817ABCE00352A0C /* Utilities */,
C3D9E43025676D3D0040E4F3 /* Configuration.swift */,
);
path = SessionUtilitiesKit;
@ -3329,6 +3404,7 @@
D221A08C169C9E5E00537ABF /* Frameworks */,
D221A08A169C9E5E00537ABF /* Products */,
2BADBA206E0B8D297E313FBA /* Pods */,
7B81682428B30BEC0069F315 /* Recovered References */,
);
sourceTree = "<group>";
};
@ -3405,6 +3481,7 @@
isa = PBXGroup;
children = (
C3F0A58F255C8E3D007BE2A3 /* Meta */,
7B9F71CA2852EEE2006DFE7B /* Emoji */,
B8B558ED26C4B55F00693325 /* Calls */,
C360969C25AD18BA008B62B2 /* Closed Groups */,
B835246C25C38AA20089A44F /* Conversations */,
@ -3432,6 +3509,8 @@
FD09797127FAA2F500936362 /* Optional+Utilities.swift */,
FD09797C27FBDB2000936362 /* Notification+Utilities.swift */,
FDF222082818D2B0000A4995 /* NSAttributedString+Utilities.swift */,
C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */,
C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */,
);
path = Utilities;
sourceTree = "<group>";
@ -3457,6 +3536,7 @@
FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */,
FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */,
FD5C7308285007920029977D /* BlindedIdLookup.swift */,
FD09B7E6288670FD00ED0B66 /* Reaction.swift */,
);
path = Models;
sourceTree = "<group>";
@ -3470,6 +3550,8 @@
FDF40CDD2897A1BC006A0CC4 /* _004_RemoveLegacyYDB.swift */,
FD37EA0C28AB2A45003AE748 /* _005_FixDeletedMessageReadState.swift */,
FD37EA0E28AB3330003AE748 /* _006_FixHiddenModAdminSupport.swift */,
7B81682728B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift */,
FD09B7E4288670BB00ED0B66 /* _008_EmojiReacts.swift */,
);
path = Migrations;
sourceTree = "<group>";
@ -3653,13 +3735,6 @@
path = "Shared Models";
sourceTree = "<group>";
};
FD4B200A283367350034334B /* Models */ = {
isa = PBXGroup;
children = (
);
path = Models;
sourceTree = "<group>";
};
FD716E6F28505E5100C96BF4 /* Views */ = {
isa = PBXGroup;
children = (
@ -3788,6 +3863,8 @@
FDC438A327BB107F00C60D73 /* UserBanRequest.swift */,
FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */,
FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */,
7B81682928B6F1420069F315 /* ReactionResponse.swift */,
7B81682B28B72F480069F315 /* PendingChange.swift */,
);
path = Models;
sourceTree = "<group>";
@ -3815,6 +3892,7 @@
FDC4384E27B4804F00C60D73 /* Header.swift */,
FDC4385027B4807400C60D73 /* QueryParam.swift */,
FD83B9CD27D17A04005E1583 /* Request.swift */,
7B81682228A4C1210069F315 /* UpdateTypes.swift */,
);
path = "Common Networking";
sourceTree = "<group>";
@ -3890,14 +3968,6 @@
path = Models;
sourceTree = "<group>";
};
FDCDB8EF2817ABCE00352A0C /* Utilities */ = {
isa = PBXGroup;
children = (
FDCDB8F02817ABE600352A0C /* Optional+Utilities.swift */,
);
path = Utilities;
sourceTree = "<group>";
};
FDE7214E287E50D50093DF33 /* Scripts */ = {
isa = PBXGroup;
children = (
@ -4012,6 +4082,7 @@
C3C2A67D255388CC00C340D1 /* SessionUtilitiesKit.h in Headers */,
C32C6018256E07F9003C73A2 /* NSUserDefaults+OWS.h in Headers */,
B8856D8D256F1502001CE70E /* UIView+OWS.h in Headers */,
FD52090128AF61BA006098F6 /* OWSBackgroundTask.h in Headers */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -4021,7 +4092,6 @@
files = (
C3C2A6F425539DE700C340D1 /* SessionMessagingKit.h in Headers */,
C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */,
C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */,
FD716E732850647900C96BF4 /* NSData+messagePadding.h in Headers */,
B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */,
B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */,
@ -4855,6 +4925,8 @@
buildActionMask = 2147483647;
files = (
C331FF972558FA6B00070591 /* Fonts.swift in Sources */,
7BBBDC44286EAD2D00747E59 /* TappableLabel.swift in Sources */,
FD09B7E328865FDA00ED0B66 /* HighlightMentionBackgroundView.swift in Sources */,
C331FF9B2558FA6B00070591 /* Gradients.swift in Sources */,
C331FFB82558FA8D00070591 /* DeviceUtilities.swift in Sources */,
C331FFE72558FB0000070591 /* TextField.swift in Sources */,
@ -5028,7 +5100,6 @@
FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */,
C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */,
FD09797B27FBB25900936362 /* Updatable.swift in Sources */,
FDCDB8F12817ABE600352A0C /* Optional+Utilities.swift in Sources */,
7B7CB192271508AD0079FF93 /* CallRingTonePlayer.swift in Sources */,
C3C2ABD22553C6C900C340D1 /* Data+SecureRandom.swift in Sources */,
FD848B8B283DC509000E298B /* PagedDatabaseObserver.swift in Sources */,
@ -5074,6 +5145,7 @@
FD17D7B827F51ECA00122BE0 /* Migration.swift in Sources */,
FD7728982849E8110018502F /* UITableView+ReusableView.swift in Sources */,
7B0EFDEE274F598600FFAAE7 /* TimestampUtils.swift in Sources */,
FD52090028AF6153006098F6 /* OWSBackgroundTask.m in Sources */,
C32C5DDB256DD9FF003C73A2 /* ContentProxy.swift in Sources */,
C3A71F892558BA9F0043A11F /* Mnemonic.swift in Sources */,
B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */,
@ -5118,6 +5190,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7B81682828B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift in Sources */,
FD86585828507B24008B6CF9 /* NSData+messagePadding.m in Sources */,
FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */,
B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */,
@ -5129,18 +5202,21 @@
C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */,
FDF40CDE2897A1BC006A0CC4 /* _004_RemoveLegacyYDB.swift in Sources */,
FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */,
7B81682C28B72F480069F315 /* PendingChange.swift in Sources */,
FD77289A284AF1BD0018502F /* Sodium+Utilities.swift in Sources */,
FD5C7309285007920029977D /* BlindedIdLookup.swift in Sources */,
7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */,
C300A5F22554B09800555489 /* MessageSender.swift in Sources */,
B8B558FF26C4E05E00693325 /* WebRTCSession+MessageHandling.swift in Sources */,
C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */,
FD09B7E7288670FD00ED0B66 /* Reaction.swift in Sources */,
FD245C58285065F700B966DD /* OpenGroupServerIdLookup.swift in Sources */,
FD245C5A2850660100B966DD /* LinkPreviewDraft.swift in Sources */,
FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */,
FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */,
FD716E6A2850327900C96BF4 /* EndCallMode.swift in Sources */,
FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */,
7B81682A28B6F1420069F315 /* ReactionResponse.swift in Sources */,
FD09799727FFA84A00936362 /* RecipientState.swift in Sources */,
FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */,
FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */,
@ -5153,13 +5229,19 @@
FD245C6A2850666F00B966DD /* FileServerAPI.swift in Sources */,
FDC4386927B4E6B800C60D73 /* String+Utlities.swift in Sources */,
FD716E6628502EE200C96BF4 /* CurrentCallProtocol.swift in Sources */,
FD09B7E5288670BB00ED0B66 /* _008_EmojiReacts.swift in Sources */,
FDC4385F27B4C4A200C60D73 /* PinnedMessage.swift in Sources */,
7BFD1A8C2747150E00FB91B9 /* TurnServerInfo.swift in Sources */,
C3D9E3BF25676AD70040E4F3 /* (null) in Sources */,
B8BF43BA26CC95FB007828D1 /* WebRTC+Utilities.swift in Sources */,
7B8D5FC428332600008324D9 /* VisibleMessage+Reaction.swift in Sources */,
C3BBE0B52554F0E10050F1E3 /* (null) in Sources */,
FDC4386527B4DE7600C60D73 /* RoomPollInfo.swift in Sources */,
FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */,
FD37EA0F28AB3330003AE748 /* _006_FixHiddenModAdminSupport.swift in Sources */,
C3D9E3BF25676AD70040E4F3 /* (null) in Sources */,
B8BF43BA26CC95FB007828D1 /* WebRTC+Utilities.swift in Sources */,
7B81682328A4C1210069F315 /* UpdateTypes.swift in Sources */,
FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */,
C3BBE0B52554F0E10050F1E3 /* (null) in Sources */,
FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */,
@ -5247,7 +5329,6 @@
FD245C5C2850660A00B966DD /* ConfigurationMessage.swift in Sources */,
FD5C7301284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift in Sources */,
C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */,
C32C5FBB256E0206003C73A2 /* OWSBackgroundTask.m in Sources */,
FD245C642850664F00B966DD /* Threading.swift in Sources */,
FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */,
C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */,
@ -5293,6 +5374,7 @@
FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */,
4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */,
34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */,
7BFA8AE32831D0D4001876F3 /* ContextMenuVC+EmojiReactsView.swift in Sources */,
7B13E1EB2811138200BD4F64 /* PrivacySettingsTableViewController.swift in Sources */,
C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */,
7BAF54D027ACCEEC003D12F8 /* EmptySearchResultCell.swift in Sources */,
@ -5312,6 +5394,7 @@
EF764C351DB67CC5000D9A87 /* UIViewController+Permissions.m in Sources */,
45CD81EF1DC030E7004C9430 /* SyncPushTokensJob.swift in Sources */,
B83524A525C3BA4B0089A44F /* InfoMessageCell.swift in Sources */,
7B9F71D82853100A006DFE7B /* EmojiWithSkinTones.swift in Sources */,
FDE72118286C156E0093DF33 /* ChatSettingsViewController.swift in Sources */,
B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */,
34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */,
@ -5321,9 +5404,9 @@
7B1581E6271FD2A100848B49 /* VideoPreviewVC.swift in Sources */,
B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */,
3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */,
FDE72154287FE4470093DF33 /* HighlightMentionBackgroundView.swift in Sources */,
340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */,
4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */,
7B9F71D12852EEE2006DFE7B /* EmojiWithSkinTones+String.swift in Sources */,
34F308A21ECB469700BB7697 /* OWSBezierPathView.m in Sources */,
B886B4A92398BA1500211ABE /* QRCode.swift in Sources */,
3496955D219B605E00DCFE74 /* PhotoCollectionPickerController.swift in Sources */,
@ -5335,7 +5418,9 @@
3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */,
C3A76A8D25DB83F90074CB90 /* PermissionMissingModal.swift in Sources */,
340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */,
7B1B52E028580D51006069F2 /* EmojiSkinTonePicker.swift in Sources */,
B849789625D4A2F500D0D0B3 /* LinkPreviewView.swift in Sources */,
7B1B52DF28580D51006069F2 /* EmojiPickerCollectionView.swift in Sources */,
C3D0972B2510499C00F6E3E4 /* BackgroundPoller.swift in Sources */,
C3548F0624456447009433A8 /* PNModeVC.swift in Sources */,
B80A579F23DFF1F300876683 /* NewClosedGroupVC.swift in Sources */,
@ -5364,6 +5449,7 @@
45A6DAD61EBBF85500893231 /* ReminderView.swift in Sources */,
B82B408E239DC00D00A248E7 /* DisplayNameVC.swift in Sources */,
B835246E25C38ABF0089A44F /* ConversationVC.swift in Sources */,
7B7037432834B81F000DCF35 /* ReactionContainerView.swift in Sources */,
B8AF4BB426A5204600583500 /* SendSeedModal.swift in Sources */,
B821494625D4D6FF009C0F2A /* URLModal.swift in Sources */,
7BBBDC462875600700747E59 /* DocumentTitleViewController.swift in Sources */,
@ -5392,11 +5478,14 @@
4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */,
B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */,
C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */,
7B9F71D02852EEE2006DFE7B /* Emoji+Category.swift in Sources */,
7BAADFCC27B0EF23007BCF92 /* CallVideoView.swift in Sources */,
B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */,
FD848B9828422F1A000E298B /* Date+Utilities.swift in Sources */,
B85357C323A1BD1200AAF6CD /* SeedVC.swift in Sources */,
45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */,
7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */,
7B7037452834BCC0000DCF35 /* ReactionView.swift in Sources */,
B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */,
7B7CB190270FB2150079FF93 /* MiniCallView.swift in Sources */,
B875885A264503A6000E60D0 /* JoinOpenGroupModal.swift in Sources */,
@ -5412,14 +5501,17 @@
B8041A9525C8FA1D003C2166 /* MediaLoaderView.swift in Sources */,
45F32C232057297A00A300D5 /* MediaPageViewController.swift in Sources */,
34D2CCDA2062E7D000CB1A14 /* OWSScreenLockUI.m in Sources */,
7B9F71D42852EEE2006DFE7B /* Emoji+Name.swift in Sources */,
4CA46F4C219CCC630038ABDE /* CaptionView.swift in Sources */,
C328253025CA55370062D0A7 /* ContextMenuWindow.swift in Sources */,
340FC8B7204DAC8D007AEB0F /* OWSConversationSettingsViewController.m in Sources */,
7B1581E4271FC59D00848B49 /* CallModal.swift in Sources */,
34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */,
B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */,
7B9F71D72853100A006DFE7B /* Emoji+Available.swift in Sources */,
FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */,
7B7CB189270430D20079FF93 /* CallMessageView.swift in Sources */,
7B9F71D32852EEE2006DFE7B /* Emoji.swift in Sources */,
C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */,
B82B4090239DD75000A248E7 /* RestoreVC.swift in Sources */,
3488F9362191CC4000E524CC /* MediaView.swift in Sources */,
@ -5440,11 +5532,11 @@
C31A6C5A247F214E001123EF /* UIView+Glow.swift in Sources */,
4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */,
340FC8AC204DAC8D007AEB0F /* PrivacySettingsTableViewController.m in Sources */,
7B9F71D22852EEE2006DFE7B /* Emoji+SkinTones.swift in Sources */,
7B7CB18E270D066F0079FF93 /* IncomingCallBanner.swift in Sources */,
B8569AE325CBB19A00DBA3DB /* DocumentView.swift in Sources */,
7BFD1A8A2745C4F000FB91B9 /* Permissions.swift in Sources */,
B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */,
B821494F25D4E163009C0F2A /* BodyTextView.swift in Sources */,
FD716E6C28505E1C00C96BF4 /* MessageRequestsViewModel.swift in Sources */,
C35E8AAE2485E51D00ACB629 /* IP2Country.swift in Sources */,
B835249B25C3AB650089A44F /* VisibleMessageCell.swift in Sources */,
@ -5452,6 +5544,7 @@
B8D0A25025E3678700C1835E /* LinkDeviceVC.swift in Sources */,
B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */,
7B93D07727CF1A8A00811CB6 /* MockDataGenerator.swift in Sources */,
7B1B52D828580C6D006069F2 /* EmojiPickerSheet.swift in Sources */,
7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */,
FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */,
FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */,
@ -5707,7 +5800,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 348;
CURRENT_PROJECT_VERSION = 374;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -5732,7 +5825,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.13.0;
MARKETING_VERSION = 2.1.0;
MTL_ENABLE_DEBUG_INFO = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -5780,7 +5873,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 348;
CURRENT_PROJECT_VERSION = 374;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO;
@ -5810,7 +5903,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.13.0;
MARKETING_VERSION = 2.1.0;
MTL_ENABLE_DEBUG_INFO = NO;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -5846,7 +5939,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 348;
CURRENT_PROJECT_VERSION = 374;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -5869,7 +5962,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.13.0;
MARKETING_VERSION = 2.1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
@ -5920,7 +6013,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 348;
CURRENT_PROJECT_VERSION = 374;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO;
@ -5948,7 +6041,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.13.0;
MARKETING_VERSION = 2.1.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
@ -6858,7 +6951,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 371;
CURRENT_PROJECT_VERSION = 374;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
@ -6897,7 +6990,7 @@
"$(SRCROOT)",
);
LLVM_LTO = NO;
MARKETING_VERSION = 2.0.3;
MARKETING_VERSION = 2.1.0;
OTHER_LDFLAGS = "$(inherited)";
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
@ -6930,7 +7023,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 371;
CURRENT_PROJECT_VERSION = 374;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
@ -6969,7 +7062,7 @@
"$(SRCROOT)",
);
LLVM_LTO = NO;
MARKETING_VERSION = 2.0.3;
MARKETING_VERSION = 2.1.0;
OTHER_LDFLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
PRODUCT_NAME = Session;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,138 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
class EmojiPickerSheet: BaseVC {
let completionHandler: (EmojiWithSkinTones?) -> Void
let dismissHandler: () -> Void
// MARK: Components
private lazy var contentView: UIView = {
let result = UIView()
let line = UIView()
line.set(.height, to: 0.5)
line.backgroundColor = Colors.border.withAlphaComponent(0.5)
result.addSubview(line)
line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top ], to: result)
result.backgroundColor = Colors.modalBackground
return result
}()
private let collectionView = EmojiPickerCollectionView()
private lazy var searchBar: SearchBar = {
let result = SearchBar()
result.tintColor = Colors.text
result.backgroundColor = .clear
result.delegate = self
return result
}()
// MARK: Lifecycle
init(completionHandler: @escaping (EmojiWithSkinTones?) -> Void, dismissHandler: @escaping () -> Void) {
self.completionHandler = completionHandler
self.dismissHandler = dismissHandler
super.init(nibName: nil, bundle: nil)
}
public required init() {
fatalError("init() has not been implemented")
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func viewDidLoad() {
super.viewDidLoad()
setUpViewHierarchy()
}
private func setUpViewHierarchy() {
view.addSubview(contentView)
contentView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.bottom ], to: view)
contentView.set(.height, to: 440)
populateContentView()
}
private func populateContentView() {
let topStackView = UIStackView()
topStackView.axis = .horizontal
topStackView.isLayoutMarginsRelativeArrangement = true
topStackView.spacing = 8
topStackView.addArrangedSubview(searchBar)
contentView.addSubview(topStackView)
topStackView.autoPinWidthToSuperview()
topStackView.autoPinEdge(toSuperviewEdge: .top)
contentView.addSubview(collectionView)
collectionView.autoPinEdge(.top, to: .bottom, of: searchBar)
collectionView.autoPinEdge(.bottom, to: .bottom, of: contentView)
collectionView.autoPinWidthToSuperview()
collectionView.pickerDelegate = self
collectionView.alwaysBounceVertical = true
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { _ in
self.collectionView.reloadData()
}, completion: nil)
}
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// Ensure the scrollView's layout has completed
// as we're about to use its bounds to calculate
// the masking view and contentOffset.
contentView.layoutIfNeeded()
}
// MARK: Interaction
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let touch = touches.first!
let location = touch.location(in: view)
if contentView.frame.contains(location) {
super.touchesBegan(touches, with: event)
} else {
close()
}
}
@objc func close() {
dismiss(animated: true, completion: dismissHandler)
}
}
extension EmojiPickerSheet: EmojiPickerCollectionViewDelegate {
func emojiPickerWillBeginDragging(_ emojiPicker: EmojiPickerCollectionView) {
searchBar.resignFirstResponder()
}
func emojiPicker(_ emojiPicker: EmojiPickerCollectionView?, didSelectEmoji emoji: EmojiWithSkinTones) {
completionHandler(emoji)
dismiss(animated: true, completion: dismissHandler)
}
}
extension EmojiPickerSheet: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
collectionView.searchText = searchText
}
func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
searchBar.showsCancelButton = true
return true
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
searchBar.showsCancelButton = false
searchBar.resignFirstResponder()
}
}

View file

@ -0,0 +1,305 @@
import Foundation
class EmojiSkinTonePicker: UIView {
let emoji: Emoji
let preferredSkinTonePermutation: [Emoji.SkinTone]?
let completion: (EmojiWithSkinTones?) -> Void
private let referenceOverlay = UIView()
private let containerView = UIView()
class func present(
referenceView: UIView,
emoji: EmojiWithSkinTones,
completion: @escaping (EmojiWithSkinTones?) -> Void
) -> EmojiSkinTonePicker? {
guard let baseEmoji = emoji.baseEmoji, baseEmoji.hasSkinTones else { return nil }
UIImpactFeedbackGenerator(style: .light).impactOccurred()
let picker = EmojiSkinTonePicker(emoji: emoji, completion: completion)
guard let superview = referenceView.superview else {
owsFailDebug("reference is missing superview")
return nil
}
superview.addSubview(picker)
picker.referenceOverlay.autoMatch(.width, to: .width, of: referenceView)
picker.referenceOverlay.autoMatch(.height, to: .height, of: referenceView, withOffset: 30)
picker.referenceOverlay.autoPinEdge(.leading, to: .leading, of: referenceView)
let leadingConstraint = picker.autoPinEdge(toSuperviewEdge: .leading)
picker.layoutIfNeeded()
let halfWidth = picker.width() / 2
let margin: CGFloat = 8
if (halfWidth + margin) > referenceView.center.x {
leadingConstraint.constant = margin
} else if (halfWidth + margin) > (superview.width() - referenceView.center.x) {
leadingConstraint.constant = superview.width() - picker.width() - margin
} else {
leadingConstraint.constant = referenceView.center.x - halfWidth
}
let distanceFromTop = referenceView.frame.minY - superview.bounds.minY
if distanceFromTop > picker.containerView.height() {
picker.containerView.autoPinEdge(toSuperviewEdge: .top)
picker.referenceOverlay.autoPinEdge(.top, to: .bottom, of: picker.containerView, withOffset: -20)
picker.referenceOverlay.autoPinEdge(toSuperviewEdge: .bottom)
picker.autoPinEdge(.bottom, to: .bottom, of: referenceView)
} else {
picker.containerView.autoPinEdge(toSuperviewEdge: .bottom)
picker.referenceOverlay.autoPinEdge(.bottom, to: .top, of: picker.containerView, withOffset: 20)
picker.referenceOverlay.autoPinEdge(toSuperviewEdge: .top)
picker.autoPinEdge(.top, to: .top, of: referenceView)
}
picker.alpha = 0
UIView.animate(withDuration: 0.12) { picker.alpha = 1 }
return picker
}
func dismiss() {
UIView.animate(withDuration: 0.12, animations: { self.alpha = 0 }) { _ in
self.removeFromSuperview()
}
}
func didChangeLongPress(_ sender: UILongPressGestureRecognizer) {
guard let singleSelectionButtons = singleSelectionButtons else { return }
if referenceOverlay.frame.contains(sender.location(in: self)) {
singleSelectionButtons.forEach { $0.isSelected = false }
} else {
let point = sender.location(in: containerView)
let previouslySelectedButton = singleSelectionButtons.first { $0.isSelected }
singleSelectionButtons.forEach { $0.isSelected = $0.frame.insetBy(dx: -3, dy: -80).contains(point) }
let selectedButton = singleSelectionButtons.first { $0.isSelected }
if let selectedButton = selectedButton, selectedButton != previouslySelectedButton {
SelectionHapticFeedback().selectionChanged()
}
}
}
func didEndLongPress(_ sender: UILongPressGestureRecognizer) {
guard let singleSelectionButtons = singleSelectionButtons else { return }
let point = sender.location(in: containerView)
if referenceOverlay.frame.contains(sender.location(in: self)) {
// Do nothing.
} else if let selectedButton = singleSelectionButtons.first(where: {
$0.frame.insetBy(dx: -3, dy: -80).contains(point)
}) {
selectedButton.sendActions(for: .touchUpInside)
} else {
dismiss()
}
}
init(emoji: EmojiWithSkinTones, completion: @escaping (EmojiWithSkinTones?) -> Void) {
owsAssertDebug(emoji.baseEmoji!.hasSkinTones)
self.emoji = emoji.baseEmoji!
self.preferredSkinTonePermutation = emoji.skinTones
self.completion = completion
super.init(frame: .zero)
layer.shadowOffset = .zero
layer.shadowOpacity = 0.25
layer.shadowRadius = 4
referenceOverlay.backgroundColor = Colors.modalBackground
referenceOverlay.layer.cornerRadius = 9
addSubview(referenceOverlay)
containerView.layoutMargins = UIEdgeInsets(top: 9, leading: 16, bottom: 9, trailing: 16)
containerView.backgroundColor = Colors.modalBackground
containerView.layer.cornerRadius = 11
addSubview(containerView)
containerView.autoPinWidthToSuperview()
containerView.setCompressionResistanceHigh()
if emoji.baseEmoji!.allowsMultipleSkinTones {
prepareForMultipleSkinTones()
} else {
prepareForSingleSkinTone()
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Single Skin Tone
private lazy var yellowEmoji = EmojiWithSkinTones(baseEmoji: emoji, skinTones: nil)
private lazy var yellowButton = button(for: yellowEmoji) { [weak self] emojiWithSkinTone in
self?.completion(emojiWithSkinTone)
}
private var singleSelectionButtons: [UIButton]?
private func prepareForSingleSkinTone() {
let hStack = UIStackView()
hStack.axis = .horizontal
hStack.spacing = 8
containerView.addSubview(hStack)
hStack.autoPinEdgesToSuperviewMargins()
hStack.addArrangedSubview(yellowButton)
hStack.addArrangedSubview(.spacer(withWidth: 2))
let divider = UIView()
divider.autoSetDimension(.width, toSize: 1)
divider.backgroundColor = isDarkMode ? .ows_gray75 : .ows_gray05
hStack.addArrangedSubview(divider)
hStack.addArrangedSubview(.spacer(withWidth: 2))
let skinToneButtons = self.skinToneButtons(for: emoji) { [weak self] emojiWithSkinTone in
self?.completion(emojiWithSkinTone)
}
singleSelectionButtons = skinToneButtons.map { $0.button }
singleSelectionButtons?.forEach { hStack.addArrangedSubview($0) }
singleSelectionButtons?.append(yellowButton)
}
// MARK: - Multiple Skin Tones
private lazy var skinToneComponentEmoji: [Emoji] = {
guard let skinToneComponentEmoji = emoji.skinToneComponentEmoji else {
owsFailDebug("missing skin tone component emoji \(emoji)")
return []
}
return skinToneComponentEmoji
}()
private var buttonsPerComponentEmojiIndex = [Int: [(Emoji.SkinTone, UIButton)]]()
private lazy var skinToneButton = button(for: EmojiWithSkinTones(
baseEmoji: emoji,
skinTones: .init(repeating: .medium, count: skinToneComponentEmoji.count)
)) { [weak self] _ in
guard let self = self else { return }
guard self.selectedSkinTones.count == self.skinToneComponentEmoji.count else { return }
self.completion(EmojiWithSkinTones(baseEmoji: self.emoji, skinTones: self.selectedSkinTones))
}
private var selectedSkinTones = [Emoji.SkinTone]() {
didSet {
if selectedSkinTones.count == skinToneComponentEmoji.count {
skinToneButton.setTitle(
EmojiWithSkinTones(
baseEmoji: emoji,
skinTones: selectedSkinTones
).rawValue,
for: .normal
)
skinToneButton.isEnabled = true
skinToneButton.alpha = 1
} else {
skinToneButton.setTitle(
EmojiWithSkinTones(
baseEmoji: emoji,
skinTones: [.medium]
).rawValue,
for: .normal
)
skinToneButton.isEnabled = false
skinToneButton.alpha = 0.2
}
}
}
private var skinTonePerComponentEmojiIndex = [Int: Emoji.SkinTone]() {
didSet {
var selectedSkinTones = [Emoji.SkinTone]()
for idx in skinToneComponentEmoji.indices {
for (skinTone, button) in buttonsPerComponentEmojiIndex[idx] ?? [] {
if skinTonePerComponentEmojiIndex[idx] == skinTone {
selectedSkinTones.append(skinTone)
button.isSelected = true
} else {
button.isSelected = false
}
}
}
self.selectedSkinTones = selectedSkinTones
}
}
private func prepareForMultipleSkinTones() {
let vStack = UIStackView()
vStack.axis = .vertical
vStack.spacing = 6
containerView.addSubview(vStack)
vStack.autoPinEdgesToSuperviewMargins()
for (idx, emoji) in skinToneComponentEmoji.enumerated() {
let skinToneButtons = self.skinToneButtons(for: emoji) { [weak self] emojiWithSkinTone in
self?.skinTonePerComponentEmojiIndex[idx] = emojiWithSkinTone.skinTones?.first
}
buttonsPerComponentEmojiIndex[idx] = skinToneButtons
let hStack = UIStackView(arrangedSubviews: skinToneButtons.map { $0.button })
hStack.axis = .horizontal
hStack.spacing = 6
vStack.addArrangedSubview(hStack)
skinTonePerComponentEmojiIndex[idx] = preferredSkinTonePermutation?[safe: idx]
// If there's only one preferred skin tone, all the component emoji use it.
if preferredSkinTonePermutation?.count == 1 {
skinTonePerComponentEmojiIndex[idx] = preferredSkinTonePermutation?.first
} else {
skinTonePerComponentEmojiIndex[idx] = preferredSkinTonePermutation?[safe: idx]
}
}
let divider = UIView()
divider.autoSetDimension(.height, toSize: 1)
divider.backgroundColor = isDarkMode ? .ows_gray75 : .ows_gray05
vStack.addArrangedSubview(divider)
let leftSpacer = UIView.hStretchingSpacer()
let middleSpacer = UIView.hStretchingSpacer()
let rightSpacer = UIView.hStretchingSpacer()
let hStack = UIStackView(arrangedSubviews: [leftSpacer, yellowButton, middleSpacer, skinToneButton, rightSpacer])
hStack.axis = .horizontal
vStack.addArrangedSubview(hStack)
leftSpacer.autoMatch(.width, to: .width, of: rightSpacer)
middleSpacer.autoMatch(.width, to: .width, of: rightSpacer)
}
// MARK: - Button Helpers
func skinToneButtons(for emoji: Emoji, handler: @escaping (EmojiWithSkinTones) -> Void) -> [(skinTone: Emoji.SkinTone, button: UIButton)] {
var buttons = [(Emoji.SkinTone, UIButton)]()
for skinTone in Emoji.SkinTone.allCases {
let emojiWithSkinTone = EmojiWithSkinTones(baseEmoji: emoji, skinTones: [skinTone])
buttons.append((skinTone, button(for: emojiWithSkinTone, handler: handler)))
}
return buttons
}
func button(for emoji: EmojiWithSkinTones, handler: @escaping (EmojiWithSkinTones) -> Void) -> UIButton {
let button = OWSButton { handler(emoji) }
button.titleLabel?.font = .boldSystemFont(ofSize: 32)
button.setTitle(emoji.rawValue, for: .normal)
button.setBackgroundImage(UIImage(color: isDarkMode ? .ows_gray60 : .ows_gray25), for: .selected)
button.layer.cornerRadius = 6
button.clipsToBounds = true
button.autoSetDimensions(to: CGSize(width: 38, height: 38))
return button
}
}

View file

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

View file

@ -102,6 +102,7 @@ final class CallMessageCell: MessageCell {
with cellViewModel: MessageViewModel,
mediaCache: NSCache<NSString, AnyObject>,
playbackInfo: ConversationViewModel.PlaybackInfo?,
showExpandedReactions: Bool,
lastSearchText: String?
) {
guard

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,588 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import DifferenceKit
import SessionUIKit
import SessionMessagingKit
import SignalUtilitiesKit
final class ReactionListSheet: BaseVC {
public struct ReactionSummary: Hashable, Differentiable {
let emoji: EmojiWithSkinTones
let number: Int
let isSelected: Bool
var description: String {
return "\(emoji.rawValue) · \(number)"
}
}
private let interactionId: Int64
private let onDismiss: (() -> ())?
private var messageViewModel: MessageViewModel = MessageViewModel()
private var reactionSummaries: [ReactionSummary] = []
private var selectedReactionUserList: [MessageViewModel.ReactionInfo] = []
private var lastSelectedReactionIndex: Int = 0
public var delegate: ReactionDelegate?
// MARK: - UI
private lazy var contentView: UIView = {
let result: UIView = UIView()
result.backgroundColor = Colors.modalBackground
let line: UIView = UIView()
line.backgroundColor = Colors.border.withAlphaComponent(0.5)
result.addSubview(line)
line.set(.height, to: 0.5)
line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top ], to: result)
return result
}()
private lazy var layout: UICollectionViewFlowLayout = {
let result: UICollectionViewFlowLayout = UICollectionViewFlowLayout()
result.scrollDirection = .horizontal
result.sectionInset = UIEdgeInsets(
top: 0,
leading: Values.smallSpacing,
bottom: 0,
trailing: Values.smallSpacing
)
result.minimumLineSpacing = Values.smallSpacing
result.minimumInteritemSpacing = Values.smallSpacing
result.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
return result
}()
private lazy var reactionContainer: UICollectionView = {
let result: UICollectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
result.register(view: Cell.self)
result.set(.height, to: 48)
result.backgroundColor = .clear
result.isScrollEnabled = true
result.showsHorizontalScrollIndicator = false
result.dataSource = self
result.delegate = self
return result
}()
private lazy var detailInfoLabel: UILabel = {
let result: UILabel = UILabel()
result.font = .systemFont(ofSize: Values.mediumFontSize)
result.textColor = Colors.grey.withAlphaComponent(0.8)
result.set(.height, to: 32)
return result
}()
private lazy var clearAllButton: Button = {
let result: Button = Button(style: .destructiveOutline, size: .small)
result.translatesAutoresizingMaskIntoConstraints = false
result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal)
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
result.layer.borderWidth = 0
result.isHidden = true
return result
}()
private lazy var userListView: UITableView = {
let result: UITableView = UITableView()
result.dataSource = self
result.delegate = self
result.register(view: UserCell.self)
result.register(view: FooterCell.self)
result.separatorStyle = .none
result.backgroundColor = .clear
result.showsVerticalScrollIndicator = false
return result
}()
// MARK: - Lifecycle
init(for interactionId: Int64, onDismiss: (() -> ())? = nil) {
self.interactionId = interactionId
self.onDismiss = onDismiss
super.init(nibName: nil, bundle: nil)
}
override init(nibName: String?, bundle: Bundle?) {
preconditionFailure("Use init(for:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(for:) instead.")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
let swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(close))
swipeGestureRecognizer.direction = .down
view.addGestureRecognizer(swipeGestureRecognizer)
setUpViewHierarchy()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
reactionContainer.scrollToItem(
at: IndexPath(item: lastSelectedReactionIndex, section: 0),
at: .centeredHorizontally,
animated: false
)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
self.onDismiss?()
}
private func setUpViewHierarchy() {
view.addSubview(contentView)
contentView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.bottom ], to: view)
// Emoji collectionView height + seleted emoji detail height + 5 × user cell height + footer cell height + bottom safe area inset
let contentViewHeight: CGFloat = 100 + 5 * 65 + 45 + UIApplication.shared.keyWindow!.safeAreaInsets.bottom
contentView.set(.height, to: contentViewHeight)
populateContentView()
}
private func populateContentView() {
// Reactions container
contentView.addSubview(reactionContainer)
reactionContainer.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: contentView)
reactionContainer.pin(.top, to: .top, of: contentView, withInset: Values.verySmallSpacing)
// Seperator
let seperator = UIView()
seperator.backgroundColor = Colors.border.withAlphaComponent(0.1)
seperator.set(.height, to: 0.5)
contentView.addSubview(seperator)
seperator.pin(.leading, to: .leading, of: contentView, withInset: Values.smallSpacing)
seperator.pin(.trailing, to: .trailing, of: contentView, withInset: -Values.smallSpacing)
seperator.pin(.top, to: .bottom, of: reactionContainer, withInset: Values.verySmallSpacing)
// Detail info & clear all
let stackView = UIStackView(arrangedSubviews: [ detailInfoLabel, clearAllButton ])
contentView.addSubview(stackView)
stackView.pin(.top, to: .bottom, of: seperator, withInset: Values.smallSpacing)
stackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing)
stackView.pin(.trailing, to: .trailing, of: contentView, withInset: -Values.mediumSpacing)
// Line
let line = UIView()
line.set(.height, to: 0.5)
line.backgroundColor = Colors.border.withAlphaComponent(0.5)
contentView.addSubview(line)
line.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: contentView)
line.pin(.top, to: .bottom, of: stackView, withInset: Values.smallSpacing)
// Reactor list
contentView.addSubview(userListView)
userListView.pin([ UIView.HorizontalEdge.trailing, UIView.HorizontalEdge.leading, UIView.VerticalEdge.bottom ], to: contentView)
userListView.pin(.top, to: .bottom, of: line, withInset: 0)
}
// MARK: - Content
public func handleInteractionUpdates(
_ allMessages: [MessageViewModel],
selectedReaction: EmojiWithSkinTones? = nil,
updatedReactionIndex: Int? = nil,
initialLoad: Bool = false,
shouldShowClearAllButton: Bool = false
) {
guard let cellViewModel: MessageViewModel = allMessages.first(where: { $0.id == self.interactionId }) else {
return
}
// If we have no more reactions (eg. the user removed the last one) then closed the list sheet
guard cellViewModel.reactionInfo?.isEmpty == false else {
close()
return
}
// Generated the updated data
let updatedReactionInfo: OrderedDictionary<EmojiWithSkinTones, [MessageViewModel.ReactionInfo]> = (cellViewModel.reactionInfo ?? [])
.reduce(into: OrderedDictionary<EmojiWithSkinTones, [MessageViewModel.ReactionInfo]>()) {
result, reactionInfo in
guard let emoji: EmojiWithSkinTones = EmojiWithSkinTones(rawValue: reactionInfo.reaction.emoji) else {
return
}
guard var updatedValue: [MessageViewModel.ReactionInfo] = result.value(forKey: emoji) else {
result.append(key: emoji, value: [reactionInfo])
return
}
if reactionInfo.reaction.authorId == cellViewModel.currentUserPublicKey {
updatedValue.insert(reactionInfo, at: 0)
}
else {
updatedValue.append(reactionInfo)
}
result.replace(key: emoji, value: updatedValue)
}
let oldSelectedReactionIndex: Int = self.lastSelectedReactionIndex
let updatedSelectedReactionIndex: Int = updatedReactionIndex
.defaulting(
to: {
// If we explicitly provided a 'selectedReaction' value then try to use that
if selectedReaction != nil, let targetIndex: Int = updatedReactionInfo.orderedKeys.firstIndex(where: { $0 == selectedReaction }) {
return targetIndex
}
// Otherwise try to maintain the index of the currently selected index
guard
!self.reactionSummaries.isEmpty,
let emoji: EmojiWithSkinTones = self.reactionSummaries[safe: oldSelectedReactionIndex]?.emoji,
let targetIndex: Int = updatedReactionInfo.orderedKeys.firstIndex(of: emoji)
else { return 0 }
return targetIndex
}()
)
let updatedSummaries: [ReactionSummary] = updatedReactionInfo
.orderedKeys
.enumerated()
.map { index, emoji in
ReactionSummary(
emoji: emoji,
number: updatedReactionInfo.value(forKey: emoji)
.defaulting(to: [])
.map { Int($0.reaction.count) }
.reduce(0, +),
isSelected: (index == updatedSelectedReactionIndex)
)
}
// Update the general UI
self.detailInfoLabel.text = updatedSummaries[safe: updatedSelectedReactionIndex]?.description
// Update general properties
self.messageViewModel = cellViewModel
self.lastSelectedReactionIndex = updatedSelectedReactionIndex
// Ensure the first load or a load when returning from a child screen runs without animations (if
// we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition)
guard !initialLoad else {
self.reactionSummaries = updatedSummaries
self.selectedReactionUserList = updatedReactionInfo
.orderedKeys[safe: updatedSelectedReactionIndex]
.map { updatedReactionInfo.value(forKey: $0) }
.defaulting(to: [])
// Update clear all button visibility
self.clearAllButton.isHidden = !shouldShowClearAllButton
UIView.performWithoutAnimation {
self.reactionContainer.reloadData()
self.userListView.reloadData()
}
return
}
// Update the collection view content
let collectionViewChangeset: StagedChangeset<[ReactionSummary]> = StagedChangeset(
source: self.reactionSummaries,
target: updatedSummaries
)
// If there are changes then we want to reload both the collection and table views
self.reactionContainer.reload(
using: collectionViewChangeset,
interrupt: { $0.changeCount > 1 }
) { [weak self] updatedData in
self?.reactionSummaries = updatedData
}
// If we changed the selected index then no need to reload the changes
guard
oldSelectedReactionIndex == updatedSelectedReactionIndex &&
self.reactionSummaries[safe: oldSelectedReactionIndex]?.emoji == updatedSummaries[safe: updatedSelectedReactionIndex]?.emoji
else {
self.selectedReactionUserList = updatedReactionInfo
.orderedKeys[safe: updatedSelectedReactionIndex]
.map { updatedReactionInfo.value(forKey: $0) }
.defaulting(to: [])
self.userListView.reloadData()
return
}
let tableChangeset: StagedChangeset<[MessageViewModel.ReactionInfo]> = StagedChangeset(
source: self.selectedReactionUserList,
target: updatedReactionInfo
.orderedKeys[safe: updatedSelectedReactionIndex]
.map { updatedReactionInfo.value(forKey: $0) }
.defaulting(to: [])
)
self.userListView.reload(
using: tableChangeset,
deleteSectionsAnimation: .none,
insertSectionsAnimation: .none,
reloadSectionsAnimation: .none,
deleteRowsAnimation: .none,
insertRowsAnimation: .none,
reloadRowsAnimation: .none,
interrupt: { [weak self] changeset in
/// This is the case where there were 6 reactors in total and locally we only have 5 including current user,
/// and current user remove the reaction. There would be 4 reactors locally and we need to show more
/// reactors cell at this moment. After update from sogs, we'll get the all 5 reactors and update the table
/// with 5 reactors and not showing the more reactors cell.
changeset.elementInserted.count == 1 && self?.selectedReactionUserList.count == 4 ||
/// This is the case where there were 5 reactors without current user, and current user reacted. Before we got
/// the update from sogs, we'll have 6 reactors locally and not showing the more reactors cell. After the update,
/// we'll need to update the table and show 5 reactors with the more reactors cell.
changeset.elementDeleted.count == 1 && self?.selectedReactionUserList.count == 6 ||
/// To many changes to make
changeset.changeCount > 100
}
) { [weak self] updatedData in
self?.selectedReactionUserList = updatedData
}
}
// MARK: - Interaction
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch: UITouch = touches.first, contentView.frame.contains(touch.location(in: view)) else {
close()
return
}
super.touchesBegan(touches, with: event)
}
@objc func close() {
dismiss(animated: true, completion: nil)
}
@objc private func clearAllTapped() {
guard let selectedReaction: EmojiWithSkinTones = self.reactionSummaries.first(where: { $0.isSelected })?.emoji else { return }
delegate?.removeAllReactions(messageViewModel, for: selectedReaction.rawValue)
}
}
// MARK: - UICollectionView
extension ReactionListSheet: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
// MARK: Data Source
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.reactionSummaries.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell: Cell = collectionView.dequeue(type: Cell.self, for: indexPath)
let summary: ReactionSummary = self.reactionSummaries[indexPath.item]
cell.update(
with: summary.emoji.rawValue,
count: summary.number,
isCurrentSelection: summary.isSelected
)
return cell
}
// MARK: Interaction
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
self.handleInteractionUpdates([messageViewModel], updatedReactionIndex: indexPath.item)
}
}
// MARK: - UITableViewDelegate & UITableViewDataSource
extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
let moreReactorCount = self.reactionSummaries[lastSelectedReactionIndex].number - self.selectedReactionUserList.count
return moreReactorCount > 0 ? self.selectedReactionUserList.count + 1 : self.selectedReactionUserList.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard indexPath.row < self.selectedReactionUserList.count else {
let moreReactorCount = self.reactionSummaries[lastSelectedReactionIndex].number - self.selectedReactionUserList.count
let footerCell: FooterCell = tableView.dequeue(type: FooterCell.self, for: indexPath)
footerCell.update(
moreReactorCount: moreReactorCount,
emoji: self.reactionSummaries[lastSelectedReactionIndex].emoji.rawValue
)
footerCell.selectionStyle = .none
return footerCell
}
let cell: UserCell = tableView.dequeue(type: UserCell.self, for: indexPath)
let cellViewModel: MessageViewModel.ReactionInfo = self.selectedReactionUserList[indexPath.row]
cell.update(
with: cellViewModel.reaction.authorId,
profile: cellViewModel.profile,
isZombie: false,
mediumFont: true,
accessory: (cellViewModel.reaction.authorId == self.messageViewModel.currentUserPublicKey ?
.x :
.none
)
)
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard indexPath.row < self.selectedReactionUserList.count else { return }
let cellViewModel: MessageViewModel.ReactionInfo = self.selectedReactionUserList[indexPath.row]
guard
let selectedReaction: EmojiWithSkinTones = self.reactionSummaries
.first(where: { $0.isSelected })?
.emoji,
selectedReaction.rawValue == cellViewModel.reaction.emoji,
cellViewModel.reaction.authorId == self.messageViewModel.currentUserPublicKey
else { return }
delegate?.removeReact(self.messageViewModel, for: selectedReaction)
}
}
// MARK: - Cell
extension ReactionListSheet {
fileprivate final class Cell: UICollectionViewCell {
// MARK: - UI
private static var contentViewHeight: CGFloat = 32
private static var contentViewCornerRadius: CGFloat { contentViewHeight / 2 }
private lazy var snContentView: UIView = {
let result = UIView()
result.backgroundColor = Colors.receivedMessageBackground
result.set(.height, to: Cell.contentViewHeight)
result.layer.cornerRadius = Cell.contentViewCornerRadius
return result
}()
private lazy var emojiLabel: UILabel = {
let result = UILabel()
result.font = .systemFont(ofSize: Values.mediumFontSize)
return result
}()
private lazy var numberLabel: UILabel = {
let result = UILabel()
result.textColor = Colors.text
result.font = .systemFont(ofSize: Values.mediumFontSize)
return result
}()
// MARK: - Initialization
override init(frame: CGRect) {
super.init(frame: frame)
setUpViewHierarchy()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setUpViewHierarchy()
}
private func setUpViewHierarchy() {
addSubview(snContentView)
let stackView = UIStackView(arrangedSubviews: [ emojiLabel, numberLabel ])
stackView.axis = .horizontal
stackView.alignment = .center
let spacing = Values.smallSpacing + 2
stackView.spacing = spacing
stackView.layoutMargins = UIEdgeInsets(top: 0, left: spacing, bottom: 0, right: spacing)
stackView.isLayoutMarginsRelativeArrangement = true
snContentView.addSubview(stackView)
stackView.pin(to: snContentView)
snContentView.pin(to: self)
}
// MARK: - Content
fileprivate func update(
with emoji: String,
count: Int,
isCurrentSelection: Bool
) {
snContentView.addBorder(
with: (isCurrentSelection == true ? Colors.accent : .clear)
)
emojiLabel.text = emoji
numberLabel.text = (count < 1000 ?
"\(count)" :
String(format: "%.1fk", Float(count) / 1000)
)
}
}
fileprivate final class FooterCell: UITableViewCell {
private lazy var label: UILabel = {
let result = UILabel()
result.textAlignment = .center
result.font = .systemFont(ofSize: Values.smallFontSize)
result.textColor = Colors.grey.withAlphaComponent(0.8)
return result
}()
// MARK: - Initialization
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setUpViewHierarchy()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setUpViewHierarchy()
}
private func setUpViewHierarchy() {
// Background color
backgroundColor = Colors.cellBackground
contentView.addSubview(label)
label.pin(to: contentView)
label.set(.height, to: 45)
}
func update(moreReactorCount: Int, emoji: String) {
label.text = (moreReactorCount == 1) ?
String(format: "EMOJI_REACTS_MORE_REACTORS_ONE".localized(), "\(emoji)") :
String(format: "EMOJI_REACTS_MORE_REACTORS_MUTIPLE".localized(), "\(moreReactorCount)" ,"\(emoji)")
}
}
}
// MARK: - Delegate
protocol ReactionDelegate: AnyObject {
func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones)
func removeReact(_ cellViewModel: MessageViewModel, for emoji: EmojiWithSkinTones)
func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String)
}

View file

@ -0,0 +1,111 @@
import Foundation
extension Emoji {
private static let availableCache: Atomic<[Emoji:Bool]> = Atomic([:])
private static let iosVersionKey = "iosVersion"
private static let cacheUrl = URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath())
.appendingPathComponent("Library")
.appendingPathComponent("Caches")
.appendingPathComponent("emoji.plist")
static func warmAvailableCache() {
owsAssertDebug(!Thread.isMainThread)
guard CurrentAppContext().isMainAppAndActive else { return }
var availableCache = [Emoji: Bool]()
var uncachedEmoji = [Emoji]()
let iosVersion = UIDevice.current.systemVersion
// Use an NSMutableDictionary for built-in plist serialization and heterogeneous values.
var availableMap = NSMutableDictionary()
do {
availableMap = try NSMutableDictionary(contentsOf: Self.cacheUrl, error: ())
} catch {
Logger.info("Re-building emoji availability cache. Cache could not be loaded. \(error)")
uncachedEmoji = Emoji.allCases
}
let lastIosVersion = availableMap[iosVersionKey] as? String
if lastIosVersion == iosVersion {
Logger.debug("Loading emoji availability cache (expect \(Emoji.allCases.count) items, found \(availableMap.count - 1)).")
for emoji in Emoji.allCases {
if let available = availableMap[emoji.rawValue] as? Bool {
availableCache[emoji] = available
} else {
Logger.warn("Emoji unexpectedly missing from cache: \(emoji).")
uncachedEmoji.append(emoji)
}
}
} else if uncachedEmoji.isEmpty {
Logger.info("Re-building emoji availability cache. iOS version upgraded from \(lastIosVersion ?? "(none)") -> \(iosVersion)")
uncachedEmoji = Emoji.allCases
}
if !uncachedEmoji.isEmpty {
Logger.info("Checking emoji availability for \(uncachedEmoji.count) uncached emoji")
uncachedEmoji.forEach {
let available = isEmojiAvailable($0)
availableMap[$0.rawValue] = available
availableCache[$0] = available
}
availableMap[iosVersionKey] = iosVersion
do {
// Use FileManager.createDirectory directly because OWSFileSystem.ensureDirectoryExists
// can modify the protection, and this is a system-managed directory.
try FileManager.default.createDirectory(at: Self.cacheUrl.deletingLastPathComponent(),
withIntermediateDirectories: true)
try availableMap.write(to: Self.cacheUrl)
} catch {
Logger.warn("Failed to save emoji availability cache; it will be recomputed next time! \(error)")
}
}
Logger.info("Warmed emoji availability cache with \(availableCache.lazy.filter { $0.value }.count) available emoji for iOS \(iosVersion)")
Self.availableCache.mutate{ $0 = availableCache }
}
private static func isEmojiAvailable(_ emoji: Emoji) -> Bool {
return emoji.rawValue.isUnicodeStringAvailable
}
/// Indicates whether the given emoji is available on this iOS
/// version. We cache the availability in memory.
var available: Bool {
guard let available = Self.availableCache.wrappedValue[self] else {
let available = Self.isEmojiAvailable(self)
Self.availableCache.mutate{ $0[self] = available }
return available
}
return available
}
}
private extension String {
/// A known undefined unicode character for comparison
private static let unknownUnicodeStringPng = "\u{1fff}".unicodeStringPngRepresentation
// Based on https://stackoverflow.com/a/41393387
// Check if an emoji is available on the current iOS version
// by verifying its image is different than the "unknown"
// reference image
var isUnicodeStringAvailable: Bool {
guard self.isSingleEmoji else { return false }
return String.unknownUnicodeStringPng != unicodeStringPngRepresentation
}
var unicodeStringPngRepresentation: Data? {
let attributes = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 8)]
let size = (self as NSString).size(withAttributes: attributes)
UIGraphicsBeginImageContext(size)
defer { UIGraphicsEndImageContext() }
(self as NSString).draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
guard let unicodeImage = UIGraphicsGetImageFromCurrentImageContext() else { return nil }
return unicodeImage.pngData()
}
}

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

1863
Session/Emoji/Emoji.swift Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,138 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import DifferenceKit
import SessionMessagingKit
public struct EmojiWithSkinTones: Hashable, Equatable, ContentEquatable, ContentIdentifiable {
let baseEmoji: Emoji?
let skinTones: [Emoji.SkinTone]?
let unsupportedValue: String?
init(baseEmoji: Emoji, skinTones: [Emoji.SkinTone]? = nil) {
self.baseEmoji = baseEmoji
// Deduplicate skin tones, while preserving order. This allows for
// multi-skin tone emoji, where if you have for example the permutation
// [.dark, .dark], it is consolidated to just [.dark], to be initialized
// with either variant and result in the correct emoji.
self.skinTones = skinTones?.reduce(into: [Emoji.SkinTone]()) { result, skinTone in
guard !result.contains(skinTone) else { return }
result.append(skinTone)
}
self.unsupportedValue = nil
}
init(unsupportedValue: String) {
self.unsupportedValue = unsupportedValue
self.baseEmoji = nil
self.skinTones = nil
}
var rawValue: String {
if let baseEmoji = baseEmoji {
if let skinTones = skinTones {
return baseEmoji.emojiPerSkinTonePermutation?[skinTones] ?? baseEmoji.rawValue
} else {
return baseEmoji.rawValue
}
}
if let unsupportedValue = unsupportedValue {
return unsupportedValue
}
return "" // Should not happen
}
var normalized: EmojiWithSkinTones {
if let baseEmoji = baseEmoji, baseEmoji.normalized != baseEmoji {
return EmojiWithSkinTones(baseEmoji: baseEmoji.normalized)
}
return self
}
var isNormalized: Bool { self == normalized }
}
extension Emoji {
static func getRecent(_ db: Database, withDefaultEmoji: Bool) throws -> [String] {
let recentReactionEmoji: [String] = (db[.recentReactionEmoji]?
.components(separatedBy: ","))
.defaulting(to: [])
// No need to continue if we don't want the default emoji to pad out the list
guard withDefaultEmoji else { return recentReactionEmoji }
// Add in our default emoji if desired
let defaultEmoji = ["😂", "🥰", "😢", "😡", "😮", "😈"]
.filter { !recentReactionEmoji.contains($0) }
return Array(recentReactionEmoji
.appending(contentsOf: defaultEmoji)
.prefix(6))
}
static func addRecent(_ db: Database, emoji: String) {
// Add/move the emoji to the start of the most recent list
db[.recentReactionEmoji] = (db[.recentReactionEmoji]?
.components(separatedBy: ","))
.defaulting(to: [])
.filter { $0 != emoji }
.inserting(emoji, at: 0)
.prefix(6)
.joined(separator: ",")
}
static func allSendableEmojiByCategoryWithPreferredSkinTones(_ db: Database) -> [Category: [EmojiWithSkinTones]] {
return Category.allCases
.reduce(into: [Category: [EmojiWithSkinTones]]()) { result, category in
result[category] = category.normalizedEmoji
.filter { $0.available }
.map { $0.withPreferredSkinTones(db) }
}
}
private func withPreferredSkinTones(_ db: Database) -> EmojiWithSkinTones {
guard let rawSkinTones: String = db[.emojiPreferredSkinTones(emoji: rawValue)] else {
return EmojiWithSkinTones(baseEmoji: self, skinTones: nil)
}
return EmojiWithSkinTones(
baseEmoji: self,
skinTones: rawSkinTones
.split(separator: ",")
.compactMap { SkinTone(rawValue: String($0)) }
)
}
func setPreferredSkinTones(_ db: Database, preferredSkinTonePermutation: [SkinTone]?) {
db[.emojiPreferredSkinTones(emoji: rawValue)] = preferredSkinTonePermutation
.map { preferredSkinTonePermutation in
preferredSkinTonePermutation
.map { $0.rawValue }
.joined(separator: ",")
}
}
init?(_ string: String) {
guard let emojiWithSkinTonePermutation = EmojiWithSkinTones(rawValue: string) else { return nil }
if let baseEmoji = emojiWithSkinTonePermutation.baseEmoji {
self = baseEmoji
} else {
return nil
}
}
}
// MARK: -
extension String {
// This is slightly more accurate than String.isSingleEmoji,
// but slower.
//
// * This will reject "lone modifiers".
// * This will reject certain edge cases such as 🌈.
var isSingleEmojiUsingEmojiWithSkinTones: Bool {
EmojiWithSkinTones(rawValue: self) != nil
}
}

View file

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

View file

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

View file

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

View file

@ -388,7 +388,7 @@ public class MediaGalleryViewModel {
.removeDuplicates()
}
@discardableResult public func loadAndCacheAlbumData(for interactionId: Int64) -> [Item] {
@discardableResult public func loadAndCacheAlbumData(for interactionId: Int64, in threadId: String) -> [Item] {
typealias AlbumInfo = (albumData: [Item], interactionIdBefore: Int64?, interactionIdAfter: Int64?)
// Note: It's possible we already have cached album data for this interaction
@ -415,13 +415,19 @@ public class MediaGalleryViewModel {
let itemBefore: Item? = try Item
.baseQuery(
orderSQL: Item.galleryReverseOrderSQL,
customFilters: SQL("\(interaction[.timestampMs]) > \(albumTimestampMs)")
customFilters: SQL("""
\(interaction[.timestampMs]) > \(albumTimestampMs) AND
\(interaction[.threadId]) = \(threadId)
""")
)
.fetchOne(db)
let itemAfter: Item? = try Item
.baseQuery(
orderSQL: Item.galleryOrderSQL,
customFilters: SQL("\(interaction[.timestampMs]) < \(albumTimestampMs)")
customFilters: SQL("""
\(interaction[.timestampMs]) < \(albumTimestampMs) AND
\(interaction[.threadId]) = \(threadId)
""")
)
.fetchOne(db)
@ -527,7 +533,7 @@ public class MediaGalleryViewModel {
isPagedData: false,
mediaType: .media
)
viewModel.loadAndCacheAlbumData(for: interactionId)
viewModel.loadAndCacheAlbumData(for: interactionId, in: threadId)
viewModel.replaceAlbumObservation(toObservationFor: interactionId)
guard

View file

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

View file

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

View file

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

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

@ -689,3 +689,24 @@
"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation.";
"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…";
"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…";
/* The name for the emoji category 'Activities' */
"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities";
/* The name for the emoji category 'Animals & Nature' */
"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature";
/* The name for the emoji category 'Flags' */
"EMOJI_CATEGORY_FLAGS_NAME" = "Flags";
/* The name for the emoji category 'Food & Drink' */
"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink";
/* The name for the emoji category 'Objects' */
"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects";
/* The name for the emoji category 'Recents' */
"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used";
/* The name for the emoji category 'Smileys & People' */
"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People";
/* The name for the emoji category 'Symbols' */
"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols";
/* The name for the emoji category 'Travel & Places' */
"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places";
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message.";
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@
import Foundation
import DifferenceKit
import SignalUtilitiesKit
public extension ArraySection {
init(section: Model, elements: [Element] = []) {

View file

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

View file

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

View file

@ -0,0 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
enum UpdateTypes: String {
case reaction = "r"
}

View file

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

View file

@ -1250,7 +1250,7 @@ enum _003_YDBToGRDBMigration: Migration {
threadId: processedMessage.threadId,
details: MessageReceiveJob.Details(
messages: [processedMessage.messageInfo],
isBackgroundPoll: legacyJob.isBackgroundPoll
calledFromBackgroundPoller: legacyJob.isBackgroundPoll
)
)?.inserted(db)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,134 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
public struct Reaction: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "reaction" }
internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id])
internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id])
private static let profile = hasOne(Profile.self, using: profileForeignKey)
internal static let interaction = belongsTo(Interaction.self, using: interactionForeignKey)
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
case interactionId
case serverHash
case timestampMs
case authorId
case emoji
case count
case sortId
}
/// The id for the interaction this reaction belongs to
public let interactionId: Int64
/// The server hash for this reaction in the swarm
///
/// **Note:** This value will be `null` for reactions in open groups
public let serverHash: String?
/// When the reaction was created in milliseconds since epoch
public let timestampMs: Int64
/// The id for the user who made this reaction
public let authorId: String
/// The emoji for this reaction
public let emoji: String
/// The number of times this emoji was used
///
/// **Note:** This value will always be `1` for 1-1 messages and closed groups, but will be either `0` or
/// the total number of emoji's used in open groups (this allows us to `SUM` this column to get the official total
/// regardless of the type of conversation)
public let count: Int64
/// The first timestamp that an emoji is added
public let sortId: Int64
// MARK: - Relationships
public var interaction: QueryInterfaceRequest<Interaction> {
request(for: Reaction.interaction)
}
public var profile: QueryInterfaceRequest<Profile> {
request(for: Reaction.profile)
}
// MARK: - Initialization
public init(
interactionId: Int64,
serverHash: String?,
timestampMs: Int64,
authorId: String,
emoji: String,
count: Int64,
sortId: Int64
) {
self.interactionId = interactionId
self.serverHash = serverHash
self.timestampMs = timestampMs
self.authorId = authorId
self.emoji = emoji
self.count = count
self.sortId = sortId
}
}
// MARK: - Mutation
public extension Reaction {
func with(
interactionId: Int64? = nil,
serverHash: String? = nil,
authorId: String? = nil,
count: Int64? = nil,
sortId: Int64? = nil
) -> Reaction {
return Reaction(
interactionId: (interactionId ?? self.interactionId),
serverHash: (serverHash ?? self.serverHash),
timestampMs: self.timestampMs,
authorId: (authorId ?? self.authorId),
emoji: self.emoji,
count: (count ?? self.count),
sortId: (sortId ?? self.sortId)
)
}
}
// MARK: - SortId
public extension Reaction {
static func getSortId(
_ db: Database,
interactionId: Int64,
emoji: String
) -> Int64 {
if let existingSortId: Int64 = try? Reaction
.select(Columns.sortId)
.filter(Columns.interactionId == interactionId)
.filter(Columns.emoji == emoji)
.asRequest(of: Int64.self)
.fetchOne(db)
{
return existingSortId
}
if let existingLargestSortId: Int64 = try? Reaction
.select(max(Columns.sortId))
.filter(Columns.interactionId == interactionId)
.asRequest(of: Int64.self)
.fetchOne(db)
{
return existingLargestSortId + 1
}
return 0
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,47 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
extension OpenGroupAPI {
public struct PendingChange: Equatable {
public enum ChangeType {
case reaction
}
public enum ReactAction: Equatable {
case add
case remove
case removeAll
}
enum Metadata {
case reaction(messageId: Int64, emoji: String, action: ReactAction)
}
let server: String
let room: String
let changeType: ChangeType
var seqNo: Int64?
let metadata: Metadata
public static func == (lhs: OpenGroupAPI.PendingChange, rhs: OpenGroupAPI.PendingChange) -> Bool {
guard lhs.server == rhs.server &&
lhs.room == rhs.room &&
lhs.changeType == rhs.changeType &&
lhs.seqNo == rhs.seqNo
else {
return false
}
switch lhs.changeType {
case .reaction:
if case .reaction(let lhsMessageId, let lhsEmoji, let lhsAction) = lhs.metadata,
case .reaction(let rhsMessageId, let rhsEmoji, let rhsAction) = rhs.metadata {
return lhsMessageId == rhsMessageId && lhsEmoji == rhsEmoji && lhsAction == rhsAction
} else {
return false
}
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -67,12 +67,12 @@ extension OpenGroupAPI {
@discardableResult
public func poll(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) -> Promise<Void> {
return poll(isBackgroundPoll: false, isPostCapabilitiesRetry: false, using: dependencies)
return poll(calledFromBackgroundPoller: false, isPostCapabilitiesRetry: false, using: dependencies)
}
@discardableResult
public func poll(
isBackgroundPoll: Bool,
calledFromBackgroundPoller: Bool,
isBackgroundPollerValid: @escaping (() -> Bool) = { true },
isPostCapabilitiesRetry: Bool,
using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()
@ -107,7 +107,7 @@ extension OpenGroupAPI {
.map(on: OpenGroupAPI.workQueue) { (failureCount, $0) }
}
.done(on: OpenGroupAPI.workQueue) { [weak self] failureCount, response in
guard !isBackgroundPoll || isBackgroundPollerValid() else {
guard !calledFromBackgroundPoller || isBackgroundPollerValid() else {
// If this was a background poll and the background poll is no longer valid
// then just stop
self?.isPolling = false
@ -119,7 +119,6 @@ extension OpenGroupAPI {
self?.handlePollResponse(
response,
failureCount: failureCount,
isBackgroundPoll: isBackgroundPoll,
using: dependencies
)
@ -133,7 +132,7 @@ extension OpenGroupAPI {
seal.fulfill(())
}
.catch(on: OpenGroupAPI.workQueue) { [weak self] error in
guard !isBackgroundPoll || isBackgroundPollerValid() else {
guard !calledFromBackgroundPoller || isBackgroundPollerValid() else {
// If this was a background poll and the background poll is no longer valid
// then just stop
self?.isPolling = false
@ -145,7 +144,8 @@ extension OpenGroupAPI {
// method will always resolve)
self?.updateCapabilitiesAndRetryIfNeeded(
server: server,
isBackgroundPoll: isBackgroundPoll,
calledFromBackgroundPoller: calledFromBackgroundPoller,
isBackgroundPollerValid: isBackgroundPollerValid,
isPostCapabilitiesRetry: isPostCapabilitiesRetry,
error: error
)
@ -186,7 +186,8 @@ extension OpenGroupAPI {
private func updateCapabilitiesAndRetryIfNeeded(
server: String,
isBackgroundPoll: Bool,
calledFromBackgroundPoller: Bool,
isBackgroundPollerValid: @escaping (() -> Bool) = { true },
isPostCapabilitiesRetry: Bool,
error: Error,
using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()
@ -233,7 +234,8 @@ extension OpenGroupAPI {
// Regardless of the outcome we can just resolve this
// immediately as it'll handle it's own response
return strongSelf.poll(
isBackgroundPoll: isBackgroundPoll,
calledFromBackgroundPoller: calledFromBackgroundPoller,
isBackgroundPollerValid: isBackgroundPollerValid,
isPostCapabilitiesRetry: true,
using: dependencies
)
@ -251,7 +253,6 @@ extension OpenGroupAPI {
private func handlePollResponse(
_ response: PollResponse,
failureCount: Int64,
isBackgroundPoll: Bool,
using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()
) {
let server: String = self.server
@ -440,7 +441,6 @@ extension OpenGroupAPI {
messages: responseBody.compactMap { $0.value },
for: roomToken,
on: server,
isBackgroundPoll: isBackgroundPoll,
dependencies: dependencies
)
@ -464,7 +464,6 @@ extension OpenGroupAPI {
messages: messages,
fromOutbox: fromOutbox,
on: server,
isBackgroundPoll: isBackgroundPoll,
dependencies: dependencies
)

View file

@ -89,7 +89,7 @@ public final class Poller {
private func pollNextSnode(seal: Resolver<Void>) {
let userPublicKey = getUserHexEncodedPublicKey()
let swarm = SnodeAPI.swarmCache[userPublicKey] ?? []
let swarm = SnodeAPI.swarmCache.wrappedValue[userPublicKey] ?? []
let unusedSnodes = swarm.subtracting(usedSnodes)
guard !unusedSnodes.isEmpty else {
@ -173,7 +173,7 @@ public final class Poller {
threadId: threadId,
details: MessageReceiveJob.Details(
messages: threadMessages.map { $0.messageInfo },
isBackgroundPoll: false
calledFromBackgroundPoller: false
)
)
)

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