ProfileView.swift (23070B)
1 // 2 // ProfileView.swift 3 // damus 4 // 5 // Created by William Casarin on 2022-04-23. 6 // 7 8 import SwiftUI 9 10 enum ProfileTab: Hashable { 11 case posts 12 case following 13 } 14 15 enum FollowState { 16 case follows 17 case following 18 case unfollowing 19 case unfollows 20 } 21 22 func follow_btn_txt(_ fs: FollowState, follows_you: Bool) -> String { 23 switch fs { 24 case .follows: 25 return NSLocalizedString("Unfollow", comment: "Button to unfollow a user.") 26 case .following: 27 return NSLocalizedString("Following...", comment: "Label to indicate that the user is in the process of following another user.") 28 case .unfollowing: 29 return NSLocalizedString("Unfollowing...", comment: "Label to indicate that the user is in the process of unfollowing another user.") 30 case .unfollows: 31 if follows_you { 32 return NSLocalizedString("Follow Back", comment: "Button to follow a user back.") 33 } else { 34 return NSLocalizedString("Follow", comment: "Button to follow a user.") 35 } 36 } 37 } 38 39 func follow_btn_enabled_state(_ fs: FollowState) -> Bool { 40 switch fs { 41 case .follows: 42 return true 43 case .following: 44 return false 45 case .unfollowing: 46 return false 47 case .unfollows: 48 return true 49 } 50 } 51 52 func followersCountString(_ count: Int, locale: Locale = Locale.current) -> String { 53 let format = localizedStringFormat(key: "followers_count", locale: locale) 54 return String(format: format, locale: locale, count) 55 } 56 57 func followingCountString(_ count: Int, locale: Locale = Locale.current) -> String { 58 let format = localizedStringFormat(key: "following_count", locale: locale) 59 return String(format: format, locale: locale, count) 60 } 61 62 func relaysCountString(_ count: Int, locale: Locale = Locale.current) -> String { 63 let format = localizedStringFormat(key: "relays_count", locale: locale) 64 return String(format: format, locale: locale, count) 65 } 66 67 struct EditButton: View { 68 let damus_state: DamusState 69 70 @Environment(\.colorScheme) var colorScheme 71 72 var body: some View { 73 NavigationLink(destination: EditMetadataView(damus_state: damus_state)) { 74 Text("Edit", comment: "Button to edit user's profile.") 75 .frame(height: 30) 76 .padding(.horizontal,25) 77 .font(.caption.weight(.bold)) 78 .foregroundColor(fillColor()) 79 .cornerRadius(24) 80 .overlay { 81 RoundedRectangle(cornerRadius: 24) 82 .stroke(borderColor(), lineWidth: 1) 83 } 84 .minimumScaleFactor(0.5) 85 .lineLimit(1) 86 } 87 } 88 89 func fillColor() -> Color { 90 colorScheme == .light ? Color("DamusBlack") : Color("DamusWhite") 91 } 92 93 func borderColor() -> Color { 94 colorScheme == .light ? Color("DamusBlack") : Color("DamusWhite") 95 } 96 } 97 98 struct VisualEffectView: UIViewRepresentable { 99 var effect: UIVisualEffect? 100 101 func makeUIView(context: UIViewRepresentableContext<Self>) -> UIVisualEffectView { 102 UIVisualEffectView() 103 } 104 105 func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext<Self>) { 106 uiView.effect = effect 107 } 108 } 109 110 struct ProfileView: View { 111 let damus_state: DamusState 112 let pfp_size: CGFloat = 90.0 113 let bannerHeight: CGFloat = 150.0 114 115 static let markdown = Markdown() 116 117 @State private var selected_tab: ProfileTab = .posts 118 @State private var showingEditProfile = false 119 @State var showing_select_wallet: Bool = false 120 @State var is_zoomed: Bool = false 121 @State var show_share_sheet: Bool = false 122 @State var action_sheet_presented: Bool = false 123 @State var filter_state : FilterState = .posts 124 @State var yOffset: CGFloat = 0 125 126 @StateObject var profile: ProfileModel 127 @StateObject var followers: FollowersModel 128 129 init(damus_state: DamusState, profile: ProfileModel, followers: FollowersModel) { 130 self.damus_state = damus_state 131 self._profile = StateObject(wrappedValue: profile) 132 self._followers = StateObject(wrappedValue: followers) 133 } 134 135 init(damus_state: DamusState, pubkey: String) { 136 self.damus_state = damus_state 137 self._profile = StateObject(wrappedValue: ProfileModel(pubkey: pubkey, damus: damus_state)) 138 self._followers = StateObject(wrappedValue: FollowersModel(damus_state: damus_state, target: pubkey)) 139 } 140 141 @Environment(\.dismiss) var dismiss 142 @Environment(\.colorScheme) var colorScheme 143 @Environment(\.openURL) var openURL 144 @Environment(\.presentationMode) var presentationMode 145 146 func fillColor() -> Color { 147 colorScheme == .light ? Color("DamusLightGrey") : Color("DamusDarkGrey") 148 } 149 150 func imageBorderColor() -> Color { 151 colorScheme == .light ? Color("DamusWhite") : Color("DamusBlack") 152 } 153 154 func bannerBlurViewOpacity() -> Double { 155 let progress = -(yOffset + navbarHeight) / 100 156 return Double(-yOffset > navbarHeight ? progress : 0) 157 } 158 159 var bannerSection: some View { 160 GeometryReader { proxy -> AnyView in 161 162 let minY = proxy.frame(in: .global).minY 163 164 DispatchQueue.main.async { 165 self.yOffset = minY 166 } 167 168 return AnyView( 169 VStack(spacing: 0) { 170 ZStack { 171 BannerImageView(pubkey: profile.pubkey, profiles: damus_state.profiles) 172 .aspectRatio(contentMode: .fill) 173 .frame(width: proxy.size.width, height: minY > 0 ? bannerHeight + minY : bannerHeight) 174 .clipped() 175 176 VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)).opacity(bannerBlurViewOpacity()) 177 } 178 179 Divider().opacity(bannerBlurViewOpacity()) 180 } 181 .frame(height: minY > 0 ? bannerHeight + minY : nil) 182 .offset(y: minY > 0 ? -minY : -minY < navbarHeight ? 0 : -minY - navbarHeight) 183 ) 184 185 } 186 .frame(height: bannerHeight) 187 .allowsHitTesting(false) 188 } 189 190 var navbarHeight: CGFloat { 191 return 100.0 - (Theme.safeAreaInsets?.top ?? 0) 192 } 193 194 @ViewBuilder 195 func navImage(systemImage: String) -> some View { 196 Image(systemName: systemImage) 197 .frame(width: 33, height: 33) 198 .background(Color.black.opacity(0.6)) 199 .clipShape(Circle()) 200 } 201 202 var navBackButton: some View { 203 Button { 204 presentationMode.wrappedValue.dismiss() 205 } label: { 206 navImage(systemImage: "chevron.left") 207 } 208 } 209 210 var navActionSheetButton: some View { 211 Button(action: { 212 action_sheet_presented = true 213 }) { 214 navImage(systemImage: "ellipsis") 215 } 216 .confirmationDialog(NSLocalizedString("Actions", comment: "Title for confirmation dialog to either share, report, or block a profile."), isPresented: $action_sheet_presented) { 217 Button(NSLocalizedString("Share", comment: "Button to share the link to a profile.")) { 218 show_share_sheet = true 219 } 220 221 // Only allow reporting if logged in with private key and the currently viewed profile is not the logged in profile. 222 if profile.pubkey != damus_state.pubkey && damus_state.is_privkey_user { 223 Button(NSLocalizedString("Report", comment: "Button to report a profile."), role: .destructive) { 224 let target: ReportTarget = .user(profile.pubkey) 225 notify(.report, target) 226 } 227 228 Button(NSLocalizedString("Block", comment: "Button to block a profile."), role: .destructive) { 229 notify(.block, profile.pubkey) 230 } 231 } 232 } 233 } 234 235 var customNavbar: some View { 236 HStack { 237 navBackButton 238 Spacer() 239 navActionSheetButton 240 } 241 .padding(.top, 5) 242 .padding(.horizontal) 243 .accentColor(Color("DamusWhite")) 244 } 245 246 func lnButton(lnurl: String, profile: Profile) -> some View { 247 Button(action: { 248 if damus_state.settings.show_wallet_selector { 249 showing_select_wallet = true 250 } else { 251 open_with_wallet(wallet: damus_state.settings.default_wallet.model, invoice: lnurl) 252 } 253 }) { 254 Image(systemName: "bolt.circle") 255 .profile_button_style(scheme: colorScheme) 256 .contextMenu { 257 Button { 258 UIPasteboard.general.string = profile.lnurl ?? "" 259 } label: { 260 Label(NSLocalizedString("Copy LNURL", comment: "Context menu option for copying a user's Lightning URL."), systemImage: "doc.on.doc") 261 } 262 } 263 264 } 265 .cornerRadius(24) 266 .sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) { 267 SelectWalletView(showingSelectWallet: $showing_select_wallet, our_pubkey: damus_state.pubkey, invoice: lnurl) 268 } 269 } 270 271 var dmButton: some View { 272 let dm_model = damus_state.dms.lookup_or_create(profile.pubkey) 273 let dmview = DMChatView(damus_state: damus_state, pubkey: profile.pubkey) 274 .environmentObject(dm_model) 275 return NavigationLink(destination: dmview) { 276 Image(systemName: "bubble.left.circle") 277 .profile_button_style(scheme: colorScheme) 278 } 279 } 280 281 func actionSection(profile_data: Profile?) -> some View { 282 return Group { 283 284 if let profile = profile_data { 285 if let lnurl = profile.lnurl, lnurl != "" { 286 lnButton(lnurl: lnurl, profile: profile) 287 } 288 } 289 290 dmButton 291 292 if profile.pubkey != damus_state.pubkey { 293 FollowButtonView( 294 target: profile.get_follow_target(), 295 follows_you: profile.follows(pubkey: damus_state.pubkey), 296 follow_state: damus_state.contacts.follow_state(profile.pubkey) 297 ) 298 } else if damus_state.keypair.privkey != nil { 299 NavigationLink(destination: EditMetadataView(damus_state: damus_state)) { 300 EditButton(damus_state: damus_state) 301 } 302 } 303 304 } 305 } 306 307 func pfpOffset() -> CGFloat { 308 let progress = -yOffset / navbarHeight 309 let offset = (pfp_size / 4.0) * (progress < 1.0 ? progress : 1) 310 return offset > 0 ? offset : 0 311 } 312 313 func pfpScale() -> CGFloat { 314 let progress = -yOffset / navbarHeight 315 let scale = 1.0 - (0.5 * (progress < 1.0 ? progress : 1)) 316 return scale < 1 ? scale : 1 317 } 318 319 func nameSection(profile_data: Profile?) -> some View { 320 return Group { 321 HStack(alignment: .center) { 322 ProfilePicView(pubkey: profile.pubkey, size: pfp_size, highlight: .custom(imageBorderColor(), 4.0), profiles: damus_state.profiles) 323 .padding(.top, -(pfp_size / 2.0)) 324 .offset(y: pfpOffset()) 325 .scaleEffect(pfpScale()) 326 .onTapGesture { 327 is_zoomed.toggle() 328 } 329 .fullScreenCover(isPresented: $is_zoomed) { 330 ProfileZoomView(pubkey: profile.pubkey, profiles: damus_state.profiles) } 331 332 Spacer() 333 334 actionSection(profile_data: profile_data) 335 } 336 337 let follows_you = profile.pubkey != damus_state.pubkey && profile.follows(pubkey: damus_state.pubkey) 338 ProfileNameView(pubkey: profile.pubkey, profile: profile_data, follows_you: follows_you, damus: damus_state) 339 } 340 } 341 342 var followersCount: some View { 343 HStack { 344 if followers.count == nil { 345 Image(systemName: "square.and.arrow.down") 346 Text("Followers", comment: "Label describing followers of a user.") 347 .font(.subheadline) 348 .foregroundColor(.gray) 349 } else { 350 let followerCount = followers.count! 351 let noun_text = Text(verbatim: "\(followersCountString(followerCount))").font(.subheadline).foregroundColor(.gray) 352 Text("\(Text("\(followerCount)").font(.subheadline.weight(.medium))) \(noun_text)", comment: "Sentence composed of 2 variables to describe how many people are following a user. In source English, the first variable is the number of followers, and the second variable is 'Follower' or 'Followers'.") 353 } 354 } 355 } 356 357 var aboutSection: some View { 358 VStack(alignment: .leading, spacing: 8.0) { 359 let profile_data = damus_state.profiles.lookup(id: profile.pubkey) 360 361 nameSection(profile_data: profile_data) 362 363 Text(ProfileView.markdown.process(profile_data?.about ?? "")) 364 .font(.subheadline).textSelection(.enabled) 365 366 if let url = profile_data?.website_url { 367 WebsiteLink(url: url) 368 } 369 370 HStack { 371 if let contact = profile.contacts { 372 let contacts = contact.referenced_pubkeys.map { $0.ref_id } 373 let following_model = FollowingModel(damus_state: damus_state, contacts: contacts) 374 NavigationLink(destination: FollowingView(damus_state: damus_state, following: following_model, whos: profile.pubkey)) { 375 HStack { 376 let noun_text = Text(verbatim: "\(followingCountString(profile.following))").font(.subheadline).foregroundColor(.gray) 377 Text("\(Text("\(profile.following)").font(.subheadline.weight(.medium))) \(noun_text)", comment: "Sentence composed of 2 variables to describe how many profiles a user is following. In source English, the first variable is the number of profiles being followed, and the second variable is 'Following'.") 378 } 379 } 380 .buttonStyle(PlainButtonStyle()) 381 } 382 let fview = FollowersView(damus_state: damus_state, whos: profile.pubkey) 383 .environmentObject(followers) 384 if followers.contacts != nil { 385 NavigationLink(destination: fview) { 386 followersCount 387 } 388 .buttonStyle(PlainButtonStyle()) 389 } else { 390 followersCount 391 .onTapGesture { 392 UIImpactFeedbackGenerator(style: .light).impactOccurred() 393 followers.contacts = [] 394 followers.subscribe() 395 } 396 } 397 398 if let relays = profile.relays { 399 // Only open relay config view if the user is logged in with private key and they are looking at their own profile. 400 let noun_text = Text(verbatim: "\(relaysCountString(relays.keys.count))").font(.subheadline).foregroundColor(.gray) 401 let relay_text = Text("\(Text("\(relays.keys.count)").font(.subheadline.weight(.medium))) \(noun_text)", comment: "Sentence composed of 2 variables to describe how many relay servers a user is connected. In source English, the first variable is the number of relay servers, and the second variable is 'Relay' or 'Relays'.") 402 if profile.pubkey == damus_state.pubkey && damus_state.is_privkey_user { 403 NavigationLink(destination: RelayConfigView(state: damus_state)) { 404 relay_text 405 } 406 .buttonStyle(PlainButtonStyle()) 407 } else { 408 NavigationLink(destination: UserRelaysView(state: damus_state, pubkey: profile.pubkey, relays: Array(relays.keys).sorted())) { 409 relay_text 410 } 411 .buttonStyle(PlainButtonStyle()) 412 } 413 } 414 } 415 } 416 .padding(.horizontal) 417 } 418 419 var body: some View { 420 ScrollView(.vertical) { 421 VStack(spacing: 0) { 422 bannerSection 423 .zIndex(1) 424 425 VStack() { 426 aboutSection 427 428 VStack(spacing: 0) { 429 CustomPicker(selection: $filter_state, content: { 430 Text("Posts", comment: "Label for filter for seeing only your posts (instead of posts and replies).").tag(FilterState.posts) 431 Text("Posts & Replies", comment: "Label for filter for seeing your posts and replies (instead of only your posts).").tag(FilterState.posts_and_replies) 432 }) 433 Divider() 434 .frame(height: 1) 435 } 436 .background(colorScheme == .dark ? Color.black : Color.white) 437 438 if filter_state == FilterState.posts { 439 InnerTimelineView(events: profile.events, damus: damus_state, show_friend_icon: false, filter: FilterState.posts.filter) 440 } 441 if filter_state == FilterState.posts_and_replies { 442 InnerTimelineView(events: profile.events, damus: damus_state, show_friend_icon: false, filter: FilterState.posts_and_replies.filter) 443 } 444 } 445 .padding(.horizontal, Theme.safeAreaInsets?.left) 446 .zIndex(-yOffset > navbarHeight ? 0 : 1) 447 } 448 } 449 .ignoresSafeArea() 450 .navigationTitle("") 451 .navigationBarHidden(true) 452 .overlay(customNavbar, alignment: .top) 453 .onReceive(handle_notify(.switched_timeline)) { _ in 454 dismiss() 455 } 456 .onAppear() { 457 profile.subscribe() 458 //followers.subscribe() 459 } 460 .onDisappear { 461 profile.unsubscribe() 462 followers.unsubscribe() 463 // our profilemodel needs a bit more help 464 } 465 .sheet(isPresented: $show_share_sheet) { 466 if let npub = bech32_pubkey(profile.pubkey) { 467 if let url = URL(string: "https://damus.io/" + npub) { 468 ShareSheet(activityItems: [url]) 469 } 470 } 471 } 472 } 473 } 474 475 struct ProfileView_Previews: PreviewProvider { 476 static var previews: some View { 477 let ds = test_damus_state() 478 ProfileView(damus_state: ds, pubkey: ds.pubkey) 479 } 480 } 481 482 func test_damus_state() -> DamusState { 483 let pubkey = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681" 484 let damus = DamusState.empty 485 486 let prof = Profile(name: "damus", display_name: "damus", about: "iOS app!", picture: "https://damus.io/img/logo.png", banner: "", website: "https://damus.io", lud06: nil, lud16: "jb55@sendsats.lol", nip05: "damus.io") 487 let tsprof = TimestampedProfile(profile: prof, timestamp: 0) 488 damus.profiles.add(id: pubkey, profile: tsprof) 489 return damus 490 } 491 492 struct KeyView: View { 493 let pubkey: String 494 495 @Environment(\.colorScheme) var colorScheme 496 497 @State private var isCopied = false 498 499 func fillColor() -> Color { 500 colorScheme == .light ? Color("DamusLightGrey") : Color("DamusDarkGrey") 501 } 502 503 func keyColor() -> Color { 504 colorScheme == .light ? Color("DamusBlack") : Color("DamusWhite") 505 } 506 507 private func copyPubkey(_ pubkey: String) { 508 UIPasteboard.general.string = pubkey 509 UIImpactFeedbackGenerator(style: .medium).impactOccurred() 510 withAnimation { 511 isCopied = true 512 DispatchQueue.main.asyncAfter(deadline: .now() + 3) { 513 withAnimation { 514 isCopied = false 515 } 516 } 517 } 518 } 519 520 var body: some View { 521 let bech32 = bech32_pubkey(pubkey) ?? pubkey 522 523 HStack { 524 HStack { 525 Button { 526 copyPubkey(bech32) 527 } label: { 528 Label(NSLocalizedString("Public Key", comment: "Label indicating that the text is a user's public account key."), systemImage: "key.fill") 529 .font(.custom("key", size: 12.0)) 530 .labelStyle(IconOnlyLabelStyle()) 531 .foregroundStyle(hex_to_rgb(pubkey)) 532 .symbolRenderingMode(.palette) 533 } 534 .padding(.trailing, 2) 535 Text(verbatim: "\(abbrev_pubkey(bech32, amount: 16))") 536 .font(.footnote) 537 .foregroundColor(keyColor()) 538 } 539 .padding(2) 540 .padding([.leading, .trailing], 3) 541 .background(RoundedRectangle(cornerRadius: 11).foregroundColor(fillColor())) 542 543 if isCopied != true { 544 Button { 545 copyPubkey(bech32) 546 } label: { 547 Label { 548 Text("Public key", comment: "Label indicating that the text is a user's public account key.") 549 } icon: { 550 Image(systemName: "square.on.square.dashed") 551 .contentShape(Rectangle()) 552 .foregroundColor(.gray) 553 .frame(width: 20, height: 20) 554 } 555 .labelStyle(IconOnlyLabelStyle()) 556 .symbolRenderingMode(.hierarchical) 557 } 558 } else { 559 HStack { 560 Image(systemName: "checkmark.circle") 561 .frame(width: 20, height: 20) 562 Text(NSLocalizedString("Copied", comment: "Label indicating that a user's key was copied.")) 563 .font(.footnote) 564 .layoutPriority(1) 565 } 566 .foregroundColor(Color("DamusGreen")) 567 } 568 } 569 } 570 } 571 572 extension View { 573 func profile_button_style(scheme: ColorScheme) -> some View { 574 self.symbolRenderingMode(.palette) 575 .font(.system(size: 32).weight(.thin)) 576 .foregroundStyle(scheme == .dark ? .white : .black, scheme == .dark ? .white : .black) 577 } 578 }