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