├── 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 |
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 |
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 |
--------------------------------------------------------------------------------