Compare commits

..

11 Commits

Author SHA1 Message Date
8f65fe39ab Merge pull request 'feat/ios-core-session-api' (#32) from feat/ios-core-session-api into main
Some checks failed
iOS and Rust / rust (push) Has been cancelled
iOS and Rust / ios-shell (push) Has been cancelled
Reviewed-on: #32
2026-05-21 13:24:31 +00:00
Mikhail Kilin
d48a03f93d Add iOS AVPlayer voice backend
Some checks failed
iOS and Rust / rust (pull_request) Has been cancelled
iOS and Rust / ios-shell (pull_request) Has been cancelled
2026-05-21 15:57:23 +03:00
Mikhail Kilin
c12f9f9b78 Add iOS notification scheduler 2026-05-21 15:55:54 +03:00
Mikhail Kilin
5a32ee0a4c Add iOS reaction picker 2026-05-21 15:53:26 +03:00
Mikhail Kilin
782f08e00e Add iOS profile leave flow 2026-05-21 15:52:25 +03:00
Mikhail Kilin
ec74961677 Add iOS media placeholders 2026-05-21 15:50:14 +03:00
Mikhail Kilin
508db79c34 Add iOS message date grouping 2026-05-21 15:45:48 +03:00
Mikhail Kilin
da41e1ed91 Add iOS pinned messages bar 2026-05-21 15:39:16 +03:00
Mikhail Kilin
419f409d98 Render iOS message markdown 2026-05-21 15:37:04 +03:00
Mikhail Kilin
a0413f23b3 Add iOS chat list status indicators 2026-05-21 15:35:16 +03:00
Mikhail Kilin
6062c1b503 Add iOS message action controls 2026-05-21 15:33:18 +03:00
10 changed files with 842 additions and 52 deletions

View File

@@ -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",

View File

@@ -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
) )

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,16 +124,26 @@ 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 {
VStack(spacing: 0) {
List(selection: $selectedChat) { List(selection: $selectedChat) {
ForEach(viewModel.filteredChats) { chat in ForEach(viewModel.filteredChats) { chat in
NavigationLink(value: chat) { NavigationLink(value: chat) {
@@ -135,6 +151,8 @@ public struct ChatListView: View {
} }
} }
} }
ChatListStatusBar(networkState: networkState, typingState: typingState)
}
.navigationTitle("Chats") .navigationTitle("Chats")
.searchable(text: $viewModel.searchText) .searchable(text: $viewModel.searchText)
.toolbar { .toolbar {
@@ -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)
} }
} }
HStack(spacing: 4) {
if chat.draft != nil {
Text("Draft")
.fontWeight(.semibold)
}
Text(chat.draft?.text ?? chat.lastMessage) 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
VStack(spacing: 0) {
if !viewModel.pinnedMessages.isEmpty {
PinnedMessagesBar(messages: viewModel.pinnedMessages) { message in
withAnimation {
scrollProxy.scrollTo(message.id, anchor: .center)
}
}
}
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) 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 { .contextMenu {
Button("Reply") { Button {
viewModel.beginReply(to: message) viewModel.beginReply(to: message)
} label: {
Label("Reply", systemImage: "arrowshape.turn.up.left")
} }
Button("React") { Button {
Task { await viewModel.react(message: message, reaction: "👍") } editingMessage = message
editedText = message.text
} label: {
Label("Edit", systemImage: "pencil")
} }
Button("Copy") { Button {
Task { await viewModel.copyPayload(for: message) } forwardCandidate = message
forwardChatIdText = ""
} label: {
Label("Forward", systemImage: "arrowshape.turn.up.forward")
} }
Button("Delete", role: .destructive) { Button {
Task { await viewModel.delete(message: message) } 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) .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,18 +723,141 @@ 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 {
VStack(spacing: 8) {
if let replyTo {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(replyTo.senderName)
.font(.caption)
.foregroundStyle(.secondary)
Text(replyTo.text)
.font(.footnote)
.lineLimit(1)
}
Spacer()
Button(action: cancelReply) {
Image(systemName: "xmark.circle.fill")
}
.buttonStyle(.plain)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
}
HStack(spacing: 10) { HStack(spacing: 10) {
if !text.isEmpty { if !text.isEmpty {
Button { Button {
@@ -332,12 +870,15 @@ public struct ComposeBar: View {
TextField("Message", text: $text, axis: .vertical) TextField("Message", text: $text, axis: .vertical)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.lineLimit(1...5) .lineLimit(1...5)
Button(action: send) { Button {
send()
} label: {
Image(systemName: "paperplane.fill") Image(systemName: "paperplane.fill")
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .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) {}
}
} }
} }
} }

View File

@@ -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")

View File

@@ -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" \