Timeline Reactions: Emoji picker (#350)

* Screens template

* Loading and parsing

* Gridview

* New json

* Search method

* Unit tests

* Added emoji provider state

* Removed containsIgnoringCase

* Added plus icon, added double tap gesture, dismiss sheet after selecting emoji

* Renamed EmojisProvider to EmojiProvider

* Renamed EmojiPickerScreenScreen

* Extra padding

* Renamed EmojisLoaderProtocol

* Category names

* Moved method into public section of the class

* Return all categories when search string is empty

* Added cancel button and tittle

* Removed empty lines

* Removed swiftlint warning
This commit is contained in:
Aleksandrs Proskurins
2022-12-06 16:04:48 +02:00
committed by GitHub
parent dcedb3fb98
commit 7f91852291
30 changed files with 30941 additions and 21 deletions
@@ -0,0 +1,21 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
struct EmojiCategory: Equatable, Identifiable {
let id: String
let emojis: [EmojiItem]
}
@@ -0,0 +1,35 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
struct EmojiItem: Equatable, Identifiable {
var id: String
let name: String
let keywords: [String]
let skins: [EmojiItemSkin]
}
extension EmojiItem {
init?(from emojiMart: EmojiMartEmoji) {
id = emojiMart.id
name = emojiMart.name
keywords = emojiMart.keywords
skins = emojiMart.skins.compactMap { emojiMartEmojiSkin in
EmojiItemSkin(from: emojiMartEmojiSkin)
}
}
}
@@ -0,0 +1,36 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
struct EmojiItemSkin: Equatable {
let value: String
init?(from emojiMartEmojiSkin: EmojiMartEmojiSkin) {
let unicodeStringComponents = emojiMartEmojiSkin.unified.components(separatedBy: "-")
var emoji = ""
for unicodeStringComponent in unicodeStringComponents {
guard let unicodeCodePoint = Int(unicodeStringComponent, radix: 16),
let emojiUnicodeScalar = UnicodeScalar(unicodeCodePoint) else {
return nil
}
emoji.append(String(emojiUnicodeScalar))
}
value = emoji
}
}
@@ -0,0 +1,21 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
protocol EmojiLoaderProtocol {
func load() async -> [EmojiCategory]
}
@@ -0,0 +1,22 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
struct EmojiMartCategory: Decodable {
let id: String
let emojis: [String]
}
@@ -0,0 +1,29 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
struct EmojiMartEmoji: Decodable {
let id: String
let name: String
let keywords: [String]
let skins: [EmojiMartEmojiSkin]
}
struct EmojiMartEmojiSkin: Decodable {
let unified: String
let native: String
}
@@ -0,0 +1,60 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
private enum EmojiMartJSONLoaderError: Error {
case fileNotFound
}
class EmojiMartJSONLoader: EmojiLoaderProtocol {
/// Emoji data coming from https://github.com/missive/emoji-mart/blob/main/packages/emoji-mart-data/sets/14/apple.json
private let jsonFilename = "apple_emojis_data"
func load() async -> [EmojiCategory] {
do {
let data = try await loadJSONData()
let store = try await decodeJSONData(data: data)
return emojiCategories(from: store)
} catch {
MXLog.error("Couldn't parse emoji json")
return []
}
}
private func loadJSONData() async throws -> Data {
guard let jsonDataURL = Bundle.main.url(forResource: jsonFilename, withExtension: "json") else {
throw EmojiMartJSONLoaderError.fileNotFound
}
return try Data(contentsOf: jsonDataURL)
}
private func decodeJSONData(data: Data) async throws -> EmojiMartStore {
try JSONDecoder().decode(EmojiMartStore.self, from: data)
}
private func emojiCategories(from emojiMartStore: EmojiMartStore) -> [EmojiCategory] {
emojiMartStore.categories.map { emojiMartCategory -> EmojiCategory in
let emojiItems = emojiMartCategory.emojis.compactMap { emoji -> EmojiItem? in
guard let emojiMartEmoji = emojiMartStore.emojis.first(where: { $0.id == emoji }) else {
return nil
}
return EmojiItem(from: emojiMartEmoji)
}
return EmojiCategory(id: emojiMartCategory.id, emojis: emojiItems)
}
}
}
@@ -0,0 +1,35 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
struct EmojiMartStore: Decodable {
let categories: [EmojiMartCategory]
let emojis: [EmojiMartEmoji]
enum CodingKeys: CodingKey {
case categories
case emojis
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
categories = try container.decode([EmojiMartCategory].self, forKey: .categories)
let emojisDictionary = try container.decode([String: EmojiMartEmoji].self, forKey: .emojis)
emojis = emojisDictionary.map(\.value)
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,76 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
@MainActor
protocol EmojiProviderProtocol {
func getCategories(searchString: String?) async -> [EmojiCategory]
}
private enum EmojiProviderState {
case notLoaded
case inProgress(Task<[EmojiCategory], Never>)
case loaded([EmojiCategory])
}
class EmojiProvider: EmojiProviderProtocol {
private let loader: EmojiLoaderProtocol
private var state: EmojiProviderState = .notLoaded
init(loader: EmojiLoaderProtocol = EmojiMartJSONLoader()) {
self.loader = loader
Task {
await loadIfNeeded()
}
}
func getCategories(searchString: String? = nil) async -> [EmojiCategory] {
let emojiCategories = await loadIfNeeded()
if let searchString = searchString, searchString.isEmpty == false {
return search(searchString: searchString, emojiCategories: emojiCategories)
} else {
return emojiCategories
}
}
private func search(searchString: String, emojiCategories: [EmojiCategory]) -> [EmojiCategory] {
emojiCategories.compactMap { category in
let emojis = category.emojis.filter { emoji in
let searchArray = [emoji.id, emoji.name] + emoji.keywords
return searchArray.description.range(of: searchString, options: .caseInsensitive) != nil
}
return emojis.isEmpty ? nil : EmojiCategory(id: category.id, emojis: emojis)
}
}
private func loadIfNeeded() async -> [EmojiCategory] {
switch state {
case .notLoaded:
let task = Task {
await loader.load()
}
state = .inProgress(task)
let categories = await task.value
state = .loaded(categories)
return categories
case .loaded(let categories):
return categories
case .inProgress(let task):
return await task.value
}
}
}
@@ -26,6 +26,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
private let userSession: UserSessionProtocol
private let navigationController: NavigationController
private let bugReportService: BugReportServiceProtocol
private let emojiProvider: EmojiProviderProtocol = EmojiProvider()
var callback: ((UserSessionFlowCoordinatorAction) -> Void)?
@@ -148,7 +149,8 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
timelineController: timelineController,
mediaProvider: userSession.mediaProvider,
roomName: roomProxy.displayName ?? roomProxy.name,
roomAvatarUrl: roomProxy.avatarURL)
roomAvatarUrl: roomProxy.avatarURL,
emojiProvide: emojiProvider)
let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationController.push(coordinator) { [weak self] in