damus

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

commit e70f270c5c8d05cd192212769b514ca5581bd012
parent 4ed79ff3c3869d3db70c9cfd81ed78defac02156
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Sat, 21 Oct 2023 04:44:36 +0000

zaps: Improve discoverability of profile zaps

via zappability badges and profile action sheets

This commit improves discoverability of zaps with the following changes:

1. New zap icon appears on profile pictures of events where the author of such event has zaps setup
2. Clicking on a profile picture from an event shows an action sheet that makes it easier to see a preview of their profile, and a zap button

Testing
-------

Devices:
- iPhone 14 Pro simulator
- iPad 10 simulator

iOS:
- 17.0.1
- 16.4

Damus: This commit

Coverage:
1. Checked that zap icon appears on profile pictures on events in different feeds and threads
2. Checked that this zap icon only appears for profiles that have zaps enabled
3. Checked that profile action sheet looks good on both light mode and dark mode
4. Checked that long descriptions are truncated and the "see more" "see less" buttons work
5. Checked that clicking "see more" or "see less" adapts the size of the action sheet (on iPhone)
6. Checked that action sheet looks good whether or not the user has a website link setup
7. Checked that long presses on the zap button in the action sheet bring the same options as the normal profile view
8. Checked all the buttons in the action sheet take the user to the expected place
9. Checked that the original profile view looks good (on both light and dark mode)

Notes:
- Action sheet cannot be resized on iPad.
- Could not test on Mac Catalyst because there seems to be a crash on the creation of a new account

Reference ticket: https://github.com/damus-io/damus/issues/1596

Changelog-Added: Improve discoverability of profile zaps with zappability badges and profile action sheets
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 9+++++++++
Mdamus/Components/NeutralButtonStyle.swift | 14++++++++++++++
Mdamus/Components/SelectableText.swift | 11+++++++++++
Mdamus/Components/WebsiteLink.swift | 30+++++++++++++++++++++++++++---
Mdamus/ContentView.swift | 4++++
Mdamus/Views/Events/EventProfile.swift | 4++--
Mdamus/Views/Profile/AboutView.swift | 12++++++++++--
Mdamus/Views/Profile/MaybeAnonPfpView.swift | 6+++---
Mdamus/Views/Profile/ProfileNameView.swift | 80+------------------------------------------------------------------------------
Mdamus/Views/Profile/ProfilePicView.swift | 53+++++++++++++++++++++++++++++++++++++----------------
Mdamus/Views/Profile/ProfileView.swift | 38++++++--------------------------------
Adamus/Views/ProfileActionSheetView.swift | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/PubkeyView.swift | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Views/Zaps/ZapButtonView.swift | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
14 files changed, 453 insertions(+), 137 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -430,6 +430,7 @@ BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; }; D2277EEA2A089BD5006C3807 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2277EE92A089BD5006C3807 /* Router.swift */; }; D71DC1EC2A9129C3006E207C /* PostViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71DC1EB2A9129C3006E207C /* PostViewTests.swift */; }; + D723C38E2AB8D83400065664 /* ContentFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = D723C38D2AB8D83400065664 /* ContentFilters.swift */; }; D72A2D022AD9C136002AFF62 /* EventViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2CFF2AD9B66B002AFF62 /* EventViewTests.swift */; }; D72A2D052AD9C1B5002AFF62 /* MockDamusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2D042AD9C1B5002AFF62 /* MockDamusState.swift */; }; D72A2D072AD9C1FB002AFF62 /* MockProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2D062AD9C1FB002AFF62 /* MockProfiles.swift */; }; @@ -437,6 +438,8 @@ D7315A2A2ACDF3B70036E30A /* DamusCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */; }; D7315A2C2ACDF4DA0036E30A /* DamusCacheManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */; }; D783A63F2AD4E53D00658DDA /* SuggestedHashtagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */; }; + D76874F32AE3632B00FB0F68 /* ZapButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76874F22AE3632B00FB0F68 /* ZapButtonView.swift */; }; + D77BFA0B2AE3051200621634 /* ProfileActionSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */; }; D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */; }; D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; }; D7870BC32AC47EBC0080BA88 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; }; @@ -1136,6 +1139,8 @@ D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManager.swift; sourceTree = "<group>"; }; D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManagerTests.swift; sourceTree = "<group>"; }; D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedHashtagsView.swift; sourceTree = "<group>"; }; + D76874F22AE3632B00FB0F68 /* ZapButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapButtonView.swift; sourceTree = "<group>"; }; + D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileActionSheetView.swift; sourceTree = "<group>"; }; D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = "<group>"; }; D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = "<group>"; }; D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLoaderView.swift; sourceTree = "<group>"; }; @@ -1725,6 +1730,7 @@ 5C513FCB2984ACA60072348F /* QRCodeView.swift */, 643EA5C7296B764E005081BB /* RelayFilterView.swift */, D783A63E2AD4E53D00658DDA /* SuggestedHashtagsView.swift */, + D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */, ); path = Views; sourceTree = "<group>"; @@ -2236,6 +2242,7 @@ 4C9F18E129AA9B6C008C55EC /* CustomizeZapView.swift */, 4CA3FA0F29F593D000FDB3C3 /* ZapTypePicker.swift */, 4C73C5132A4437C10062CAC0 /* ZapUserView.swift */, + D76874F22AE3632B00FB0F68 /* ZapButtonView.swift */, ); path = Zaps; sourceTree = "<group>"; @@ -2960,6 +2967,8 @@ E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */, 4C75EFBB2804A34C0006080F /* ProofOfWork.swift in Sources */, 4C3AC7A52836987600E1F516 /* MainTabView.swift in Sources */, + D76874F32AE3632B00FB0F68 /* ZapButtonView.swift in Sources */, + D77BFA0B2AE3051200621634 /* ProfileActionSheetView.swift in Sources */, 4C1A9A1F29DDD24B00516EAC /* AppearanceSettingsView.swift in Sources */, 3AA59D1D2999B0400061C48E /* DraftsModel.swift in Sources */, 3169CAED294FCCFC00EE4006 /* Constants.swift in Sources */, diff --git a/damus/Components/NeutralButtonStyle.swift b/damus/Components/NeutralButtonStyle.swift @@ -20,6 +20,20 @@ struct NeutralButtonStyle: ButtonStyle { } } +struct NeutralCircleButtonStyle: ButtonStyle { + func makeBody(configuration: Self.Configuration) -> some View { + return configuration.label + .padding(20) + .background(DamusColors.neutral1) + .cornerRadius(9999) + .overlay( + RoundedRectangle(cornerRadius: 9999) + .stroke(DamusColors.neutral3, lineWidth: 1) + ) + .scaleEffect(configuration.isPressed ? 0.95 : 1) + } +} + struct NeutralButtonStyle_Previews: PreviewProvider { static var previews: some View { diff --git a/damus/Components/SelectableText.swift b/damus/Components/SelectableText.swift @@ -11,12 +11,19 @@ import SwiftUI struct SelectableText: View { let attributedString: AttributedString + let textAlignment: NSTextAlignment @State private var selectedTextHeight: CGFloat = .zero @State private var selectedTextWidth: CGFloat = .zero let size: EventViewKind + init(attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) { + self.attributedString = attributedString + self.textAlignment = textAlignment ?? NSTextAlignment.natural + self.size = size + } + var body: some View { GeometryReader { geo in TextViewRepresentable( @@ -24,6 +31,7 @@ struct SelectableText: View { textColor: UIColor.label, font: eventviewsize_to_uifont(size), fixedWidth: selectedTextWidth, + textAlignment: self.textAlignment, height: $selectedTextHeight ) .padding([.leading, .trailing], -1.0) @@ -48,6 +56,7 @@ struct SelectableText: View { let textColor: UIColor let font: UIFont let fixedWidth: CGFloat + let textAlignment: NSTextAlignment @Binding var height: CGFloat @@ -61,12 +70,14 @@ struct SelectableText: View { view.textContainerInset = .zero view.textContainerInset.left = 1.0 view.textContainerInset.right = 1.0 + view.textAlignment = textAlignment return view } func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<Self>) { let mutableAttributedString = createNSAttributedString() uiView.attributedText = mutableAttributedString + uiView.textAlignment = self.textAlignment let newHeight = mutableAttributedString.height(containerWidth: fixedWidth) diff --git a/damus/Components/WebsiteLink.swift b/damus/Components/WebsiteLink.swift @@ -9,33 +9,57 @@ import SwiftUI struct WebsiteLink: View { let url: URL + let style: StyleVariant @Environment(\.openURL) var openURL + + init(url: URL, style: StyleVariant? = nil) { + self.url = url + self.style = style ?? .normal + } var body: some View { HStack { Image("link") - .foregroundColor(.gray) - .font(.footnote) + .resizable() + .frame(width: 16, height: 16) + .foregroundColor(self.style == .accent ? .white : .gray) + .padding(.vertical, 5) + .padding([.leading], 10) Button(action: { openURL(url) }, label: { Text(link_text) .font(.footnote) - .foregroundColor(.accentColor) + .foregroundColor(self.style == .accent ? .white : .accentColor) .truncationMode(.tail) .lineLimit(1) }) + .padding(.vertical, 5) + .padding([.trailing], 10) } + .background( + self.style == .accent ? + AnyView(RoundedRectangle(cornerRadius: 50).fill(PinkGradient)) + : AnyView(Color.clear) + ) } var link_text: String { url.host ?? url.absoluteString } + + enum StyleVariant { + case normal + case accent + } } struct WebsiteLink_Previews: PreviewProvider { static var previews: some View { WebsiteLink(url: URL(string: "https://jb55.com")!) + .previewDisplayName("Normal") + WebsiteLink(url: URL(string: "https://jb55.com")!, style: .accent) + .previewDisplayName("Accent") } } diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -22,6 +22,7 @@ enum Sheets: Identifiable { case post(PostAction) case report(ReportTarget) case event(NostrEvent) + case profile_action(Pubkey) case zap(ZapSheet) case select_wallet(SelectWallet) case filter @@ -42,6 +43,7 @@ enum Sheets: Identifiable { case .user_status: return "user_status" case .post(let action): return "post-" + (action.ev?.id.hex() ?? "") case .event(let ev): return "event-" + ev.id.hex() + case .profile_action(let pubkey): return "profile-action-" + pubkey.npub case .zap(let sheet): return "zap-" + hex_encode(sheet.target.id) case .select_wallet: return "select-wallet" case .filter: return "filter" @@ -316,6 +318,8 @@ struct ContentView: View { .presentationDragIndicator(.visible) case .event: EventDetailView() + case .profile_action(let pubkey): + ProfileActionSheetView(damus_state: damus_state!, pubkey: pubkey) case .zap(let zapsheet): CustomizeZapView(state: damus_state!, target: zapsheet.target, lnurl: zapsheet.lnurl) case .select_wallet(let select): diff --git a/damus/Views/Events/EventProfile.swift b/damus/Views/Events/EventProfile.swift @@ -37,9 +37,9 @@ struct EventProfile: View { var body: some View { HStack(alignment: .center, spacing: 10) { - ProfilePicView(pubkey: pubkey, size: pfp_size, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation) + ProfilePicView(pubkey: pubkey, size: pfp_size, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation, show_zappability: true) .onTapGesture { - damus_state.nav.push(route: .ProfileByKey(pubkey: pubkey)) + notify(.present_sheet(Sheets.profile_action(pubkey))) } VStack(alignment: .leading, spacing: 0) { diff --git a/damus/Views/Profile/AboutView.swift b/damus/Views/Profile/AboutView.swift @@ -10,15 +10,23 @@ import SwiftUI struct AboutView: View { let state: DamusState let about: String - let max_about_length = 280 + let max_about_length: Int + let text_alignment: NSTextAlignment @State var show_full_about: Bool = false @State private var about_string: AttributedString? = nil + init(state: DamusState, about: String, max_about_length: Int? = nil, text_alignment: NSTextAlignment? = nil) { + self.state = state + self.about = about + self.max_about_length = max_about_length ?? 280 + self.text_alignment = text_alignment ?? .natural + } + var body: some View { Group { if let about_string { let truncated_about = show_full_about ? about_string : about_string.truncateOrNil(maxLength: max_about_length) - SelectableText(attributedString: truncated_about ?? about_string, size: .subheadline) + SelectableText(attributedString: truncated_about ?? about_string, textAlignment: self.text_alignment, size: .subheadline) if truncated_about != nil { if show_full_about { diff --git a/damus/Views/Profile/MaybeAnonPfpView.swift b/damus/Views/Profile/MaybeAnonPfpView.swift @@ -21,16 +21,16 @@ struct MaybeAnonPfpView: View { } var body: some View { - Group { + ZStack { if is_anon { Image("question") .resizable() .font(.largeTitle) .frame(width: size, height: size) } else { - ProfilePicView(pubkey: pubkey, size: size, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation) + ProfilePicView(pubkey: pubkey, size: size, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true) .onTapGesture { - state.nav.push(route: Route.ProfileByKey(pubkey: pubkey)) + notify(.present_sheet(Sheets.profile_action(pubkey))) } } } diff --git a/damus/Views/Profile/ProfileNameView.swift b/damus/Views/Profile/ProfileNameView.swift @@ -7,84 +7,6 @@ import SwiftUI -fileprivate struct KeyView: View { - let pubkey: Pubkey - - @Environment(\.colorScheme) var colorScheme - - @State private var isCopied = false - - func keyColor() -> Color { - colorScheme == .light ? DamusColors.black : DamusColors.white - } - - private func copyPubkey(_ pubkey: String) { - UIPasteboard.general.string = pubkey - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - withAnimation { - isCopied = true - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - withAnimation { - isCopied = false - } - } - } - } - - func pubkey_context_menu(pubkey: Pubkey) -> some View { - return self.contextMenu { - Button { - UIPasteboard.general.string = pubkey.npub - } label: { - Label(NSLocalizedString("Copy Account ID", comment: "Context menu option for copying the ID of the account that created the note."), image: "copy2") - } - } - } - - var body: some View { - let bech32 = pubkey.npub - - HStack { - Text(verbatim: "\(abbrev_pubkey(bech32, amount: 16))") - .font(.footnote) - .foregroundColor(keyColor()) - .padding(5) - .padding([.leading, .trailing], 5) - .background(RoundedRectangle(cornerRadius: 11).foregroundColor(DamusColors.adaptableGrey)) - - if isCopied { - HStack { - Image("check-circle") - .resizable() - .frame(width: 20, height: 20) - Text(NSLocalizedString("Copied", comment: "Label indicating that a user's key was copied.")) - .font(.footnote) - .layoutPriority(1) - } - .foregroundColor(DamusColors.green) - } else { - HStack { - Button { - copyPubkey(bech32) - } label: { - Label { - Text("Public key", comment: "Label indicating that the text is a user's public account key.") - } icon: { - Image("copy2") - .resizable() - .contentShape(Rectangle()) - .foregroundColor(.accentColor) - .frame(width: 20, height: 20) - } - .labelStyle(IconOnlyLabelStyle()) - .symbolRenderingMode(.hierarchical) - } - } - } - } - } -} - struct ProfileNameView: View { let pubkey: Pubkey let damus: DamusState @@ -116,7 +38,7 @@ struct ProfileNameView: View { Spacer() - KeyView(pubkey: pubkey) + PubkeyView(pubkey: pubkey) .pubkey_context_menu(pubkey: pubkey) } } diff --git a/damus/Views/Profile/ProfilePicView.swift b/damus/Views/Profile/ProfilePicView.swift @@ -69,38 +69,59 @@ struct ProfilePicView: View { let highlight: Highlight let profiles: Profiles let disable_animation: Bool + let zappability_indicator: Bool @State var picture: String? - init(pubkey: Pubkey, size: CGFloat, highlight: Highlight, profiles: Profiles, disable_animation: Bool, picture: String? = nil) { + init(pubkey: Pubkey, size: CGFloat, highlight: Highlight, profiles: Profiles, disable_animation: Bool, picture: String? = nil, show_zappability: Bool? = nil) { self.pubkey = pubkey self.profiles = profiles self.size = size self.highlight = highlight self._picture = State(initialValue: picture) self.disable_animation = disable_animation + self.zappability_indicator = show_zappability ?? false + } + + func get_lnurl() -> String? { + return profiles.lookup_with_timestamp(pubkey).unsafeUnownedValue?.lnurl } var body: some View { - InnerProfilePicView(url: get_profile_url(picture: picture, pubkey: pubkey, profiles: profiles), fallbackUrl: URL(string: robohash(pubkey)), pubkey: pubkey, size: size, highlight: highlight, disable_animation: disable_animation) - .onReceive(handle_notify(.profile_updated)) { updated in - guard updated.pubkey == self.pubkey else { - return - } - - switch updated { - case .manual(_, let profile): - if let pic = profile.picture { - self.picture = pic + ZStack (alignment: Alignment(horizontal: .trailing, vertical: .bottom)) { + InnerProfilePicView(url: get_profile_url(picture: picture, pubkey: pubkey, profiles: profiles), fallbackUrl: URL(string: robohash(pubkey)), pubkey: pubkey, size: size, highlight: highlight, disable_animation: disable_animation) + .onReceive(handle_notify(.profile_updated)) { updated in + guard updated.pubkey == self.pubkey else { + return } - case .remote(pubkey: let pk): - let profile_txn = profiles.lookup(id: pk) - let profile = profile_txn.unsafeUnownedValue - if let pic = profile?.picture { - self.picture = pic + + switch updated { + case .manual(_, let profile): + if let pic = profile.picture { + self.picture = pic + } + case .remote(pubkey: let pk): + let profile_txn = profiles.lookup(id: pk) + let profile = profile_txn.unsafeUnownedValue + if let pic = profile?.picture { + self.picture = pic + } } } + + if self.zappability_indicator, let lnurl = self.get_lnurl(), lnurl != "" { + Image("zap.fill") + .resizable() + .frame( + width: size * 0.24, + height: size * 0.24 + ) + .padding(size * 0.04) + .foregroundColor(.white) + .background(Color.orange) + .clipShape(Circle()) } + } } } diff --git a/damus/Views/Profile/ProfileView.swift b/damus/Views/Profile/ProfileView.swift @@ -221,39 +221,13 @@ struct ProfileView: View { .accentColor(DamusColors.white) } - func lnButton(lnurl: String, unownedProfile: Profile?, pubkey: Pubkey) -> some View { - let reactions = unownedProfile?.reactions ?? true - let button_img = reactions ? "zap.fill" : "zap" - let lud16 = unownedProfile?.lud16 - - return Button(action: { [lnurl] in - present_sheet(.zap(target: .profile(self.profile.pubkey), lnurl: lnurl)) - }) { - Image(button_img) - .foregroundColor(button_img == "zap.fill" ? .orange : Color.primary) + func lnButton(unownedProfile: Profile?, record: ProfileRecord?) -> some View { + return ZapButtonView(unownedProfileRecord: record, profileModel: self.profile) { reactions_enabled, lud16, lnurl in + Image(reactions_enabled ? "zap.fill" : "zap") + .foregroundColor(reactions_enabled ? .orange : Color.primary) .profile_button_style(scheme: colorScheme) - .contextMenu { [lud16, reactions, lnurl] in - if reactions == false { - Text("OnlyZaps Enabled", comment: "Non-tappable text in context menu that shows up when the zap button on profile is long pressed to indicate that the user has enabled OnlyZaps, meaning that they would like to be only zapped and not accept reactions to their notes.") - } - - if let lud16 { - Button { - UIPasteboard.general.string = lud16 - } label: { - Label(lud16, image: "copy2") - } - } else { - Button { - UIPasteboard.general.string = lnurl - } label: { - Label(NSLocalizedString("Copy LNURL", comment: "Context menu option for copying a user's Lightning URL."), image: "copy") - } - } - } - + .cornerRadius(24) } - .cornerRadius(24) } var dmButton: some View { @@ -283,7 +257,7 @@ struct ProfileView: View { let lnurl = record.lnurl, lnurl != "" { - lnButton(lnurl: lnurl, unownedProfile: profile, pubkey: pubkey) + lnButton(unownedProfile: profile, record: record) } dmButton diff --git a/damus/Views/ProfileActionSheetView.swift b/damus/Views/ProfileActionSheetView.swift @@ -0,0 +1,154 @@ +// +// ProfileActionSheetView.swift +// damus +// +// Created by Daniel D’Aquino on 2023-10-20. +// + +import SwiftUI + +struct ProfileActionSheetView: View { + let damus_state: DamusState + let pfp_size: CGFloat = 90.0 + + @StateObject var profile: ProfileModel + @StateObject var zap_button_model: ZapButtonModel = ZapButtonModel() + @State private var sheetHeight: CGFloat = .zero + + @Environment(\.dismiss) var dismiss + @Environment(\.colorScheme) var colorScheme + @Environment(\.presentationMode) var presentationMode + + init(damus_state: DamusState, pubkey: Pubkey) { + self.damus_state = damus_state + self._profile = StateObject(wrappedValue: ProfileModel(pubkey: pubkey, damus: damus_state)) + } + + func imageBorderColor() -> Color { + colorScheme == .light ? DamusColors.white : DamusColors.black + } + + func profile_data() -> ProfileRecord? { + let profile_txn = damus_state.profiles.lookup_with_timestamp(profile.pubkey) + return profile_txn.unsafeUnownedValue + } + + func get_profile() -> Profile? { + return self.profile_data()?.profile + } + + var dmButton: some View { + let dm_model = damus_state.dms.lookup_or_create(profile.pubkey) + return VStack(alignment: .center, spacing: 10) { + Button( + action: { + damus_state.nav.push(route: Route.DMChat(dms: dm_model)) + dismiss() + }, + label: { + Image("messages") + .profile_button_style(scheme: colorScheme) + } + ) + .buttonStyle(NeutralCircleButtonStyle()) + Text(NSLocalizedString("Message", comment: "Button label that allows the user to start a direct message conversation with the user shown on-screen")) + .foregroundStyle(.secondary) + .font(.caption) + } + } + + var zapButton: some View { + if let lnurl = self.profile_data()?.lnurl, lnurl != "" { + return AnyView( + VStack(alignment: .center, spacing: 10) { + ZapButtonView(damus_state: damus_state, pubkey: self.profile.pubkey, action: { dismiss() }) { reactions_enabled, lud16, lnurl in + Image(reactions_enabled ? "zap.fill" : "zap") + .foregroundColor(reactions_enabled ? .orange : Color.primary) + .profile_button_style(scheme: colorScheme) + } + .buttonStyle(NeutralCircleButtonStyle()) + + Text(NSLocalizedString("Zap", comment: "Button label that allows the user to zap (i.e. send a Bitcoin tip via the lightning network) the user shown on-screen")) + .foregroundStyle(.secondary) + .font(.caption) + } + ) + } + else { + return AnyView(EmptyView()) + } + } + + var profileName: some View { + let display_name = Profile.displayName(profile: self.get_profile(), pubkey: self.profile.pubkey).displayName + return HStack(alignment: .center, spacing: 10) { + Text(display_name) + .font(.title) + } + } + + var body: some View { + VStack(alignment: .center) { + ProfilePicView(pubkey: profile.pubkey, size: pfp_size, highlight: .custom(imageBorderColor(), 4.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation) + if let url = self.profile_data()?.profile?.website_url { + WebsiteLink(url: url, style: .accent) + .padding(.top, -15) + } + + profileName + + PubkeyView(pubkey: profile.pubkey) + + if let about = self.profile_data()?.profile?.about { + AboutView(state: damus_state, about: about, max_about_length: 140, text_alignment: .center) + .padding(.top) + } + + HStack(spacing: 20) { + self.dmButton + self.zapButton + } + .padding() + + Button( + action: { + damus_state.nav.push(route: Route.ProfileByKey(pubkey: profile.pubkey)) + dismiss() + }, + label: { + HStack { + Spacer() + Text(NSLocalizedString("View full profile", comment: "A button label that allows the user to see the full profile of the profile they are previewing")) + Image(systemName: "arrow.up.right") + Spacer() + } + + } + ) + + .buttonStyle(NeutralCircleButtonStyle()) + } + .padding() + .padding(.top, 20) + .overlay { + GeometryReader { geometry in + Color.clear.preference(key: InnerHeightPreferenceKey.self, value: geometry.size.height) + } + } + .onPreferenceChange(InnerHeightPreferenceKey.self) { newHeight in + sheetHeight = newHeight + } + .presentationDetents([.height(sheetHeight)]) + } +} + +struct InnerHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = .zero + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +#Preview { + ProfileActionSheetView(damus_state: test_damus_state, pubkey: test_pubkey) +} diff --git a/damus/Views/PubkeyView.swift b/damus/Views/PubkeyView.swift @@ -7,6 +7,89 @@ import SwiftUI +struct PubkeyView: View { + let pubkey: Pubkey + + @Environment(\.colorScheme) var colorScheme + + @State private var isCopied = false + + func keyColor() -> Color { + colorScheme == .light ? DamusColors.black : DamusColors.white + } + + private func copyPubkey(_ pubkey: String) { + UIPasteboard.general.string = pubkey + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + withAnimation { + isCopied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + withAnimation { + isCopied = false + } + } + } + } + + func pubkey_context_menu(pubkey: Pubkey) -> some View { + return self.contextMenu { + Button { + UIPasteboard.general.string = pubkey.npub + } label: { + Label(NSLocalizedString("Copy Account ID", comment: "Context menu option for copying the ID of the account that created the note."), image: "copy2") + } + } + } + + var body: some View { + let bech32 = pubkey.npub + + HStack { + Text(verbatim: "\(abbrev_pubkey(bech32, amount: 16))") + .font(.footnote) + .foregroundColor(keyColor()) + .padding(5) + .padding([.leading], 5) + + HStack { + if isCopied { + Image("check-circle") + .resizable() + .foregroundColor(DamusColors.green) + .frame(width: 20, height: 20) + Text(NSLocalizedString("Copied", comment: "Label indicating that a user's key was copied.")) + .font(.footnote) + .layoutPriority(1) + .foregroundColor(DamusColors.green) + } else { + Button { + copyPubkey(bech32) + } label: { + Label { + Text("Public key", comment: "Label indicating that the text is a user's public account key.") + } icon: { + Image("copy2") + .resizable() + .contentShape(Rectangle()) + .foregroundColor(colorScheme == .light ? DamusColors.darkGrey : DamusColors.lightGrey) + .frame(width: 20, height: 20) + } + .labelStyle(IconOnlyLabelStyle()) + .symbolRenderingMode(.hierarchical) + + } + } + } + .padding([.trailing], 10) + } + .background(RoundedRectangle(cornerRadius: 11).foregroundColor(colorScheme == .light ? DamusColors.adaptableGrey : DamusColors.neutral1)) + } +} + +#Preview { + PubkeyView(pubkey: test_pubkey) +} + func abbrev_pubkey(_ pubkey: String, amount: Int = 8) -> String { return pubkey.prefix(amount) + ":" + pubkey.suffix(amount) } diff --git a/damus/Views/Zaps/ZapButtonView.swift b/damus/Views/Zaps/ZapButtonView.swift @@ -0,0 +1,92 @@ +// +// ZapButtonView.swift +// damus +// +// Created by Daniel D’Aquino on 2023-10-20. +// + +import SwiftUI + +struct ZapButtonView<Content: View>: View { + typealias ContentViewFunction = (_ reactions_enabled: Bool, _ lud16: String?, _ lnurl: String?) -> Content + typealias ActionFunction = () -> Void + + let pubkey: Pubkey + @ViewBuilder let label: ContentViewFunction + let action: ActionFunction? + + let reactions_enabled: Bool + let lud16: String? + let lnurl: String? + + init(pubkey: Pubkey, reactions_enabled: Bool, lud16: String?, lnurl: String?, action: ActionFunction? = nil, @ViewBuilder label: @escaping ContentViewFunction) { + self.pubkey = pubkey + self.label = label + self.action = action + self.reactions_enabled = reactions_enabled + self.lud16 = lud16 + self.lnurl = lnurl + } + + init(damus_state: DamusState, pubkey: Pubkey, action: ActionFunction? = nil, @ViewBuilder label: @escaping ContentViewFunction) { + self.pubkey = pubkey + self.label = label + self.action = action + + let profile_txn = damus_state.profiles.lookup_with_timestamp(pubkey) + let record = profile_txn.unsafeUnownedValue + self.reactions_enabled = record?.profile?.reactions ?? true + self.lud16 = record?.profile?.lud06 + self.lnurl = record?.lnurl + } + + init(unownedProfileRecord: ProfileRecord?, profileModel: ProfileModel, action: ActionFunction? = nil, @ViewBuilder label: @escaping ContentViewFunction) { + self.pubkey = profileModel.pubkey + self.label = label + self.action = action + + self.reactions_enabled = unownedProfileRecord?.profile?.reactions ?? true + self.lud16 = unownedProfileRecord?.profile?.lud16 + self.lnurl = unownedProfileRecord?.lnurl + } + + var body: some View { + Button( + action: { + if let lnurl { + present_sheet(.zap(target: .profile(self.pubkey), lnurl: lnurl)) + } + action?() + }, + label: { + self.label(self.reactions_enabled, self.lud16, self.lnurl) + } + ) + .contextMenu { + if self.reactions_enabled == false { + Text("OnlyZaps Enabled", comment: "Non-tappable text in context menu that shows up when the zap button on profile is long pressed to indicate that the user has enabled OnlyZaps, meaning that they would like to be only zapped and not accept reactions to their notes.") + } + + if let lud16 { + Button { + UIPasteboard.general.string = lud16 + } label: { + Label(lud16, image: "copy2") + } + } else { + Button { + UIPasteboard.general.string = lnurl + } label: { + Label(NSLocalizedString("Copy LNURL", comment: "Context menu option for copying a user's Lightning URL."), image: "copy") + } + } + } + .disabled(lnurl == nil) + } +} + +#Preview { + ZapButtonView(pubkey: test_pubkey, reactions_enabled: true, lud16: make_test_profile().lud16, lnurl: "test@sendzaps.lol", label: { reactions_enabled, lud16, lnurl in + Image("zap.fill") + }) +}