add creation chat
This commit is contained in:
parent
21d120cb3d
commit
a937db4385
@ -395,7 +395,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = V22H44W47J;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@ -435,7 +435,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
CURRENT_PROJECT_VERSION = 7;
|
||||
DEVELOPMENT_TEAM = V22H44W47J;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
||||
@ -10,6 +10,28 @@ struct PrivateChatHistoryData: Decodable {
|
||||
let hasMore: Bool
|
||||
}
|
||||
|
||||
struct PrivateChatCreateData: Decodable {
|
||||
let chatId: String
|
||||
let chatType: PrivateChatListItem.ChatType
|
||||
let status: String
|
||||
let message: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case chatId
|
||||
case chatType
|
||||
case status
|
||||
case message
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.chatId = try container.decodeFlexibleString(forKey: .chatId)
|
||||
self.chatType = try container.decode(PrivateChatListItem.ChatType.self, forKey: .chatType)
|
||||
self.status = try container.decode(String.self, forKey: .status)
|
||||
self.message = try container.decode(String.self, forKey: .message)
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivateMessageSendData: Decodable {
|
||||
let messageId: String
|
||||
let chatId: String
|
||||
@ -180,6 +202,34 @@ struct ChatProfile: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatProfile {
|
||||
init(
|
||||
userId: String,
|
||||
login: String?,
|
||||
fullName: String?,
|
||||
customName: String?,
|
||||
bio: String? = nil,
|
||||
lastSeen: Int? = nil,
|
||||
createdAt: Date? = nil,
|
||||
stories: [JSONValue] = [],
|
||||
permissions: ChatPermissions? = nil,
|
||||
profilePermissions: ChatProfilePermissions? = nil,
|
||||
relationship: RelationshipStatus? = nil
|
||||
) {
|
||||
self.userId = userId
|
||||
self.login = login
|
||||
self.fullName = fullName
|
||||
self.customName = customName
|
||||
self.bio = bio
|
||||
self.lastSeen = lastSeen
|
||||
self.createdAt = createdAt
|
||||
self.stories = stories
|
||||
self.permissions = permissions
|
||||
self.profilePermissions = profilePermissions
|
||||
self.relationship = relationship
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatPermissions: Decodable {
|
||||
let youCanSendMessage: Bool
|
||||
let youCanPublicInvitePermission: Bool
|
||||
|
||||
@ -111,6 +111,43 @@ final class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
func createOrFindPrivateChat(
|
||||
targetUserId: String,
|
||||
completion: @escaping (Result<PrivateChatCreateData, Error>) -> Void
|
||||
) {
|
||||
let query: [String: String?] = [
|
||||
"target_user_id": targetUserId
|
||||
]
|
||||
|
||||
client.request(
|
||||
path: "/v1/chat/private/create",
|
||||
method: .post,
|
||||
query: query,
|
||||
requiresAuth: true
|
||||
) { [decoder] result in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
do {
|
||||
let apiResponse = try decoder.decode(APIResponse<PrivateChatCreateData>.self, from: response.data)
|
||||
guard apiResponse.status == "fine" else {
|
||||
let message = apiResponse.detail ?? NSLocalizedString("Не удалось создать чат.", comment: "")
|
||||
completion(.failure(ChatServiceError.unexpectedStatus(message)))
|
||||
return
|
||||
}
|
||||
completion(.success(apiResponse.data))
|
||||
} catch {
|
||||
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
|
||||
if AppConfig.DEBUG {
|
||||
print("[ChatService] create private chat decode failed: \(debugMessage)")
|
||||
}
|
||||
completion(.failure(ChatServiceError.decoding(debugDescription: debugMessage)))
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendPrivateMessage(
|
||||
chatId: String,
|
||||
content: String,
|
||||
|
||||
@ -939,6 +939,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Не удалось открыть чат" : {
|
||||
"comment" : "Chat creation error title"
|
||||
},
|
||||
"Не удалось открыть чат." : {
|
||||
"comment" : "Chat creation fallback"
|
||||
},
|
||||
"Не удалось отправить сообщение." : {
|
||||
|
||||
},
|
||||
@ -954,6 +960,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Не удалось создать чат." : {
|
||||
|
||||
},
|
||||
"Не удалось сохранить изменения профиля." : {
|
||||
"comment" : "Profile update unexpected status"
|
||||
@ -1303,6 +1312,7 @@
|
||||
}
|
||||
},
|
||||
"Ошибка сервера (%@)." : {
|
||||
"comment" : "Chat creation server status",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -1323,6 +1333,7 @@
|
||||
}
|
||||
},
|
||||
"Ошибка сети: %@" : {
|
||||
"comment" : "Chat creation network error",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -1333,7 +1344,7 @@
|
||||
}
|
||||
},
|
||||
"Ошибка соединения с сервером." : {
|
||||
"comment" : "Search error connection",
|
||||
"comment" : "Chat creation connection\nSearch error connection",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
@ -1565,6 +1576,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Понятно" : {
|
||||
"comment" : "Chat creation error acknowledgment"
|
||||
},
|
||||
"Попробуйте изменить запрос поиска." : {
|
||||
|
||||
},
|
||||
@ -1715,6 +1729,9 @@
|
||||
"Произошла неизвестная ошибка." : {
|
||||
"comment" : "Search unknown error"
|
||||
},
|
||||
"Произошла неизвестная ошибка. Попробуйте позже." : {
|
||||
"comment" : "Chat creation unknown error"
|
||||
},
|
||||
"Произошла ошибка." : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
@ -1911,7 +1928,7 @@
|
||||
}
|
||||
},
|
||||
"Сессия истекла. Войдите снова." : {
|
||||
"comment" : "Search error unauthorized",
|
||||
"comment" : "Chat creation unauthorized\nSearch error unauthorized",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
|
||||
@ -15,6 +15,7 @@ struct ChatsTab: View {
|
||||
@Binding var searchRevealProgress: CGFloat
|
||||
@Binding var searchText: String
|
||||
private let searchService = SearchService()
|
||||
private let chatService = ChatService()
|
||||
@AppStorage("chatRowMessageLineLimit") private var messageLineLimitSetting: Int = 2
|
||||
@StateObject private var viewModel = PrivateChatsViewModel()
|
||||
@State private var selectedChatId: String?
|
||||
@ -24,7 +25,11 @@ struct ChatsTab: View {
|
||||
@State private var isGlobalSearchLoading: Bool = false
|
||||
@State private var globalSearchError: String?
|
||||
@State private var globalSearchTask: Task<Void, Never>? = nil
|
||||
@State private var selectedSearchUserId: UUID?
|
||||
@State private var creatingChatForUserId: UUID?
|
||||
@State private var chatCreationError: String?
|
||||
@State private var isChatCreationAlertPresented: Bool = false
|
||||
@State private var pendingChatItem: PrivateChatListItem?
|
||||
@State private var isPendingChatActive: Bool = false
|
||||
|
||||
private let searchRevealDistance: CGFloat = 90
|
||||
|
||||
@ -53,6 +58,23 @@ struct ChatsTab: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
NSLocalizedString("Не удалось открыть чат", comment: "Chat creation error title"),
|
||||
isPresented: Binding(
|
||||
get: { isChatCreationAlertPresented && chatCreationError != nil },
|
||||
set: { newValue in
|
||||
isChatCreationAlertPresented = newValue
|
||||
if !newValue {
|
||||
chatCreationError = nil
|
||||
}
|
||||
}
|
||||
),
|
||||
presenting: chatCreationError
|
||||
) { _ in
|
||||
Button(NSLocalizedString("Понятно", comment: "Chat creation error acknowledgment"), role: .cancel) { }
|
||||
} message: { error in
|
||||
Text(error)
|
||||
}
|
||||
.onDisappear {
|
||||
globalSearchTask?.cancel()
|
||||
globalSearchTask = nil
|
||||
@ -69,80 +91,111 @@ struct ChatsTab: View {
|
||||
}
|
||||
|
||||
private var chatList: some View {
|
||||
List {
|
||||
ZStack {
|
||||
List {
|
||||
// VStack(spacing: 0) {
|
||||
// searchBar
|
||||
// .padding(.horizontal, 16)
|
||||
// .padding(.vertical, 8)
|
||||
// }
|
||||
// .background(Color(UIColor.systemBackground))
|
||||
|
||||
if let message = viewModel.errorMessage {
|
||||
Section {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.orange)
|
||||
Spacer(minLength: 0)
|
||||
Button(action: { viewModel.refresh() }) {
|
||||
Text(NSLocalizedString("Обновить", comment: ""))
|
||||
|
||||
if let message = viewModel.errorMessage {
|
||||
Section {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.orange)
|
||||
Spacer(minLength: 0)
|
||||
Button(action: { viewModel.refresh() }) {
|
||||
Text(NSLocalizedString("Обновить", comment: ""))
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
}
|
||||
|
||||
if isSearching {
|
||||
Section(header: localSearchHeader) {
|
||||
if localSearchResults.isEmpty {
|
||||
emptySearchResultView
|
||||
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
|
||||
.listRowSeparator(.hidden)
|
||||
} else {
|
||||
ForEach(localSearchResults) { chat in
|
||||
chatRowItem(for: chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
}
|
||||
|
||||
if isSearching {
|
||||
Section(header: localSearchHeader) {
|
||||
if localSearchResults.isEmpty {
|
||||
emptySearchResultView
|
||||
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
|
||||
.listRowSeparator(.hidden)
|
||||
Section(header: globalSearchHeader) {
|
||||
globalSearchContent
|
||||
}
|
||||
} else {
|
||||
if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
|
||||
errorState(message: message)
|
||||
} else if viewModel.chats.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ForEach(localSearchResults) { chat in
|
||||
|
||||
ForEach(viewModel.chats) { chat in
|
||||
chatRowItem(for: chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: globalSearchHeader) {
|
||||
globalSearchContent
|
||||
}
|
||||
} else {
|
||||
if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
|
||||
errorState(message: message)
|
||||
} else if viewModel.chats.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
|
||||
ForEach(viewModel.chats) { chat in
|
||||
chatRowItem(for: chat)
|
||||
}
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
loadingMoreRow
|
||||
if viewModel.isLoadingMore {
|
||||
loadingMoreRow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.modifier(ScrollDismissesKeyboardModifier())
|
||||
.simultaneousGesture(searchBarGesture)
|
||||
.simultaneousGesture(tapToDismissKeyboardGesture)
|
||||
// .safeAreaInset(edge: .top) {
|
||||
// VStack(spacing: 0) {
|
||||
// searchBar
|
||||
// .padding(.horizontal, 16)
|
||||
// .padding(.top, 8)
|
||||
// .padding(.bottom, 8)
|
||||
// Divider()
|
||||
.listStyle(.plain)
|
||||
.modifier(ScrollDismissesKeyboardModifier())
|
||||
.simultaneousGesture(searchBarGesture)
|
||||
.simultaneousGesture(tapToDismissKeyboardGesture)
|
||||
// .safeAreaInset(edge: .top) {
|
||||
// VStack(spacing: 0) {
|
||||
// searchBar
|
||||
// .padding(.horizontal, 16)
|
||||
// .padding(.top, 8)
|
||||
// .padding(.bottom, 8)
|
||||
// Divider()
|
||||
// }
|
||||
// .background(Color(UIColor.systemBackground))
|
||||
// }
|
||||
// .background(Color(UIColor.systemBackground))
|
||||
// }
|
||||
|
||||
pendingChatNavigationLink
|
||||
}
|
||||
}
|
||||
|
||||
private var pendingChatNavigationLink: some View {
|
||||
NavigationLink(
|
||||
destination: pendingChatDestination,
|
||||
isActive: Binding(
|
||||
get: { isPendingChatActive && pendingChatItem != nil },
|
||||
set: { newValue in
|
||||
if !newValue {
|
||||
isPendingChatActive = false
|
||||
pendingChatItem = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
.hidden()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var pendingChatDestination: some View {
|
||||
if let pendingChatItem {
|
||||
PrivateChatView(chat: pendingChatItem, currentUserId: currentUserId)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var searchBarGesture: some Gesture {
|
||||
@ -404,7 +457,7 @@ struct ChatsTab: View {
|
||||
|
||||
private func globalSearchRow(for user: UserSearchResult) -> some View {
|
||||
Button {
|
||||
selectedSearchUserId = user.userId
|
||||
openPrivateChat(for: user)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
@ -440,21 +493,17 @@ struct ChatsTab: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if creatingChatForUserId == user.userId {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(creatingChatForUserId != nil)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
.background(
|
||||
NavigationLink(
|
||||
destination: SearchResultPlaceholderView(userId: user.userId),
|
||||
tag: user.userId,
|
||||
selection: $selectedSearchUserId
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
.hidden()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -480,7 +529,6 @@ private extension ChatsTab {
|
||||
globalSearchResults = []
|
||||
globalSearchError = nil
|
||||
isGlobalSearchLoading = false
|
||||
selectedSearchUserId = nil
|
||||
return
|
||||
}
|
||||
|
||||
@ -499,7 +547,6 @@ private extension ChatsTab {
|
||||
isGlobalSearchLoading = false
|
||||
globalSearchError = nil
|
||||
globalSearchTask = nil
|
||||
selectedSearchUserId = nil
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// Ignore cancellation
|
||||
@ -510,7 +557,6 @@ private extension ChatsTab {
|
||||
isGlobalSearchLoading = false
|
||||
globalSearchError = friendlyErrorMessage(for: error)
|
||||
globalSearchTask = nil
|
||||
selectedSearchUserId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -522,7 +568,6 @@ private extension ChatsTab {
|
||||
globalSearchResults = []
|
||||
globalSearchError = nil
|
||||
isGlobalSearchLoading = false
|
||||
selectedSearchUserId = nil
|
||||
}
|
||||
|
||||
func secondaryLine(for user: UserSearchResult) -> String? {
|
||||
@ -552,6 +597,91 @@ private extension ChatsTab {
|
||||
return nil
|
||||
}
|
||||
|
||||
func openPrivateChat(for user: UserSearchResult) {
|
||||
guard creatingChatForUserId == nil else { return }
|
||||
|
||||
dismissKeyboard()
|
||||
creatingChatForUserId = user.userId
|
||||
chatCreationError = nil
|
||||
|
||||
chatService.createOrFindPrivateChat(targetUserId: user.userId.uuidString) { result in
|
||||
creatingChatForUserId = nil
|
||||
|
||||
switch result {
|
||||
case .success(let data):
|
||||
let chatItem = PrivateChatListItem(
|
||||
chatId: data.chatId,
|
||||
chatType: data.chatType,
|
||||
chatData: chatProfile(from: user),
|
||||
lastMessage: nil,
|
||||
createdAt: nil,
|
||||
unreadCount: 0
|
||||
)
|
||||
|
||||
pendingChatItem = chatItem
|
||||
isPendingChatActive = true
|
||||
viewModel.refresh()
|
||||
case .failure(let error):
|
||||
chatCreationError = friendlyChatCreationMessage(for: error)
|
||||
isChatCreationAlertPresented = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func chatProfile(from user: UserSearchResult) -> ChatProfile {
|
||||
let profile = user.profile
|
||||
let login = profile?.login ?? user.login
|
||||
let fullName = user.officialFullName ?? user.fullName ?? profile?.fullName
|
||||
let customName = user.preferredCustomName ?? user.customName ?? profile?.customName
|
||||
|
||||
return ChatProfile(
|
||||
userId: user.userId.uuidString,
|
||||
login: login,
|
||||
fullName: fullName,
|
||||
customName: customName,
|
||||
bio: profile?.bio,
|
||||
lastSeen: profile?.lastSeen,
|
||||
createdAt: profile?.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
func friendlyChatCreationMessage(for error: Error) -> String {
|
||||
if let chatError = error as? ChatServiceError {
|
||||
return chatError.errorDescription ?? NSLocalizedString("Не удалось открыть чат.", comment: "Chat creation fallback")
|
||||
}
|
||||
|
||||
if let networkError = error as? NetworkError {
|
||||
switch networkError {
|
||||
case .unauthorized:
|
||||
return NSLocalizedString("Сессия истекла. Войдите снова.", comment: "Chat creation unauthorized")
|
||||
case .invalidURL, .noResponse:
|
||||
return NSLocalizedString("Ошибка соединения с сервером.", comment: "Chat creation connection")
|
||||
case .network(let underlying):
|
||||
return String(format: NSLocalizedString("Ошибка сети: %@", comment: "Chat creation network error"), underlying.localizedDescription)
|
||||
case .server(let statusCode, let data):
|
||||
if let data {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
if let payload = try? decoder.decode(ErrorResponse.self, from: data) {
|
||||
if let detail = payload.detail?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty {
|
||||
return detail
|
||||
}
|
||||
if let message = payload.data?.message?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
if let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
return String(format: NSLocalizedString("Ошибка сервера (%@).", comment: "Chat creation server status"), "\(statusCode)")
|
||||
}
|
||||
}
|
||||
|
||||
return NSLocalizedString("Произошла неизвестная ошибка. Попробуйте позже.", comment: "Chat creation unknown error")
|
||||
}
|
||||
|
||||
|
||||
func friendlyErrorMessage(for error: Error) -> String {
|
||||
if let searchError = error as? SearchServiceError {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user