├── Mercury Watch App ├── Assets.xcassets │ ├── Contents.json │ ├── Credits │ │ ├── Contents.json │ │ ├── marco.imageset │ │ │ ├── marco.png │ │ │ └── Contents.json │ │ └── alessandro.imageset │ │ │ ├── alessandro.png │ │ │ └── Contents.json │ ├── TDColors │ │ ├── Contents.json │ │ ├── TDBlue.colorset │ │ │ └── Contents.json │ │ ├── TDGreen.colorset │ │ │ └── Contents.json │ │ ├── TDOrange.colorset │ │ │ └── Contents.json │ │ ├── TDPink.colorset │ │ │ └── Contents.json │ │ ├── TDPurple.colorset │ │ │ └── Contents.json │ │ ├── TDRed.colorset │ │ │ └── Contents.json │ │ └── TDTeal.colorset │ │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── MercuryAppIcon.jpg │ │ └── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ └── BgBlue.colorset │ │ └── Contents.json ├── Preview Content │ ├── sticker.tgs │ ├── Preview Assets.xcassets │ │ ├── Contents.json │ │ └── astro.imageset │ │ │ ├── astro.png │ │ │ └── Contents.json │ └── audio_sample.m4a ├── TDLib │ ├── TDLibManagerProtocols.swift │ ├── TDLibViewModel.swift │ └── TDLibManager.swift ├── Utils │ ├── Extensions │ │ ├── PreviewTrait+Mock.swift │ │ ├── Color+.swift │ │ ├── MessageSender+.swift │ │ ├── FileManager+.swift │ │ ├── Chat+.swift │ │ ├── View+.swift │ │ ├── ChatAction+.swift │ │ ├── User+.swift │ │ ├── TDImage+.swift │ │ ├── Date+.swift │ │ ├── FormattedText+.swift │ │ └── ChatNotificationSettings+.swift │ ├── Services │ │ ├── SecretService-sample.swift │ │ ├── UserDefaulsService.swift │ │ ├── FileService.swift │ │ ├── LoggerService.swift │ │ ├── RecorderService.swift │ │ ├── PlayerService.swift │ │ └── SendMessageService.swift │ └── Mockable.swift ├── Shared │ ├── Components │ │ ├── TextDivider.swift │ │ ├── FitStack.swift │ │ ├── Stack.swift │ │ ├── AlertView.swift │ │ ├── FlowLayout.swift │ │ ├── AsyncView.swift │ │ └── AvatarView.swift │ └── Models │ │ └── ChatFolder.swift ├── MercuryApp.swift ├── AppState.swift ├── Pages │ ├── Login │ │ ├── Subviews │ │ │ ├── StepView.swift │ │ │ ├── QRCodeView.swift │ │ │ └── PasswordView.swift │ │ ├── LoginPage.swift │ │ └── LoginViewModel.swift │ ├── ChatDetail │ │ ├── Subviews │ │ │ ├── Message │ │ │ │ ├── Components │ │ │ │ │ ├── ReplyView.swift │ │ │ │ │ ├── StateIconView.swift │ │ │ │ │ ├── ReactionView.swift │ │ │ │ │ └── MessageBubbleShape.swift │ │ │ │ ├── Contents │ │ │ │ │ ├── PillView.swift │ │ │ │ │ └── LocationView.swift │ │ │ │ └── MessageView.swift │ │ │ ├── BubbleShape.swift │ │ │ ├── ReactionsView.swift │ │ │ └── MessageView.swift │ │ ├── Subpages │ │ │ ├── MessageOptions │ │ │ │ ├── MessageOptionsSubpage.swift │ │ │ │ └── MessageOptionsViewModel.swift │ │ │ └── VoiceNoteRecord │ │ │ │ ├── AudioMessageView.swift │ │ │ │ ├── VoiceNoteRecordSubpage.swift │ │ │ │ └── VoiceNoteRecordViewModel.swift │ │ ├── ChatDetailViewModel+Interactions.swift │ │ ├── ChatDetailPage.swift │ │ ├── ChatDetailViewModel+MessageContents.swift │ │ ├── ChatDetailViewModel.swift │ │ └── ChatDetailViewModel+Updates.swift │ ├── ChatList │ │ ├── ChatListPage.swift │ │ ├── ChatListViewModel+Chats.swift │ │ └── ChatListViewModel.swift │ ├── Settings │ │ ├── SettingsViewModel.swift │ │ └── SettingsPage.swift │ └── Home │ │ ├── HomePage.swift │ │ ├── Subviews │ │ └── UserCellView.swift │ │ └── HomeViewModel.swift ├── AppDelegate.swift └── Old │ └── OldPages │ └── AudioMessage │ └── AudioMessageView.swift ├── Mercury-Watch-App-Info.plist ├── README.md ├── Mercury.xcodeproj └── xcshareddata │ └── xcschemes │ └── Mercury Watch App.xcscheme └── .gitignore /Mercury Watch App/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/Credits/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/TDColors/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Mercury Watch App/Preview Content/sticker.tgs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mercurytelegram/Mercury/HEAD/Mercury Watch App/Preview Content/sticker.tgs -------------------------------------------------------------------------------- /Mercury Watch App/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Mercury Watch App/Preview Content/audio_sample.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mercurytelegram/Mercury/HEAD/Mercury Watch App/Preview Content/audio_sample.m4a -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/Credits/marco.imageset/marco.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mercurytelegram/Mercury/HEAD/Mercury Watch App/Assets.xcassets/Credits/marco.imageset/marco.png -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/AppIcon.appiconset/MercuryAppIcon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mercurytelegram/Mercury/HEAD/Mercury Watch App/Assets.xcassets/AppIcon.appiconset/MercuryAppIcon.jpg -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/Credits/alessandro.imageset/alessandro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mercurytelegram/Mercury/HEAD/Mercury Watch App/Assets.xcassets/Credits/alessandro.imageset/alessandro.png -------------------------------------------------------------------------------- /Mercury Watch App/Preview Content/Preview Assets.xcassets/astro.imageset/astro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mercurytelegram/Mercury/HEAD/Mercury Watch App/Preview Content/Preview Assets.xcassets/astro.imageset/astro.png -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "MercuryAppIcon.jpg", 5 | "idiom" : "universal", 6 | "platform" : "watchos", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "universal", 6 | "reference" : "systemBlueColor" 7 | }, 8 | "idiom" : "universal" 9 | } 10 | ], 11 | "info" : { 12 | "author" : "xcode", 13 | "version" : 1 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Mercury-Watch-App-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSAppTransportSecurity 6 | 7 | NSAllowsArbitraryLoads 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Mercury Watch App/TDLib/TDLibManagerProtocols.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TDLibManagerProtocol.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 29/04/24. 6 | // 7 | 8 | import Foundation 9 | import TDLibKit 10 | 11 | protocol TDLibManagerProtocol: AnyObject { 12 | func updateHandler(update: Update) 13 | func connectionStateUpdate(state: ConnectionState) 14 | } 15 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Extensions/PreviewTrait+Mock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewTrait+Mock.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 2/11/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | extension PreviewTrait where T == Preview.ViewTraits { 12 | static func mock() -> Self { 13 | AppState.shared.isMock = true 14 | return .defaultLayout 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/Credits/marco.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "marco.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/BgBlue.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x5F", 9 | "green" : "0x21", 10 | "red" : "0x12" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/Credits/alessandro.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "alessandro.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Mercury Watch App/Preview Content/Preview Assets.xcassets/astro.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "astro.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/TDColors/TDBlue.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "203", 9 | "green" : "136", 10 | "red" : "78" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/TDColors/TDGreen.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "58", 9 | "green" : "167", 10 | "red" : "94" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/TDColors/TDOrange.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "56", 9 | "green" : "124", 10 | "red" : "201" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/TDColors/TDPink.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "137", 9 | "green" : "88", 10 | "red" : "185" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/TDColors/TDPurple.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "212", 9 | "green" : "95", 10 | "red" : "141" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/TDColors/TDRed.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "78", 9 | "green" : "88", 10 | "red" : "189" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Mercury Watch App/Assets.xcassets/TDColors/TDTeal.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "183", 9 | "green" : "156", 10 | "red" : "82" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Services/SecretService-sample.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Secrets.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 29/05/24. 6 | // 7 | 8 | import Foundation 9 | 10 | /// Rename this file and `enum` to `SecretService` and fill in the missing values 11 | enum SecretService_Sample { 12 | /// The API hash from the Telegram API 13 | static var apiHash: String = "" 14 | 15 | /// The API id from the Telegram API 16 | static var apiId: Int = 0 17 | } 18 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Services/UserDefaulsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaulService.swift 3 | // Mercury 4 | // 5 | // Created by Marco Tammaro on 03/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | class UserDefaulsService { 11 | 12 | private enum Keys { 13 | static let isAuthenticated = "isAuthenticated" 14 | } 15 | 16 | static var isAuthenticated: Bool { 17 | get { 18 | return UserDefaults.standard.bool(forKey: Keys.isAuthenticated) 19 | } 20 | set { 21 | UserDefaults.standard.set(newValue, forKey: Keys.isAuthenticated) 22 | } 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Mercury Watch App/Shared/Components/TextDivider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextDivider.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 1/17/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TextDivider: View { 11 | let text: String 12 | 13 | init(_ text: String) { 14 | self.text = text 15 | } 16 | 17 | var body: some View { 18 | HStack { 19 | Rectangle() 20 | .frame(height: 1) 21 | .foregroundStyle(.secondary) 22 | Text(text) 23 | Rectangle() 24 | .frame(height: 1) 25 | .foregroundStyle(.secondary) 26 | } 27 | } 28 | } 29 | 30 | #Preview { 31 | TextDivider("hello") 32 | } 33 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Extensions/Color+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 17/05/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension Color { 11 | init(fromUserId userId: Int64) { 12 | let colors: [Color] = [ 13 | .tdRed, 14 | .tdGreen, 15 | .yellow, 16 | .tdBlue, 17 | .tdPurple, 18 | .tdPink, 19 | .tdTeal, 20 | .tdOrange, 21 | ] 22 | guard let id = Int(String(userId).replacingOccurrences(of: "-100", with: "-")) else { 23 | self.init(.blue) 24 | return 25 | } 26 | self.init(uiColor: UIColor(colors[[0, 7, 4, 1, 6, 3, 5][abs(id % 7)]])) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Extensions/MessageSender+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageSender+.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 29/05/24. 6 | // 7 | 8 | import Foundation 9 | import TDLibKit 10 | 11 | extension MessageSender { 12 | func username() async -> String? { 13 | if case .messageSenderUser(let senderUser) = self { 14 | 15 | guard let user = try? await TDLibManager.shared.client?.getUser(userId: senderUser.userId) 16 | else { return nil } 17 | 18 | var name = user.firstName 19 | if !user.lastName.isEmpty { 20 | name += " " + user.lastName 21 | } 22 | 23 | return name 24 | 25 | } 26 | 27 | return nil 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Mercury Watch App/TDLib/TDLibViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TDLibViewModel.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 29/04/24. 6 | // 7 | 8 | import SwiftUI 9 | import TDLibKit 10 | import Combine 11 | import os 12 | 13 | class TDLibViewModel: ObservableObject, TDLibManagerProtocol { 14 | 15 | let logger: LoggerService 16 | 17 | init() { 18 | self.logger = LoggerService("\(type(of: self))") 19 | TDLibManager.shared.subscribe(self) 20 | self.logger.log("initialised") 21 | } 22 | 23 | deinit { 24 | TDLibManager.shared.unsubscribe(self) 25 | self.logger.log("deinitialised") 26 | } 27 | 28 | // TDLibManagerProtocol 29 | func updateHandler(update: TDLibKit.Update) {} 30 | func connectionStateUpdate(state: TDLibKit.ConnectionState) {} 31 | } 32 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Extensions/FileManager+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManager+.swift 3 | // Mercury 4 | // 5 | // Created by Marco Tammaro on 25/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension FileManager { 11 | 12 | var tmpFolder: URL { 13 | 14 | let tmpDir = FileManager.default.temporaryDirectory 15 | 16 | if !FileManager.default.fileExists(atPath: tmpDir.path) { 17 | do { 18 | try FileManager.default.createDirectory( 19 | atPath: tmpDir.path, 20 | withIntermediateDirectories: true, 21 | attributes: nil 22 | ) 23 | } catch { 24 | print(error.localizedDescription) 25 | } 26 | } 27 | 28 | return tmpDir 29 | 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Extensions/Chat+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Chat+.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 29/05/24. 6 | // 7 | 8 | import SwiftUI 9 | import TDLibKit 10 | 11 | extension Chat { 12 | 13 | var isGroup: Bool { 14 | switch self.type { 15 | case .chatTypeBasicGroup(_), .chatTypeSupergroup(_): 16 | return true 17 | 18 | default: 19 | return false 20 | } 21 | } 22 | 23 | var isArchived: Bool { 24 | return self.chatLists.contains(.chatListArchive) 25 | } 26 | 27 | func toAvatarModel() -> AvatarModel { 28 | let avatarImage = photo?.getAsyncModel() 29 | let letters = "\(self.title.prefix(1))" 30 | 31 | return AvatarModel(avatarImage: avatarImage, letters: letters) 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /Mercury Watch App/Shared/Models/ChatFolder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Folder.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 06/07/24. 6 | // 7 | 8 | import TDLibKit 9 | import SwiftUI 10 | 11 | struct ChatFolder: Hashable { 12 | var title: String 13 | var chatList: ChatList 14 | 15 | var iconName: String { 16 | if case ChatList.chatListArchive = chatList { 17 | return "archivebox" 18 | } 19 | return "folder" 20 | } 21 | 22 | var color: Color { 23 | if case ChatList.chatListArchive = chatList { 24 | return .orange 25 | } 26 | return .blue 27 | } 28 | 29 | static var main: ChatFolder { 30 | ChatFolder(title: "All Chats", chatList: .chatListMain) 31 | } 32 | static var archive: ChatFolder { 33 | ChatFolder(title: "Archive", chatList: .chatListArchive) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Extensions/View+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 21/08/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | var isPreview: Bool { 12 | ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" 13 | } 14 | 15 | /// Applies the given transform if the given condition evaluates to `true`. 16 | /// - Parameters: 17 | /// - condition: The condition to evaluate. 18 | /// - transform: The transform to apply to the source `View`. 19 | /// - Returns: Either the original `View` or the modified `View` if the condition is `true`. 20 | @ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { 21 | if condition { 22 | transform(self) 23 | } else { 24 | self 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Mercury Watch App/MercuryApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MercuryApp.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 18/04/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct MercuryApp: App { 12 | @Environment(\.isLuminanceReduced) var isLuminanceReduced 13 | @WKApplicationDelegateAdaptor var appDelegate: AppDelegate 14 | 15 | var body: some Scene { 16 | WindowGroup { 17 | 18 | let isMock = AppState.shared.isMock 19 | let isAuthenticated = AppState.shared.isAuthenticated ?? false 20 | 21 | if isMock || isAuthenticated { 22 | HomePage() 23 | } else { 24 | LoginPage() 25 | } 26 | 27 | } 28 | .onChange(of: isLuminanceReduced) { 29 | if isLuminanceReduced { 30 | LoginViewModel.setOfflineStatus() 31 | } else { 32 | LoginViewModel.setOnlineStatus() 33 | } 34 | } 35 | 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Mercury Watch App/AppState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppState.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 2/11/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | @Observable 12 | class AppState { 13 | 14 | static var shared = AppState() 15 | 16 | var isMock: Bool = false 17 | var isAuthenticated: Bool? = UserDefaulsService.isAuthenticated 18 | private(set) var folders: [ChatFolder] = [.main, .archive] 19 | 20 | func insertFolder(_ folder: ChatFolder) { 21 | 22 | let isNotMain = folder != .main 23 | let isNotArchive = folder != .archive 24 | let isNotAdded = !folders.contains(folder) 25 | 26 | guard isNotMain, isNotArchive, isNotAdded 27 | else { return } 28 | 29 | withAnimation { 30 | // To leave Archive in the last position 31 | self.folders.insert(folder, at: self.folders.count - 1) 32 | } 33 | } 34 | 35 | public func clear() { 36 | folders = [.main, .archive] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/Login/Subviews/StepView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StepView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 2/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StepView: View { 11 | var steps: [String] 12 | 13 | var body: some View { 14 | VStack { 15 | ForEach(steps.indices, id: \.self) { index in 16 | infoCell(number: index + 1, text: steps[index]) 17 | .padding(.vertical) 18 | } 19 | } 20 | } 21 | 22 | func infoCell(number: Int, text: String) -> some View { 23 | HStack { 24 | Image(systemName: "\(number).circle.fill") 25 | .font(.title) 26 | .foregroundStyle(.white, .blue) 27 | Text(text) 28 | .frame(maxWidth: .infinity, alignment: .leading) 29 | } 30 | } 31 | 32 | } 33 | 34 | #Preview { 35 | ScrollView { 36 | StepView(steps: [ 37 | "Open Telegram on your phone", 38 | "Go to Settings → Devices → Link Desktop Device", 39 | "Point your phone at the QR code to confirm login" 40 | ]) 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /Mercury Watch App/Shared/Components/FitStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FitStack.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 11/14/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FitStack: View { 11 | let content: () -> Content 12 | let vAlignment: HorizontalAlignment 13 | let hAlignment: VerticalAlignment 14 | 15 | init ( 16 | vAlignment: HorizontalAlignment = .leading, 17 | hAlignment: VerticalAlignment = .center, 18 | @ViewBuilder content: @escaping () -> Content 19 | ) { 20 | self.content = content 21 | self.vAlignment = vAlignment 22 | self.hAlignment = hAlignment 23 | } 24 | 25 | var body: some View { 26 | ViewThatFits { 27 | HStack(alignment: hAlignment) { content() } 28 | VStack(alignment: vAlignment) { content() } 29 | } 30 | } 31 | } 32 | 33 | #Preview("HStack") { 34 | FitStack() { 35 | Text("Hello, World!") 36 | Spacer() 37 | Text("Hello, World!") 38 | } 39 | } 40 | 41 | #Preview("VStack") { 42 | FitStack() { 43 | Text("Hello, World!") 44 | Spacer() 45 | Text("Hello, World!") 46 | } 47 | .frame(width: 80) 48 | } 49 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/Subviews/Message/Components/ReplyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReplyView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 07/09/24. 6 | // 7 | 8 | import SwiftUI 9 | import TDLibKit 10 | 11 | struct ReplyView: View { 12 | let model: ReplyModel 13 | 14 | var body: some View { 15 | VStack (alignment: .leading) { 16 | Text(model.title) 17 | .fontWeight(.semibold) 18 | .foregroundStyle(model.color) 19 | Text(model.text) 20 | .lineLimit(2) 21 | } 22 | .padding() 23 | .padding(.leading, 3) 24 | .background { 25 | HStack(spacing: 0) { 26 | Rectangle() 27 | .foregroundStyle(model.color) 28 | .frame(width: 5) 29 | Rectangle() 30 | .foregroundStyle(model.color.opacity(0.3)) 31 | } 32 | .clipShape(RoundedRectangle(cornerRadius: 8)) 33 | } 34 | } 35 | } 36 | 37 | struct ReplyModel { 38 | var color: Color 39 | var title: String 40 | var text: AttributedString 41 | } 42 | 43 | #Preview { 44 | ReplyView(model: .init( 45 | color: .blue, 46 | title: "Title", 47 | text: "Message content" 48 | )) 49 | } 50 | -------------------------------------------------------------------------------- /Mercury Watch App/Shared/Components/Stack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Stack.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 11/15/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct Stack: View { 11 | let content: () -> Content 12 | let type: StackType 13 | 14 | init ( 15 | _ type: StackType, 16 | @ViewBuilder content: @escaping () -> Content 17 | ) { 18 | self.content = content 19 | self.type = type 20 | } 21 | 22 | var body: some View { 23 | switch type { 24 | case .vertical(let alignment): 25 | VStack(alignment: alignment) { content() } 26 | 27 | case .horizontal(let alignment): 28 | HStack(alignment: alignment) { content() } 29 | } 30 | } 31 | 32 | enum StackType { 33 | case vertical(alignment: HorizontalAlignment = .center) 34 | case horizontal(alignment: VerticalAlignment = .center) 35 | } 36 | } 37 | 38 | #Preview("HStack") { 39 | Stack(.horizontal()) { 40 | Text("Hello, World!") 41 | Spacer() 42 | Text("Hello, World!") 43 | } 44 | } 45 | 46 | #Preview("VStack") { 47 | Stack(.vertical()) { 48 | Text("Hello, World!") 49 | Spacer() 50 | Text("Hello, World!") 51 | } 52 | .frame(width: 80) 53 | } 54 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/Subviews/Message/Contents/PillView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PillView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 30/08/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PillView: View { 11 | var title: String? = nil 12 | let description: LocalizedStringKey 13 | 14 | var body: some View { 15 | VStack { 16 | Group { 17 | if let title { 18 | Text(title) 19 | .bold() 20 | } 21 | Text(description) 22 | } 23 | .multilineTextAlignment(.center) 24 | .font(.footnote) 25 | } 26 | .padding() 27 | .padding(.horizontal) 28 | .background { 29 | RoundedRectangle(cornerRadius: 30) 30 | .foregroundStyle(.ultraThinMaterial) 31 | } 32 | } 33 | } 34 | 35 | #Preview { 36 | ScrollView { 37 | PillView( 38 | title: "Title", 39 | description: "Description" 40 | ) 41 | 42 | PillView( 43 | title: "Alessandro", 44 | description: "changed the group name to _test_" 45 | ) 46 | 47 | PillView(description: "Yesterday") 48 | } 49 | .frame(maxWidth: .infinity, maxHeight: .infinity) 50 | .background(.blue.opacity(0.3)) 51 | } 52 | 53 | 54 | -------------------------------------------------------------------------------- /Mercury Watch App/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 18/05/24. 6 | // 7 | 8 | import Foundation 9 | import WatchKit 10 | import AVFAudio 11 | 12 | class AppDelegate: NSObject, WKApplicationDelegate { 13 | 14 | let logger = LoggerService(AppDelegate.self) 15 | 16 | func applicationDidFinishLaunching() { 17 | cleanTmpFolder() 18 | cleanDirectoryFolder() 19 | } 20 | 21 | func applicationDidBecomeActive() { 22 | LoginViewModel.setOnlineStatus() 23 | } 24 | 25 | func applicationDidEnterBackground() { 26 | LoginViewModel.setOfflineStatus() 27 | } 28 | 29 | func applicationWillResignActive() { 30 | LoginViewModel.setOfflineStatus() 31 | } 32 | 33 | private func cleanTmpFolder() { 34 | try? FileManager.default.removeItem( 35 | at: FileManager.default.temporaryDirectory 36 | ) 37 | } 38 | 39 | #warning("Remove it in a future release") 40 | /// This function will remove all the files in Documents Directory since the recoder was using it as tmp storage 41 | /// Once all the users will have documents dir cleard, this function can be removed in order to reuse the documents directory 42 | private func cleanDirectoryFolder() { 43 | let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) 44 | guard let path = url.first else { return } 45 | try? FileManager.default.removeItem(at: path) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Extensions/ChatAction+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatAction+.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 29/05/24. 6 | // 7 | 8 | import Foundation 9 | import TDLibKit 10 | 11 | extension ChatAction { 12 | 13 | var description: AttributedString? { 14 | switch self { 15 | case .chatActionCancel: 16 | return nil 17 | case .chatActionTyping: 18 | return "typing" 19 | case .chatActionRecordingVoiceNote: 20 | return "recording" 21 | case .chatActionRecordingVideo: 22 | return "recording video" 23 | case .chatActionRecordingVideoNote: 24 | return "recording video note" 25 | case .chatActionChoosingContact: 26 | return "selecting contact" 27 | case .chatActionChoosingLocation: 28 | return "selecting location" 29 | case .chatActionChoosingSticker: 30 | return "selecting sticker" 31 | case .chatActionStartPlayingGame: 32 | return "starting game" 33 | case .chatActionUploadingDocument(_): 34 | return "uploading document" 35 | case .chatActionUploadingPhoto(_): 36 | return "uploading photo" 37 | case .chatActionUploadingVideo(_): 38 | return "uploading video" 39 | case .chatActionUploadingVideoNote(_): 40 | return "uploading video note" 41 | case .chatActionUploadingVoiceNote(_): 42 | return "uploading recording" 43 | case .chatActionWatchingAnimations(_): 44 | return "watching animations" 45 | } 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatList/ChatListPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginPage.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 02/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ChatListPage: View { 11 | 12 | @State 13 | @Mockable 14 | var vm: ChatListViewModel 15 | 16 | init(folder: ChatFolder) { 17 | _vm = Mockable.state( 18 | value: { ChatListViewModel(folder: folder) }, 19 | mock: { ChatListViewModelMock() } 20 | ) 21 | } 22 | 23 | var body: some View { 24 | if vm.isLoading { 25 | ProgressView() 26 | } else { 27 | 28 | List(vm.chats) { chat in 29 | NavigationLink(value: chat) { 30 | ChatCellView(model: chat) { 31 | vm.didPressPin(on: chat) 32 | } onPressMuteButton: { 33 | vm.didPressMute(on: chat) 34 | } 35 | } 36 | .listItemTint(chat.isPinned ? .blue : nil) 37 | } 38 | .listStyle(.carousel) 39 | .navigationTitle(vm.folder.title) 40 | .toolbar { 41 | ToolbarItem(placement: .topBarTrailing) { 42 | Button("New Chat", systemImage: "square.and.pencil") { 43 | vm.didPressOnNewMessage() 44 | } 45 | } 46 | } 47 | .sheet(isPresented: $vm.showNewMessage) { 48 | AlertView.inDevelopment("new messages are") 49 | } 50 | } 51 | } 52 | } 53 | 54 | #Preview(traits: .mock()) { 55 | NavigationStack { 56 | ChatListPage(folder: .main) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/Settings/SettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewModel.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 02/11/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import TDLibKit 11 | 12 | @Observable 13 | class SettingsViewModel: TDLibViewModel { 14 | 15 | var user: UserModel? 16 | 17 | override init() { 18 | super.init() 19 | getUser() 20 | } 21 | 22 | func logout() { 23 | LoginViewModel.logout() 24 | } 25 | 26 | fileprivate func getUser() { 27 | 28 | Task.detached(priority: .userInitiated) { 29 | 30 | do { 31 | guard let user = try await TDLibManager.shared.client?.getMe() 32 | else { return } 33 | 34 | await MainActor.run { 35 | withAnimation { 36 | self.user = user.toUserModel() 37 | } 38 | } 39 | 40 | } catch { 41 | self.logger.log(error, level: .error) 42 | } 43 | } 44 | } 45 | } 46 | 47 | struct UserModel { 48 | let thumbnail: UIImage? 49 | let avatar: AvatarModel 50 | let fullName: String 51 | let mainUserName: String 52 | let phoneNumber: String 53 | } 54 | 55 | // MARK: - Mock 56 | @Observable 57 | class SettingsViewModelMock: SettingsViewModel { 58 | override func getUser() { 59 | self.user = .init( 60 | thumbnail: UIImage(named: "astro"), 61 | avatar: .astro, 62 | fullName: "John Appleseed", 63 | mainUserName: "@johnappleseed", 64 | phoneNumber: "+39 0000000000" 65 | ) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Extensions/User+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // User+.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 15/07/24. 6 | // 7 | 8 | import SwiftUI 9 | import TDLibKit 10 | 11 | extension User { 12 | var fullName: String { 13 | return firstName + " " + lastName 14 | } 15 | var mainUserName: String? { 16 | guard let name = usernames?.activeUsernames.first else { return nil } 17 | return "@" + name 18 | } 19 | 20 | func toAvatarModel() -> AvatarModel { 21 | let firstLetter = self.firstName.prefix(1) 22 | let secondLetter = self.lastName.prefix(1) 23 | 24 | return AvatarModel( 25 | avatarImage: getAvatar(), 26 | letters: "\(firstLetter)\(secondLetter)" 27 | ) 28 | } 29 | 30 | private func getThumbnail() -> UIImage? { 31 | guard let data = self.profilePhoto?.minithumbnail?.data 32 | else { return nil } 33 | return UIImage(data: data) 34 | } 35 | 36 | private func getAvatar() -> AsyncImageModel { 37 | let thumbnail = getThumbnail() ?? UIImage() 38 | return AsyncImageModel( 39 | thumbnail: thumbnail, 40 | getImage: { 41 | guard let photo = self.profilePhoto?.lowRes 42 | else { return nil } 43 | return await FileService.getImage(for: photo) 44 | } 45 | ) 46 | } 47 | 48 | func toUserModel() -> UserModel { 49 | return UserModel( 50 | thumbnail: getThumbnail() ?? UIImage(), 51 | avatar: toAvatarModel(), 52 | fullName: fullName, 53 | mainUserName: mainUserName ?? "", 54 | phoneNumber: phoneNumber 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/Home/HomePage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginPage.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 02/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct HomePage: View { 11 | 12 | @State 13 | @Mockable(mockInit: HomeViewModelMock.init) 14 | var vm = HomeViewModel.init 15 | 16 | var body: some View { 17 | NavigationStack(path: $vm.navigationPath) { 18 | List { 19 | NavigationLink { 20 | SettingsPage() 21 | } label: { 22 | UserCellView(model: vm.userCellModel) 23 | } 24 | 25 | Section { 26 | ForEach(AppState.shared.folders, id: \.self) { folder in 27 | NavigationLink(value: folder) { 28 | Label { 29 | Text(folder.title) 30 | } icon: { 31 | Image(systemName: folder.iconName) 32 | .font(.caption) 33 | .foregroundStyle(folder.color) 34 | } 35 | } 36 | .listItemTint(folder.color) 37 | } 38 | } 39 | 40 | } 41 | .navigationTitle("Mercury") 42 | .navigationDestination(for: ChatFolder.self) { folder in 43 | return ChatListPage(folder: folder) 44 | } 45 | .navigationDestination(for: ChatCellModel.self) { chat in 46 | if let id = chat.id { 47 | ChatDetailPage(chatId: id) 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | 55 | #Preview(traits: .mock()) { 56 | HomePage() 57 | } 58 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/Home/Subviews/UserCellView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserCellView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 09/07/24. 6 | // 7 | 8 | import SwiftUI 9 | import TDLibKit 10 | 11 | struct UserCellView: View { 12 | 13 | let model: UserCellModel? 14 | 15 | var body: some View { 16 | Group { 17 | if let model { 18 | content(model) 19 | } else { 20 | loader() 21 | } 22 | } 23 | .padding(.vertical) 24 | } 25 | 26 | @ViewBuilder 27 | private func content(_ model: UserCellModel) -> some View { 28 | HStack(spacing: 10) { 29 | AvatarView(model: model.avatar) 30 | .frame(width: 40, height: 40) 31 | VStack(alignment: .leading) { 32 | Text(model.fullname) 33 | .fontWeight(.semibold) 34 | } 35 | } 36 | } 37 | 38 | @ViewBuilder 39 | private func loader() -> some View { 40 | HStack(spacing: 10) { 41 | Circle() 42 | .fill(.tertiary) 43 | .frame(width: 40, height: 40) 44 | VStack(alignment: .leading) { 45 | Text("placeholder") 46 | .fontWeight(.semibold) 47 | } 48 | } 49 | .redacted(reason: .placeholder) 50 | } 51 | } 52 | 53 | struct UserCellModel { 54 | var avatar: AvatarModel 55 | var fullname: String 56 | } 57 | 58 | 59 | #Preview { 60 | List { 61 | UserCellView( 62 | model: UserCellModel(avatar: .alessandro, fullname: "Alessandro") 63 | ) 64 | UserCellView( 65 | model: UserCellModel(avatar: .init(letters: "MT"), fullname: "Marco Tammaro") 66 | ) 67 | UserCellView(model: nil) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Extensions/TDImage+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TDImage+.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 16/07/24. 6 | // 7 | 8 | import TDLibKit 9 | import UIKit 10 | 11 | protocol TDImage { 12 | var minithumbnail: Minithumbnail? { get } 13 | var lowRes: File? { get } 14 | var highRes: File? { get } 15 | } 16 | 17 | extension TDImage { 18 | func getAsyncModel() -> AsyncImageModel { 19 | var thumbnail: UIImage? = nil 20 | if let data = self.minithumbnail?.data { 21 | thumbnail = UIImage(data: data) 22 | } 23 | 24 | return AsyncImageModel( 25 | thumbnail: thumbnail, 26 | getImage: { 27 | guard let photo = self.lowRes 28 | else { return nil } 29 | return await FileService.getImage(for: photo) 30 | } 31 | ) 32 | } 33 | } 34 | 35 | extension ChatPhotoInfo: TDImage { 36 | var lowRes: File? { 37 | return small 38 | } 39 | var highRes: File? { 40 | return big 41 | } 42 | } 43 | extension ProfilePhoto: TDImage { 44 | var lowRes: File? { 45 | return small 46 | } 47 | var highRes: File? { 48 | return big 49 | } 50 | } 51 | 52 | extension Photo: TDImage { 53 | var lowRes: File? { 54 | return sizes.first?.photo 55 | } 56 | 57 | var highRes: File? { 58 | return sizes.last?.photo 59 | } 60 | } 61 | 62 | extension Video: TDImage { 63 | var lowRes: File? { 64 | return thumbnail?.file 65 | } 66 | 67 | var highRes: File? { 68 | return nil 69 | } 70 | } 71 | 72 | extension ChatPhoto: TDImage { 73 | var lowRes: File? { 74 | self.sizes.isEmpty ? nil : self.sizes[0].photo 75 | } 76 | 77 | var highRes: File? { 78 | return nil 79 | } 80 | } 81 | 82 | 83 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/Subviews/Message/Contents/LocationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 09/08/24. 6 | // 7 | 8 | import SwiftUI 9 | import MapKit 10 | import TDLibKit 11 | 12 | struct LocationView: View { 13 | 14 | let model: LocationModel 15 | 16 | var camera: MapCamera { 17 | var editCoord = model.coordinate 18 | if model.shiftCenter { 19 | editCoord.latitude += 0.00015 20 | } 21 | return MapCamera(centerCoordinate: editCoord, distance: 200) 22 | } 23 | 24 | var body: some View { 25 | Map(position: .constant(.camera(camera)), interactionModes: []) { 26 | Marker(model.title, systemImage: model.markerSymbol, coordinate: model.coordinate) 27 | .tint(model.color ?? .red) 28 | } 29 | .mapStyle(.hybrid(pointsOfInterest: .excludingAll)) 30 | .frame(height: model.shiftCenter ? 130 : 100) 31 | } 32 | } 33 | 34 | struct LocationModel { 35 | var title: String = "" 36 | var coordinate: CLLocationCoordinate2D 37 | var color: Color? 38 | var markerSymbol: String = "mapin" 39 | var shiftCenter: Bool = false 40 | } 41 | 42 | #Preview { 43 | LocationView( 44 | model: .init( 45 | title: "", 46 | coordinate: CLLocationCoordinate2DMake( 47 | 37.33187132756376, 48 | -122.02965972794414 49 | ), 50 | markerSymbol: "" 51 | ) 52 | ) 53 | } 54 | 55 | #Preview { 56 | LocationView( 57 | model: .init( 58 | title: "", 59 | coordinate: CLLocationCoordinate2DMake( 60 | 37.33187132756376, 61 | -122.02965972794414 62 | ), 63 | color: .white, 64 | markerSymbol: "" 65 | ) 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/Login/Subviews/QRCodeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QRCodeView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 2/11/24. 6 | // 7 | 8 | import SwiftUI 9 | import QRCode 10 | 11 | 12 | struct QRCodeView: View { 13 | private var shape: QRCodeShape? 14 | let placeholder: Content 15 | let color: Color? 16 | 17 | init( 18 | text: String?, 19 | color: Color? = nil, 20 | placeholder: @escaping () -> Content = { EmptyView() } 21 | ) { 22 | self.placeholder = placeholder() 23 | self.color = color 24 | self.shape = nil 25 | 26 | guard let text else { return } 27 | shape = try? QRCodeShape( 28 | text: text, 29 | errorCorrection: .low 30 | ) 31 | } 32 | 33 | var body: some View { 34 | if let shape { 35 | ZStack { 36 | RoundedRectangle(cornerRadius: 20) 37 | .fill(.white) 38 | shape 39 | .eyeShape(QRCode.EyeShape.Squircle()) 40 | .pupilShape(QRCode.PupilShape.Squircle()) 41 | .padding() 42 | .if(color != nil) { view in 43 | view.foregroundStyle(color!) 44 | } 45 | .if(color == nil) { view in 46 | view.blendMode(.destinationOut) 47 | } 48 | } 49 | .compositingGroup() 50 | } else { 51 | placeholder 52 | } 53 | } 54 | } 55 | 56 | #Preview("Transparent") { 57 | QRCodeView(text: "Hello World") 58 | .aspectRatio(contentMode: .fit) 59 | .padding() 60 | .background { 61 | Color.blue.opacity(0.5) 62 | } 63 | } 64 | 65 | #Preview("Colored") { 66 | QRCodeView(text: "Hello World", color: .orange) 67 | .aspectRatio(contentMode: .fit) 68 | } 69 | 70 | #Preview("Placeholder") { 71 | QRCodeView(text: nil) { 72 | Text("Placeholder") 73 | } 74 | .aspectRatio(contentMode: .fit) 75 | } 76 | -------------------------------------------------------------------------------- /Mercury Watch App/Shared/Components/AlertView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlertView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 06/07/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AlertView: View { 11 | var symbolSystemName: String 12 | var symbolColor: Color = .white 13 | var title: String 14 | var description: String = "" 15 | 16 | var body: some View { 17 | ScrollView { 18 | Image(systemName: symbolSystemName) 19 | .resizable() 20 | .scaledToFit() 21 | .foregroundStyle(symbolColor) 22 | .symbolVariant(.fill) 23 | .frame(height: 60) 24 | .padding(.bottom) 25 | 26 | Text(title) 27 | .font(.title3) 28 | .fontWeight(.semibold) 29 | .multilineTextAlignment(.center) 30 | .padding(.bottom) 31 | 32 | Text(description) 33 | .font(.footnote) 34 | .foregroundStyle(.secondary) 35 | .multilineTextAlignment(.center) 36 | } 37 | .scenePadding(.horizontal) 38 | } 39 | 40 | static func inDevelopment(_ feature: String = "this feature is") -> AlertView { 41 | AlertView( 42 | symbolSystemName: "hammer", 43 | symbolColor: .blue, 44 | title: "In Development", 45 | description: "Sorry, \(feature) currently under development" 46 | ) 47 | } 48 | } 49 | 50 | #Preview("Generic Alert") { 51 | Spacer() 52 | .sheet(isPresented: .constant(true), content: { 53 | AlertView( 54 | symbolSystemName: "exclamationmark.triangle", 55 | symbolColor: .yellow, 56 | title: "Alert", 57 | description: "Description" 58 | ) 59 | }) 60 | } 61 | 62 | #Preview("inDevelopment") { 63 | Spacer() 64 | .sheet(isPresented: .constant(true), content: { 65 | AlertView.inDevelopment("audio recording is") 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Extensions/Date+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 10/05/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Date { 11 | var stringDescription: String { 12 | let formatter = DateFormatter() 13 | 14 | if Calendar.current.isDateInToday(self) { 15 | formatter.dateStyle = .none 16 | formatter.timeStyle = .short 17 | return formatter.string(from: self) 18 | } else if Calendar.current.isDateInYesterday(self) { 19 | return "Yesterday" 20 | } else { 21 | formatter.dateStyle = .short 22 | formatter.timeStyle = .none 23 | } 24 | 25 | return formatter.string(from: self) 26 | } 27 | 28 | var dayDescription: String { 29 | let formatter = DateFormatter() 30 | 31 | if Calendar.current.isDateInToday(self) { 32 | return "Today" 33 | } else if Calendar.current.isDateInYesterday(self) { 34 | return "Yesterday" 35 | } else { 36 | formatter.dateStyle = .medium 37 | formatter.timeStyle = .none 38 | } 39 | 40 | return formatter.string(from: self) 41 | } 42 | 43 | static var appleWatchPresentationDate: Date { 44 | let calendar = Calendar(identifier: .gregorian) 45 | let components = DateComponents( 46 | year: 2014, 47 | month: 9, 48 | day: 9, 49 | hour: 10, 50 | minute: 9 51 | ) 52 | return calendar.date(from: components) ?? Date() 53 | } 54 | 55 | static var iPhonePresentationDate: Date { 56 | let calendar = Calendar(identifier: .gregorian) 57 | let components = DateComponents( 58 | year: 2007, 59 | month: 1, 60 | day: 9, 61 | hour: 9, 62 | minute: 41 63 | ) 64 | return calendar.date(from: components) ?? Date() 65 | } 66 | 67 | init(fromUnixTimestamp timestamp: Int) { 68 | self.init(timeIntervalSince1970: TimeInterval(timestamp)) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/Subviews/Message/Components/StateIconView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StateIconView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 05/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StateIconView: View { 11 | let style: MessageModel.StateStyle 12 | 13 | var body: some View { 14 | switch style { 15 | case .sending: 16 | sending() 17 | case .delivered: 18 | delivered() 19 | case .seen: 20 | seen() 21 | case .failed: 22 | failed() 23 | } 24 | } 25 | 26 | @ViewBuilder 27 | func sending() -> some View { 28 | TimelineView(.animation) { timeline in 29 | let date = timeline.date 30 | let rotationAngle = Angle.degrees( 31 | date 32 | .timeIntervalSinceReferenceDate 33 | .truncatingRemainder(dividingBy: 1.8) * 360 / 1.8 34 | ) 35 | 36 | Image(systemName: "arrow.triangle.2.circlepath") 37 | .rotationEffect(rotationAngle) 38 | .font(.system(size: 15)) 39 | .foregroundStyle(.secondary) 40 | } 41 | } 42 | 43 | @ViewBuilder 44 | func delivered() -> some View { 45 | Image(systemName: "checkmark") 46 | .font(.footnote) 47 | } 48 | 49 | @ViewBuilder 50 | func seen() -> some View { 51 | ZStack { 52 | Image(systemName: "checkmark") 53 | .font(.footnote) 54 | 55 | Image(systemName: "checkmark") 56 | .font(.footnote) 57 | .offset(x: 5) 58 | .mask(alignment: .leading) { 59 | Rectangle() 60 | .offset(x: 10, y: -4) 61 | .rotationEffect(Angle(degrees: 33)) 62 | 63 | } 64 | } 65 | .offset(x: -3) 66 | } 67 | 68 | @ViewBuilder 69 | func failed() -> some View { 70 | Image(systemName: "exclamationmark.circle.fill") 71 | .font(.footnote) 72 | } 73 | 74 | } 75 | 76 | #Preview { 77 | VStack(spacing: 20) { 78 | StateIconView(style: .sending) 79 | StateIconView(style: .delivered) 80 | StateIconView(style: .seen) 81 | StateIconView(style: .failed) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/Subpages/MessageOptions/MessageOptionsSubpage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageOptionsView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 11/09/24. 6 | // 7 | 8 | import SwiftUI 9 | import TDLibKit 10 | 11 | struct MessageOptionsSubpage: View { 12 | 13 | @State 14 | @Mockable 15 | var vm: MessageOptionsViewModel 16 | 17 | @Binding var isPresented: Bool 18 | 19 | init(isPresented: Binding, model: MessageOptionsModel) { 20 | self._isPresented = isPresented 21 | _vm = Mockable.state( 22 | value: { MessageOptionsViewModel(model: model) }, 23 | mock: { MessageOptionsViewModelMock() } 24 | ) 25 | } 26 | 27 | private let columns = [ 28 | GridItem(.adaptive(minimum: 40)) 29 | ] 30 | 31 | var body: some View { 32 | ScrollView { 33 | LazyVGrid(columns: columns) { 34 | ForEach(vm.emojis, id: \.self) { emoji in 35 | Button(action: { 36 | Task { 37 | await vm.sendReaction(emoji) 38 | await MainActor.run { 39 | isPresented = false 40 | } 41 | } 42 | }, label: { 43 | Text(emoji) 44 | .font(.system(size: 30)) 45 | .background { 46 | RoundedRectangle(cornerRadius: 10) 47 | .fill(.white.opacity(0.2)) 48 | .opacity(vm.selectedEmoji == emoji ? 1 : 0) 49 | 50 | } 51 | }) 52 | .buttonStyle(PlainButtonStyle()) 53 | } 54 | } 55 | .padding(.horizontal) 56 | } 57 | } 58 | } 59 | 60 | struct MessageOptionsModel { 61 | var chatId: Int64 62 | var messageId: Int64 63 | var sendService: SendMessageService 64 | } 65 | 66 | #Preview { 67 | Rectangle() 68 | .foregroundStyle(.blue.opacity(0.8)) 69 | .ignoresSafeArea() 70 | .sheet(isPresented: .constant(true), content: { 71 | MessageOptionsSubpage( 72 | isPresented: .constant(true), 73 | model: .init( 74 | chatId: 0, 75 | messageId: 0, 76 | sendService: SendMessageServiceMock() 77 | ) 78 | ) 79 | }) 80 | } 81 | 82 | 83 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/Home/HomeViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewModel.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 02/11/24. 6 | // 7 | 8 | import SwiftUI 9 | import TDLibKit 10 | 11 | @Observable 12 | class HomeViewModel: TDLibViewModel { 13 | 14 | var navigationPath = NavigationPath() 15 | 16 | var userCellModel: UserCellModel? 17 | 18 | override init() { 19 | super.init() 20 | self.navigationPath.append(ChatFolder.main) 21 | } 22 | 23 | override func updateHandler(update: Update) { 24 | DispatchQueue.main.async { 25 | switch update { 26 | case .updateChatFolders(let update): 27 | self.updateChatFolders(update) 28 | default: 29 | break 30 | } 31 | } 32 | } 33 | 34 | override func connectionStateUpdate(state: ConnectionState) { 35 | DispatchQueue.main.async { 36 | if case .connectionStateReady = state { 37 | self.getUserCellModel() 38 | } 39 | } 40 | } 41 | 42 | @MainActor 43 | func updateChatFolders(_ update: UpdateChatFolders) { 44 | for chatFolderInfo in update.chatFolders { 45 | let chatList = ChatList.chatListFolder(ChatListFolder(chatFolderId: chatFolderInfo.id)) 46 | let folder = ChatFolder(title: chatFolderInfo.title, chatList: chatList) 47 | AppState.shared.insertFolder(folder) 48 | } 49 | } 50 | 51 | func getUserCellModel() { 52 | 53 | Task.detached(priority: .userInitiated) { 54 | 55 | do { 56 | guard let user = try await TDLibManager.shared.client?.getMe() 57 | else { return } 58 | 59 | let fullname = user.firstName + " " + user.lastName 60 | let model = UserCellModel( 61 | avatar: user.toAvatarModel(), 62 | fullname: fullname 63 | ) 64 | 65 | await MainActor.run { 66 | withAnimation { 67 | self.userCellModel = model 68 | } 69 | } 70 | 71 | } catch { 72 | self.logger.log(error, level: .error) 73 | } 74 | } 75 | } 76 | 77 | } 78 | 79 | // MARK: - Mock 80 | @Observable 81 | class HomeViewModelMock: HomeViewModel { 82 | override func getUserCellModel() { 83 | self.userCellModel = UserCellModel(avatar: .astro, fullname: "John Appleseed") 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mercury Messaging 2 | Git Banner 3 |

4 | 5 | 6 | Mercury is an open-source Telegram client designed specifically for the Apple Watch. It delivers a native and standalone experience, allowing you to send and receive Telegram messages directly from your wrist without relying on your iPhone. 7 | More info available [here](https://alessandro-alberti.notion.site/mercury). 8 | 9 | 10 | 11 | Download on TestFlight 12 | 13 | 14 | 15 | ## Main Features 16 | 17 | ### **Privacy-Focused and Open-Source** 18 | Mercury’s code is fully open-source, so anyone can verify it handles user data responsibly and transparently. 19 | 20 | ### **True Standalone Experience** 21 | Enjoy Telegram on your Apple Watch without needing an iPhone. Mercury works independently, so you stay connected wherever you are. 22 | 23 | ### **Modern Design, Cutting-Edge Technology** 24 | Built with the latest Apple technologies and APIs, Mercury delivers a sleek, intuitive design for a seamless user experience. 25 | ## How to Build 26 | 27 | If you want to build the project yourself, you'll need to generate your own **Telegram API Hash** and **ID**. Follow these steps: 28 | 29 | 1. **Generate Telegram API Credentials** 30 | - Visit [this page](https://core.telegram.org/api/obtaining_api_id) to obtain your **API Hash** and **API ID**. 31 | 32 | 2. **Modify the Secret Service File** 33 | - Navigate to [`SecretService-sample.swift`](https://github.com/mercurytelegram/Mercury/blob/main/Mercury%20Watch%20App/Utils/Services/SecretService-sample.swift). 34 | - Rename the `SecretService_Sample` enum to `SecretService`. 35 | 36 | 3. **Add Your Credentials** 37 | - Insert the **API Hash** and **API ID** you obtained in Step 1 into the `static` properties of the `SecretService` enum. 38 | 39 | 4. **Build and Run** 40 | - You're all set! Build and run the project in Xcode. 🚀 41 | 42 | ## Contributing 43 | 44 | Contributions are welcome! Feel free to submit issues or pull requests to make **Mercury for Telegram** even better! 45 | 46 | The features changelog is available [here](https://alessandro-alberti.notion.site/mercury-changelog). 47 | 48 | ## Contact 49 | 50 | Feel free to reach out to us on Telegram: 51 | - **Alessandro Alberti**: [@AlessandroAlberti](https://t.me/AlessandroAlberti) 52 | - **Marco Tammaro**: [@MarcoTammaro](https://t.me/MarcoTammaro) 53 | 54 | 55 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Extensions/FormattedText+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormattedText+.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 31/05/24. 6 | // 7 | 8 | import TDLibKit 9 | import SwiftUI 10 | 11 | extension FormattedText { 12 | var attributedString: AttributedString { 13 | 14 | var resultString = AttributedString(text) 15 | 16 | for entity in entities { 17 | 18 | let nsRange = range(for: text, offset: entity.offset, length: entity.length) 19 | guard let range = Range(nsRange, in: resultString) else { 20 | return resultString 21 | } 22 | 23 | switch entity.type { 24 | case .textEntityTypeBold: 25 | resultString[range].font = .system(.body).bold() 26 | case .textEntityTypeItalic: 27 | resultString[range].font = .system(.body).italic() 28 | case .textEntityTypeCode: 29 | resultString[range].font = .system(.body).monospaced() 30 | case .textEntityTypeUnderline: 31 | resultString[range].underlineStyle = .single 32 | case .textEntityTypeStrikethrough: 33 | resultString[range].strikethroughStyle = .single 34 | case .textEntityTypeMention: 35 | resultString[range].foregroundColor = .blue 36 | case .textEntityTypeSpoiler: 37 | resultString.characters.replaceSubrange(range, with: getRandomBraille(length: entity.length)) 38 | case .textEntityTypeBlockQuote: 39 | let quote = String(resultString[range].characters) 40 | resultString.characters.replaceSubrange(range, with: "❝\(quote)❞") 41 | default: 42 | break 43 | } 44 | } 45 | return resultString 46 | } 47 | 48 | func getRandomBraille(length: Int) -> String { 49 | let braille = "⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏⠐⠑⠒⠓⠔⠕⠖⠗⠘⠙⠚⠛⠜⠝⠞⠟⠠⠡⠢⠣⠤⠥⠦⠧⠨⠩⠪⠫⠬⠭⠮⠯⠰⠱⠲⠳⠴⠵⠶⠷⠸⠹⠺⠻⠼⠽⠾⠿" 50 | var string = "" 51 | 52 | for _ in 0...length - 1{ 53 | let randomIndex = Int.random(in: 0...braille.count - 1) 54 | let index = braille.index(braille.startIndex, offsetBy: randomIndex) 55 | string.append(braille[index]) 56 | } 57 | return string 58 | } 59 | 60 | private func range(for string: String, offset: Int, length: Int) -> NSRange { 61 | let start = text.utf16.index(text.startIndex, offsetBy: offset) 62 | let end = text.utf16.index(start, offsetBy: length) 63 | return NSRange(start.. ChatNotificationSettings { 29 | return ChatNotificationSettings( 30 | disableMentionNotifications: disableMentionNotifications ?? self.disableMentionNotifications, 31 | disablePinnedMessageNotifications: disablePinnedMessageNotifications ?? self.disablePinnedMessageNotifications, 32 | muteFor: muteFor ?? self.muteFor, 33 | muteStories: muteStories ?? self.muteStories, 34 | showPreview: showPreview ?? self.showPreview, 35 | showStorySender: showStorySender ?? self.showStorySender, 36 | soundId: soundId ?? self.soundId, 37 | storySoundId: storySoundId ?? self.storySoundId, 38 | useDefaultDisableMentionNotifications: useDefaultDisableMentionNotifications ?? self.useDefaultDisableMentionNotifications, 39 | useDefaultDisablePinnedMessageNotifications: useDefaultDisablePinnedMessageNotifications ?? self.useDefaultDisablePinnedMessageNotifications, 40 | useDefaultMuteFor: useDefaultMuteFor ?? self.useDefaultMuteFor, 41 | useDefaultMuteStories: useDefaultMuteStories ?? self.useDefaultMuteStories, 42 | useDefaultShowPreview: useDefaultShowPreview ?? self.useDefaultShowPreview, 43 | useDefaultShowStorySender: useDefaultShowStorySender ?? self.useDefaultShowStorySender, 44 | useDefaultSound: useDefaultSound ?? self.useDefaultSound, 45 | useDefaultStorySound: useDefaultStorySound ?? self.useDefaultStorySound 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Services/FileService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoManager.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 14/05/24. 6 | // 7 | 8 | import SwiftUI 9 | import TDLibKit 10 | import Gzip 11 | 12 | 13 | class FileService { 14 | 15 | static let logger = LoggerService(FileService.self) 16 | 17 | static func getImage(for photo: File) async -> UIImage? { 18 | 19 | guard let imagePath = await FileService.getPath(for: photo) else { 20 | logger.log("imagePath is nil") 21 | return nil 22 | } 23 | 24 | guard let uiImage = UIImage(contentsOfFile: imagePath) else { 25 | logger.log("Unable to convert file to image") 26 | return nil 27 | } 28 | 29 | return uiImage 30 | } 31 | 32 | static func getFilePath(for file: File) async -> URL? { 33 | 34 | guard let path = await FileService.getPath(for: file) else { 35 | logger.log("path is nil") 36 | return nil 37 | } 38 | 39 | return URL(fileURLWithPath: path) 40 | } 41 | 42 | static func getPath(for file: File) async -> String? { 43 | 44 | var filePath = file.local.path 45 | 46 | if filePath.isEmpty { 47 | do { 48 | let fileID = file.id 49 | guard let file = try await TDLibManager.shared.client?.downloadFile( 50 | fileId: fileID, 51 | limit: 0, 52 | offset: 0, 53 | priority: 4, 54 | synchronous: true 55 | ) else { 56 | logger.log("Unable to retrive file", level: .error) 57 | return nil 58 | } 59 | 60 | filePath = file.local.path 61 | 62 | } catch { 63 | logger.log(error, level: .error) 64 | } 65 | } 66 | 67 | return filePath 68 | } 69 | 70 | static func getLottieJson(for tgsPath: URL) -> Data? { 71 | let zipPath = tgsPath.deletingPathExtension().appendingPathExtension("zip") 72 | 73 | do { 74 | // Change file extension 75 | if !FileManager.default.fileExists(atPath: zipPath.path) { 76 | try FileManager.default.copyItem(at: tgsPath, to: zipPath) 77 | } 78 | let sourceData = try Data(contentsOf: zipPath) 79 | let lottieJSONData = try sourceData.gunzipped() 80 | return lottieJSONData 81 | } catch { 82 | return nil 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/Settings/SettingsPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginPage.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 02/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SettingsPage: View { 11 | 12 | @State 13 | @Mockable(mockInit: SettingsViewModelMock.init) 14 | var vm = SettingsViewModel.init 15 | 16 | var body: some View { 17 | ScrollView { 18 | avatarHeader() 19 | Spacer() 20 | Button("Logout", role: .destructive) { 21 | vm.logout() 22 | } 23 | credits() 24 | .padding(.top) 25 | } 26 | } 27 | 28 | @ViewBuilder 29 | func avatarHeader() -> some View { 30 | ZStack { 31 | Image(uiImage: vm.user?.thumbnail ?? UIImage()) 32 | .resizable() 33 | .frame(height: 120) 34 | .clipShape(Ellipse()) 35 | .blur(radius: 30) 36 | .opacity(0.8) 37 | 38 | VStack { 39 | 40 | if let avatar = vm.user?.avatar { 41 | AvatarView(model: avatar) 42 | .frame(width: 50, height: 50) 43 | } 44 | 45 | Text(vm.user?.fullName ?? "") 46 | .fontDesign(.rounded) 47 | .fontWeight(.semibold) 48 | Text(vm.user?.mainUserName ?? "") 49 | .font(.footnote) 50 | .foregroundStyle(.secondary) 51 | Text(vm.user?.phoneNumber ?? "") 52 | .font(.footnote) 53 | .foregroundStyle(.secondary) 54 | } 55 | } 56 | .frame(height: 120) 57 | } 58 | 59 | @ViewBuilder 60 | func credits() -> some View { 61 | VStack { 62 | TextDivider("by") 63 | HStack { 64 | creditsAvatar( 65 | name: "Alessandro\nAlberti", 66 | image: "alessandro" 67 | ) 68 | Spacer() 69 | creditsAvatar( 70 | name: "Marco\nTammaro", 71 | image: "marco" 72 | ) 73 | } 74 | } 75 | .padding(.horizontal) 76 | } 77 | 78 | @ViewBuilder 79 | func creditsAvatar(name: String, image: String) -> some View { 80 | VStack { 81 | Image(image) 82 | .resizable() 83 | .frame(width: 50, height: 50) 84 | .clipShape(Circle()) 85 | Text(name) 86 | .multilineTextAlignment(.center) 87 | } 88 | } 89 | } 90 | 91 | #Preview(traits: .mock()) { 92 | SettingsPage() 93 | } 94 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/Login/Subviews/PasswordView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PasswordView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 2/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PasswordView: View { 11 | @Binding var password: String 12 | let model: PasswordModel 13 | var onSubmit : () -> () = {} 14 | 15 | var body: some View { 16 | ScrollView { 17 | Image(systemName: model.iconName) 18 | .resizable() 19 | .scaledToFit() 20 | .frame(height: 60) 21 | .rotationEffect(.degrees(45)) 22 | .padding(.top, -25) 23 | 24 | Text(model.title) 25 | .font(.title3) 26 | .multilineTextAlignment(.center) 27 | .padding(.bottom) 28 | 29 | SecureField("\(Image(systemName: "lock.fill")) Password", text: $password, onCommit: onSubmit) 30 | .clipShape(RoundedRectangle(cornerRadius: 15)) 31 | .clipped() 32 | .if(model.style == .error, transform: { view in 33 | view.overlay { 34 | RoundedRectangle(cornerRadius: 15).stroke(.red) 35 | } 36 | }) 37 | .padding(.horizontal, 1) 38 | 39 | Text(model.description) 40 | .font(.footnote) 41 | .foregroundStyle(.secondary) 42 | .multilineTextAlignment(.center) 43 | .padding(.top) 44 | } 45 | .scenePadding(.horizontal) 46 | } 47 | } 48 | 49 | struct PasswordModel { 50 | var title: String 51 | var iconName: String 52 | var description: String 53 | var style: Style 54 | 55 | enum Style { 56 | case plain, error 57 | } 58 | } 59 | 60 | extension PasswordModel { 61 | static var plain: Self { 62 | .init( 63 | title: "Insert your Telegram Password", 64 | iconName: "key.fill", 65 | description: "You have Two-Step Verification enabled, so your account is protected with an additional password.", 66 | style: .plain 67 | ) 68 | } 69 | 70 | static var error: Self { 71 | .init( 72 | title: "Wrong password, try again!", 73 | iconName: "key.fill", 74 | description: "You have Two-Step Verification enabled, so your account is protected with an additional password.", 75 | style: .error 76 | ) 77 | } 78 | } 79 | 80 | #Preview("Plain") { 81 | PasswordView(password: .constant(""), model: .plain) { 82 | print("commit") 83 | } 84 | } 85 | 86 | #Preview("Error") { 87 | PasswordView(password: .constant(""), model: .error) { 88 | print("commit") 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Services/LoggerService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoggerService.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 02/06/24. 6 | // 7 | 8 | import Foundation 9 | import os 10 | 11 | class LoggerService { 12 | 13 | /// The logging level: 14 | /// - debug: Specify a log that will be used to debug only, no trace will be stored. 15 | /// - info: Specify a log that will be used to report certain behaviours, the log will be stored for future retrieval. 16 | /// - error: Specify a log that will be used to report an error, the log will be stored for future retrieval. 17 | /// - fatal: Specify a log that will be used to report an error that will cause the app to crash, the log will be stored for future retrieval. 18 | public enum Level: String { 19 | case debug = "Debug" 20 | case info = "Info" 21 | case error = "Error" 22 | case fatal = "Fatal" 23 | } 24 | 25 | private let subsystem: String 26 | private let category: String 27 | private let logger: Logger 28 | 29 | /// Init the logging service. 30 | /// 31 | /// - Parameter category: The class that will use the logger service. 32 | public init(_ category: Any) { 33 | self.category = "\(category)" 34 | 35 | #if DEBUG 36 | self.subsystem = "Mercury" 37 | #else 38 | self.subsystem = Bundle.main.bundleIdentifier ?? "Mercury" 39 | #endif 40 | 41 | self.logger = Logger( 42 | subsystem: self.subsystem, 43 | category: self.category 44 | ) 45 | } 46 | 47 | /// Logs a string with a specified level and context 48 | /// 49 | /// - Parameter message: A string. 50 | /// - Parameter caller: The function calling the log, used to get a context related to the log. 51 | /// - Parameter level: The logging ``LoggerService/Level`` 52 | public func log(_ message: String, 53 | caller: String = #function, 54 | level: Level = .debug) { 55 | 56 | let prefix: String = "[\(subsystem)] [\(level.rawValue)] [\(self.category)] [\(caller)]" 57 | 58 | switch level { 59 | case .debug: 60 | self.logger.debug("\(prefix) \(message)") 61 | case .info: 62 | self.logger.info("\(prefix) \(message)") 63 | case .error: 64 | self.logger.error("\(prefix) \(message)") 65 | case .fatal: 66 | self.logger.critical("\(prefix) \(message)") 67 | } 68 | 69 | } 70 | 71 | /// Logs a string with a specified level and context 72 | /// 73 | /// - Parameter object: An object that will be string described. 74 | /// - Parameter caller: The function calling the log, used to get a context related to the log. 75 | /// - Parameter level: The logging ``LoggerService/Level`` 76 | public func log(_ object: Any?, 77 | caller: String = #function, 78 | level: Level = .debug) { 79 | self.log(String(describing: object), caller: caller, level: level) 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /Mercury Watch App/Shared/Components/FlowLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlowLayout.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 11/15/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FlowLayout: Layout { 11 | var spacing: (x: CGFloat, y: CGFloat) 12 | 13 | init(horizontalSpacing: CGFloat = 5, verticalSpacing: CGFloat = 5) { 14 | self.spacing = (horizontalSpacing, verticalSpacing) 15 | } 16 | 17 | func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { 18 | let arranger = Arranger( 19 | containerSize: proposal.replacingUnspecifiedDimensions(), 20 | subviews: subviews, 21 | spacing: spacing 22 | ) 23 | let result = arranger.arrange() 24 | return result.size 25 | } 26 | 27 | func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { 28 | let arranger = Arranger( 29 | containerSize: proposal.replacingUnspecifiedDimensions(), 30 | subviews: subviews, 31 | spacing: spacing 32 | ) 33 | let result = arranger.arrange() 34 | 35 | for (index, cell) in result.cells.enumerated() { 36 | let point = CGPoint( 37 | x: bounds.minX + cell.frame.origin.x, 38 | y: bounds.minY + cell.frame.origin.y 39 | ) 40 | 41 | subviews[index].place( 42 | at: point, 43 | anchor: .topLeading, 44 | proposal: ProposedViewSize(cell.frame.size) 45 | ) 46 | } 47 | } 48 | } 49 | 50 | struct Arranger { 51 | var containerSize: CGSize 52 | var subviews: LayoutSubviews 53 | var spacing: (x: CGFloat, y: CGFloat) 54 | 55 | func arrange() -> Result { 56 | var cells: [Cell] = [] 57 | 58 | var maxY: CGFloat = 0 59 | var previousFrame: CGRect = .zero 60 | 61 | for (index, subview) in subviews.enumerated() { 62 | let size = subview.sizeThatFits(ProposedViewSize(containerSize)) 63 | 64 | var origin: CGPoint 65 | if index == 0 { 66 | origin = .zero 67 | } else if previousFrame.maxX + spacing.x + size.width > containerSize.width { 68 | origin = CGPoint(x: 0, y: maxY + spacing.y) 69 | } else { 70 | origin = CGPoint(x: previousFrame.maxX + spacing.x, y: previousFrame.minY) 71 | } 72 | 73 | let frame = CGRect(origin: origin, size: size) 74 | let cell = Cell(frame: frame) 75 | cells.append(cell) 76 | 77 | previousFrame = frame 78 | maxY = max(maxY, frame.maxY) 79 | } 80 | 81 | let maxWidth = cells.reduce(0, { max($0, $1.frame.maxX) }) 82 | return Result( 83 | size: CGSize(width: maxWidth, height: previousFrame.maxY), 84 | cells: cells 85 | ) 86 | } 87 | 88 | struct Result { 89 | var size: CGSize 90 | var cells: [Cell] 91 | } 92 | 93 | struct Cell { 94 | var frame: CGRect 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Mercury Watch App/Shared/Components/AsyncView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 11/7/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AsyncView: View { 11 | @State private var data: Data? = nil 12 | 13 | let getData: () async throws -> Data? 14 | var placeholder: () -> Placeholder 15 | let content: (Data) -> Content? 16 | 17 | init( 18 | getData: @escaping () async throws -> Data?, 19 | placeholder: @escaping () -> Placeholder = { ProgressView() }, 20 | buildContent: @escaping (Data) -> Content 21 | ) { 22 | self.placeholder = placeholder 23 | self.getData = getData 24 | self.content = buildContent 25 | } 26 | 27 | var body: some View { 28 | if let data { 29 | content(data) 30 | } else { 31 | placeholder() 32 | .task { 33 | let loadedData = try? await getData() 34 | await MainActor.run { 35 | withAnimation { 36 | self.data = loadedData 37 | } 38 | } 39 | } 40 | } 41 | } 42 | } 43 | 44 | struct AsyncImageModel { 45 | let thumbnail: UIImage? 46 | let getImage: () async throws -> UIImage? 47 | } 48 | 49 | extension AsyncImageModel: Equatable { 50 | static func == (lhs: AsyncImageModel, rhs: AsyncImageModel) -> Bool { 51 | return lhs.thumbnail == rhs.thumbnail 52 | } 53 | } 54 | 55 | 56 | #Preview("Loader") { 57 | AsyncView(getData: getAsyncPreviewImage) { image in 58 | return Image(uiImage: image) 59 | .resizable() 60 | .aspectRatio(contentMode: .fit) 61 | } 62 | } 63 | 64 | #Preview("Placeholder") { 65 | AsyncView(getData: getAsyncPreviewImage) { 66 | Text("Placeholder...") 67 | } buildContent: { image in 68 | return Image(uiImage: image) 69 | .resizable() 70 | .aspectRatio(contentMode: .fit) 71 | } 72 | } 73 | 74 | #Preview("Error") { 75 | AsyncView(getData: getAsyncPreviewResult) { 76 | Text("Placeholder...") 77 | } buildContent: { result in 78 | Group { 79 | switch result { 80 | case .success(let image): 81 | Image(uiImage: image) 82 | .resizable() 83 | .aspectRatio(contentMode: .fit) 84 | case .failure(let error): 85 | Text("Error...") 86 | } 87 | } 88 | } 89 | } 90 | 91 | private func getAsyncPreviewImage() async throws -> UIImage? { 92 | let second: UInt64 = 1_000_000_000 93 | try? await Task.sleep(nanoseconds: 3 * second) 94 | 95 | return UIImage(named: "astro") 96 | } 97 | 98 | private func getAsyncPreviewResult() async throws -> Result { 99 | let second: UInt64 = 1_000_000_000 100 | try? await Task.sleep(nanoseconds: 3 * second) 101 | 102 | return .failure(NSError(domain: "", code: 0)) 103 | } 104 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatList/ChatListViewModel+Chats.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewModel.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 02/11/24. 6 | // 7 | 8 | import Foundation 9 | import TDLibKit 10 | import SwiftUI 11 | 12 | extension ChatListViewModel { 13 | 14 | func loadChats(limit: Int = 10) async -> [Chat] { 15 | 16 | var chatsData = [Chat]() 17 | let chatList = folder.chatList 18 | 19 | do { 20 | 21 | let result = try await TDLibManager.shared.client?.getChats( 22 | chatList: chatList, 23 | limit: limit 24 | ) 25 | 26 | guard let result else { return [] } 27 | 28 | for id in result.chatIds { 29 | guard let chat = try await TDLibManager.shared.client?.getChat(chatId: id) 30 | else { continue } 31 | 32 | chatsData.append(chat) 33 | } 34 | 35 | self.logger.log(result) 36 | 37 | } catch { 38 | self.logger.log(error, level: .error) 39 | } 40 | 41 | return chatsData 42 | 43 | } 44 | 45 | func chatCellModelFrom(_ chat: Chat) -> ChatCellModel { 46 | 47 | let date = Date(fromUnixTimestamp: chat.lastMessage?.date ?? 0) 48 | 49 | var userId: Int64? = nil 50 | if case .chatTypePrivate(let data) = chat.type { 51 | userId = data.userId 52 | } 53 | 54 | var avatar = chat.toAvatarModel() 55 | avatar.userId = userId 56 | 57 | let position = chat.positions.first(where: { $0.list == folder.chatList }) 58 | let positionOrder = position?.order.rawValue 59 | let isPinned = position?.isPinned ?? false 60 | let isMuted = chat.notificationSettings.muteFor != 0 61 | 62 | var messageStyle: ChatCellModel.MessageStyle? = nil 63 | if let message = chat.lastMessage?.description { 64 | messageStyle = .message(message) 65 | } 66 | 67 | var unreadBadgeStyle: ChatCellModel.UnreadStyle? = nil 68 | if chat.unreadMentionCount != 0 { 69 | unreadBadgeStyle = .mention 70 | } else if chat.unreadReactionCount != 0 { 71 | unreadBadgeStyle = .reaction 72 | } else if chat.unreadCount != 0 { 73 | unreadBadgeStyle = .message(count: chat.unreadCount) 74 | } 75 | 76 | return ChatCellModel( 77 | id: chat.id, 78 | position: positionOrder, 79 | title: chat.title, 80 | time: date.stringDescription, 81 | avatar: avatar, 82 | isMuted: isMuted, 83 | isPinned: isPinned, 84 | messageStyle: messageStyle, 85 | unreadBadgeStyle: unreadBadgeStyle 86 | ) 87 | } 88 | 89 | func chatSortingLogic(elem1: ChatCellModel, elem2: ChatCellModel) -> Bool { 90 | guard let p1 = elem1.position, let p2 = elem2.position 91 | else { return true } 92 | 93 | // Sorting also by id does not update correctly the group chat's position 94 | return p1 > p2 // && elem1.id > elem2.id 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/Subpages/MessageOptions/MessageOptionsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageOptionsViewModel.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 11/09/24. 6 | // 7 | 8 | import SwiftUI 9 | import TDLibKit 10 | 11 | @Observable 12 | class MessageOptionsViewModel { 13 | 14 | var emojis: [String] = [] 15 | var selectedEmoji: String? = nil 16 | 17 | let model: MessageOptionsModel 18 | 19 | private let logger = LoggerService(MessageOptionsViewModel.self) 20 | 21 | init(model: MessageOptionsModel) { 22 | self.model = model 23 | Task.detached(priority: .high) { 24 | await self.getReactions() 25 | await self.getSelectedEmoji() 26 | } 27 | } 28 | 29 | private func getReactions() async { 30 | 31 | let chatId = model.chatId 32 | let messageId = model.messageId 33 | 34 | let reactions = try? await TDLibManager.shared.client?.getMessageAvailableReactions( 35 | chatId: chatId, 36 | messageId: messageId, 37 | rowSize: 4 38 | ) 39 | 40 | let availableEmojis = reactions?.topReactions.map { reaction in 41 | if case .reactionTypeEmoji(let emojiReaction) = reaction.type { 42 | return emojiReaction.emoji 43 | } 44 | return "?" 45 | } 46 | 47 | await MainActor.run { 48 | self.emojis = availableEmojis ?? [] 49 | } 50 | } 51 | 52 | private func getSelectedEmoji() async { 53 | 54 | let chatId = model.chatId 55 | let messageId = model.messageId 56 | 57 | do { 58 | 59 | guard let message = try await TDLibManager.shared.client?.getMessage( 60 | chatId: chatId, 61 | messageId: messageId 62 | ) else { return } 63 | 64 | let reactions = message.interactionInfo?.reactions?.reactions 65 | let chosenReaction = reactions?.first(where: { $0.isChosen }) 66 | 67 | if case .reactionTypeEmoji(let type) = chosenReaction?.type { 68 | await MainActor.run { 69 | self.selectedEmoji = type.emoji 70 | } 71 | } 72 | 73 | } catch { 74 | self.logger.log(error, level: .error) 75 | } 76 | } 77 | 78 | func sendReaction(_ emoji: String) async { 79 | 80 | let chatId = model.chatId 81 | let messageId = model.messageId 82 | 83 | WKInterfaceDevice.current().play(.click) 84 | await MainActor.run { 85 | self.selectedEmoji = emoji 86 | } 87 | 88 | model.sendService.sendReaction( 89 | emoji, 90 | chatId: chatId, 91 | messageId: messageId 92 | ) 93 | 94 | } 95 | } 96 | 97 | class MessageOptionsViewModelMock: MessageOptionsViewModel { 98 | init() { 99 | super.init( 100 | model: MessageOptionsModel( 101 | chatId: 0, 102 | messageId: 0, 103 | sendService: SendMessageServiceMock() 104 | ) 105 | ) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/Subviews/BubbleShape.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BubbleShape.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 24/05/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BubbleShape: Shape { 11 | var myMessage : Bool 12 | func path(in rect: CGRect) -> Path { 13 | let width = rect.width 14 | let height = rect.height 15 | 16 | let bezierPath = UIBezierPath() 17 | if !myMessage { 18 | bezierPath.move(to: CGPoint(x: 20, y: height)) 19 | bezierPath.addLine(to: CGPoint(x: width - 15, y: height)) 20 | bezierPath.addCurve(to: CGPoint(x: width, y: height - 15), controlPoint1: CGPoint(x: width - 8, y: height), controlPoint2: CGPoint(x: width, y: height - 8)) 21 | bezierPath.addLine(to: CGPoint(x: width, y: 15)) 22 | bezierPath.addCurve(to: CGPoint(x: width - 15, y: 0), controlPoint1: CGPoint(x: width, y: 8), controlPoint2: CGPoint(x: width - 8, y: 0)) 23 | bezierPath.addLine(to: CGPoint(x: 20, y: 0)) 24 | bezierPath.addCurve(to: CGPoint(x: 5, y: 15), controlPoint1: CGPoint(x: 12, y: 0), controlPoint2: CGPoint(x: 5, y: 8)) 25 | bezierPath.addLine(to: CGPoint(x: 5, y: height - 10)) 26 | bezierPath.addCurve(to: CGPoint(x: 0, y: height), controlPoint1: CGPoint(x: 5, y: height - 1), controlPoint2: CGPoint(x: 0, y: height)) 27 | bezierPath.addLine(to: CGPoint(x: -1, y: height)) 28 | bezierPath.addCurve(to: CGPoint(x: 12, y: height - 4), controlPoint1: CGPoint(x: 4, y: height + 1), controlPoint2: CGPoint(x: 8, y: height - 1)) 29 | bezierPath.addCurve(to: CGPoint(x: 20, y: height), controlPoint1: CGPoint(x: 15, y: height), controlPoint2: CGPoint(x: 20, y: height)) 30 | } else { 31 | bezierPath.move(to: CGPoint(x: width - 20, y: height)) 32 | bezierPath.addLine(to: CGPoint(x: 15, y: height)) 33 | bezierPath.addCurve(to: CGPoint(x: 0, y: height - 15), controlPoint1: CGPoint(x: 8, y: height), controlPoint2: CGPoint(x: 0, y: height - 8)) 34 | bezierPath.addLine(to: CGPoint(x: 0, y: 15)) 35 | bezierPath.addCurve(to: CGPoint(x: 15, y: 0), controlPoint1: CGPoint(x: 0, y: 8), controlPoint2: CGPoint(x: 8, y: 0)) 36 | bezierPath.addLine(to: CGPoint(x: width - 20, y: 0)) 37 | bezierPath.addCurve(to: CGPoint(x: width - 5, y: 15), controlPoint1: CGPoint(x: width - 12, y: 0), controlPoint2: CGPoint(x: width - 5, y: 8)) 38 | bezierPath.addLine(to: CGPoint(x: width - 5, y: height - 12)) 39 | bezierPath.addCurve(to: CGPoint(x: width, y: height), controlPoint1: CGPoint(x: width - 5, y: height - 1), controlPoint2: CGPoint(x: width, y: height)) 40 | bezierPath.addLine(to: CGPoint(x: width + 1, y: height)) 41 | bezierPath.addCurve(to: CGPoint(x: width - 12, y: height - 4), controlPoint1: CGPoint(x: width - 4, y: height + 1), controlPoint2: CGPoint(x: width - 8, y: height - 1)) 42 | bezierPath.addCurve(to: CGPoint(x: width - 20, y: height), controlPoint1: CGPoint(x: width - 15, y: height), controlPoint2: CGPoint(x: width - 20, y: height)) 43 | } 44 | return Path(bezierPath.cgPath) 45 | } 46 | } 47 | 48 | #Preview { 49 | VStack { 50 | BubbleShape(myMessage: false) 51 | .frame(width: 100, height: 50) 52 | .foregroundStyle(.gray) 53 | .frame(maxWidth: .infinity, alignment: .leading) 54 | BubbleShape(myMessage: true) 55 | .frame(width: 100, height: 50) 56 | .foregroundStyle(.blue) 57 | .frame(maxWidth: .infinity, alignment: .trailing) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/Subviews/Message/Components/ReactionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReactionView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 10/08/24. 6 | // 7 | 8 | import SwiftUI 9 | import TDLibKit 10 | 11 | struct ReactionView: View { 12 | var reaction: ReactionModel 13 | var avatarMaxNumber = 3 14 | var blurredBg: Bool = false 15 | @State private var images: [AsyncImageModel] = [] 16 | 17 | var shouldShowAvatars: Bool { 18 | reaction.count <= avatarMaxNumber && !images.isEmpty 19 | } 20 | 21 | var bgColor: AnyShapeStyle { 22 | reaction.isSelected ? AnyShapeStyle(.blue) : 23 | AnyShapeStyle(.white 24 | .opacity(blurredBg ? 0.8 : 0.2) 25 | .blendMode(blurredBg ? .overlay : .normal) 26 | ) 27 | } 28 | 29 | var body: some View { 30 | HStack(spacing: 2) { 31 | Text(reaction.emoji) 32 | .frame(minWidth: 20) 33 | 34 | if shouldShowAvatars { 35 | avatarsView() 36 | } else { 37 | Text("\(reaction.count)") 38 | } 39 | } 40 | .padding(.vertical, 3) 41 | .padding(.horizontal, 6) 42 | .background { 43 | ZStack { 44 | if blurredBg { 45 | Capsule() 46 | .foregroundStyle(.ultraThinMaterial) 47 | } 48 | Capsule() 49 | .foregroundStyle(bgColor) 50 | } 51 | } 52 | .task { 53 | if reaction.count <= 3 { 54 | await loadUserImages() 55 | } 56 | } 57 | } 58 | 59 | @ViewBuilder 60 | func avatarsView() -> some View { 61 | HStack(spacing: 0) { 62 | ForEach(images.indices, id: \.self) { index in 63 | avatar(index) 64 | .padding(.leading, index == 0 ? 0 : -8) 65 | .zIndex(Double(images.count - index)) 66 | } 67 | } 68 | } 69 | 70 | @ViewBuilder 71 | func avatar(_ index: Int) -> some View { 72 | let mask = Rectangle() 73 | .overlay { 74 | Circle() 75 | .frame(width: 21, height: 21) 76 | .blendMode(.destinationOut) 77 | .offset(x: -11) 78 | } 79 | 80 | AvatarView(image: images[index]) 81 | .frame(width: 20, height: 20) 82 | .if(index != 0) { view in 83 | view.mask { mask } 84 | } 85 | } 86 | 87 | func loadUserImages() async { 88 | for userId in reaction.recentUsers { 89 | guard let user = try? await TDLibManager.shared.client?.getUser(userId: userId) else { return } 90 | guard let photo = user.toAvatarModel().avatarImage else { return } 91 | await MainActor.run { 92 | images.append(photo) 93 | } 94 | } 95 | } 96 | } 97 | 98 | struct ReactionModel: Hashable { 99 | let emoji: String 100 | let count: Int 101 | let isSelected: Bool 102 | var recentUsers: [Int64] = [] 103 | } 104 | 105 | #Preview { 106 | VStack { 107 | ReactionView(reaction: ReactionModel(emoji: "🔥", count: 3, isSelected: true)) 108 | ReactionView(reaction: ReactionModel(emoji: "❤️", count: 1, isSelected: false)) 109 | ReactionView(reaction: ReactionModel(emoji: "👍", count: 4, isSelected: false)) 110 | } 111 | .scaleEffect(1.5) 112 | } 113 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/Subviews/Message/Components/MessageBubbleShape.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BubbleShape.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 24/05/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MessageBubbleShape: Shape { 11 | let isOutgoing : Bool 12 | func path(in rect: CGRect) -> Path { 13 | let width = rect.width 14 | let height = rect.height 15 | 16 | let bezierPath = UIBezierPath() 17 | if !isOutgoing { 18 | bezierPath.move(to: CGPoint(x: 20, y: height)) 19 | bezierPath.addLine(to: CGPoint(x: width - 15, y: height)) 20 | bezierPath.addCurve(to: CGPoint(x: width, y: height - 15), controlPoint1: CGPoint(x: width - 8, y: height), controlPoint2: CGPoint(x: width, y: height - 8)) 21 | bezierPath.addLine(to: CGPoint(x: width, y: 15)) 22 | bezierPath.addCurve(to: CGPoint(x: width - 15, y: 0), controlPoint1: CGPoint(x: width, y: 8), controlPoint2: CGPoint(x: width - 8, y: 0)) 23 | bezierPath.addLine(to: CGPoint(x: 20, y: 0)) 24 | bezierPath.addCurve(to: CGPoint(x: 5, y: 15), controlPoint1: CGPoint(x: 12, y: 0), controlPoint2: CGPoint(x: 5, y: 8)) 25 | bezierPath.addLine(to: CGPoint(x: 5, y: height - 10)) 26 | bezierPath.addCurve(to: CGPoint(x: 0, y: height), controlPoint1: CGPoint(x: 5, y: height - 1), controlPoint2: CGPoint(x: 0, y: height)) 27 | bezierPath.addLine(to: CGPoint(x: -1, y: height)) 28 | bezierPath.addCurve(to: CGPoint(x: 12, y: height - 4), controlPoint1: CGPoint(x: 4, y: height + 1), controlPoint2: CGPoint(x: 8, y: height - 1)) 29 | bezierPath.addCurve(to: CGPoint(x: 20, y: height), controlPoint1: CGPoint(x: 15, y: height), controlPoint2: CGPoint(x: 20, y: height)) 30 | } else { 31 | bezierPath.move(to: CGPoint(x: width - 20, y: height)) 32 | bezierPath.addLine(to: CGPoint(x: 15, y: height)) 33 | bezierPath.addCurve(to: CGPoint(x: 0, y: height - 15), controlPoint1: CGPoint(x: 8, y: height), controlPoint2: CGPoint(x: 0, y: height - 8)) 34 | bezierPath.addLine(to: CGPoint(x: 0, y: 15)) 35 | bezierPath.addCurve(to: CGPoint(x: 15, y: 0), controlPoint1: CGPoint(x: 0, y: 8), controlPoint2: CGPoint(x: 8, y: 0)) 36 | bezierPath.addLine(to: CGPoint(x: width - 20, y: 0)) 37 | bezierPath.addCurve(to: CGPoint(x: width - 5, y: 15), controlPoint1: CGPoint(x: width - 12, y: 0), controlPoint2: CGPoint(x: width - 5, y: 8)) 38 | bezierPath.addLine(to: CGPoint(x: width - 5, y: height - 12)) 39 | bezierPath.addCurve(to: CGPoint(x: width, y: height), controlPoint1: CGPoint(x: width - 5, y: height - 1), controlPoint2: CGPoint(x: width, y: height)) 40 | bezierPath.addLine(to: CGPoint(x: width + 1, y: height)) 41 | bezierPath.addCurve(to: CGPoint(x: width - 12, y: height - 4), controlPoint1: CGPoint(x: width - 4, y: height + 1), controlPoint2: CGPoint(x: width - 8, y: height - 1)) 42 | bezierPath.addCurve(to: CGPoint(x: width - 20, y: height), controlPoint1: CGPoint(x: width - 15, y: height), controlPoint2: CGPoint(x: width - 20, y: height)) 43 | } 44 | return Path(bezierPath.cgPath) 45 | } 46 | } 47 | 48 | #Preview { 49 | VStack { 50 | MessageBubbleShape(isOutgoing: false) 51 | .frame(width: 100, height: 50) 52 | .foregroundStyle(.gray) 53 | .frame(maxWidth: .infinity, alignment: .leading) 54 | MessageBubbleShape(isOutgoing: true) 55 | .frame(width: 100, height: 50) 56 | .foregroundStyle(.blue) 57 | .frame(maxWidth: .infinity, alignment: .trailing) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Mercury.xcodeproj/xcshareddata/xcschemes/Mercury Watch App.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 30 | 36 | 37 | 38 | 39 | 40 | 46 | 47 | 57 | 59 | 65 | 66 | 67 | 68 | 74 | 76 | 82 | 83 | 84 | 85 | 87 | 88 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /Mercury Watch App/Old/OldPages/AudioMessage/AudioMessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecordingView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 21/06/24. 6 | // 7 | 8 | import SwiftUI 9 | import Charts 10 | import AVFAudio 11 | import TDLibKit 12 | import DSWaveformImageViews 13 | 14 | struct AudioMessageView_Old: View { 15 | 16 | @StateObject var vm: AudioMessageViewModel_Old 17 | @Binding var isPresented: Bool 18 | var onSend: (URL, Double) -> Void 19 | 20 | init(isPresented: Binding, action: Binding, chat: ChatCellModel_Old, onSend: @escaping (URL, Double) -> Void ) { 21 | self.onSend = onSend 22 | self._vm = StateObject(wrappedValue: AudioMessageViewModel_Old(chat: chat, action: action)) 23 | self._isPresented = isPresented 24 | } 25 | 26 | var elapsedTime: String { 27 | let time = (vm.state == .playStarted ? vm.player?.elapsedTime : vm.recorder.elapsedTime) ?? 0 28 | let seconds = Int(time.truncatingRemainder(dividingBy: 60)) 29 | let minutes = Int(time / 60) 30 | return String(format:"%02d:%02d", minutes, seconds) 31 | } 32 | 33 | var mainActionIcon: String { 34 | switch vm.state { 35 | case .recStarted: 36 | return "square.fill" 37 | case .recStopped, .playPaused: 38 | return "play.fill" 39 | case .playStarted: 40 | return "pause.fill" 41 | case .sending: 42 | return "ellipsis" 43 | } 44 | } 45 | 46 | var mainActionTitle: String { 47 | switch vm.state { 48 | case .recStarted: 49 | return "Stop" 50 | case .recStopped, .playPaused: 51 | return "Play" 52 | case .playStarted: 53 | return "Pause" 54 | case .sending: 55 | return "Loading" 56 | } 57 | } 58 | 59 | var body: some View { 60 | 61 | WaveformLiveCanvas( 62 | samples: vm.recorder.waveformSamples, 63 | configuration: .init( 64 | style: .striped(), 65 | verticalScalingFactor: 0.3 66 | ), 67 | shouldDrawSilencePadding: true 68 | ) 69 | .overlay { 70 | if vm.isLoadingPlayerWaveform { 71 | ProgressView().background(.black.opacity(0.5)) 72 | } 73 | } 74 | .navigationTitle(elapsedTime) 75 | .defaultScrollAnchor(.bottom) 76 | .toolbar { 77 | ToolbarItem(placement: .topBarTrailing) { 78 | Button("Send", systemImage: "arrow.up") { 79 | isPresented = false 80 | if vm.didPressSendButton() { 81 | onSend(vm.filePath, vm.recorder.elapsedTime) 82 | } 83 | } 84 | .foregroundStyle(.white, .blue) 85 | } 86 | 87 | ToolbarItemGroup(placement: .bottomBar) { 88 | 89 | Button( 90 | mainActionTitle, 91 | systemImage: mainActionIcon 92 | ) { 93 | vm.didPressMainAction() 94 | } 95 | .controlSize(.large) 96 | .foregroundStyle(.white, .blue) 97 | 98 | } 99 | } 100 | .task { 101 | // Dismiss audio message if no recording permission 102 | isPresented = await vm.onAppear() 103 | } 104 | .onDisappear { 105 | vm.onDisappear() 106 | } 107 | 108 | } 109 | 110 | } 111 | 112 | //#Preview { 113 | // Text("background") 114 | // .sheet(isPresented: .constant(true), content: { 115 | // AudioMessageView(isPresented: .constant(true), chat: .preview(), sendVM: <#SendMessageViewModel#>) 116 | // }) 117 | //} 118 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/Subpages/VoiceNoteRecord/AudioMessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecordingView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 21/06/24. 6 | // 7 | 8 | import SwiftUI 9 | import Charts 10 | import AVFAudio 11 | import TDLibKit 12 | import DSWaveformImageViews 13 | 14 | struct AudioMessageView_Old: View { 15 | 16 | @StateObject var vm: AudioMessageViewModel_Old 17 | @Binding var isPresented: Bool 18 | var onSend: (URL, Double) -> Void 19 | 20 | init(isPresented: Binding, action: Binding, chat: ChatCellModel_Old, onSend: @escaping (URL, Double) -> Void ) { 21 | self.onSend = onSend 22 | self._vm = StateObject(wrappedValue: AudioMessageViewModel_Old(chat: chat, action: action)) 23 | self._isPresented = isPresented 24 | } 25 | 26 | var elapsedTime: String { 27 | let time = (vm.state == .playStarted ? vm.player?.elapsedTime : vm.recorder.elapsedTime) ?? 0 28 | let seconds = Int(time.truncatingRemainder(dividingBy: 60)) 29 | let minutes = Int(time / 60) 30 | return String(format:"%02d:%02d", minutes, seconds) 31 | } 32 | 33 | var mainActionIcon: String { 34 | switch vm.state { 35 | case .recStarted: 36 | return "square.fill" 37 | case .recStopped, .playPaused: 38 | return "play.fill" 39 | case .playStarted: 40 | return "pause.fill" 41 | case .sending: 42 | return "ellipsis" 43 | } 44 | } 45 | 46 | var mainActionTitle: String { 47 | switch vm.state { 48 | case .recStarted: 49 | return "Stop" 50 | case .recStopped, .playPaused: 51 | return "Play" 52 | case .playStarted: 53 | return "Pause" 54 | case .sending: 55 | return "Loading" 56 | } 57 | } 58 | 59 | var body: some View { 60 | 61 | WaveformLiveCanvas( 62 | samples: vm.recorder.waveformSamples, 63 | configuration: .init( 64 | style: .striped(), 65 | verticalScalingFactor: 0.3 66 | ), 67 | shouldDrawSilencePadding: true 68 | ) 69 | .overlay { 70 | if vm.isLoadingPlayerWaveform { 71 | ProgressView().background(.black.opacity(0.5)) 72 | } 73 | } 74 | .navigationTitle(elapsedTime) 75 | .defaultScrollAnchor(.bottom) 76 | .toolbar { 77 | ToolbarItem(placement: .topBarTrailing) { 78 | Button("Send", systemImage: "arrow.up") { 79 | isPresented = false 80 | if vm.didPressSendButton() { 81 | onSend(vm.filePath, vm.recorder.elapsedTime) 82 | } 83 | } 84 | .foregroundStyle(.white, .blue) 85 | } 86 | 87 | ToolbarItemGroup(placement: .bottomBar) { 88 | 89 | Button( 90 | mainActionTitle, 91 | systemImage: mainActionIcon 92 | ) { 93 | vm.didPressMainAction() 94 | } 95 | .controlSize(.large) 96 | .foregroundStyle(.white, .blue) 97 | 98 | } 99 | } 100 | .task { 101 | // Dismiss audio message if no recording permission 102 | isPresented = await vm.onAppear() 103 | } 104 | .onDisappear { 105 | vm.onDisappear() 106 | } 107 | 108 | } 109 | 110 | } 111 | 112 | //#Preview { 113 | // Text("background") 114 | // .sheet(isPresented: .constant(true), content: { 115 | // AudioMessageView(isPresented: .constant(true), chat: .preview(), sendVM: <#SendMessageViewModel#>) 116 | // }) 117 | //} 118 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/Login/LoginPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginPage.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 02/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LoginPage: View { 11 | @State 12 | @Mockable(mockInit: LoginViewModelMock.init) 13 | var vm = LoginViewModel.init 14 | 15 | var body: some View { 16 | NavigationStack { 17 | VStack { 18 | QR() 19 | Text(vm.statusMessage) 20 | .padding(.top) 21 | .padding(.bottom, vm.showFullscreenQR ? 0 : -20) 22 | } 23 | .containerBackground(for: .navigation) { 24 | background() 25 | } 26 | .navigationTitle { 27 | Text("Mercury") 28 | .foregroundStyle(vm.showFullscreenQR ? .white : .blue) 29 | .opacity(vm.showFullscreenQR ? 0 : 1) 30 | } 31 | .toolbar { 32 | ToolbarItem(placement: .topBarLeading) { 33 | Button( 34 | "Info", 35 | systemImage: "info", 36 | action: vm.didPressInfoButton 37 | ) 38 | .opacity(vm.showFullscreenQR ? 0 : 1) 39 | } 40 | } 41 | .sheet( 42 | isPresented: $vm.showTutorialView, 43 | content: tutorialView 44 | ) 45 | .sheet(isPresented: $vm.showPasswordView) { 46 | passwordView() 47 | } 48 | } 49 | .onChange( 50 | of: vm.showPasswordView, 51 | vm.didChangeShowPasswordValue 52 | ) 53 | .overlay { 54 | if AppState.shared.isAuthenticated == nil { 55 | loader() 56 | } 57 | } 58 | } 59 | 60 | @ViewBuilder 61 | func QR() -> some View { 62 | QRCodeView(text: vm.qrCodeLink) { 63 | ProgressView() 64 | } 65 | .aspectRatio( 66 | vm.showFullscreenQR ? 0.75 : 1, 67 | contentMode: vm.showFullscreenQR ? .fill : .fit 68 | ) 69 | .ignoresSafeArea(edges: vm.showFullscreenQR ? .all : .bottom) 70 | .padding(.top) 71 | .onTapGesture(perform: vm.didPressQR) 72 | } 73 | 74 | @ViewBuilder 75 | func tutorialView() -> some View { 76 | ScrollView { 77 | StepView(steps: vm.tutorialSteps) 78 | Divider() 79 | Text("If you can't scan the QR code:") 80 | .foregroundStyle(.secondary) 81 | .padding() 82 | Button("Demo", action: vm.didPressDemoButton) 83 | } 84 | .navigationTitle("Info") 85 | .scenePadding(.horizontal) 86 | } 87 | 88 | @ViewBuilder 89 | func passwordView() -> some View { 90 | PasswordView( 91 | password: $vm.password, 92 | model: vm.passwordModel, 93 | onSubmit: vm.validatePassword 94 | ) 95 | .overlay { 96 | if vm.isValidatingPassword { 97 | loader() 98 | } 99 | } 100 | } 101 | 102 | @ViewBuilder 103 | func loader() -> some View { 104 | ZStack { 105 | Rectangle() 106 | .foregroundStyle(.thinMaterial) 107 | .ignoresSafeArea(edges: .all) 108 | ProgressView() 109 | } 110 | } 111 | 112 | @ViewBuilder 113 | func background() -> some View { 114 | 115 | let gradient = Gradient( 116 | colors: [ 117 | .bgBlue, 118 | .bgBlue.opacity(0.2) 119 | ] 120 | ) 121 | 122 | Rectangle() 123 | .foregroundStyle(gradient) 124 | } 125 | 126 | } 127 | 128 | #Preview(traits: .mock()) { 129 | LoginPage() 130 | } 131 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/Subviews/ReactionsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReactionsView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 10/08/24. 6 | // 7 | 8 | import SwiftUI 9 | import TDLibKit 10 | 11 | struct ReactionsView: View { 12 | var reaction: ReactionModel 13 | var blurredBg: Bool = false 14 | @State private var images: [TDImage] = [] 15 | 16 | var shouldShowAvatars: Bool { 17 | reaction.count <= 3 && !images.isEmpty 18 | } 19 | 20 | var bgColor: AnyShapeStyle { 21 | reaction.isSelected ? AnyShapeStyle(.blue) : 22 | AnyShapeStyle(.white 23 | .opacity(blurredBg ? 0.8 : 0.2) 24 | .blendMode(blurredBg ? .overlay : .normal) 25 | ) 26 | } 27 | 28 | var body: some View { 29 | HStack(spacing: 2) { 30 | Text(reaction.emoji) 31 | .frame(minWidth: 20) 32 | 33 | if shouldShowAvatars { 34 | avatarsView() 35 | } else { 36 | Text("\(reaction.count)") 37 | } 38 | } 39 | .padding(.vertical, 3) 40 | .padding(.horizontal, 6) 41 | .background { 42 | ZStack { 43 | if blurredBg { 44 | Capsule() 45 | .foregroundStyle(.ultraThinMaterial) 46 | } 47 | Capsule() 48 | .foregroundStyle(bgColor) 49 | } 50 | } 51 | .task { 52 | if reaction.count <= 3 { 53 | await loadUserImages() 54 | } 55 | } 56 | } 57 | 58 | @ViewBuilder 59 | func avatarsView() -> some View { 60 | HStack(spacing: 0) { 61 | ForEach(images.indices, id: \.self) { index in 62 | AvatarView_Old(image: images[index]) 63 | .frame(width: 20, height: 20) 64 | .if(index != 0) { view in 65 | view.mask { 66 | Rectangle() 67 | .overlay { 68 | Circle() 69 | .frame(width: 21, height: 21) 70 | .blendMode(.destinationOut) 71 | .offset(x: -11) 72 | } 73 | } 74 | } 75 | .padding(.leading, index == 0 ? 0 : -8) 76 | .zIndex(Double(images.count - index)) 77 | 78 | } 79 | } 80 | } 81 | 82 | func loadUserImages() async { 83 | 84 | guard !isPreview else { 85 | let tmpImages = [TDImageMock("tim"), TDImageMock("craig"), TDImageMock("lisa")] 86 | for i in 0 ..< reaction.count { 87 | images.append(tmpImages[i]) 88 | } 89 | return 90 | } 91 | 92 | for userId in reaction.recentUsers { 93 | guard let user = try? await TDLibManager.shared.client?.getUser(userId: userId) else { return } 94 | guard let photo = user.profilePhoto else { return } 95 | await MainActor.run { 96 | images.append(photo) 97 | } 98 | } 99 | } 100 | } 101 | 102 | struct ReactionModel: Hashable { 103 | let emoji: String 104 | let count: Int 105 | let isSelected: Bool 106 | var recentUsers: [Int64] = [] 107 | } 108 | 109 | #Preview { 110 | VStack { 111 | ReactionsView(reaction: ReactionModel(emoji: "🔥", count: 3, isSelected: true)) 112 | ReactionsView(reaction: ReactionModel(emoji: "❤️", count: 1, isSelected: false)) 113 | ReactionsView(reaction: ReactionModel(emoji: "👍", count: 4, isSelected: false)) 114 | } 115 | .scaleEffect(1.5) 116 | } 117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/xcode,swift,macos 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=xcode,swift,macos 3 | 4 | ### macOS ### 5 | # General 6 | .DS_Store 7 | .AppleDouble 8 | .LSOverride 9 | 10 | # Icon must end with two \r 11 | Icon 12 | 13 | 14 | # Thumbnails 15 | ._* 16 | 17 | # Files that might appear in the root of a volume 18 | .DocumentRevisions-V100 19 | .fseventsd 20 | .Spotlight-V100 21 | .TemporaryItems 22 | .Trashes 23 | .VolumeIcon.icns 24 | .com.apple.timemachine.donotpresent 25 | 26 | # Directories potentially created on remote AFP share 27 | .AppleDB 28 | .AppleDesktop 29 | Network Trash Folder 30 | Temporary Items 31 | .apdisk 32 | 33 | ### macOS Patch ### 34 | # iCloud generated files 35 | *.icloud 36 | 37 | ### Swift ### 38 | # Xcode 39 | # 40 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 41 | 42 | ## User settings 43 | xcuserdata/ 44 | 45 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 46 | *.xcscmblueprint 47 | *.xccheckout 48 | 49 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 50 | build/ 51 | DerivedData/ 52 | *.moved-aside 53 | *.pbxuser 54 | !default.pbxuser 55 | *.mode1v3 56 | !default.mode1v3 57 | *.mode2v3 58 | !default.mode2v3 59 | *.perspectivev3 60 | !default.perspectivev3 61 | 62 | ## Obj-C/Swift specific 63 | *.hmap 64 | 65 | ## App packaging 66 | *.ipa 67 | *.dSYM.zip 68 | *.dSYM 69 | 70 | ## Playgrounds 71 | timeline.xctimeline 72 | playground.xcworkspace 73 | 74 | # Swift Package Manager 75 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 76 | # Packages/ 77 | # Package.pins 78 | # Package.resolved 79 | # *.xcodeproj 80 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 81 | # hence it is not needed unless you have added a package configuration file to your project 82 | # .swiftpm 83 | 84 | .build/ 85 | 86 | # CocoaPods 87 | # We recommend against adding the Pods directory to your .gitignore. However 88 | # you should judge for yourself, the pros and cons are mentioned at: 89 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 90 | # Pods/ 91 | # Add this line if you want to avoid checking in source code from the Xcode workspace 92 | # *.xcworkspace 93 | 94 | # Carthage 95 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 96 | # Carthage/Checkouts 97 | 98 | Carthage/Build/ 99 | 100 | # Accio dependency management 101 | Dependencies/ 102 | .accio/ 103 | 104 | # fastlane 105 | # It is recommended to not store the screenshots in the git repo. 106 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 107 | # For more information about the recommended setup visit: 108 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 109 | 110 | fastlane/report.xml 111 | fastlane/Preview.html 112 | fastlane/screenshots/**/*.png 113 | fastlane/test_output 114 | 115 | # Code Injection 116 | # After new code Injection tools there's a generated folder /iOSInjectionProject 117 | # https://github.com/johnno1962/injectionforxcode 118 | 119 | iOSInjectionProject/ 120 | 121 | ### Xcode ### 122 | 123 | ## Xcode 8 and earlier 124 | 125 | ### Xcode Patch ### 126 | *.xcodeproj/* 127 | !*.xcodeproj/project.pbxproj 128 | !*.xcodeproj/xcshareddata/ 129 | !*.xcodeproj/project.xcworkspace/ 130 | !*.xcworkspace/contents.xcworkspacedata 131 | /*.gcno 132 | **/xcshareddata/WorkspaceSettings.xcsettings 133 | 134 | # End of https://www.toptal.com/developers/gitignore/api/xcode,swift,macos 135 | 136 | SecretService.swift 137 | Mercury.xcodeproj/project.xcworkspace/contents.xcworkspacedata 138 | Mercury.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist 139 | Mercury.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved 140 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/Subpages/VoiceNoteRecord/VoiceNoteRecordSubpage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecordingView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 21/06/24. 6 | // 7 | 8 | import SwiftUI 9 | import Charts 10 | import AVFAudio 11 | import TDLibKit 12 | import DSWaveformImageViews 13 | 14 | struct VoiceNoteRecordSubpage: View { 15 | 16 | @State 17 | @Mockable 18 | var vm: VoiceNoteRecordViewModel 19 | 20 | @Binding var isPresented: Bool 21 | 22 | init(isPresented: Binding, action: Binding, sendService: SendMessageService) { 23 | self._isPresented = isPresented 24 | _vm = Mockable.state( 25 | value: { VoiceNoteRecordViewModel(action: action, sendService: sendService, isPresented: isPresented) }, 26 | mock: { VoiceNoteRecordViewModelMock() } 27 | ) 28 | } 29 | 30 | var elapsedTime: String { 31 | let time = (vm.state == .playStarted ? vm.player?.elapsedTime : vm.recorder.elapsedTime) ?? 0 32 | let seconds = Int(time.truncatingRemainder(dividingBy: 60)) 33 | let minutes = Int(time / 60) 34 | return String(format:"%02d:%02d", minutes, seconds) 35 | } 36 | 37 | var mainActionIcon: String { 38 | switch vm.state { 39 | case .recStarted: 40 | return "square.fill" 41 | case .recStopped, .playPaused: 42 | return "play.fill" 43 | case .playStarted: 44 | return "pause.fill" 45 | case .sending: 46 | return "ellipsis" 47 | } 48 | } 49 | 50 | var mainActionTitle: String { 51 | switch vm.state { 52 | case .recStarted: 53 | return "Stop" 54 | case .recStopped, .playPaused: 55 | return "Play" 56 | case .playStarted: 57 | return "Pause" 58 | case .sending: 59 | return "Loading" 60 | } 61 | } 62 | 63 | var body: some View { 64 | 65 | WaveformLiveCanvas( 66 | samples: vm.recorder.waveformSamples, 67 | configuration: .init( 68 | style: .striped(), 69 | verticalScalingFactor: 0.3 70 | ), 71 | shouldDrawSilencePadding: true 72 | ) 73 | .overlay { 74 | if vm.isLoadingPlayerWaveform || vm.state == .sending { 75 | ProgressView().background(.black.opacity(0.5)) 76 | } 77 | } 78 | .navigationTitle(elapsedTime) 79 | .defaultScrollAnchor(.bottom) 80 | .toolbar { 81 | ToolbarItem(placement: .topBarTrailing) { 82 | Button("Send", systemImage: "arrow.up") { 83 | vm.didPressSendButton() 84 | } 85 | .foregroundStyle(.white, .blue) 86 | } 87 | 88 | ToolbarItemGroup(placement: .bottomBar) { 89 | 90 | Button( 91 | mainActionTitle, 92 | systemImage: mainActionIcon 93 | ) { 94 | vm.didPressMainAction() 95 | } 96 | .controlSize(.large) 97 | .foregroundStyle(.white, .blue) 98 | 99 | } 100 | } 101 | .task { 102 | // Dismiss audio message if no recording permission 103 | isPresented = await vm.onAppear() 104 | } 105 | .onDisappear { 106 | vm.onDisappear() 107 | } 108 | 109 | } 110 | 111 | } 112 | 113 | #Preview { 114 | Rectangle() 115 | .foregroundStyle(.blue.opacity(0.8)) 116 | .ignoresSafeArea() 117 | .sheet(isPresented: .constant(true), content: { 118 | VoiceNoteRecordSubpage( 119 | isPresented: .constant(true), 120 | action: .constant(nil), 121 | sendService: SendMessageServiceMock() 122 | ) 123 | }) 124 | } 125 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Services/RecorderService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecordingViewModel.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 27/06/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import AVFoundation 11 | 12 | @Observable 13 | class RecorderService { 14 | 15 | static let updateInterval: Double = 0.01 16 | var waveformSamples: [Float] = [] 17 | var elapsedTime: TimeInterval = .zero 18 | 19 | private var audioRecorder: AVAudioRecorder? 20 | private var recFilePath: URL 21 | private let logger = LoggerService(RecorderService.self) 22 | var waveformTimer: Timer? 23 | 24 | init(recFilePath: URL) { 25 | 26 | // Recording file path 27 | self.recFilePath = recFilePath 28 | 29 | waveformTimer = Timer.scheduledTimer( 30 | withTimeInterval: RecorderService.updateInterval, 31 | repeats: true, 32 | block: { [weak self] _ in 33 | self?.updateWaveform() 34 | } 35 | ) 36 | } 37 | 38 | deinit { 39 | waveformTimer?.invalidate() 40 | audioRecorder?.stop() 41 | audioRecorder = nil 42 | } 43 | 44 | func initAudioRecorder() { 45 | do { 46 | 47 | let audioSession = AVAudioSession.sharedInstance() 48 | try audioSession.setCategory(.playAndRecord, mode: .default) 49 | try audioSession.setActive(true) 50 | 51 | let recSettings = [ 52 | AVFormatIDKey: Int(kAudioFormatMPEG4AAC), 53 | AVSampleRateKey: 48000, 54 | AVEncoderBitRateKey: 256000, 55 | AVNumberOfChannelsKey: 1, 56 | AVEncoderAudioQualityKey: .max 57 | ] 58 | 59 | audioRecorder = try AVAudioRecorder(url: recFilePath, settings: recSettings) 60 | audioRecorder?.isMeteringEnabled = true 61 | audioRecorder?.prepareToRecord() 62 | 63 | } catch { 64 | logger.log(error, level: .error) 65 | return 66 | } 67 | } 68 | 69 | func startDataTimer() { 70 | guard let timer = self.waveformTimer else { return } 71 | let queue = DispatchQueue.global(qos: .userInteractive) 72 | 73 | queue.async { 74 | RunLoop.current.add(timer, forMode: .default) 75 | RunLoop.current.run() 76 | } 77 | } 78 | 79 | func updateWaveform() { 80 | 81 | guard audioRecorder?.isRecording ?? false 82 | else { return } 83 | 84 | audioRecorder?.updateMeters() 85 | 86 | // Gives -160...0 values 87 | guard let decibel = audioRecorder?.averagePower(forChannel: 0) 88 | else { return } 89 | 90 | // Normalization parameters 91 | typealias MinMax = (min: Float, max: Float) 92 | let normalizationFrom: MinMax = (-60, -20) 93 | let normalizationTo: MinMax = (1.0, 0.1) 94 | 95 | // Calculate the normalized value 96 | let normalizedValue = (decibel - normalizationFrom.min) / (normalizationFrom.max - normalizationFrom.min) 97 | 98 | // Scale the normalized value to the end range 99 | let scaledNormalizedValue = (normalizedValue * (normalizationTo.max - normalizationTo.min)) + normalizationTo.min 100 | 101 | self.waveformSamples.append(scaledNormalizedValue) 102 | self.elapsedTime += RecorderService.updateInterval 103 | 104 | } 105 | 106 | func startRecordingAudio() { 107 | logger.log("Start recording") 108 | audioRecorder?.record() 109 | startDataTimer() 110 | } 111 | 112 | func stopRecordingAudio() { 113 | logger.log("Stop recording") 114 | audioRecorder?.stop() 115 | waveformTimer?.invalidate() 116 | } 117 | 118 | func clearWaveform() { 119 | waveformSamples = [] 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/ChatDetailViewModel+Interactions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatDetailViewModel+Interactions.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 08/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension ChatDetailViewModel { 11 | func onPressLoadMore(_ proxy: ScrollViewProxy) { 12 | 13 | withAnimation { 14 | self.isLoadingMoreMessages = true 15 | } 16 | 17 | Task.detached { 18 | let lastMessage = self.messages.first 19 | let newMessages = await self.requestMessages(fromId: lastMessage?.id) 20 | 21 | await MainActor.run { 22 | 23 | for msg in newMessages { 24 | self.insertMessage(at: .first, message: msg) 25 | } 26 | 27 | withAnimation { 28 | proxy.scrollTo(lastMessage?.id, anchor: .bottom) 29 | self.isLoadingMoreMessages = false 30 | } 31 | } 32 | } 33 | } 34 | 35 | func onPressAvatar() { 36 | self.showChatInfoView = true 37 | } 38 | 39 | func onPressTextInsert() { 40 | 41 | self.chatAction = .chatActionTyping 42 | 43 | WKExtension.shared() 44 | .visibleInterfaceController? 45 | .presentTextInputController(withSuggestions: [], 46 | allowedInputMode: .allowEmoji) { result in 47 | 48 | self.chatAction = nil 49 | guard let result = result as? [String], 50 | let text = result.first 51 | else { return } 52 | 53 | self.sendService?.sendTextMessage(text) 54 | 55 | } 56 | } 57 | 58 | func onPressVoiceRecording() { 59 | self.chatAction = .chatActionRecordingVoiceNote 60 | self.showAudioMessageView = true 61 | } 62 | 63 | func onPressStickersSelection() { 64 | self.showStickersView = true 65 | } 66 | 67 | func onDublePressOf(_ message: MessageModel) { 68 | selectedMessage = message 69 | showOptionsView = true 70 | } 71 | 72 | func onMessageListAppear(_ proxy: ScrollViewProxy) { 73 | #warning("disabling automatic scroll until future development") 74 | return 75 | guard let lastReadInboxMessageId else { return } 76 | 77 | // Scroll to the first unread message 78 | for message in messages { 79 | 80 | if case .pill(_,_) = message.content { continue } 81 | 82 | if message.id >= lastReadInboxMessageId { 83 | proxy.scrollTo(message.id) 84 | break 85 | } 86 | } 87 | } 88 | 89 | func onOpenChat() { 90 | chatAction = nil 91 | Task.detached(priority: .background) { 92 | do { 93 | try await TDLibManager.shared.client?.openChat(chatId: self.chatId) 94 | } catch { 95 | self.logger.log(error, level: .error) 96 | } 97 | } 98 | } 99 | 100 | func onCloseChat() { 101 | Task.detached(priority: .background) { 102 | do { 103 | try await TDLibManager.shared.client?.closeChat(chatId: self.chatId) 104 | } catch { 105 | self.logger.log(error, level: .error) 106 | } 107 | } 108 | } 109 | 110 | func onMessageAppear(_ message: MessageModel) { 111 | 112 | // Pill messages doesn't need to be marked as seen 113 | if case .pill(_,_) = message.content { return } 114 | 115 | Task.detached(priority: .background) { 116 | do { 117 | try await TDLibManager.shared.client?.viewMessages( 118 | chatId: self.chatId, 119 | forceRead: true, 120 | messageIds: [message.id], 121 | source: nil 122 | ) 123 | } catch { 124 | self.logger.log(error, level: .error) 125 | } 126 | } 127 | } 128 | 129 | } 130 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Services/PlayerService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayingViewModel.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 27/06/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import AVFoundation 11 | import SwiftOGG 12 | 13 | @Observable 14 | class PlayerService: NSObject { 15 | 16 | static let updateInterval: Double = 0.20 17 | 18 | var audioDuration: TimeInterval 19 | var elapsedTime: TimeInterval = .zero 20 | var isPlaying: Bool = false 21 | var isLoading: Bool 22 | 23 | private var audioPlayer: AVAudioPlayer? 24 | private let logger = LoggerService(PlayerService.self) 25 | var elapsedTimeTimer: Timer? 26 | var filePath: URL 27 | 28 | init(audioFilePath: URL, delegate: AVAudioPlayerDelegate? = nil) throws { 29 | 30 | self.isLoading = true 31 | self.filePath = audioFilePath 32 | self.audioDuration = 0 33 | super.init() 34 | 35 | // if audio file format is oga or ogg, convert to m4a 36 | if audioFilePath.pathExtension == "oga" || audioFilePath.pathExtension == "ogg" { 37 | let dest: URL = audioFilePath.deletingPathExtension().appendingPathExtension("m4a") 38 | 39 | // Check if file has been already converted 40 | if !FileManager.default.fileExists(atPath: dest.absoluteString) { 41 | try OGGConverter.convertOpusOGGToM4aFile(src: audioFilePath, dest: dest) 42 | } 43 | 44 | self.filePath = dest 45 | } 46 | 47 | logger.log(self.filePath.absoluteString, level: .debug) 48 | 49 | let audioSession = AVAudioSession.sharedInstance() 50 | try audioSession.setCategory(.playback, mode: .default) 51 | try audioSession.setActive(true) 52 | 53 | audioPlayer = try AVAudioPlayer(contentsOf: self.filePath) 54 | audioPlayer?.isMeteringEnabled = true 55 | audioPlayer?.volume = 1.0 56 | audioPlayer?.prepareToPlay() 57 | audioPlayer?.delegate = delegate ?? self 58 | 59 | audioDuration = audioPlayer?.duration ?? 0 60 | elapsedTimeTimer = Timer.scheduledTimer( 61 | withTimeInterval: PlayerService.updateInterval, 62 | repeats: true, 63 | block: { [weak self] _ in 64 | self?.updateElapsedTime() 65 | } 66 | ) 67 | 68 | self.isLoading = false 69 | } 70 | 71 | deinit { 72 | elapsedTimeTimer?.invalidate() 73 | audioPlayer?.stop() 74 | audioPlayer = nil 75 | removeM4aAudio() 76 | } 77 | 78 | private func removeM4aAudio() { 79 | do { 80 | try FileManager.default.removeItem(at: self.filePath) 81 | } catch { 82 | logger.log(error, level: .error) 83 | } 84 | } 85 | 86 | private func startTimer() { 87 | guard let timer = self.elapsedTimeTimer else { return } 88 | let queue = DispatchQueue.global(qos: .userInteractive) 89 | 90 | queue.async { 91 | RunLoop.current.add(timer, forMode: .default) 92 | RunLoop.current.run() 93 | } 94 | } 95 | 96 | func updateElapsedTime() { 97 | if isPlaying { 98 | withAnimation { 99 | self.elapsedTime += PlayerService.updateInterval 100 | } 101 | } 102 | } 103 | 104 | func startPlayingAudio() { 105 | isPlaying = true 106 | logger.log("Start playing") 107 | audioPlayer?.play() 108 | startTimer() 109 | } 110 | 111 | func pausePlayingAudio() { 112 | isPlaying = false 113 | logger.log("Pause playing") 114 | audioPlayer?.pause() 115 | } 116 | 117 | func stopPlayingAudio() { 118 | isPlaying = false 119 | elapsedTime = .zero 120 | logger.log("Stop playing") 121 | audioPlayer?.stop() 122 | } 123 | } 124 | 125 | extension PlayerService: AVAudioPlayerDelegate { 126 | func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { 127 | if flag { self.stopPlayingAudio() } 128 | } 129 | } 130 | 131 | class PlayerServiceMock: PlayerService { 132 | init() { 133 | let sampleFile = Bundle.main.url(forResource: "audio_sample", withExtension: "m4a")! 134 | try! super.init(audioFilePath: sampleFile) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/Subviews/MessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 16/05/24. 6 | // 7 | 8 | import SwiftUI 9 | import TDLibKit 10 | import MapKit 11 | 12 | struct MessageView: View { 13 | @StateObject var vm: MessageViewModel 14 | 15 | init(vm: MessageViewModelMock) { 16 | self._vm = StateObject(wrappedValue: vm) 17 | } 18 | 19 | init(message: Message, chat: ChatCellModel_Old) { 20 | self._vm = StateObject(wrappedValue: MessageViewModel(message: message, chat: chat)) 21 | } 22 | 23 | var body: some View { 24 | Group { 25 | switch vm.message.content { 26 | case .messageText(let message): 27 | MessageBubbleView { 28 | Text(message.text.attributedString) 29 | } 30 | 31 | case .messagePhoto(let message): 32 | MessageBubbleView(style: .fullScreen, caption: message.caption.text) { 33 | TdImageView(tdImage: message.photo) 34 | } 35 | 36 | case .messageVoiceNote(let message): 37 | MessageBubbleView { 38 | VoiceNoteContentView(message: message) 39 | } 40 | 41 | case .messageVideo(let message): 42 | MessageBubbleView(style: .fullScreen, caption: message.caption.text) { 43 | TdImageView(tdImage: message.video) 44 | } 45 | .overlay { 46 | Image(systemName: "play.circle.fill") 47 | .font(.largeTitle) 48 | .foregroundStyle(.white, .ultraThinMaterial) 49 | } 50 | 51 | case .messageLocation(let message): 52 | MessageBubbleView(style: .fullScreen) { 53 | LocationContentView(coordinate: CLLocationCoordinate2D(latitude: message.location.latitude, longitude: message.location.longitude)) 54 | } 55 | case .messageVenue(let message): 56 | MessageBubbleView(style: .fullScreen) { 57 | LocationContentView(venue: message.venue) 58 | } 59 | case .messagePinMessage(_): 60 | PillMessageView(description: "pinned a message") 61 | case .messageChatChangeTitle(let message): 62 | PillMessageView(description: "changed the group name to \"\(message.title)\"") 63 | case .messageChatChangePhoto(let message): 64 | VStack { 65 | PillMessageView(description: "changed group photo") 66 | TdImageView(tdImage: message.photo) 67 | .frame(width: 60, height: 60) 68 | .clipShape(RoundedRectangle(cornerRadius: 10)) 69 | } 70 | case .messageSticker(let message): 71 | MessageBubbleView(style: .hideBackground) { 72 | switch message.sticker.format { 73 | case .stickerFormatWebp: 74 | WebpStickerView(sticker: message.sticker) 75 | .frame(maxWidth: 100) 76 | .padding() 77 | case .stickerFormatTgs: 78 | TgsStickerView(sticker: message.sticker) 79 | case .stickerFormatWebm: 80 | Spacer() 81 | } 82 | 83 | } 84 | default: 85 | MessageBubbleView { 86 | Text(vm.message.description) 87 | } 88 | } 89 | } 90 | .environmentObject(vm) 91 | } 92 | } 93 | 94 | #Preview("Messages") { 95 | VStack { 96 | MessageView(vm: MessageViewModelMock(name: "Craig Federighi")) 97 | MessageView(vm: MessageViewModelMock(message: .preview(isOutgoing: true))) 98 | } 99 | } 100 | 101 | #Preview("Location") { 102 | MessageView(vm: MessageViewModelMock()) 103 | 104 | } 105 | 106 | #Preview("Loading Name") { 107 | MessageView(vm: MessageViewModelMock(showSender: true)) 108 | } 109 | 110 | #Preview("Group Photo Change") { 111 | VStack { 112 | PillMessageView(description: "changed group photo") 113 | TdImageView(tdImage: TDImageMock("astro")) 114 | .frame(width: 60, height: 60) 115 | .clipShape(RoundedRectangle(cornerRadius: 10)) 116 | } 117 | .frame(maxWidth: .infinity, maxHeight: .infinity) 118 | .background(.blue.opacity(0.3)) 119 | .environmentObject(MessageViewModelMock() as MessageViewModel) 120 | } 121 | 122 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Mockable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Mockable.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 2/11/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | 12 | /// A property wrapper that provides mockable values for testing or debugging. 13 | /// It allows for lazy initialization of both the main and mock values, depending 14 | /// on the application state. 15 | /// 16 | /// The `Mockable` wrapper can be particularly useful when you want to replace a 17 | /// real implementation with a mock for testing purposes, without needing to modify 18 | /// the wrapped property's code. 19 | /// 20 | @propertyWrapper 21 | @dynamicMemberLookup 22 | public class Mockable { 23 | 24 | /// The actual value to be used in non-mock scenarios, initialized on demand. 25 | private var value: T? 26 | 27 | /// The mock value to be used in mock scenarios, initialized on demand. 28 | private var mock: T? 29 | 30 | /// Closure used to initialize the mock value if `AppState.shared.isMock` is `true`. 31 | private let mockInit: () -> T 32 | 33 | /// Closure used to initialize the main value if `AppState.shared.isMock` is `false`. 34 | private let valueInit: () -> T 35 | 36 | /// Initializes the property wrapper with a provided main value and a mock value. 37 | /// 38 | /// This initializer allocates immediatly both the `wrappedValue` and the `mock` objects. 39 | /// 40 | /// - Parameters: 41 | /// - wrappedValue: The main value to be used in production. 42 | /// - mock: The mock value to be used when `AppState.shared.isMock` is `true`. 43 | init(wrappedValue: T, mock: T) { 44 | self.valueInit = { wrappedValue } 45 | self.mockInit = { mock } 46 | } 47 | 48 | /// Initializes the property wrapper with closures for lazy initialization 49 | /// of the main and mock values. 50 | /// 51 | /// Use this initializer when you need lazy initialization of both the mock 52 | /// and the main value to prevent unnecessary creation of the objects. 53 | /// 54 | /// - Parameters: 55 | /// - wrappedValue: A closure that returns the main value. 56 | /// - mockInit: A closure that returns the mock value. 57 | init(wrappedValue: @escaping () -> T, mockInit: @escaping () -> T) { 58 | self.valueInit = wrappedValue 59 | self.mockInit = mockInit 60 | } 61 | 62 | /// Returns either the main or mock value based on the application state. 63 | public var wrappedValue: T { 64 | get { 65 | if AppState.shared.isMock == false { 66 | if let value = value { return value } 67 | value = valueInit() 68 | return value! 69 | } 70 | 71 | if let mock = mock { return mock } 72 | self.mock = mockInit() 73 | return mock! 74 | } 75 | 76 | set { 77 | if AppState.shared.isMock == false { 78 | value = newValue 79 | } else { 80 | mock = newValue 81 | } 82 | } 83 | } 84 | 85 | public subscript(dynamicMember keyPath: KeyPath) -> U { 86 | wrappedValue[keyPath: keyPath] 87 | } 88 | 89 | public subscript(dynamicMember keyPath: WritableKeyPath) -> U { 90 | get { wrappedValue[keyPath: keyPath] } 91 | set { wrappedValue[keyPath: keyPath] = newValue } 92 | } 93 | } 94 | 95 | extension Mockable { 96 | /// A helper function to create a `State` instance of `Mockable`, wrapping both a value and a mock initializer. 97 | /// 98 | /// This function enables initializing a `@State` `@Mockable` variable later in the view’s initializer, allowing you to pass parameters to the initializer of the type `T` 99 | /// 100 | /// - Returns: A `State` instance containing the `Mockable` wrapped type. 101 | /// 102 | /// ### Usage Example 103 | /// ```swift 104 | /// @State 105 | /// @Mockable 106 | /// var vm: ViewModel 107 | /// 108 | /// init() { 109 | /// _vm = Mockable.state( 110 | /// value: { ViewModel("Value") }, 111 | /// mock: { ViewModelMock("Value") } 112 | /// ) 113 | /// } 114 | /// ``` 115 | /// 116 | /// In this example, the `vm` property is a `@State` property that uses the `@Mockable` property wrapper. 117 | /// By calling `Mockable.state(value:mock:)` within the initializer, parameters can be passed to the `ViewModel` initializers. 118 | static func state( 119 | value: @escaping () -> T, 120 | mock: @escaping () -> T 121 | ) -> State> { 122 | return State(wrappedValue: Mockable( 123 | wrappedValue: value, 124 | mockInit: mock 125 | )) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Mercury Watch App/Utils/Services/SendMessageService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SendMessageViewModel.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 28/05/24. 6 | // 7 | 8 | import Foundation 9 | import TDLibKit 10 | import SwiftOGG 11 | 12 | class SendMessageService { 13 | 14 | let chat: Chat? 15 | let logger: LoggerService 16 | 17 | init(chat: Chat?) { 18 | self.chat = chat 19 | self.logger = LoggerService(SendMessageService.self) 20 | } 21 | 22 | func sendTextMessage(_ text: String) { 23 | 24 | let formattedText: FormattedText = .init(entities: [], text: text) 25 | let message: InputMessageText = .init(clearDraft: true, linkPreviewOptions: nil, text: formattedText) 26 | let messageContent: InputMessageContent = .inputMessageText(message) 27 | 28 | Task.detached { 29 | do { 30 | let result = try await TDLibManager.shared.client?.sendMessage( 31 | chatId: self.chat?.id, 32 | inputMessageContent: messageContent, 33 | messageThreadId: nil, 34 | options: nil, 35 | replyMarkup: nil, 36 | replyTo: nil 37 | ) 38 | self.logger.log(result) 39 | } catch { 40 | self.logger.log(error, level: .error) 41 | } 42 | } 43 | } 44 | 45 | func sendVoiceNote(_ filePath: URL, _ duration: Int, didProcessAudio: @escaping () -> Void) { 46 | 47 | Task.detached { 48 | do { 49 | 50 | var audioFilePath = filePath 51 | 52 | // if audio file format is m4a, convert to ogg for faster upload 53 | if audioFilePath.pathExtension == "m4a" { 54 | let dest: URL = audioFilePath.deletingPathExtension().appendingPathExtension("ogg") 55 | 56 | // Check if file has been already converted 57 | if !FileManager.default.fileExists(atPath: dest.absoluteString) { 58 | try OGGConverter.convertM4aFileToOpusOGG(src: audioFilePath, dest: dest) 59 | } 60 | 61 | audioFilePath = dest 62 | } 63 | 64 | let audioFile: InputFile = .inputFileLocal(.init(path: audioFilePath.relativePath)) 65 | let audioWaveform = try Data(contentsOf: filePath) 66 | 67 | let audio: InputMessageVoiceNote = .init( 68 | caption: nil, 69 | duration: duration, 70 | selfDestructType: nil, 71 | voiceNote: audioFile, 72 | waveform: audioWaveform 73 | ) 74 | 75 | didProcessAudio() 76 | 77 | let result = try await TDLibManager.shared.client?.sendMessage( 78 | chatId: self.chat?.id, 79 | inputMessageContent: .inputMessageVoiceNote(audio), 80 | messageThreadId: nil, 81 | options: nil, 82 | replyMarkup: nil, 83 | replyTo: nil 84 | ) 85 | 86 | self.logger.log(result) 87 | 88 | } catch { 89 | self.logger.log(error, level: .error) 90 | } 91 | } 92 | } 93 | 94 | func sendReaction(_ emoji: String, chatId: Int64, messageId: Int64) { 95 | 96 | Task.detached { 97 | do { 98 | 99 | let emoji = ReactionTypeEmoji(emoji: emoji) 100 | let reaction: ReactionType = .reactionTypeEmoji(emoji) 101 | 102 | let result = try await TDLibManager.shared.client?.addMessageReaction( 103 | chatId: chatId, 104 | isBig: false, 105 | messageId: messageId, 106 | reactionType: reaction, 107 | updateRecentReactions: false 108 | ) 109 | 110 | self.logger.log(result) 111 | 112 | } catch { 113 | self.logger.log(error, level: .error) 114 | } 115 | } 116 | } 117 | 118 | } 119 | 120 | class SendMessageServiceMock: SendMessageService { 121 | 122 | init() { 123 | super.init(chat: nil) 124 | } 125 | 126 | override func sendTextMessage(_ text: String) {} 127 | override func sendVoiceNote(_ filePath: URL, _ duration: Int, didProcessAudio: @escaping () -> Void) { 128 | didProcessAudio() 129 | } 130 | override func sendReaction(_ emoji: String, chatId: Int64, messageId: Int64) {} 131 | } 132 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/Subpages/VoiceNoteRecord/VoiceNoteRecordViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecordingViewModel.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 21/06/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import AVFoundation 11 | import Combine 12 | import TDLibKit 13 | 14 | @Observable 15 | class VoiceNoteRecordViewModel: NSObject { 16 | 17 | /* 18 | 19 | State Diagram: 20 | ┌───────────────┬───────────────┬─────────────────┬───> sending ──> Final State 21 | │ │ │ │ 22 | Initial State ──> recStarted ───> recStopped ───> playStarted ───> playPaused 23 | ^ │ 24 | └─────────────────┘ 25 | 26 | Notice: From each state it is possible to reach the cancel 27 | state that consist in the sheet dismissal 28 | 29 | */ 30 | 31 | enum RecordingState { 32 | case recStarted, recStopped, playStarted, playPaused, sending 33 | } 34 | 35 | var state: RecordingState = .recStarted 36 | var hightlightIndex: Int? 37 | var isLoadingPlayerWaveform: Bool = false 38 | var isPresented: Binding 39 | 40 | var recorder: RecorderService 41 | var player: PlayerService? 42 | 43 | let sendService: SendMessageService 44 | let action: Binding 45 | 46 | var filePath: URL 47 | private let logger = LoggerService(VoiceNoteRecordViewModel.self) 48 | 49 | init(action: Binding, sendService: SendMessageService, isPresented: Binding) { 50 | self.sendService = sendService 51 | 52 | // Recording file path 53 | let recName = "\(UUID().uuidString).m4a" 54 | let tmpFolder = FileManager.default.tmpFolder 55 | let filePath = tmpFolder.appendingPathComponent(recName) 56 | 57 | self.filePath = filePath 58 | self.recorder = RecorderService(recFilePath: filePath) 59 | self.state = .recStarted 60 | self.action = action 61 | self.isPresented = isPresented 62 | 63 | super.init() 64 | } 65 | 66 | /// Returns true if has recording permission, false otherwise 67 | func onAppear() async -> Bool { 68 | let hasPermission = await AVAudioApplication.requestRecordPermission() 69 | if hasPermission { 70 | self.recorder.initAudioRecorder() 71 | self.recorder.startRecordingAudio() 72 | } 73 | 74 | return hasPermission 75 | } 76 | 77 | func onDisappear() { 78 | action.wrappedValue = nil 79 | } 80 | 81 | func didPressMainAction() { 82 | switch state { 83 | case .recStarted: 84 | action.wrappedValue = nil 85 | recorder.stopRecordingAudio() 86 | createPlayer() 87 | 88 | case .recStopped, .playPaused: 89 | player?.startPlayingAudio() 90 | state = .playStarted 91 | 92 | case .playStarted: 93 | player?.stopPlayingAudio() 94 | state = .playPaused 95 | 96 | default: 97 | return 98 | } 99 | } 100 | 101 | func didPressSendButton() { 102 | 103 | guard state != .sending else { return } 104 | 105 | action.wrappedValue = .chatActionUploadingVoiceNote(.init(progress: 0)) 106 | if state == .recStarted { 107 | recorder.stopRecordingAudio() 108 | } 109 | 110 | state = .sending 111 | 112 | sendService.sendVoiceNote( 113 | filePath, 114 | Int(recorder.elapsedTime) 115 | ) { 116 | self.isPresented.wrappedValue = false 117 | } 118 | } 119 | 120 | private func createPlayer() { 121 | DispatchQueue.main.async { 122 | do { 123 | self.isLoadingPlayerWaveform = true 124 | self.player = try PlayerService(audioFilePath: self.filePath, delegate: self) 125 | self.isLoadingPlayerWaveform = false 126 | } catch { 127 | self.logger.log(error, level: .error) 128 | } 129 | self.state = .recStopped 130 | } 131 | } 132 | 133 | } 134 | 135 | extension VoiceNoteRecordViewModel: AVAudioPlayerDelegate { 136 | func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { 137 | if flag { 138 | self.player?.stopPlayingAudio() 139 | self.state = .playPaused 140 | } 141 | } 142 | } 143 | 144 | class VoiceNoteRecordViewModelMock: VoiceNoteRecordViewModel { 145 | init() { 146 | super.init( 147 | action: .constant(nil), 148 | sendService: SendMessageServiceMock(), 149 | isPresented: .constant(true) 150 | ) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Mercury Watch App/Shared/Components/AvatarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AvatarView.swift 3 | // Mercury 4 | // 5 | // Created by Marco Tammaro on 03/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AvatarView: View { 11 | var model: AvatarModel 12 | 13 | init(model: AvatarModel) { 14 | self.model = model 15 | } 16 | 17 | init(image: AsyncImageModel) { 18 | self.model = AvatarModel(avatarImage: image) 19 | } 20 | 21 | var body: some View { 22 | GeometryReader { proxy in 23 | let size = proxy.size.width 24 | let onlineSize = size/5 25 | let onlineMaskSize = size/3.5 26 | let onlineOffset = CGSize( 27 | width: (onlineSize - onlineMaskSize)/2, 28 | height: (onlineSize - onlineMaskSize)/2 29 | ) 30 | 31 | circle(size) 32 | .mask { 33 | Rectangle() 34 | .overlay(alignment: .bottomTrailing) { 35 | if model.isOnline { 36 | Circle() 37 | .frame(width: onlineMaskSize) 38 | .blendMode(.destinationOut) 39 | } 40 | } 41 | } 42 | .overlay(alignment: .bottomTrailing) { 43 | if model.isOnline { 44 | Circle() 45 | .fill(.green) 46 | .frame(width: onlineSize) 47 | .offset(onlineOffset) 48 | } 49 | } 50 | } 51 | } 52 | 53 | @ViewBuilder 54 | func circle(_ size: CGFloat) -> some View { 55 | if let getImage = model.avatarImage?.getImage { 56 | AsyncView( 57 | getData: getImage, 58 | placeholder: { 59 | Group { 60 | if let thumbnail = model.avatarImage?.thumbnail { 61 | Image(uiImage: thumbnail) 62 | .resizable() 63 | .aspectRatio(contentMode: .fit) 64 | .clipShape(Circle()) 65 | } else { 66 | placeholder(size) 67 | } 68 | } 69 | } 70 | ) { data in 71 | Image(uiImage: data) 72 | .resizable() 73 | .aspectRatio(contentMode: .fit) 74 | .clipShape(Circle()) 75 | } 76 | .id(model.avatarImage?.thumbnail) 77 | } else { 78 | placeholder(size) 79 | } 80 | 81 | } 82 | 83 | @ViewBuilder 84 | func placeholder(_ size: CGFloat) -> some View { 85 | Circle() 86 | .foregroundStyle(model.color.gradient) 87 | .overlay { 88 | Text(model.letters) 89 | .font(.system(size: size/2, weight: .semibold, design: .rounded)) 90 | .foregroundStyle(.white) 91 | } 92 | } 93 | } 94 | 95 | struct AvatarModel: Equatable { 96 | var avatarImage: AsyncImageModel? 97 | var letters: String = "" 98 | var color: Color = .blue 99 | var isOnline: Bool = false 100 | 101 | /// The id releated to the TDImage, nil if the avatar represent a group chat 102 | var userId: Int64? = nil 103 | 104 | static func == (lhs: AvatarModel, rhs: AvatarModel) -> Bool { 105 | return lhs.letters == rhs.letters && 106 | lhs.color == rhs.color && 107 | lhs.isOnline == rhs.isOnline && 108 | lhs.userId == rhs.userId && 109 | lhs.avatarImage == rhs.avatarImage 110 | } 111 | } 112 | 113 | extension AvatarModel { 114 | static var marco: AvatarModel { 115 | AvatarModel( 116 | avatarImage: AsyncImageModel( 117 | thumbnail: UIImage(named: "marco"), 118 | getImage: { UIImage(named: "marco") } 119 | ) 120 | ) 121 | } 122 | 123 | static var alessandro: AvatarModel { 124 | AvatarModel( 125 | avatarImage: AsyncImageModel( 126 | thumbnail: UIImage(named: "alessandro"), 127 | getImage: { UIImage(named: "alessandro") } 128 | ) 129 | ) 130 | } 131 | 132 | static var astro: AvatarModel { 133 | AvatarModel( 134 | avatarImage: AsyncImageModel( 135 | thumbnail: UIImage(named: "astro"), 136 | getImage: { UIImage(named: "astro") } 137 | ) 138 | ) 139 | } 140 | } 141 | 142 | 143 | #Preview("Big Image") { 144 | AvatarView(model: .alessandro) 145 | .frame(width: 150, height: 150) 146 | } 147 | 148 | #Preview("Small Image") { 149 | AvatarView(model: .marco) 150 | .frame(width: 50, height: 50) 151 | } 152 | 153 | #Preview("Big Letters") { 154 | AvatarView(model: .init(letters: "AA")) 155 | .frame(width: 150, height: 150) 156 | } 157 | 158 | 159 | #Preview("Small Letters") { 160 | AvatarView(model: .init(letters: "MT")) 161 | .frame(width: 50, height: 50) 162 | } 163 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/Subviews/Message/MessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageView.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 16/05/24. 6 | // 7 | 8 | import SwiftUI 9 | import TDLibKit 10 | import MapKit 11 | import TDLibKit 12 | 13 | struct MessageView: View { 14 | let model: MessageModel 15 | 16 | var body: some View { 17 | switch model.content { 18 | 19 | case .text(let text): 20 | MessageBubbleView(model: self.model) { 21 | Text(text) 22 | } 23 | 24 | case .voiceNote(let voiceModel): 25 | MessageBubbleView(model: self.model) { 26 | VoiceNoteView( 27 | model: voiceModel, 28 | isOutgoing: self.model.isOutgoing 29 | ) 30 | } 31 | 32 | case .photo(let imageModel, let caption): 33 | MessageBubbleView(model: self.model, style: .fullScreen(caption: caption ?? "")) { 34 | AsyncView(getData: imageModel.getImage) { 35 | Group { 36 | if let thumbnail = imageModel.thumbnail { 37 | Image(uiImage: thumbnail) 38 | .resizable() 39 | .scaledToFill() 40 | } 41 | if imageModel.thumbnail == nil { 42 | ProgressView() 43 | } 44 | } 45 | } buildContent: { image in 46 | Image(uiImage: image) 47 | .resizable() 48 | .scaledToFill() 49 | } 50 | } 51 | 52 | case .stickerImage(let stickerModel): 53 | MessageBubbleView(model: self.model, style: .clearBackground) { 54 | AsyncView(getData: stickerModel.getImage) { 55 | Text(stickerModel.emoji) 56 | .font(.largeTitle) 57 | } buildContent: { image in 58 | Image(uiImage: image) 59 | .resizable() 60 | .scaledToFit() 61 | .frame(width: 100) 62 | } 63 | } 64 | 65 | 66 | case .location(let locationModel): 67 | MessageBubbleView(model: self.model, style: .fullScreen(caption: "")) { 68 | LocationView(model: locationModel) 69 | } 70 | 71 | case .pill(let title, let description): 72 | PillView(title: title, description: description) 73 | // 74 | // case .video: 75 | // MessageBubbleView(model: self.model)(style: .fullScreen, caption: message.caption.text) { 76 | // TdImageView(tdImage: message.video) 77 | // } 78 | // .overlay { 79 | // Image(systemName: "play.circle.fill") 80 | // .font(.largeTitle) 81 | // .foregroundStyle(.white, .ultraThinMaterial) 82 | // } 83 | } 84 | } 85 | } 86 | 87 | struct MessageModel: Identifiable { 88 | 89 | var id: Int64 90 | 91 | var sender: String? 92 | var senderColor: Color? 93 | var isSenderHidden: Bool = false 94 | 95 | var date: Foundation.Date 96 | var time: String { 97 | date.formatted(.dateTime.hour().minute()) 98 | } 99 | 100 | var isOutgoing: Bool = false 101 | 102 | var reactions: [ReactionModel] = [] 103 | var reply: ReplyModel? = nil 104 | 105 | var stateStyle: StateStyle? 106 | enum StateStyle { 107 | case sending, delivered, seen, failed 108 | } 109 | 110 | var content: MessageContent 111 | enum MessageContent { 112 | case text(AttributedString) 113 | case voiceNote(model: VoiceNoteModel) 114 | case photo(model: AsyncImageModel, caption: String?) 115 | case stickerImage(model: StickerImageModel) 116 | case location(model: LocationModel) 117 | case pill(title: String?, description: LocalizedStringKey) 118 | } 119 | } 120 | 121 | struct StickerImageModel { 122 | let emoji: String 123 | let getImage: () async -> UIImage? 124 | } 125 | 126 | extension MessageModel { 127 | static func mock( 128 | sender: String = "", 129 | isOutgoing: Bool = true, 130 | reactions: [ReactionModel] = [], 131 | reply: ReplyModel? = nil, 132 | state: MessageModel.StateStyle = .delivered, 133 | content: MessageModel.MessageContent = .text("") 134 | ) -> Self { 135 | .init( 136 | id: 0, 137 | sender: sender, 138 | senderColor: .blue, 139 | isSenderHidden: sender.isEmpty, 140 | date: .now, 141 | isOutgoing: sender.isEmpty ? isOutgoing : false, 142 | reactions: reactions, 143 | reply: reply, 144 | stateStyle: isOutgoing ? state : nil, 145 | content: content 146 | ) 147 | } 148 | } 149 | 150 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/ChatDetailPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginPage.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 02/11/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ChatDetailPage: View { 11 | 12 | @State 13 | @Mockable 14 | var vm: ChatDetailViewModel 15 | 16 | init(chatId: Int64) { 17 | _vm = Mockable.state( 18 | value: { ChatDetailViewModel(chatId: chatId) }, 19 | mock: { ChatDetailViewModelMock() } 20 | ) 21 | } 22 | 23 | var body: some View { 24 | 25 | ScrollViewReader { proxy in 26 | 27 | Group { 28 | if vm.isLoadingInitialMessages { 29 | ProgressView() 30 | } else { 31 | ScrollView { 32 | 33 | if vm.isLoadingMoreMessages { 34 | ProgressView() 35 | } else { 36 | Button("Load more") { 37 | vm.onPressLoadMore(proxy) 38 | } 39 | } 40 | 41 | messageList() 42 | .onAppear { vm.onMessageListAppear(proxy) } 43 | .padding(.vertical, 2) 44 | 45 | } 46 | .defaultScrollAnchor(.bottom) 47 | } 48 | } 49 | .toolbar { 50 | 51 | if let avatar = vm.avatar { 52 | ToolbarItem(placement: .topBarTrailing) { 53 | AvatarView(model: avatar) 54 | .onTapGesture { 55 | vm.onPressAvatar() 56 | } 57 | } 58 | } 59 | 60 | ToolbarItemGroup(placement: .bottomBar) { 61 | toolbarActions() 62 | } 63 | } 64 | .containerBackground(for: .navigation) { 65 | background() 66 | } 67 | .navigationTitle { 68 | Text(vm.chatName ?? "") 69 | .foregroundStyle(.white) 70 | } 71 | .toolbarForegroundStyle(.white, for: .navigationBar) 72 | .onAppear(perform: vm.onOpenChat) 73 | .onDisappear(perform: vm.onCloseChat) 74 | } 75 | .sheet(isPresented: $vm.showChatInfoView) { 76 | AlertView.inDevelopment("chat info is") 77 | } 78 | .sheet(isPresented: $vm.showStickersView) { 79 | AlertView.inDevelopment("stickers are") 80 | } 81 | .sheet(isPresented: $vm.showAudioMessageView) { 82 | if let sendService = vm.sendService { 83 | VoiceNoteRecordSubpage( 84 | isPresented: $vm.showAudioMessageView, 85 | action: $vm.chatAction, 86 | sendService: sendService 87 | ) 88 | } 89 | } 90 | .sheet(isPresented: $vm.showOptionsView) { 91 | if let messageId = vm.selectedMessage?.id, let sendService = vm.sendService { 92 | MessageOptionsSubpage( 93 | isPresented: $vm.showOptionsView, 94 | model: .init( 95 | chatId: vm.chatId, 96 | messageId: messageId, 97 | sendService: sendService 98 | ) 99 | ) 100 | } 101 | } 102 | } 103 | 104 | @ViewBuilder 105 | func messageList() -> some View { 106 | ForEach(vm.messages) { message in 107 | MessageView(model: message) 108 | .id(message.id) 109 | .scrollTransition(.animated.threshold(.visible(0.2))) { content, phase in 110 | content 111 | .scaleEffect(phase.isIdentity ? 1 : 0.7) 112 | .opacity(phase.isIdentity ? 1 : 0) 113 | } 114 | .onAppear { 115 | vm.onMessageAppear(message) 116 | } 117 | .onTapGesture(count: 2) { 118 | vm.onDublePressOf(message) 119 | } 120 | } 121 | } 122 | 123 | @ViewBuilder 124 | func toolbarActions() -> some View { 125 | if vm.canSendText ?? false { 126 | Button("Stickers", systemImage: "keyboard.fill") { 127 | vm.onPressTextInsert() 128 | } 129 | } 130 | 131 | if vm.canSendVoiceNotes ?? false { 132 | Button("Record", systemImage: "mic.fill") { 133 | vm.onPressVoiceRecording() 134 | } 135 | .controlSize(.large) 136 | .background { 137 | Circle().foregroundStyle(.ultraThinMaterial) 138 | } 139 | } 140 | 141 | if vm.canSendStickers ?? false { 142 | Button("Stickers", systemImage: "face.smiling.inverse") { 143 | vm.onPressStickersSelection() 144 | } 145 | } 146 | } 147 | 148 | @ViewBuilder 149 | func background() -> some View { 150 | 151 | let gradient = Gradient( 152 | colors: [ 153 | .bgBlue, 154 | .bgBlue.opacity(0.2) 155 | ] 156 | ) 157 | 158 | Rectangle() 159 | .foregroundStyle(gradient) 160 | } 161 | 162 | } 163 | 164 | #Preview(traits: .mock()) { 165 | NavigationView { 166 | ChatDetailPage(chatId: 0) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/ChatDetailViewModel+MessageContents.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatDetailViewModel+MessageContents.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Alessandro Alberti on 11/10/24. 6 | // 7 | 8 | import SwiftUI 9 | import TDLibKit 10 | import SDWebImageWebPCoder 11 | 12 | extension ChatDetailViewModel { 13 | 14 | func messageContentFrom(_ msg: Message) async -> MessageModel.MessageContent { 15 | switch msg.content { 16 | 17 | case .messageText(let message): 18 | return .text(message.text.attributedString) 19 | 20 | case .messageVoiceNote(let message): 21 | guard var model = await message.getModel() 22 | else { return .text(msg.description) } 23 | model.onPress = { self.setMessageAsOpened(msg.id) } 24 | return .voiceNote(model: model) 25 | 26 | case .messagePhoto(let message): 27 | return .photo(model: message.getModel(), caption: message.caption.text) 28 | 29 | case .messageSticker(let message): 30 | switch message.sticker.format { 31 | 32 | case .stickerFormatWebp: 33 | return .stickerImage(model: message.getImageModel()) 34 | case .stickerFormatTgs: 35 | return .text(msg.description) 36 | case .stickerFormatWebm: 37 | return .text(msg.description) 38 | } 39 | 40 | case .messageLocation(let message): 41 | return .location(model: message.getModel()) 42 | 43 | case .messageVenue(let message): 44 | return .location(model: message.getModel()) 45 | 46 | case .messagePinMessage(_): 47 | return await getPillModel(message: msg, text: "pinned a message") 48 | 49 | case .messageChatChangeTitle(let message): 50 | return await getPillModel( 51 | message: msg, 52 | text: "changed the group name to _\(message.title)_" 53 | ) 54 | 55 | case .messageChatChangePhoto(_): 56 | return await getPillModel( 57 | message: msg, 58 | text: "changed group photo" 59 | ) 60 | 61 | 62 | default: 63 | return .text(msg.description) 64 | } 65 | 66 | } 67 | 68 | func getPillModel(message: Message, text: LocalizedStringKey) async -> MessageModel.MessageContent { 69 | let sender = await self.senderNameFrom(message) 70 | return .pill( 71 | title: sender.name, 72 | description: text 73 | ) 74 | } 75 | } 76 | 77 | extension MessageVoiceNote { 78 | func getModel() async -> VoiceNoteModel? { 79 | return VoiceNoteModel( 80 | isListened: self.isListened, 81 | getPlayer: { 82 | guard let file = await FileService.getFilePath(for: voiceNote.voice), 83 | let player = try? PlayerService(audioFilePath: file) 84 | else { return nil } 85 | return player 86 | } 87 | ) 88 | } 89 | } 90 | 91 | extension MessagePhoto { 92 | func getModel() -> AsyncImageModel { 93 | var thumbnail: UIImage? = nil 94 | if let data = photo.minithumbnail?.data { 95 | thumbnail = UIImage(data: data) 96 | } 97 | return AsyncImageModel( 98 | thumbnail: thumbnail, 99 | getImage: { 100 | guard let photo = photo.lowRes 101 | else { return nil } 102 | return await FileService.getImage(for: photo) 103 | } 104 | ) 105 | } 106 | } 107 | 108 | extension MessageSticker { 109 | func getImageModel() -> StickerImageModel { 110 | return StickerImageModel( 111 | emoji: sticker.emoji, 112 | getImage: { 113 | guard let filePath = await FileService.getFilePath(for: sticker.sticker), 114 | let data = try? Data(contentsOf: filePath) 115 | else { return nil } 116 | return SDImageWebPCoder.shared.decodedImage(with: data, options: nil) 117 | } 118 | ) 119 | } 120 | } 121 | 122 | extension MessageLocation { 123 | func getModel() -> LocationModel { 124 | return LocationModel( 125 | coordinate: CLLocationCoordinate2D( 126 | latitude: self.location.latitude, 127 | longitude: self.location.longitude 128 | ) 129 | ) 130 | } 131 | } 132 | 133 | extension MessageVenue { 134 | func getModel() -> LocationModel { 135 | let style = pinStyle() 136 | 137 | return LocationModel( 138 | title: venue.title, 139 | coordinate: CLLocationCoordinate2D( 140 | latitude: venue.location.latitude, 141 | longitude: venue.location.longitude 142 | ), 143 | color: style.color, 144 | markerSymbol: style.symbol 145 | ) 146 | } 147 | 148 | private func pinStyle() -> (symbol: String, color: Color) { 149 | switch venue.type { 150 | case "arts_entertainment/museum": 151 | return ("building.columns.fill", .pink) 152 | case "travel/hotel": 153 | return ("bed.double.fill", .purple) 154 | case let type where type.contains("food"): 155 | return ("fork.knife", .orange) 156 | case let type where type.contains("parks_outdoors"): 157 | return ("tree.fill", .green) 158 | case let type where type.contains("shops"): 159 | return ("bag.fill", .yellow) 160 | case let type where type.contains("building"): 161 | return ("building.2.fill", .gray) 162 | default: 163 | return ("mapin", .red) 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /Mercury Watch App/TDLib/TDLibManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TDLibManager.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 29/04/24. 6 | // 7 | 8 | import Foundation 9 | import TDLibKit 10 | import WatchKit 11 | 12 | final class TDLibManager { 13 | 14 | // Singleton 15 | static let shared = TDLibManager() 16 | private var manager: TDLibClientManager 17 | 18 | // Properties 19 | public var client: TDLibClient? 20 | public var connectionState: ConnectionState? 21 | 22 | private var isClosing = false 23 | private let tdlibPath = FileManager.default 24 | .urls(for: .cachesDirectory, in: .userDomainMask) 25 | .first? 26 | .appendingPathComponent("tdlib", isDirectory: true) 27 | .path 28 | 29 | // Delegate 30 | private var delegatesObj = NSHashTable.weakObjects() 31 | private var delegates: [TDLibManagerProtocol] { 32 | return delegatesObj.allObjects as? [TDLibManagerProtocol] ?? [] 33 | } 34 | 35 | private init() { 36 | self.manager = TDLibClientManager() 37 | self.createClient() 38 | } 39 | 40 | public func subscribe(_ delegate: TDLibManagerProtocol) { 41 | self.delegatesObj.add(delegate) 42 | if let connectionState { 43 | delegate.connectionStateUpdate(state: connectionState) 44 | } 45 | } 46 | 47 | public func unsubscribe(_ delegate: TDLibManagerProtocol) { 48 | self.delegatesObj.remove(delegate) 49 | } 50 | 51 | public func close() { 52 | self.manager.closeClients() 53 | } 54 | 55 | public func createClient() { 56 | self.client = self.manager.createClient(updateHandler: updateHandler) 57 | } 58 | 59 | private func updateHandler(data: Data, client: TDLibClient) { 60 | guard let update = try? client.decoder.decode(Update.self, from: data) else { return } 61 | 62 | switch update { 63 | case .updateConnectionState(let state): 64 | self.updateConnectionState(state: state.state) 65 | case .updateAuthorizationState(let state): 66 | self.updateAuthorizationState(state: state.authorizationState) 67 | default: 68 | break 69 | } 70 | 71 | for elem in delegates { 72 | elem.updateHandler(update: update) 73 | } 74 | } 75 | 76 | private func updateConnectionState(state: ConnectionState) { 77 | for elem in delegates { 78 | elem.connectionStateUpdate(state: state) 79 | } 80 | self.connectionState = state 81 | } 82 | 83 | private func updateAuthorizationState(state: AuthorizationState) { 84 | 85 | DispatchQueue.main.async { 86 | AppState.shared.isAuthenticated = state == .authorizationStateReady 87 | UserDefaulsService.isAuthenticated = state == .authorizationStateReady 88 | } 89 | 90 | switch state { 91 | case .authorizationStateWaitTdlibParameters: 92 | setTdlibParameters() 93 | break 94 | case .authorizationStateLoggingOut: 95 | if !isClosing { self.close() } 96 | break 97 | case .authorizationStateClosing: 98 | self.isClosing = true 99 | DispatchQueue.main.async { 100 | AppState.shared.isAuthenticated = nil 101 | } 102 | break 103 | case .authorizationStateClosed: 104 | try? FileManager.default.removeItem(atPath: tdlibPath!) 105 | TDLibManager.shared.createClient() 106 | DispatchQueue.main.async { 107 | self.isClosing = false 108 | } 109 | break 110 | default: 111 | break 112 | } 113 | } 114 | 115 | private func setTdlibParameters() { 116 | 117 | let logger = LoggerService(TDLibManager.self) 118 | 119 | Task { 120 | do { 121 | let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String 122 | let device = WKInterfaceDevice.current() 123 | let deviceModel = device.name 124 | let systemVersion = "\(device.systemName) \(device.systemVersion)" 125 | 126 | let result = try await self.client?.setTdlibParameters( 127 | apiHash: SecretService.apiHash, 128 | apiId: SecretService.apiId, 129 | applicationVersion: appVersion, 130 | databaseDirectory: tdlibPath, 131 | databaseEncryptionKey: nil, 132 | deviceModel: deviceModel, 133 | filesDirectory: nil, 134 | systemLanguageCode: "en-US", 135 | systemVersion: systemVersion, 136 | useChatInfoDatabase: true, 137 | useFileDatabase: true, 138 | useMessageDatabase: true, 139 | useSecretChats: false, 140 | useTestDc: false 141 | ) 142 | 143 | #if DEBUG 144 | try await self.client?.setLogVerbosityLevel(newVerbosityLevel: 1) 145 | #else 146 | try await self.client?.setLogVerbosityLevel(newVerbosityLevel: 0) 147 | #endif 148 | 149 | let options = [ 150 | "disable_network_statistics", 151 | "disable_persistent_network_statistics", 152 | "use_storage_optimizer" 153 | ] 154 | 155 | for option in options { 156 | try await self.client?.setOption( 157 | name: option, 158 | value: .optionValueBoolean(.init(value: true)) 159 | ) 160 | } 161 | 162 | logger.log(result) 163 | 164 | } catch { 165 | logger.log(error, level: .error) 166 | } 167 | } 168 | } 169 | 170 | } 171 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/Login/LoginViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewModel.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 02/11/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import TDLibKit 11 | 12 | @Observable 13 | class LoginViewModel: TDLibViewModel { 14 | var showTutorialView: Bool = false 15 | var showPasswordView: Bool = false 16 | var showFullscreenQR: Bool = false 17 | var isValidatingPassword: Bool = false 18 | 19 | var passwordModel: PasswordModel = .plain 20 | var password: String = "" 21 | var qrCodeLink: String? = nil 22 | var statusMessage: String = "Connecting..." 23 | 24 | let tutorialSteps = [ 25 | "Open Telegram on your phone", 26 | "Go to Settings → Devices → Link Desktop Device", 27 | "Point your phone at the QR code to confirm login" 28 | ] 29 | 30 | func didPressDemoButton() { 31 | AppState.shared.isMock = true 32 | } 33 | 34 | func didPressInfoButton() { 35 | showTutorialView = true 36 | } 37 | 38 | func didPressQR() { 39 | withAnimation(.bouncy) { 40 | showFullscreenQR.toggle() 41 | } 42 | } 43 | 44 | func didChangeShowPasswordValue(oldValue: Bool, newValue: Bool) { 45 | if newValue == false { 46 | LoginViewModel.logout() 47 | } 48 | } 49 | 50 | // MARK: - TDLib 51 | override func updateHandler(update: Update) { 52 | super.updateHandler(update: update) 53 | switch update { 54 | case .updateAuthorizationState(let state): 55 | self.manageUpdateAuthorizationState(state: state.authorizationState) 56 | case .updateChatFolders(let update): 57 | DispatchQueue.main.async { 58 | self.updateChatFolders(update) 59 | } 60 | default: 61 | break 62 | } 63 | } 64 | 65 | override func connectionStateUpdate(state: ConnectionState) { 66 | guard state == .connectionStateReady else { return } 67 | DispatchQueue.main.async { 68 | self.statusMessage = "Login with QR code" 69 | } 70 | } 71 | 72 | func manageUpdateAuthorizationState(state: AuthorizationState) { 73 | 74 | switch state { 75 | case .authorizationStateWaitPhoneNumber: 76 | self.getQrcodeLink() 77 | break 78 | case .authorizationStateWaitOtherDeviceConfirmation(let info): 79 | DispatchQueue.main.async { 80 | self.qrCodeLink = info.link 81 | } 82 | break 83 | case .authorizationStateWaitPassword(_): 84 | DispatchQueue.main.async { 85 | self.qrCodeLink = nil 86 | self.showPasswordView = true 87 | } 88 | break 89 | default: 90 | self.logger.log("Unmanaged state \(state)") 91 | break 92 | } 93 | } 94 | 95 | func getQrcodeLink() { 96 | Task { 97 | do { 98 | let result = try await TDLibManager.shared.client?.requestQrCodeAuthentication(otherUserIds: []) 99 | self.logger.log(result) 100 | 101 | } catch { 102 | self.logger.log(error, level: .error) 103 | } 104 | } 105 | } 106 | 107 | func validatePassword() { 108 | 109 | isValidatingPassword = true 110 | passwordModel = .plain 111 | 112 | Task { 113 | do { 114 | let result = try await TDLibManager.shared.client?.checkAuthenticationPassword(password: password) 115 | self.logger.log(result) 116 | 117 | await MainActor.run { 118 | passwordModel = .plain 119 | } 120 | 121 | } catch { 122 | self.logger.log(error, level: .error) 123 | guard let error = error as? TDLibKit.Error else { return } 124 | 125 | if error.message == "PASSWORD_HASH_INVALID" { 126 | await MainActor.run { 127 | self.password = "" 128 | passwordModel = .error 129 | } 130 | } 131 | } 132 | 133 | await MainActor.run { 134 | self.isValidatingPassword = false 135 | } 136 | 137 | } 138 | } 139 | 140 | @MainActor 141 | func updateChatFolders(_ update: UpdateChatFolders) { 142 | for chatFolderInfo in update.chatFolders { 143 | let chatList = ChatList.chatListFolder(ChatListFolder(chatFolderId: chatFolderInfo.id)) 144 | let folder = ChatFolder(title: chatFolderInfo.title, chatList: chatList) 145 | AppState.shared.insertFolder(folder) 146 | } 147 | } 148 | 149 | static func logout() { 150 | 151 | let logger = LoggerService(LoginViewModel.self) 152 | 153 | if AppState.shared.isMock { 154 | AppState.shared.isMock = false 155 | return 156 | } 157 | 158 | AppState.shared.clear() 159 | 160 | Task.detached { 161 | do { 162 | let result = try await TDLibManager.shared.client?.logOut() 163 | logger.log(result) 164 | } catch { 165 | logger.log(error, level: .error) 166 | } 167 | 168 | TDLibManager.shared.close() 169 | } 170 | } 171 | 172 | static func setOnlineStatus(online: Bool = true) { 173 | Task { 174 | let logger = LoggerService(LoginViewModel.self) 175 | do { 176 | let result = try await TDLibManager.shared.client?.setOption( 177 | name: "online", 178 | value: .optionValueBoolean(.init(value: online)) 179 | ) 180 | logger.log(result) 181 | } catch { 182 | logger.log(error, level: .error) 183 | } 184 | } 185 | } 186 | 187 | static func setOfflineStatus() { 188 | setOnlineStatus(online: false) 189 | } 190 | 191 | } 192 | 193 | // MARK: - Mock 194 | @Observable 195 | class LoginViewModelMock: LoginViewModel { 196 | override init() { 197 | super.init() 198 | qrCodeLink = "Hello World" 199 | } 200 | 201 | } 202 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/ChatDetailViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewModel.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 02/11/24. 6 | // 7 | 8 | import Foundation 9 | import TDLibKit 10 | import SwiftUI 11 | 12 | @Observable 13 | class ChatDetailViewModel: TDLibViewModel { 14 | 15 | enum InsertAt { case first, last, index(_ value: Int)} 16 | 17 | var chatId: Int64 18 | 19 | var chatName: String? 20 | var isLoadingInitialMessages: Bool = false 21 | var isLoadingMoreMessages: Bool = false 22 | 23 | var showAudioMessageView: Bool = false 24 | var showStickersView: Bool = false 25 | var showOptionsView: Bool = false 26 | var showChatInfoView: Bool = false 27 | 28 | var canSendVoiceNotes: Bool? 29 | var canSendText: Bool? 30 | var canSendStickers: Bool? 31 | 32 | var messages: [MessageModel] = [] 33 | var selectedMessage: MessageModel? 34 | var avatar: AvatarModel? 35 | 36 | var sendService: SendMessageService? 37 | var lastReadInboxMessageId: Int64? 38 | 39 | var chatAction: ChatAction? 40 | var chatActionTimer: Timer? 41 | 42 | init(chatId: Int64) { 43 | self.chatId = chatId 44 | super.init() 45 | 46 | self.chatActionTimer = Timer.scheduledTimer( 47 | withTimeInterval: 1, 48 | repeats: true, 49 | block: { [weak self] _ in 50 | guard let self else { return } 51 | self.setChatAction(self.chatAction) 52 | } 53 | ) 54 | 55 | self.loadChatData() 56 | } 57 | 58 | deinit { 59 | chatActionTimer?.invalidate() 60 | chatActionTimer = nil 61 | } 62 | 63 | func loadChatData() { 64 | 65 | self.isLoadingInitialMessages = true 66 | 67 | Task.detached(priority: .high) { 68 | do { 69 | 70 | guard let chat = try await TDLibManager.shared.client?.getChat(chatId: self.chatId) 71 | else { return } 72 | 73 | await MainActor.run { 74 | self.sendService = SendMessageService(chat: chat) 75 | self.chatName = chat.title 76 | self.canSendVoiceNotes = chat.permissions.canSendVoiceNotes 77 | self.canSendText = chat.permissions.canSendBasicMessages 78 | self.canSendStickers = chat.permissions.canSendOtherMessages 79 | self.lastReadInboxMessageId = chat.lastReadInboxMessageId 80 | self.avatar = chat.toAvatarModel() 81 | } 82 | 83 | let newMessages = await self.requestMessages(firstBatch: true) 84 | await MainActor.run { 85 | for msg in newMessages { 86 | self.insertMessage(at: .first, message: msg) 87 | } 88 | 89 | withAnimation { 90 | self.isLoadingInitialMessages = false 91 | } 92 | } 93 | 94 | } catch { 95 | self.logger.log(error, level: .error) 96 | self.isLoadingInitialMessages = false 97 | } 98 | } 99 | } 100 | 101 | override func updateHandler(update: Update) { 102 | super.updateHandler(update: update) 103 | DispatchQueue.main.async { 104 | switch update { 105 | case .updateUserStatus(let update): 106 | self.updateUserStatus(update) 107 | case .updateNewMessage(let update): 108 | self.updateNewMessage(update) 109 | case .updateDeleteMessages(let update): 110 | self.updateDeleteMessages(update) 111 | case .updateMessageSendFailed(let update): 112 | self.updateMessageSendFailed(update) 113 | case .updateMessageSendSucceeded(let update): 114 | self.updateMessageSendSucceeded(update) 115 | case .updateMessageContent(let update): 116 | self.updateMessageContent(messageId: update.messageId) 117 | case .updateMessageInteractionInfo(let update): 118 | self.updateMessageInteractionInfo(update) 119 | case .updateChatReadOutbox(let update): 120 | self.updateChatReadOutbox(update) 121 | case .updateMessageContentOpened(let update): 122 | self.updateMessageContentOpened(update) 123 | default: 124 | break 125 | } 126 | } 127 | } 128 | 129 | /// Do not call this function manually, it is periodically called from a task 130 | private func setChatAction(_ action: ChatAction?) { 131 | Task.detached(priority: .background) { 132 | do { 133 | try await TDLibManager.shared.client?.sendChatAction( 134 | action: action, 135 | businessConnectionId: nil, 136 | chatId: self.chatId, 137 | messageThreadId: 0 138 | ) 139 | } catch { 140 | self.logger.log(error, level: .error) 141 | } 142 | } 143 | } 144 | 145 | } 146 | 147 | 148 | // MARK: - Mock 149 | @Observable 150 | class ChatDetailViewModelMock: ChatDetailViewModel { 151 | init() { 152 | super.init(chatId: 0) 153 | canSendText = true 154 | canSendVoiceNotes = true 155 | canSendStickers = true 156 | 157 | chatName = "Astro" 158 | avatar = .astro 159 | } 160 | 161 | override func loadChatData() { 162 | self.messages = [ 163 | .init( 164 | id: 0, 165 | isSenderHidden: true, 166 | date: .iPhonePresentationDate, 167 | isOutgoing: false, 168 | content: .text("Hello World 👋") 169 | ), 170 | .init( 171 | id: 1, 172 | isSenderHidden: true, 173 | date: .iPhonePresentationDate, 174 | isOutgoing: false, 175 | content: .text("Landed on Mercury? 👽") 176 | ), 177 | .init( 178 | id: 2, 179 | isSenderHidden: true, 180 | date: .appleWatchPresentationDate, 181 | isOutgoing: true, 182 | content: .text("Yes, it's amazing! 😍") 183 | ), 184 | // 185 | // .init( 186 | // id: 3, 187 | // isSenderHidden: true, 188 | // date: .now, 189 | // isOutgoing: false, 190 | // content: .voiceNote(model: .init(getPlayer: { 191 | // PlayerServiceMock() 192 | // })) 193 | // ), 194 | ] 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatDetail/ChatDetailViewModel+Updates.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatDetailViewModel+Updates.swift 3 | // Mercury 4 | // 5 | // Created by Marco Tammaro on 08/11/24. 6 | // 7 | 8 | import SwiftUI 9 | import TDLibKit 10 | 11 | extension ChatDetailViewModel { 12 | func updateNewMessage(_ update: UpdateNewMessage) { 13 | 14 | let chatId = update.message.chatId 15 | let messageData = update.message 16 | 17 | guard chatId == self.chatId else { return } 18 | 19 | // A message with same ID already exist, its updates will 20 | // be managed by itself, no need to insert a new one 21 | if messages.contains(where: { $0.id == messageData.id }) { 22 | return 23 | } 24 | 25 | Task.detached { 26 | let message = await self.messageModelFrom(messageData) 27 | 28 | await MainActor.run { 29 | self.insertMessage(at: .last, message: message) 30 | } 31 | } 32 | } 33 | 34 | @MainActor 35 | func updateDeleteMessages(_ update: UpdateDeleteMessages) { 36 | 37 | let chatId = update.chatId 38 | let messageIds = update.messageIds 39 | 40 | guard chatId == self.chatId else { return } 41 | 42 | withAnimation { 43 | self.messages.removeAll(where: { messageIds.contains($0.id) }) 44 | } 45 | } 46 | 47 | func updateUserStatus(_ update: UpdateUserStatus) { 48 | 49 | let userId = update.userId 50 | let status = update.status 51 | 52 | Task.detached { 53 | do { 54 | 55 | guard let chat = try await TDLibManager.shared.client?.getChat(chatId: self.chatId) 56 | else { return } 57 | 58 | var chatUserId: Int64? = nil 59 | if case .chatTypePrivate(let data) = chat.type { 60 | chatUserId = data.userId 61 | } 62 | 63 | guard userId == chatUserId else { return } 64 | 65 | await MainActor.run { 66 | switch status { 67 | case .userStatusOnline(_): 68 | self.avatar?.isOnline = true 69 | case .userStatusOffline(_): 70 | self.avatar?.isOnline = false 71 | default: 72 | break 73 | } 74 | } 75 | 76 | } catch { 77 | self.logger.log(error, level: .error) 78 | } 79 | } 80 | } 81 | 82 | @MainActor 83 | func updateMessageSendFailed(_ update: UpdateMessageSendFailed) { 84 | let index = self.messages.firstIndex(where: { $0.id == update.oldMessageId}) 85 | guard let index, index != -1 else { return } 86 | 87 | self.messages[index].id = update.message.id 88 | self.messages[index].stateStyle = .failed 89 | } 90 | 91 | @MainActor 92 | func updateMessageSendSucceeded(_ update: UpdateMessageSendSucceeded) { 93 | let index = self.messages.firstIndex(where: { $0.id == update.oldMessageId}) 94 | guard let index, index != -1 else { return } 95 | 96 | self.messages[index].id = update.message.id 97 | self.messages[index].stateStyle = .delivered 98 | } 99 | 100 | func updateMessageContentOpened(_ update: UpdateMessageContentOpened) { 101 | 102 | let messageId = update.messageId 103 | 104 | guard self.messages.contains(where: { $0.id == messageId }) 105 | else { return } 106 | 107 | Task.detached { 108 | do { 109 | 110 | let messageData = try await TDLibManager.shared.client?.getMessage(chatId: self.chatId, messageId: messageId) 111 | guard let messageData else { return } 112 | 113 | let message = await self.messageModelFrom(messageData) 114 | 115 | await MainActor.run { 116 | let index = self.messages.firstIndex(where: { $0.id == messageId }) 117 | guard let index, index != -1 else { return } 118 | 119 | withAnimation { 120 | self.messages[index] = message 121 | } 122 | } 123 | 124 | } catch { 125 | self.logger.log(error, level: .error) 126 | } 127 | } 128 | } 129 | 130 | @MainActor 131 | func updateMessageInteractionInfo(_ update: UpdateMessageInteractionInfo) { 132 | 133 | let messageId = update.messageId 134 | 135 | let index = self.messages.firstIndex(where: { $0.id == messageId }) 136 | guard let index, index != -1 else { return } 137 | 138 | withAnimation { 139 | if let reactions = update.interactionInfo?.reactions?.reactions { 140 | self.messages[index].reactions = self.reactionsModelFrom(reactions) 141 | } else { 142 | self.messages[index].reactions = [] 143 | } 144 | } 145 | } 146 | 147 | func updateMessageContent(messageId: Int64) { 148 | 149 | guard self.messages.contains(where: { $0.id == messageId }) 150 | else { return } 151 | 152 | Task.detached { 153 | do { 154 | 155 | let messageData = try await TDLibManager.shared.client?.getMessage(chatId: self.chatId, messageId: messageId) 156 | guard let messageData else { return } 157 | 158 | let message = await self.messageModelFrom(messageData) 159 | 160 | await MainActor.run { 161 | let index = self.messages.firstIndex(where: { $0.id == messageId }) 162 | guard let index, index != -1 else { return } 163 | 164 | withAnimation { 165 | self.messages[index] = message 166 | } 167 | } 168 | 169 | } catch { 170 | self.logger.log(error, level: .error) 171 | } 172 | } 173 | } 174 | 175 | @MainActor 176 | func updateChatReadOutbox(_ update: UpdateChatReadOutbox) { 177 | 178 | let latestReadMessageId = update.lastReadOutboxMessageId 179 | 180 | for (index, message) in self.messages.enumerated() { 181 | if message.stateStyle == .delivered && message.id <= latestReadMessageId { 182 | withAnimation { 183 | self.messages[index].stateStyle = .seen 184 | } 185 | } 186 | } 187 | } 188 | 189 | } 190 | -------------------------------------------------------------------------------- /Mercury Watch App/Pages/ChatList/ChatListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewModel.swift 3 | // Mercury Watch App 4 | // 5 | // Created by Marco Tammaro on 02/11/24. 6 | // 7 | 8 | import Foundation 9 | import TDLibKit 10 | import SwiftUI 11 | 12 | @Observable 13 | class ChatListViewModel: TDLibViewModel { 14 | 15 | var folder: ChatFolder 16 | 17 | var chats: [ChatCellModel] = [] 18 | var isLoading: Bool = false 19 | var showNewMessage: Bool = false 20 | 21 | init(folder: ChatFolder) { 22 | self.folder = folder 23 | super.init() 24 | 25 | self.initChatList() 26 | } 27 | 28 | func didPressPin(on chat: ChatCellModel) { 29 | guard let id = chat.id else { return } 30 | pinChat(id, isPinned: chat.isPinned) 31 | } 32 | 33 | func didPressMute(on chat: ChatCellModel) { 34 | guard let id = chat.id else { return } 35 | muteChat(id) 36 | } 37 | 38 | func didPressOnNewMessage() { 39 | self.showNewMessage = true 40 | } 41 | 42 | override func updateHandler(update: Update) { 43 | super.updateHandler(update: update) 44 | 45 | DispatchQueue.main.async { 46 | switch update { 47 | 48 | case .updateChatRemovedFromList(let update): 49 | self.updateChatRemovedFromList(update) 50 | 51 | // Chat metadata update 52 | case .updateUserStatus(let update): 53 | self.updateUserStatus(update) 54 | case .updateChatLastMessage(let update): 55 | self.updateChatLastMessage(update) 56 | case .updateChatTitle(let update): 57 | self.updateChatTitle(update) 58 | case .updateChatPhoto(let update): 59 | self.updateChatPhoto(update) 60 | case .updateChatPosition(let update): 61 | self.updateChatPosition(update) 62 | case .updateChatNotificationSettings(let update): 63 | self.updateChatNotificationSettings(update) 64 | 65 | // Chat Counters update 66 | case .updateChatReadInbox(let update): 67 | self.updateCounters(chatId: update.chatId, unreadCount: update.unreadCount) 68 | case .updateChatUnreadMentionCount(let update): 69 | self.updateCounters(chatId: update.chatId, mentionCount: update.unreadMentionCount) 70 | case .updateChatUnreadReactionCount(let update): 71 | self.updateCounters(chatId: update.chatId, reactionCount: update.unreadReactionCount) 72 | case .updateMessageUnreadReactions(let update): 73 | self.updateCounters(chatId: update.chatId, reactionCount: update.unreadReactionCount) 74 | 75 | // Chat Action update 76 | case .updateChatAction(let update): 77 | self.updateChatAction(update) 78 | 79 | default: 80 | break 81 | } 82 | } 83 | } 84 | func initChatList() { 85 | 86 | Task.detached(priority: .high) { 87 | 88 | await MainActor.run { 89 | self.isLoading = true 90 | } 91 | 92 | let chatsData = await self.loadChats() 93 | let chatsModels = chatsData 94 | .map { self.chatCellModelFrom($0) } 95 | .sorted(by: self.chatSortingLogic) 96 | 97 | await MainActor.run { 98 | self.chats = chatsModels 99 | self.isLoading = false 100 | } 101 | 102 | } 103 | } 104 | 105 | private func pinChat(_ chatId: Int64, isPinned: Bool) { 106 | let index = self.chats.firstIndex { c in c.id == chatId } 107 | guard let index, index != -1 else { return } 108 | 109 | let newValue = !isPinned 110 | let list = self.folder.chatList 111 | 112 | withAnimation { 113 | self.chats[index].isPinned = newValue 114 | } 115 | 116 | Task.detached { 117 | do { 118 | try await TDLibManager.shared.client?.toggleChatIsPinned( 119 | chatId: chatId, 120 | chatList: list, 121 | isPinned: newValue 122 | ) 123 | self.logger.log("IsPinned updated") 124 | 125 | } catch { 126 | self.logger.log(error, level: .error) 127 | } 128 | } 129 | 130 | } 131 | 132 | private func muteChat(_ chatId: Int64) { 133 | Task.detached { 134 | do { 135 | 136 | guard let chat = try await TDLibManager.shared.client?.getChat(chatId: chatId) 137 | else { return } 138 | 139 | let currentNotificationSettings = chat.notificationSettings 140 | let currentIsMuted = currentNotificationSettings.muteFor != 0 141 | 142 | let oneHour = 60 * 60 143 | let oneDay = 24 * oneHour 144 | let oneYear = oneDay * 365 145 | 146 | /// A value of 0 mens unmute 147 | let unmute = 0 148 | /// A values above 366 days means mute forever 149 | let foreverMute = oneYear + (oneDay * 2) 150 | 151 | let newNotificationSettings = currentNotificationSettings.copyWith( 152 | muteFor: currentIsMuted ? unmute : foreverMute 153 | ) 154 | 155 | try await TDLibManager.shared.client?.setChatNotificationSettings( 156 | chatId: chat.id, 157 | notificationSettings: newNotificationSettings 158 | ) 159 | 160 | await MainActor.run { 161 | let index = self.chats.firstIndex { c in c.id == chatId } 162 | guard let index, index != -1 else { return } 163 | 164 | withAnimation { 165 | self.chats[index].isMuted = !currentIsMuted 166 | } 167 | } 168 | 169 | self.logger.log("Notification settings updated") 170 | 171 | } catch { 172 | self.logger.log(error, level: .error) 173 | } 174 | 175 | } 176 | 177 | } 178 | } 179 | 180 | // MARK: - Mock 181 | @Observable 182 | class ChatListViewModelMock: ChatListViewModel { 183 | init() { 184 | super.init(folder: .main) 185 | 186 | self.chats = [ 187 | .init( 188 | id: 0, 189 | title: "Alessandro", 190 | time: "10:09", 191 | avatar: .alessandro, 192 | isMuted: false, 193 | isPinned: false, 194 | messageStyle: .message("Lorem ipsum dolor sit amet."), 195 | unreadBadgeStyle: .message(count: 3) 196 | ), 197 | .init( 198 | id: 1, 199 | title: "Marco", 200 | time: "09:41", 201 | avatar: .marco, 202 | isMuted: false, 203 | isPinned: false, 204 | messageStyle: .action("is typing"), 205 | unreadBadgeStyle: .reaction 206 | ), 207 | ] 208 | } 209 | 210 | override func connectionStateUpdate(state: ConnectionState) {} 211 | override func updateHandler(update: Update) {} 212 | override func initChatList() {} 213 | } 214 | --------------------------------------------------------------------------------