Files
mobile-ios/Rosetta/Features/Chats/ChatDetail/OpponentProfileView.swift

473 lines
19 KiB
Swift

import SwiftUI
/// Telegram-parity peer profile screen with expandable header, shared media tabs.
struct OpponentProfileView: View {
let route: ChatRoute
@StateObject private var viewModel: PeerProfileViewModel
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@State private var copiedField: String?
@State private var isLargeHeader = false
@State private var topInset: CGFloat = 0
@State private var isMuted = false
@State private var showMoreSheet = false
@State private var selectedTab: PeerProfileTab = .media
@Namespace private var tabNamespace
enum PeerProfileTab: String, CaseIterable {
case media = "Media"
case files = "Files"
case links = "Links"
case groups = "Groups"
}
init(route: ChatRoute) {
self.route = route
_viewModel = StateObject(wrappedValue: PeerProfileViewModel(dialogKey: route.publicKey))
}
// MARK: - Computed properties
private var dialog: Dialog? {
DialogRepository.shared.dialogs[route.publicKey]
}
private var displayName: String {
if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if !route.title.isEmpty { return route.title }
if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
if !route.username.isEmpty { return "@\(route.username)" }
return String(route.publicKey.prefix(12))
}
private var username: String {
if let dialog, !dialog.opponentUsername.isEmpty { return dialog.opponentUsername }
return route.username
}
private var effectiveVerified: Int {
if let dialog { return dialog.effectiveVerified }
if route.verified > 0 { return route.verified }
return 0
}
private var opponentAvatar: UIImage? {
AvatarRepository.shared.loadAvatar(publicKey: route.publicKey)
}
/// Only real photos can expand to full-width; letter avatars stay as circles.
private var canExpand: Bool { opponentAvatar != nil }
/// Telegram parity: show online status, not username/key
private var subtitleText: String {
viewModel.isOnline ? "online" : "offline"
}
// MARK: - Body
var body: some View {
scrollContent
.scrollIndicators(.hidden)
.modifier(ProfileScrollTracker(isLargeHeader: $isLargeHeader, topInset: $topInset, canExpand: canExpand))
.background(RosettaColors.Adaptive.background.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.enableSwipeBack()
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button { dismiss() } label: { backButtonLabel }
.buttonStyle(.plain)
}
}
.toolbarBackground(.hidden, for: .navigationBar)
.task {
isMuted = dialog?.isMuted ?? false
// Only expand when user has a REAL photo (not letter avatar)
if opponentAvatar != nil {
isLargeHeader = true
}
viewModel.startObservingOnline()
viewModel.loadSharedContent()
viewModel.loadCommonGroups()
}
.confirmationDialog("", isPresented: $showMoreSheet, titleVisibility: .hidden) {
Button("Block User", role: .destructive) {}
Button("Clear Chat History", role: .destructive) {}
}
}
private var scrollContent: some View {
ScrollView(.vertical) {
LazyVStack(spacing: 0) {
infoSections
.padding(.top, 16)
.padding(.horizontal, 16)
sharedMediaTabBar
.padding(.top, 20)
sharedMediaContent
.padding(.top, 12)
Spacer(minLength: 40)
}
.safeAreaInset(edge: .top, spacing: 0) {
PeerProfileHeaderView(
isLargeHeader: $isLargeHeader,
topInset: $topInset,
displayName: displayName,
subtitleText: subtitleText,
effectiveVerified: effectiveVerified,
avatarImage: opponentAvatar,
avatarInitials: RosettaColors.initials(name: displayName, publicKey: route.publicKey),
avatarColorIndex: RosettaColors.avatarColorIndex(for: displayName, publicKey: route.publicKey),
isMuted: isMuted,
onCall: handleCall,
onMuteToggle: handleMuteToggle,
onSearch: { dismiss() },
onMore: { showMoreSheet = true }
)
}
}
}
// MARK: - Back Button (always white chevron glass capsule provides dark tint for contrast)
private var backButtonLabel: some View {
TelegramVectorIcon(
pathData: TelegramIconPath.backChevron,
viewBox: CGSize(width: 11, height: 20),
color: isLargeHeader ? .white : RosettaColors.Adaptive.text
)
.frame(width: 11, height: 20)
.frame(width: 36, height: 36)
.frame(height: 44)
.padding(.horizontal, 4)
.contentShape(Rectangle())
.background {
if isLargeHeader {
TelegramGlassCapsule()
} else {
Capsule()
.fill(colorScheme == .dark
? Color.white.opacity(0.12)
: Color.black.opacity(0.06))
.overlay(
Capsule()
.strokeBorder(colorScheme == .dark
? Color.white.opacity(0.08)
: Color.black.opacity(0.08), lineWidth: 0.5)
)
}
}
}
// MARK: - Info Sections
private var infoSections: some View {
TelegramSectionCard {
VStack(spacing: 0) {
if !username.isEmpty {
infoRow(label: "username", value: "@\(username)", rawValue: username, fieldId: "username")
telegramDivider
}
infoRow(label: "public key", value: route.publicKey, rawValue: route.publicKey, fieldId: "publicKey")
}
}
}
private var telegramDivider: some View {
Rectangle()
.fill(telegramSeparatorColor)
.frame(height: 1 / UIScreen.main.scale)
.padding(.leading, 16)
}
private func infoRow(label: String, value: String, rawValue: String, fieldId: String) -> some View {
Button {
UIPasteboard.general.string = rawValue
withAnimation(.easeInOut(duration: 0.2)) { copiedField = fieldId }
Task { @MainActor in
try? await Task.sleep(for: .seconds(1.5))
withAnimation(.easeInOut(duration: 0.2)) {
if copiedField == fieldId { copiedField = nil }
}
}
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Text(copiedField == fieldId ? "Copied" : value)
.font(.system(size: 17))
.foregroundStyle(copiedField == fieldId ? RosettaColors.online : RosettaColors.primaryBlue)
.lineLimit(1)
.truncationMode(.middle)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.buttonStyle(.plain)
}
// MARK: - Shared Media Tab Bar (Telegram parity)
private var tabActiveColor: Color { colorScheme == .dark ? .white : .black }
private var tabInactiveColor: Color { colorScheme == .dark ? Color.white.opacity(0.6) : Color.black.opacity(0.4) }
private var tabIndicatorFill: Color { colorScheme == .dark ? Color.white.opacity(0.18) : Color.black.opacity(0.08) }
private var sharedMediaTabBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(PeerProfileTab.allCases, id: \.self) { tab in
Button {
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
selectedTab = tab
}
} label: {
Text(tab.rawValue)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(selectedTab == tab ? tabActiveColor : tabInactiveColor)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background {
if selectedTab == tab {
Capsule()
.fill(tabIndicatorFill)
.matchedGeometryEffect(id: "peer_tab", in: tabNamespace)
}
}
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 3)
.padding(.vertical, 3)
}
.background(Capsule().fill(telegramSectionFill))
.padding(.horizontal, 16)
}
// MARK: - Shared Media Content
@ViewBuilder
private var sharedMediaContent: some View {
switch selectedTab {
case .media:
if viewModel.mediaItems.isEmpty {
emptyState(icon: "photo.on.rectangle", title: "No Media Yet")
} else {
peerMediaGrid
}
case .files:
if viewModel.fileItems.isEmpty {
emptyState(icon: "doc", title: "No Files Yet")
} else {
peerFilesList
}
case .links:
if viewModel.linkItems.isEmpty {
emptyState(icon: "link", title: "No Links Yet")
} else {
peerLinksList
}
case .groups:
if viewModel.commonGroups.isEmpty {
emptyState(icon: "person.2", title: "No Groups in Common")
} else {
commonGroupsList
}
}
}
private func emptyState(icon: String, title: String) -> some View {
VStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 40))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
Text(title)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
private var peerMediaGrid: some View {
let columns = Array(repeating: GridItem(.flexible(), spacing: 1), count: 3)
return LazyVGrid(columns: columns, spacing: 1) {
ForEach(viewModel.mediaItems) { item in
PeerMediaTile(item: item, allItems: viewModel.mediaItems)
}
}
}
private var peerFilesList: some View {
TelegramSectionCard {
VStack(spacing: 0) {
ForEach(Array(viewModel.fileItems.enumerated()), id: \.element.id) { index, file in
HStack(spacing: 12) {
Image(systemName: "doc.fill")
.font(.system(size: 22))
.foregroundStyle(RosettaColors.primaryBlue)
.frame(width: 40, height: 40)
.background(RoundedRectangle(cornerRadius: 10).fill(RosettaColors.primaryBlue.opacity(0.12)))
VStack(alignment: .leading, spacing: 2) {
Text(file.fileName).font(.system(size: 16)).foregroundStyle(RosettaColors.Adaptive.text).lineLimit(1)
Text(file.subtitle).font(.system(size: 13)).foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
Spacer()
}
.padding(.horizontal, 16).padding(.vertical, 10)
if index < viewModel.fileItems.count - 1 {
Rectangle().fill(telegramSeparatorColor).frame(height: 1 / UIScreen.main.scale).padding(.leading, 68)
}
}
}
}
.padding(.horizontal, 16)
}
private var peerLinksList: some View {
TelegramSectionCard {
VStack(spacing: 0) {
ForEach(Array(viewModel.linkItems.enumerated()), id: \.element.id) { index, link in
Button { if let url = URL(string: link.url) { UIApplication.shared.open(url) } } label: {
HStack(spacing: 12) {
Text(String(link.displayHost.prefix(1)).uppercased())
.font(.system(size: 18, weight: .bold)).foregroundStyle(.white)
.frame(width: 40, height: 40)
.background(RoundedRectangle(cornerRadius: 10).fill(RosettaColors.primaryBlue))
VStack(alignment: .leading, spacing: 2) {
Text(link.displayHost).font(.system(size: 16, weight: .medium)).foregroundStyle(RosettaColors.Adaptive.text).lineLimit(1)
Text(link.context).font(.system(size: 13)).foregroundStyle(RosettaColors.Adaptive.textSecondary).lineLimit(2)
}
Spacer()
}
.padding(.horizontal, 16).padding(.vertical, 10)
}.buttonStyle(.plain)
if index < viewModel.linkItems.count - 1 {
Rectangle().fill(telegramSeparatorColor).frame(height: 1 / UIScreen.main.scale).padding(.leading, 68)
}
}
}
}
.padding(.horizontal, 16)
}
private var commonGroupsList: some View {
TelegramSectionCard {
VStack(spacing: 0) {
ForEach(Array(viewModel.commonGroups.enumerated()), id: \.element.id) { index, group in
HStack(spacing: 12) {
let initials = RosettaColors.initials(name: group.title, publicKey: group.dialogKey)
let colorIdx = RosettaColors.avatarColorIndex(for: group.title, publicKey: group.dialogKey)
AvatarView(initials: initials, colorIndex: colorIdx, size: 40, isOnline: false, image: group.avatar)
Text(group.title)
.font(.system(size: 17))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
Spacer()
}
.padding(.horizontal, 16).padding(.vertical, 8)
if index < viewModel.commonGroups.count - 1 {
Rectangle().fill(telegramSeparatorColor).frame(height: 1 / UIScreen.main.scale).padding(.leading, 68)
}
}
}
}
.padding(.horizontal, 16)
}
// MARK: - Actions
private func handleCall() {
let name = displayName
let user = username
Task { @MainActor in
_ = CallManager.shared.startOutgoingCall(toPublicKey: route.publicKey, title: name, username: user)
}
}
private func handleMuteToggle() {
DialogRepository.shared.toggleMute(opponentKey: route.publicKey)
isMuted.toggle()
}
}
// MARK: - Media Tile
private struct PeerMediaTile: View {
let item: SharedMediaItem
let allItems: [SharedMediaItem]
@State private var image: UIImage?
@State private var blurImage: UIImage?
var body: some View {
GeometryReader { proxy in
ZStack {
if let image { Image(uiImage: image).resizable().scaledToFill().frame(width: proxy.size.width, height: proxy.size.width).clipped() }
else if let blurImage { Image(uiImage: blurImage).resizable().scaledToFill().frame(width: proxy.size.width, height: proxy.size.width).clipped() }
else { Color(white: 0.15) }
}
.contentShape(Rectangle())
.onTapGesture { openGallery() }
}
.aspectRatio(1, contentMode: .fit)
.task { loadImages() }
}
private func loadImages() {
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: item.attachmentId) { image = cached }
else if !item.blurhash.isEmpty { blurImage = BlurHashDecoder.decode(blurHash: item.blurhash, width: 32, height: 32) }
}
private func openGallery() {
let viewable = allItems.map { ViewableImageInfo(attachmentId: $0.attachmentId, messageId: $0.messageId, senderName: $0.senderName, timestamp: Date(timeIntervalSince1970: Double($0.timestamp) / 1000.0), caption: $0.caption) }
let index = allItems.firstIndex(where: { $0.attachmentId == item.attachmentId }) ?? 0
ImageViewerPresenter.shared.present(state: ImageViewerState(images: viewable, initialIndex: index, sourceFrame: .zero))
}
}
// MARK: - Scroll Tracking
private struct ProfileScrollTracker: ViewModifier {
@Binding var isLargeHeader: Bool
@Binding var topInset: CGFloat
let canExpand: Bool
func body(content: Content) -> some View {
if #available(iOS 18, *) {
IOS18ScrollTracker(isLargeHeader: $isLargeHeader, topInset: $topInset, canExpand: canExpand) { content }
} else { content }
}
}
@available(iOS 18, *)
private struct IOS18ScrollTracker<Content: View>: View {
@Binding var isLargeHeader: Bool
@Binding var topInset: CGFloat
let canExpand: Bool
@State private var scrollPhase: ScrollPhase = .idle
let content: () -> Content
var body: some View {
content()
.onScrollGeometryChange(for: CGFloat.self) { $0.contentInsets.top } action: { _, v in topInset = v }
.onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.y + $0.contentInsets.top } action: { _, v in
if scrollPhase == .interacting {
withAnimation(.snappy(duration: 0.2, extraBounce: 0)) {
isLargeHeader = canExpand && (v < -10 || (isLargeHeader && v < 0))
}
}
}
.onScrollPhaseChange { _, p in scrollPhase = p }
}
}