damus

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

commit 480921db20b1dc4ada8a43c4088f54e4fd173bb8
parent f0de8721c7b40c4273ed3ba86732462e7d3bb3e4
Author: Joel Klabo <joelklabo@gmail.com>
Date:   Thu, 20 Jul 2023 12:45:10 -0700

Suggested Users to Follow

ui: Add Suggested Users Views and Helpers
ui: Add Logic to Launch Suggested User Screen

Changelog-Added: Suggested Users to Follow

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 32++++++++++++++++++++++++++++++++
Adamus/Components/Gradients/GrayGradient.swift | 26++++++++++++++++++++++++++
Mdamus/ContentView.swift | 10+++++++++-
Adamus/Views/Buttons/GradientFollowButton.swift | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Views/Onboarding/SuggestedUserView.swift | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Views/Onboarding/SuggestedUsersView.swift | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Views/Onboarding/SuggestedUsersViewModel.swift | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Views/Onboarding/suggested_users.json | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 487 insertions(+), 1 deletion(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -348,6 +348,12 @@ E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */; }; E990020F2955F837003BBC5A /* EditMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E990020E2955F837003BBC5A /* EditMetadataView.swift */; }; E9E4ED0B295867B900DD7078 /* ThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E4ED0A295867B900DD7078 /* ThreadView.swift */; }; + F71694EA2A662232001F4053 /* SuggestedUsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694E92A662232001F4053 /* SuggestedUsersView.swift */; }; + F71694EC2A662292001F4053 /* SuggestedUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */; }; + F71694EE2A6624F9001F4053 /* suggested_users.json in Resources */ = {isa = PBXBuildFile; fileRef = F71694ED2A6624F9001F4053 /* suggested_users.json */; }; + F71694F22A67314D001F4053 /* SuggestedUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F12A67314D001F4053 /* SuggestedUserView.swift */; }; + F71694F42A6732B7001F4053 /* GradientFollowButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F32A6732B7001F4053 /* GradientFollowButton.swift */; }; + F71694F82A6983AF001F4053 /* GrayGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F72A6983AF001F4053 /* GrayGradient.swift */; }; F757933A29D7AECD007DEAC1 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F757933929D7AECD007DEAC1 /* ImagePicker.swift */; }; F75BA12D29A1855400E10810 /* BookmarksManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75BA12C29A1855400E10810 /* BookmarksManager.swift */; }; F75BA12F29A18EF500E10810 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75BA12E29A18EF500E10810 /* BookmarksView.swift */; }; @@ -855,6 +861,12 @@ E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSettingsView.swift; sourceTree = "<group>"; }; E990020E2955F837003BBC5A /* EditMetadataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditMetadataView.swift; sourceTree = "<group>"; }; E9E4ED0A295867B900DD7078 /* ThreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadView.swift; sourceTree = "<group>"; }; + F71694E92A662232001F4053 /* SuggestedUsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedUsersView.swift; sourceTree = "<group>"; }; + F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedUsersViewModel.swift; sourceTree = "<group>"; }; + F71694ED2A6624F9001F4053 /* suggested_users.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = suggested_users.json; sourceTree = "<group>"; }; + F71694F12A67314D001F4053 /* SuggestedUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedUserView.swift; sourceTree = "<group>"; }; + F71694F32A6732B7001F4053 /* GradientFollowButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientFollowButton.swift; sourceTree = "<group>"; }; + F71694F72A6983AF001F4053 /* GrayGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GrayGradient.swift; sourceTree = "<group>"; }; F757933929D7AECD007DEAC1 /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = "<group>"; }; F75BA12C29A1855400E10810 /* BookmarksManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksManager.swift; sourceTree = "<group>"; }; F75BA12E29A18EF500E10810 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = "<group>"; }; @@ -1146,6 +1158,7 @@ 4C75EFA227FA576C0006080F /* Views */ = { isa = PBXGroup; children = ( + F71694E82A66221E001F4053 /* Onboarding */, 4C190F232A547D1700027FD5 /* NostrScript */, 4C7D09692A0AEA0400943473 /* CodeScanner */, 4C7D095A2A098C5C00943473 /* Wallet */, @@ -1263,6 +1276,7 @@ 4C7D09732A0AEF9000943473 /* AlbyGradient.swift */, 4C2859612A12A7F0004746F7 /* GoldSupportGradient.swift */, 5C6E1DAE2A194075008FC15A /* PinkGradient.swift */, + F71694F72A6983AF001F4053 /* GrayGradient.swift */, 5C0707D02A1ECB38004E7B51 /* DamusLogoGradient.swift */, 4C687C202A5F7ED00092C550 /* DamusBackground.swift */, ); @@ -1325,6 +1339,7 @@ isa = PBXGroup; children = ( 4C8D1A6E29F31E5000ACDF75 /* FriendsButton.swift */, + F71694F32A6732B7001F4053 /* GradientFollowButton.swift */, 4C7D09652A0AE62100943473 /* AlbyButton.swift */, ); path = Buttons; @@ -1707,6 +1722,17 @@ path = Extensions; sourceTree = "<group>"; }; + F71694E82A66221E001F4053 /* Onboarding */ = { + isa = PBXGroup; + children = ( + F71694E92A662232001F4053 /* SuggestedUsersView.swift */, + F71694F12A67314D001F4053 /* SuggestedUserView.swift */, + F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */, + F71694ED2A6624F9001F4053 /* suggested_users.json */, + ); + path = Onboarding; + sourceTree = "<group>"; + }; F7F0BA23297892AE009531F3 /* Modifiers */ = { isa = PBXGroup; children = ( @@ -1875,6 +1901,7 @@ 4CE6DEEB27F7A08200C66700 /* Assets.xcassets in Resources */, 4C198DF129F88C6B004C165C /* License.txt in Resources */, 4C198DF029F88C6B004C165C /* Readme.md in Resources */, + F71694EE2A6624F9001F4053 /* suggested_users.json in Resources */, 3A4325A82961E11400BFCD9D /* Localizable.stringsdict in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1984,6 +2011,7 @@ F7F0BA25297892BD009531F3 /* SwipeToDismiss.swift in Sources */, 4C8D00CA29DF80350036AF10 /* TruncatedText.swift in Sources */, 4C4DD3DB2A6CA7E8005B4E85 /* ContentParsing.swift in Sources */, + F71694F22A67314D001F4053 /* SuggestedUserView.swift in Sources */, 4C9BB83429C12D9900FC4E37 /* EventProfileName.swift in Sources */, 4C7D09602A098C5D00943473 /* WalletView.swift in Sources */, 4CB8838F296F781C00DC99E7 /* ReactionsView.swift in Sources */, @@ -2073,6 +2101,7 @@ 4C363A8828236948006E126D /* BlocksView.swift in Sources */, 4C06670628FCB08600038D2A /* ImageCarousel.swift in Sources */, 3A23838E2A297DD200E5AA2E /* ZapButtonModel.swift in Sources */, + F71694F82A6983AF001F4053 /* GrayGradient.swift in Sources */, 5053ACA72A56DF3B00851AE3 /* DeveloperSettingsView.swift in Sources */, F79C7FAD29D5E9620000F946 /* EditPictureControl.swift in Sources */, 4C9F18E229AA9B6C008C55EC /* CustomizeZapView.swift in Sources */, @@ -2112,6 +2141,7 @@ 4C1A9A2729DDE31900516EAC /* TranslationSettingsView.swift in Sources */, 4CB8838629656C8B00DC99E7 /* NIP05.swift in Sources */, 4CF0ABD82981980C00D66079 /* Lists.swift in Sources */, + F71694EA2A662232001F4053 /* SuggestedUsersView.swift in Sources */, 501F8C5829FF5FC5001AFC1D /* Damus.xcdatamodeld in Sources */, 4C30AC8029A6A53F00E2BD5A /* ProfilePicturesView.swift in Sources */, 5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */, @@ -2145,6 +2175,7 @@ 4CEE2AED2805B22500AB5EEF /* NostrRequest.swift in Sources */, 4C06670E28FDEAA000038D2A /* utf8.c in Sources */, 4C3EA66D28FF782800C48A62 /* amount.c in Sources */, + F71694F42A6732B7001F4053 /* GradientFollowButton.swift in Sources */, 4C3AC7A728369BA200E1F516 /* SearchHomeView.swift in Sources */, 4CB883B0297705DD00DC99E7 /* ZapButton.swift in Sources */, 4C363A922825FCF2006E126D /* ProfileUpdate.swift in Sources */, @@ -2168,6 +2199,7 @@ 4C1A9A1A29DCA17E00516EAC /* ReplyCounter.swift in Sources */, 50B5685329F97CB400A23243 /* CredentialHandler.swift in Sources */, 643EA5C8296B764E005081BB /* RelayFilterView.swift in Sources */, + F71694EC2A662292001F4053 /* SuggestedUsersViewModel.swift in Sources */, 4C3EA67D28FFBBA300C48A62 /* InvoicesView.swift in Sources */, 4C363A8E28236FE4006E126D /* NoteContentView.swift in Sources */, 4C90BD1A283AA67F008EE7EF /* Bech32.swift in Sources */, diff --git a/damus/Components/Gradients/GrayGradient.swift b/damus/Components/Gradients/GrayGradient.swift @@ -0,0 +1,26 @@ +// +// GrayGradient.swift +// damus +// +// Created by klabo on 7/20/23. +// + +import SwiftUI + +let GrayGradient = LinearGradient(gradient: + Gradient(colors: [Color(#colorLiteral(red: 0.9764705882, green: 0.9803921569, blue: 0.9803921569, alpha: 1))]), + startPoint: .leading, + endPoint: .trailing) + +struct GrayGradientView: View { + var body: some View { + GrayGradient + .edgesIgnoringSafeArea([.top, .bottom]) + } +} + +struct GrayGradient_Previews: PreviewProvider { + static var previews: some View { + GrayGradientView() + } +} diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -30,6 +30,7 @@ enum Sheets: Identifiable { case zap(ZapSheet) case select_wallet(SelectWallet) case filter + case suggestedUsers static func zap(target: ZapTarget, lnurl: String) -> Sheets { return .zap(ZapSheet(target: target, lnurl: lnurl)) @@ -47,6 +48,7 @@ enum Sheets: Identifiable { case .zap(let sheet): return "zap-" + sheet.target.id case .select_wallet: return "select-wallet" case .filter: return "filter" + case .suggestedUsers: return "suggested-users" } } } @@ -89,7 +91,7 @@ struct ContentView: View { @State private var isSideBarOpened = false var home: HomeModel = HomeModel() @StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator() - + @AppStorage("has_seen_suggested_users") private var hasSeenSuggestedUsers = false let sub_id = UUID().description @Environment(\.colorScheme) var colorScheme @@ -302,6 +304,10 @@ struct ContentView: View { self.connect() try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers) setup_notifications() + if !hasSeenSuggestedUsers { + active_sheet = .suggestedUsers + hasSeenSuggestedUsers = true + } } .sheet(item: $active_sheet) { item in switch item { @@ -324,6 +330,8 @@ struct ContentView: View { } else { RelayFilterView(state: damus_state!, timeline: timeline) } + case .suggestedUsers: + SuggestedUsersView(model: SuggestedUsersViewModel(damus_state: damus_state!)) } } .onOpenURL { url in diff --git a/damus/Views/Buttons/GradientFollowButton.swift b/damus/Views/Buttons/GradientFollowButton.swift @@ -0,0 +1,83 @@ +// +// GradientFollowButton.swift +// damus +// +// Created by klabo on 7/18/23. +// + +import SwiftUI + +struct GradientFollowButton: View { + + let target: FollowTarget + let follows_you: Bool + + @State var follow_state: FollowState + + private let grayTextColor = Color(#colorLiteral(red: 0.1450980392, green: 0.1607843137, blue: 0.1764705882, alpha: 1)) + private let grayBorder = Color(#colorLiteral(red: 0.8666666667, green: 0.8823529412, blue: 0.8901960784, alpha: 1)) + + var body: some View { + + Button(action: { + follow_state = perform_follow_btn_action(follow_state, target: target) + }) { + Text("\(follow_btn_txt(follow_state, follows_you: follows_you))") + .foregroundColor(follow_state == .unfollows ? .white : grayTextColor) + .font(.callout) + .fontWeight(.medium) + .padding([.top, .bottom], 10) + .padding([.leading, .trailing], 12) + .background(follow_state == .unfollows ? PinkGradient : GrayGradient) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(grayBorder, lineWidth: follow_state == .unfollows ? 0 : 1) + ) + } + .onReceive(handle_notify(.followed)) { notif in + let pk = notif.object as? ReferencedId + if pk?.ref_id != target.pubkey { + return + } + + self.follow_state = .follows + } + .onReceive(handle_notify(.unfollowed)) { notif in + let pk = notif.object as? ReferencedId + if pk?.ref_id != target.pubkey { + return + } + + self.follow_state = .unfollows + } + } +} + +struct GradientFollowButtonPreviews: View { + let target: FollowTarget = .pubkey("") + var body: some View { + VStack { + Text(verbatim: "Unfollows") + GradientFollowButton(target: target, follows_you: false, follow_state: .unfollows) + + Text(verbatim: "Following") + GradientFollowButton(target: target, follows_you: false, follow_state: .following) + + Text(verbatim: "Follows") + GradientFollowButton(target: target, follows_you: false, follow_state: .follows) + + Text(verbatim: "Follows") + GradientFollowButton(target: target, follows_you: true, follow_state: .follows) + + Text(verbatim: "Unfollowing") + GradientFollowButton(target: target, follows_you: false, follow_state: .unfollowing) + } + } +} + +struct GradientButton_Previews: PreviewProvider { + static var previews: some View { + GradientFollowButtonPreviews() + } +} diff --git a/damus/Views/Onboarding/SuggestedUserView.swift b/damus/Views/Onboarding/SuggestedUserView.swift @@ -0,0 +1,72 @@ +// +// SuggestedUserView.swift +// damus +// +// Created by klabo on 7/18/23. +// + +import SwiftUI + +struct SuggestedUser: Codable { + let pubkey: String + let name: String + let about: String + let pfp: URL + let profile: Profile + + init?(profile: Profile, pubkey: String) { + + guard let name = profile.name, + let about = profile.about, + let picture = profile.picture, + let pfpURL = URL(string: picture) else { + return nil + } + + self.pubkey = pubkey + self.name = name + self.about = about + self.pfp = pfpURL + self.profile = profile + } +} + +struct SuggestedUserView: View { + + let user: SuggestedUser + let damus_state: DamusState + + var body: some View { + HStack { + let target = FollowTarget.pubkey(user.pubkey) + InnerProfilePicView(url: user.pfp, + fallbackUrl: nil, + pubkey: target.pubkey, + size: 50, + highlight: .none, + disable_animation: false) + VStack(alignment: .leading, spacing: 4) { + HStack { + ProfileName(pubkey: user.pubkey, profile: user.profile, damus: damus_state) + } + Text(user.about) + .lineLimit(3) + .foregroundColor(.gray) + .font(.caption) + } + Spacer() + GradientFollowButton(target: target, follows_you: false, follow_state: damus_state.contacts.follow_state(target.pubkey)) + } + } +} + +struct SuggestedUserView_Previews: PreviewProvider { + static var previews: some View { + let profile = Profile(name: "klabo", about: "A person who likes nostr a lot and I like to tell people about myself in very long-winded ways that push the limits of UI and almost break things", picture: "https://primal.b-cdn.net/media-cache?s=m&a=1&u=https%3A%2F%2Fpbs.twimg.com%2Fprofile_images%2F1599994711430742017%2F33zLk9Wi_400x400.jpg") + + let user = SuggestedUser(profile: profile, pubkey: "abcd")! + List { + SuggestedUserView(user: user, damus_state: test_damus_state()) + } + } +} diff --git a/damus/Views/Onboarding/SuggestedUsersView.swift b/damus/Views/Onboarding/SuggestedUsersView.swift @@ -0,0 +1,77 @@ +// +// SuggestedUsersView.swift +// damus +// +// Created by klabo on 7/17/23. +// + +import SwiftUI + +struct SuggestedUsersView: View { + + @StateObject var model: SuggestedUsersViewModel + + @Environment(\.presentationMode) private var presentationMode + + var body: some View { + NavigationView { + VStack { + List { + ForEach(model.groups) { group in + Section { + ForEach(group.users, id: \.self) { pk in + if let user = model.suggestedUser(pubkey: pk) { + SuggestedUserView(user: user, damus_state: model.damus_state) + } + } + } header: { + SuggestedUsersSectionHeader(group: group, model: model) + } + } + } + .listStyle(.plain) + + Spacer() + + Button(action: { + presentationMode.wrappedValue.dismiss() + }) { + Text(NSLocalizedString("Continue", comment: "Button to dismiss suggested users view and continue to the main app")) + .frame(minWidth: 300, maxWidth: .infinity, alignment: .center) + } + .buttonStyle(GradientButtonStyle()) + .padding([.leading, .trailing], 24) + .padding(.bottom, 16) + } + .navigationTitle(NSLocalizedString("Who to Follow", comment: "Title for a screen displaying suggestions of who to follow")) + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(trailing: Button(action: { + presentationMode.wrappedValue.dismiss() + }, label: { + Text(NSLocalizedString("Skip", comment: "Button to dismiss the suggested users screen")) + .font(.subheadline.weight(.semibold)) + })) + } + } +} + +struct SuggestedUsersSectionHeader: View { + let group: SuggestedUserGroup + let model: SuggestedUsersViewModel + var body: some View { + HStack { + Text(group.title.uppercased()) + Spacer() + Button(NSLocalizedString("Follow All", comment: "Button to follow all users in this section")) { + model.follow(pubkeys: group.users) + } + .font(.subheadline.weight(.semibold)) + } + } +} + +struct SuggestedUsersView_Previews: PreviewProvider { + static var previews: some View { + SuggestedUsersView(model: SuggestedUsersViewModel(damus_state: test_damus_state())) + } +} diff --git a/damus/Views/Onboarding/SuggestedUsersViewModel.swift b/damus/Views/Onboarding/SuggestedUsersViewModel.swift @@ -0,0 +1,108 @@ +// +// SuggestedUsersViewModel.swift +// damus +// +// Created by klabo on 7/17/23. +// + +import Foundation +import Combine + +struct SuggestedUserGroup: Identifiable, Codable { + let id = UUID() + let title: String + let users: [String] + + enum CodingKeys: String, CodingKey { + case title, users + } +} + + +class SuggestedUsersViewModel: ObservableObject { + + public let damus_state: DamusState + + @Published var groups: [SuggestedUserGroup] = [] + + private let sub_id = UUID().uuidString + + init(damus_state: DamusState) { + self.damus_state = damus_state + loadSuggestedUserGroups() + let pubkeys = getPubkeys(groups: groups) + subscribeToSuggestedProfiles(pubkeys: pubkeys) + } + + func suggestedUser(pubkey: String) -> SuggestedUser? { + if let profile = damus_state.profiles.lookup(id: pubkey), + let user = SuggestedUser(profile: profile, pubkey: pubkey) { + return user + } + return nil + } + + func follow(pubkeys: [String]) { + for pubkey in pubkeys { + notify(.follow, FollowTarget.pubkey(pubkey)) + } + } + + private func loadSuggestedUserGroups() { + guard let url = Bundle.main.url(forResource: "suggested_users", withExtension: "json") else { + return + } + + guard let data = try? Data(contentsOf: url) else { + return + } + + let decoder = JSONDecoder() + do { + let groups = try decoder.decode([SuggestedUserGroup].self, from: data) + self.groups = groups + } catch { + print(error.localizedDescription.localizedLowercase) + } + } + + private func getPubkeys(groups: [SuggestedUserGroup]) -> [String] { + var pubkeys: [String] = [] + for group in groups { + pubkeys.append(contentsOf: group.users) + } + return pubkeys + } + + private func subscribeToSuggestedProfiles(pubkeys: [String]) { + let filter = NostrFilter(kinds: [.metadata], + authors: pubkeys) + damus_state.pool.subscribe(sub_id: sub_id, filters: [filter], handler: handle_event) + } + + func handle_event(relay_id: String, ev: NostrConnectionEvent) { + guard case .nostr_event(let nev) = ev else { + return + } + + switch nev { + case .event(let sub_id, let ev): + guard sub_id == self.sub_id else { + return + } + + if ev.known_kind == .metadata { + process_metadata_event(events: damus_state.events, our_pubkey: damus_state.pubkey, profiles: damus_state.profiles, ev: ev) + } + + case .notice(let msg): + print("suggested user profiles notice: \(msg)") + + case .eose: + self.objectWillChange.send() + + case .ok: + break + } + } +} diff --git a/damus/Views/Onboarding/suggested_users.json b/damus/Views/Onboarding/suggested_users.json @@ -0,0 +1,80 @@ +[ + { + "title": "nostr", + "users": [ + "ba2f394833658475e91680b898f9be0f1d850166c6a839dbe084d0266ad6e20a", + "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", + "b708f7392f588406212c3882e7b3bc0d9b08d62f95fa170d099127ece2770e5e" + ] + }, + { + "title": "permaculture & livestock & gardening", + "users": [ + "4b1804c90d59dff195ee0e8f692b98a7c762bf1793b3e126c546d730dcb04477", + "2c24e1af571fb4ccfeca3981649c1b09c695cd83b129709eb3b41c7ad2854899", + "296842eaaed9be5ae0668da09fe48aac0521c4af859ad547d93145e5ac34c17e", + "2c24e1af571fb4ccfeca3981649c1b09c695cd83b129709eb3b41c7ad2854899" + ] + }, + { + "title": "music", + "users": [ + "23708a76e7090cb108d33e8801fd36262c475e5499b23eb87eee4a31f4f0144e", + "ca9d68eb25620fc755e1b8c76b5f155f4c7e96d99c532c109a8b36d208bdce55" + ] + }, + { + "title": "books", + "users": [ + "2652f3af10de63bc10a2628871a3fce62e08655e4fcf90a58be16f246bb65da3", + "b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450" + ] + }, + { + "title": "art & photography", + "users": [ + "f96c3d76497074c4c83a7b3823380e77dc73d5a9494fd2e053e4a1453e17824b", + "11b2d93b26d7e56fb57f0afce0d33bfa7fb35b913e4c0aeb7706464befb9ca97", + "f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0", + "af146f51634a5fb0abc592fbc2bed42cf740700990344a766a8999fe55eed1c6", + "8816e8938fe60a3a925433c77410f39500fe1320787acb2165f9345e70464592", + "8d0638d3e8d9b23e337fb4017bb7d5a97def40bdc258e8121ee7d51e623ca065", + "ece8ed2111f73b92251e74a3da22dee1d6c6d9b497040acc68a37ac6bbf5a105", + "64bfa9abffe5b18d0731eed57b38173adc2ba89bf87c168da90517f021e722b5", + "546bffcb9317edf919828de272dd8eba11f2aadc07969cba6c9bb894c07712ef", + "20998a8d433181cc5e778903db528d162f5579918a3437be40fdba0130b3469c", + "37c962bfb34d32e667d5075f7d55f3a7ca9bcddbafeff59a4003c276ad147352", + "387fa328198e67a400caf00947cc91e0c166d24d71d8b4b78a6c3ef91ff4d058", + "87deef9034c52d87ae327af3a76358e58e7ee2e03d80c738ee1a2be399c23e79" + ] + }, + { + "title": "ai art", + "users": [ + "431fa2f340f0adf8963d6d7c6e2c20d913278c691fe609fd3857db13d8f39feb", + "9936a53def39d712f886ac7e2ed509ce223b534834dd29d95caba9f6bc01ef35", + "1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", + "693c2832de939b4af8ccd842b17f05df2edd551e59989d3c4ef9a44957b2f1fb", + "55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185" + ] + }, + { + "title": "parenting", + "users": [ + "c7c8f645fd45b09055fb6c26d148737ad7ed12ddecde0d4c877b88f8d4196865", + "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", + "e451c4e82e272ef912a04f490373fce9054a044b11a2aa847823b3686c8eba77", + "261c7e18545aee4eb55e8052297fd93bd886c2f96b10d599cfdad4f3477b87ab", + "22a193fb86c793d7f73348126a5fe55ab8a4c975805ebc2ff81bf290f7805f7e" + ] + }, + { + "title": "food", + "users": [ + "cbb2f023b6aa09626d51d2f4ea99fa9138ea80ec7d5ffdce9feef8dcd6352031" + ] + } +] + +