Compare commits

...

4 Commits

Author SHA1 Message Date
b34f3c2a1a profile open 2025-12-10 04:09:45 +03:00
41c25009a1 add profile placeholder 2025-12-10 03:57:14 +03:00
98de0eb313 fix top bar in msg 2025-12-10 03:53:12 +03:00
f47181877e edit top bar in msg 2025-12-10 03:46:54 +03:00
2 changed files with 439 additions and 22 deletions

View File

@ -719,7 +719,7 @@
} }
}, },
"Избранные сообщения" : { "Избранные сообщения" : {
"comment" : "Saved messages title"
}, },
"Изменение контакта \"%1$@\" появится позже." : { "Изменение контакта \"%1$@\" появится позже." : {
"comment" : "Contacts edit placeholder message" "comment" : "Contacts edit placeholder message"
@ -1420,7 +1420,7 @@
} }
}, },
"Неизвестный пользователь" : { "Неизвестный пользователь" : {
"comment" : "Deleted user display name", "comment" : "Deleted user display name\nMessage profile fallback title\nUnknown chat title",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -1628,6 +1628,9 @@
} }
} }
}, },
"Оффлайн" : {
"comment" : "Offline status placeholder"
},
"Оценка %d" : { "Оценка %d" : {
"comment" : "feedback: rating accessibility", "comment" : "feedback: rating accessibility",
"localizations" : { "localizations" : {
@ -2225,6 +2228,7 @@
"comment" : "Contacts placeholder message" "comment" : "Contacts placeholder message"
}, },
"Профиль" : { "Профиль" : {
"comment" : "Message profile placeholder nav title",
"localizations" : { "localizations" : {
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
@ -2237,6 +2241,9 @@
"Профиль в разработке" : { "Профиль в разработке" : {
"comment" : "Search placeholder title" "comment" : "Search placeholder title"
}, },
"Профиль для сообщений пока в разработке." : {
"comment" : "Message profile placeholder title"
},
"Профиль и поиск" : { "Профиль и поиск" : {
"localizations" : { "localizations" : {
"en" : { "en" : {
@ -2494,6 +2501,9 @@
"Скоро" : { "Скоро" : {
"comment" : "Add blocked user placeholder title\nContacts placeholder title\nЗаголовок заглушки" "comment" : "Add blocked user placeholder title\nContacts placeholder title\nЗаголовок заглушки"
}, },
"Скоро здесь появится информация о собеседнике, статусе и дополнительных действиях." : {
"comment" : "Message profile placeholder description"
},
"Скоро появится мини-игра, где можно заработать очки для кастомизации профиля. Следите за обновлениями!" : { "Скоро появится мини-игра, где можно заработать очки для кастомизации профиля. Следите за обновлениями!" : {
"comment" : "Concept tab placeholder description" "comment" : "Concept tab placeholder description"
}, },

View File

@ -7,6 +7,7 @@ struct PrivateChatView: View {
let chat: PrivateChatListItem let chat: PrivateChatListItem
let currentUserId: String? let currentUserId: String?
private let bottomAnchorId = "PrivateChatBottomAnchor" private let bottomAnchorId = "PrivateChatBottomAnchor"
private let headerAvatarSize: CGFloat = 36
let lineLimitInChat = 6 let lineLimitInChat = 6
@ -18,8 +19,10 @@ struct PrivateChatView: View {
@State private var inputTab: ComposerTab = .chat @State private var inputTab: ComposerTab = .chat
@State private var isVideoPreferred: Bool = false @State private var isVideoPreferred: Bool = false
@State private var legacyComposerHeight: CGFloat = 40 @State private var legacyComposerHeight: CGFloat = 40
@State private var isProfilePresented: Bool = false
@FocusState private var isComposerFocused: Bool @FocusState private var isComposerFocused: Bool
@EnvironmentObject private var messageCenter: IncomingMessageCenter @EnvironmentObject private var messageCenter: IncomingMessageCenter
@Environment(\.dismiss) private var dismiss
init(chat: PrivateChatListItem, currentUserId: String?) { init(chat: PrivateChatListItem, currentUserId: String?) {
self.chat = chat self.chat = chat
@ -28,6 +31,7 @@ struct PrivateChatView: View {
} }
var body: some View { var body: some View {
ZStack {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
content content
@ -46,8 +50,44 @@ struct PrivateChatView: View {
} }
} }
} }
.navigationTitle(title)
NavigationLink(
destination: MessageProfilePlaceholderView(chat: chat, currentUserId: currentUserId),
isActive: $isProfilePresented
) {
EmptyView()
}
.hidden()
}
.navigationTitle(toolbarTitle)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
// .toolbar {
// ToolbarItem(placement: .principal) {
// chatToolbarContent
// }
// }
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.accentColor)
}
}
ToolbarItem(placement: .principal) {
Button(action: openProfile) {
nameStatusView
}
.buttonStyle(.plain)
}
ToolbarItem(placement: .navigationBarTrailing) {
avatarButton
}
}
.task { .task {
viewModel.loadInitialHistory() viewModel.loadInitialHistory()
} }
@ -454,16 +494,383 @@ struct PrivateChatView: View {
}() }()
private var title: String { private var title: String {
if let full = chat.chatData?.fullName, !full.isEmpty { switch chat.chatType {
return full case .self:
} return NSLocalizedString("Избранные сообщения", comment: "Saved messages title")
if let custom = chat.chatData?.customName, !custom.isEmpty { case .privateChat, .unknown:
if let custom = trimmed(chat.chatData?.customName) {
return custom return custom
} }
if let login = chat.chatData?.login, !login.isEmpty { if let full = trimmed(chat.chatData?.fullName) {
return full
}
if let login = trimmed(chat.chatData?.login) {
return "@\(login)" return "@\(login)"
} }
return NSLocalizedString("Чат", comment: "") return NSLocalizedString("Неизвестный пользователь", comment: "Unknown chat title")
}
}
private var toolbarTitle: String {
officialDisplayName ?? title
}
private var offlineStatusText: String {
NSLocalizedString("Оффлайн", comment: "Offline status placeholder")
}
private var loginDisplay: String? {
guard let login = trimmed(chat.chatData?.login) else {
return nil
}
return "@\(login)"
}
private var isOfficial: Bool {
chat.chatData?.isOfficial ?? false
}
private var officialDisplayName: String? {
guard isOfficial else { return nil }
if let customName = trimmed(chat.chatData?.customName) {
return customName
}
if let name = trimmed(chat.chatData?.fullName) {
return NSLocalizedString(name, comment: "Official chat name")
}
return loginDisplay
}
private var isDeletedUser: Bool {
guard chat.chatType != .self else { return false }
return trimmed(chat.chatData?.login) == nil
}
private var avatarBackgroundColor: Color {
if isDeletedUser {
return Color(.systemGray5)
}
return isOfficial ? Color.accentColor.opacity(0.85) : Color.accentColor.opacity(0.15)
}
private var avatarTextColor: Color {
if isDeletedUser {
return Color.accentColor
}
return isOfficial ? Color.white : Color.accentColor
}
private var deletedUserSymbolName: String {
"person.slash"
}
private var avatarUrl: URL? {
guard let chatData = chat.chatData,
let fileId = chatData.avatars?.current?.fileId else {
return nil
}
let userId = chatData.userId
return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(userId)?file_id=\(fileId)")
}
private var avatarInitial: String {
if let name = trimmed(chat.chatData?.customName) ?? trimmed(chat.chatData?.fullName) {
let components = name.split(separator: " ")
let initials = components.prefix(2).compactMap { $0.first }
if !initials.isEmpty {
return initials.map { String($0) }.joined().uppercased()
}
}
if let login = trimmed(chat.chatData?.login) {
return String(login.prefix(1)).uppercased()
}
return "?"
}
private func trimmed(_ string: String?) -> String? {
guard let string = string?.trimmingCharacters(in: .whitespacesAndNewlines), !string.isEmpty else {
return nil
}
return string
}
private var chatToolbarContent: some View {
HStack(spacing: 12) {
backButton
// Spacer(minLength: 0)
nameStatusButton
.frame(maxWidth: .infinity)
// Spacer(minLength: 0)
avatarButton
}
.frame(maxWidth: .infinity, minHeight: headerAvatarSize, alignment: .center)
}
@ViewBuilder
private var nameStatusView: some View {
VStack(spacing: 2) {
if let officialName = officialDisplayName {
HStack(spacing: 4) {
nameText(officialName, weight: .semibold)
Image(systemName: "checkmark.seal.fill")
.foregroundColor(Color.accentColor)
.font(.caption)
}
} else {
nameText(title, weight: .semibold)
}
Text(offlineStatusText)
.font(.caption)
.foregroundColor(.secondary)
}
.multilineTextAlignment(.center)
}
@ViewBuilder
private var avatarView: some View {
if let url = avatarUrl,
let fileId = chat.chatData?.avatars?.current?.fileId,
let userId = currentUserId {
CachedAvatarView(url: url, fileId: fileId, userId: userId) {
headerPlaceholderAvatar
}
.aspectRatio(contentMode: .fill)
.frame(width: headerAvatarSize, height: headerAvatarSize)
.clipShape(Circle())
} else {
headerPlaceholderAvatar
}
}
private var nameStatusButton: some View {
Button(action: openProfile) {
nameStatusView
}
.buttonStyle(.plain)
}
private var avatarButton: some View {
Button(action: openProfile) {
avatarView
}
.buttonStyle(.plain)
}
private func nameText(_ text: String, weight: Font.Weight) -> some View {
Group {
if #available(iOS 16.0, *) {
Text(text)
.font(.headline)
.fontWeight(weight)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
.strikethrough(isDeletedUser, color: Color.secondary)
} else {
Text(text)
.font(.headline)
.fontWeight(weight)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
}
}
}
private var backButton: some View {
Button(action: { dismiss() }) {
Image(systemName: "chevron.left")
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.accentColor)
}
.frame(width: headerAvatarSize, height: headerAvatarSize)
.contentShape(Rectangle())
}
private func openProfile() {
isProfilePresented = true
}
private var headerPlaceholderAvatar: some View {
Circle()
.fill(avatarBackgroundColor)
.frame(width: headerAvatarSize, height: headerAvatarSize)
.overlay(
Group {
if isDeletedUser {
Image(systemName: deletedUserSymbolName)
.symbolRenderingMode(.hierarchical)
.font(.system(size: headerAvatarSize * 0.45, weight: .semibold))
.foregroundColor(avatarTextColor)
} else {
Text(avatarInitial)
.font(.system(size: headerAvatarSize * 0.5, weight: .semibold))
.foregroundColor(avatarTextColor)
}
}
)
}
}
struct MessageProfilePlaceholderView: View {
let chat: PrivateChatListItem
let currentUserId: String?
private let avatarSize: CGFloat = 96
var body: some View {
ScrollView {
VStack(spacing: 24) {
profileAvatar
VStack(spacing: 4) {
Text(displayName)
.font(.title3)
.fontWeight(.semibold)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
if let login = loginDisplay {
Text(login)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
Text(NSLocalizedString("Профиль для сообщений пока в разработке.", comment: "Message profile placeholder title"))
.font(.body)
.multilineTextAlignment(.center)
Text(NSLocalizedString("Скоро здесь появится информация о собеседнике, статусе и дополнительных действиях.", comment: "Message profile placeholder description"))
.font(.footnote)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 24)
.padding(.top, 60)
.frame(maxWidth: .infinity)
}
.background(Color(UIColor.systemBackground))
.navigationTitle(NSLocalizedString("Профиль", comment: "Message profile placeholder nav title"))
.navigationBarTitleDisplayMode(.inline)
}
private var displayName: String {
if let custom = trimmed(chat.chatData?.customName) {
return custom
}
if let full = trimmed(chat.chatData?.fullName) {
return full
}
if let login = trimmed(chat.chatData?.login) {
return "@\(login)"
}
return NSLocalizedString("Неизвестный пользователь", comment: "Message profile fallback title")
}
private var loginDisplay: String? {
guard let login = trimmed(chat.chatData?.login) else { return nil }
return "@\(login)"
}
private var isDeletedUser: Bool {
trimmed(chat.chatData?.login) == nil
}
private var isOfficial: Bool {
chat.chatData?.isOfficial ?? false
}
private var avatarBackgroundColor: Color {
if isDeletedUser {
return Color(.systemGray5)
}
return isOfficial ? Color.accentColor.opacity(0.85) : Color.accentColor.opacity(0.15)
}
private var avatarTextColor: Color {
if isDeletedUser {
return Color.accentColor
}
return isOfficial ? Color.white : Color.accentColor
}
private var avatarInitial: String {
if let name = trimmed(chat.chatData?.customName) ?? trimmed(chat.chatData?.fullName) {
let components = name.split(separator: " ")
let initials = components.prefix(2).compactMap { $0.first }
if !initials.isEmpty {
return initials.map { String($0) }.joined().uppercased()
}
}
if let login = trimmed(chat.chatData?.login) {
return String(login.prefix(1)).uppercased()
}
return "?"
}
private var avatarUrl: URL? {
guard let chatData = chat.chatData,
let fileId = chatData.avatars?.current?.fileId else {
return nil
}
let userId = chatData.userId
return URL(string: "\(AppConfig.API_SERVER)/v1/storage/download/avatar/\(userId)?file_id=\(fileId)")
}
@ViewBuilder
private var profileAvatar: some View {
if let url = avatarUrl,
let fileId = chat.chatData?.avatars?.current?.fileId,
let userId = currentUserId {
CachedAvatarView(url: url, fileId: fileId, userId: userId) {
placeholderAvatar
}
.aspectRatio(contentMode: .fill)
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
} else {
placeholderAvatar
}
}
private var placeholderAvatar: some View {
Circle()
.fill(avatarBackgroundColor)
.frame(width: avatarSize, height: avatarSize)
.overlay(
Group {
if isDeletedUser {
Image(systemName: "person.slash")
.symbolRenderingMode(.hierarchical)
.font(.system(size: avatarSize * 0.45, weight: .semibold))
.foregroundColor(avatarTextColor)
} else {
Text(avatarInitial)
.font(.system(size: avatarSize * 0.45, weight: .semibold))
.foregroundColor(avatarTextColor)
}
}
)
}
private func trimmed(_ text: String?) -> String? {
guard let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else {
return nil
}
return text
} }
} }