✨ Chat room listing
This commit is contained in:
@@ -14,6 +14,7 @@ struct ContentView: View {
|
||||
|
||||
enum Panel: Hashable {
|
||||
case explore
|
||||
case chat
|
||||
case notifications
|
||||
case account
|
||||
}
|
||||
@@ -22,6 +23,7 @@ struct ContentView: View {
|
||||
NavigationSplitView {
|
||||
List(selection: $selection) {
|
||||
Label("Explore", systemImage: "globe").tag(Panel.explore)
|
||||
Label("Chat", systemImage: "message").tag(Panel.chat)
|
||||
Label("Notifications", systemImage: "bell").tag(Panel.notifications)
|
||||
Label("Account", systemImage: "person.circle").tag(Panel.account)
|
||||
}
|
||||
@@ -31,6 +33,9 @@ struct ContentView: View {
|
||||
case .explore:
|
||||
ExploreView()
|
||||
.environmentObject(appState)
|
||||
case .chat:
|
||||
ChatView()
|
||||
.environmentObject(appState)
|
||||
case .notifications:
|
||||
NotificationView()
|
||||
.environmentObject(appState)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//
|
||||
// Models.swift
|
||||
// WatchRunner Watch App
|
||||
//
|
||||
@@ -88,7 +87,7 @@ enum DiscoveryItemData: Codable {
|
||||
}
|
||||
self = .unknown
|
||||
}
|
||||
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
// Not needed for decoding
|
||||
}
|
||||
@@ -246,3 +245,86 @@ struct SnAccountStatus: Codable {
|
||||
let updatedAt: Date
|
||||
let deletedAt: Date?
|
||||
}
|
||||
|
||||
// MARK: - Chat Models
|
||||
|
||||
struct SnChatRoom: Codable, Identifiable {
|
||||
let id: String
|
||||
let name: String?
|
||||
let description: String?
|
||||
let type: Int
|
||||
let isPublic: Bool
|
||||
let isCommunity: Bool
|
||||
let picture: SnCloudFile?
|
||||
let background: SnCloudFile?
|
||||
let realmId: String?
|
||||
let realm: SnRealm?
|
||||
let createdAt: Date
|
||||
let updatedAt: Date
|
||||
let deletedAt: Date?
|
||||
let members: [SnChatMember]?
|
||||
}
|
||||
|
||||
struct SnChatMessage: Codable, Identifiable {
|
||||
let id: String
|
||||
let type: String
|
||||
let content: String?
|
||||
let nonce: String?
|
||||
let meta: [String: AnyCodable]
|
||||
let membersMentioned: [String]
|
||||
let editedAt: Date?
|
||||
let attachments: [SnCloudFile]
|
||||
let reactions: [SnChatReaction]
|
||||
let repliedMessageId: String?
|
||||
let forwardedMessageId: String?
|
||||
let senderId: String
|
||||
let sender: SnChatMember
|
||||
let chatRoomId: String
|
||||
let createdAt: Date
|
||||
let updatedAt: Date
|
||||
let deletedAt: Date?
|
||||
}
|
||||
|
||||
struct SnChatReaction: Codable, Identifiable {
|
||||
let id: String
|
||||
let messageId: String
|
||||
let senderId: String
|
||||
let sender: SnChatMember
|
||||
let symbol: String
|
||||
let attitude: Int
|
||||
let createdAt: Date
|
||||
let updatedAt: Date
|
||||
let deletedAt: Date?
|
||||
}
|
||||
|
||||
struct SnChatMember: Codable, Identifiable {
|
||||
let id: String
|
||||
let chatRoomId: String
|
||||
let chatRoom: SnChatRoom?
|
||||
let accountId: String
|
||||
let account: SnAccount
|
||||
let nick: String?
|
||||
let role: Int
|
||||
let notify: Int
|
||||
let joinedAt: Date?
|
||||
let breakUntil: Date?
|
||||
let timeoutUntil: Date?
|
||||
let isBot: Bool
|
||||
let status: SnAccountStatus?
|
||||
let createdAt: Date
|
||||
let updatedAt: Date
|
||||
let deletedAt: Date?
|
||||
}
|
||||
|
||||
struct SnChatSummary: Codable {
|
||||
let unreadCount: Int
|
||||
let lastMessage: SnChatMessage?
|
||||
}
|
||||
|
||||
struct ChatRoomsResponse {
|
||||
let rooms: [SnChatRoom]
|
||||
}
|
||||
|
||||
struct ChatInvitesResponse {
|
||||
let invites: [SnChatMember]
|
||||
}
|
||||
|
||||
@@ -211,4 +211,117 @@ class NetworkService {
|
||||
throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chat API Methods
|
||||
|
||||
func fetchChatRooms(token: String, serverUrl: String) async throws -> ChatRoomsResponse {
|
||||
guard let baseURL = URL(string: serverUrl) else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
let url = baseURL.appendingPathComponent("/sphere/chat")
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let (data, _) = try await session.data(for: request)
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
let rooms = try decoder.decode([SnChatRoom].self, from: data)
|
||||
return ChatRoomsResponse(rooms: rooms)
|
||||
}
|
||||
|
||||
func fetchChatRoom(identifier: String, token: String, serverUrl: String) async throws -> SnChatRoom {
|
||||
guard let baseURL = URL(string: serverUrl) else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
let url = baseURL.appendingPathComponent("/sphere/chat/\(identifier)")
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 404 {
|
||||
throw URLError(.resourceUnavailable)
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
return try decoder.decode(SnChatRoom.self, from: data)
|
||||
}
|
||||
|
||||
func fetchChatInvites(token: String, serverUrl: String) async throws -> ChatInvitesResponse {
|
||||
guard let baseURL = URL(string: serverUrl) else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
let url = baseURL.appendingPathComponent("/sphere/chat/invites")
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let (data, _) = try await session.data(for: request)
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
let invites = try decoder.decode([SnChatMember].self, from: data)
|
||||
return ChatInvitesResponse(invites: invites)
|
||||
}
|
||||
|
||||
func acceptChatInvite(chatRoomId: String, token: String, serverUrl: String) async throws {
|
||||
guard let baseURL = URL(string: serverUrl) else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
let url = baseURL.appendingPathComponent("/sphere/chat/invites/\(chatRoomId)/accept")
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
|
||||
let responseBody = String(data: data, encoding: .utf8) ?? ""
|
||||
print("[watchOS] acceptChatInvite failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
|
||||
throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
|
||||
}
|
||||
}
|
||||
|
||||
func declineChatInvite(chatRoomId: String, token: String, serverUrl: String) async throws {
|
||||
guard let baseURL = URL(string: serverUrl) else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
let url = baseURL.appendingPathComponent("/sphere/chat/invites/\(chatRoomId)/decline")
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("SolianWatch/1.0", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
|
||||
let responseBody = String(data: data, encoding: .utf8) ?? ""
|
||||
print("[watchOS] declineChatInvite failed with status code: \(httpResponse.statusCode), body: \(responseBody)")
|
||||
throw URLError(URLError.Code(rawValue: httpResponse.statusCode))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ class AppState: ObservableObject {
|
||||
@Published var token: String? = nil
|
||||
@Published var serverUrl: String? = nil
|
||||
@Published var isReady = false
|
||||
|
||||
|
||||
let networkService = NetworkService()
|
||||
private var wcService = WatchConnectivityService()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
|
||||
472
ios/WatchRunner Watch App/Views/ChatView.swift
Normal file
472
ios/WatchRunner Watch App/Views/ChatView.swift
Normal file
@@ -0,0 +1,472 @@
|
||||
//
|
||||
// ChatView.swift
|
||||
// WatchRunner Watch App
|
||||
//
|
||||
// Created by LittleSheep on 2025/10/30.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ChatView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State private var selectedTab = 0
|
||||
@State private var chatRooms: [SnChatRoom] = []
|
||||
@State private var chatInvites: [SnChatMember] = []
|
||||
@State private var isLoading = false
|
||||
@State private var error: Error?
|
||||
@State private var showingInvites = false
|
||||
|
||||
private let tabs = ["All", "Direct", "Group"]
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
ForEach(0..<tabs.count, id: \.self) { index in
|
||||
VStack {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
} else if error != nil {
|
||||
VStack {
|
||||
Text("Error loading chats")
|
||||
.font(.caption)
|
||||
Button("Retry") {
|
||||
Task {
|
||||
await loadChatRooms()
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
}
|
||||
} else {
|
||||
ChatRoomListView(
|
||||
chatRooms: filteredChatRooms(for: index),
|
||||
selectedTab: index
|
||||
)
|
||||
}
|
||||
}
|
||||
.tabItem {
|
||||
Text(tabs[index])
|
||||
}
|
||||
.tag(index)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page)
|
||||
.navigationTitle("Chat")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showingInvites = true
|
||||
} label: {
|
||||
ZStack {
|
||||
Image(systemName: "envelope")
|
||||
if !chatInvites.isEmpty {
|
||||
Circle()
|
||||
.fill(Color.red)
|
||||
.frame(width: 8, height: 8)
|
||||
.offset(x: 8, y: -8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingInvites) {
|
||||
ChatInvitesView(invites: $chatInvites, appState: appState)
|
||||
}
|
||||
.onAppear {
|
||||
Task.detached {
|
||||
await loadChatRooms()
|
||||
await loadChatInvites()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func filteredChatRooms(for tabIndex: Int) -> [SnChatRoom] {
|
||||
switch tabIndex {
|
||||
case 0: // All
|
||||
return chatRooms
|
||||
case 1: // Direct
|
||||
return chatRooms.filter { $0.type == 1 }
|
||||
case 2: // Group
|
||||
return chatRooms.filter { $0.type != 1 }
|
||||
default:
|
||||
return chatRooms
|
||||
}
|
||||
}
|
||||
|
||||
private func loadChatRooms() async {
|
||||
guard let token = appState.token, let serverUrl = appState.serverUrl else { return }
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
do {
|
||||
let response = try await appState.networkService.fetchChatRooms(token: token, serverUrl: serverUrl)
|
||||
chatRooms = response.rooms
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func loadChatInvites() async {
|
||||
guard let token = appState.token, let serverUrl = appState.serverUrl else { return }
|
||||
|
||||
do {
|
||||
let response = try await appState.networkService.fetchChatInvites(token: token, serverUrl: serverUrl)
|
||||
chatInvites = response.invites
|
||||
} catch {
|
||||
// Handle error silently for invites
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatRoomListView: View {
|
||||
let chatRooms: [SnChatRoom]
|
||||
let selectedTab: Int
|
||||
|
||||
var body: some View {
|
||||
if chatRooms.isEmpty {
|
||||
VStack {
|
||||
Image(systemName: "message")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("No chats yet")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
List(chatRooms) { room in
|
||||
ChatRoomListItem(room: room)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatRoomListItem: View {
|
||||
let room: SnChatRoom
|
||||
@EnvironmentObject var appState: AppState
|
||||
@StateObject private var avatarLoader = ImageLoader()
|
||||
|
||||
private var displayName: String {
|
||||
if room.type == 1, let members = room.members, !members.isEmpty {
|
||||
// For direct messages, show the other member's name
|
||||
return members[0].account.nick
|
||||
} else {
|
||||
// For group chats, show room name or fallback
|
||||
return room.name ?? "Group Chat"
|
||||
}
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if room.type == 1, let members = room.members, members.count > 1 {
|
||||
// For direct messages, show member usernames
|
||||
return members.map { "@\($0.account.name)" }.joined(separator: ", ")
|
||||
} else if let description = room.description {
|
||||
// For group chats with description
|
||||
return description
|
||||
} else {
|
||||
// Fallback
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
private var avatarPictureId: String? {
|
||||
if room.type == 1, let members = room.members, !members.isEmpty {
|
||||
// For direct messages, use the other member's avatar
|
||||
return members[0].account.profile.picture?.id
|
||||
} else {
|
||||
// For group chats, use room picture
|
||||
return room.picture?.id
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(destination: ChatRoomView(room: room)) {
|
||||
HStack {
|
||||
// Avatar using ImageLoader pattern
|
||||
Group {
|
||||
if avatarLoader.isLoading {
|
||||
ProgressView()
|
||||
.frame(width: 32, height: 32)
|
||||
} else if let image = avatarLoader.image {
|
||||
image
|
||||
.resizable()
|
||||
.frame(width: 32, height: 32)
|
||||
.clipShape(Circle())
|
||||
} else if let errorMessage = avatarLoader.errorMessage {
|
||||
// Error state - show fallback
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 32, height: 32)
|
||||
.overlay(
|
||||
Text(displayName.prefix(1).uppercased())
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
)
|
||||
} else {
|
||||
// No image available - show initial
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 32, height: 32)
|
||||
.overlay(
|
||||
Text(displayName.prefix(1).uppercased())
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
.task(id: avatarPictureId) {
|
||||
if let serverUrl = appState.serverUrl,
|
||||
let pictureId = avatarPictureId,
|
||||
let imageUrl = getAttachmentUrl(for: pictureId, serverUrl: serverUrl),
|
||||
let token = appState.token {
|
||||
await avatarLoader.loadImage(from: imageUrl, token: token)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(displayName)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.lineLimit(1)
|
||||
|
||||
if !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Unread count badge placeholder
|
||||
// In a full implementation, this would show unread count
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatRoomView: View {
|
||||
let room: SnChatRoom
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State private var messages: [SnChatMessage] = []
|
||||
@State private var isLoading = false
|
||||
@State private var error: Error?
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
} else if error != nil {
|
||||
VStack {
|
||||
Text("Error loading messages")
|
||||
.font(.caption)
|
||||
Button("Retry") {
|
||||
Task {
|
||||
await loadMessages()
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
}
|
||||
} else if messages.isEmpty {
|
||||
VStack {
|
||||
Image(systemName: "bubble.left")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("No messages yet")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
List(messages) { message in
|
||||
ChatMessageItem(message: message)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.navigationTitle(room.name ?? "Chat")
|
||||
.task {
|
||||
await loadMessages()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMessages() async {
|
||||
// Placeholder for message loading
|
||||
// In a full implementation, this would fetch messages from the API
|
||||
// For now, just show empty state
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatMessageItem: View {
|
||||
let message: SnChatMessage
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(message.sender.account.nick)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
Spacer()
|
||||
Text(message.createdAt, style: .time)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let content = message.content {
|
||||
Text(content)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatInvitesView: View {
|
||||
@Binding var invites: [SnChatMember]
|
||||
let appState: AppState
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isLoading = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack {
|
||||
if invites.isEmpty {
|
||||
VStack {
|
||||
Image(systemName: "envelope.open")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("No invites")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
List(invites) { invite in
|
||||
ChatInviteItem(invite: invite, appState: appState, invites: $invites)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Invites")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatInviteItem: View {
|
||||
let invite: SnChatMember
|
||||
let appState: AppState
|
||||
@Binding var invites: [SnChatMember]
|
||||
@State private var isAccepting = false
|
||||
@State private var isDeclining = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 24, height: 24)
|
||||
.overlay(
|
||||
Text((invite.chatRoom?.name ?? "C").prefix(1).uppercased())
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(invite.chatRoom?.name ?? "Unknown Chat")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text(invite.role == 100 ? "Owner" : invite.role >= 50 ? "Moderator" : "Member")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if invite.chatRoom?.type == 1 {
|
||||
Text("Direct")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.blue)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task {
|
||||
await acceptInvite()
|
||||
}
|
||||
} label: {
|
||||
if isAccepting {
|
||||
ProgressView()
|
||||
.frame(width: 20, height: 20)
|
||||
} else {
|
||||
Image(systemName: "checkmark")
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
}
|
||||
.disabled(isAccepting || isDeclining)
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await declineInvite()
|
||||
}
|
||||
} label: {
|
||||
if isDeclining {
|
||||
ProgressView()
|
||||
.frame(width: 20, height: 20)
|
||||
} else {
|
||||
Image(systemName: "xmark")
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
}
|
||||
.disabled(isAccepting || isDeclining)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func acceptInvite() async {
|
||||
guard let token = appState.token,
|
||||
let serverUrl = appState.serverUrl,
|
||||
let chatRoomId = invite.chatRoom?.id else { return }
|
||||
|
||||
isAccepting = true
|
||||
|
||||
do {
|
||||
try await appState.networkService.acceptChatInvite(chatRoomId: chatRoomId, token: token, serverUrl: serverUrl)
|
||||
// Remove from invites list
|
||||
invites.removeAll { $0.id == invite.id }
|
||||
} catch {
|
||||
// Handle error - could show alert
|
||||
print("Failed to accept invite: \(error)")
|
||||
}
|
||||
|
||||
isAccepting = false
|
||||
}
|
||||
|
||||
private func declineInvite() async {
|
||||
guard let token = appState.token,
|
||||
let serverUrl = appState.serverUrl,
|
||||
let chatRoomId = invite.chatRoom?.id else { return }
|
||||
|
||||
isDeclining = true
|
||||
|
||||
do {
|
||||
try await appState.networkService.declineChatInvite(chatRoomId: chatRoomId, token: token, serverUrl: serverUrl)
|
||||
// Remove from invites list
|
||||
invites.removeAll { $0.id == invite.id }
|
||||
} catch {
|
||||
// Handle error - could show alert
|
||||
print("Failed to decline invite: \(error)")
|
||||
}
|
||||
|
||||
isDeclining = false
|
||||
}
|
||||
}
|
||||
@@ -22,18 +22,21 @@ struct ExploreView: View {
|
||||
.tabItem {
|
||||
Label("Explore", systemImage: "safari")
|
||||
}
|
||||
.labelStyle(.titleOnly)
|
||||
|
||||
ActivityListView(filter: "Subscriptions")
|
||||
.tag("Subscriptions")
|
||||
.tabItem {
|
||||
Label("Subscriptions", systemImage: "star")
|
||||
}
|
||||
.labelStyle(.titleOnly)
|
||||
|
||||
ActivityListView(filter: "Friends")
|
||||
.tag("Friends")
|
||||
.tabItem {
|
||||
Label("Friends", systemImage: "person.2")
|
||||
}
|
||||
.labelStyle(.titleOnly)
|
||||
}
|
||||
.navigationTitle(selectedTab)
|
||||
.toolbar {
|
||||
@@ -56,4 +59,4 @@ struct ExploreView: View {
|
||||
.environmentObject(appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user