Files
damus/damus/Views/Profile/ProfileView.swift
Terry Yiu 422167f7aa Add indication of followers you know in a profile
Changelog-Added: Add indication of followers you know in a profile
2023-06-25 09:38:57 +02:00

617 lines
25 KiB
Swift

//
// ProfileView.swift
// damus
//
// Created by William Casarin on 2022-04-23.
//
import SwiftUI
enum FollowState {
case follows
case following
case unfollowing
case unfollows
}
func follow_btn_txt(_ fs: FollowState, follows_you: Bool) -> String {
switch fs {
case .follows:
return NSLocalizedString("Unfollow", comment: "Button to unfollow a user.")
case .following:
return NSLocalizedString("Following...", comment: "Label to indicate that the user is in the process of following another user.")
case .unfollowing:
return NSLocalizedString("Unfollowing...", comment: "Label to indicate that the user is in the process of unfollowing another user.")
case .unfollows:
if follows_you {
return NSLocalizedString("Follow Back", comment: "Button to follow a user back.")
} else {
return NSLocalizedString("Follow", comment: "Button to follow a user.")
}
}
}
func followersCountString(_ count: Int, locale: Locale = Locale.current) -> String {
let format = localizedStringFormat(key: "followers_count", locale: locale)
return String(format: format, locale: locale, count)
}
func followingCountString(_ count: Int, locale: Locale = Locale.current) -> String {
let format = localizedStringFormat(key: "following_count", locale: locale)
return String(format: format, locale: locale, count)
}
func relaysCountString(_ count: Int, locale: Locale = Locale.current) -> String {
let format = localizedStringFormat(key: "relays_count", locale: locale)
return String(format: format, locale: locale, count)
}
func followedByString(_ friend_intersection: [String], profiles: Profiles, locale: Locale = Locale.current) -> String {
let bundle = bundleForLocale(locale: locale)
let names: [String] = friend_intersection.prefix(3).map {
let profile = profiles.lookup(id: $0)
return Profile.displayName(profile: profile, pubkey: $0).username.truncate(maxLength: 20)
}
switch friend_intersection.count {
case 0:
return ""
case 1:
let format = NSLocalizedString("Followed by %@", bundle: bundle, comment: "Text to indicate that the user is followed by one of our follows.")
return String(format: format, locale: locale, names[0])
case 2:
let format = NSLocalizedString("Followed by %@ & %@", bundle: bundle, comment: "Text to indicate that the user is followed by two of our follows.")
return String(format: format, locale: locale, names[0], names[1])
case 3:
let format = NSLocalizedString("Followed by %@, %@ & %@", bundle: bundle, comment: "Text to indicate that the user is followed by three of our follows.")
return String(format: format, locale: locale, names[0], names[1], names[2])
default:
let format = localizedStringFormat(key: "followed_by_three_and_others", locale: locale)
return String(format: format, locale: locale, friend_intersection.count - 3, names[0], names[1], names[2])
}
}
struct EditButton: View {
let damus_state: DamusState
@Environment(\.colorScheme) var colorScheme
var body: some View {
NavigationLink(destination: EditMetadataView(damus_state: damus_state)) {
Text("Edit", comment: "Button to edit user's profile.")
.frame(height: 30)
.padding(.horizontal,25)
.font(.caption.weight(.bold))
.foregroundColor(fillColor())
.cornerRadius(24)
.overlay {
RoundedRectangle(cornerRadius: 24)
.stroke(borderColor(), lineWidth: 1)
}
.minimumScaleFactor(0.5)
.lineLimit(1)
}
}
func fillColor() -> Color {
colorScheme == .light ? DamusColors.black : DamusColors.white
}
func borderColor() -> Color {
colorScheme == .light ? DamusColors.black : DamusColors.white
}
}
struct VisualEffectView: UIViewRepresentable {
var effect: UIVisualEffect?
func makeUIView(context: UIViewRepresentableContext<Self>) -> UIVisualEffectView {
UIVisualEffectView()
}
func updateUIView(_ uiView: UIVisualEffectView, context: UIViewRepresentableContext<Self>) {
uiView.effect = effect
}
}
struct ProfileView: View {
let damus_state: DamusState
let pfp_size: CGFloat = 90.0
let bannerHeight: CGFloat = 150.0
static let markdown = Markdown()
@State var is_zoomed: Bool = false
@State var show_share_sheet: Bool = false
@State var show_qr_code: Bool = false
@State var action_sheet_presented: Bool = false
@State var filter_state : FilterState = .posts
@State var yOffset: CGFloat = 0
@StateObject var profile: ProfileModel
@StateObject var followers: FollowersModel
@StateObject var zap_button_model: ZapButtonModel = ZapButtonModel()
init(damus_state: DamusState, profile: ProfileModel, followers: FollowersModel) {
self.damus_state = damus_state
self._profile = StateObject(wrappedValue: profile)
self._followers = StateObject(wrappedValue: followers)
}
init(damus_state: DamusState, pubkey: String) {
self.damus_state = damus_state
self._profile = StateObject(wrappedValue: ProfileModel(pubkey: pubkey, damus: damus_state))
self._followers = StateObject(wrappedValue: FollowersModel(damus_state: damus_state, target: pubkey))
}
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@Environment(\.presentationMode) var presentationMode
func imageBorderColor() -> Color {
colorScheme == .light ? DamusColors.white : DamusColors.black
}
func bannerBlurViewOpacity() -> Double {
let progress = -(yOffset + navbarHeight) / 100
return Double(-yOffset > navbarHeight ? progress : 0)
}
var bannerSection: some View {
GeometryReader { proxy -> AnyView in
let minY = proxy.frame(in: .global).minY
DispatchQueue.main.async {
self.yOffset = minY
}
return AnyView(
VStack(spacing: 0) {
ZStack {
BannerImageView(pubkey: profile.pubkey, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
.aspectRatio(contentMode: .fill)
.frame(width: proxy.size.width, height: minY > 0 ? bannerHeight + minY : bannerHeight)
.clipped()
VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)).opacity(bannerBlurViewOpacity())
}
Divider().opacity(bannerBlurViewOpacity())
}
.frame(height: minY > 0 ? bannerHeight + minY : nil)
.offset(y: minY > 0 ? -minY : -minY < navbarHeight ? 0 : -minY - navbarHeight)
)
}
.frame(height: bannerHeight)
.allowsHitTesting(false)
}
var navbarHeight: CGFloat {
return 100.0 - (Theme.safeAreaInsets?.top ?? 0)
}
@ViewBuilder
func navImage(img: String) -> some View {
Image(img)
.frame(width: 33, height: 33)
.background(Color.black.opacity(0.6))
.clipShape(Circle())
}
var navBackButton: some View {
Button {
presentationMode.wrappedValue.dismiss()
} label: {
navImage(img: "chevron-left")
}
}
var navActionSheetButton: some View {
Button(action: {
action_sheet_presented = true
}) {
navImage(img: "share3")
}
.confirmationDialog(NSLocalizedString("Actions", comment: "Title for confirmation dialog to either share, report, or mute a profile."), isPresented: $action_sheet_presented) {
Button(NSLocalizedString("Share", comment: "Button to share the link to a profile.")) {
show_share_sheet = true
}
Button(NSLocalizedString("QR Code", comment: "Button to view profile's qr code.")) {
show_qr_code = true
}
// Only allow reporting if logged in with private key and the currently viewed profile is not the logged in profile.
if profile.pubkey != damus_state.pubkey && damus_state.is_privkey_user {
Button(NSLocalizedString("Report", comment: "Button to report a profile."), role: .destructive) {
let target: ReportTarget = .user(profile.pubkey)
notify(.report, target)
}
if damus_state.contacts.is_muted(profile.pubkey) {
Button(NSLocalizedString("Unmute", comment: "Button to unmute a profile.")) {
guard
let keypair = damus_state.keypair.to_full(),
let mutelist = damus_state.contacts.mutelist
else {
return
}
guard let new_ev = remove_from_mutelist(keypair: keypair, prev: mutelist, to_remove: profile.pubkey) else {
return
}
damus_state.contacts.set_mutelist(new_ev)
damus_state.postbox.send(new_ev)
}
} else {
Button(NSLocalizedString("Mute", comment: "Button to mute a profile."), role: .destructive) {
notify(.mute, profile.pubkey)
}
}
}
}
}
var customNavbar: some View {
HStack {
navBackButton
Spacer()
navActionSheetButton
}
.padding(.top, 5)
.padding(.horizontal)
.accentColor(DamusColors.white)
}
func lnButton(lnurl: String, profile: Profile) -> some View {
let button_img = profile.reactions == false ? "zap.fill" : "zap"
return Button(action: {
present_sheet(.zap(target: .profile(self.profile.pubkey), lnurl: lnurl))
}) {
Image(button_img)
.foregroundColor(button_img == "zap.fill" ? .orange : Color.primary)
.profile_button_style(scheme: colorScheme)
.contextMenu {
if profile.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 addr = profile.lud16 {
Button {
UIPasteboard.general.string = addr
} label: {
Label(addr, image: "copy2")
}
} else if let lnurl = profile.lnurl {
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)
}
var dmButton: some View {
let dm_model = damus_state.dms.lookup_or_create(profile.pubkey)
let dmview = DMChatView(damus_state: damus_state, dms: dm_model)
return NavigationLink(destination: dmview) {
Image("messages")
.profile_button_style(scheme: colorScheme)
}
}
func actionSection(profile_data: Profile?) -> some View {
return Group {
if let profile = profile_data {
if let lnurl = profile.lnurl, lnurl != "" {
lnButton(lnurl: lnurl, profile: profile)
}
}
dmButton
if profile.pubkey != damus_state.pubkey {
FollowButtonView(
target: profile.get_follow_target(),
follows_you: profile.follows(pubkey: damus_state.pubkey),
follow_state: damus_state.contacts.follow_state(profile.pubkey)
)
} else if damus_state.keypair.privkey != nil {
NavigationLink(destination: EditMetadataView(damus_state: damus_state)) {
EditButton(damus_state: damus_state)
}
}
}
}
func pfpOffset() -> CGFloat {
let progress = -yOffset / navbarHeight
let offset = (pfp_size / 4.0) * (progress < 1.0 ? progress : 1)
return offset > 0 ? offset : 0
}
func pfpScale() -> CGFloat {
let progress = -yOffset / navbarHeight
let scale = 1.0 - (0.5 * (progress < 1.0 ? progress : 1))
return scale < 1 ? scale : 1
}
func nameSection(profile_data: Profile?) -> some View {
return Group {
HStack(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)
.padding(.top, -(pfp_size / 2.0))
.offset(y: pfpOffset())
.scaleEffect(pfpScale())
.onTapGesture {
is_zoomed.toggle()
}
.fullScreenCover(isPresented: $is_zoomed) {
ProfilePicImageView(pubkey: profile.pubkey, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
}
Spacer()
actionSection(profile_data: profile_data)
}
let follows_you = profile.pubkey != damus_state.pubkey && profile.follows(pubkey: damus_state.pubkey)
ProfileNameView(pubkey: profile.pubkey, profile: profile_data, follows_you: follows_you, damus: damus_state)
}
}
var followersCount: some View {
HStack {
if followers.count == nil {
Image("download")
.resizable()
.frame(width: 20, height: 20)
Text("Followers", comment: "Label describing followers of a user.")
.font(.subheadline)
.foregroundColor(.gray)
} else {
let followerCount = followers.count!
let noun_text = Text(verbatim: followersCountString(followerCount)).font(.subheadline).foregroundColor(.gray)
Text("\(Text(verbatim: followerCount.formatted()).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'.")
}
}
}
var aboutSection: some View {
VStack(alignment: .leading, spacing: 8.0) {
let profile_data = damus_state.profiles.lookup(id: profile.pubkey)
nameSection(profile_data: profile_data)
if let about = profile_data?.about {
AboutView(state: damus_state, about: about)
}
if let url = profile_data?.website_url {
WebsiteLink(url: url)
}
HStack {
if let contact = profile.contacts {
let contacts = contact.referenced_pubkeys.map { $0.ref_id }
let following_model = FollowingModel(damus_state: damus_state, contacts: contacts)
NavigationLink(destination: FollowingView(damus_state: damus_state, following: following_model)) {
HStack {
let noun_text = Text(verbatim: "\(followingCountString(profile.following))").font(.subheadline).foregroundColor(.gray)
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'.")
}
}
.buttonStyle(PlainButtonStyle())
}
let fview = FollowersView(damus_state: damus_state)
.environmentObject(followers)
if followers.contacts != nil {
NavigationLink(destination: fview) {
followersCount
}
.buttonStyle(PlainButtonStyle())
} else {
followersCount
.onTapGesture {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
followers.contacts = []
followers.subscribe()
}
}
if let relays = profile.relays {
// Only open relay config view if the user is logged in with private key and they are looking at their own profile.
let noun_text = Text(verbatim: relaysCountString(relays.keys.count)).font(.subheadline).foregroundColor(.gray)
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'.")
if profile.pubkey == damus_state.pubkey && damus_state.is_privkey_user {
NavigationLink(destination: RelayConfigView(state: damus_state)) {
relay_text
}
.buttonStyle(PlainButtonStyle())
} else {
NavigationLink(destination: UserRelaysView(state: damus_state, relays: Array(relays.keys).sorted())) {
relay_text
}
.buttonStyle(PlainButtonStyle())
}
}
}
if profile.pubkey != damus_state.pubkey {
let friended_followers = damus_state.contacts.get_friended_followers(profile.pubkey)
if !friended_followers.isEmpty {
Spacer()
NavigationLink(destination: FollowersYouKnowView(damus_state: damus_state, friended_followers: friended_followers)) {
HStack {
CondensedProfilePicturesView(state: damus_state, pubkeys: friended_followers, maxPictures: 3)
Text(followedByString(friended_followers, profiles: damus_state.profiles))
.font(.subheadline).foregroundColor(.gray)
.multilineTextAlignment(.leading)
}
}
}
}
}
.padding(.horizontal)
}
var body: some View {
ScrollView(.vertical) {
VStack(spacing: 0) {
bannerSection
.zIndex(1)
VStack() {
aboutSection
VStack(spacing: 0) {
CustomPicker(selection: $filter_state, content: {
Text("Notes", comment: "Label for filter for seeing only your notes (instead of notes and replies).").tag(FilterState.posts)
Text("Notes & Replies", comment: "Label for filter for seeing your notes and replies (instead of only your notes).").tag(FilterState.posts_and_replies)
})
Divider()
.frame(height: 1)
}
.background(colorScheme == .dark ? Color.black : Color.white)
if filter_state == FilterState.posts {
InnerTimelineView(events: profile.events, damus: damus_state, filter: FilterState.posts.filter)
}
if filter_state == FilterState.posts_and_replies {
InnerTimelineView(events: profile.events, damus: damus_state, filter: FilterState.posts_and_replies.filter)
}
}
.padding(.horizontal, Theme.safeAreaInsets?.left)
.zIndex(-yOffset > navbarHeight ? 0 : 1)
}
}
.ignoresSafeArea()
.navigationTitle("")
.navigationBarHidden(true)
.overlay(customNavbar, alignment: .top)
.onReceive(handle_notify(.switched_timeline)) { _ in
dismiss()
}
.onAppear() {
profile.subscribe()
//followers.subscribe()
}
.onDisappear {
profile.unsubscribe()
followers.unsubscribe()
// our profilemodel needs a bit more help
}
.sheet(isPresented: $show_share_sheet) {
if let npub = bech32_pubkey(profile.pubkey) {
if let url = URL(string: "https://damus.io/" + npub) {
ShareSheet(activityItems: [url])
}
}
}
.fullScreenCover(isPresented: $show_qr_code) {
QRCodeView(damus_state: damus_state, pubkey: profile.pubkey)
}
}
}
struct ProfileView_Previews: PreviewProvider {
static var previews: some View {
let ds = test_damus_state()
ProfileView(damus_state: ds, pubkey: ds.pubkey)
}
}
func test_damus_state() -> DamusState {
let pubkey = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681"
let damus = DamusState.empty
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", damus_donation: nil)
let tsprof = TimestampedProfile(profile: prof, timestamp: 0, event: test_event)
damus.profiles.add(id: pubkey, profile: tsprof)
return damus
}
struct KeyView: View {
let pubkey: String
@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
}
}
}
}
var body: some View {
let bech32 = bech32_pubkey(pubkey) ?? pubkey
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 != true {
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)
}
} else {
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)
}
}
}
}
extension View {
func profile_button_style(scheme: ColorScheme) -> some View {
self.symbolRenderingMode(.palette)
.font(.system(size: 32).weight(.thin))
.foregroundStyle(scheme == .dark ? .white : .black, scheme == .dark ? .white : .black)
}
}