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
@@ -139,6 +139,7 @@
"common_edited_suffix" = "(edited)";
"common_editing" = "Editing";
"common_emote" = "* %1$@ %2$@";
"common_encryption" = "Encryption";
"common_encryption_enabled" = "Encryption enabled";
"common_enter_your_pin" = "Enter your PIN";
"common_error" = "Error";
@@ -149,6 +150,7 @@
"common_favourited" = "Favourited";
"common_file" = "File";
"common_forward_message" = "Forward message";
"common_frequently_used" = "Frequently used";
"common_gif" = "GIF";
"common_image" = "Image";
"common_in_reply_to" = "In reply to %1$@";
@@ -342,6 +344,9 @@
"screen_advanced_settings_element_call_base_url" = "Custom Element Call base URL";
"screen_advanced_settings_element_call_base_url_description" = "Set a custom base URL for Element Call.";
"screen_advanced_settings_element_call_base_url_validation_error" = "Invalid URL, please make sure you include the protocol (http/https) and the correct address.";
"screen_create_room_room_address_section_footer" = "In order for this room to be visible in the public room directory, you will need a room address.";
"screen_create_room_room_address_section_title" = "Room address";
"screen_create_room_room_visibility_section_title" = "Room visibility";
"screen_create_room_access_section_anyone_option_description" = "Anyone can join this room";
"screen_create_room_access_section_anyone_option_title" = "Anyone";
"screen_create_room_access_section_header" = "Room Access";
@@ -449,9 +454,12 @@
"screen_change_server_title" = "Select your server";
"screen_chat_backup_key_backup_action_disable" = "Turn off backup";
"screen_chat_backup_key_backup_action_enable" = "Turn on backup";
"screen_chat_backup_key_backup_description" = "Backup ensures that you don't lose your message history. %1$@.";
"screen_chat_backup_key_backup_title" = "Backup";
"screen_chat_backup_key_backup_description" = "Store your cryptographic identity and message keys securely on the server. This will allow you to view your message history on any new devices. %1$@.";
"screen_chat_backup_key_backup_title" = "Key storage";
"screen_chat_backup_key_storage_toggle_description" = "Upload keys from this device";
"screen_chat_backup_key_storage_toggle_title" = "Allow key storage";
"screen_chat_backup_recovery_action_change" = "Change recovery key";
"screen_chat_backup_recovery_action_change_description" = "Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices.";
"screen_chat_backup_recovery_action_confirm_description" = "Your chat backup is currently out of sync.";
"screen_chat_backup_recovery_action_setup" = "Set up recovery";
"screen_chat_backup_recovery_action_setup_description" = "Get access to your encrypted messages if you lose all your devices or are signed out of %1$@ everywhere.";
@@ -473,10 +481,10 @@
"screen_create_poll_title" = "Create Poll";
"screen_create_room_action_create_room" = "New room";
"screen_create_room_error_creating_room" = "An error occurred when creating the room";
"screen_create_room_private_option_description" = "Messages in this room are encrypted. Encryption can’t be disabled afterwards.";
"screen_create_room_private_option_title" = "Private room (invite only)";
"screen_create_room_public_option_description" = "Messages are not encrypted and anyone can read them. You can enable encryption at a later date.";
"screen_create_room_public_option_title" = "Public room (anyone)";
"screen_create_room_private_option_description" = "Only people invited can access this room. All messages are end-to-end encrypted.";
"screen_create_room_private_option_title" = "Private room";
"screen_create_room_public_option_description" = "Anyone can find this room.\nYou can change this anytime in room settings.";
"screen_create_room_public_option_title" = "Public room";
"screen_create_room_topic_label" = "Topic (optional)";
"screen_deactivate_account_confirmation_dialog_content" = "Please confirm that you want to deactivate your account. This action cannot be undone.";
"screen_deactivate_account_delete_all_messages" = "Delete all my messages";
@@ -624,7 +632,6 @@
"screen_qr_code_login_verify_code_title" = "Your verification code";
"screen_recovery_key_change_description" = "Get a new recovery key if you've lost your existing one. After changing your recovery key, your old one will no longer work.";
"screen_recovery_key_change_generate_key" = "Generate a new recovery key";
"screen_recovery_key_change_generate_key_description" = "Make sure you can store your recovery key somewhere safe";
"screen_recovery_key_change_success" = "Recovery key changed";
"screen_recovery_key_change_title" = "Change recovery key?";
"screen_recovery_key_confirm_create_new_recovery_key" = "Create new recovery key";
@@ -638,14 +645,14 @@
"screen_recovery_key_copied_to_clipboard" = "Copied recovery key";
"screen_recovery_key_generating_key" = "Generating…";
"screen_recovery_key_save_action" = "Save recovery key";
"screen_recovery_key_save_description" = "Write down your recovery key somewhere safe or save it in a password manager.";
"screen_recovery_key_save_description" = "Write down this recovery key somewhere safe, like a password manager, encrypted note, or a physical safe.";
"screen_recovery_key_save_key_description" = "Tap to copy recovery key";
"screen_recovery_key_save_title" = "Save your recovery key";
"screen_recovery_key_save_title" = "Save your recovery key somewhere safe";
"screen_recovery_key_setup_confirmation_description" = "You will not be able to access your new recovery key after this step.";
"screen_recovery_key_setup_confirmation_title" = "Have you saved your recovery key?";
"screen_recovery_key_setup_description" = "Your chat backup is protected by a recovery key. If you need a new recovery key after setup you can recreate by selecting ‘Change recovery key’.";
"screen_recovery_key_setup_generate_key" = "Generate your recovery key";
"screen_recovery_key_setup_generate_key_description" = "Make sure you can store your recovery key somewhere safe";
"screen_recovery_key_setup_generate_key_description" = "Do not share this with anyone!";
"screen_recovery_key_setup_success" = "Recovery setup successful";
"screen_recovery_key_setup_title" = "Set up recovery";
"screen_report_content_block_user_hint" = "Check if you want to hide all current and future messages from this user";
@@ -1019,6 +1026,7 @@
"screen_login_subtitle" = "Matrix is an open network for secure, decentralised communication.";
"screen_notification_settings_mentions_section_title" = "Mentions";
"screen_qr_code_login_invalid_scan_state_retry_button" = "Try again";
"screen_recovery_key_change_generate_key_description" = "Do not share this with anyone!";
"screen_recovery_key_confirm_title" = "Enter your recovery key";
"screen_report_content_block_user" = "Block user";
"screen_reset_encryption_password_placeholder" = "Enter…";
@@ -48,6 +48,7 @@ final class AppSettings {
case enableOnlySignedDeviceIsolationMode
case identityPinningViolationNotificationsEnabled
case knockingEnabled
case frequentEmojisEnabled
}
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
@@ -289,6 +290,9 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.knockingEnabled, defaultValue: false, storageType: .userDefaults(store))
var knockingEnabled
@UserPreference(key: UserDefaultsKeys.frequentEmojisEnabled, defaultValue: isDevelopmentBuild, storageType: .userDefaults(store))
var frequentEmojisEnabled
#endif
@@ -22,6 +22,7 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
private let roomProxy: JoinedRoomProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let appMediator: AppMediatorProtocol
private let emojiProvider: EmojiProviderProtocol
private let actionsSubject: PassthroughSubject<PinnedEventsTimelineFlowCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<PinnedEventsTimelineFlowCoordinatorAction, Never> {
@@ -35,13 +36,15 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol,
roomProxy: JoinedRoomProxyProtocol,
userIndicatorController: UserIndicatorControllerProtocol,
appMediator: AppMediatorProtocol) {
appMediator: AppMediatorProtocol,
emojiProvider: EmojiProviderProtocol) {
self.navigationStackCoordinator = navigationStackCoordinator
self.userSession = userSession
self.roomTimelineControllerFactory = roomTimelineControllerFactory
self.roomProxy = roomProxy
self.userIndicatorController = userIndicatorController
self.appMediator = appMediator
self.emojiProvider = emojiProvider
}
func start() {
@@ -71,7 +74,8 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
mediaProvider: userSession.mediaProvider,
mediaPlayerProvider: MediaPlayerProvider(),
voiceMessageMediaManager: userSession.voiceMessageMediaManager,
appMediator: appMediator))
appMediator: appMediator,
emojiProvider: emojiProvider))
coordinator.actions
.sink { [weak self] action in
@@ -1340,7 +1340,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
roomTimelineControllerFactory: roomTimelineControllerFactory,
roomProxy: roomProxy,
userIndicatorController: userIndicatorController,
appMediator: appMediator)
appMediator: appMediator,
emojiProvider: emojiProvider)
coordinator.actionsPublisher.sink { [weak self] action in
guard let self else {
@@ -492,7 +492,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
isChildFlow: false,
roomTimelineControllerFactory: roomTimelineControllerFactory,
navigationStackCoordinator: detailNavigationStackCoordinator,
emojiProvider: EmojiProvider(),
emojiProvider: EmojiProvider(appSettings: appSettings),
ongoingCallRoomIDPublisher: elementCallService.ongoingCallRoomIDPublisher,
appMediator: appMediator,
appSettings: appSettings,
+27 -10
View File
@@ -312,6 +312,8 @@ internal enum L10n {
internal static func commonEmote(_ p1: Any, _ p2: Any) -> String {
return L10n.tr("Localizable", "common_emote", String(describing: p1), String(describing: p2))
}
/// Encryption
internal static var commonEncryption: String { return L10n.tr("Localizable", "common_encryption") }
/// Encryption enabled
internal static var commonEncryptionEnabled: String { return L10n.tr("Localizable", "common_encryption_enabled") }
/// Enter your PIN
@@ -332,6 +334,8 @@ internal enum L10n {
internal static var commonFile: String { return L10n.tr("Localizable", "common_file") }
/// Forward message
internal static var commonForwardMessage: String { return L10n.tr("Localizable", "common_forward_message") }
/// Frequently used
internal static var commonFrequentlyUsed: String { return L10n.tr("Localizable", "common_frequently_used") }
/// GIF
internal static var commonGif: String { return L10n.tr("Localizable", "common_gif") }
/// Image
@@ -1005,14 +1009,20 @@ internal enum L10n {
internal static var screenChatBackupKeyBackupActionDisable: String { return L10n.tr("Localizable", "screen_chat_backup_key_backup_action_disable") }
/// Turn on backup
internal static var screenChatBackupKeyBackupActionEnable: String { return L10n.tr("Localizable", "screen_chat_backup_key_backup_action_enable") }
/// Backup ensures that you don't lose your message history. %1$@.
/// Store your cryptographic identity and message keys securely on the server. This will allow you to view your message history on any new devices. %1$@.
internal static func screenChatBackupKeyBackupDescription(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_chat_backup_key_backup_description", String(describing: p1))
}
/// Backup
/// Key storage
internal static var screenChatBackupKeyBackupTitle: String { return L10n.tr("Localizable", "screen_chat_backup_key_backup_title") }
/// Upload keys from this device
internal static var screenChatBackupKeyStorageToggleDescription: String { return L10n.tr("Localizable", "screen_chat_backup_key_storage_toggle_description") }
/// Allow key storage
internal static var screenChatBackupKeyStorageToggleTitle: String { return L10n.tr("Localizable", "screen_chat_backup_key_storage_toggle_title") }
/// Change recovery key
internal static var screenChatBackupRecoveryActionChange: String { return L10n.tr("Localizable", "screen_chat_backup_recovery_action_change") }
/// Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices.
internal static var screenChatBackupRecoveryActionChangeDescription: String { return L10n.tr("Localizable", "screen_chat_backup_recovery_action_change_description") }
/// Enter recovery key
internal static var screenChatBackupRecoveryActionConfirm: String { return L10n.tr("Localizable", "screen_chat_backup_recovery_action_confirm") }
/// Your chat backup is currently out of sync.
@@ -1079,16 +1089,23 @@ internal enum L10n {
internal static var screenCreateRoomAddPeopleTitle: String { return L10n.tr("Localizable", "screen_create_room_add_people_title") }
/// An error occurred when creating the room
internal static var screenCreateRoomErrorCreatingRoom: String { return L10n.tr("Localizable", "screen_create_room_error_creating_room") }
/// Messages in this room are encrypted. Encryption can’t be disabled afterwards.
/// Only people invited can access this room. All messages are end-to-end encrypted.
internal static var screenCreateRoomPrivateOptionDescription: String { return L10n.tr("Localizable", "screen_create_room_private_option_description") }
/// Private room (invite only)
/// Private room
internal static var screenCreateRoomPrivateOptionTitle: String { return L10n.tr("Localizable", "screen_create_room_private_option_title") }
/// Messages are not encrypted and anyone can read them. You can enable encryption at a later date.
/// Anyone can find this room.
/// You can change this anytime in room settings.
internal static var screenCreateRoomPublicOptionDescription: String { return L10n.tr("Localizable", "screen_create_room_public_option_description") }
/// Public room (anyone)
/// Public room
internal static var screenCreateRoomPublicOptionTitle: String { return L10n.tr("Localizable", "screen_create_room_public_option_title") }
/// In order for this room to be visible in the public room directory, you will need a room address.
internal static var screenCreateRoomRoomAddressSectionFooter: String { return L10n.tr("Localizable", "screen_create_room_room_address_section_footer") }
/// Room address
internal static var screenCreateRoomRoomAddressSectionTitle: String { return L10n.tr("Localizable", "screen_create_room_room_address_section_title") }
/// Room name
internal static var screenCreateRoomRoomNameLabel: String { return L10n.tr("Localizable", "screen_create_room_room_name_label") }
/// Room visibility
internal static var screenCreateRoomRoomVisibilitySectionTitle: String { return L10n.tr("Localizable", "screen_create_room_room_visibility_section_title") }
/// Create a room
internal static var screenCreateRoomTitle: String { return L10n.tr("Localizable", "screen_create_room_title") }
/// Topic (optional)
@@ -1475,7 +1492,7 @@ internal enum L10n {
internal static var screenRecoveryKeyChangeDescription: String { return L10n.tr("Localizable", "screen_recovery_key_change_description") }
/// Generate a new recovery key
internal static var screenRecoveryKeyChangeGenerateKey: String { return L10n.tr("Localizable", "screen_recovery_key_change_generate_key") }
/// Make sure you can store your recovery key somewhere safe
/// Do not share this with anyone!
internal static var screenRecoveryKeyChangeGenerateKeyDescription: String { return L10n.tr("Localizable", "screen_recovery_key_change_generate_key_description") }
/// Recovery key changed
internal static var screenRecoveryKeyChangeSuccess: String { return L10n.tr("Localizable", "screen_recovery_key_change_success") }
@@ -1505,11 +1522,11 @@ internal enum L10n {
internal static var screenRecoveryKeyGeneratingKey: String { return L10n.tr("Localizable", "screen_recovery_key_generating_key") }
/// Save recovery key
internal static var screenRecoveryKeySaveAction: String { return L10n.tr("Localizable", "screen_recovery_key_save_action") }
/// Write down your recovery key somewhere safe or save it in a password manager.
/// Write down this recovery key somewhere safe, like a password manager, encrypted note, or a physical safe.
internal static var screenRecoveryKeySaveDescription: String { return L10n.tr("Localizable", "screen_recovery_key_save_description") }
/// Tap to copy recovery key
internal static var screenRecoveryKeySaveKeyDescription: String { return L10n.tr("Localizable", "screen_recovery_key_save_key_description") }
/// Save your recovery key
/// Save your recovery key somewhere safe
internal static var screenRecoveryKeySaveTitle: String { return L10n.tr("Localizable", "screen_recovery_key_save_title") }
/// You will not be able to access your new recovery key after this step.
internal static var screenRecoveryKeySetupConfirmationDescription: String { return L10n.tr("Localizable", "screen_recovery_key_setup_confirmation_description") }
@@ -1519,7 +1536,7 @@ internal enum L10n {
internal static var screenRecoveryKeySetupDescription: String { return L10n.tr("Localizable", "screen_recovery_key_setup_description") }
/// Generate your recovery key
internal static var screenRecoveryKeySetupGenerateKey: String { return L10n.tr("Localizable", "screen_recovery_key_setup_generate_key") }
/// Make sure you can store your recovery key somewhere safe
/// Do not share this with anyone!
internal static var screenRecoveryKeySetupGenerateKeyDescription: String { return L10n.tr("Localizable", "screen_recovery_key_setup_generate_key_description") }
/// Recovery setup successful
internal static var screenRecoveryKeySetupSuccess: String { return L10n.tr("Localizable", "screen_recovery_key_setup_success") }
@@ -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 {
@@ -1,13 +0,0 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
struct EmojiCategory: Equatable, Identifiable {
let id: String
let emojis: [EmojiItem]
}
@@ -1,19 +0,0 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
struct EmojiItem: Equatable, Identifiable {
var id: String {
label
}
let label: String
let unicode: String
let keywords: [String]
let shortcodes: [String]
}
@@ -7,31 +7,38 @@
import Emojibase
import Foundation
@MainActor
protocol EmojiProviderProtocol {
func categories(searchString: String?) async -> [EmojiCategory]
}
private enum EmojiProviderState {
case notLoaded
case inProgress(Task<[EmojiCategory], Never>)
case loaded([EmojiCategory])
}
import OrderedCollections
class EmojiProvider: EmojiProviderProtocol {
private let loader: EmojiLoaderProtocol
private var state: EmojiProviderState = .notLoaded
private let appSettings: AppSettings
init(loader: EmojiLoaderProtocol = EmojibaseDatasource()) {
private(set) var state: EmojiProviderState = .notLoaded
init(loader: EmojiLoaderProtocol = EmojibaseDatasource(), appSettings: AppSettings) {
self.loader = loader
self.appSettings = appSettings
Task {
await loadIfNeeded()
}
}
func categories(searchString: String? = nil) async -> [EmojiCategory] {
let emojiCategories = await loadIfNeeded()
var emojiCategories = await loadIfNeeded()
let allEmojis = emojiCategories.reduce([]) { partialResult, category in
partialResult + category.emojis
}
// Map frequently used system unicode emojis to our emoji provider ones
let frequentlyUsedEmojis = frequentlyUsedSystemEmojis().prefix(20)
let emojis = allEmojis.filter { frequentlyUsedEmojis.contains($0.unicode) }
if !emojis.isEmpty {
emojiCategories.insert(.init(id: EmojiCategory.frequentlyUsedCategoryIdentifier, emojis: emojis), at: 0)
}
if let searchString, searchString.isEmpty == false {
return search(searchString: searchString, emojiCategories: emojiCategories)
} else {
@@ -39,6 +46,40 @@ class EmojiProvider: EmojiProviderProtocol {
}
}
func frequentlyUsedSystemEmojis() -> [String] {
guard appSettings.frequentEmojisEnabled, !ProcessInfo.processInfo.isiOSAppOnMac else {
return []
}
guard let preferences = UserDefaults(suiteName: "com.apple.EmojiPreferences"),
let defaults = preferences.dictionary(forKey: "EMFDefaultsKey"),
let recents = defaults["EMFRecentsKey"] as? [String]
else {
return []
}
return recents
}
func markEmojiAsFrequentlyUsed(_ emoji: String) {
guard appSettings.frequentEmojisEnabled else {
return
}
guard let preferences = UserDefaults(suiteName: "com.apple.EmojiPreferences"),
let defaults = preferences.dictionary(forKey: "EMFDefaultsKey"),
let recents = defaults["EMFRecentsKey"] as? [String] else {
return
}
var uniqueOrderedRecents = OrderedSet(recents)
uniqueOrderedRecents.insert(emoji, at: 0)
preferences.setValue(["EMFRecentsKey": Array(uniqueOrderedRecents)], forKey: "EMFDefaultsKey")
}
// MARK: - Private
private func search(searchString: String, emojiCategories: [EmojiCategory]) -> [EmojiCategory] {
emojiCategories.compactMap { category in
let emojis = category.emojis.filter { emoji in
@@ -0,0 +1,42 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
struct EmojiItem: Equatable, Identifiable {
var id: String {
label
}
let label: String
let unicode: String
let keywords: [String]
let shortcodes: [String]
}
struct EmojiCategory: Equatable, Identifiable {
static let frequentlyUsedCategoryIdentifier = "io.element.elementx.frequently_used"
let id: String
let emojis: [EmojiItem]
}
enum EmojiProviderState {
case notLoaded
case inProgress(Task<[EmojiCategory], Never>)
case loaded([EmojiCategory])
}
@MainActor
protocol EmojiProviderProtocol {
var state: EmojiProviderState { get }
func categories(searchString: String?) async -> [EmojiCategory]
func frequentlyUsedSystemEmojis() -> [String]
func markEmojiAsFrequentlyUsed(_ emoji: String)
}
@@ -239,7 +239,7 @@ class MockScreen: Identifiable {
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
emojiProvider: EmojiProvider(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -258,7 +258,7 @@ class MockScreen: Identifiable {
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
emojiProvider: EmojiProvider(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -277,7 +277,7 @@ class MockScreen: Identifiable {
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
emojiProvider: EmojiProvider(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -296,7 +296,7 @@ class MockScreen: Identifiable {
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
emojiProvider: EmojiProvider(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -318,7 +318,7 @@ class MockScreen: Identifiable {
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
emojiProvider: EmojiProvider(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -340,7 +340,7 @@ class MockScreen: Identifiable {
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
emojiProvider: EmojiProvider(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -362,7 +362,7 @@ class MockScreen: Identifiable {
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
emojiProvider: EmojiProvider(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -385,7 +385,7 @@ class MockScreen: Identifiable {
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
emojiProvider: EmojiProvider(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -407,7 +407,7 @@ class MockScreen: Identifiable {
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
emojiProvider: EmojiProvider(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -428,7 +428,7 @@ class MockScreen: Identifiable {
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
emojiProvider: EmojiProvider(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -463,7 +463,7 @@ class MockScreen: Identifiable {
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
emojiProvider: EmojiProvider(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -485,7 +485,7 @@ class MockScreen: Identifiable {
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
emojiProvider: EmojiProvider(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
@@ -507,7 +507,7 @@ class MockScreen: Identifiable {
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
emojiProvider: EmojiProvider(),
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,