diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ce39173..d1a31fe58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added Lists view and two ways to navigate to it. [#133](https://github.com/verse-pbc/issues/issues/133) - Added view for editing a list's title and description. [#134](https://github.com/verse-pbc/issues/issues/134) - Added List detail view. [#155](https://github.com/verse-pbc/issues/issues/155) +- Added view for managing users in a list. [#135](https://github.com/verse-pbc/issues/issues/135) ### Internal Changes - Added function for creating a new list and a test verifying list editing. [#112](https://github.com/verse-pbc/issues/issues/112) diff --git a/Nos.xcodeproj/project.pbxproj b/Nos.xcodeproj/project.pbxproj index e71ba1114..fc5620c4f 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -220,6 +220,7 @@ 509533002C62535400E0BACA /* zap_request.json in Resources */ = {isa = PBXBuildFile; fileRef = 509532FF2C62535400E0BACA /* zap_request.json */; }; 5095330B2C625B5D00E0BACA /* zap_request_one_sat.json in Resources */ = {isa = PBXBuildFile; fileRef = 509533092C625B5D00E0BACA /* zap_request_one_sat.json */; }; 5095330C2C625B5D00E0BACA /* zap_request_no_amount.json in Resources */ = {isa = PBXBuildFile; fileRef = 5095330A2C625B5D00E0BACA /* zap_request_no_amount.json */; }; + 50CBD79A2D37FAF400BF8A0B /* UserSelectionCircle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CBD7992D37FAF000BF8A0B /* UserSelectionCircle.swift */; }; 50CBD7AB2D39341B00BF8A0B /* AuthorListDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CBD7AA2D39341700BF8A0B /* AuthorListDetailView.swift */; }; 50CBD8152D3A8FED00BF8A0B /* ListCircle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50CBD80F2D3A8B6D00BF8A0B /* ListCircle.swift */; }; 50DE6B1B2C6B88FE0065665D /* View+StyledBorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50DE6B1A2C6B88FE0065665D /* View+StyledBorder.swift */; }; @@ -230,6 +231,7 @@ 50EA885C2D2D523F001E62CC /* follow_set_with_unknown_tag.json in Resources */ = {isa = PBXBuildFile; fileRef = 50EA885B2D2D5235001E62CC /* follow_set_with_unknown_tag.json */; }; 50EA886F2D2D5783001E62CC /* AuthorListsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EA886E2D2D5780001E62CC /* AuthorListsView.swift */; }; 50EA89A62D3010EA001E62CC /* EditAuthorListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EA89A52D3010EA001E62CC /* EditAuthorListView.swift */; }; + 50EA8A742D32CB38001E62CC /* AuthorListManageUsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50EA8A732D32CB33001E62CC /* AuthorListManageUsersView.swift */; }; 50F695072C6392C4000E4C74 /* zap_receipt.json in Resources */ = {isa = PBXBuildFile; fileRef = 50F695062C6392C4000E4C74 /* zap_receipt.json */; }; 5B098DBC2BDAF6CB00500A1B /* NoteParserTests+NIP08.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B098DBB2BDAF6CB00500A1B /* NoteParserTests+NIP08.swift */; }; 5B098DC62BDAF73500500A1B /* AttributedString+Links.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B098DC52BDAF73500500A1B /* AttributedString+Links.swift */; }; @@ -279,7 +281,7 @@ 5BFF66B62A58A8A000AA79DD /* MutesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFF66B52A58A8A000AA79DD /* MutesView.swift */; }; 659B27242BD9CB4500BEA6CC /* VerifiableEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 659B27232BD9CB4500BEA6CC /* VerifiableEvent.swift */; }; 659B27312BD9D6FE00BEA6CC /* VerifiableEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 659B27232BD9CB4500BEA6CC /* VerifiableEvent.swift */; }; - 65BD8DB92BDAF28200802039 /* CircularFollowButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BD8DB82BDAF28200802039 /* CircularFollowButton.swift */; }; + 65BD8DB92BDAF28200802039 /* CircularButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BD8DB82BDAF28200802039 /* CircularButton.swift */; }; 65BD8DC22BDAF2C300802039 /* DiscoverTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BD8DBE2BDAF2C300802039 /* DiscoverTab.swift */; }; 65BD8DC32BDAF2C300802039 /* FeaturedAuthorCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BD8DBF2BDAF2C300802039 /* FeaturedAuthorCategory.swift */; }; 65BD8DC42BDAF2C300802039 /* DiscoverContentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65BD8DC02BDAF2C300802039 /* DiscoverContentsView.swift */; }; @@ -794,6 +796,7 @@ 509532FF2C62535400E0BACA /* zap_request.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = zap_request.json; sourceTree = ""; }; 509533092C625B5D00E0BACA /* zap_request_one_sat.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = zap_request_one_sat.json; sourceTree = ""; }; 5095330A2C625B5D00E0BACA /* zap_request_no_amount.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = zap_request_no_amount.json; sourceTree = ""; }; + 50CBD7992D37FAF000BF8A0B /* UserSelectionCircle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSelectionCircle.swift; sourceTree = ""; }; 50CBD7AA2D39341700BF8A0B /* AuthorListDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorListDetailView.swift; sourceTree = ""; }; 50CBD80F2D3A8B6D00BF8A0B /* ListCircle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListCircle.swift; sourceTree = ""; }; 50DE6B1A2C6B88FE0065665D /* View+StyledBorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+StyledBorder.swift"; sourceTree = ""; }; @@ -802,6 +805,7 @@ 50EA885B2D2D5235001E62CC /* follow_set_with_unknown_tag.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = follow_set_with_unknown_tag.json; sourceTree = ""; }; 50EA886E2D2D5780001E62CC /* AuthorListsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorListsView.swift; sourceTree = ""; }; 50EA89A52D3010EA001E62CC /* EditAuthorListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAuthorListView.swift; sourceTree = ""; }; + 50EA8A732D32CB33001E62CC /* AuthorListManageUsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorListManageUsersView.swift; sourceTree = ""; }; 50F695062C6392C4000E4C74 /* zap_receipt.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = zap_receipt.json; sourceTree = ""; }; 5B098DBB2BDAF6CB00500A1B /* NoteParserTests+NIP08.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NoteParserTests+NIP08.swift"; sourceTree = ""; }; 5B098DC52BDAF73500500A1B /* AttributedString+Links.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Links.swift"; sourceTree = ""; }; @@ -852,7 +856,7 @@ 5BFF66B32A58853D00AA79DD /* PublishedEventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishedEventsView.swift; sourceTree = ""; }; 5BFF66B52A58A8A000AA79DD /* MutesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutesView.swift; sourceTree = ""; }; 659B27232BD9CB4500BEA6CC /* VerifiableEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifiableEvent.swift; sourceTree = ""; }; - 65BD8DB82BDAF28200802039 /* CircularFollowButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularFollowButton.swift; sourceTree = ""; }; + 65BD8DB82BDAF28200802039 /* CircularButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularButton.swift; sourceTree = ""; }; 65BD8DBE2BDAF2C300802039 /* DiscoverTab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiscoverTab.swift; sourceTree = ""; }; 65BD8DBF2BDAF2C300802039 /* FeaturedAuthorCategory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeaturedAuthorCategory.swift; sourceTree = ""; }; 65BD8DC02BDAF2C300802039 /* DiscoverContentsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiscoverContentsView.swift; sourceTree = ""; }; @@ -1388,7 +1392,7 @@ C987F81929BA4D0E00B44E7A /* ActionButton.swift */, C987F81629BA4C6900B44E7A /* BigActionButton.swift */, CD2CF38D299E67F900332116 /* CardButtonStyle.swift */, - 65BD8DB82BDAF28200802039 /* CircularFollowButton.swift */, + 65BD8DB82BDAF28200802039 /* CircularButton.swift */, A303AF8229A9153A005DC8FC /* FollowButton.swift */, C960C57029F3236200929990 /* LikeButton.swift */, 030036AA2C5D872B002C71F5 /* NewNotesButton.swift */, @@ -1398,6 +1402,7 @@ 5BE281C62AE2CCD800880466 /* ReplyButton.swift */, C960C57329F3251E00929990 /* RepostButton.swift */, C9A0DAD929C685E500466635 /* SideMenuButton.swift */, + 50CBD7992D37FAF000BF8A0B /* UserSelectionCircle.swift */, ); path = Button; sourceTree = ""; @@ -1595,6 +1600,7 @@ children = ( 50CBD7AA2D39341700BF8A0B /* AuthorListDetailView.swift */, 50EA886E2D2D5780001E62CC /* AuthorListsView.swift */, + 50EA8A732D32CB33001E62CC /* AuthorListManageUsersView.swift */, 50EA89A52D3010EA001E62CC /* EditAuthorListView.swift */, 50CBD80F2D3A8B6D00BF8A0B /* ListCircle.swift */, ); @@ -2565,6 +2571,7 @@ C98CA9042B14FA3D00929141 /* PagedRelaySubscription.swift in Sources */, 5B0D99032A94090A0039F0C5 /* DoubleTapToPopModifier.swift in Sources */, 030024192CC00DFC0073ED56 /* SplashScreenView.swift in Sources */, + 50CBD8102D3A8B7000BF8A0B /* ListCircle.swift in Sources */, 0357299B2BE415E5005FEE85 /* ContentWarningController.swift in Sources */, 5BFF66B42A58853D00AA79DD /* PublishedEventsView.swift in Sources */, 03D1B42C2C3C1B0D001778CD /* TLVElement.swift in Sources */, @@ -2576,6 +2583,7 @@ A3B943CF299AE00100A15A08 /* Keychain.swift in Sources */, C9671D73298DB94C00EE7E12 /* Data+Encoding.swift in Sources */, 03C7E7922CB9C0B30054624C /* WelcomeToFeedTip.swift in Sources */, + 50EA8A742D32CB38001E62CC /* AuthorListManageUsersView.swift in Sources */, C9646EA129B7A22C007239A4 /* Analytics.swift in Sources */, 03A743452CC048C700893CAE /* GoToFeedTip.swift in Sources */, 5045540D2C81E10C0044ECAE /* EditableAvatarView.swift in Sources */, @@ -2656,7 +2664,7 @@ 5B29B5842BEAA0D7008F6008 /* BioSheet.swift in Sources */, C93CA0C329AE3A1E00921183 /* JSONEvent.swift in Sources */, 3FFB1D89299FF37C002A755D /* AvatarView.swift in Sources */, - 65BD8DB92BDAF28200802039 /* CircularFollowButton.swift in Sources */, + 65BD8DB92BDAF28200802039 /* CircularButton.swift in Sources */, C97A1C8829E45B3C009D9E8D /* RawEventView.swift in Sources */, C9DEC04529894BED0078B43A /* Event+CoreDataClass.swift in Sources */, CD76865029B6503500085358 /* NoteOptionsButton.swift in Sources */, @@ -2713,6 +2721,7 @@ 04C9D7272CBF09C200EAAD4D /* TextField+PlaceHolderStyle.swift in Sources */, C9DEC04D29894BED0078B43A /* Author+CoreDataClass.swift in Sources */, C905B0772A619E99009B8A78 /* LPLinkViewRepresentable.swift in Sources */, + 50CBD79A2D37FAF400BF8A0B /* UserSelectionCircle.swift in Sources */, C95D68A7299E6FF000429F86 /* KeyFixture.swift in Sources */, 0304D0B22C9B731F001D16C7 /* MockOpenGraphService.swift in Sources */, 030E570D2CC2A05B00A4A51E /* DisplayNameView.swift in Sources */, diff --git a/Nos/Assets/Localization/Localizable.xcstrings b/Nos/Assets/Localization/Localizable.xcstrings index 660d1a4ac..5ba8714ff 100644 --- a/Nos/Assets/Localization/Localizable.xcstrings +++ b/Nos/Assets/Localization/Localizable.xcstrings @@ -10489,6 +10489,17 @@ } } }, + "listStep2" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Step 2: add users" + } + } + } + }, "loading" : { "extractionState" : "manual", "localizations" : { diff --git a/Nos/Controller/FeedController.swift b/Nos/Controller/FeedController.swift index a4637778f..f6316b361 100644 --- a/Nos/Controller/FeedController.swift +++ b/Nos/Controller/FeedController.swift @@ -82,7 +82,11 @@ import SwiftUI .publisher .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] lists in - self?.lists = lists + // ensure that we only publish the most recent list for each replaceable identifier + let grouped = Dictionary(grouping: lists, by: { $0.replaceableIdentifier ?? "" }) + self?.lists = grouped.compactMap { _, events in + events.max(by: { $0.createdAt ?? Date.distantPast < $1.createdAt ?? Date.distantPast }) + } }) .store(in: &cancellables) } diff --git a/Nos/Controller/SearchController.swift b/Nos/Controller/SearchController.swift index 6f712bb0e..c67aa4d6a 100644 --- a/Nos/Controller/SearchController.swift +++ b/Nos/Controller/SearchController.swift @@ -26,6 +26,9 @@ enum SearchState { enum SearchOrigin { /// Search initiated from the Discover tab case discover + + /// Search initiated from ``AuthorListManageUsersView`` + case lists /// Search initiated from the mentions `AuthorSearchView` case mentions @@ -91,6 +94,8 @@ enum SearchOrigin { switch searchOrigin { case .discover: analytics.searchedDiscover() + case .lists: + break // TODO: Analytics case .mentions: analytics.mentionsAutocompleteCharactersEntered() } diff --git a/Nos/Models/CoreData/AuthorList+CoreDataClass.swift b/Nos/Models/CoreData/AuthorList+CoreDataClass.swift index 08723c115..b5958f756 100644 --- a/Nos/Models/CoreData/AuthorList+CoreDataClass.swift +++ b/Nos/Models/CoreData/AuthorList+CoreDataClass.swift @@ -88,7 +88,7 @@ public class AuthorList: Event { owner, kind ) - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \AuthorList.identifier, ascending: true)] + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] fetchRequest.fetchLimit = 1 return fetchRequest } @@ -101,7 +101,7 @@ public class AuthorList: Event { let request = NSFetchRequest(entityName: "AuthorList") request.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] request.predicate = NSPredicate( - format: "kind = %i AND author = %@ AND title != nil AND deletedOn.@count = 0", + format: "kind = %i AND author = %@ AND title != nil AND title != '' AND deletedOn.@count = 0", EventKind.followSet.rawValue, owner ) diff --git a/Nos/Views/Components/Author/AuthorCard.swift b/Nos/Views/Components/Author/AuthorCard.swift index 22c1c94ae..18ad9009c 100644 --- a/Nos/Views/Components/Author/AuthorCard.swift +++ b/Nos/Views/Components/Author/AuthorCard.swift @@ -1,23 +1,38 @@ import SwiftUI +/// Modes for determining the state of the ``UserSelectionCircle`` on the ``AuthorCard`` +enum AvatarOverlayMode { + /// Uses following state by the current user + case follows + + /// Uses inclusion in the given set of ``Author``s + case inSet(authors: Set) + + /// Always displays the selected state + case alwaysSelected +} + /// This view displays the information we have for an author suitable for being used in a list. -struct AuthorCard: View { +struct AuthorCard: View { + @ObservedObject var author: Author @Environment(CurrentUser.self) var currentUser - /// Whether the follow button should be displayed or not. - let showsFollowButton: Bool - - var tapAction: (() -> Void)? + let avatarOverlayView: () -> AvatarOverlay? + let tapAction: (() -> Void)? /// Initializes an `AuthorCard` with the given parameters. /// - Parameters: /// - author: The author to show in the card. - /// - showsFollowButton: Whether the follow button should be displayed or not. Defaults to `true`. + /// - avatarOverlayView: The view to show as an overlay at the bottom right of the avatar. /// - onTap: The action to take when this card is tapped, if any. Defaults to `nil`. - init(author: Author, showsFollowButton: Bool = true, onTap: (() -> Void)? = nil) { + init( + author: Author, + @ViewBuilder avatarOverlayView: @escaping () -> AvatarOverlay? = { nil as AnyView? }, + onTap: (() -> Void)? = nil + ) { self.author = author - self.showsFollowButton = showsFollowButton + self.avatarOverlayView = avatarOverlayView self.tapAction = onTap } @@ -30,7 +45,10 @@ struct AuthorCard: View { ZStack(alignment: .bottomTrailing) { AvatarView(imageUrl: author.profilePhotoURL, size: 80) .padding(.trailing, 12) - if showsFollowButton { + + if let overlay = avatarOverlayView() { + overlay + } else { CircularFollowButton(author: author) } } diff --git a/Nos/Views/Components/Author/AuthorSearchView.swift b/Nos/Views/Components/Author/AuthorSearchView.swift index 02c2f2871..11b84e538 100644 --- a/Nos/Views/Components/Author/AuthorSearchView.swift +++ b/Nos/Views/Components/Author/AuthorSearchView.swift @@ -1,22 +1,47 @@ import Foundation import SwiftUI -struct AuthorSearchView: View { +struct AuthorSearchView: View { - @Binding var isPresented: Bool + @Environment(\.dismiss) private var dismiss @Environment(\.managedObjectContext) private var viewContext - @State private var searchController = SearchController(searchOrigin: .mentions) + @State private var searchController: SearchController @FocusState private var isSearching: Bool @State private var filteredAuthors: [Author] = [] - + + let title: LocalizedStringKey? + let isModal: Bool + let avatarOverlayMode: AvatarOverlayMode + + /// The view to show when the search bar is empty. + let emptyPlaceholder: () -> EmptyPlaceholder? + /// The authors are referenced in a note / who replied under the note the user is replying if any. var relatedAuthors: [Author]? var didSelectGesture: ((Author) -> Void)? + init( + searchOrigin: SearchOrigin, + title: LocalizedStringKey? = nil, + isModal: Bool, + avatarOverlayMode: AvatarOverlayMode = .follows, + relatedAuthors: [Author]? = nil, + @ViewBuilder emptyPlaceholder: @escaping () -> EmptyPlaceholder? = { nil }, + didSelectGesture: ((Author) -> Void)? = nil + ) { + self.title = title + self.isModal = isModal + self.avatarOverlayMode = avatarOverlayMode + self.relatedAuthors = relatedAuthors + self.didSelectGesture = didSelectGesture + self.emptyPlaceholder = emptyPlaceholder + _searchController = State(initialValue: SearchController(searchOrigin: searchOrigin)) + } + var body: some View { ScrollView(.vertical) { SearchBar(text: $searchController.query, isSearching: $isSearching) @@ -25,19 +50,19 @@ struct AuthorSearchView: View { .onSubmit { searchController.submitSearch(query: searchController.query) } - LazyVStack { - ForEach(filteredAuthors) { author in - AuthorCard(author: author, showsFollowButton: false) { - didSelectGesture?(author) + + if filteredAuthors.isEmpty { + emptyPlaceholder() + } else { + LazyVStack { + ForEach(filteredAuthors) { author in + row(forAuthor: author) } - .padding(.horizontal, 13) - .padding(.top, 5) - .readabilityPadding() } } } .background(Color.appBg) - .nosNavigationBar("mention") + .nosNavigationBar(title ?? "") .onAppear { isSearching = true @@ -55,14 +80,38 @@ struct AuthorSearchView: View { } .disableAutocorrection(true) .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(action: { - isPresented = false - }, label: { - Text("cancel") - .foregroundColor(.primaryTxt) - }) + if isModal { + ToolbarItem(placement: .cancellationAction) { + Button(action: { + dismiss() + }, label: { + Text("cancel") + .foregroundColor(.primaryTxt) + }) + } } } } + + private func row(forAuthor author: Author) -> some View { + AuthorCard( + author: author, + avatarOverlayView: { + switch avatarOverlayMode { + case .follows: + AnyView(CircularFollowButton(author: author)) + case .alwaysSelected: + AnyView(UserSelectionCircle(diameter: 30, selected: true)) + case .inSet(let authors): + AnyView(UserSelectionCircle(diameter: 30, selected: authors.contains(author))) + } + }, + onTap: { + didSelectGesture?(author) + } + ) + .padding(.horizontal, 13) + .padding(.top, 5) + .readabilityPadding() + } } diff --git a/Nos/Views/Components/Button/CircularFollowButton.swift b/Nos/Views/Components/Button/CircularButton.swift similarity index 60% rename from Nos/Views/Components/Button/CircularFollowButton.swift rename to Nos/Views/Components/Button/CircularButton.swift index b3f249bda..55b4610dc 100644 --- a/Nos/Views/Components/Button/CircularFollowButton.swift +++ b/Nos/Views/Components/Button/CircularButton.swift @@ -39,27 +39,10 @@ struct CircularFollowButton: View { } } } label: { - ZStack { - Circle() - .frame(width: diameter) - .foregroundStyle( - following ? LinearGradient.verticalAccentSecondary : LinearGradient.verticalAccentPrimary - ) - .background( - Circle() - .frame(width: diameter) - .offset(y: 1) - .foregroundStyle( - following ? Color.actionSecondaryBackground : Color.actionPrimaryBackground - ) - ) - if following { - Image.followingIcon - .padding(.top, 3) // the icon file isn't square so we need to shift it down - } else { - Image.followIcon - } - } + UserSelectionCircle( + diameter: diameter, + selected: following + ) } .disabled(disabled) } diff --git a/Nos/Views/Components/Button/UserSelectionCircle.swift b/Nos/Views/Components/Button/UserSelectionCircle.swift new file mode 100644 index 000000000..430ccfaa5 --- /dev/null +++ b/Nos/Views/Components/Button/UserSelectionCircle.swift @@ -0,0 +1,36 @@ +import SwiftUI + +/// A circular image view that is either a purple checkmark or an orange user-plus based +/// on the `selected` property. +/// +/// This is useful to indicate "selected" or "included" state of the contextual item, such +/// as a user being followed or not or being in an ``AuthorList`` or not. +struct UserSelectionCircle: View { + let diameter: CGFloat + let selected: Bool + + var body: some View { + ZStack { + Circle() + .frame(width: diameter) + .foregroundStyle( + selected ? LinearGradient.verticalAccentSecondary : LinearGradient.verticalAccentPrimary + ) + .background( + Circle() + .frame(width: diameter) + .offset(y: 1) + .foregroundStyle( + selected ? Color.actionSecondaryBackground : Color.actionPrimaryBackground + ) + ) + + if selected { + Image.followingIcon + .padding(.top, 3) // the icon file isn't square so we need to shift it down + } else { + Image.followIcon + } + } + } +} diff --git a/Nos/Views/Components/NoteTextEditor.swift b/Nos/Views/Components/NoteTextEditor.swift index b43986cb0..bb49af062 100644 --- a/Nos/Views/Components/NoteTextEditor.swift +++ b/Nos/Views/Components/NoteTextEditor.swift @@ -35,15 +35,19 @@ struct NoteTextEditor: View { .sheet(isPresented: $controller.showMentionsAutocomplete) { NavigationStack { AuthorSearchView( - isPresented: $controller.showMentionsAutocomplete, - relatedAuthors: relatedAuthors - ) { [weak controller] author in - /// Guard against double presses - guard let controller, controller.showMentionsAutocomplete else { return } - - controller.insertMention(of: author) - controller.showMentionsAutocomplete = false - } + searchOrigin: .mentions, + title: "mention", + isModal: true, + relatedAuthors: relatedAuthors, + emptyPlaceholder: { EmptyView() }, + didSelectGesture: { [weak controller] author in + /// Guard against double presses + guard let controller, controller.showMentionsAutocomplete else { return } + + controller.insertMention(of: author) + controller.showMentionsAutocomplete = false + } + ) } } } diff --git a/Nos/Views/Discover/DiscoverContentsView.swift b/Nos/Views/Discover/DiscoverContentsView.swift index ff17274ac..691b181ee 100644 --- a/Nos/Views/Discover/DiscoverContentsView.swift +++ b/Nos/Views/Discover/DiscoverContentsView.swift @@ -50,13 +50,13 @@ struct DiscoverContentsView: View { ScrollView { LazyVStack { ForEach(searchController.authorResults) { author in - AuthorCard(author: author) { + AuthorCard(author: author, onTap: { let resultsCount = searchController.authorResults.count analytics.displayedAuthorFromDiscoverSearch( resultsCount: resultsCount ) router.push(author) - } + }) .padding(.horizontal, 15) .padding(.top, 10) .readabilityPadding() @@ -86,9 +86,9 @@ struct DiscoverContentsView: View { AuthorObservationView(authorID: authorID) { author in VStack { if author.lastUpdatedMetadata != nil { - AuthorCard(author: author) { + AuthorCard(author: author, onTap: { router.push(author) - } + }) .padding(.horizontal, 13) .padding(.top, 5) .readabilityPadding() diff --git a/Nos/Views/Lists/AuthorListDetailView.swift b/Nos/Views/Lists/AuthorListDetailView.swift index 06a4e8d17..a12644e4a 100644 --- a/Nos/Views/Lists/AuthorListDetailView.swift +++ b/Nos/Views/Lists/AuthorListDetailView.swift @@ -3,7 +3,9 @@ import SwiftUI struct AuthorListDetailView: View { + @Environment(\.dismiss) private var dismiss @Dependency(\.relayService) private var relayService + @Environment(CurrentUser.self) private var currentUser @EnvironmentObject private var router: Router @ObservedObject var list: AuthorList @@ -12,6 +14,7 @@ struct AuthorListDetailView: View { @State private var subscriptions = [ObjectIdentifier: SubscriptionCancellable]() @State private var showingEditListInfo = false + @State private var showingManageUsers = false var body: some View { ScrollView { @@ -43,7 +46,7 @@ struct AuthorListDetailView: View { ProfileView(author: author) } label: { AuthorObservationView(authorID: author.hexadecimalPublicKey) { author in - AuthorCard(author: author) + AuthorCard(author: author, avatarOverlayView: { EmptyView() }) .padding(.horizontal, 13) .padding(.top, 5) .readabilityPadding() @@ -70,7 +73,7 @@ struct AuthorListDetailView: View { showingEditListInfo = true } Button("manageUsers") { - // TODO: Manage Users + showingManageUsers = true } Button("deleteList", role: .destructive) { // TODO: Delete List @@ -88,5 +91,10 @@ struct AuthorListDetailView: View { EditAuthorListView(list: list) } } + .sheet(isPresented: $showingManageUsers) { + NavigationStack { + AuthorListManageUsersView(list: list) + } + } } } diff --git a/Nos/Views/Lists/AuthorListManageUsersView.swift b/Nos/Views/Lists/AuthorListManageUsersView.swift new file mode 100644 index 000000000..6c40f1c6d --- /dev/null +++ b/Nos/Views/Lists/AuthorListManageUsersView.swift @@ -0,0 +1,161 @@ +import Logger +import SwiftUI + +/// Displays a search bar and user results. Allows the user to add or remove users +/// from an existing ``AuthorList`` and while creating a new one. +struct AuthorListManageUsersView: View { + + private enum Mode { + case create(title: String, description: String?) + case update(list: AuthorList) + + var buttonTitleKey: LocalizedStringKey { + switch self { + case .create: + "save" + case .update: + "done" + } + } + } + + @Environment(\.dismiss) private var dismiss + @Environment(RelayService.self) private var relayService + @Environment(CurrentUser.self) private var currentUser + @Environment(\.managedObjectContext) private var viewContext + @State private var authors: Set + + private var mode: Mode + + /// An action that runs after successfully saving an ``AuthorList``. + /// + /// Leaving the value nil will cause the Environment's `dismiss()` to be called, which may appear + /// as a modal dismissal or a navigational pop depending on the presentation context. + private let onSave: (() -> Void)? + + init(list: AuthorList) { + mode = .update(list: list) + _authors = State(initialValue: list.allAuthors) + onSave = nil + } + + init(title: String, description: String?, onSave: (() -> Void)?) { + mode = .create(title: title, description: description) + _authors = State(initialValue: []) + self.onSave = onSave + } + + var body: some View { + ZStack { + Color.appBg + .ignoresSafeArea() + + VStack(alignment: .leading, spacing: 0) { + if case .create = mode { + Text("listStep2") + .font(.clarity(.medium, textStyle: .subheadline)) + .foregroundColor(Color.secondaryTxt) + .padding(EdgeInsets(top: 28, leading: 20, bottom: 0, trailing: 16)) + } + + AuthorSearchView( + searchOrigin: .lists, + isModal: false, + avatarOverlayMode: .inSet(authors: authors), + emptyPlaceholder: { + AuthorsView( + authors: Array(authors), + avatarOverlayMode: .alwaysSelected, + onTapGesture: toggleAuthor + ) + }, + didSelectGesture: toggleAuthor + ) + } + } + .nosNavigationBar(title: AttributedString(viewTitle)) + .toolbar { + if case .update = mode { + ToolbarItem(placement: .cancellationAction) { + Button("cancel") { + dismiss() + } + } + } + ToolbarItem(placement: .primaryAction) { + ActionButton(mode.buttonTitleKey, action: saveButtonPressed) + .frame(height: 22) + .padding(.bottom, 3) + } + } + } + + private var viewTitle: String { + switch mode { + case .create(let title, _): + title + case .update(let list): + list.title ?? "" + } + } + + private var buttonTitleKey: LocalizedStringKey { + switch mode { + case .create: + "save" + case .update: + "done" + } + } + + private func toggleAuthor(_ author: Author) { + if authors.contains(author) { + authors.remove(author) + } else { + authors.insert(author) + } + } + + private func saveButtonPressed() { + guard let keyPair = currentUser.keyPair else { + return + } + + let title: String + let description: String? + let replaceableID: String? + + switch mode { + case .create(let newTitle, let newDescription): + title = newTitle + description = newDescription + replaceableID = nil + case .update(let list): + title = list.title ?? "" + description = list.listDescription + replaceableID = list.replaceableIdentifier + } + + let event = JSONEvent.followSet( + pubKey: keyPair.publicKeyHex, + title: title, + description: description, + replaceableID: replaceableID, + authorIDs: authors.compactMap { $0.hexadecimalPublicKey } + ) + + Task { + do { + try await relayService.publishToAll(event: event, signingKey: keyPair, context: viewContext) + + if let onSave { + onSave() + } else { + dismiss() + } + } catch { + Log.error("Error when creating list: \(error.localizedDescription)") + } + } + } +} diff --git a/Nos/Views/Lists/AuthorListsView.swift b/Nos/Views/Lists/AuthorListsView.swift index 2d53e00cf..796df2b2b 100644 --- a/Nos/Views/Lists/AuthorListsView.swift +++ b/Nos/Views/Lists/AuthorListsView.swift @@ -7,7 +7,6 @@ struct ListsDestination: Hashable { /// A view that displays a list of an ``Author``'s ``AuthorList``s. struct AuthorListsView: View { - @Environment(\.managedObjectContext) private var viewContext let author: Author @@ -53,6 +52,7 @@ struct AuthorListsView: View { Text(list.rowDescription) .foregroundStyle(Color.secondaryTxt) .font(.footnote) + .lineLimit(1) } Spacer() diff --git a/Nos/Views/Lists/EditAuthorListView.swift b/Nos/Views/Lists/EditAuthorListView.swift index f31a89529..8d08d15f3 100644 --- a/Nos/Views/Lists/EditAuthorListView.swift +++ b/Nos/Views/Lists/EditAuthorListView.swift @@ -23,6 +23,8 @@ struct EditAuthorListView: View { @FocusState private var focusedField: Field? private let mode: Mode + @State private var showingManageUsers = false + init(list: AuthorList? = nil) { self.list = list mode = list == nil ? .create : .update @@ -75,6 +77,11 @@ struct EditAuthorListView: View { .padding(.bottom, 3) } } + .navigationDestination(isPresented: $showingManageUsers) { + AuthorListManageUsersView(title: title, description: description) { + dismiss() + } + } .onAppear { title = list?.title ?? "" description = list?.listDescription ?? "" @@ -99,7 +106,7 @@ struct EditAuthorListView: View { title: title, description: description, replaceableID: list?.replaceableIdentifier, - authorIDs: [] + authorIDs: list?.authors.compactMap { $0.hexadecimalPublicKey } ?? [] ) Task { @@ -111,7 +118,7 @@ struct EditAuthorListView: View { } } } else { - // TODO: Manage Users + showingManageUsers = true } } } diff --git a/Nos/Views/Profile/AuthorsView.swift b/Nos/Views/Profile/AuthorsView.swift index 4744143c1..5d20dba35 100644 --- a/Nos/Views/Profile/AuthorsView.swift +++ b/Nos/Views/Profile/AuthorsView.swift @@ -15,10 +15,14 @@ struct FollowersDestination: Hashable { /// Displays a list of authors. struct AuthorsView: View { /// Screen title - let title: LocalizedStringKey + let title: LocalizedStringKey? /// Sorted list of authors to display in the list let authors: [Author] + + let avatarOverlayMode: AvatarOverlayMode + + let onTapGesture: ((Author) -> Void)? /// Subscriptions for metadata requests from the relay service, keyed by author ID. @State private var subscriptions = [ObjectIdentifier: SubscriptionCancellable]() @@ -27,11 +31,15 @@ struct AuthorsView: View { @EnvironmentObject private var router: Router init( - _ title: LocalizedStringKey, - authors: [Author] + _ title: LocalizedStringKey? = nil, + authors: [Author], + avatarOverlayMode: AvatarOverlayMode = .follows, + onTapGesture: ((Author) -> Void)? = nil ) { self.title = title self.authors = authors + self.avatarOverlayMode = avatarOverlayMode + self.onTapGesture = onTapGesture } var body: some View { @@ -39,9 +47,26 @@ struct AuthorsView: View { LazyVStack { ForEach(authors) { author in AuthorObservationView(authorID: author.hexadecimalPublicKey) { author in - AuthorCard(author: author) { - router.push(author) - } + AuthorCard( + author: author, + avatarOverlayView: { + switch avatarOverlayMode { + case .follows: + AnyView(CircularFollowButton(author: author)) + case .alwaysSelected: + AnyView(UserSelectionCircle(diameter: 30, selected: true)) + case .inSet(let authors): + AnyView(UserSelectionCircle(diameter: 30, selected: authors.contains(author))) + } + }, + onTap: { + if let onTapGesture { + onTapGesture(author) + } else { + router.push(author) + } + } + ) .padding(.horizontal, 13) .padding(.top, 5) .readabilityPadding() @@ -58,6 +83,6 @@ struct AuthorsView: View { .padding(.vertical, 12) } .background(Color.appBg) - .nosNavigationBar(title) + .nosNavigationBar(title ?? "") } }