From 59fe200d8bc5852fe5da19a00efbbb88e8837385 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Tue, 27 Jun 2023 12:34:04 +0300 Subject: [PATCH] Implement all emoji text message boosting --- ElementX.xcodeproj/project.pbxproj | 8 +++ ElementX/Sources/Other/EmojiDetection.swift | 66 +++++++++++++++++++ .../View/Timeline/FormattedBodyText.swift | 33 +++++++--- .../View/Timeline/TextRoomTimelineView.swift | 8 ++- UnitTests/Sources/EmojiDetectionTests.swift | 48 ++++++++++++++ 5 files changed, 151 insertions(+), 12 deletions(-) create mode 100644 ElementX/Sources/Other/EmojiDetection.swift create mode 100644 UnitTests/Sources/EmojiDetectionTests.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 654d0d029..607531c4b 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -630,6 +630,7 @@ E3291AD16D7A5CB14781819C /* UserNotificationCenterProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45D8149FDDA0315CDC553B4B /* UserNotificationCenterProtocol.swift */; }; E3CA565A4B9704F191B191F0 /* JoinedRoomSize+MemberCount.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF9AEA706926DD0DA2B954C /* JoinedRoomSize+MemberCount.swift */; }; E3E1E255DC8CB34BD8573E0D /* UserIndicatorControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12D3B1BCF920880CA8BBB6B /* UserIndicatorControllerProtocol.swift */; }; + E45C9FA22BC13B477FD3B4AC /* EmojiDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D99730313BEBF08CDE81EE3 /* EmojiDetection.swift */; }; E481C8FDCB6C089963C95344 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = BC01130651CB23340B899032 /* DeviceKit */; }; E570117376826665640F0CFD /* SessionVerificationScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B16CAF20C9AC874A210E2DCF /* SessionVerificationScreenViewModelProtocol.swift */; }; E571163060CBE87D82CE24FD /* NSESettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9DFC0FBA0FC6FC4DC0FC9FC /* NSESettings.swift */; }; @@ -681,6 +682,7 @@ F54E2D6CAD96E1AC15BC526F /* MessageForwardingScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E60332509665C00179ACF6 /* MessageForwardingScreenViewModel.swift */; }; F5D2270B5021D521C0D22E11 /* FlowCoordinatorProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9FCA1CFD07B8CF9BD21266 /* FlowCoordinatorProtocol.swift */; }; F656F92A63D3DC1978D79427 /* Compound in Frameworks */ = {isa = PBXBuildFile; productRef = 07FEEEDB11543A7DED420F04 /* Compound */; }; + F697284B9B5F2C00CFEA3B12 /* EmojiDetectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58E93D91DE3288010390DEE /* EmojiDetectionTests.swift */; }; F6F49E37272AD7397CD29A01 /* HomeScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */; }; F7567DD6635434E8C563BF85 /* AnalyticsClientProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B97591B2D3D4D67553506D /* AnalyticsClientProtocol.swift */; }; F777C6FEE7D106136E2ED2B2 /* MessageForwardingScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F6E6EDC4BBF962B2ED595A4 /* MessageForwardingScreenViewModelTests.swift */; }; @@ -996,6 +998,7 @@ 5C7C7CFA6B2A62A685FF6CE3 /* DeveloperOptionsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperOptionsScreenCoordinator.swift; sourceTree = ""; }; 5D26A086A8278D39B5756D6F /* project.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = project.yml; sourceTree = ""; }; 5D2D0A6F1ABC99D29462FB84 /* AuthenticationCoordinatorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationCoordinatorUITests.swift; sourceTree = ""; }; + 5D99730313BEBF08CDE81EE3 /* EmojiDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiDetection.swift; sourceTree = ""; }; 5DE8D25D6A91030175D52A20 /* RoomTimelineItemProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProperties.swift; sourceTree = ""; }; 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenCoordinator.swift; sourceTree = ""; }; 5F4134FEFE4EB55759017408 /* UserSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionProtocol.swift; sourceTree = ""; }; @@ -1165,6 +1168,7 @@ A436057DBEA1A23CA8CB1FD7 /* UIFont+AttributedStringBuilder.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIFont+AttributedStringBuilder.h"; sourceTree = ""; }; A4756C5A8C8649AD6C10C615 /* MockUserSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserSession.swift; sourceTree = ""; }; A58DB8EFB91BE920762025D0 /* NCE.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = NCE.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + A58E93D91DE3288010390DEE /* EmojiDetectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiDetectionTests.swift; sourceTree = ""; }; A65F140F9FE5E8D4DAEFF354 /* RoomProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomProxy.swift; sourceTree = ""; }; A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = ""; }; A73A07BAEDD74C48795A996A /* AsyncSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSequence.swift; sourceTree = ""; }; @@ -2357,6 +2361,7 @@ 3B5E97E9615A158C76B2AB77 /* DateTests.swift */, 6D0A27607AB09784C8501B5C /* DeveloperOptionsScreenViewModelTests.swift */, DBFEAC3AC691CBB84983E275 /* ElementXTests.swift */, + A58E93D91DE3288010390DEE /* EmojiDetectionTests.swift */, 9BF9E3E6A23180EC05F06460 /* EmojiMartJSONLoaderTests.swift */, 099F2D36C141D845A445B1E6 /* EmojiProviderTests.swift */, 84B7A28A6606D58D1E38C55A /* ExpiringTaskRunnerTests.swift */, @@ -3040,6 +3045,7 @@ AE52983FAFB4E0998C00EE8A /* CancellableTask.swift */, 127A57D053CE8C87B5EFB089 /* Consumable.swift */, 127C8472672A5BA09EF1ACF8 /* CurrentValuePublisher.swift */, + 5D99730313BEBF08CDE81EE3 /* EmojiDetection.swift */, 7B25F959A434BB9923A3223F /* ExpiringTaskRunner.swift */, 6A580295A56B55A856CC4084 /* InfoPlistReader.swift */, 6AD1A853D605C2146B0DC028 /* MatrixEntityRegex.swift */, @@ -3890,6 +3896,7 @@ CD0088B763CD970CF1CBF8CB /* DateTests.swift in Sources */, 864C69CF951BF36D25BE0C03 /* DeveloperOptionsScreenViewModelTests.swift in Sources */, 9C45CE85325CD591DADBC4CA /* ElementXTests.swift in Sources */, + F697284B9B5F2C00CFEA3B12 /* EmojiDetectionTests.swift in Sources */, 501304F26B52DF7024011B6C /* EmojiMartJSONLoaderTests.swift in Sources */, 25618589E0DE0F1E95FC7B5C /* EmojiProviderTests.swift in Sources */, 71B62C48B8079D49F3FBC845 /* ExpiringTaskRunnerTests.swift in Sources */, @@ -4060,6 +4067,7 @@ D8CFF02C2730EE5BC4F17ABF /* ElementToggleStyle.swift in Sources */, 7C1A7B594B2F8143F0DD0005 /* ElementXAttributeScope.swift in Sources */, 7361B011A79BF723D8C9782B /* EmojiCategory.swift in Sources */, + E45C9FA22BC13B477FD3B4AC /* EmojiDetection.swift in Sources */, D5C805F49B2C75DC3793E780 /* EmojiItem.swift in Sources */, 3A08584ECDD4A4541DBF21F8 /* EmojiLoaderProtocol.swift in Sources */, EE6933C935080B4E0348A58B /* EmojiMartCategory.swift in Sources */, diff --git a/ElementX/Sources/Other/EmojiDetection.swift b/ElementX/Sources/Other/EmojiDetection.swift new file mode 100644 index 000000000..e848f2add --- /dev/null +++ b/ElementX/Sources/Other/EmojiDetection.swift @@ -0,0 +1,66 @@ +// +// Copyright 2023 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 + +extension UnicodeScalar { + var isZeroWidthJoiner: Bool { + value == 8205 + } + + var isKeycap: Bool { + value == 8419 + } + + var isNumber: Bool { + switch value { + case 48...57: + return true + default: + return false + } + } +} + +extension String { + var containsOnlyEmoji: Bool { + guard !isEmpty else { + return false + } + + var emojiMarkerCount = 0 + for scalar in unicodeScalars { + let isEmojiMarker = scalar.properties.isEmoji || + scalar.properties.isEmojiPresentation || + scalar.isZeroWidthJoiner || + scalar.properties.isDefaultIgnorableCodePoint || + scalar.isKeycap + + guard isEmojiMarker else { + return false + } + + emojiMarkerCount += 1 + } + + // Plain numbers like 0 return true for .isEmoji. We don't want that + let markersRequiringSiblings = unicodeScalars.filter { + $0.properties.isEmoji && $0.isNumber + } + + return markersRequiringSiblings.count != emojiMarkerCount && emojiMarkerCount == unicodeScalars.count + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift index 591dc41bd..d464ab811 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FormattedBodyText.swift @@ -22,16 +22,35 @@ struct FormattedBodyText: View { private let attributedString: AttributedString private let additionalWhitespacesCount: Int + private let boostEmojiSize: Bool private var attributedComponents: [AttributedStringBuilderComponent] { - var attributedString = attributedString - attributedString.append(AttributedString(stringLiteral: additionalWhitespacesSuffix)) - return attributedString.formattedComponents + var adjustedAttributedString = attributedString + adjustedAttributedString.append(AttributedString(stringLiteral: additionalWhitespacesSuffix)) + + let string = String(attributedString.characters) + + if boostEmojiSize, + string.containsOnlyEmoji, + let range = adjustedAttributedString.range(of: string) { + adjustedAttributedString[range].font = .system(size: 48.0) + } + + return adjustedAttributedString.formattedComponents } - init(attributedString: AttributedString, additionalWhitespacesCount: Int = 0) { + init(attributedString: AttributedString, + additionalWhitespacesCount: Int = 0, + boostEmojiSize: Bool = false) { self.attributedString = attributedString self.additionalWhitespacesCount = additionalWhitespacesCount + self.boostEmojiSize = boostEmojiSize + } + + init(text: String, additionalWhitespacesCount: Int = 0, boostEmojiSize: Bool = false) { + self.init(attributedString: AttributedString(text), + additionalWhitespacesCount: additionalWhitespacesCount, + boostEmojiSize: boostEmojiSize) } // These is needed to create the slightly off inlined timestamp effect @@ -123,12 +142,6 @@ struct FormattedBodyText: View { } } -extension FormattedBodyText { - init(text: String, additionalWhitespacesCount: Int = 0) { - self.init(attributedString: AttributedString(text), additionalWhitespacesCount: additionalWhitespacesCount) - } -} - // MARK: - Previews struct FormattedBodyText_Previews: PreviewProvider { diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift index 27b12c199..94934a90b 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift @@ -24,9 +24,13 @@ struct TextRoomTimelineView: View, TextBasedRoomTimelineViewProtocol { var body: some View { TimelineStyler(timelineItem: timelineItem) { if let attributedString = timelineItem.content.formattedBody { - FormattedBodyText(attributedString: attributedString, additionalWhitespacesCount: additionalWhitespaces) + FormattedBodyText(attributedString: attributedString, + additionalWhitespacesCount: additionalWhitespaces, + boostEmojiSize: true) } else { - FormattedBodyText(text: timelineItem.body, additionalWhitespacesCount: additionalWhitespaces) + FormattedBodyText(text: timelineItem.body, + additionalWhitespacesCount: additionalWhitespaces, + boostEmojiSize: true) } } } diff --git a/UnitTests/Sources/EmojiDetectionTests.swift b/UnitTests/Sources/EmojiDetectionTests.swift new file mode 100644 index 000000000..c7bb45eae --- /dev/null +++ b/UnitTests/Sources/EmojiDetectionTests.swift @@ -0,0 +1,48 @@ +// +// Copyright 2023 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 XCTest + +@testable import ElementX + +class EmojiDetectionTests: XCTestCase { + func testEmojiDetection() { + XCTAssertTrue("👨‍👩‍đŸ‘Ļ".containsOnlyEmoji) + XCTAssertTrue("1ī¸âƒŖ".containsOnlyEmoji) + XCTAssertTrue("🚀".containsOnlyEmoji) + XCTAssertTrue("đŸ‘ŗđŸžâ€â™‚ī¸".containsOnlyEmoji) + XCTAssertTrue("đŸĒŠ".containsOnlyEmoji) + + XCTAssertTrue("👨‍👩‍đŸ‘Ļ1ī¸âƒŖđŸš€đŸ‘ŗđŸžâ€â™‚ī¸đŸĒŠ".containsOnlyEmoji) + + XCTAssertFalse(" 👨‍👩‍đŸ‘Ļ".containsOnlyEmoji) + XCTAssertFalse(" 👨‍👩‍đŸ‘Ļ ".containsOnlyEmoji) + XCTAssertFalse("👨‍👩‍đŸ‘Ļ ".containsOnlyEmoji) + XCTAssertFalse("Ciao 👨‍👩‍đŸ‘Ļ peeps".containsOnlyEmoji) + + XCTAssertFalse("0".containsOnlyEmoji) + XCTAssertFalse("1".containsOnlyEmoji) + XCTAssertFalse("5".containsOnlyEmoji) + XCTAssertFalse("000".containsOnlyEmoji) + + XCTAssertTrue("👍".containsOnlyEmoji) + XCTAssertTrue("đŸĢąđŸŧ‍đŸĢ˛đŸž".containsOnlyEmoji) + XCTAssertTrue("đŸ‘â¤ī¸đŸ".containsOnlyEmoji) + XCTAssertFalse("🙂 ".containsOnlyEmoji) + XCTAssertFalse("Hello 👋".containsOnlyEmoji) + XCTAssertFalse("Thanks".containsOnlyEmoji) + } +}