473 lines
19 KiB
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 }
|
|
}
|
|
}
|