982 lines
40 KiB
Swift
982 lines
40 KiB
Swift
//
|
||
// 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
|
||
}
|