damus

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

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 }