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

982 lines
40 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
@State private var areNotificationsEnabled: Bool = true
@State private var placeholderAlert: PlaceholderAlert?
@State private var isBioExpanded: Bool = false
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 24) {
headerCard
DescriptionSection
quickActionsSection
aboutSection
mediaPreviewSection
chatSettingsSection
contactActionsSection
safetySection
footerHint
}
.padding(.horizontal, 20)
.padding(.vertical, 24)
}
.background(Color(UIColor.systemGroupedBackground).ignoresSafeArea())
.navigationTitle(NSLocalizedString("Профиль", comment: "Message profile navigation title"))
.navigationBarTitleDisplayMode(.inline)
.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
)
}
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
)
}
}
}
// 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 {
section(
title: NSLocalizedString("Медиа, ссылки и файлы", comment: "Message profile media title"),
description: NSLocalizedString("Плитки как в Telegram — скоро здесь появятся вложения из чата.", comment: "Message profile media description")
) {
card {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) {
ForEach(Array(sharedMediaPlaceholderIcons.enumerated()), id: \.offset) { index, icon in
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(sharedMediaPlaceholderColors[index % sharedMediaPlaceholderColors.count])
.frame(height: 72)
.overlay(
Image(systemName: icon)
.font(.system(size: 20, weight: .semibold))
.foregroundColor(.white.opacity(0.9))
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.white.opacity(0.18), lineWidth: 1)
)
}
}
.frame(maxWidth: .infinity)
Text(NSLocalizedString("История медиа синхронизируется. Как только появятся первые вложения, они покажутся здесь списком превью.", comment: "Message profile media footer"))
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 12)
}
}
}
private var chatSettingsSection: some View {
section(
title: NSLocalizedString("Настройки чата", comment: "Message profile chat settings title"),
description: NSLocalizedString("Тонкие настройки диалога по образцу профиля Telegram.", comment: "Message profile chat settings description")
) {
card {
VStack(spacing: 0) {
Toggle(isOn: $areNotificationsEnabled) {
VStack(alignment: .leading, spacing: 2) {
Text(NSLocalizedString("Уведомления", comment: "Message profile notifications title"))
Text(
areNotificationsEnabled
? NSLocalizedString("Push включены — приходят все новые сообщения.", comment: "Message profile notifications subtitle on")
: NSLocalizedString("Тишина включена. Чат не тревожит до включения сигнала.", comment: "Message profile notifications subtitle off")
)
.font(.caption)
.foregroundColor(.secondary)
}
}
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
.padding(.vertical, 4)
rowDivider
buttonRow(
icon: "timer",
title: NSLocalizedString("Автоудаление", comment: "Message profile auto delete title"),
subtitle: NSLocalizedString("Сообщения пока сохраняются навсегда.", comment: "Message profile auto delete subtitle"),
iconTint: .orange
) {
showPlaceholderAction(
title: NSLocalizedString("Автоудаление", comment: "Message profile auto delete alert title"),
message: NSLocalizedString("Режим автоудаления появится чуть позже. Мы добавим пресеты на 24 часа, 7 дней и 1 месяц — совсем как в Telegram.", comment: "Message profile auto delete alert message")
)
}
rowDivider
buttonRow(
icon: "text.bubble",
title: NSLocalizedString("Очистить историю", comment: "Message profile clear history title"),
subtitle: NSLocalizedString("Удалить переписку только для себя.", comment: "Message profile clear history subtitle"),
iconTint: .blue
) {
showPlaceholderAction(
title: NSLocalizedString("Очистка истории", comment: "Message profile clear history alert title"),
message: NSLocalizedString("Скоро можно будет очистить сообщения выборочно или целиком. Пока подготовим дизайн.", comment: "Message profile clear history alert message")
)
}
}
}
}
}
private var contactActionsSection: some View {
section(
title: NSLocalizedString("Контакт", comment: "Message profile contact section title"),
description: NSLocalizedString("Управление карточкой собеседника.", comment: "Message profile contact section description")
) {
card {
VStack(spacing: 0) {
buttonRow(
icon: "person.badge.plus",
title: NSLocalizedString("Добавить в контакты", comment: "Message profile add to contacts title"),
subtitle: NSLocalizedString("Появится отдельная запись в адресной книге Yobble.", comment: "Message profile add to contacts subtitle"),
iconTint: .accentColor
) {
showPlaceholderAction(
title: NSLocalizedString("Добавить контакт", comment: "Message profile add contact alert title"),
message: NSLocalizedString("Редактор контактов скоро появится. Мы сохраним имя, телефон и заметку.", comment: "Message profile add contact alert message")
)
}
rowDivider
buttonRow(
icon: "paperplane.fill",
title: NSLocalizedString("Поделиться профилем", comment: "Message profile share contact title"),
subtitle: NSLocalizedString("Отправим ссылку или QR — как в Telegram.", comment: "Message profile share contact subtitle"),
iconTint: .purple
) {
showPlaceholderAction(
title: NSLocalizedString("Поделиться", comment: "Message profile share alert title"),
message: NSLocalizedString("Кнопка поделиться соберёт ссылку, QR и кнопку пересылки контакта.", comment: "Message profile share alert message")
)
}
}
}
}
}
private var safetySection: some View {
section(
title: NSLocalizedString("Безопасность", comment: "Message profile safety section title"),
description: NSLocalizedString("Блокировка и жалобы доступны из профиля, как в Telegram.", comment: "Message profile safety section description")
) {
card {
VStack(spacing: 0) {
buttonRow(
icon: "hand.raised.slash.fill",
title: isBlockedByCurrentUser
? NSLocalizedString("Разблокировать", comment: "Message profile unblock title")
: NSLocalizedString("Заблокировать", comment: "Message profile block title"),
subtitle: isBlockedByCurrentUser
? NSLocalizedString("Пользователь снова сможет писать вам.", comment: "Message profile unblock subtitle")
: NSLocalizedString("Перестанет появляться в чате и не сможет писать.", comment: "Message profile block subtitle"),
iconTint: .red,
destructive: true
) {
let message = isBlockedByCurrentUser
? NSLocalizedString("Скоро появится разблокировка с подтверждением и синхронизацией.", comment: "Message profile unblock alert message")
: NSLocalizedString("Блокировка чата пока в дизайне. Готовим отдельный экран со статусом и жалобой.", comment: "Message profile block alert message")
showPlaceholderAction(
title: isBlockedByCurrentUser
? NSLocalizedString("Разблокировать", comment: "Message profile unblock alert title")
: NSLocalizedString("Заблокировать", comment: "Message profile block alert title"),
message: message
)
}
rowDivider
buttonRow(
icon: "exclamationmark.bubble.fill",
title: NSLocalizedString("Пожаловаться", comment: "Message profile report title"),
subtitle: NSLocalizedString("Сообщите о спаме или нарушении правил.", comment: "Message profile report subtitle"),
iconTint: .orange,
destructive: true
) {
showPlaceholderAction(
title: NSLocalizedString("Жалоба", comment: "Message profile report alert title"),
message: NSLocalizedString("Форма жалобы появится чуть позже — добавим прикрепление скриншотов и тип нарушения.", comment: "Message profile report alert message")
)
}
}
}
}
}
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 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)
}
// MARK: - Derived Data
private var profileBio: String? {
trimmed(chat.chatData?.bio)
}
private var membershipDescription: String? {
guard let createdAt = chat.chatData?.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 ratingDisplayValue: String {
guard let rating = chat.chatData?.rating else {
return NSLocalizedString("Недоступно", comment: "Message profile rating unavailable")
}
let safeRating = max(0, min(5, rating))
return String(
format: NSLocalizedString("%d из 5", comment: "Message profile rating format"),
safeRating
)
}
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 = chat.chatData?.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 = chat.chatData?.relationship {
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 var sharedMediaPlaceholderIcons: [String] {
[
"photo.on.rectangle",
"doc.text.fill",
"link",
"paperclip",
"music.note",
"video.fill"
]
}
private var sharedMediaPlaceholderColors: [Color] {
[
Color.accentColor.opacity(0.8),
Color.purple.opacity(0.8),
Color.blue.opacity(0.7),
Color.orange.opacity(0.8),
Color.green.opacity(0.7),
Color.pink.opacity(0.8)
]
}
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 isBlockedByCurrentUser: Bool {
chat.chatData?.relationship?.isTargetUserBlockedByCurrentUser ?? false
}
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 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 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(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 publicFullName: String? {
guard
let custom = trimmed(chat.chatData?.customName),
!custom.isEmpty, // custom должен быть
let full = trimmed(chat.chatData?.fullName),
!full.isEmpty // full должен быть
else {
return nil
}
return full
}
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
}
// 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 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 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
}