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
+16
View File
@@ -1239,6 +1239,22 @@ public enum ElementL10n {
public static var editPollTitle: String { return ElementL10n.tr("Localizable", "edit_poll_title") }
/// (edited)
public static var editedSuffix: String { return ElementL10n.tr("Localizable", "edited_suffix") }
/// Activities
public static var emojiPickerActivityCategory: String { return ElementL10n.tr("Localizable", "emoji_picker_activity_category") }
/// Flags
public static var emojiPickerFlagsCategory: String { return ElementL10n.tr("Localizable", "emoji_picker_flags_category") }
/// Food & Drink
public static var emojiPickerFoodsCategory: String { return ElementL10n.tr("Localizable", "emoji_picker_foods_category") }
/// Animals & Nature
public static var emojiPickerNatureCategory: String { return ElementL10n.tr("Localizable", "emoji_picker_nature_category") }
/// Objects
public static var emojiPickerObjectsCategory: String { return ElementL10n.tr("Localizable", "emoji_picker_objects_category") }
/// Smileys & People
public static var emojiPickerPeopleCategory: String { return ElementL10n.tr("Localizable", "emoji_picker_people_category") }
/// Travel & Places
public static var emojiPickerPlacesCategory: String { return ElementL10n.tr("Localizable", "emoji_picker_places_category") }
/// Symbols
public static var emojiPickerSymbolsCategory: String { return ElementL10n.tr("Localizable", "emoji_picker_symbols_category") }
/// Your contact book is empty
public static var emptyContactBook: String { return ElementL10n.tr("Localizable", "empty_contact_book") }
/// Encrypted message
@@ -0,0 +1,55 @@
//
// 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 SwiftUI
struct EmojiPickerScreenCoordinatorParameters {
let emojiProvider: EmojiProviderProtocol
let itemId: String
}
enum EmojiPickerScreenCoordinatorAction {
case selectEmoji(emojiId: String, itemId: String)
}
final class EmojiPickerScreenCoordinator: CoordinatorProtocol {
private let parameters: EmojiPickerScreenCoordinatorParameters
private var viewModel: EmojiPickerScreenViewModelProtocol
var callback: ((EmojiPickerScreenCoordinatorAction) -> Void)?
init(parameters: EmojiPickerScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = EmojiPickerScreenViewModel(emojiProvider: parameters.emojiProvider)
}
func start() {
viewModel.callback = { [weak self] action in
guard let self else { return }
MXLog.debug("EmojiPickerScreenViewModel did complete with result: \(action).")
switch action {
case let .selectEmoji(emojiId: emojiId):
self.callback?(.selectEmoji(emojiId: emojiId, itemId: self.parameters.itemId))
}
}
}
func toPresentable() -> AnyView {
AnyView(EmojiPickerScreen(context: viewModel.context)
.presentationDetents([.medium, .large]))
}
}
@@ -0,0 +1,64 @@
//
// 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
enum EmojiPickerScreenViewModelAction {
case selectEmoji(emojiId: String)
}
struct EmojiPickerScreenViewState: BindableState {
var categories: [EmojiPickerEmojiCategoryViewData]
}
enum EmojiPickerScreenViewAction {
case search(searchString: String)
case emojiSelected(emoji: EmojiPickerEmojiViewData)
}
struct EmojiPickerEmojiCategoryViewData: Identifiable {
let id: String
let emojis: [EmojiPickerEmojiViewData]
var name: String {
switch id {
case "people":
return ElementL10n.emojiPickerPeopleCategory
case "nature":
return ElementL10n.emojiPickerNatureCategory
case "foods":
return ElementL10n.emojiPickerFoodsCategory
case "activity":
return ElementL10n.emojiPickerActivityCategory
case "places":
return ElementL10n.emojiPickerPlacesCategory
case "objects":
return ElementL10n.emojiPickerObjectsCategory
case "symbols":
return ElementL10n.emojiPickerSymbolsCategory
case "flags":
return ElementL10n.emojiPickerFlagsCategory
default:
MXLog.failure("Missing translation for emoji category with id \(id)")
return ""
}
}
}
struct EmojiPickerEmojiViewData: Identifiable {
var id: String
let value: String
}
@@ -0,0 +1,68 @@
//
// 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 SwiftUI
typealias EmojiPickerScreenViewModelType = StateStoreViewModel<EmojiPickerScreenViewState, EmojiPickerScreenViewAction>
class EmojiPickerScreenViewModel: EmojiPickerScreenViewModelType, EmojiPickerScreenViewModelProtocol {
var callback: ((EmojiPickerScreenViewModelAction) -> Void)?
private let emojiProvider: EmojiProviderProtocol
init(emojiProvider: EmojiProviderProtocol) {
let initialViewState = EmojiPickerScreenViewState(categories: [])
self.emojiProvider = emojiProvider
super.init(initialViewState: initialViewState)
loadEmojis()
}
// MARK: - Public
override func process(viewAction: EmojiPickerScreenViewAction) async {
switch viewAction {
case let .search(searchString: searchString):
let categories = await emojiProvider.getCategories(searchString: searchString)
state.categories = convert(emojiCategories: categories)
case let .emojiSelected(emoji: emoji):
callback?(.selectEmoji(emojiId: emoji.id))
}
}
// MARK: - Private
private func loadEmojis() {
Task(priority: .userInitiated) { [weak self] in
let categories = await emojiProvider.getCategories(searchString: nil)
self?.state.categories = convert(emojiCategories: categories)
}
}
private func convert(emojiCategories: [EmojiCategory]) -> [EmojiPickerEmojiCategoryViewData] {
emojiCategories.compactMap { emojiCategory in
let emojisViewData: [EmojiPickerEmojiViewData] = emojiCategory.emojis.compactMap { emojiItem in
guard let firstSkin = emojiItem.skins.first else {
return nil
}
return EmojiPickerEmojiViewData(id: emojiItem.id, value: firstSkin.value)
}
return EmojiPickerEmojiCategoryViewData(id: emojiCategory.id, emojis: emojisViewData)
}
}
}
@@ -0,0 +1,23 @@
//
// 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 EmojiPickerScreenViewModelProtocol {
var callback: ((EmojiPickerScreenViewModelAction) -> Void)? { get set }
var context: EmojiPickerScreenViewModelType.Context { get }
}
@@ -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 SwiftUI
struct EmojiPickerHeaderView: View {
let title: String
var body: some View {
HStack {
Text(title)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
struct EmojiPickerHeaderView_Previews: PreviewProvider {
static var previews: some View {
Group {
EmojiPickerHeaderView(title: "")
}
}
}
@@ -0,0 +1,59 @@
//
// 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 SwiftUI
struct EmojiPickerScreen: View {
@ObservedObject var context: EmojiPickerScreenViewModel.Context
@State var searchString = ""
var body: some View {
VStack {
Text(ElementL10n.reactions)
.padding(.top, 20)
EmojiPickerSearchFieldView(searchString: $searchString)
.padding(.horizontal, 10)
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 45))], spacing: 3) {
ForEach(context.viewState.categories) { category in
Section(header: EmojiPickerHeaderView(title: category.name)
.padding(.horizontal, 13)
.padding(.top, 10)) {
ForEach(category.emojis) { emoji in
Text(emoji.value)
.frame(width: 45, height: 45)
.onTapGesture {
context.send(viewAction: .emojiSelected(emoji: emoji))
}
}
}
}
}
}
}
.onChange(of: searchString) { _ in
context.send(viewAction: .search(searchString: searchString))
}
}
}
// MARK: - Previews
struct EmojiPickerScreen_Previews: PreviewProvider {
static var previews: some View {
EmojiPickerScreen(context: EmojiPickerScreenViewModel(emojiProvider: EmojiProvider()).context)
}
}
@@ -0,0 +1,45 @@
//
// 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 SwiftUI
struct EmojiPickerSearchFieldView: View {
@Binding var searchString: String
@FocusState private var isSearchFocused: Bool
var body: some View {
HStack {
Image(systemName: "magnifyingglass")
TextField(ElementL10n.search, text: $searchString)
.focused($isSearchFocused)
if isSearchFocused {
Spacer()
Button {
searchString = ""
isSearchFocused = false
} label: {
Text(ElementL10n.actionCancel)
}
}
}
}
}
struct EmojiPickerSearchFieldView_Previews: PreviewProvider {
static var previews: some View {
EmojiPickerSearchFieldView(searchString: .constant(""))
}
}
@@ -22,6 +22,7 @@ struct RoomScreenCoordinatorParameters {
let mediaProvider: MediaProviderProtocol
let roomName: String?
let roomAvatarUrl: String?
let emojiProvide: EmojiProviderProtocol
}
final class RoomScreenCoordinator: CoordinatorProtocol {
@@ -57,6 +58,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
self.displayVideo(for: videoURL)
case .displayFile(let fileURL, let title):
self.displayFile(for: fileURL, with: title)
case .displayEmojiPicker(let itemId):
self.displayEmojiPickerScreen(for: itemId)
}
}
}
@@ -108,4 +111,22 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
navigationController.push(coordinator)
}
private func displayEmojiPickerScreen(for itemId: String) {
guard let emojiProvider = parameters?.emojiProvide else {
fatalError()
}
let params = EmojiPickerScreenCoordinatorParameters(emojiProvider: emojiProvider,
itemId: itemId)
let coordinator = EmojiPickerScreenCoordinator(parameters: params)
coordinator.callback = { [weak self] action in
switch action {
case let .selectEmoji(emojiId: emojiId, itemId: itemId):
self?.navigationController.dismissSheet()
MXLog.debug("Save \(emojiId) for \(itemId)")
}
}
navigationController.presentSheet(coordinator)
}
}
@@ -20,6 +20,7 @@ import UIKit
enum RoomScreenViewModelAction {
case displayVideo(videoURL: URL)
case displayFile(fileURL: URL, title: String?)
case displayEmojiPicker(itemId: String)
}
enum RoomScreenComposerMode: Equatable {
@@ -29,6 +30,7 @@ enum RoomScreenComposerMode: Equatable {
}
enum RoomScreenViewAction {
case displayEmojiPicker(itemId: String)
case paginateBackwards
case itemAppeared(id: String)
case itemDisappeared(id: String)
@@ -38,6 +40,7 @@ enum RoomScreenViewAction {
case sendReaction(key: String, eventID: String)
case cancelReply
case cancelEdit
case displayReactionsMenuForItemId(itemId: String)
}
struct RoomScreenViewState: BindableState {
@@ -48,6 +51,7 @@ struct RoomScreenViewState: BindableState {
var isBackPaginating = false
var showLoading = false
var bindings: RoomScreenViewStateBindings
var displayReactionsMenuForItemId = ""
var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)?
@@ -91,6 +91,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
var callback: ((RoomScreenViewModelAction) -> Void)?
// swiftlint:disable:next cyclomatic_complexity
override func process(viewAction: RoomScreenViewAction) async {
switch viewAction {
case .paginateBackwards:
@@ -108,6 +109,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
case .sendReaction(let key, _):
#warning("Reaction implementation awaiting SDK support.")
MXLog.warning("React with \(key) failed. Not implemented.")
case .displayEmojiPicker(let itemId):
callback?(.displayEmojiPicker(itemId: itemId))
case .displayReactionsMenuForItemId(let itemId):
state.displayReactionsMenuForItemId = itemId
case .cancelReply:
state.composerMode = .default
case .cancelEdit:
@@ -19,6 +19,7 @@ import SwiftUI
struct RoomScreen: View {
@ObservedObject private var settings = ElementSettings.shared
@ObservedObject var context: RoomScreenViewModel.Context
@State private var showReactionsMenuForItemId = ""
var body: some View {
timeline
@@ -0,0 +1,51 @@
//
// 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 SwiftUI
struct TimelineItemReactionsMenuView: View {
private let emojis = ["👍🏼", "👎🏼", "😄", "🙏🏼", "😇"]
var onDisplayEmojiPicker: (() -> Void)?
var body: some View {
HStack {
HStack(spacing: 10) {
ForEach(emojis, id: \.self) { emoji in
Text(emoji)
}
}
.padding(10)
.background(.gray)
.cornerRadius(15)
HStack(spacing: 10) {
Text("➕")
}
.padding(10)
.background(.gray)
.cornerRadius(15)
.onTapGesture {
onDisplayEmojiPicker?()
}
}
}
}
struct TimelineItemReactionsMenuView_Previews: PreviewProvider {
static var previews: some View {
TimelineItemReactionsMenuView(onDisplayEmojiPicker: nil)
}
}
@@ -102,6 +102,12 @@ struct TimelineTableView: UIViewRepresentable {
}
}
var displayReactionsMenuForItemId = "" {
didSet {
tableView?.reloadData()
}
}
/// The table's diffable data source.
private var dataSource: UITableViewDiffableDataSource<TimelineSection, RoomTimelineViewProvider>?
private var cancellables: Set<AnyCancellable> = []
@@ -175,25 +181,35 @@ struct TimelineTableView: UIViewRepresentable {
cell.item = timelineItem
cell.contentConfiguration = UIHostingConfiguration {
timelineItem
.frame(maxWidth: .infinity, alignment: .leading)
.opacity(viewModelContext.viewState.opacity(for: timelineItem))
.contextMenu {
viewModelContext.viewState.contextMenuBuilder?(timelineItem.id)
}
.onAppear {
viewModelContext.send(viewAction: .itemAppeared(id: timelineItem.id))
}
.onDisappear {
viewModelContext.send(viewAction: .itemDisappeared(id: timelineItem.id))
}
.environment(\.openURL, OpenURLAction { url in
viewModelContext.send(viewAction: .linkClicked(url: url))
return .systemAction
})
.onTapGesture {
viewModelContext.send(viewAction: .itemTapped(id: timelineItem.id))
VStack {
if viewModelContext.viewState.displayReactionsMenuForItemId == timelineItem.id {
TimelineItemReactionsMenuView {
viewModelContext.send(viewAction: .displayEmojiPicker(itemId: timelineItem.id))
}
}
timelineItem
.frame(maxWidth: .infinity, alignment: .leading)
.opacity(viewModelContext.viewState.opacity(for: timelineItem))
.contextMenu {
viewModelContext.viewState.contextMenuBuilder?(timelineItem.id)
}
.onAppear {
viewModelContext.send(viewAction: .itemAppeared(id: timelineItem.id))
}
.onDisappear {
viewModelContext.send(viewAction: .itemDisappeared(id: timelineItem.id))
}
.environment(\.openURL, OpenURLAction { url in
viewModelContext.send(viewAction: .linkClicked(url: url))
return .systemAction
})
.onTapGesture(count: 2) {
viewModelContext.send(viewAction: .displayReactionsMenuForItemId(itemId: timelineItem.id))
}
.onTapGesture {
viewModelContext.send(viewAction: .itemTapped(id: timelineItem.id))
}
}
}
.margins(.all, self.timelineStyle.rowInsets)
.minSize(height: 1)
@@ -240,6 +256,9 @@ struct TimelineTableView: UIViewRepresentable {
if composerMode != viewModelContext.viewState.composerMode {
composerMode = viewModelContext.viewState.composerMode
}
if displayReactionsMenuForItemId != viewModelContext.viewState.displayReactionsMenuForItemId {
displayReactionsMenuForItemId = viewModelContext.viewState.displayReactionsMenuForItemId
}
}
/// Updates the table view with the latest items from the ``timelineItems`` array. After
@@ -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
@@ -121,14 +121,16 @@ class MockScreen: Identifiable {
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
roomName: "Some room name",
roomAvatarUrl: nil)
roomAvatarUrl: nil,
emojiProvide: EmojiProvider())
return RoomScreenCoordinator(parameters: parameters)
case .roomEncryptedWithAvatar:
let parameters = RoomScreenCoordinatorParameters(navigationController: navigationController,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
roomName: "Some room name",
roomAvatarUrl: "mock_url")
roomAvatarUrl: "mock_url",
emojiProvide: EmojiProvider())
return RoomScreenCoordinator(parameters: parameters)
case .sessionVerification:
let parameters = SessionVerificationCoordinatorParameters(sessionVerificationControllerProxy: MockSessionVerificationControllerProxy())