damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

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 }