Integrate follow packs into onboarding suggestions

Closes: https://github.com/damus-io/damus/issues/3007
Changelog-Added: Added new onboarding suggestions based on user-selected interests
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2025-07-04 09:58:10 -07:00
parent b8bf5df7bc
commit eeea9d3266
20 changed files with 1187 additions and 206 deletions
+14 -1
View File
@@ -334,7 +334,20 @@ struct ContentView: View {
.presentationDetents([.height(550)])
.presentationDragIndicator(.visible)
case .onboardingSuggestions:
OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: damus_state!))
if let model = try? SuggestedUsersViewModel(damus_state: damus_state!) {
OnboardingSuggestionsView(model: model)
.interactiveDismissDisabled(true)
}
else {
ErrorView(
damus_state: damus_state,
error: .init(
user_visible_description: NSLocalizedString("Unexpected error loading user suggestions", comment: "Human readable error label"),
tip: NSLocalizedString("Please contact support", comment: "Human readable error tip"),
technical_info: "Error inializing SuggestedUsersViewModel"
)
)
}
case .purple(let purple_url):
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
case .purple_onboarding:
+77
View File
@@ -0,0 +1,77 @@
//
// Interests.swift
// damus
//
// Created by Daniel DAquino on 2025-06-25.
//
import Foundation
struct DIP06 {
/// Standard general interest topics.
/// See https://github.com/damus-io/dips/pull/3
enum Interest: String, CaseIterable {
/// Bitcoin-related topics (e.g. Bitcoin, Lightning, e-cash etc)
case bitcoin = "bitcoin"
/// Any non-Bitcoin technology-related topic (e.g. Linux, new releases, software development, supersonic flight, etc)
case technology = "technology"
/// Any science-related topic (e.g. astronomy, biology, physics, etc)
case science = "science"
/// Lifestyle topics (e.g. Worldschooling, Digital nomading, vagabonding, homesteading, digital minimalism, life hacks, etc)
case lifestyle = "lifestyle"
/// Travel-related topics (e.g. Information about locations to visit, travel logs, etc)
case travel = "travel"
/// Any art-related topic (e.g. poetry, painting, sculpting, photography, etc)
case art = "art"
/// Topics focused on improving human health (e.g. advances in medicine, exercising, nutrition, meditation, sleep, etc)
case health = "health"
/// Any music-related topic (e.g. Bands, fan pages, instruments, classical music theory, etc)
case music = "music"
/// Any topic related to food (e.g. Cooking, recipes, meal planning, nutrition)
case food = "food"
/// Any topic related to sports (e.g. Athlete fan pages, general sports information, sports news, sports equipment, etc)
case sports = "sports"
/// Any topic related to religion, spirituality, or faith (e.g. Christianity, Judaism, Buddhism, Islamism, Hinduism, Taoism, general meditation practice, etc)
case religionSpirituality = "religion-spirituality"
/// General humanities topics (e.g. philosophy, sociology, culture, etc)
case humanities = "humanities"
/// General topics about politics
case politics = "politics"
/// Other miscellaneous topics that do not fit in any of the previous items of the list
case other = "other"
var label: String {
switch self {
case .bitcoin:
return NSLocalizedString("₿ Bitcoin", comment: "Interest topic label")
case .technology:
return NSLocalizedString("💻 Tech", comment: "Interest topic label")
case .science:
return NSLocalizedString("🔭 Science", comment: "Interest topic label")
case .lifestyle:
return NSLocalizedString("🏝️ Lifestyle", comment: "Interest topic label")
case .travel:
return NSLocalizedString("✈️ Travel", comment: "Interest topic label")
case .art:
return NSLocalizedString("🎨 Art", comment: "Interest topic label")
case .health:
return NSLocalizedString("🏃 Health", comment: "Interest topic label")
case .music:
return NSLocalizedString("🎶 Music", comment: "Interest topic label")
case .food:
return NSLocalizedString("🍱 Food", comment: "Interest topic label")
case .sports:
return NSLocalizedString("⚾️ Sports", comment: "Interest topic label")
case .religionSpirituality:
return NSLocalizedString("🛐 Religion", comment: "Interest topic label")
case .humanities:
return NSLocalizedString("📚 Humanities", comment: "Interest topic label")
case .politics:
return NSLocalizedString("🏛️ Politics", comment: "Interest topic label")
case .other:
return NSLocalizedString("♾️ Other", comment: "Interest topic label")
}
}
}
}
+6 -1
View File
@@ -8,13 +8,14 @@
import Foundation
struct FollowPackEvent {
struct FollowPackEvent: Hashable {
let event: NostrEvent
var title: String? = nil
var uuid: String? = nil
var image: URL? = nil
var description: String? = nil
var publicKeys: [Pubkey] = []
var interests: Set<DIP06.Interest> = []
static func parse(from ev: NostrEvent) -> FollowPackEvent {
@@ -29,6 +30,10 @@ struct FollowPackEvent {
case "description": followlist.description = tag[1].string()
case "p":
followlist.publicKeys.append(Pubkey(Data(hex: tag[1].string())))
case "t":
if let interest = DIP06.Interest(rawValue: tag[1].string()) {
followlist.interests.insert(interest)
}
default:
break
}
+2
View File
@@ -229,6 +229,8 @@ class HomeModel: ContactsDelegate {
break // This will be handled by `UserRelayListManager`
case .follow_list:
break
case .interest_list:
break // Don't care for now
}
}
+3
View File
@@ -137,6 +137,9 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "hide_nsfw_tagged_content", default_value: false)
var hide_nsfw_tagged_content: Bool
@Setting(key: "reduce_bitcoin_content", default_value: false)
var reduce_bitcoin_content: Bool
@Setting(key: "show_profile_action_sheet_on_pfp_click", default_value: true)
var show_profile_action_sheet_on_pfp_click: Bool
+111
View File
@@ -0,0 +1,111 @@
//
// InterestList.swift
// damus
//
// Created by Daniel D'Aquino on 2025-06-23.
//
// Some text excerpts taken from the Nostr Protocol itself (which are public domain)
import Foundation
/// Includes models and functions for working with NIP-51
struct NIP51: Sendable {}
extension NIP51 {
/// An error thrown when decoding an item into a NIP-51 list
enum NIP51DecodingError: Error {
/// The Nostr event being converted is not a NIP-51 interest list
case notInterestList
}
}
extension NIP51 {
/// Models a NIP-51 Interest List (kind:10015)
struct InterestList: NostrEventConvertible, Sendable {
typealias E = NIP51DecodingError
enum InterestItem: Sendable, Hashable {
case hashtag(String)
case interestSet(String, String, String) // a-tag: kind, pubkey, identifier
var tag: [String] {
switch self {
case .hashtag(let tag):
return ["t", tag]
case .interestSet(let kind, let pubkey, let identifier):
var tag = ["a", "\(kind):\(pubkey):\(identifier)"]
return tag
}
}
static func fromTag(tag: TagSequence) -> InterestItem? {
var i = tag.makeIterator()
guard let t0 = i.next(),
let t1 = i.next() else { return nil }
let tagName = t0.string()
if tagName == "t" {
return .hashtag(t1.string())
} else if tagName == "a" {
let components = t1.string().split(separator: ":")
guard components.count > 2 else { return nil }
let kind = String(components[0])
let pubkey = String(components[1])
let identifier = String(components[2])
return .interestSet(kind, pubkey, identifier)
}
return nil
}
}
let interests: [InterestItem]
// MARK: - Initialization
init(event: NdbNote) throws(E) {
try self.init(event: UnownedNdbNote(event))
}
init(event: borrowing UnownedNdbNote) throws(E) {
guard event.known_kind == .interest_list else {
throw E.notInterestList
}
var interests: [InterestItem] = []
for tag in event.tags {
if let interest = InterestItem.fromTag(tag: tag) {
interests.append(interest)
}
}
self.interests = interests
}
init?(event: NdbNote?) throws(E) {
guard let event else { return nil }
try self.init(event: event)
}
init(interests: [InterestItem]) {
self.interests = interests
}
// MARK: - Conversion to a Nostr Event
func toNostrEvent(keypair: FullKeypair, timestamp: UInt32? = nil) -> NostrEvent? {
return NdbNote(
content: "",
keypair: keypair.to_keypair(),
kind: NostrKind.interest_list.rawValue,
tags: self.interests.map { $0.tag },
createdAt: timestamp ?? UInt32(Date.now.timeIntervalSince1970)
)
}
}
}
+1
View File
@@ -20,6 +20,7 @@ enum NostrKind: UInt32, Codable {
case chat = 42
case mute_list = 10000
case relay_list = 10002
case interest_list = 10015
case list_deprecated = 30000
case draft = 31234
case longform = 30023
-1
View File
@@ -45,4 +45,3 @@ struct Pubkey: IdType, TagKey, TagConvertible, Identifiable {
}
}
+3
View File
@@ -18,6 +18,9 @@ class Constants {
static let MAIN_APP_BUNDLE_IDENTIFIER: String = "com.jb55.damus2"
static let NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER: String = "com.jb55.damus2.DamusNotificationService"
// MARK: Curation
static let ONBOARDING_FOLLOW_PACK_CURATOR_PUBKEY: Pubkey = Pubkey(hex: "895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba")!
// MARK: Push notification server
static let PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL: URL = URL(string: "https://notify.damus.io")!
static let PUSH_NOTIFICATION_SERVER_STAGING_BASE_URL: URL = URL(string: "https://notify-staging.damus.io")!
+1 -1
View File
@@ -74,7 +74,7 @@ class LoadableNostrEventViewModel: ObservableObject {
case .zap, .zap_request:
guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found }
return .loaded(route: Route.Zaps(target: zap.target))
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list, .follow_list:
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list, .follow_list, .interest_list:
return .unknown_or_unsupported_kind
}
case .naddr(let naddr):
@@ -0,0 +1,121 @@
//
// InterestSelectionView.swift
// damus
//
// Created by Daniel DAquino on 2025-05-16.
//
import SwiftUI
extension OnboardingSuggestionsView {
typealias Interest = DIP06.Interest
struct InterestSelectionView: View {
var damus_state: DamusState
var next_page: (() -> Void)
/// Track selected interests using a Set
@Binding var selectedInterests: Set<Interest>
var isNextEnabled: Bool
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Title
Text(NSLocalizedString("Select Your Interests", comment: "Screen title for interest selection"))
.font(.largeTitle)
.fontWeight(.bold)
.multilineTextAlignment(.center)
.padding(.top)
// Instruction subtitle
Text(NSLocalizedString("Please pick your interests. This will help us recommend accounts to follow.", comment: "Instruction for interest selection"))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
// Interests grid view
InterestsGridView(availableInterests: Interest.allCases,
selectedInterests: $selectedInterests)
.padding()
Spacer()
// Next button wrapped inside a NavigationLink for easy transition.
Button(action: {
self.next_page()
}, label: {
Text(NSLocalizedString("Next", comment: "Next button title"))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
})
.buttonStyle(GradientButtonStyle())
.disabled(!isNextEnabled)
.opacity(isNextEnabled ? 1.0 : 0.5)
.padding([.leading, .trailing, .bottom])
}
.padding()
}
}
}
/// A grid view to display interest options
struct InterestsGridView: View {
let availableInterests: [Interest]
@Binding var selectedInterests: Set<Interest>
// Adaptive grid layout with two columns
private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 480)),
GridItem(.adaptive(minimum: 120, maximum: 480)),
]
var body: some View {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(availableInterests, id: \ .self) { interest in
let disabled = false
InterestButton(interest: interest,
isSelected: selectedInterests.contains(interest)) {
// Toggle selection
if selectedInterests.contains(interest) {
selectedInterests.remove(interest)
} else {
selectedInterests.insert(interest)
}
}
.disabled(disabled)
.opacity(disabled ? 0.5 : 1.0)
}
}
}
}
/// A button view representing a single interest option
struct InterestButton: View {
let interest: Interest
let isSelected: Bool
var action: () -> Void
var body: some View {
Button(action: action) {
Text(interest.label)
.font(.body)
.padding(.vertical, 8)
.padding(.horizontal, 10)
.frame(maxWidth: .infinity)
.background(isSelected ? Color.accentColor : Color.gray.opacity(0.2))
.foregroundColor(isSelected ? Color.white : Color.primary)
.cornerRadius(50)
}
}
}
}
struct InterestSelectionView_Previews: PreviewProvider {
static var previews: some View {
OnboardingSuggestionsView.InterestSelectionView(
damus_state: test_damus_state,
next_page: { print("next") },
selectedInterests: Binding.constant(Set([DIP06.Interest.art, DIP06.Interest.music])), isNextEnabled: true
)
}
}
@@ -0,0 +1,82 @@
//
// OnboardingContentSettings.swift
// damus
//
// Created by Daniel DAquino on 2025-05-19.
//
import SwiftUI
extension OnboardingSuggestionsView {
struct OnboardingContentSettings: View {
var model: SuggestedUsersViewModel
var next_page: (() -> Void)
@ObservedObject var settings: UserSettingsStore
@Binding var selectedInterests: Set<Interest>
private var isNextEnabled: Bool { true }
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Title
Text(NSLocalizedString("Other preferences", comment: "Screen title for content preferences screen during onboarding"))
.font(.largeTitle)
.fontWeight(.bold)
.multilineTextAlignment(.center)
.padding(.top)
// Instruction subtitle
Text(NSLocalizedString("Tweak these settings to better match your preferences", comment: "Instructions for content preferences screen during onboarding"))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
// Content preferences section with toggles
Section() {
VStack(alignment: .leading, spacing: 5) {
Toggle(NSLocalizedString("Hide notes with #nsfw tags", comment: "Setting to hide notes with not safe for work tags"), isOn: $settings.hide_nsfw_tagged_content)
.toggleStyle(.switch)
Text(NSLocalizedString("Notes with the #nsfw tag usually contains adult content or other \"Not safe for work\" content", comment: "Explanation of what NSFW means"))
.font(.caption)
.foregroundColor(.secondary)
.padding(.bottom, 10)
if !selectedInterests.contains(.bitcoin) {
Toggle(
NSLocalizedString("Show Bitcoin-heavy profile suggestions", comment: "Setting label during onboarding"),
isOn: Binding(get: { !model.reduceBitcoinContent }, set: { model.reduceBitcoinContent = !$0 })
)
.toggleStyle(.switch)
Text(NSLocalizedString("Some profiles tend to have a lot of Bitcoin-related content alongside their topics of interest. Disable this setting if you prefer to filter out follow suggestions that frequently talk about Bitcoin.", comment: "Explanation label for the 'Show Bitcoin-heavy profile suggestions' onboarding toggle setting"))
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(Color.secondary.opacity(0.1))
.cornerRadius(10)
}
.padding()
Spacer()
Button(action: {
self.next_page()
}, label: {
Text(NSLocalizedString("Next", comment: "Next button title"))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
})
.buttonStyle(GradientButtonStyle())
.disabled(!isNextEnabled)
.opacity(isNextEnabled ? 1.0 : 0.5)
.padding([.leading, .trailing, .bottom])
}
.padding()
}
}
}
}
@@ -26,49 +26,103 @@ struct OnboardingSuggestionsView: View {
current_page += 1
}
}
private var canLeaveInterestSelectionPage: Bool {
let count = model.interests.count
return count > 0
}
/// Save the user's selected interests to NDB
private func saveInterestsToNdb() {
// Convert the selected interests to hashtags for the NIP51 interest list
let interestItems = model.interests.map { interest in
NIP51.InterestList.InterestItem.hashtag(interest.rawValue)
}
// Create the interest list
let interestList = NIP51.InterestList(interests: Array(interestItems))
// Convert to a NostrEvent and send to NDB
guard let keypair = model.damus_state.keypair.to_full(),
let event = interestList.toNostrEvent(keypair: keypair, timestamp: nil) else {
return // Not a big deal, fail silently
}
// Send the event to NostrDB to allow us to retrieve later
// Did not send this to the network yet because:
// 1. I believe we should add an opt-out/opt-in button.
// 2. If we do, and the user accepts to share it, it will be an awkward situation considering:
// - We don't show that anywhere else yet
// - We don't have other mechanisms to allow the user to edit this yet
//
// Therefore, it is better to just save it locally, and retrieve this once we build out https://github.com/damus-io/damus/issues/3042
model.damus_state.nostrNetwork.pool.send_raw_to_local_ndb(.typical(.event(event)))
}
var body: some View {
NavigationView {
TabView(selection: $current_page) {
SuggestedUsersPageView(model: model, next_page: self.next_page)
.navigationTitle(NSLocalizedString("Who to Follow", comment: "Title for a screen displaying suggestions of who to follow"))
InterestSelectionView(damus_state: model.damus_state, next_page: {
self.next_page()
}, selectedInterests: $model.interests, isNextEnabled: canLeaveInterestSelectionPage)
.navigationTitle(NSLocalizedString("Select your interests", comment: "Title for a screen asking the user for interests"))
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: Button(action: {
self.next_page()
}, label: {
Text("Skip", comment: "Button to dismiss the suggested users screen")
.font(.subheadline.weight(.semibold))
})
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_sheet_skip_button.rawValue)
)
.tag(0)
PostView(
action: .posting(.user(model.damus_state.pubkey)),
damus_state: model.damus_state,
prompt_view: {
AnyView(
HStack {
Image(systemName: "sparkles")
Text("Add your first post", comment: "Prompt given to the user during onboarding, suggesting them to write their first post")
}
.foregroundColor(.secondary)
.font(.callout)
.padding(.top, 10)
if canLeaveInterestSelectionPage {
OnboardingContentSettings(model: model, next_page: self.next_page, settings: model.damus_state.settings, selectedInterests: $model.interests)
.navigationTitle(NSLocalizedString("Content settings", comment: "Title for an onboarding screen showing user some content settings"))
.navigationBarTitleDisplayMode(.inline)
.tag(1)
SuggestedUsersPageView(model: model, next_page: self.next_page)
.navigationTitle(NSLocalizedString("Who to Follow", comment: "Title for a screen displaying suggestions of who to follow"))
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: Button(action: {
self.next_page()
}, label: {
Text("Skip", comment: "Button to dismiss the suggested users screen")
.font(.subheadline.weight(.semibold))
})
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_sheet_skip_button.rawValue)
)
},
placeholder_messages: self.first_post_examples,
initial_text_suffix: self.initial_text_suffix
)
.onReceive(handle_notify(.post)) { _ in
// NOTE: Even though PostView already calls `dismiss`, that is not guaranteed to work under deeply nested views.
// Thus, we should also call `dismiss` from here (a direct subview of a sheet), which is explicitly supported by Apple.
// See https://github.com/damus-io/damus/issues/1726 for more context and information
dismiss()
.tag(2)
PostView(
action: .posting(.user(model.damus_state.pubkey)),
damus_state: model.damus_state,
prompt_view: {
AnyView(
HStack {
Image(systemName: "sparkles")
Text("Add your first post", comment: "Prompt given to the user during onboarding, suggesting them to write their first post")
}
.foregroundColor(.secondary)
.font(.callout)
.padding(.top, 10)
)
},
placeholder_messages: self.first_post_examples,
initial_text_suffix: self.initial_text_suffix
)
.onReceive(handle_notify(.post)) { _ in
// NOTE: Even though PostView already calls `dismiss`, that is not guaranteed to work under deeply nested views.
// Thus, we should also call `dismiss` from here (a direct subview of a sheet), which is explicitly supported by Apple.
// See https://github.com/damus-io/damus/issues/1726 for more context and information
dismiss()
}
.tag(3)
}
.tag(1)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: current_page) { newPage in
// If the user just swiped from the interests page (0) to the next page (1),
// save their interests to NDB
if newPage == 1 && current_page == 1 {
saveInterestsToNdb()
}
}
}
}
}
@@ -79,20 +133,27 @@ fileprivate struct SuggestedUsersPageView: View {
var body: some View {
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)
if let suggestions = model.suggestions {
List {
ForEach(suggestions, id: \.self) { followPack in
Section {
ForEach(followPack.publicKeys, id: \.self) { pk in
if let usersInterests = model.interestUserMap[pk],
!usersInterests.intersection(model.interests).isEmpty && usersInterests.intersection(model.disinterests).isEmpty,
let user = model.suggestedUser(pubkey: pk) {
SuggestedUserView(user: user, damus_state: model.damus_state)
}
}
} header: {
SuggestedUsersSectionHeader(followPack: followPack, model: model)
}
} header: {
SuggestedUsersSectionHeader(group: group, model: model)
}
}
.listStyle(.plain)
}
else {
ProgressView()
}
.listStyle(.plain)
Spacer()
@@ -110,17 +171,14 @@ fileprivate struct SuggestedUsersPageView: View {
}
struct SuggestedUsersSectionHeader: View {
let group: SuggestedUserGroup
let followPack: FollowPackEvent
let model: SuggestedUsersViewModel
var body: some View {
HStack {
let locale = Locale.current
let format = localizedStringFormat(key: group.category, locale: locale)
let categoryName = String(format: format, locale: locale)
Text(categoryName)
Text(followPack.title ?? NSLocalizedString("Untitled Follow Pack", comment: "Default title for a follow pack if no title is specified"))
Spacer()
Button(NSLocalizedString("Follow All", comment: "Button to follow all users in this section")) {
model.follow(pubkeys: group.users)
model.follow(pubkeys: followPack.publicKeys)
}
.font(.subheadline.weight(.semibold))
}
@@ -129,6 +187,6 @@ struct SuggestedUsersSectionHeader: View {
struct SuggestedUsersView_Previews: PreviewProvider {
static var previews: some View {
OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: test_damus_state))
OnboardingSuggestionsView(model: try! SuggestedUsersViewModel(damus_state: test_damus_state))
}
}
@@ -8,32 +8,76 @@
import Foundation
import Combine
struct SuggestedUserGroup: Identifiable, Codable {
let id = UUID()
let category: String
let users: [Pubkey]
enum CodingKeys: String, CodingKey {
case category, users
}
}
/// This model does the following:
///
/// - It loads follow packs (From the network, with a local copy fallback), and related profiles
/// - It tracks the interests and disinterests as selected by the user via an interface
/// - It computes publishes suggestions for users based on selected interests
@MainActor
class SuggestedUsersViewModel: ObservableObject {
/// The Damus State
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)
/// Keeps all the suggested follow packs available. For internal use only.
private var allSuggestions: [FollowPackEvent]? = nil {
didSet { self.recomputeSuggestions() }
}
/// The user-selected topics of interests
@Published var interests: Set<Interest> = [] {
didSet {
self.recomputeSuggestions()
if interests.contains(.bitcoin) {
// Ensures there are no setting contradictions if user goes back and forth on onboarding
reduceBitcoinContent = false
}
}
}
/// A user preference that allows users to reduce bitcoin content
@Published var reduceBitcoinContent: Bool {
didSet {
self.recomputeDisinterests()
damus_state.settings.reduce_bitcoin_content = reduceBitcoinContent
}
}
@Published private(set) var disinterests: Set<Interest> = [] {
didSet { self.recomputeSuggestions() }
}
/// Keeps the suggested follow packs to the user.
///
/// ## Implementation notes
///
/// This is technically meant to be a computed property (see `recomputeSuggestions`),
/// but we also want views that display this to be automatically updated,
/// so therefore we use `@Published` instead, and add property write observers on its logical dependencies
@Published private(set) var suggestions: [FollowPackEvent]? = nil
/// A map of suggested pubkeys and the particular interest categories they belong to
private(set) var interestUserMap: [Pubkey: Set<Interest>] = [:]
// MARK: - Helper types
typealias FollowPackID = String
typealias Interest = DIP06.Interest
// MARK: - Initialization
init(damus_state: DamusState) throws {
self.damus_state = damus_state
self.reduceBitcoinContent = damus_state.settings.reduce_bitcoin_content
self.recomputeAll()
Task.detached {
await self.loadSuggestedFollowPacks()
}
}
// MARK: - External interface methods
/// Gets suggested user information from a provided pubkey
func suggestedUser(pubkey: Pubkey) -> SuggestedUser? {
let profile_txn = damus_state.profiles.lookup(id: pubkey)
if let profile = profile_txn?.unsafeUnownedValue,
@@ -43,63 +87,154 @@ class SuggestedUsersViewModel: ObservableObject {
return nil
}
/// Allows the user to follow a list of other users
func follow(pubkeys: [Pubkey]) {
for pubkey in pubkeys {
notify(.follow(.pubkey(pubkey)))
}
}
private func loadSuggestedUserGroups() {
guard let url = Bundle.main.url(forResource: "suggested_users", withExtension: "json") else {
return
// MARK: - Internal state management logic
/// State management function that recomputes all "computed" properties
///
/// This helps ensure views get instant updates everytime the suggestions are supposed to be updated.
private func recomputeAll() {
self.recomputeDisinterests()
self.recomputeSuggestions()
}
/// State management function that recomputes `disinterests` based its logical dependencies
///
/// This helps ensure views get instant updates everytime the suggestions are supposed to be updated.
private func recomputeDisinterests() {
self.disinterests = reduceBitcoinContent ? Set([.bitcoin]) : []
}
/// State management function that recomputes `suggestions` based its logical dependencies
///
/// This helps ensure views get instant updates everytime the suggestions are supposed to be updated.
private func recomputeSuggestions() {
self.suggestions = Self.computeSuggestions(basedOn: allSuggestions, interests: interests, disinterests: disinterests)
}
/// Purely functional function that computes suggestions based on the ones available, and the user's interest selections
private static func computeSuggestions(basedOn allSuggestions: [FollowPackEvent]?, interests: Set<Interest>, disinterests: Set<Interest>) -> [FollowPackEvent]? {
guard let allSuggestions else { return nil }
return allSuggestions.filter({ suggestion in
return !suggestion.interests.intersection(interests).isEmpty && suggestion.interests.intersection(disinterests).isEmpty
})
}
// MARK: - Internal loading logic
/// Loads suggestions
///
/// (This is the main loading function that kicks-off the others)
///
/// ## Usage notes
///
/// - Long running task, preferably use this as a detached task
private func loadSuggestedFollowPacks() async {
// First, try preload events from the local file (To have a fallback in the case of an unstable internet connection)
var packsById = await self.loadLocalSuggestedFollowPacks()
// Then fetch the newest follow packs from the network and overwrite old ones where necessary
let subscriptionTask = Task {
await self.loadSuggestedFollowPacksFromNetwork(packsById: &packsById)
}
guard let data = try? Data(contentsOf: url) else {
return
// Wait for 5 seconds before timing out
try? await Task.sleep(nanoseconds: 5_000_000_000)
// Cancel the subscription task on timeout, to make sure we don't load forever
subscriptionTask.cancel()
// Finish loading and computing suggestions, as well as profile info
let allPacks = Array(packsById.values)
self.allSuggestions = allPacks
await self.loadProfiles(for: allPacks)
}
/// Load the local follow packs, to have a fallback in the case of network instability
///
/// ## Implementation notes
///
/// This might seem redundant, but onboarding is a crucial moment for users, so we need to make sure they are onboarded successfully.
private func loadLocalSuggestedFollowPacks() async -> [FollowPackID: FollowPackEvent] {
var packsById: [String: FollowPackEvent] = [:]
if let bundleURL = Bundle.main.url(forResource: "follow-packs", withExtension: "jsonl"),
let jsonlData = try? Data(contentsOf: bundleURL),
let jsonlString = String(data: jsonlData, encoding: .utf8) {
let lines = jsonlString.components(separatedBy: .newlines)
for line in lines where !line.isEmpty {
if let note = NdbNote.owned_from_json(json: line) {
let followPack = FollowPackEvent.parse(from: note)
if let id = followPack.uuid {
packsById[id] = followPack
}
}
}
}
return packsById
}
/// Loads the newest follow packs from the network, and overwrites the provided follow packs where appropriate
private func loadSuggestedFollowPacksFromNetwork(packsById: inout [FollowPackID: FollowPackEvent]) async {
let filter = NostrFilter(
kinds: [NostrKind.follow_list],
authors: [Constants.ONBOARDING_FOLLOW_PACK_CURATOR_PUBKEY]
)
for await item in self.damus_state.nostrNetwork.reader.subscribe(filters: [filter]) {
// Check for cancellation on each iteration
guard !Task.isCancelled else { break }
let decoder = JSONDecoder()
do {
let groups = try decoder.decode([SuggestedUserGroup].self, from: data)
self.groups = groups
} catch {
print(error.localizedDescription.localizedLowercase)
switch item {
case .event(let borrow):
try? borrow { event in
let followPack = FollowPackEvent.parse(from: event.toOwned())
guard let id = followPack.uuid else { return }
let latestPackForThisId: FollowPackEvent
if let existingPack = packsById[id], existingPack.event.created_at > followPack.event.created_at {
latestPackForThisId = existingPack
} else {
latestPackForThisId = followPack
}
packsById[id] = latestPackForThisId
}
case .eose:
break
}
}
}
private func getPubkeys(groups: [SuggestedUserGroup]) -> [Pubkey] {
var pubkeys: [Pubkey] = []
for group in groups {
pubkeys.append(contentsOf: group.users)
/// Finds all profiles mentioned in the follow packs, and loads the profile data from the network
private func loadProfiles(for packs: [FollowPackEvent]) async {
var allPubkeys: [Pubkey] = []
for followPack in packs {
for pubkey in followPack.publicKeys {
self.interestUserMap[pubkey] = Set(Array(self.interestUserMap[pubkey] ?? []) + Array(followPack.interests))
allPubkeys.append(pubkey)
}
}
return pubkeys
}
private func subscribeToSuggestedProfiles(pubkeys: [Pubkey]) {
let filter = NostrFilter(kinds: [.metadata], authors: pubkeys)
damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: [filter], handler: handle_event)
}
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
guard case .nostr_event(let nev) = ev else {
return
}
switch nev {
case .event:
break
case .notice(let msg):
print("suggested user profiles notice: \(msg)")
case .eose:
self.objectWillChange.send()
case .ok:
break
case .auth:
break
let profileFilter = NostrFilter(kinds: [.metadata], authors: allPubkeys)
for await item in damus_state.nostrNetwork.reader.subscribe(filters: [profileFilter]) {
switch item {
case .event(_):
continue // We just need NostrDB to ingest these for them to be available elsewhere, no need to analyze the data
case .eose:
break
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{"kind":39089,"id":"5d0bb7d7cfde8614d91180e534ef9e33c35006d81185d8065a36ab3d3fa079db","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1750724913,"tags":[["title","Bitcoin enthusiasts"],["description","Profiles that like to talk about Bitcoin-related topics."],["image","https://images.unsplash.com/photo-1623227413711-25ee4388dae3?q=80&w=2972&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["d","hzgji33wnyku"],["t","bitcoin"],["p","704c4773626bf0f7ebf99d861eb0eae473be2b004f91725f8a1750486c9c848f"],["p","5b3260bc06f8e96b64a8c02e37385125d85479d681bb0302652b7a817c307a5e"],["p","7b3f7803750746f455413a221f80965eecb69ef308f2ead1da89cc2c8912e968"],["p","58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196"],["p","7c765d407d3a9d5ea117cb8b8699628560787fc084a0c76afaa449bfbd121d84"],["p","692c6feb28a0bd93d67cc19840bb07bfe678f97bf15930b5d812676b4cd512ef"],["p","130dcd3a1963f7fa35b206c44be6bc6f4ea0f5ee531b26126cb989678d5cfff5"],["p","c1831fbe2653f76164421d57db6cee38b8cef8ce6771bc65c12f8543de4b39bf"],["p","4d023ce9dfd75a7f3075b8e8e084008be17a1f750c63b5de721e6ef883adc765"],["p","f783ba3b12b91e375aba6594015b90bd95f7e132b03cc8c4c52ce0a7c36aab52"],["p","e07bfc04ea87c72a9185b0891f055361e6492f259f81247683d6c9ccb2b651ed"],["p","500ccc532c301711d88aa6d30b11dc477e6a32770853f8ab1c2be389b824e3f8"],["p","6a359852238dc902aed19fbbf6a055f9abf21c1ca8915d1c4e27f50df2f290d9"],["p","1586fd57ac81b66177b0087bb0c0fa465f30b9895949c8936836ec5e6cd13132"],["p","31ff694f01417160459e59e2e283b5454b499d3885092c792ae0d6b2de7839f0"],["p","259b1ddc0d3137cad123d4af64f87ff9db3834a843843963e58e2a9ac956cf39"],["p","91c9a5e1a9744114c6fe2d61ae4de82629eaaa0fb52f48288093c7e7e036f832"],["p","a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f"],["p","b7b51cc25216d4c10bc85ae27055c9a945fe77cafd463cf23b20917e39ce6816"],["p","25a2192dcf34c3be326988b5c9f942aa96789899d15b59412602854a8723e9e8"],["p","1c197b12ca9ce0415b70e7405b9770f0ec6bccfb59b32b63aafd42cb242e1642"],["p","9c86d77537a6380ce371e3a4860bc7e1fb2adbb2821bf1a8f1cd4e8ce02240c9"],["p","b203872cefbd5572b5551dde5bdfc0387219d99fb7e8d1c8418145d432ca0390"],["p","97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"],["p","ba2f394833658475e91680b898f9be0f1d850166c6a839dbe084d0266ad6e20a"],["p","566c166f3adab0c8fba5da015b0b3bcc8eb3696b455f2a1d43bfbd97059646a8"],["p","c7617e84337c611c7d5f941b35b1ec51f2ae6e9f41aac9616092d510e1c295e0"],["p","e9986a10caaa96738ceda88aabd3e184307be5143e687457581f9b096c6ef89c"],["p","805b34f708837dfb3e7f05815ac5760564628b58d5a0ce839ccbb6ef3620fac3"],["p","d284b9523c99fe1acc9e759129bd9f17984a5aff413caf87018510a020a656fd"],["p","7644119b18ec56b5b2779e0d035e7712a9d669dbfbe5b2c5f458cb564afb6c95"],["p","5a8e581f16a012e24d2a640152ad562058cb065e1df28e907c1bfa82c150c8ba"],["p","0d6c8388dcb049b8dd4fc8d3d8c3bb93de3da90ba828e4f09c8ad0f346488a33"],["p","738f7873ac2c6cb7701e3150616afc824379b132b467ba5a8429d5964af1b136"],["p","37039b91a6ff36ff48189dfae4a1f6ea017992a61fb9485b47de54fe491f75bf"],["p","d36e8083fa7b36daee646cb8b3f99feaa3d89e5a396508741f003e21ac0b6bec"],["p","f4d89779148ccd245c8d50914a284fd62d97cb0fb68b797a70f24a172b522db9"],["p","32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],["p","b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450"],["p","40b9c85fffeafc1cadf8c30a4e5c88660ff6e4971a0dc723d5ab674b5e61b451"],["p","963a2dca29e2ed5663a627b5289ed36d445531a3f5ef127716227d8a1aaa5166"],["p","139137969c1c56bf8306cd71272f681ed54206be15eeceb9eb98956d2fd9f7ef"],["p","f1989a96d75aa386b4c871543626cbb362c03248b220dc9ae53d7cefbcaaf2c1"],["p","76e36f2fabfbd8c353ffcda8fe07877a0977ee4aa9d9bcc224f02038d73b3787"],["p","e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb"],["p","4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"],["p","5e7e69f863d20ca30103df88887194e091679491fefe5eb8212753013cc3d552"],["p","ce9cfafcdc8425dc90c7045f263f966d077dbde96940b7fab4c9403998c5b49b"],["p","75da94027ad408bc2faffeb1e67d71babe8d78d89c3620da212303b877a65b5c"],["p","eda96cb93aecdd61ade0c1f9d2bfdf95a7e76cf1ca89820c38e6e4cea55c0c05"]],"content":"","sig":"5128ec83bd15ea260f6597b2e1d77125e297c8fef26977fe952b7e9d04e06d8ceb17d5cf94d5e7fb58e475c6afb8ff2880ec63b405bf868f826b425dde12fbe1"}
{"kind":39089,"id":"7426682b30d03cf8a40bcd86fe074e2f79c1b81c3b6c335ef56a3ba9d1035d16","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1750721921,"tags":[["title","Health"],["d","7i53tdhuhhex"],["image","https://images.unsplash.com/photo-1649134296132-56606326c566?q=80&w=1332&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","6b4ec98f02e647e01440b473bbd92a7fae21e01b6aa6c65e32db94a36092272e"],["description","Profiles that talk about health topics."],["t","health"]],"content":"","sig":"ff9f6f7d3c9a00fe89baef11559ca2ad9b5247e87129c0d6631c75bf3e10a12e9778f1ef5d2ae951aca504c7ac6f08a250868aae978ad5fae235e593af0c98d7"}
{"kind":39089,"id":"88d94f81af4eb6b5a8c70dcdb54124cf7d177dddd6408095d30e301f41024f9b","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1750721164,"tags":[["title","Sports"],["d","i6z5fdlgm0mc"],["image","https://images.unsplash.com/photo-1612872087720-bb876e2e67d1?q=80&w=1307&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","7c765d407d3a9d5ea117cb8b8699628560787fc084a0c76afaa449bfbd121d84"],["description","Profiles that talk or post about Sports."],["t","sports"]],"content":"","sig":"6b2d43dea6e50c04cd4bf1e5992e277c1719d1206125bbe045d3396543c3642daa8513d3a44eca5ed6f9a3bd536c9425ab034aaa29a0aa3e096aba93ad83071c"}
{"kind":39089,"id":"97d3211d499773c7b67a79f48a0bda207bcc09239af069285b256f247815f774","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1749695841,"tags":[["title","Christianity"],["d","fa5b4qnthgtw"],["image","https://images.unsplash.com/photo-1646281579942-ef27ddbf729f?q=80&w=3270&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","ffca54e078fd9884a745d70eb0159c0b8a9e7d383a4906a7484b83ebf6101dd5"],["p","d1b118c61b0fcaac3d9e8beafa8bb9a649cce921756a8fd7c21ca97b4985b38d"],["p","8d34bd2432240c5637174a3db191878baa1c133aec739b64a264259f414be32b"],["p","6e80246f10cc91b261e46d376bd3d64409b79e0c9bb3c2a0ecba612c321f6c4f"],["p","31ff694f01417160459e59e2e283b5454b499d3885092c792ae0d6b2de7839f0"],["description","Profiles that actively post about Christian topics."],["t","religion-spirituality"]],"content":"","sig":"f02e1d37fbb0f18f4b5c5da7d6a5fdfca4506d81232acb1032ef9aab5397641ad645296ed358729c968f0260cafd628fffd7efee60d91694849834b6b5279d86"}
{"kind":39089,"id":"7b18fc4ca212dd7d9afb025d08fe9562ff9ac32c6daffdeda3a15196cd1e158e","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1749691990,"tags":[["title","Music"],["d","w6d8d4247nr8"],["image","https://images.unsplash.com/photo-1511379938547-c1f69419868d?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","ca9d68eb25620fc755e1b8c76b5f155f4c7e96d99c532c109a8b36d208bdce55"],["p","23708a76e7090cb108d33e8801fd36262c475e5499b23eb87eee4a31f4f0144e"],["p","8806372af51515bf4aef807291b96487ea1826c966a5596bca86697b5d8b23bc"],["p","b9d02cb8fddeb191701ec0648e37ed1f6afba263e0060fc06099a62851d25e04"],["p","eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e"],["p","937ce652c92cb8bb7a8a3715d62c6e9f71dc66fd9164193e6c3cb962fdc07c94"],["description","Profiles of musicians or profiles that talk about music."],["t","music"]],"content":"","sig":"04f03a368481a1c3716fe426ac920a6977f4717f68073c0114ba16b1a76e30e7c3b99a97f8a7dee666317b077d30aaf2a6206223d6a3d65eb6d8377cf38a124d"}
{"kind":39089,"id":"9927b76f56750aeb3f1ef454720546a0e22c0f9cfcce346a6928fd7844d728f9","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1749256086,"tags":[["title","Human rights"],["d","vhsaqseo9sbx"],["image","https://images.unsplash.com/photo-1629753908080-e8551ac57b8d?q=80&w=1475&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240"],["p","6a359852238dc902aed19fbbf6a055f9abf21c1ca8915d1c4e27f50df2f290d9"],["p","f1989a96d75aa386b4c871543626cbb362c03248b220dc9ae53d7cefbcaaf2c1"],["p","500ccc532c301711d88aa6d30b11dc477e6a32770853f8ab1c2be389b824e3f8"],["p","58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196"],["p","33bd77e5394520747faae1394a4af5fa47f404389676375b6dc7be865ed81452"],["p","6e1010d8e5b953f9a52314d97bc94c597af26d51bae88b3fdf2c8fbd7e962d01"],["description","Human rights activists, or profiles that talk about human rights."],["t","politics"]],"content":"","sig":"7d2bf4423be8cab7d21138c172c9fc409e011f6c148e86ceed500f51edcf6d10530950385486d28f8b42eee0eb3dbb3b4b502d0e9d4adfe3f24fff99245074b3"}
{"kind":39089,"id":"dc17bccfe044d3350768f3c58632aaacee025eaf1e0b17717cbb51461446f377","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748291531,"tags":[["title","Philosophy"],["d","2dnchq9fxd5r"],["image","https://images.unsplash.com/photo-1620662736427-b8a198f52a4d?q=80&w=2971&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","0ffab7d9247132f14bcd38378b0acedc30d35e2b2670d89b7ae8c2d2c306af99"],["p","580f511af0a79d2cca20fb5bf6bb89abe6988593f8568177d2a513e87b2bbef3"],["t","humanities"]],"content":"","sig":"8cc9baf5beac297e575fb6db72785792cf43ac1086460f82deb4e7bc81c232ed0887e061de8bdc05389d6b26ded882036c9a7c120842548a192805c543ef4814"}
{"kind":39089,"id":"a77a31ee6fbab6ba3067ce72ba3a5f376b90f8a98f50635179010d98e158095c","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287979,"tags":[["title","Farmers (farmstr)"],["d","cioc58duuftq"],["image","https://images.unsplash.com/photo-1589923188900-85dae523342b?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","d70d50091504b992d1838822af245d5f6b3a16b82d917acb7924cef61ed4acee"],["p","4d023ce9dfd75a7f3075b8e8e084008be17a1f750c63b5de721e6ef883adc765"],["p","8d9d2b77930ee54ec3e46faf774ddd041dbb4e4aa35ad47c025884a286dd65fa"],["p","c0e0c4272134d92da8651650c10ca612b710a670d5e043488f27e073a1f63a16"],["p","5e7e69f863d20ca30103df88887194e091679491fefe5eb8212753013cc3d552"],["p","a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be"],["p","f27a4813f31d8faf80377ef15d4a397e3640b5ca1dffba9ce9dca91d3362c091"],["p","715c279295b65454c464b78503d6154ad7697ba82460c56d8a42eb5da11e6c4b"],["p","ab11aa702482080cfead6e35fb6fbad454a3e792dd4d75f183f52f5fea2a2fa9"],["p","6a02b7d5d5c1ceec3d0ad28dd71c4cfeebb6397b95fef5cd5032c9223a13d02a"],["p","e7211c227437dcb5582712b5b1bbfb2a23b27654649ddc7c1fb5a7dde87afeee"],["p","7b8fcd9153d2c47146155df24b7f167924c1fc16fb4e843d98f8b5f3add6bd85"],["p","0f4011159c24a6a53eeea72e1c2e97e0425be8af15e7de397003dd65d9e8d278"],["p","58d2be9f20537ad7074b0283287611507ef7bd313c5edb64def9e1fc5df26d11"],["p","d020fe6e22b3aca9f578172a135e25d7b692e67b52e97fdce55431127d7626af"],["p","7644119b18ec56b5b2779e0d035e7712a9d669dbfbe5b2c5f458cb564afb6c95"],["description","Profiles of farmers or profiles that talk about farming"],["t","food"]],"content":"","sig":"d6bbdbc1d84bf6f85e462f1abbe7dfc5d06218091ab0be00cd0ba440d0835eb26f81e8d0b2aea3e4bd3a5ab3638be0ce27c4072df460e99c5fe9dcc59113a153"}
{"kind":39089,"id":"fb51210ce13077cecd890a053f611fa8a258e8898e8b7b3116b641dbf1330959","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287889,"tags":[["title","Lifestyle"],["d","rptxdnrphqsr"],["image","https://images.unsplash.com/photo-1493225255756-d9584f8606e9?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","2652f3af10de63bc10a2628871a3fce62e08655e4fcf90a58be16f246bb65da3"],["p","c7c8f645fd45b09055fb6c26d148737ad7ed12ddecde0d4c877b88f8d4196865"],["p","e451c4e82e272ef912a04f490373fce9054a044b11a2aa847823b3686c8eba77"],["p","22a193fb86c793d7f73348126a5fe55ab8a4c975805ebc2ff81bf290f7805f7e"],["p","805b34f708837dfb3e7f05815ac5760564628b58d5a0ce839ccbb6ef3620fac3"],["description","Profiles that talk about different lifestyles."],["t","lifestyle"]],"content":"","sig":"11d70b315a7084ad26f31e3821ff295962654c2d41526f7e8491e18dcd7ed5a578acade9c47b0456c76b12e74653147bef069dafd1b05278506d63d7a8cb2316"}
{"kind":39089,"id":"d1c8d4b8684b7e1eda6830e6dde96b2ea548ce4f717ff5cf528a00a2206992e7","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287802,"tags":[["title","Art & Photography"],["d","9gnjzbkd59lp"],["image","https://images.unsplash.com/photo-1579541592065-da8a15e49bc7?q=80&w=3201&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","23708a76e7090cb108d33e8801fd36262c475e5499b23eb87eee4a31f4f0144e"],["p","f96c3d76497074c4c83a7b3823380e77dc73d5a9494fd2e053e4a1453e17824b"],["p","11b2d93b26d7e56fb57f0afce0d33bfa7fb35b913e4c0aeb7706464befb9ca97"],["p","f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0"],["p","af146f51634a5fb0abc592fbc2bed42cf740700990344a766a8999fe55eed1c6"],["p","8816e8938fe60a3a925433c77410f39500fe1320787acb2165f9345e70464592"],["p","8d0638d3e8d9b23e337fb4017bb7d5a97def40bdc258e8121ee7d51e623ca065"],["p","ece8ed2111f73b92251e74a3da22dee1d6c6d9b497040acc68a37ac6bbf5a105"],["p","64bfa9abffe5b18d0731eed57b38173adc2ba89bf87c168da90517f021e722b5"],["p","546bffcb9317edf919828de272dd8eba11f2aadc07969cba6c9bb894c07712ef"],["p","20998a8d433181cc5e778903db528d162f5579918a3437be40fdba0130b3469c"],["p","37c962bfb34d32e667d5075f7d55f3a7ca9bcddbafeff59a4003c276ad147352"],["p","87deef9034c52d87ae327af3a76358e58e7ee2e03d80c738ee1a2be399c23e79"],["p","55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185"],["description","A list of profiles related to art and photography"],["t","art"]],"content":"","sig":"a6409e0172380d30d36446f46ff2ccc97cb01cd3cdff612991b8e759a103a8243928579efdcea9f5e0c44ce2785c240218928fec26cdd6b2b0bafb63a8c0790a"}
{"kind":39089,"id":"f650750b46c7f97d9f4f85ee1f1c704015f3ca35b129826ac370ad47faa016a4","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287746,"tags":[["title","Technology companies"],["d","yogtlbnbuw39"],["image","https://images.unsplash.com/photo-1531297484001-80022131f5a1?q=80&w=3220&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","d7df8b3e14166796a8ad8740b06f427aea9dd95b72e0276aa9179210e27f81f7"],["p","6b4a29bbd43d1d0eeead384f512dbb591ce9407d27dba48ad54b00d9d2e1972b"],["p","34104dedf3cc5936802c8308a3d0090f2857d4aba4e8b720accaf6b2ab049969"],["p","ebdee92945ef05283be0ac3de25787c81a6a58a10f568f9c6b61d9dd513adbad"],["p","37cdc3e7f5a7147752cc6cb348bd77e1b999e3e43a4b21b740812193ba81c298"],["p","de7aa63b1c7e809f66a67bdcd2bd4c952a09cd52ee01ed7db358d09bad97f840"],["p","c7617e84337c611c7d5f941b35b1ec51f2ae6e9f41aac9616092d510e1c295e0"],["p","9be21611a341426e9146257c54179e22d178bb7d4106e247ddf3e507b7985a6b"],["description","A list of technology companies on Nostr"],["t","technology"]],"content":"","sig":"d1e9adeb5f9850ecffc3a524e59dbfa6c49913ac77b3653852f46908ae6e6290d7e58fc4c74717c45ee9a2fd4b6421d8c5885b8e198a6aaf14db39e461902d68"}
{"kind":39089,"id":"7e734701c3a09c63e9c68a4c6762c7dd122220d2d325802e40d1f8c983733b42","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287584,"tags":[["title","Technology news"],["d","mbj6yxabm94c"],["image","https://images.unsplash.com/photo-1726568313407-c7d9c8a8ce88?q=80&w=2971&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","9c5d0fe246b80bc313082aa3dbb4f82744751ca8d79901dc74f7eb69bba8ad4b"],["p","8451100685869f1fa9b122300167359f9a0459fcf517f2fb1b1dd22319a38815"],["p","de5ba539eb564946ae0e3f5abbf3599e8eeed8efd40875c6320405a0c17d976e"],["t","technology"]],"content":"","sig":"b92854e27172dfb0225308669a1d8c422f82258736e2403a89dc104e0d3a97ac4434d482402f37ecb16e57f65cd7ced7dccf978f1da0065d6f0bcd55782829bc"}
{"kind":39089,"id":"9143d35516660826a8c1a4a2440fa124c395b144bd172d39ea647d0222aaaeb3","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287486,"tags":[["title","Bitcoin builders"],["d","2iofns19fsiv"],["image","https://images.unsplash.com/photo-1564241832756-58a1d4055663?q=80&w=2969&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","566c166f3adab0c8fba5da015b0b3bcc8eb3696b455f2a1d43bfbd97059646a8"],["p","91c9a5e1a9744114c6fe2d61ae4de82629eaaa0fb52f48288093c7e7e036f832"],["p","25a2192dcf34c3be326988b5c9f942aa96789899d15b59412602854a8723e9e8"],["p","11b9a89404dbf3034e7e1886ba9dc4c6d376f239a118271bd2ec567a889850ce"],["p","b7b51cc25216d4c10bc85ae27055c9a945fe77cafd463cf23b20917e39ce6816"],["p","e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411"],["p","5a8e581f16a012e24d2a640152ad562058cb065e1df28e907c1bfa82c150c8ba"],["description","People who builds products and services around Bitcoin.\n\n(Not an exhaustive list, work in progress)"],["t","bitcoin"]],"content":"","sig":"192f65e8392742a01be41b60aef05da4026a1fd22229bde468a6123890f680d2f7765fa8b21c41f75945b676053e91eabfe2529bb289a7dda80789a0e6486d20"}
{"kind":39089,"id":"0ed119d7e0828ac23e7a0d735498be9054da91d21e7e268c25633ad747a949c1","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287175,"tags":[["title","Cars"],["d","dgj9fi1mk83t"],["image","https://images.unsplash.com/photo-1526726538690-5cbf956ae2fd?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","98adf137d9da1b1b3654f29ede930f27c95847c9719bf4b38e1cef33c21f7e38"],["description","A list of profiles that talk about cars."],["t","other"]],"content":"","sig":"47e6b974e57b6344e47269cc2ac1c800332a2c1f949c9cd1522b90580ece31d49409a02fa9cdac64a5cfe660c221a1c50b9eeaa5204a44c24d5d6c5bd5ade506"}
{"kind":39089,"id":"37d183d3f069167e4df7294c842086d61abee8ca8f7d79a402eeb0d2dca34268","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287050,"tags":[["title","Science"],["d","ypfr6bg55nrw"],["image","https://images.unsplash.com/photo-1606125784258-570fc63c22c1?q=80&w=3161&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","c582af78dff442700ec59e21786532a7074c00be8b7b1eac989bbf62698069cc"],["p","e85ed75286cb77475776c1007df8c4ff1c9c68eff91c3627347b065c5bf4dc78"],["t","science"]],"content":"","sig":"3649115023c3c62432742d209f3a4791b0d38d784a147a967d81288add8ab127197a6207de82b5cb53d4618cd1c92b0d190b3c7a9f5276535853f0a83fc485d3"}
{"kind":39089,"id":"bf1546f39fc0d3dac03ab77b0ce9d62d71707114cfe3f88c75fb13227c0b02ad","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748286976,"tags":[["title","Nostr builders"],["d","68qs4i0xgex8"],["image","https://images.unsplash.com/photo-1588508107117-227d4ab6b751?q=80&w=3228&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],["p","97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"],["p","d61f3bc5b3eb4400efdae6169a5c17cabf3246b514361de939ce4a1a0da6ef4a"],["p","3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],["p","a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be"],["p","17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4"],["p","8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"],["p","4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"],["p","1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef"],["p","8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883"],["p","1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411"],["p","2779f3d9f42c7dee17f0e6bcdcf89a8f9d592d19e3b1bbd27ef1cffd1a7f98d1"],["p","520830c334a3f79f88cac934580d26f91a7832c6b21fb9625690ea2ed81b5626"],["p","e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb"],["p","850605096dbfb50b929e38a6c26c3d56c425325c85e05de29b759bc0e5d6cebc"],["p","0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd"],["p","d36e8083fa7b36daee646cb8b3f99feaa3d89e5a396508741f003e21ac0b6bec"],["p","0d6c8388dcb049b8dd4fc8d3d8c3bb93de3da90ba828e4f09c8ad0f346488a33"],["p","460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"],["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["p","bd1e19980e2c91e6dc657e92c25762ca882eb9272d2579e221f037f93788de91"],["p","e7424ad457e512fdf4764a56bf6d428a06a13a1006af1fb8e0fe32f6d03265c7"],["p","68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272"],["p","50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63"],["p","76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa"],["description","People who build products and services on Nostr. (Not an exhaustive list)"],["t","technology"]],"content":"","sig":"566d88b6ecbfa00f51789bc660c92abb4c9d6d592a3f2022531c0615444ad773aecf6d1781177b19dd2f26fad94e4ab077affcad86b60fda8f5f7658545a2ae6"}
{"kind":39089,"id":"700646832f4d70ce896d9ebd833c6c7552a91b2acaaf469c5ac35c59c9d8801b","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748286876,"tags":[["title","Travel"],["d","2xhos7ml5pcp"],["image","https://images.unsplash.com/photo-1507525428034-b723cf961d3e?q=80&w=2973&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","7d33ba57d8a6e8869a1f1d5215254597594ac0dbfeb01b690def8c461b82db35"],["description","Profiles that talk about travel."],["t","travel"]],"content":"","sig":"b62a5df0295cac59d2616a3786a72ba47052a540b7185b2b93f77d26dbb4fad171cf145ea445d15b150e7642260646c5850ea4482330cd5dc5038d0ef26954de"}
{"kind":39089,"id":"03012789fd1d247142192bf6f8999ee848ba83edf6b7f4919b4e3690e75889b6","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1747845497,"tags":[["title","Human Architecture, Local Vernacular, and Craftsmanship"],["d","y156932o9xfh"],["image","https://c4.wallpaperflare.com/wallpaper/82/123/783/architecture-building-bulgaria-village-wallpaper-preview.jpg"],["p","5db2be23cde61dd0a69e667a021a943fa38760104fe4160ce13b1a097e9fe447"],["p","7a8e476bd97e1a9c348b2f9e1c8c9d1f371e2fda001dae82e44d336d4ca2f7ec"],["p","16f1a0100d4cfffbcc4230e8e0e4290cc5849c1adc64d6653fda07c031b1074b"],["p","462eb31a6b3de5727407e796d984be2c631cb4bfa854f8a1a2b092dcc6d7bbe1"],["p","e9986a10caaa96738ceda88aabd3e184307be5143e687457581f9b096c6ef89c"],["p","704c4773626bf0f7ebf99d861eb0eae473be2b004f91725f8a1750486c9c848f"],["p","41261aca9c043397d53c1d09d2d62926e8ab230ec8f7516c258c81c6844169c3"],["p","94f57887daad1a4b952bd755539f239922cd614a1b1ba0e623ea8361a4ca2a65"],["description","Aesthetic architecture, craftsmanship, and localism"],["t","art"]],"content":"","sig":"2a987dcbc86d66166599c55221a0bfb39a42fc83cd5db5f79e7aa6ddbf0b3777cfd39e1d97d36572084eb34ae30d58e10fab4e50de29a8798ef21a64e9b90e84"}
{"kind":39089,"id":"7e847eafeaf8ec7dec435b7a8f2eda41b645653ea2414297fbf4b9788d77b582","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1747767150,"tags":[["title","Linux Enjoyers"],["d","unjue0fdg0ef"],["image","https://image.nostr.build/2e730d4041764c32c40f33dd1e5ae15158f4463ed1bf09f217715b2735f667f1.jpg"],["p","52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd"],["p","edb470271297ac5a61f277f3cd14de54c67eb5ccd20ef0d9df29be18685bb004"],["p","fd5989ddfadd9e2af6ceb8b63942a9e31b37367e89917931ede3b2ea76823f10"],["p","139137969c1c56bf8306cd71272f681ed54206be15eeceb9eb98956d2fd9f7ef"],["p","f4d89779148ccd245c8d50914a284fd62d97cb0fb68b797a70f24a172b522db9"],["p","70122128273bdc07af9be7725fa5c4bc0fc146866bec38d44360dc4bc6cc18b9"],["p","0463223adf38df9a22a7fb07999a638fdd42d8437573e0bf19c43e013b14d673"],["p","480ec1a7516406090dc042ddf67780ef30f26f3a864e83b417c053a5a611c838"],["p","93332f1e437be05aa1a4c2cbb694cafc9721be57cca98aef7ec6641895ae4735"],["p","259b1ddc0d3137cad123d4af64f87ff9db3834a843843963e58e2a9ac956cf39"],["p","75656740209960c74fe373e6943f8a21ab896889d8691276a60f86aadbc8f92a"],["p","bd4ae3e67e29964d494172261dc45395c89f6bd2e774642e366127171dfb81f5"],["p","76e36f2fabfbd8c353ffcda8fe07877a0977ee4aa9d9bcc224f02038d73b3787"],["p","75da94027ad408bc2faffeb1e67d71babe8d78d89c3620da212303b877a65b5c"],["p","a793448d75b909e9597252cc5b133b24f64012d02fe041009f70cba16bf72864"],["p","c7eda660a6bc8270530e82b4a7712acdea2e31dc0a56f8dc955ac009efd97c86"],["p","1c197b12ca9ce0415b70e7405b9770f0ec6bccfb59b32b63aafd42cb242e1642"],["p","74fb3ef27cd8985d7fefc6e94d178290275f5492557b4a166ab9cd1458adabc7"],["p","d9a329afabb263a8af216838eb45e1f04cc3000704464947c4b907e1bef580d7"],["p","b203872cefbd5572b5551dde5bdfc0387219d99fb7e8d1c8418145d432ca0390"],["p","566c166f3adab0c8fba5da015b0b3bcc8eb3696b455f2a1d43bfbd97059646a8"],["p","fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1"],["p","97a403640c83ac12bce556ded8db2f3ebe891801832fa1114abda73a6ae8598c"],["p","40b9c85fffeafc1cadf8c30a4e5c88660ff6e4971a0dc723d5ab674b5e61b451"],["p","a5ee447568a2372226d647013a55251eac7dd37ab40804a5121a63ce2ca75401"],["p","abd277d90caba20aee0f1f05a68d24cd117badc1205d0d1b1a0451357d32b92f"],["p","b7ed68b062de6b4a12e51fd5285c1e1e0ed0e5128cda93ab11b4150b55ed32fc"],["p","ce9cfafcdc8425dc90c7045f263f966d077dbde96940b7fab4c9403998c5b49b"],["t","technology"]],"content":"","sig":"271c27e39c2cb24977a1f04e7df0c234b0fd35624abbbb0df35cf5ae6ae4e4b40f00acbcb2fd964edc40167fd0b45598d3879c01c0645938c1686f103285cb0f"}
@@ -1,79 +0,0 @@
[
{
"category": "suggested_users_nostr",
"users": [
"ba2f394833658475e91680b898f9be0f1d850166c6a839dbe084d0266ad6e20a",
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
"b708f7392f588406212c3882e7b3bc0d9b08d62f95fa170d099127ece2770e5e"
]
},
{
"category": "suggested_users_permaculture_livestock_gardening",
"users": [
"4b1804c90d59dff195ee0e8f692b98a7c762bf1793b3e126c546d730dcb04477",
"2c24e1af571fb4ccfeca3981649c1b09c695cd83b129709eb3b41c7ad2854899",
"296842eaaed9be5ae0668da09fe48aac0521c4af859ad547d93145e5ac34c17e"
]
},
{
"category": "suggested_users_music",
"users": [
"23708a76e7090cb108d33e8801fd36262c475e5499b23eb87eee4a31f4f0144e",
"ca9d68eb25620fc755e1b8c76b5f155f4c7e96d99c532c109a8b36d208bdce55"
]
},
{
"category": "suggested_users_books",
"users": [
"2652f3af10de63bc10a2628871a3fce62e08655e4fcf90a58be16f246bb65da3",
"b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450"
]
},
{
"category": "suggested_users_art_photography",
"users": [
"f96c3d76497074c4c83a7b3823380e77dc73d5a9494fd2e053e4a1453e17824b",
"11b2d93b26d7e56fb57f0afce0d33bfa7fb35b913e4c0aeb7706464befb9ca97",
"f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0",
"af146f51634a5fb0abc592fbc2bed42cf740700990344a766a8999fe55eed1c6",
"8816e8938fe60a3a925433c77410f39500fe1320787acb2165f9345e70464592",
"8d0638d3e8d9b23e337fb4017bb7d5a97def40bdc258e8121ee7d51e623ca065",
"ece8ed2111f73b92251e74a3da22dee1d6c6d9b497040acc68a37ac6bbf5a105",
"64bfa9abffe5b18d0731eed57b38173adc2ba89bf87c168da90517f021e722b5",
"546bffcb9317edf919828de272dd8eba11f2aadc07969cba6c9bb894c07712ef",
"20998a8d433181cc5e778903db528d162f5579918a3437be40fdba0130b3469c",
"37c962bfb34d32e667d5075f7d55f3a7ca9bcddbafeff59a4003c276ad147352",
"387fa328198e67a400caf00947cc91e0c166d24d71d8b4b78a6c3ef91ff4d058",
"87deef9034c52d87ae327af3a76358e58e7ee2e03d80c738ee1a2be399c23e79"
]
},
{
"category": "suggested_users_ai_art",
"users": [
"431fa2f340f0adf8963d6d7c6e2c20d913278c691fe609fd3857db13d8f39feb",
"9936a53def39d712f886ac7e2ed509ce223b534834dd29d95caba9f6bc01ef35",
"1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b",
"693c2832de939b4af8ccd842b17f05df2edd551e59989d3c4ef9a44957b2f1fb",
"55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185"
]
},
{
"category": "suggested_users_parenting",
"users": [
"c7c8f645fd45b09055fb6c26d148737ad7ed12ddecde0d4c877b88f8d4196865",
"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
"e451c4e82e272ef912a04f490373fce9054a044b11a2aa847823b3686c8eba77",
"261c7e18545aee4eb55e8052297fd93bd886c2f96b10d599cfdad4f3477b87ab",
"22a193fb86c793d7f73348126a5fe55ab8a4c975805ebc2ff81bf290f7805f7e"
]
},
{
"category": "suggested_users_food",
"users": [
"cbb2f023b6aa09626d51d2f4ea99fa9138ea80ec7d5ffdce9feef8dcd6352031"
]
}
]