371 lines
11 KiB
Swift
371 lines
11 KiB
Swift
import SwiftUI
|
|
|
|
public struct RootView: View {
|
|
@StateObject private var store: SessionStore
|
|
@StateObject private var authViewModel: AuthViewModel
|
|
@StateObject private var chatListViewModel: ChatListViewModel
|
|
|
|
public init(store: SessionStore) {
|
|
let authViewModel = AuthViewModel(store: store)
|
|
let chatListViewModel = ChatListViewModel(bridge: store.bridge)
|
|
_store = StateObject(wrappedValue: store)
|
|
_authViewModel = StateObject(wrappedValue: authViewModel)
|
|
_chatListViewModel = StateObject(wrappedValue: chatListViewModel)
|
|
}
|
|
|
|
public var body: some View {
|
|
Group {
|
|
switch store.authState {
|
|
case .ready:
|
|
ChatListView(viewModel: chatListViewModel, bridge: store.bridge)
|
|
default:
|
|
AuthView(state: store.authState, viewModel: authViewModel)
|
|
}
|
|
}
|
|
.task {
|
|
await store.refreshAuthState()
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct AuthView: View {
|
|
public var state: AuthState
|
|
@ObservedObject public var viewModel: AuthViewModel
|
|
|
|
public init(state: AuthState, viewModel: AuthViewModel) {
|
|
self.state = state
|
|
self.viewModel = viewModel
|
|
}
|
|
|
|
public var body: some View {
|
|
VStack(spacing: 16) {
|
|
Text("Telegram")
|
|
.font(.largeTitle)
|
|
.fontWeight(.semibold)
|
|
|
|
authField
|
|
|
|
Button(action: {
|
|
Task { await viewModel.submitCurrentStep() }
|
|
}) {
|
|
Text(buttonTitle)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(viewModel.isLoading || !canSubmit)
|
|
|
|
if let errorMessage = viewModel.errorMessage {
|
|
Text(errorMessage)
|
|
.font(.footnote)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var authField: some View {
|
|
switch state {
|
|
case .waitPhoneNumber, .waitTdlibParameters:
|
|
TextField("Phone number", text: $viewModel.phone)
|
|
.textContentType(.telephoneNumber)
|
|
.textFieldStyle(.roundedBorder)
|
|
case .waitCode:
|
|
TextField("Code", text: $viewModel.code)
|
|
.textContentType(.oneTimeCode)
|
|
.textFieldStyle(.roundedBorder)
|
|
case .waitPassword:
|
|
SecureField("Password", text: $viewModel.password)
|
|
.textContentType(.password)
|
|
.textFieldStyle(.roundedBorder)
|
|
case .ready:
|
|
Text("Ready")
|
|
case .closed:
|
|
Text("Session closed")
|
|
case let .error(message):
|
|
Text(message)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
|
|
private var buttonTitle: String {
|
|
switch state {
|
|
case .waitPhoneNumber, .waitTdlibParameters:
|
|
"Continue"
|
|
case .waitCode:
|
|
"Verify"
|
|
case .waitPassword:
|
|
"Unlock"
|
|
default:
|
|
"Continue"
|
|
}
|
|
}
|
|
|
|
private var canSubmit: Bool {
|
|
switch state {
|
|
case .waitPhoneNumber, .waitTdlibParameters:
|
|
!viewModel.phone.isEmpty
|
|
case .waitCode:
|
|
!viewModel.code.isEmpty
|
|
case .waitPassword:
|
|
!viewModel.password.isEmpty
|
|
default:
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct ChatListView: View {
|
|
@ObservedObject public var viewModel: ChatListViewModel
|
|
public let bridge: SessionBridge
|
|
@State private var selectedChat: ChatSummary?
|
|
@State private var showsAccountSwitcher = false
|
|
|
|
public init(viewModel: ChatListViewModel, bridge: SessionBridge) {
|
|
self.viewModel = viewModel
|
|
self.bridge = bridge
|
|
}
|
|
|
|
public var body: some View {
|
|
NavigationSplitView {
|
|
List(selection: $selectedChat) {
|
|
ForEach(viewModel.filteredChats) { chat in
|
|
NavigationLink(value: chat) {
|
|
ChatRow(chat: chat)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Chats")
|
|
.searchable(text: $viewModel.searchText)
|
|
.toolbar {
|
|
ToolbarItem {
|
|
Button("Accounts") {
|
|
showsAccountSwitcher = true
|
|
}
|
|
}
|
|
ToolbarItem {
|
|
folderMenu
|
|
}
|
|
}
|
|
.sheet(isPresented: $showsAccountSwitcher) {
|
|
AccountSwitcherView()
|
|
}
|
|
.task {
|
|
await viewModel.load()
|
|
}
|
|
} detail: {
|
|
if let selectedChat {
|
|
ChatDetailView(viewModel: ChatViewModel(chat: selectedChat, bridge: bridge), bridge: bridge)
|
|
} else {
|
|
Text("Select a chat")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var folderMenu: some View {
|
|
Menu("Folders") {
|
|
Button("All") {
|
|
viewModel.selectedFolderId = nil
|
|
Task { await viewModel.load() }
|
|
}
|
|
ForEach(viewModel.folders) { folder in
|
|
Button(folder.name) {
|
|
viewModel.selectedFolderId = folder.id
|
|
Task { await viewModel.load() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct ChatRow: View {
|
|
public var chat: ChatSummary
|
|
|
|
public init(chat: ChatSummary) {
|
|
self.chat = chat
|
|
}
|
|
|
|
public var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(chat.title)
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
if chat.isPinned {
|
|
Image(systemName: "pin.fill")
|
|
.font(.caption)
|
|
}
|
|
Spacer()
|
|
if chat.unreadCount > 0 {
|
|
Text("\(chat.unreadCount)")
|
|
.font(.caption)
|
|
.padding(.horizontal, 7)
|
|
.padding(.vertical, 3)
|
|
.background(.blue, in: Capsule())
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
Text(chat.draft?.text ?? chat.lastMessage)
|
|
.font(.subheadline)
|
|
.foregroundStyle(chat.draft == nil ? Color.secondary : Color.red)
|
|
.lineLimit(2)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
public struct ChatDetailView: View {
|
|
@StateObject public var viewModel: ChatViewModel
|
|
public let bridge: SessionBridge
|
|
@StateObject private var profileViewModel: ProfileViewModel
|
|
@State private var showsProfile = false
|
|
|
|
public init(viewModel: ChatViewModel, bridge: SessionBridge) {
|
|
_viewModel = StateObject(wrappedValue: viewModel)
|
|
self.bridge = bridge
|
|
_profileViewModel = StateObject(wrappedValue: ProfileViewModel(bridge: bridge))
|
|
}
|
|
|
|
public var body: some View {
|
|
VStack(spacing: 0) {
|
|
List(viewModel.messages) { message in
|
|
MessageRow(message: message)
|
|
.listRowSeparator(.hidden)
|
|
}
|
|
ComposeBar(text: $viewModel.composeText) {
|
|
Task { await viewModel.send() }
|
|
}
|
|
}
|
|
.navigationTitle(viewModel.chat.title)
|
|
.toolbar {
|
|
Button("Profile") {
|
|
showsProfile = true
|
|
Task { await profileViewModel.load(chatId: viewModel.chat.id) }
|
|
}
|
|
}
|
|
.sheet(isPresented: $showsProfile) {
|
|
ProfileView(viewModel: profileViewModel)
|
|
}
|
|
.task {
|
|
await viewModel.load()
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct MessageRow: View {
|
|
public var message: Message
|
|
|
|
public init(message: Message) {
|
|
self.message = message
|
|
}
|
|
|
|
public var body: some View {
|
|
HStack {
|
|
if message.isOutgoing {
|
|
Spacer(minLength: 48)
|
|
}
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
if !message.isOutgoing {
|
|
Text(message.senderName)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if let replyText = message.replyText {
|
|
Text(replyText)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.leading, 6)
|
|
}
|
|
Text(message.text)
|
|
.textSelection(.enabled)
|
|
if !message.reactions.isEmpty {
|
|
Text(message.reactions.map(\.emoji).joined(separator: " "))
|
|
.font(.caption)
|
|
}
|
|
}
|
|
.padding(10)
|
|
.background(message.isOutgoing ? Color.blue.opacity(0.16) : Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
|
if !message.isOutgoing {
|
|
Spacer(minLength: 48)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct ComposeBar: View {
|
|
@Binding public var text: String
|
|
public var send: () -> Void
|
|
|
|
public init(text: Binding<String>, send: @escaping () -> Void) {
|
|
_text = text
|
|
self.send = send
|
|
}
|
|
|
|
public var body: some View {
|
|
HStack(spacing: 10) {
|
|
TextField("Message", text: $text, axis: .vertical)
|
|
.textFieldStyle(.roundedBorder)
|
|
.lineLimit(1...5)
|
|
Button(action: send) {
|
|
Image(systemName: "paperplane.fill")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
}
|
|
.padding()
|
|
.background(.bar)
|
|
}
|
|
}
|
|
|
|
public struct ProfileView: View {
|
|
@ObservedObject public var viewModel: ProfileViewModel
|
|
|
|
public init(viewModel: ProfileViewModel) {
|
|
self.viewModel = viewModel
|
|
}
|
|
|
|
public var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
if let profile = viewModel.profile {
|
|
Section {
|
|
Text(profile.title)
|
|
.font(.title2)
|
|
if let username = profile.username {
|
|
Text("@\(username)")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if let bio = profile.bio {
|
|
Text(bio)
|
|
}
|
|
}
|
|
if let memberCount = profile.memberCount {
|
|
Section {
|
|
Text("\(memberCount) members")
|
|
}
|
|
}
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
}
|
|
.navigationTitle("Profile")
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct AccountSwitcherView: View {
|
|
public init() {}
|
|
|
|
public var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section {
|
|
Label("Default", systemImage: "person.crop.circle")
|
|
Label("Add account", systemImage: "plus.circle")
|
|
}
|
|
}
|
|
.navigationTitle("Accounts")
|
|
}
|
|
}
|
|
}
|