ProfileView.swift (25948B)
1 // 2 // ProfileView.swift 3 // damus 4 // 5 // Created by William Casarin on 2022-04-23. 6 // 7 8 import SwiftUI 9 10 func follow_btn_txt(_ fs: FollowState, follows_you: Bool) -> String { 11 switch fs { 12 case .follows: 13 return NSLocalizedString("Unfollow", comment: "Button to unfollow a user.") 14 case .following: 15 return NSLocalizedString("Following...", comment: "Label to indicate that the user is in the process of following another user.") 16 case .unfollowing: 17 return NSLocalizedString("Unfollowing...", comment: "Label to indicate that the user is in the process of unfollowing another user.") 18 case .unfollows: 19 if follows_you { 20 return NSLocalizedString("Follow Back", comment: "Button to follow a user back.") 21 } else { 22 return NSLocalizedString("Follow", comment: "Button to follow a user.") 23 } 24 } 25 } 26 27 func followedByString(_ friend_intersection: [Pubkey], ndb: Ndb, locale: Locale = Locale.current) -> String { 28 let bundle = bundleForLocale(locale: locale) 29 let names: [String] = friend_intersection.prefix(3).map { pk in 30 let profile = ndb.lookup_profile(pk)?.unsafeUnownedValue?.profile 31 return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 20) 32 } 33 34 switch friend_intersection.count { 35 case 0: 36 return "" 37 case 1: 38 let format = NSLocalizedString("Followed by %@", bundle: bundle, comment: "Text to indicate that the user is followed by one of our follows.") 39 return String(format: format, locale: locale, names[0]) 40 case 2: 41 let format = NSLocalizedString("Followed by %@ & %@", bundle: bundle, comment: "Text to indicate that the user is followed by two of our follows.") 42 return String(format: format, locale: locale, names[0], names[1]) 43 case 3: 44 let format = NSLocalizedString("Followed by %@, %@ & %@", bundle: bundle, comment: "Text to indicate that the user is followed by three of our follows.") 45 return String(format: format, locale: locale, names[0], names[1], names[2]) 46 default: 47 let format = localizedStringFormat(key: "followed_by_three_and_others", locale: locale) 48 return String(format: format, locale: locale, friend_intersection.count - 3, names[0], names[1], names[2]) 49 } 50 } 51 52 struct VisualEffectView: UIViewRepresentable { 53 var effect: UIVisualEffect? 54 var darkeningOpacity: CGFloat = 0.3 // degree of darkening 55 56 func makeUIView(context: UIViewRepresentableContext<Self>) -> UIVisualEffectView { 57 let effectView = UIVisualEffectView() 58 effectView.backgroundColor = UIColor.black.withAlphaComponent(darkeningOpacity) 59 return effectView 60 } 61 62 func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext<Self>) { 63 uiView.effect = effect 64 uiView.backgroundColor = UIColor.black.withAlphaComponent(darkeningOpacity) 65 } 66 } 67 68 struct ProfileView: View { 69 let damus_state: DamusState 70 let pfp_size: CGFloat = 90.0 71 let bannerHeight: CGFloat = 150.0 72 73 @State var is_zoomed: Bool = false 74 @State var show_share_sheet: Bool = false 75 @State var show_qr_code: Bool = false 76 @State var action_sheet_presented: Bool = false 77 @State var mute_dialog_presented: Bool = false 78 @State var filter_state : FilterState = .posts 79 @State var yOffset: CGFloat = 0 80 81 @StateObject var profile: ProfileModel 82 @StateObject var followers: FollowersModel 83 @StateObject var zap_button_model: ZapButtonModel = ZapButtonModel() 84 85 init(damus_state: DamusState, profile: ProfileModel, followers: FollowersModel) { 86 self.damus_state = damus_state 87 self._profile = StateObject(wrappedValue: profile) 88 self._followers = StateObject(wrappedValue: followers) 89 } 90 91 init(damus_state: DamusState, pubkey: Pubkey) { 92 self.damus_state = damus_state 93 self._profile = StateObject(wrappedValue: ProfileModel(pubkey: pubkey, damus: damus_state)) 94 self._followers = StateObject(wrappedValue: FollowersModel(damus_state: damus_state, target: pubkey)) 95 } 96 97 @Environment(\.dismiss) var dismiss 98 @Environment(\.colorScheme) var colorScheme 99 @Environment(\.presentationMode) var presentationMode 100 101 func imageBorderColor() -> Color { 102 colorScheme == .light ? DamusColors.white : DamusColors.black 103 } 104 105 func bannerBlurViewOpacity() -> Double { 106 let progress = -(yOffset + navbarHeight) / 100 107 return Double(-yOffset > navbarHeight ? progress : 0) 108 } 109 110 func getProfileInfo() -> (String, String) { 111 let profile_txn = self.damus_state.profiles.lookup(id: profile.pubkey) 112 let ndbprofile = profile_txn?.unsafeUnownedValue 113 let displayName = Profile.displayName(profile: ndbprofile, pubkey: profile.pubkey).displayName.truncate(maxLength: 25) 114 let userName = Profile.displayName(profile: ndbprofile, pubkey: profile.pubkey).username.truncate(maxLength: 25) 115 return (displayName, "@\(userName)") 116 } 117 118 func showFollowBtnInBlurrBanner() -> Bool { 119 damus_state.contacts.follow_state(profile.pubkey) == .unfollows && bannerBlurViewOpacity() > 1.0 120 } 121 122 func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) { 123 var filters = ContentFilters.defaults(damus_state: damus_state) 124 filters.append(fstate.filter) 125 switch fstate { 126 case .posts, .posts_and_replies: 127 filters.append({ profile.pubkey == $0.pubkey }) 128 case .conversations: 129 filters.append({ profile.conversation_events.contains($0.id) } ) 130 } 131 return ContentFilters(filters: filters).filter 132 } 133 134 var bannerSection: some View { 135 GeometryReader { proxy -> AnyView in 136 137 let minY = proxy.frame(in: .global).minY 138 139 DispatchQueue.main.async { 140 self.yOffset = minY 141 } 142 143 return AnyView( 144 VStack(spacing: 0) { 145 ZStack { 146 BannerImageView(pubkey: profile.pubkey, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation) 147 .aspectRatio(contentMode: .fill) 148 .frame(width: proxy.size.width, height: minY > 0 ? bannerHeight + minY : bannerHeight) 149 .clipped() 150 151 VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)).opacity(bannerBlurViewOpacity()) 152 } 153 154 Divider().opacity(bannerBlurViewOpacity()) 155 } 156 .frame(height: minY > 0 ? bannerHeight + minY : nil) 157 .offset(y: minY > 0 ? -minY : -minY < navbarHeight ? 0 : -minY - navbarHeight) 158 ) 159 160 } 161 .frame(height: bannerHeight) 162 .allowsHitTesting(false) 163 } 164 165 var navbarHeight: CGFloat { 166 return 100.0 - (Theme.safeAreaInsets?.top ?? 0) 167 } 168 169 func navImage(img: String) -> some View { 170 Image(img) 171 .frame(width: 33, height: 33) 172 .background(Color.black.opacity(0.6)) 173 .clipShape(Circle()) 174 } 175 176 var navBackButton: some View { 177 Button { 178 presentationMode.wrappedValue.dismiss() 179 } label: { 180 navImage(img: "chevron-left") 181 } 182 } 183 184 var navActionSheetButton: some View { 185 Button(action: { 186 action_sheet_presented = true 187 }) { 188 Image(systemName: "ellipsis") 189 .frame(width: 33, height: 33) 190 .background(Color.black.opacity(0.6)) 191 .clipShape(Circle()) 192 } 193 .confirmationDialog(NSLocalizedString("Actions", comment: "Title for confirmation dialog to either share, report, or mute a profile."), isPresented: $action_sheet_presented) { 194 Button(NSLocalizedString("Share", comment: "Button to share the link to a profile.")) { 195 show_share_sheet = true 196 } 197 198 Button(NSLocalizedString("QR Code", comment: "Button to view profile's qr code.")) { 199 show_qr_code = true 200 } 201 202 // Only allow reporting if logged in with private key and the currently viewed profile is not the logged in profile. 203 if profile.pubkey != damus_state.pubkey && damus_state.is_privkey_user { 204 Button(NSLocalizedString("Report", comment: "Button to report a profile."), role: .destructive) { 205 notify(.report(.user(profile.pubkey))) 206 } 207 208 if damus_state.mutelist_manager.is_muted(.user(profile.pubkey, nil)) { 209 Button(NSLocalizedString("Unmute", comment: "Button to unmute a profile.")) { 210 guard 211 let keypair = damus_state.keypair.to_full(), 212 let mutelist = damus_state.mutelist_manager.event 213 else { 214 return 215 } 216 217 guard let new_ev = remove_from_mutelist(keypair: keypair, prev: mutelist, to_remove: .user(profile.pubkey, nil)) else { 218 return 219 } 220 221 damus_state.mutelist_manager.set_mutelist(new_ev) 222 damus_state.postbox.send(new_ev) 223 } 224 } else { 225 Button(NSLocalizedString("Mute", comment: "Button to mute a profile"), role: .destructive) { 226 mute_dialog_presented = true 227 } 228 } 229 } 230 } 231 .confirmationDialog(NSLocalizedString("Mute", comment: "Title for confirmation dialog to mute a profile."), isPresented: $mute_dialog_presented) { 232 ForEach(DamusDuration.allCases, id: \.self) { duration in 233 Button { 234 notify(.mute(.user(profile.pubkey, duration.date_from_now))) 235 } label: { 236 Text(duration.title) 237 } 238 } 239 } 240 } 241 242 func lnButton(unownedProfile: Profile?, record: ProfileRecord?) -> some View { 243 return ProfileZapLinkView(unownedProfileRecord: record, profileModel: self.profile) { reactions_enabled, lud16, lnurl in 244 Image(reactions_enabled ? "zap.fill" : "zap") 245 .foregroundColor(reactions_enabled ? .orange : Color.primary) 246 .profile_button_style(scheme: colorScheme) 247 .cornerRadius(24) 248 } 249 } 250 251 var dmButton: some View { 252 let dm_model = damus_state.dms.lookup_or_create(profile.pubkey) 253 return NavigationLink(value: Route.DMChat(dms: dm_model)) { 254 Image("messages") 255 .profile_button_style(scheme: colorScheme) 256 } 257 } 258 259 private var followsYouBadge: some View { 260 Text("Follows you", comment: "Text to indicate that a user is following your profile.") 261 .padding([.leading, .trailing], 6.0) 262 .padding([.top, .bottom], 2.0) 263 .foregroundColor(.gray) 264 .background { 265 RoundedRectangle(cornerRadius: 5.0) 266 .foregroundColor(DamusColors.adaptableGrey) 267 } 268 .font(.footnote) 269 } 270 271 func actionSection(record: ProfileRecord?, pubkey: Pubkey) -> some View { 272 return Group { 273 if let record, 274 let profile = record.profile, 275 let lnurl = record.lnurl, 276 lnurl != "" 277 { 278 lnButton(unownedProfile: profile, record: record) 279 } 280 281 dmButton 282 283 if profile.pubkey != damus_state.pubkey { 284 FollowButtonView( 285 target: profile.get_follow_target(), 286 follows_you: profile.follows(pubkey: damus_state.pubkey), 287 follow_state: damus_state.contacts.follow_state(profile.pubkey) 288 ) 289 } else if damus_state.keypair.privkey != nil { 290 NavigationLink(value: Route.EditMetadata) { 291 ProfileEditButton(damus_state: damus_state) 292 } 293 } 294 295 } 296 } 297 298 func pfpOffset() -> CGFloat { 299 let progress = -yOffset / navbarHeight 300 let offset = (pfp_size / 4.0) * (progress < 1.0 ? progress : 1) 301 return offset > 0 ? offset : 0 302 } 303 304 func pfpScale() -> CGFloat { 305 let progress = -yOffset / navbarHeight 306 let scale = 1.0 - (0.5 * (progress < 1.0 ? progress : 1)) 307 return scale < 1 ? scale : 1 308 } 309 310 func nameSection(profile_data: ProfileRecord?) -> some View { 311 return Group { 312 let follows_you = profile.pubkey != damus_state.pubkey && profile.follows(pubkey: damus_state.pubkey) 313 314 HStack(alignment: .center) { 315 ProfilePicView(pubkey: profile.pubkey, size: pfp_size, highlight: .custom(imageBorderColor(), 4.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation) 316 .padding(.top, -(pfp_size / 2.0)) 317 .offset(y: pfpOffset()) 318 .scaleEffect(pfpScale()) 319 .onTapGesture { 320 is_zoomed.toggle() 321 } 322 .damus_full_screen_cover($is_zoomed, damus_state: damus_state) { 323 ProfilePicImageView(pubkey: profile.pubkey, profiles: damus_state.profiles, settings: damus_state.settings, nav: damus_state.nav, shouldShowEditButton: damus_state.pubkey == profile.pubkey) 324 } 325 326 Spacer() 327 328 if follows_you { 329 followsYouBadge 330 } 331 332 actionSection(record: profile_data, pubkey: profile.pubkey) 333 } 334 335 ProfileNameView(pubkey: profile.pubkey, damus: damus_state) 336 } 337 } 338 339 var followersCount: some View { 340 HStack { 341 if let followerCount = followers.count { 342 let nounString = pluralizedString(key: "followers_count", count: followerCount) 343 let nounText = Text(verbatim: nounString).font(.subheadline).foregroundColor(.gray) 344 Text("\(Text(verbatim: followerCount.formatted()).font(.subheadline.weight(.medium))) \(nounText)", 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'.") 345 } else { 346 Image("download") 347 .resizable() 348 .frame(width: 20, height: 20) 349 Text("Followers", comment: "Label describing followers of a user.") 350 .font(.subheadline) 351 .foregroundColor(.gray) 352 } 353 } 354 } 355 356 var aboutSection: some View { 357 VStack(alignment: .leading, spacing: 8.0) { 358 let profile_txn = damus_state.profiles.lookup_with_timestamp(profile.pubkey) 359 let profile_data = profile_txn?.unsafeUnownedValue 360 361 nameSection(profile_data: profile_data) 362 363 if let about = profile_data?.profile?.about { 364 AboutView(state: damus_state, about: about) 365 } 366 367 if let url = profile_data?.profile?.website_url { 368 WebsiteLink(url: url) 369 } 370 371 HStack { 372 if let contact = profile.contacts { 373 let contacts = Array(contact.referenced_pubkeys) 374 let hashtags = Array(contact.referenced_hashtags) 375 let following_model = FollowingModel(damus_state: damus_state, contacts: contacts, hashtags: hashtags) 376 NavigationLink(value: Route.Following(following: following_model)) { 377 HStack { 378 let noun_text = Text(verbatim: "\(pluralizedString(key: "following_count", count: profile.following))").font(.subheadline).foregroundColor(.gray) 379 Text("\(Text(verbatim: profile.following.formatted()).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'.") 380 } 381 } 382 .buttonStyle(PlainButtonStyle()) 383 } 384 385 if followers.contacts != nil { 386 NavigationLink(value: Route.Followers(followers: followers)) { 387 followersCount 388 } 389 .buttonStyle(PlainButtonStyle()) 390 } else { 391 followersCount 392 .onTapGesture { 393 UIImpactFeedbackGenerator(style: .light).impactOccurred() 394 followers.contacts = [] 395 followers.subscribe() 396 } 397 } 398 399 if let relays = profile.relays { 400 // Only open relay config view if the user is logged in with private key and they are looking at their own profile. 401 let noun_string = pluralizedString(key: "relays_count", count: relays.keys.count) 402 let noun_text = Text(noun_string).font(.subheadline).foregroundColor(.gray) 403 let relay_text = Text("\(Text(verbatim: relays.keys.count.formatted()).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'.") 404 if profile.pubkey == damus_state.pubkey && damus_state.is_privkey_user { 405 NavigationLink(value: Route.RelayConfig) { 406 relay_text 407 } 408 .buttonStyle(PlainButtonStyle()) 409 } else { 410 NavigationLink(value: Route.UserRelays(relays: Array(relays.keys).sorted())) { 411 relay_text 412 } 413 .buttonStyle(PlainButtonStyle()) 414 } 415 } 416 } 417 418 if profile.pubkey != damus_state.pubkey { 419 let friended_followers = damus_state.contacts.get_friended_followers(profile.pubkey) 420 if !friended_followers.isEmpty { 421 Spacer() 422 423 NavigationLink(value: Route.FollowersYouKnow(friendedFollowers: friended_followers, followers: followers)) { 424 HStack { 425 CondensedProfilePicturesView(state: damus_state, pubkeys: friended_followers, maxPictures: 3) 426 let followedByString = followedByString(friended_followers, ndb: damus_state.ndb) 427 Text(followedByString) 428 .font(.subheadline).foregroundColor(.gray) 429 .multilineTextAlignment(.leading) 430 } 431 } 432 } 433 } 434 } 435 .padding(.horizontal) 436 } 437 438 var tabs: [(String, FilterState)] { 439 var tabs = [ 440 (NSLocalizedString("Notes", comment: "Label for filter for seeing only notes (instead of notes and replies)."), FilterState.posts), 441 (NSLocalizedString("Notes & Replies", comment: "Label for filter for seeing notes and replies (instead of only notes)."), FilterState.posts_and_replies) 442 ] 443 if profile.pubkey != damus_state.pubkey && !profile.conversation_events.isEmpty { 444 tabs.append((NSLocalizedString("Conversations", comment: "Label for filter for seeing notes and replies that involve conversations between the signed in user and the current profile."), FilterState.conversations)) 445 } 446 return tabs 447 } 448 449 var body: some View { 450 ZStack { 451 ScrollView(.vertical) { 452 VStack(spacing: 0) { 453 bannerSection 454 .zIndex(1) 455 456 VStack() { 457 aboutSection 458 459 VStack(spacing: 0) { 460 CustomPicker(tabs: tabs, selection: $filter_state) 461 Divider() 462 .frame(height: 1) 463 } 464 .background(colorScheme == .dark ? Color.black : Color.white) 465 466 if filter_state == FilterState.posts { 467 InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.posts)) 468 } 469 if filter_state == FilterState.posts_and_replies { 470 InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.posts_and_replies)) 471 } 472 if filter_state == FilterState.conversations && !profile.conversation_events.isEmpty { 473 InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.conversations)) 474 } 475 } 476 .padding(.horizontal, Theme.safeAreaInsets?.left) 477 .zIndex(-yOffset > navbarHeight ? 0 : 1) 478 } 479 } 480 .padding(.bottom, tabHeight + getSafeAreaBottom()) 481 .ignoresSafeArea() 482 .navigationTitle("") 483 .navigationBarBackButtonHidden() 484 .toolbar { 485 ToolbarItem(placement: .topBarLeading) { 486 HStack(spacing: 8) { 487 navBackButton 488 .padding(.top, 5) 489 .accentColor(DamusColors.white) 490 VStack(alignment: .leading, spacing: -4.5) { 491 Text(getProfileInfo().0) // Display name 492 .font(.headline) 493 .foregroundColor(.white) 494 Text(getProfileInfo().1) // Username 495 .font(.subheadline) 496 .foregroundColor(.white.opacity(0.8)) 497 } 498 .opacity(bannerBlurViewOpacity()) 499 .frame(maxWidth: .infinity, alignment: .leading) 500 .padding(.top, max(5, 15 + (yOffset / 30))) 501 } 502 } 503 if showFollowBtnInBlurrBanner() { 504 ToolbarItem(placement: .topBarTrailing) { 505 FollowButtonView( 506 target: profile.get_follow_target(), 507 follows_you: profile.follows(pubkey: damus_state.pubkey), 508 follow_state: damus_state.contacts.follow_state(profile.pubkey) 509 ) 510 .padding(.top, 8) 511 } 512 } else { 513 ToolbarItem(placement: .topBarTrailing) { 514 navActionSheetButton 515 .padding(.top, 5) 516 .accentColor(DamusColors.white) 517 } 518 } 519 } 520 .toolbarBackground(.hidden) 521 .onReceive(handle_notify(.switched_timeline)) { _ in 522 dismiss() 523 } 524 .onAppear() { 525 check_nip05_validity(pubkey: self.profile.pubkey, profiles: self.damus_state.profiles) 526 profile.subscribe() 527 //followers.subscribe() 528 } 529 .onDisappear { 530 profile.unsubscribe() 531 followers.unsubscribe() 532 // our profilemodel needs a bit more help 533 } 534 .sheet(isPresented: $show_share_sheet) { 535 let url = URL(string: "https://damus.io/" + profile.pubkey.npub)! 536 ShareSheet(activityItems: [url]) 537 } 538 .damus_full_screen_cover($show_qr_code, damus_state: damus_state) { 539 QRCodeView(damus_state: damus_state, pubkey: profile.pubkey) 540 } 541 542 if damus_state.is_privkey_user { 543 PostButtonContainer(is_left_handed: damus_state.settings.left_handed) { 544 notify(.compose(.posting(.user(profile.pubkey)))) 545 } 546 .padding(.bottom, tabHeight) 547 } 548 } 549 } 550 } 551 552 struct ProfileView_Previews: PreviewProvider { 553 static var previews: some View { 554 let ds = test_damus_state 555 ProfileView(damus_state: ds, pubkey: ds.pubkey) 556 } 557 } 558 559 extension View { 560 func profile_button_style(scheme: ColorScheme) -> some View { 561 self.symbolRenderingMode(.palette) 562 .font(.system(size: 32).weight(.thin)) 563 .foregroundStyle(scheme == .dark ? .white : .black, scheme == .dark ? .white : .black) 564 } 565 } 566 567 @MainActor 568 func check_nip05_validity(pubkey: Pubkey, profiles: Profiles) { 569 let profile_txn = profiles.lookup(id: pubkey) 570 571 guard let profile = profile_txn?.unsafeUnownedValue, 572 let nip05 = profile.nip05, 573 profiles.is_validated(pubkey) == nil 574 else { 575 return 576 } 577 578 Task.detached(priority: .background) { 579 let validated = await validate_nip05(pubkey: pubkey, nip05_str: nip05) 580 if validated != nil { 581 print("validated nip05 for '\(nip05)'") 582 } 583 584 Task { @MainActor in 585 profiles.set_validated(pubkey, nip05: validated) 586 profiles.nip05_pubkey[nip05] = pubkey 587 notify(.profile_updated(.remote(pubkey: pubkey))) 588 } 589 } 590 }