Fix #1934 - Hook reaction pickers into the system's recently used keyboard emojis (#3453)

This commit is contained in:
Stefan Ceriu
2024-10-25 19:58:56 +03:00
committed by GitHub
parent 35cbc84a99
commit 7a47e37d38
35 changed files with 274 additions and 133 deletions
@@ -44,6 +44,8 @@ struct EmojiPickerEmojiCategoryViewData: Identifiable {
return L10n.emojiPickerCategorySymbols
case "flags":
return L10n.emojiPickerCategoryFlags
case EmojiCategory.frequentlyUsedCategoryIdentifier:
return L10n.commonFrequentlyUsed
default:
MXLog.failure("Missing translation for emoji category with id \(id)")
return ""
@@ -36,6 +36,7 @@ class EmojiPickerScreenViewModel: EmojiPickerScreenViewModelType, EmojiPickerScr
state.categories = convert(emojiCategories: categories)
}
case let .emojiTapped(emoji: emoji):
emojiProvider.markEmojiAsFrequentlyUsed(emoji.value)
actionsSubject.send(.emojiSelected(emoji: emoji.value))
case .dismiss:
actionsSubject.send(.dismiss)
@@ -81,7 +81,7 @@ struct EmojiPickerScreen: View {
// MARK: - Previews
struct EmojiPickerScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = EmojiPickerScreenViewModel(emojiProvider: EmojiProvider())
static let viewModel = EmojiPickerScreenViewModel(emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
static var previews: some View {
EmojiPickerScreen(context: viewModel.context, selectedEmojis: ["😀", "😄"])
@@ -91,7 +91,7 @@ struct EmojiPickerScreen_Previews: PreviewProvider, TestablePreview {
}
struct EmojiPickerScreenSheet_Previews: PreviewProvider {
static let viewModel = EmojiPickerScreenViewModel(emojiProvider: EmojiProvider())
static let viewModel = EmojiPickerScreenViewModel(emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
static var previews: some View {
Text("Timeline view")
@@ -15,6 +15,7 @@ struct PinnedEventsTimelineScreenCoordinatorParameters {
let mediaPlayerProvider: MediaPlayerProviderProtocol
let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
let appMediator: AppMediatorProtocol
let emojiProvider: EmojiProviderProtocol
}
enum PinnedEventsTimelineScreenCoordinatorAction {
@@ -49,7 +50,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: parameters.appMediator,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: parameters.emojiProvider)
}
func start() {
@@ -37,7 +37,8 @@ struct PinnedEventsTimelineScreen: View {
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
isDM: timelineContext.viewState.isEncryptedOneToOneRoom,
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
isPinnedEventsTimeline: timelineContext.viewState.isPinnedEventsTimeline)
isPinnedEventsTimeline: timelineContext.viewState.isPinnedEventsTimeline,
emojiProvider: timelineContext.viewState.emojiProvider)
.makeActions()
if let actions {
TimelineItemMenu(item: info.item, actions: actions)
@@ -96,7 +97,8 @@ struct PinnedEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
userIndicatorController: UserIndicatorControllerMock(),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
}()
static var previews: some View {
@@ -81,7 +81,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: parameters.appMediator,
appSettings: parameters.appSettings,
analyticsService: ServiceLocator.shared.analytics)
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: parameters.emojiProvider)
wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
maxCompressedHeight: ComposerConstant.maxHeight,
@@ -76,7 +76,8 @@ struct RoomScreen: View {
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
isDM: timelineContext.viewState.isEncryptedOneToOneRoom,
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
isPinnedEventsTimeline: timelineContext.viewState.isPinnedEventsTimeline)
isPinnedEventsTimeline: timelineContext.viewState.isPinnedEventsTimeline,
emojiProvider: timelineContext.viewState.emojiProvider)
.makeActions()
if let actions {
TimelineItemMenu(item: info.item, actions: actions)
@@ -229,7 +230,8 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview {
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
static var previews: some View {
NavigationStack {
@@ -51,6 +51,7 @@ protocol DeveloperOptionsProtocol: AnyObject {
var elementCallBaseURLOverride: URL? { get set }
var identityPinningViolationNotificationsEnabled: Bool { get set }
var knockingEnabled: Bool { get set }
var frequentEmojisEnabled: Bool { get set }
}
extension AppSettings: DeveloperOptionsProtocol { }
@@ -53,6 +53,10 @@ struct DeveloperOptionsScreen: View {
Toggle(isOn: $context.identityPinningViolationNotificationsEnabled) {
Text("Identity pinning violation notifications")
}
Toggle(isOn: $context.frequentEmojisEnabled) {
Text("Show frequently used emojis")
}
}
Section("Join rules") {
@@ -111,6 +111,8 @@ struct TimelineViewState: BindableState {
/// A closure providing the associated audio player state for an item in the timeline.
var audioPlayerStateProvider: (@MainActor (_ itemId: TimelineItemIdentifier) -> AudioPlayerState?)?
var emojiProvider: EmojiProviderProtocol
}
struct TimelineViewStateBindings {
@@ -28,6 +28,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
private let appMediator: AppMediatorProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsService
private let emojiProvider: EmojiProviderProtocol
private let timelineInteractionHandler: TimelineInteractionHandler
@@ -50,7 +51,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
userIndicatorController: UserIndicatorControllerProtocol,
appMediator: AppMediatorProtocol,
appSettings: AppSettings,
analyticsService: AnalyticsService) {
analyticsService: AnalyticsService,
emojiProvider: EmojiProviderProtocol) {
self.timelineController = timelineController
self.mediaPlayerProvider = mediaPlayerProvider
self.roomProxy = roomProxy
@@ -58,6 +60,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
self.analyticsService = analyticsService
self.userIndicatorController = userIndicatorController
self.appMediator = appMediator
self.emojiProvider = emojiProvider
let voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: AudioRecorder(), mediaPlayerProvider: mediaPlayerProvider)
@@ -79,7 +82,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
ownUserID: roomProxy.ownUserID,
isViewSourceEnabled: appSettings.viewSourceEnabled,
hideTimelineMedia: appSettings.hideTimelineMedia,
bindings: .init(reactionsCollapsed: [:])),
bindings: .init(reactionsCollapsed: [:]),
emojiProvider: emojiProvider),
mediaProvider: mediaProvider)
if focussedEventID != nil {
@@ -132,6 +136,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
case .itemSendInfoTapped(let itemID):
handleItemSendInfoTapped(itemID: itemID)
case .toggleReaction(let emoji, let itemID):
emojiProvider.markEmojiAsFrequentlyUsed(emoji)
guard case let .event(_, eventOrTransactionID) = itemID else {
fatalError()
}
@@ -861,7 +867,8 @@ extension TimelineViewModel {
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
static let pinnedEventsTimelineMock = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")),
focussedEventID: nil,
@@ -872,7 +879,8 @@ extension TimelineViewModel {
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
}
extension EnvironmentValues {
@@ -308,7 +308,8 @@ struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview {
guard var item = RoomTimelineItemFixtures.singleMessageChunk.first as? TextRoomTimelineItem,
let actions = TimelineItemMenuActions(isReactable: true,
actions: [.copy, .edit, .reply(isThread: false), .pin, .redact],
debugActions: [.viewSource]) else {
debugActions: [.viewSource],
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings)) else {
return nil
}
@@ -8,26 +8,38 @@
import SFSafeSymbols
import SwiftUI
@MainActor
struct TimelineItemMenuActions {
let reactions: [TimelineItemMenuReaction]
let actions: [TimelineItemMenuAction]
let debugActions: [TimelineItemMenuAction]
init?(isReactable: Bool, actions: [TimelineItemMenuAction], debugActions: [TimelineItemMenuAction]) {
init?(isReactable: Bool,
actions: [TimelineItemMenuAction],
debugActions: [TimelineItemMenuAction],
emojiProvider: EmojiProviderProtocol) {
if !isReactable, actions.isEmpty, debugActions.isEmpty {
return nil
}
self.actions = actions
self.debugActions = debugActions
// Only process 5 of the most frequently used emojis instead of all of them
var frequentlyUsed = emojiProvider.frequentlyUsedSystemEmojis().prefix(5).map { TimelineItemMenuReaction(key: $0, symbol: .heart) }
frequentlyUsed += [
.init(key: "👍️", symbol: .handThumbsup),
.init(key: "👎️", symbol: .handThumbsdown),
.init(key: "🔥", symbol: .flame),
.init(key: "❤️", symbol: .heart),
.init(key: "👏", symbol: .handsClap)
]
frequentlyUsed = Array(frequentlyUsed.prefix(5))
reactions = if isReactable {
[
.init(key: "👍️", symbol: .handThumbsup),
.init(key: "👎️", symbol: .handThumbsdown),
.init(key: "🔥", symbol: .flame),
.init(key: "❤️", symbol: .heart),
.init(key: "👏", symbol: .handsClap)
]
frequentlyUsed
} else {
[]
}
@@ -7,6 +7,7 @@
import Foundation
@MainActor
struct TimelineItemMenuActionProvider {
let timelineItem: RoomTimelineItemProtocol
let canCurrentUserRedactSelf: Bool
@@ -16,6 +17,7 @@ struct TimelineItemMenuActionProvider {
let isDM: Bool
let isViewSourceEnabled: Bool
let isPinnedEventsTimeline: Bool
let emojiProvider: EmojiProviderProtocol
// swiftlint:disable:next cyclomatic_complexity
func makeActions() -> TimelineItemMenuActions? {
@@ -42,7 +44,10 @@ struct TimelineItemMenuActionProvider {
break
}
return .init(isReactable: false, actions: [.copyPermalink], debugActions: debugActions)
return .init(isReactable: false,
actions: [.copyPermalink],
debugActions: debugActions,
emojiProvider: emojiProvider)
}
var actions: [TimelineItemMenuAction] = []
@@ -100,7 +105,10 @@ struct TimelineItemMenuActionProvider {
actions = actions.filter(\.canAppearInPinnedEventsTimeline)
}
return .init(isReactable: isPinnedEventsTimeline ? false : item.isReactable, actions: actions, debugActions: debugActions)
return .init(isReactable: isPinnedEventsTimeline ? false : item.isReactable,
actions: actions,
debugActions: debugActions,
emojiProvider: emojiProvider)
}
private func canRedactItem(_ item: EventBasedTimelineItemProtocol) -> Bool {
@@ -51,7 +51,8 @@ struct ReadReceiptsSummaryView_Previews: PreviewProvider, TestablePreview {
userIndicatorController: UserIndicatorControllerMock(),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
return mock
}()
@@ -148,7 +148,8 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
pinnedEventIDs: context.viewState.pinnedEventIDs,
isDM: context.viewState.isEncryptedOneToOneRoom,
isViewSourceEnabled: context.viewState.isViewSourceEnabled,
isPinnedEventsTimeline: context.viewState.isPinnedEventsTimeline)
isPinnedEventsTimeline: context.viewState.isPinnedEventsTimeline,
emojiProvider: context.viewState.emojiProvider)
TimelineItemMacContextMenu(item: timelineItem, actionProvider: provider) { action in
context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: action))
}
@@ -364,7 +365,8 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
}()
static var previews: some View {
@@ -89,7 +89,8 @@ struct TimelineReadReceiptsView_Previews: PreviewProvider, TestablePreview {
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
static let singleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now")]
static let doubleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now"),
@@ -96,7 +96,8 @@ struct HighlightedTimelineItemTimeline_Previews: PreviewProvider {
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
static var previews: some View {
NavigationStack {
@@ -89,7 +89,8 @@ struct TimelineView_Previews: PreviewProvider, TestablePreview {
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
static var previews: some View {
NavigationStack {