ios_app_v2/yobble/Views/Chat/MessageProfileView.swift
2025-12-11 03:20:27 +03:00

1040 lines
39 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// MessageProfileView.swift
// yobble
//
// Created by cheykrym on 10.12.2025.
//
import SwiftUI
struct MessageProfileView: View {
let chat: PrivateChatListItem
let currentUserId: String?
private let avatarSize: CGFloat = 96
private let blockedUsersService = BlockedUsersService()
private let profileService = ProfileService()
@State private var areNotificationsEnabled: Bool = true
@State private var placeholderAlert: PlaceholderAlert?
@State private var isBioExpanded: Bool = false
@State private var chatProfile: ChatProfile?
@State private var isBlockedByCurrentUserState: Bool
@State private var isProcessingBlockAction = false
init(chat: PrivateChatListItem, currentUserId: String?) {
self.chat = chat
self.currentUserId = currentUserId
_chatProfile = State(initialValue: chat.chatData)
_isBlockedByCurrentUserState = State(initialValue: chat.chatData?.relationship?.isTargetUserBlockedByCurrentUser ?? false)
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 24) {
headerCard
DescriptionSection
quickActionsSection
aboutSection
mediaPreviewSection
// footerHint
}
.padding(.horizontal, 20)
.padding(.vertical, 24)
}
.background(Color(UIColor.systemGroupedBackground).ignoresSafeArea())
.navigationTitle(NSLocalizedString("Профиль", comment: "Message profile navigation title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if canEditContact {
Button(NSLocalizedString("Изменить", comment: "Message profile edit contact button")) {
handleEditContactTap()
}
}
}
}
.alert(item: $placeholderAlert) { alert in
Alert(
title: Text(alert.title),
message: Text(alert.message),
dismissButton: .default(Text(NSLocalizedString("Понятно", comment: "Placeholder alert dismiss")))
)
}
}
// MARK: - Header
private var headerCard: some View {
VStack(spacing: 16) {
profileAvatar
.overlay(alignment: .bottomTrailing) {
officialBadge
}
VStack(spacing: 6) {
HStack(spacing: 6) {
Text(displayName)
.font(.title2)
.fontWeight(.semibold)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
// if let login = loginDisplay {
// Text(login)
// .font(.subheadline)
// .foregroundColor(.secondary)
// }
if let status = presenceStatus {
HStack(spacing: 6) {
Circle()
.fill(status.isOnline ? Color.green : Color.gray.opacity(0.4))
.frame(width: 8, height: 8)
Text(status.text)
.font(.footnote)
.foregroundColor(.secondary)
}
}
}
// if let bio = profileBio {
// Text(bio)
// .font(.body)
// .multilineTextAlignment(.center)
// }
if !statusTags.isEmpty {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 8)], spacing: 8) {
ForEach(statusTags) { tag in
HStack(spacing: 6) {
Image(systemName: tag.icon)
.font(.system(size: 12, weight: .semibold))
Text(tag.text)
.font(.caption)
.fontWeight(.medium)
}
.padding(.vertical, 6)
.padding(.horizontal, 10)
.foregroundColor(tag.tint)
.background(tag.background)
.clipShape(Capsule())
}
}
}
}
.padding(24)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 32, style: .continuous)
.fill(headerGradient)
)
.overlay(
RoundedRectangle(cornerRadius: 32, style: .continuous)
.stroke(Color.white.opacity(0.08), lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.08), radius: 20, x: 0, y: 10)
}
private var DescriptionSection: some View {
Group {
if let full = publicFullName{
Section(){
card {
VStack(spacing: 0) {
infoRow(
title: NSLocalizedString("Публичное имя", comment: ""),
value: full
)
}
}
}
}
if let bio = profileBio {
card {
VStack(alignment: .leading, spacing: 12) {
// Заголовок
Text("Биография")
.font(.caption)
.foregroundColor(.secondary)
// Основной текст (обрезанный или полный)
Text(isBioExpanded ? bio : bioFirstLines(bio, count: 4))
.font(.body)
.textSelection(.enabled) // копирование
.animation(.easeInOut, value: isBioExpanded)
// Кнопка "ещё / скрыть" если строк больше 4
if bio.lineCount > 4 {
HStack {
Spacer()
Button(action: { isBioExpanded.toggle() }) {
Text(isBioExpanded ? "Скрыть" : "Ещё…")
.font(.subheadline)
.foregroundColor(.accentColor)
}
Spacer()
}
.padding(.top, 4)
.buttonStyle(.plain)
.padding(.top, 4)
}
}
}
}
}
}
@ViewBuilder
private var officialBadge: some View {
if isOfficial {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
.padding(6)
.background(Circle().fill(Color.accentColor))
.offset(x: 6, y: 6)
}
}
private var headerGradient: LinearGradient {
let first = isOfficial ? Color.accentColor : Color.accentColor.opacity(0.6)
let second = Color.accentColor.opacity(isOfficial ? 0.6 : 0.3)
let third = Color(UIColor.secondarySystemBackground)
return LinearGradient(colors: [first, second, third], startPoint: .topLeading, endPoint: .bottomTrailing)
}
// MARK: - Sections
private var quickActionsSection: some View {
section() {
card {
HStack(spacing: 12) {
ForEach(quickActionItems) { action in
quickActionButton(action)
}
}
}
}
}
private func quickActionButton(_ action: ProfileQuickAction) -> some View {
Button(action: {
showPlaceholderAction(title: action.title, message: action.description)
}) {
VStack(spacing: 10) {
Circle()
.fill(action.tint.opacity(0.15))
.frame(width: 64, height: 64)
.overlay(
Image(systemName: action.icon)
.font(.system(size: 22, weight: .semibold))
.foregroundColor(action.tint)
)
Text(action.title)
.font(.footnote)
.multilineTextAlignment(.center)
.foregroundColor(.primary)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
}
private var aboutSection: some View {
Section(){
card {
VStack(spacing: 0) {
if let login = loginDisplay {
infoRow(
title: NSLocalizedString("Юзернейм", comment: ""),
value: login
)
}else{
infoRow(
title: NSLocalizedString("Юзернейм", comment: ""),
value: "Неизвестный пользователь"
)
}
if let membership = membershipDescription {
rowDivider
infoRow(
title: NSLocalizedString("Дата регистрации в Yobble", comment: ""),
value: membership
)
}
if loginDisplay != nil || membershipDescription != nil {
rowDivider
}
infoRow(
title: NSLocalizedString("Рейтинг собеседника", comment: "Message profile rating title"),
value: ratingDisplayValue
)
if shouldShowRelationshipQuickActions {
rowDivider
filledActionButton(
title: NSLocalizedString("Добавить в контакты", comment: "Message profile add to contacts title"),
tint: Color.accentColor
) {
handleAddContactTap()
}
rowDivider
filledActionButton(
title: isBlockedByCurrentUser
? NSLocalizedString("Разблокировать", comment: "Message profile unblock title")
: NSLocalizedString("Заблокировать", comment: "Message profile block title"),
subtitle: isProcessingBlockAction
? NSLocalizedString("Подождите...", comment: "Message profile block action pending subtitle")
: nil,
tint: isBlockedByCurrentUser ? Color.green : Color.red
) {
handleBlockToggleTap()
}
.disabled(isProcessingBlockAction)
}
}
}
}
// section(
// title: NSLocalizedString("О пользователе", comment: "Message profile about title"),
// description: NSLocalizedString("Имя, логин и статус как в профиле Telegram.", comment: "Message profile about description")
// ) {
// card {
// VStack(spacing: 0) {
// infoRow(
// icon: "person.text.rectangle",
// title: NSLocalizedString("Имя в чате", comment: ""),
// value: displayName
// )
//
// if let login = loginDisplay {
// rowDivider
// infoRow(
// icon: "at",
// title: NSLocalizedString("Юзернейм", comment: ""),
// value: login
// )
// }
//
// if let membership = membershipDescription {
// rowDivider
// infoRow(
// icon: "calendar",
// title: NSLocalizedString("На Yobble", comment: ""),
// value: membership
// )
// }
//
// if let status = presenceStatus, !status.isOnline {
// rowDivider
// infoRow(
// icon: "clock",
// title: NSLocalizedString("Последний визит", comment: ""),
// value: status.text
// )
// }
//
// rowDivider
// infoRow(
// icon: "lock.shield",
// title: NSLocalizedString("Тип диалога", comment: ""),
// value: chatTypeDescription
// )
// }
// }
// }
}
private var mediaPreviewSection: some View {
VStack(alignment: .leading, spacing: 12) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(mediaCategories) { category in
mediaCategoryButton(category)
}
}
.padding(.vertical, 4)
}
Text(NSLocalizedString("Разделы временно показывают заглушки.", comment: "Message profile media footer new"))
.font(.caption)
.foregroundColor(.secondary)
}
}
// private var footerHint: some View {
// Text(NSLocalizedString("Мы постепенно повторяем знакомый паттерн Telegram, чтобы переход был комфортным. Укажите, что ещё ожидать на экране профиля добавим приоритетно.", comment: "Message profile footer"))
// .font(.footnote)
// .foregroundColor(.secondary)
// .multilineTextAlignment(.center)
// .padding(.horizontal, 12)
// .padding(.bottom, 24)
// }
// MARK: - Helper Builders
private func section<Content: View>(
title: String? = nil,
description: String? = nil,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 12) {
// показать только если реально есть текст
if (title?.isEmpty == false) || (description?.isEmpty == false) {
VStack(alignment: .leading, spacing: 4) {
if let title, !title.isEmpty {
Text(title)
.font(.headline)
}
if let description, !description.isEmpty {
Text(description)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 16) {
content()
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.fill(Color(UIColor.secondarySystemGroupedBackground))
)
}
private func infoRow(icon: String? = nil, title: String, value: String) -> some View {
HStack(alignment: .top, spacing: 12) {
if let icon {
iconBackground(color: .accentColor.opacity(0.18)) {
Image(systemName: icon)
.font(.system(size: 17, weight: .semibold))
.foregroundColor(.accentColor)
}
}
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
Text(value)
.font(.body)
}
Spacer()
}
.padding(.vertical, 4)
}
private func buttonRow(
icon: String,
title: String,
subtitle: String? = nil,
iconTint: Color,
destructive: Bool = false,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: 12) {
iconBackground(color: iconTint.opacity(0.15)) {
Image(systemName: icon)
.font(.system(size: 17, weight: .semibold))
.foregroundColor(iconTint)
}
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.body)
.foregroundColor(destructive ? .red : .primary)
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(UIColor.tertiaryLabel))
}
.padding(.vertical, 2)
}
.buttonStyle(.plain)
}
private func filledActionButton(
title: String,
subtitle: String? = nil,
tint: Color,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.body)
.fontWeight(.semibold)
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundColor(tint.opacity(0.7))
}
}
.foregroundColor(tint)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
}
.buttonStyle(.plain)
}
private func iconBackground<Content: View>(color: Color, @ViewBuilder content: () -> Content) -> some View {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(color)
.frame(width: 44, height: 44)
.overlay(content())
}
private var rowDivider: some View {
Rectangle()
.fill(Color(UIColor.separator).opacity(0.4))
.frame(height: 1)
.padding(.vertical, 8)
}
private func showPlaceholderAction(title: String, message: String) {
placeholderAlert = PlaceholderAlert(title: title, message: message)
}
private func handleAddContactTap() {
showPlaceholderAction(
title: NSLocalizedString("Добавить контакт", comment: "Message profile add contact alert title"),
message: NSLocalizedString("Редактор контактов скоро появится. Мы сохраним имя, телефон и заметку.", comment: "Message profile add contact alert message")
)
}
private func handleEditContactTap() {
showPlaceholderAction(
title: NSLocalizedString("Изменить контакт", comment: "Contacts context action edit"),
message: NSLocalizedString("Редактирование контакта появится позже.", comment: "Message profile edit contact alert message")
)
}
private func handleBlockToggleTap() {
guard !isProcessingBlockAction else { return }
guard let userIdString = currentChatProfile?.userId,
let userId = UUID(uuidString: userIdString) else {
placeholderAlert = PlaceholderAlert(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: NSLocalizedString("Не удалось определить пользователя для блокировки.", comment: "Message profile missing user id error")
)
return
}
isProcessingBlockAction = true
let shouldBlock = !isBlockedByCurrentUser
Task {
await performBlockAction(shouldBlock: shouldBlock, userId: userId)
}
}
private func performBlockAction(shouldBlock: Bool, userId: UUID) async {
do {
if shouldBlock {
_ = try await blockedUsersService.add(userId: userId)
} else {
_ = try await blockedUsersService.remove(userId: userId)
}
await MainActor.run {
isBlockedByCurrentUserState = shouldBlock
}
await refreshChatProfile(userId: userId)
} catch {
if AppConfig.DEBUG {
print("[MessageProfileView] block action failed: \(error)")
}
await MainActor.run {
placeholderAlert = PlaceholderAlert(
title: NSLocalizedString("Ошибка", comment: "Common error title"),
message: error.localizedDescription
)
}
}
await MainActor.run {
isProcessingBlockAction = false
}
}
private func refreshChatProfile(userId: UUID) async {
do {
let profile = try await profileService.fetchProfile(userId: userId)
await MainActor.run {
chatProfile = profile
isBlockedByCurrentUserState = profile.relationship?.isTargetUserBlockedByCurrentUser ?? isBlockedByCurrentUserState
}
} catch {
if AppConfig.DEBUG {
print("[MessageProfileView] refresh profile failed: \(error)")
}
await MainActor.run {
placeholderAlert = PlaceholderAlert(
title: NSLocalizedString("Не удалось обновить профиль", comment: "Message profile refresh error title"),
message: error.localizedDescription
)
}
}
}
private var mediaCategories: [MediaCategory] {
[
MediaCategory(title: NSLocalizedString("Медиа", comment: "Message profile category media")),
MediaCategory(title: NSLocalizedString("Сохранённые", comment: "Message profile category saved")),
MediaCategory(title: NSLocalizedString("Файлы", comment: "Message profile category files")),
MediaCategory(title: NSLocalizedString("Голосовые", comment: "Message profile category voice")),
MediaCategory(title: NSLocalizedString("Ссылки", comment: "Message profile category links")),
MediaCategory(title: NSLocalizedString("Группы", comment: "Message profile category groups")),
MediaCategory(title: NSLocalizedString("Музыка", comment: "Message profile category music")),
MediaCategory(title: NSLocalizedString("GIF", comment: "Message profile category gifs")),
MediaCategory(title: NSLocalizedString("Посты", comment: "Message profile category posts"))
]
}
// MARK: - Derived Data
private var profileBio: String? {
trimmed(currentChatProfile?.bio)
}
private var membershipDescription: String? {
guard let createdAt = currentChatProfile?.createdAt else { return nil }
let formatted = MessageProfileView.joinedFormatter.string(from: createdAt)
return String(
// format: NSLocalizedString("На платформе с %@", comment: "Message profile joined format"),
format: NSLocalizedString("%@", comment: "Message profile joined format"),
formatted
)
}
private var shouldShowRelationshipQuickActions: Bool {
guard let relationship = currentChatProfile?.relationship else { return false }
return !relationship.isTargetInContactsOfCurrentUser
}
private var ratingDisplayValue: String {
guard let rating = currentChatProfile?.rating else {
return NSLocalizedString("Недоступно", comment: "Message profile rating unavailable")
}
let safeRating = max(0, min(5, rating))
let formatted = MessageProfileView.ratingFormatter.string(from: NSNumber(value: safeRating))
?? String(format: "%.1f", safeRating)
return String(
format: NSLocalizedString("%@ из 5", comment: "Message profile rating format"),
formatted
)
}
private var chatTypeDescription: String {
switch chat.chatType {
case .self:
return NSLocalizedString("Избранное", comment: "Message profile self chat type")
case .privateChat:
return NSLocalizedString("Личный чат", comment: "Message profile private chat type")
case .unknown:
fallthrough
default:
return NSLocalizedString("Приватный диалог", comment: "Message profile default chat type")
}
}
private var presenceStatus: PresenceStatus? {
if isDeletedUser {
return PresenceStatus(
text: NSLocalizedString("Пользователь удалён", comment: "Message profile deleted user status"),
isOnline: false
)
}
guard let lastSeen = currentChatProfile?.lastSeen else { return nil }
let lastSeenDate = Date(timeIntervalSince1970: TimeInterval(lastSeen))
let interval = Date().timeIntervalSince(lastSeenDate)
if interval < 5 * 60 {
return PresenceStatus(
text: NSLocalizedString("в сети", comment: "Message profile online status"),
isOnline: true
)
}
let relative = MessageProfileView.relativeFormatter.localizedString(for: lastSeenDate, relativeTo: Date())
return PresenceStatus(
text: String(
format: NSLocalizedString("был(а) %@", comment: "Message profile last seen relative format"),
relative
),
isOnline: false
)
}
private var statusTags: [StatusTag] {
var tags: [StatusTag] = []
if isOfficial {
tags.append(
StatusTag(
icon: "checkmark.seal.fill",
text: NSLocalizedString("Подтверждённый профиль", comment: "Message profile verified tag"),
background: Color.white.opacity(0.18),
tint: .white
)
)
}
if let relationship = currentChatProfile?.relationship {
if relationship.isTargetInContactsOfCurrentUser {
tags.append(
StatusTag(
icon: "person.crop.circle.badge.checkmark",
text: NSLocalizedString("В ваших контактах", comment: "Message profile user in contacts tag"),
background: Color.white.opacity(0.14),
tint: .white
)
)
}
if relationship.isCurrentUserInContactsOfTarget {
tags.append(
StatusTag(
icon: "person.2.fill",
text: NSLocalizedString("Вы в его контактах", comment: "Message profile contact tag"),
background: Color.white.opacity(0.14),
tint: .white
)
)
}
if relationship.isCurrentUserInBlacklistOfTarget {
tags.append(
StatusTag(
icon: "hand.thumbsdown.fill",
text: NSLocalizedString("Вы в его чёрном списке", comment: "Message profile blacklist tag"),
background: Color.red.opacity(0.4),
tint: .white
)
)
}
if relationship.isTargetUserBlockedByCurrentUser {
tags.append(
StatusTag(
icon: "hand.raised.slash.fill",
text: NSLocalizedString("Заблокирован", comment: "Message profile blocked tag"),
background: Color.orange.opacity(0.4),
tint: .white
)
)
}
}
if isDeletedUser {
tags.append(
StatusTag(
icon: "person.crop.circle.badge.xmark",
text: NSLocalizedString("Аккаунт удалён", comment: "Message profile deleted tag"),
background: Color.white.opacity(0.12),
tint: .white
)
)
}
return tags
}
private func mediaCategoryButton(_ category: MediaCategory) -> some View {
Button {
showPlaceholderAction(
title: category.title,
message: NSLocalizedString("Раздел скоро станет активным — собираем и индексируем вложения.", comment: "Message profile media placeholder message")
)
} label: {
VStack(alignment: .leading, spacing: 6) {
Text(category.title)
.font(.footnote)
.fontWeight(.medium)
.foregroundColor(.primary)
}
.padding(.vertical, 12)
.padding(.horizontal, 14)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(UIColor.secondarySystemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(Color(UIColor.separator).opacity(0.2), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
private var quickActionItems: [ProfileQuickAction] {
[
ProfileQuickAction(
icon: "phone.fill",
title: NSLocalizedString("Звонок", comment: "Message profile call action"),
description: NSLocalizedString("Голосовые звонки пока недоступны. Как только включим WebRTC, кнопка оживёт.", comment: "Message profile call action description"),
tint: .green
),
ProfileQuickAction(
icon: "video.fill",
title: NSLocalizedString("Видео", comment: "Message profile video action"),
description: NSLocalizedString("Видео созвоны появятся вместе с звонками. Интерфейс повторит Telegram.", comment: "Message profile video action description"),
tint: .purple
),
ProfileQuickAction(
icon: "bell.slash.fill",
title: NSLocalizedString("Заглушить", comment: "Message profile mute action"),
description: NSLocalizedString("Появится мутация на 1 час, 1 день или навсегда.", comment: "Message profile mute action description"),
tint: .orange
),
// ProfileQuickAction(
// icon: "magnifyingglass",
// title: NSLocalizedString("Поиск", comment: "Message profile search action"),
// description: NSLocalizedString("Скоро можно будет искать сообщения, ссылки и файлы в этом чате.", comment: "Message profile search action description"),
// tint: .blue
// ),
ProfileQuickAction(
icon: "ellipsis.circle",
title: NSLocalizedString("Ещё", comment: "Message profile more action"),
description: NSLocalizedString("Дополнительные действия.", comment: "Message profile more action description"),
tint: .gray
)
]
}
private var currentChatProfile: ChatProfile? {
chatProfile ?? chat.chatData
}
private var canEditContact: Bool {
currentChatProfile?.relationship?.isTargetInContactsOfCurrentUser ?? false
}
private var isBlockedByCurrentUser: Bool { isBlockedByCurrentUserState }
private var avatarUrl: URL? {
guard let chatData = currentChatProfile,
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 = currentChatProfile?.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 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(currentChatProfile?.customName) ?? trimmed(currentChatProfile?.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(currentChatProfile?.login) {
return String(login.prefix(1)).uppercased()
}
return "?"
}
private func trimmed(_ text: String?) -> String? {
guard let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else {
return nil
}
return text
}
private var displayName: String {
if let custom = trimmed(currentChatProfile?.customName) {
return custom
}
if let full = trimmed(currentChatProfile?.fullName) {
return full
}
if let login = trimmed(currentChatProfile?.login) {
return "@\(login)"
}
return NSLocalizedString("Неизвестный пользователь", comment: "Message profile fallback title")
}
private var publicFullName: String? {
guard
let custom = trimmed(currentChatProfile?.customName),
!custom.isEmpty, // custom должен быть
let full = trimmed(currentChatProfile?.fullName),
!full.isEmpty // full должен быть
else {
return nil
}
return full
}
private var loginDisplay: String? {
guard let login = trimmed(currentChatProfile?.login) else { return nil }
return "@\(login)"
}
private var isDeletedUser: Bool {
trimmed(currentChatProfile?.login) == nil
}
private var isOfficial: Bool {
currentChatProfile?.isOfficial ?? false
}
// MARK: - Formatters & Models
private static let relativeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter
}()
private static let joinedFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .none
return formatter
}()
private static let ratingFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 1
formatter.maximumFractionDigits = 1
return formatter
}()
private func bioFirstLines(_ text: String, count: Int) -> String {
let lines = text.split(separator: "\n", omittingEmptySubsequences: false)
if lines.count <= count { return text }
return lines.prefix(count).joined(separator: "\n")
}
}
private extension String {
var lineCount: Int {
return self.split(separator: "\n", omittingEmptySubsequences: false).count
}
}
private struct PresenceStatus {
let text: String
let isOnline: Bool
}
private struct StatusTag: Identifiable {
let id = UUID()
let icon: String
let text: String
let background: Color
let tint: Color
}
private struct MediaCategory: Identifiable {
let id = UUID()
let title: String
}
private struct ProfileQuickAction: Identifiable {
let id = UUID()
let icon: String
let title: String
let description: String
let tint: Color
}
private struct PlaceholderAlert: Identifiable {
let id = UUID()
let title: String
let message: String
}