Compare commits
11 Commits
217328505c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f65fe39ab | |||
|
|
d48a03f93d | ||
|
|
c12f9f9b78 | ||
|
|
5a32ee0a4c | ||
|
|
782f08e00e | ||
|
|
ec74961677 | ||
|
|
508db79c34 | ||
|
|
da41e1ed91 | ||
|
|
419f409d98 | ||
|
|
a0413f23b3 | ||
|
|
6062c1b503 |
@@ -23,6 +23,9 @@ let localFfiTargets: [Target] = useLocalFfi ? [
|
|||||||
let coreDependencies: [Target.Dependency] = useLocalFfi ? [
|
let coreDependencies: [Target.Dependency] = useLocalFfi ? [
|
||||||
"tele_ios_ffi",
|
"tele_ios_ffi",
|
||||||
] : []
|
] : []
|
||||||
|
let coreSwiftSettings: [SwiftSetting] = useLocalFfi ? [
|
||||||
|
.define("TELE_IOS_USE_LOCAL_FFI"),
|
||||||
|
] : []
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "TeleTuiIOS",
|
name: "TeleTuiIOS",
|
||||||
@@ -38,7 +41,8 @@ let package = Package(
|
|||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
name: "TeleTuiIOSCore",
|
name: "TeleTuiIOSCore",
|
||||||
dependencies: coreDependencies
|
dependencies: coreDependencies,
|
||||||
|
swiftSettings: coreSwiftSettings
|
||||||
),
|
),
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "TeleTuiIOSApp",
|
name: "TeleTuiIOSApp",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public actor FakeSessionBridge: SessionBridge {
|
|||||||
private var messages: [Int64: [Message]]
|
private var messages: [Int64: [Message]]
|
||||||
private var events: [SessionEvent]
|
private var events: [SessionEvent]
|
||||||
private var nextMessageId: Int64
|
private var nextMessageId: Int64
|
||||||
|
private static let baseMessageDate: Int32 = 1_700_000_000
|
||||||
|
|
||||||
public init(auth: AuthState = .waitPhoneNumber) {
|
public init(auth: AuthState = .waitPhoneNumber) {
|
||||||
self.auth = auth
|
self.auth = auth
|
||||||
@@ -54,10 +55,27 @@ public actor FakeSessionBridge: SessionBridge {
|
|||||||
self.chats = [saved, team]
|
self.chats = [saved, team]
|
||||||
self.messages = [
|
self.messages = [
|
||||||
1: [
|
1: [
|
||||||
Message(id: 1, chatId: 1, senderName: "Alice", text: "Hello from fake TDLib", isOutgoing: false, isRead: false)
|
Message(
|
||||||
|
id: 1,
|
||||||
|
chatId: 1,
|
||||||
|
senderName: "Alice",
|
||||||
|
text: "Hello from fake TDLib",
|
||||||
|
date: Self.baseMessageDate,
|
||||||
|
media: .photo(PhotoMedia(fileId: 100, width: 1280, height: 720)),
|
||||||
|
isOutgoing: false,
|
||||||
|
isRead: false
|
||||||
|
)
|
||||||
],
|
],
|
||||||
2: [
|
2: [
|
||||||
Message(id: 2, chatId: 2, senderName: "Mikhail", text: "Bridge smoke is green", isOutgoing: true)
|
Message(
|
||||||
|
id: 2,
|
||||||
|
chatId: 2,
|
||||||
|
senderName: "Mikhail",
|
||||||
|
text: "Bridge smoke is green",
|
||||||
|
date: Self.baseMessageDate + 60,
|
||||||
|
media: .voice(VoiceMedia(fileId: 200, duration: 12, mimeType: "audio/ogg")),
|
||||||
|
isOutgoing: true
|
||||||
|
)
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
self.events = [.chatListChanged([saved, team])]
|
self.events = [.chatListChanged([saved, team])]
|
||||||
@@ -145,6 +163,7 @@ public actor FakeSessionBridge: SessionBridge {
|
|||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
senderName: "Me",
|
senderName: "Me",
|
||||||
text: text,
|
text: text,
|
||||||
|
date: Int32(Date().timeIntervalSince1970),
|
||||||
isOutgoing: true,
|
isOutgoing: true,
|
||||||
replyText: replyToMessageId.map { "Reply to #\($0)" }
|
replyText: replyToMessageId.map { "Reply to #\($0)" }
|
||||||
)
|
)
|
||||||
@@ -182,6 +201,7 @@ public actor FakeSessionBridge: SessionBridge {
|
|||||||
chatId: toChatId,
|
chatId: toChatId,
|
||||||
senderName: "Me",
|
senderName: "Me",
|
||||||
text: source.text,
|
text: source.text,
|
||||||
|
date: Int32(Date().timeIntervalSince1970),
|
||||||
isOutgoing: true,
|
isOutgoing: true,
|
||||||
forwardSenderName: source.senderName
|
forwardSenderName: source.senderName
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,11 +91,67 @@ public struct Reaction: Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum MediaDownloadState: Equatable, Sendable {
|
||||||
|
case notDownloaded
|
||||||
|
case downloading
|
||||||
|
case downloaded(path: String)
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PhotoMedia: Equatable, Sendable {
|
||||||
|
public var fileId: Int32
|
||||||
|
public var width: Int32
|
||||||
|
public var height: Int32
|
||||||
|
public var downloadState: MediaDownloadState
|
||||||
|
|
||||||
|
public init(
|
||||||
|
fileId: Int32,
|
||||||
|
width: Int32,
|
||||||
|
height: Int32,
|
||||||
|
downloadState: MediaDownloadState = .notDownloaded
|
||||||
|
) {
|
||||||
|
self.fileId = fileId
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.downloadState = downloadState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct VoiceMedia: Equatable, Sendable {
|
||||||
|
public var fileId: Int32
|
||||||
|
public var duration: Int32
|
||||||
|
public var mimeType: String?
|
||||||
|
public var waveform: String?
|
||||||
|
public var downloadState: MediaDownloadState
|
||||||
|
|
||||||
|
public init(
|
||||||
|
fileId: Int32,
|
||||||
|
duration: Int32,
|
||||||
|
mimeType: String? = nil,
|
||||||
|
waveform: String? = nil,
|
||||||
|
downloadState: MediaDownloadState = .notDownloaded
|
||||||
|
) {
|
||||||
|
self.fileId = fileId
|
||||||
|
self.duration = duration
|
||||||
|
self.mimeType = mimeType
|
||||||
|
self.waveform = waveform
|
||||||
|
self.downloadState = downloadState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MessageMedia: Equatable, Sendable {
|
||||||
|
case photo(PhotoMedia)
|
||||||
|
case voice(VoiceMedia)
|
||||||
|
}
|
||||||
|
|
||||||
public struct Message: Identifiable, Equatable, Sendable {
|
public struct Message: Identifiable, Equatable, Sendable {
|
||||||
public var id: Int64
|
public var id: Int64
|
||||||
public var chatId: Int64
|
public var chatId: Int64
|
||||||
public var senderName: String
|
public var senderName: String
|
||||||
public var text: String
|
public var text: String
|
||||||
|
public var date: Int32
|
||||||
|
public var mediaAlbumId: Int64?
|
||||||
|
public var media: MessageMedia?
|
||||||
public var isOutgoing: Bool
|
public var isOutgoing: Bool
|
||||||
public var isRead: Bool
|
public var isRead: Bool
|
||||||
public var editDate: Int32?
|
public var editDate: Int32?
|
||||||
@@ -108,6 +164,9 @@ public struct Message: Identifiable, Equatable, Sendable {
|
|||||||
chatId: Int64,
|
chatId: Int64,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
text: String,
|
text: String,
|
||||||
|
date: Int32 = 0,
|
||||||
|
mediaAlbumId: Int64? = nil,
|
||||||
|
media: MessageMedia? = nil,
|
||||||
isOutgoing: Bool,
|
isOutgoing: Bool,
|
||||||
isRead: Bool = true,
|
isRead: Bool = true,
|
||||||
editDate: Int32? = nil,
|
editDate: Int32? = nil,
|
||||||
@@ -119,6 +178,9 @@ public struct Message: Identifiable, Equatable, Sendable {
|
|||||||
self.chatId = chatId
|
self.chatId = chatId
|
||||||
self.senderName = senderName
|
self.senderName = senderName
|
||||||
self.text = text
|
self.text = text
|
||||||
|
self.date = date
|
||||||
|
self.mediaAlbumId = mediaAlbumId
|
||||||
|
self.media = media
|
||||||
self.isOutgoing = isOutgoing
|
self.isOutgoing = isOutgoing
|
||||||
self.isRead = isRead
|
self.isRead = isRead
|
||||||
self.editDate = editDate
|
self.editDate = editDate
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
|
#if canImport(UserNotifications)
|
||||||
|
import UserNotifications
|
||||||
|
#endif
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
@@ -62,6 +66,49 @@ public protocol NotificationScheduling: Sendable {
|
|||||||
func schedule(chat: ChatSummary, message: Message) async throws
|
func schedule(chat: ChatSummary, message: Message) async throws
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct SystemNotificationScheduler: NotificationScheduling {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func schedule(chat: ChatSummary, message: Message) async throws {
|
||||||
|
#if canImport(UserNotifications)
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = chat.title
|
||||||
|
content.body = "\(message.senderName): \(message.text)"
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "chat-\(chat.id)-message-\(message.id)",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
try await UNUserNotificationCenter.current().add(request)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct NotificationCoordinator: Sendable {
|
||||||
|
public var policy: NotificationPolicy
|
||||||
|
public var scheduler: NotificationScheduling
|
||||||
|
public var mentionOnly: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
policy: NotificationPolicy = NotificationPolicy(),
|
||||||
|
scheduler: NotificationScheduling,
|
||||||
|
mentionOnly: Bool = false
|
||||||
|
) {
|
||||||
|
self.policy = policy
|
||||||
|
self.scheduler = scheduler
|
||||||
|
self.mentionOnly = mentionOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
public func handle(chat: ChatSummary, message: Message) async throws {
|
||||||
|
guard policy.shouldNotify(chat: chat, message: message, mentionOnly: mentionOnly) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try await scheduler.schedule(chat: chat, message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public actor RecordingNotificationScheduler: NotificationScheduling {
|
public actor RecordingNotificationScheduler: NotificationScheduling {
|
||||||
public private(set) var scheduled: [(ChatSummary, Message)] = []
|
public private(set) var scheduled: [(ChatSummary, Message)] = []
|
||||||
|
|
||||||
@@ -70,6 +117,10 @@ public actor RecordingNotificationScheduler: NotificationScheduling {
|
|||||||
public func schedule(chat: ChatSummary, message: Message) async throws {
|
public func schedule(chat: ChatSummary, message: Message) async throws {
|
||||||
scheduled.append((chat, message))
|
scheduled.append((chat, message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func scheduledCount() -> Int {
|
||||||
|
scheduled.count
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public protocol URLOpening: Sendable {
|
public protocol URLOpening: Sendable {
|
||||||
@@ -115,6 +166,28 @@ public protocol VoicePlayback: Sendable {
|
|||||||
func seek(to seconds: TimeInterval) async
|
func seek(to seconds: TimeInterval) async
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public actor SystemVoicePlayer: VoicePlayback {
|
||||||
|
private var player: AVPlayer?
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func load(url: URL) async throws {
|
||||||
|
player = AVPlayer(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func play() async {
|
||||||
|
player?.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pause() async {
|
||||||
|
player?.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func seek(to seconds: TimeInterval) async {
|
||||||
|
await player?.seek(to: CMTime(seconds: seconds, preferredTimescale: 600))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public actor RecordingVoicePlayer: VoicePlayback {
|
public actor RecordingVoicePlayer: VoicePlayback {
|
||||||
public private(set) var loadedURL: URL?
|
public private(set) var loadedURL: URL?
|
||||||
public private(set) var isPlaying = false
|
public private(set) var isPlaying = false
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ public enum SessionBridgeFactory {
|
|||||||
account: Account,
|
account: Account,
|
||||||
useFakeTdlib: Bool = true
|
useFakeTdlib: Bool = true
|
||||||
) -> SessionBridge {
|
) -> SessionBridge {
|
||||||
#if canImport(tele_ios_ffi) || canImport(tele_ios_ffiFFI)
|
#if TELE_IOS_USE_LOCAL_FFI || TELE_IOS_TYPECHECK_UNIFFI
|
||||||
do {
|
do {
|
||||||
return try UniFfiSessionBridge(account: account, useFakeTdlib: useFakeTdlib)
|
return try UniFfiSessionBridge(account: account, useFakeTdlib: useFakeTdlib)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
#if canImport(tele_ios_ffi)
|
#if TELE_IOS_USE_LOCAL_FFI
|
||||||
import tele_ios_ffi
|
import tele_ios_ffi
|
||||||
#elseif canImport(tele_ios_ffiFFI)
|
#elseif TELE_IOS_TYPECHECK_UNIFFI
|
||||||
import tele_ios_ffiFFI
|
import tele_ios_ffiFFI
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if canImport(tele_ios_ffi) || canImport(tele_ios_ffiFFI)
|
#if TELE_IOS_USE_LOCAL_FFI || TELE_IOS_TYPECHECK_UNIFFI
|
||||||
public actor UniFfiSessionBridge: SessionBridge {
|
public actor UniFfiSessionBridge: SessionBridge {
|
||||||
private let handle: SessionHandle
|
private let handle: SessionHandle
|
||||||
private let defaultLimit: Int32
|
private let defaultLimit: Int32
|
||||||
@@ -199,12 +199,50 @@ public actor UniFfiSessionBridge: SessionBridge {
|
|||||||
Reaction(emoji: reaction.emoji, count: reaction.count, isChosen: reaction.isChosen)
|
Reaction(emoji: reaction.emoji, count: reaction.count, isChosen: reaction.isChosen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func mapDownloadState(_ state: IosDownloadState) -> MediaDownloadState {
|
||||||
|
switch state {
|
||||||
|
case .notDownloaded:
|
||||||
|
.notDownloaded
|
||||||
|
case .downloading:
|
||||||
|
.downloading
|
||||||
|
case let .downloaded(path):
|
||||||
|
.downloaded(path: path)
|
||||||
|
case let .error(message):
|
||||||
|
.error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapMedia(_ media: IosMedia) -> MessageMedia? {
|
||||||
|
switch media.kind {
|
||||||
|
case "photo":
|
||||||
|
.photo(PhotoMedia(
|
||||||
|
fileId: media.fileId,
|
||||||
|
width: media.width ?? 0,
|
||||||
|
height: media.height ?? 0,
|
||||||
|
downloadState: mapDownloadState(media.downloadState)
|
||||||
|
))
|
||||||
|
case "voice":
|
||||||
|
.voice(VoiceMedia(
|
||||||
|
fileId: media.fileId,
|
||||||
|
duration: media.duration ?? 0,
|
||||||
|
mimeType: media.mimeType,
|
||||||
|
waveform: media.waveform,
|
||||||
|
downloadState: mapDownloadState(media.downloadState)
|
||||||
|
))
|
||||||
|
default:
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func mapMessage(_ message: IosMessage, chatId: Int64) -> Message {
|
private static func mapMessage(_ message: IosMessage, chatId: Int64) -> Message {
|
||||||
Message(
|
Message(
|
||||||
id: message.id,
|
id: message.id,
|
||||||
chatId: chatId,
|
chatId: chatId,
|
||||||
senderName: message.senderName,
|
senderName: message.senderName,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
|
date: message.date,
|
||||||
|
mediaAlbumId: message.mediaAlbumId,
|
||||||
|
media: message.media.flatMap(mapMedia),
|
||||||
isOutgoing: message.isOutgoing,
|
isOutgoing: message.isOutgoing,
|
||||||
isRead: message.isRead,
|
isRead: message.isRead,
|
||||||
editDate: message.editDate,
|
editDate: message.editDate,
|
||||||
|
|||||||
@@ -195,6 +195,10 @@ public final class ChatViewModel: ObservableObject {
|
|||||||
replyTo = message
|
replyTo = message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func cancelReply() {
|
||||||
|
replyTo = nil
|
||||||
|
}
|
||||||
|
|
||||||
public func edit(message: Message, text: String) async {
|
public func edit(message: Message, text: String) async {
|
||||||
do {
|
do {
|
||||||
let edited = try await bridge.editMessage(chatId: chat.id, messageId: message.id, text: text)
|
let edited = try await bridge.editMessage(chatId: chat.id, messageId: message.id, text: text)
|
||||||
|
|||||||
@@ -17,13 +17,19 @@ public struct RootView: View {
|
|||||||
Group {
|
Group {
|
||||||
switch store.authState {
|
switch store.authState {
|
||||||
case .ready:
|
case .ready:
|
||||||
ChatListView(viewModel: chatListViewModel, bridge: store.bridge)
|
ChatListView(
|
||||||
|
viewModel: chatListViewModel,
|
||||||
|
bridge: store.bridge,
|
||||||
|
networkState: store.networkState,
|
||||||
|
typingState: store.typingState
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
AuthView(state: store.authState, viewModel: authViewModel)
|
AuthView(state: store.authState, viewModel: authViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await store.refreshAuthState()
|
await store.refreshAuthState()
|
||||||
|
await store.refreshNetworkState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,22 +124,34 @@ public struct AuthView: View {
|
|||||||
public struct ChatListView: View {
|
public struct ChatListView: View {
|
||||||
@ObservedObject public var viewModel: ChatListViewModel
|
@ObservedObject public var viewModel: ChatListViewModel
|
||||||
public let bridge: SessionBridge
|
public let bridge: SessionBridge
|
||||||
|
public var networkState: NetworkState
|
||||||
|
public var typingState: TypingState
|
||||||
@State private var selectedChat: ChatSummary?
|
@State private var selectedChat: ChatSummary?
|
||||||
@State private var showsAccountSwitcher = false
|
@State private var showsAccountSwitcher = false
|
||||||
|
|
||||||
public init(viewModel: ChatListViewModel, bridge: SessionBridge) {
|
public init(
|
||||||
|
viewModel: ChatListViewModel,
|
||||||
|
bridge: SessionBridge,
|
||||||
|
networkState: NetworkState = .ready,
|
||||||
|
typingState: TypingState = .idle
|
||||||
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.bridge = bridge
|
self.bridge = bridge
|
||||||
|
self.networkState = networkState
|
||||||
|
self.typingState = typingState
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
List(selection: $selectedChat) {
|
VStack(spacing: 0) {
|
||||||
ForEach(viewModel.filteredChats) { chat in
|
List(selection: $selectedChat) {
|
||||||
NavigationLink(value: chat) {
|
ForEach(viewModel.filteredChats) { chat in
|
||||||
ChatRow(chat: chat)
|
NavigationLink(value: chat) {
|
||||||
|
ChatRow(chat: chat)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ChatListStatusBar(networkState: networkState, typingState: typingState)
|
||||||
}
|
}
|
||||||
.navigationTitle("Chats")
|
.navigationTitle("Chats")
|
||||||
.searchable(text: $viewModel.searchText)
|
.searchable(text: $viewModel.searchText)
|
||||||
@@ -155,7 +173,10 @@ public struct ChatListView: View {
|
|||||||
}
|
}
|
||||||
} detail: {
|
} detail: {
|
||||||
if let selectedChat {
|
if let selectedChat {
|
||||||
ChatDetailView(viewModel: ChatViewModel(chat: selectedChat, bridge: bridge), bridge: bridge)
|
ChatDetailView(viewModel: ChatViewModel(chat: selectedChat, bridge: bridge), bridge: bridge) {
|
||||||
|
self.selectedChat = nil
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("Select a chat")
|
Text("Select a chat")
|
||||||
}
|
}
|
||||||
@@ -178,6 +199,71 @@ public struct ChatListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ChatListStatusBar: View {
|
||||||
|
public var networkState: NetworkState
|
||||||
|
public var typingState: TypingState
|
||||||
|
|
||||||
|
public init(networkState: NetworkState, typingState: TypingState) {
|
||||||
|
self.networkState = networkState
|
||||||
|
self.typingState = typingState
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: networkIconName)
|
||||||
|
.foregroundStyle(networkState == .ready ? .green : .orange)
|
||||||
|
Text(statusText)
|
||||||
|
.font(.footnote)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var networkIconName: String {
|
||||||
|
switch networkState {
|
||||||
|
case .ready:
|
||||||
|
"checkmark.circle.fill"
|
||||||
|
case .waitingForNetwork:
|
||||||
|
"wifi.slash"
|
||||||
|
case .connectingToProxy:
|
||||||
|
"shield.lefthalf.filled"
|
||||||
|
case .connecting:
|
||||||
|
"antenna.radiowaves.left.and.right"
|
||||||
|
case .updating:
|
||||||
|
"arrow.triangle.2.circlepath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusText: String {
|
||||||
|
switch typingState {
|
||||||
|
case let .typing(_, _, text) where !text.isEmpty:
|
||||||
|
text
|
||||||
|
case .typing:
|
||||||
|
"Typing"
|
||||||
|
case .idle:
|
||||||
|
networkText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var networkText: String {
|
||||||
|
switch networkState {
|
||||||
|
case .ready:
|
||||||
|
"Online"
|
||||||
|
case .waitingForNetwork:
|
||||||
|
"Waiting for network"
|
||||||
|
case .connectingToProxy:
|
||||||
|
"Connecting to proxy"
|
||||||
|
case .connecting:
|
||||||
|
"Connecting"
|
||||||
|
case .updating:
|
||||||
|
"Updating"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct ChatRow: View {
|
public struct ChatRow: View {
|
||||||
public var chat: ChatSummary
|
public var chat: ChatSummary
|
||||||
|
|
||||||
@@ -195,7 +281,20 @@ public struct ChatRow: View {
|
|||||||
Image(systemName: "pin.fill")
|
Image(systemName: "pin.fill")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
|
if chat.isMuted {
|
||||||
|
Image(systemName: "bell.slash")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if chat.unreadMentionCount > 0 {
|
||||||
|
Text("@\(chat.unreadMentionCount)")
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(.orange, in: Capsule())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
if chat.unreadCount > 0 {
|
if chat.unreadCount > 0 {
|
||||||
Text("\(chat.unreadCount)")
|
Text("\(chat.unreadCount)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -205,7 +304,13 @@ public struct ChatRow: View {
|
|||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(chat.draft?.text ?? chat.lastMessage)
|
HStack(spacing: 4) {
|
||||||
|
if chat.draft != nil {
|
||||||
|
Text("Draft")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
Text(chat.draft?.text ?? chat.lastMessage)
|
||||||
|
}
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(chat.draft == nil ? Color.secondary : Color.red)
|
.foregroundStyle(chat.draft == nil ? Color.secondary : Color.red)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
@@ -217,36 +322,113 @@ public struct ChatRow: View {
|
|||||||
public struct ChatDetailView: View {
|
public struct ChatDetailView: View {
|
||||||
@StateObject public var viewModel: ChatViewModel
|
@StateObject public var viewModel: ChatViewModel
|
||||||
public let bridge: SessionBridge
|
public let bridge: SessionBridge
|
||||||
|
public let clipboard: ClipboardWriting
|
||||||
@StateObject private var profileViewModel: ProfileViewModel
|
@StateObject private var profileViewModel: ProfileViewModel
|
||||||
@State private var showsProfile = false
|
@State private var showsProfile = false
|
||||||
|
@State private var editingMessage: Message?
|
||||||
|
@State private var editedText = ""
|
||||||
|
@State private var deleteCandidate: Message?
|
||||||
|
@State private var forwardCandidate: Message?
|
||||||
|
@State private var reactionCandidate: Message?
|
||||||
|
@State private var forwardChatIdText = ""
|
||||||
|
private let onChatLeft: () -> Void
|
||||||
|
|
||||||
public init(viewModel: ChatViewModel, bridge: SessionBridge) {
|
public init(
|
||||||
|
viewModel: ChatViewModel,
|
||||||
|
bridge: SessionBridge,
|
||||||
|
clipboard: ClipboardWriting = SystemClipboardWriter(),
|
||||||
|
onChatLeft: @escaping () -> Void = {}
|
||||||
|
) {
|
||||||
_viewModel = StateObject(wrappedValue: viewModel)
|
_viewModel = StateObject(wrappedValue: viewModel)
|
||||||
self.bridge = bridge
|
self.bridge = bridge
|
||||||
|
self.clipboard = clipboard
|
||||||
|
self.onChatLeft = onChatLeft
|
||||||
_profileViewModel = StateObject(wrappedValue: ProfileViewModel(bridge: bridge))
|
_profileViewModel = StateObject(wrappedValue: ProfileViewModel(bridge: bridge))
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
List(viewModel.messages) { message in
|
ScrollViewReader { scrollProxy in
|
||||||
MessageRow(message: message)
|
VStack(spacing: 0) {
|
||||||
.contextMenu {
|
if !viewModel.pinnedMessages.isEmpty {
|
||||||
Button("Reply") {
|
PinnedMessagesBar(messages: viewModel.pinnedMessages) { message in
|
||||||
viewModel.beginReply(to: message)
|
withAnimation {
|
||||||
}
|
scrollProxy.scrollTo(message.id, anchor: .center)
|
||||||
Button("React") {
|
}
|
||||||
Task { await viewModel.react(message: message, reaction: "👍") }
|
|
||||||
}
|
|
||||||
Button("Copy") {
|
|
||||||
Task { await viewModel.copyPayload(for: message) }
|
|
||||||
}
|
|
||||||
Button("Delete", role: .destructive) {
|
|
||||||
Task { await viewModel.delete(message: message) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowSeparator(.hidden)
|
List {
|
||||||
|
if !viewModel.searchText.isEmpty {
|
||||||
|
Section("Search") {
|
||||||
|
if viewModel.searchResults.isEmpty {
|
||||||
|
Text("No results")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.searchResults) { message in
|
||||||
|
MessageRow(message: message)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
ForEach(Array(viewModel.messages.enumerated()), id: \.element.id) { index, message in
|
||||||
|
if shouldShowDateSeparator(at: index) {
|
||||||
|
DateSeparatorView(timestamp: message.date)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
MessageRow(message: message, showsSender: shouldShowSender(at: index))
|
||||||
|
.id(message.id)
|
||||||
|
.contextMenu {
|
||||||
|
Button {
|
||||||
|
viewModel.beginReply(to: message)
|
||||||
|
} label: {
|
||||||
|
Label("Reply", systemImage: "arrowshape.turn.up.left")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
editingMessage = message
|
||||||
|
editedText = message.text
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
forwardCandidate = message
|
||||||
|
forwardChatIdText = ""
|
||||||
|
} label: {
|
||||||
|
Label("Forward", systemImage: "arrowshape.turn.up.forward")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
reactionCandidate = message
|
||||||
|
} label: {
|
||||||
|
Label("React", systemImage: "face.smiling")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await viewModel.copyPayload(for: message)
|
||||||
|
if let payload = viewModel.copiedPayload {
|
||||||
|
await clipboard.write(text: payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Copy", systemImage: "doc.on.doc")
|
||||||
|
}
|
||||||
|
Button(role: .destructive) {
|
||||||
|
deleteCandidate = message
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ComposeBar(text: $viewModel.composeText) {
|
ComposeBar(
|
||||||
|
text: $viewModel.composeText,
|
||||||
|
replyTo: viewModel.replyTo,
|
||||||
|
cancelReply: { viewModel.cancelReply() }
|
||||||
|
) {
|
||||||
Task { await viewModel.send() }
|
Task { await viewModel.send() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,19 +444,229 @@ public struct ChatDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showsProfile) {
|
.sheet(isPresented: $showsProfile) {
|
||||||
ProfileView(viewModel: profileViewModel)
|
ProfileView(viewModel: profileViewModel, chatId: viewModel.chat.id) {
|
||||||
|
showsProfile = false
|
||||||
|
onChatLeft()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Edit Message", isPresented: editAlertBinding) {
|
||||||
|
TextField("Message", text: $editedText)
|
||||||
|
Button("Save") {
|
||||||
|
if let editingMessage {
|
||||||
|
Task { await viewModel.edit(message: editingMessage, text: editedText) }
|
||||||
|
}
|
||||||
|
editingMessage = nil
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
editingMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Delete Message", isPresented: deleteAlertBinding) {
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
if let deleteCandidate {
|
||||||
|
Task { await viewModel.delete(message: deleteCandidate) }
|
||||||
|
}
|
||||||
|
deleteCandidate = nil
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
deleteCandidate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Forward Message", isPresented: forwardAlertBinding) {
|
||||||
|
TextField("Chat ID", text: $forwardChatIdText)
|
||||||
|
Button("Forward") {
|
||||||
|
if let forwardCandidate, let chatId = Int64(forwardChatIdText) {
|
||||||
|
Task { await viewModel.forward(message: forwardCandidate, to: chatId) }
|
||||||
|
}
|
||||||
|
forwardCandidate = nil
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
forwardCandidate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog("React", isPresented: reactionDialogBinding, titleVisibility: .visible) {
|
||||||
|
ForEach(["👍", "❤️", "😂", "😮", "😢", "🙏"], id: \.self) { reaction in
|
||||||
|
Button(reaction) {
|
||||||
|
if let reactionCandidate {
|
||||||
|
Task { await viewModel.react(message: reactionCandidate, reaction: reaction) }
|
||||||
|
}
|
||||||
|
reactionCandidate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
reactionCandidate = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.load()
|
await viewModel.load()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var editAlertBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { editingMessage != nil },
|
||||||
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
editingMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deleteAlertBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { deleteCandidate != nil },
|
||||||
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
deleteCandidate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var forwardAlertBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { forwardCandidate != nil },
|
||||||
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
forwardCandidate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var reactionDialogBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { reactionCandidate != nil },
|
||||||
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
reactionCandidate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldShowDateSeparator(at index: Int) -> Bool {
|
||||||
|
guard viewModel.messages.indices.contains(index), viewModel.messages[index].date > 0 else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard index > 0, viewModel.messages.indices.contains(index - 1) else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let current = Date(timeIntervalSince1970: TimeInterval(viewModel.messages[index].date))
|
||||||
|
let previous = Date(timeIntervalSince1970: TimeInterval(viewModel.messages[index - 1].date))
|
||||||
|
return !Calendar.current.isDate(current, inSameDayAs: previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldShowSender(at index: Int) -> Bool {
|
||||||
|
guard viewModel.messages.indices.contains(index) else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let message = viewModel.messages[index]
|
||||||
|
guard !message.isOutgoing else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard index > 0, viewModel.messages.indices.contains(index - 1) else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let previous = viewModel.messages[index - 1]
|
||||||
|
return previous.isOutgoing
|
||||||
|
|| previous.senderName != message.senderName
|
||||||
|
|| shouldShowDateSeparator(at: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DateSeparatorView: View {
|
||||||
|
public var timestamp: Int32
|
||||||
|
|
||||||
|
public init(timestamp: Int32) {
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.gray.opacity(0.12), in: Capsule())
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var label: String {
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(timestamp))
|
||||||
|
let calendar = Calendar.current
|
||||||
|
if calendar.isDateInToday(date) {
|
||||||
|
return "Today"
|
||||||
|
}
|
||||||
|
if calendar.isDateInYesterday(date) {
|
||||||
|
return "Yesterday"
|
||||||
|
}
|
||||||
|
return Self.formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let formatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PinnedMessagesBar: View {
|
||||||
|
public var messages: [Message]
|
||||||
|
public var select: (Message) -> Void
|
||||||
|
|
||||||
|
public init(messages: [Message], select: @escaping (Message) -> Void) {
|
||||||
|
self.messages = messages
|
||||||
|
self.select = select
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(messages.prefix(3)) { message in
|
||||||
|
Button {
|
||||||
|
select(message)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "pin.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(message.senderName)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(message.text)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.blue.opacity(0.08))
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct MessageRow: View {
|
public struct MessageRow: View {
|
||||||
public var message: Message
|
public var message: Message
|
||||||
|
public var showsSender: Bool
|
||||||
|
|
||||||
public init(message: Message) {
|
public init(message: Message, showsSender: Bool = true) {
|
||||||
self.message = message
|
self.message = message
|
||||||
|
self.showsSender = showsSender
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
@@ -283,7 +675,7 @@ public struct MessageRow: View {
|
|||||||
Spacer(minLength: 48)
|
Spacer(minLength: 48)
|
||||||
}
|
}
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
if !message.isOutgoing {
|
if showsSender && !message.isOutgoing {
|
||||||
Text(message.senderName)
|
Text(message.senderName)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -294,12 +686,35 @@ public struct MessageRow: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.leading, 6)
|
.padding(.leading, 6)
|
||||||
}
|
}
|
||||||
Text(message.text)
|
if let forwardSenderName = message.forwardSenderName {
|
||||||
|
Label(forwardSenderName, systemImage: "arrowshape.turn.up.forward")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let media = message.media {
|
||||||
|
MediaPlaceholderView(media: media, mediaAlbumId: message.mediaAlbumId)
|
||||||
|
}
|
||||||
|
Text(renderedText)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
if !message.reactions.isEmpty {
|
if !message.reactions.isEmpty {
|
||||||
Text(message.reactions.map(\.emoji).joined(separator: " "))
|
Text(message.reactions.map(\.emoji).joined(separator: " "))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
|
if message.editDate != nil || message.date > 0 || message.isOutgoing {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if message.date > 0 {
|
||||||
|
Text(Self.timeFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(message.date))))
|
||||||
|
}
|
||||||
|
if message.editDate != nil {
|
||||||
|
Text("edited")
|
||||||
|
}
|
||||||
|
if message.isOutgoing {
|
||||||
|
Image(systemName: message.isRead ? "checkmark.circle.fill" : "checkmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(10)
|
.padding(10)
|
||||||
.background(message.isOutgoing ? Color.blue.opacity(0.16) : Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
.background(message.isOutgoing ? Color.blue.opacity(0.16) : Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||||||
@@ -308,35 +723,161 @@ public struct MessageRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var renderedText: AttributedString {
|
||||||
|
(
|
||||||
|
try? AttributedString(
|
||||||
|
markdown: message.text,
|
||||||
|
options: AttributedString.MarkdownParsingOptions(
|
||||||
|
interpretedSyntax: .inlineOnlyPreservingWhitespace
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) ?? AttributedString(message.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let timeFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MediaPlaceholderView: View {
|
||||||
|
public var media: MessageMedia
|
||||||
|
public var mediaAlbumId: Int64?
|
||||||
|
|
||||||
|
public init(media: MessageMedia, mediaAlbumId: Int64? = nil) {
|
||||||
|
self.media = media
|
||||||
|
self.mediaAlbumId = mediaAlbumId
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: iconName)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline)
|
||||||
|
if mediaAlbumId != nil {
|
||||||
|
Image(systemName: "square.stack.3d.up.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(detail)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.gray.opacity(0.10), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconName: String {
|
||||||
|
switch media {
|
||||||
|
case .photo:
|
||||||
|
"photo"
|
||||||
|
case .voice:
|
||||||
|
"waveform"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var title: String {
|
||||||
|
switch media {
|
||||||
|
case .photo:
|
||||||
|
"Photo"
|
||||||
|
case .voice:
|
||||||
|
"Voice"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var detail: String {
|
||||||
|
switch media {
|
||||||
|
case let .photo(photo):
|
||||||
|
"\(photo.width)x\(photo.height) · \(downloadLabel(photo.downloadState))"
|
||||||
|
case let .voice(voice):
|
||||||
|
"\(voice.duration)s · \(downloadLabel(voice.downloadState))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func downloadLabel(_ state: MediaDownloadState) -> String {
|
||||||
|
switch state {
|
||||||
|
case .notDownloaded:
|
||||||
|
"not downloaded"
|
||||||
|
case .downloading:
|
||||||
|
"downloading"
|
||||||
|
case .downloaded:
|
||||||
|
"downloaded"
|
||||||
|
case .error:
|
||||||
|
"error"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ComposeBar: View {
|
public struct ComposeBar: View {
|
||||||
@Binding public var text: String
|
@Binding public var text: String
|
||||||
|
public var replyTo: Message?
|
||||||
|
public var cancelReply: () -> Void
|
||||||
public var send: () -> Void
|
public var send: () -> Void
|
||||||
|
|
||||||
public init(text: Binding<String>, send: @escaping () -> Void) {
|
public init(
|
||||||
|
text: Binding<String>,
|
||||||
|
replyTo: Message? = nil,
|
||||||
|
cancelReply: @escaping () -> Void = {},
|
||||||
|
send: @escaping () -> Void
|
||||||
|
) {
|
||||||
_text = text
|
_text = text
|
||||||
|
self.replyTo = replyTo
|
||||||
|
self.cancelReply = cancelReply
|
||||||
self.send = send
|
self.send = send
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
HStack(spacing: 10) {
|
VStack(spacing: 8) {
|
||||||
if !text.isEmpty {
|
if let replyTo {
|
||||||
Button {
|
HStack {
|
||||||
text = ""
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
} label: {
|
Text(replyTo.senderName)
|
||||||
Image(systemName: "xmark.circle.fill")
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(replyTo.text)
|
||||||
|
.font(.footnote)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button(action: cancelReply) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
TextField("Message", text: $text, axis: .vertical)
|
HStack(spacing: 10) {
|
||||||
.textFieldStyle(.roundedBorder)
|
if !text.isEmpty {
|
||||||
.lineLimit(1...5)
|
Button {
|
||||||
Button(action: send) {
|
text = ""
|
||||||
Image(systemName: "paperplane.fill")
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
TextField("Message", text: $text, axis: .vertical)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.lineLimit(1...5)
|
||||||
|
Button {
|
||||||
|
send()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "paperplane.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(.bar)
|
.background(.bar)
|
||||||
@@ -345,9 +886,14 @@ public struct ComposeBar: View {
|
|||||||
|
|
||||||
public struct ProfileView: View {
|
public struct ProfileView: View {
|
||||||
@ObservedObject public var viewModel: ProfileViewModel
|
@ObservedObject public var viewModel: ProfileViewModel
|
||||||
|
public var chatId: Int64?
|
||||||
|
public var onLeave: () -> Void
|
||||||
|
@State private var confirmsLeave = false
|
||||||
|
|
||||||
public init(viewModel: ProfileViewModel) {
|
public init(viewModel: ProfileViewModel, chatId: Int64? = nil, onLeave: @escaping () -> Void = {}) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
|
self.chatId = chatId
|
||||||
|
self.onLeave = onLeave
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
@@ -370,11 +916,33 @@ public struct ProfileView: View {
|
|||||||
Text("\(memberCount) members")
|
Text("\(memberCount) members")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if profile.isGroup, chatId != nil {
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
confirmsLeave = true
|
||||||
|
} label: {
|
||||||
|
Label("Leave Chat", systemImage: "rectangle.portrait.and.arrow.right")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Profile")
|
.navigationTitle("Profile")
|
||||||
|
.alert("Leave Chat", isPresented: $confirmsLeave) {
|
||||||
|
Button("Leave", role: .destructive) {
|
||||||
|
if let chatId {
|
||||||
|
Task {
|
||||||
|
await viewModel.leave(chatId: chatId)
|
||||||
|
if viewModel.errorMessage == nil {
|
||||||
|
onLeave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ struct TeleTuiIOSSmokeTests {
|
|||||||
|
|
||||||
await viewModel.load()
|
await viewModel.load()
|
||||||
precondition(viewModel.messages.count == 1)
|
precondition(viewModel.messages.count == 1)
|
||||||
|
precondition(viewModel.messages[0].date == 1_700_000_000)
|
||||||
|
precondition(viewModel.pinnedMessages.map(\.id) == [1])
|
||||||
|
if case let .photo(photo) = viewModel.messages[0].media {
|
||||||
|
precondition(photo.fileId == 100)
|
||||||
|
precondition(photo.width == 1280)
|
||||||
|
precondition(photo.height == 720)
|
||||||
|
} else {
|
||||||
|
preconditionFailure("fake saved message should contain photo media")
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.composeText = "Hi from SwiftUI"
|
viewModel.composeText = "Hi from SwiftUI"
|
||||||
await viewModel.send()
|
await viewModel.send()
|
||||||
@@ -152,6 +161,17 @@ struct TeleTuiIOSSmokeTests {
|
|||||||
precondition(policy.shouldNotify(chat: chat, message: incomingMention, mentionOnly: true))
|
precondition(policy.shouldNotify(chat: chat, message: incomingMention, mentionOnly: true))
|
||||||
precondition(!policy.shouldNotify(chat: chat, message: incomingPlain, mentionOnly: true))
|
precondition(!policy.shouldNotify(chat: chat, message: incomingPlain, mentionOnly: true))
|
||||||
precondition(!policy.shouldNotify(chat: muted, message: incomingMention, mentionOnly: false))
|
precondition(!policy.shouldNotify(chat: muted, message: incomingMention, mentionOnly: false))
|
||||||
|
let scheduler = RecordingNotificationScheduler()
|
||||||
|
let mentionCoordinator = NotificationCoordinator(scheduler: scheduler, mentionOnly: true)
|
||||||
|
try await mentionCoordinator.handle(chat: chat, message: incomingPlain)
|
||||||
|
var scheduledCount = await scheduler.scheduledCount()
|
||||||
|
precondition(scheduledCount == 0)
|
||||||
|
try await mentionCoordinator.handle(chat: chat, message: incomingMention)
|
||||||
|
scheduledCount = await scheduler.scheduledCount()
|
||||||
|
precondition(scheduledCount == 1)
|
||||||
|
try await mentionCoordinator.handle(chat: muted, message: incomingMention)
|
||||||
|
scheduledCount = await scheduler.scheduledCount()
|
||||||
|
precondition(scheduledCount == 1)
|
||||||
|
|
||||||
let clipboard = InMemoryClipboardWriter()
|
let clipboard = InMemoryClipboardWriter()
|
||||||
await clipboard.write(text: "copied")
|
await clipboard.write(text: "copied")
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ fi
|
|||||||
swiftc \
|
swiftc \
|
||||||
-typecheck \
|
-typecheck \
|
||||||
-parse-as-library \
|
-parse-as-library \
|
||||||
|
-D TELE_IOS_TYPECHECK_UNIFFI \
|
||||||
-module-name TeleTuiIOSCore \
|
-module-name TeleTuiIOSCore \
|
||||||
-module-cache-path "${module_cache}" \
|
-module-cache-path "${module_cache}" \
|
||||||
-I "${ffi_dir}/Headers" \
|
-I "${ffi_dir}/Headers" \
|
||||||
|
|||||||
Reference in New Issue
Block a user