commit 414c67a91903d8c8820e6e37ef59f742800f4ecf parent f436291209e2f7eefbcd9e8f37a901fe4814a8a0 Author: ericholguin <ericholguin@apache.org> Date: Tue, 13 May 2025 20:15:05 -0600 Follow Packs This PR adds and enables follow packs in the universe view. Closes: #3012 Changelog-Added: Added follow list kind 39089 Changelog-Added: Added follow pack preview Changelog-Added: Added follow pack timeline to Universe View Changelog-Removed: Removed hashtags in Universe View Signed-off-by: ericholguin <ericholguin@apache.org> Diffstat:
18 files changed, 774 insertions(+), 22 deletions(-)
diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -418,11 +418,26 @@ 5C0567592C8FBDE30073F23A /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2277EE92A089BD5006C3807 /* Router.swift */; }; 5C05675A2C8FBDE70073F23A /* NDBSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0567572C8FBC560073F23A /* NDBSearchView.swift */; }; 5C0707D12A1ECB38004E7B51 /* DamusLogoGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0707D02A1ECB38004E7B51 /* DamusLogoGradient.swift */; }; + 5C09FD122DF283D700823661 /* FollowPackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C09FD112DF283D200823661 /* FollowPackModel.swift */; }; + 5C09FD132DF283D700823661 /* FollowPackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C09FD112DF283D200823661 /* FollowPackModel.swift */; }; + 5C09FD142DF283D700823661 /* FollowPackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C09FD112DF283D200823661 /* FollowPackModel.swift */; }; 5C14C29B2BBBA29C00079FD2 /* RelaySoftwareDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C14C29A2BBBA29C00079FD2 /* RelaySoftwareDetail.swift */; }; 5C14C29D2BBBA40B00079FD2 /* RelayAdminDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C14C29C2BBBA40B00079FD2 /* RelayAdminDetail.swift */; }; 5C14C29F2BBBA5C600079FD2 /* RelayNipList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C14C29E2BBBA5C600079FD2 /* RelayNipList.swift */; }; 5C42E78C29DB76D90086AAC1 /* EmptyUserSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */; }; 5C4D9EA72C042FA5005EA0F7 /* HighlightDraftContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4D9EA62C042FA5005EA0F7 /* HighlightDraftContentView.swift */; }; + 5C4FA7EC2DC29AE900CE658C /* FollowPackEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7EB2DC29AE900CE658C /* FollowPackEvent.swift */; }; + 5C4FA7ED2DC29AE900CE658C /* FollowPackEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7EB2DC29AE900CE658C /* FollowPackEvent.swift */; }; + 5C4FA7EE2DC29AE900CE658C /* FollowPackEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7EB2DC29AE900CE658C /* FollowPackEvent.swift */; }; + 5C4FA7FB2DC29C3800CE658C /* FollowPackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7F92DC29C3800CE658C /* FollowPackView.swift */; }; + 5C4FA7FC2DC29C3800CE658C /* FollowPackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7F92DC29C3800CE658C /* FollowPackView.swift */; }; + 5C4FA7FD2DC29C3800CE658C /* FollowPackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7F92DC29C3800CE658C /* FollowPackView.swift */; }; + 5C4FA7FF2DC5119300CE658C /* FollowPackPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7FE2DC5119300CE658C /* FollowPackPreview.swift */; }; + 5C4FA8002DC5119300CE658C /* FollowPackPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7FE2DC5119300CE658C /* FollowPackPreview.swift */; }; + 5C4FA8012DC5119300CE658C /* FollowPackPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA7FE2DC5119300CE658C /* FollowPackPreview.swift */; }; + 5C4FA8032DCAF80E00CE658C /* FollowPackTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA8022DCAF80400CE658C /* FollowPackTimeline.swift */; }; + 5C4FA8042DCAF80E00CE658C /* FollowPackTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA8022DCAF80400CE658C /* FollowPackTimeline.swift */; }; + 5C4FA8052DCAF80E00CE658C /* FollowPackTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4FA8022DCAF80400CE658C /* FollowPackTimeline.swift */; }; 5C513FBA297F72980072348F /* CustomPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C513FB9297F72980072348F /* CustomPicker.swift */; }; 5C513FCC2984ACA60072348F /* QRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C513FCB2984ACA60072348F /* QRCodeView.swift */; }; 5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */; }; @@ -2437,11 +2452,16 @@ 5C0567542C8B60C20073F23A /* OffsetExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffsetExtension.swift; sourceTree = "<group>"; }; 5C0567572C8FBC560073F23A /* NDBSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NDBSearchView.swift; sourceTree = "<group>"; }; 5C0707D02A1ECB38004E7B51 /* DamusLogoGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusLogoGradient.swift; sourceTree = "<group>"; }; + 5C09FD112DF283D200823661 /* FollowPackModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowPackModel.swift; sourceTree = "<group>"; }; 5C14C29A2BBBA29C00079FD2 /* RelaySoftwareDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaySoftwareDetail.swift; sourceTree = "<group>"; }; 5C14C29C2BBBA40B00079FD2 /* RelayAdminDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayAdminDetail.swift; sourceTree = "<group>"; }; 5C14C29E2BBBA5C600079FD2 /* RelayNipList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayNipList.swift; sourceTree = "<group>"; }; 5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyUserSearchView.swift; sourceTree = "<group>"; }; 5C4D9EA62C042FA5005EA0F7 /* HighlightDraftContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDraftContentView.swift; sourceTree = "<group>"; }; + 5C4FA7EB2DC29AE900CE658C /* FollowPackEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowPackEvent.swift; sourceTree = "<group>"; }; + 5C4FA7F92DC29C3800CE658C /* FollowPackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowPackView.swift; sourceTree = "<group>"; }; + 5C4FA7FE2DC5119300CE658C /* FollowPackPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowPackPreview.swift; sourceTree = "<group>"; }; + 5C4FA8022DCAF80400CE658C /* FollowPackTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowPackTimeline.swift; sourceTree = "<group>"; }; 5C513FB9297F72980072348F /* CustomPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPicker.swift; sourceTree = "<group>"; }; 5C513FCB2984ACA60072348F /* QRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeView.swift; sourceTree = "<group>"; }; 5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientButtonStyle.swift; sourceTree = "<group>"; }; @@ -2808,6 +2828,8 @@ 4C0A3F8D280F63FF000448DE /* Models */ = { isa = PBXGroup; children = ( + 5C09FD112DF283D200823661 /* FollowPackModel.swift */, + 5C4FA7EB2DC29AE900CE658C /* FollowPackEvent.swift */, D73BDB122D71212600D69970 /* NostrNetworkManager */, D74F43082B23F09300425B75 /* Purple */, BA3759882ABCCDE30018D73B /* Camera */, @@ -3614,6 +3636,7 @@ 4CC7AAEE297F11B300430951 /* Events */ = { isa = PBXGroup; children = ( + 5C4FA7FA2DC29C3800CE658C /* FollowPack */, 5CC852A02BDED9970039FFC5 /* Highlight */, 4CA927682A290F8F0098A105 /* Components */, 4CC7AAEF297F11C700430951 /* SelectedEventView.swift */, @@ -3929,6 +3952,16 @@ path = Images; sourceTree = "<group>"; }; + 5C4FA7FA2DC29C3800CE658C /* FollowPack */ = { + isa = PBXGroup; + children = ( + 5C4FA8022DCAF80400CE658C /* FollowPackTimeline.swift */, + 5C4FA7FE2DC5119300CE658C /* FollowPackPreview.swift */, + 5C4FA7F92DC29C3800CE658C /* FollowPackView.swift */, + ); + path = FollowPack; + sourceTree = "<group>"; + }; 5CC852A02BDED9970039FFC5 /* Highlight */ = { isa = PBXGroup; children = ( @@ -4655,6 +4688,7 @@ BA3759972ABCCF360018D73B /* CameraPreview.swift in Sources */, 4C75EFA627FF87A20006080F /* Nostr.swift in Sources */, 4CA927672A290F8B0098A105 /* RelativeTime.swift in Sources */, + 5C4FA8032DCAF80E00CE658C /* FollowPackTimeline.swift in Sources */, 4CB883A62975F83C00DC99E7 /* LNUrlPayRequest.swift in Sources */, D7CB5D4B2B11721600AD4105 /* ZapType.swift in Sources */, 4CE4F9DE2852768D00C00DD9 /* ConfigView.swift in Sources */, @@ -4799,6 +4833,7 @@ 4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */, 4C12535E2A76CA870004F4B8 /* SwitchedTimelineNotify.swift in Sources */, D74F430A2B23F0BE00425B75 /* DamusPurple.swift in Sources */, + 5C4FA7ED2DC29AE900CE658C /* FollowPackEvent.swift in Sources */, 9CA876E229A00CEA0003B9A3 /* AttachMediaUtility.swift in Sources */, 3ACF94462DAA006500971A4E /* NIP05DomainEventsModel.swift in Sources */, D734B1452CCC19B1000B5C97 /* DamusFullScreenCover.swift in Sources */, @@ -4879,6 +4914,7 @@ D7373BAA2B68A65A00F7783D /* PurpleAccountUpdateNotify.swift in Sources */, 5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */, 3CCD1E6A2A874C4E0099A953 /* Nip98HTTPAuth.swift in Sources */, + 5C4FA7FF2DC5119300CE658C /* FollowPackPreview.swift in Sources */, 4C8EC52529D1FA6C0085D9A8 /* DamusColors.swift in Sources */, 3A4647CF2A413ADC00386AD8 /* CondensedProfilePicturesView.swift in Sources */, 5C14C29B2BBBA29C00079FD2 /* RelaySoftwareDetail.swift in Sources */, @@ -4909,6 +4945,7 @@ 3165648B295B70D500C64604 /* LinkView.swift in Sources */, 4C8D00CF29E38B950036AF10 /* nostr_bech32.c in Sources */, D7CB5D5C2B1176B200AD4105 /* MediaUploader.swift in Sources */, + 5C4FA7FD2DC29C3800CE658C /* FollowPackView.swift in Sources */, 4C1253562A76C8C60004F4B8 /* BroadcastNotify.swift in Sources */, 4C3BEFD42819DE8F00B3DE84 /* NostrKind.swift in Sources */, B533694E2B66D791008A805E /* MutelistManager.swift in Sources */, @@ -4948,6 +4985,7 @@ 4C32B9512A9AD44700DC3548 /* FlatbuffersErrors.swift in Sources */, 5CC852A62BE00F180039FFC5 /* HighlightEventRef.swift in Sources */, 4CE8794E2996B16A00F758CC /* RelayToggle.swift in Sources */, + 5C09FD132DF283D700823661 /* FollowPackModel.swift in Sources */, 4C3AC79B28306D7B00E1F516 /* Contacts.swift in Sources */, D71AD8FF2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift in Sources */, 4C3EA63D28FF52D600C48A62 /* bolt11.c in Sources */, @@ -5109,6 +5147,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5C4FA7FB2DC29C3800CE658C /* FollowPackView.swift in Sources */, D7F360262CEBBD8B009D34DA /* PresentFullScreenItemNotify.swift in Sources */, 82D6FA9A2CD9820500C925F4 /* ShareViewController.swift in Sources */, 82D6FAA92CD99F7900C925F4 /* FbConstants.swift in Sources */, @@ -5175,6 +5214,7 @@ 82D6FAE42CD99F7900C925F4 /* FollowNotify.swift in Sources */, 82D6FAE52CD99F7900C925F4 /* LikedNotify.swift in Sources */, 82D6FAE62CD99F7900C925F4 /* LocalNotificationNotify.swift in Sources */, + 5C4FA8012DC5119300CE658C /* FollowPackPreview.swift in Sources */, 82D6FAE72CD99F7900C925F4 /* LoginNotify.swift in Sources */, 82D6FAE82CD99F7900C925F4 /* LogoutNotify.swift in Sources */, D706C5B12D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */, @@ -5227,6 +5267,7 @@ 82D6FB0F2CD99F7900C925F4 /* DamusLogoGradient.swift in Sources */, 82D6FB102CD99F7900C925F4 /* DamusBackground.swift in Sources */, 82D6FB112CD99F7900C925F4 /* DamusLightGradient.swift in Sources */, + 5C4FA8042DCAF80E00CE658C /* FollowPackTimeline.swift in Sources */, 82D6FB132CD99F7900C925F4 /* Shimmer.swift in Sources */, 82D6FB142CD99F7900C925F4 /* EndBlock.swift in Sources */, 82D6FB152CD99F7900C925F4 /* ImageCarousel.swift in Sources */, @@ -5373,6 +5414,7 @@ 82D6FB9B2CD99F7900C925F4 /* MutedThreadsManager.swift in Sources */, 82D6FB9C2CD99F7900C925F4 /* WalletModel.swift in Sources */, 82D6FB9D2CD99F7900C925F4 /* ZapButtonModel.swift in Sources */, + 5C09FD142DF283D700823661 /* FollowPackModel.swift in Sources */, 82D6FB9E2CD99F7900C925F4 /* ContentFilters.swift in Sources */, 82D6FB9F2CD99F7900C925F4 /* DamusCacheManager.swift in Sources */, 82D6FBA02CD99F7900C925F4 /* NotificationsManager.swift in Sources */, @@ -5510,6 +5552,7 @@ 82D6FC202CD99F7900C925F4 /* RelayType.swift in Sources */, 82D6FC212CD99F7900C925F4 /* SignalView.swift in Sources */, 82D6FC222CD99F7900C925F4 /* RelayPicView.swift in Sources */, + 5C4FA7EC2DC29AE900CE658C /* FollowPackEvent.swift in Sources */, 82D6FC232CD99F7900C925F4 /* UserSearch.swift in Sources */, 82D6FC242CD99F7900C925F4 /* AddMuteItemView.swift in Sources */, 82D6FC252CD99F7900C925F4 /* MuteDurationMenu.swift in Sources */, @@ -5680,6 +5723,7 @@ D73E5E602C6A97F4007EB227 /* ImageMetadata.swift in Sources */, D73E5E612C6A97F4007EB227 /* ImageProcessing.swift in Sources */, D73E5E622C6A97F4007EB227 /* BlurHashEncode.swift in Sources */, + 5C09FD122DF283D700823661 /* FollowPackModel.swift in Sources */, D73E5E632C6A97F4007EB227 /* BlurHashDecode.swift in Sources */, D73E5F952C6AA753007EB227 /* FullScreenCarouselView.swift in Sources */, D73E5E642C6A97F4007EB227 /* PostBox.swift in Sources */, @@ -5699,6 +5743,7 @@ D73E5E6F2C6A97F4007EB227 /* TimeAgo.swift in Sources */, D73E5E702C6A97F4007EB227 /* Parser.swift in Sources */, D73E5E722C6A97F4007EB227 /* LinkView.swift in Sources */, + 5C4FA7EE2DC29AE900CE658C /* FollowPackEvent.swift in Sources */, D73E5F922C6AA720007EB227 /* QRCodeView.swift in Sources */, D73E5E742C6A97F4007EB227 /* Lists.swift in Sources */, D73E5E752C6A97F4007EB227 /* CoreSVG.swift in Sources */, @@ -5716,6 +5761,7 @@ D73E5E802C6A97F4007EB227 /* CredentialHandler.swift in Sources */, D73E5E812C6A97F4007EB227 /* KeyboardVisible.swift in Sources */, D73E5E832C6A97F4007EB227 /* AVPlayer+Additions.swift in Sources */, + 5C4FA7FC2DC29C3800CE658C /* FollowPackView.swift in Sources */, D73E5E842C6A97F4007EB227 /* Zaps+.swift in Sources */, D73E5E852C6A97F4007EB227 /* WalletConnect+.swift in Sources */, D73E5E862C6A97F4007EB227 /* DamusPurpleNotificationManagement.swift in Sources */, @@ -5852,6 +5898,7 @@ D73E5EFF2C6A97F4007EB227 /* ZapsView.swift in Sources */, D73E5F002C6A97F4007EB227 /* CustomizeZapView.swift in Sources */, D73E5F012C6A97F4007EB227 /* ZapTypePicker.swift in Sources */, + 5C4FA8052DCAF80E00CE658C /* FollowPackTimeline.swift in Sources */, D73E5F022C6A97F4007EB227 /* ZapUserView.swift in Sources */, D73E5F032C6A97F4007EB227 /* ProfileZapLinkView.swift in Sources */, D7DB930C2D69486700DA1EE5 /* NIP65.swift in Sources */, @@ -5990,6 +6037,7 @@ D703D7552C670A3700A400EA /* DamusUserDefaults.swift in Sources */, D703D7A32C670E1D00A400EA /* nostr_bech32.c in Sources */, D703D7992C670DF900A400EA /* sha256.c in Sources */, + 5C4FA8002DC5119300CE658C /* FollowPackPreview.swift in Sources */, D703D7972C670DED00A400EA /* wasm.c in Sources */, 5C8498042D5D150000F74FEB /* ZapExplainer.swift in Sources */, D703D7842C670C4700A400EA /* SequenceUtils.swift in Sources */, diff --git a/damus/Models/ContentFilters.swift b/damus/Models/ContentFilters.swift @@ -13,6 +13,7 @@ enum FilterState : Int { case posts = 0 case posts_and_replies = 1 case conversations = 2 + case follow_list = 3 func filter(ev: NostrEvent) -> Bool { switch self { @@ -22,6 +23,8 @@ enum FilterState : Int { return true case .conversations: return true + case .follow_list: + return ev.known_kind == .follow_list } } } diff --git a/damus/Models/FollowPackEvent.swift b/damus/Models/FollowPackEvent.swift @@ -0,0 +1,39 @@ +// +// FollowPackEvent.swift +// damus +// +// Created by eric on 4/30/25. +// + + +import Foundation + +struct FollowPackEvent { + let event: NostrEvent + var title: String? = nil + var uuid: String? = nil + var image: URL? = nil + var description: String? = nil + var publicKeys: [Pubkey] = [] + + + static func parse(from ev: NostrEvent) -> FollowPackEvent { + var followlist = FollowPackEvent(event: ev) + + for tag in ev.tags { + guard tag.count >= 2 else { continue } + switch tag[0].string() { + case "title": followlist.title = tag[1].string() + case "d": followlist.uuid = tag[1].string() + case "image": followlist.image = URL(string: tag[1].string()) + case "description": followlist.description = tag[1].string() + case "p": + followlist.publicKeys.append(Pubkey(Data(hex: tag[1].string()))) + default: + break + } + } + + return followlist + } +} diff --git a/damus/Models/FollowPackModel.swift b/damus/Models/FollowPackModel.swift @@ -0,0 +1,77 @@ +// +// FollowPackModel.swift +// damus +// +// Created by eric on 6/5/25. +// + +import Foundation + + +class FollowPackModel: ObservableObject { + var events: EventHolder + @Published var loading: Bool = false + + let damus_state: DamusState + let subid = UUID().description + let limit: UInt32 = 500 + + init(damus_state: DamusState) { + self.damus_state = damus_state + self.events = EventHolder(on_queue: { ev in + preload_events(state: damus_state, events: [ev]) + }) + } + + func subscribe(follow_pack_users: [Pubkey]) { + loading = true + let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters) + var filter = NostrFilter(kinds: [.text, .chat]) + filter.until = UInt32(Date.now.timeIntervalSince1970) + filter.authors = follow_pack_users + filter.limit = 500 + + damus_state.nostrNetwork.pool.subscribe(sub_id: subid, filters: [filter], handler: handle_event, to: to_relays) + } + + func unsubscribe(to: RelayURL? = nil) { + loading = false + damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: to.map { [$0] }) + } + + func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) { + guard case .nostr_event(let event) = conn_ev else { + return + } + + switch event { + case .event(let sub_id, let ev): + guard sub_id == self.subid else { + return + } + if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply() + { + if self.events.insert(ev) { + self.objectWillChange.send() + } + } + case .notice(let msg): + print("follow pack notice: \(msg)") + case .ok: + break + case .eose(let sub_id): + loading = false + + if sub_id == self.subid { + unsubscribe(to: relay_id) + + guard let txn = NdbTxn(ndb: damus_state.ndb) else { return } + } + + break + case .auth: + break + } + } +} + diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -227,6 +227,8 @@ class HomeModel: ContactsDelegate { break case .relay_list: break // This will be handled by `UserRelayListManager` + case .follow_list: + break } } diff --git a/damus/Models/SearchHomeModel.swift b/damus/Models/SearchHomeModel.swift @@ -1,4 +1,3 @@ -// // SearchHomeModel.swift // damus // @@ -16,6 +15,7 @@ class SearchHomeModel: ObservableObject { var seen_pubkey: Set<Pubkey> = Set() let damus_state: DamusState let base_subid = UUID().description + let follow_pack_subid = UUID().description let profiles_subid = UUID().description let limit: UInt32 = 500 //let multiple_events_per_pubkey: Bool = false @@ -42,12 +42,18 @@ class SearchHomeModel: ObservableObject { func subscribe() { loading = true let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters) + + var follow_list_filter = NostrFilter(kinds: [.follow_list]) + follow_list_filter.until = UInt32(Date.now.timeIntervalSince1970) + damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event, to: to_relays) + damus_state.nostrNetwork.pool.subscribe(sub_id: follow_pack_subid, filters: [follow_list_filter], handler: handle_event, to: to_relays) } func unsubscribe(to: RelayURL? = nil) { loading = false damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] }) + damus_state.nostrNetwork.pool.unsubscribe(sub_id: follow_pack_subid, to: to.map { [$0] }) } func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) { @@ -57,7 +63,7 @@ class SearchHomeModel: ObservableObject { switch event { case .event(let sub_id, let ev): - guard sub_id == self.base_subid || sub_id == self.profiles_subid else { + guard sub_id == self.base_subid || sub_id == self.profiles_subid || sub_id == self.follow_pack_subid else { return } if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply() diff --git a/damus/Models/SearchModel.swift b/damus/Models/SearchModel.swift @@ -36,7 +36,7 @@ class SearchModel: ObservableObject { func subscribe() { // since 1 month search.limit = self.limit - search.kinds = [.text, .like, .longform, .highlight] + search.kinds = [.text, .like, .longform, .highlight, .follow_list] //likes_filter.ids = ref_events.referenced_ids! diff --git a/damus/Nostr/NostrKind.swift b/damus/Nostr/NostrKind.swift @@ -30,4 +30,5 @@ enum NostrKind: UInt32, Codable { case nwc_response = 23195 case http_auth = 27235 case status = 30315 + case follow_list = 39089 } diff --git a/damus/Util/Router.swift b/damus/Util/Router.swift @@ -49,6 +49,7 @@ enum Route: Hashable { case FollowersYouKnow(friendedFollowers: [Pubkey], followers: FollowersModel) case NIP05DomainEvents(events: NIP05DomainEventsModel, nip05_domain_favicon: FaviconURL?) case NIP05DomainPubkeys(domain: String, nip05_domain_favicon: FaviconURL?, pubkeys: [Pubkey]) + case FollowPack(followPack: NostrEvent, model: FollowPackModel, blur_imgs: Bool) @ViewBuilder func view(navigationCoordinator: NavigationCoordinator, damusState: DamusState) -> some View { @@ -134,6 +135,8 @@ enum Route: Hashable { NIP05DomainTimelineView(damus_state: damusState, model: events, nip05_domain_favicon: nip05_domain_favicon) case .NIP05DomainPubkeys(let domain, let nip05_domain_favicon, let pubkeys): NIP05DomainPubkeysView(damus_state: damusState, domain: domain, nip05_domain_favicon: nip05_domain_favicon, pubkeys: pubkeys) + case .FollowPack(let followPack, let followPackModel, let blur_imgs): + FollowPackView(state: damusState, ev: followPack, model: followPackModel, blur_imgs: blur_imgs) } } @@ -244,6 +247,9 @@ enum Route: Hashable { case .NIP05DomainPubkeys(let domain, _, _): hasher.combine("nip05DomainPubkeys") hasher.combine(domain) + case .FollowPack(let followPack, let followPackModel, let blur_imgs): + hasher.combine("followPack") + hasher.combine(followPack.id) } } } diff --git a/damus/Views/Events/FollowPack/FollowPackPreview.swift b/damus/Views/Events/FollowPack/FollowPackPreview.swift @@ -0,0 +1,242 @@ +// +// FollowPackPreview.swift +// damus +// +// Created by eric on 4/30/25. +// + +import SwiftUI +import Kingfisher + +struct FollowPackUsers: View { + let state: DamusState + var publicKeys: [Pubkey] + + var body: some View { + HStack(alignment: .center) { + + if !publicKeys.isEmpty { + CondensedProfilePicturesView(state: state, pubkeys: publicKeys, maxPictures: 5) + } + + let followPackUserCount = publicKeys.count + let nounString = pluralizedString(key: "follow_pack_user_count", count: followPackUserCount) + let nounText = Text(verbatim: nounString).font(.subheadline).foregroundColor(.gray) + Text("\(Text(verbatim: followPackUserCount.formatted()).font(.subheadline.weight(.medium))) \(nounText)", comment: "Sentence composed of 2 variables to describe how many people are in the follow pack. In source English, the first variable is the number of users, and the second variable is 'user' or 'users'.") + } + } +} + +struct FollowPackBannerImage: View { + let state: DamusState + let options: EventViewOptions + var image: URL? = nil + var preview: Bool + @State var blur_imgs: Bool + + func Placeholder(url: URL, preview: Bool) -> some View { + Group { + if let meta = state.events.lookup_img_metadata(url: url), + case .processed(let blurhash) = meta.state { + Image(uiImage: blurhash) + .resizable() + .frame(maxWidth: preview ? 350 : UIScreen.main.bounds.width, minHeight: preview ? 180 : 200, maxHeight: preview ? 180 : 200) + } else { + DamusColors.adaptableWhite + } + } + } + + func titleImage(url: URL, preview: Bool) -> some View { + KFAnimatedImage(url) + .callbackQueue(.dispatch(.global(qos:.background))) + .backgroundDecode(true) + .imageContext(.note, disable_animation: state.settings.disable_animation) + .image_fade(duration: 0.25) + .cancelOnDisappear(true) + .configure { view in + view.framePreloadCount = 3 + } + .background { + Placeholder(url: url, preview: preview) + } + .aspectRatio(contentMode: .fill) + .frame(maxWidth: preview ? 350 : UIScreen.main.bounds.width, minHeight: preview ? 180 : 200, maxHeight: preview ? 180 : 200) + .kfClickable() + .cornerRadius(1) + } + + var body: some View { + if let url = image { + if (self.options.contains(.no_media)) { + EmptyView() + } else if !blur_imgs { + titleImage(url: url, preview: preview) + } else { + ZStack { + titleImage(url: url, preview: preview) + BlurOverlayView(blur_images: $blur_imgs, artifacts: nil, size: nil, damus_state: nil, parentView: .longFormView) + .frame(maxWidth: preview ? 350 : UIScreen.main.bounds.width, minHeight: preview ? 180 : 200, maxHeight: preview ? 180 : 200) + } + } + } else { + Text(NSLocalizedString("No cover image", comment: "Text letting user know there is no cover image.")) + .foregroundColor(.gray) + .frame(width: 350, height: 180) + Divider() + } + } + +} + +struct FollowPackPreviewBody: View { + let state: DamusState + let event: FollowPackEvent + let options: EventViewOptions + let header: Bool + @State var blur_imgs: Bool + + @ObservedObject var artifacts: NoteArtifactsModel + + init(state: DamusState, ev: FollowPackEvent, options: EventViewOptions, header: Bool, blur_imgs: Bool) { + self.state = state + self.event = ev + self.options = options + self.header = header + self.blur_imgs = blur_imgs + + self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.event.id).artifacts_model) + } + + init(state: DamusState, ev: NostrEvent, options: EventViewOptions, header: Bool, blur_imgs: Bool) { + self.state = state + self.event = FollowPackEvent.parse(from: ev) + self.options = options + self.header = header + self.blur_imgs = blur_imgs + + self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model) + } + + var body: some View { + Group { + if options.contains(.wide) { + Main.padding(.horizontal) + } else { + Main + } + } + } + + var Main: some View { + VStack(alignment: .leading, spacing: 10) { + + if state.settings.media_previews { + FollowPackBannerImage(state: state, options: options, image: event.image, preview: true, blur_imgs: blur_imgs) + } + + Text(event.title ?? NSLocalizedString("Untitled", comment: "Title of follow list event if it is untitled.")) + .font(header ? .title : .headline) + .padding(.horizontal, 10) + .padding(.top, 5) + + if let description = event.description { + Text(description) + .font(header ? .body : .caption) + .foregroundColor(.gray) + .padding(.horizontal, 10) + } else { + Text("") + .font(header ? .body : .caption) + .foregroundColor(.gray) + .padding(.horizontal, 10) + } + + HStack(alignment: .center) { + ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true) + .onTapGesture { + state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey)) + } + let profile_txn = state.profiles.lookup(id: event.event.pubkey) + let profile = profile_txn?.unsafeUnownedValue + let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey) + switch displayName { + case .one(let one): + Text(one) + .font(.subheadline).foregroundColor(.gray) + + case .both(username: let username, displayName: let displayName): + HStack(spacing: 6) { + Text(verbatim: displayName) + .font(.subheadline).foregroundColor(.gray) + + Text(verbatim: "@\(username)") + .font(.subheadline).foregroundColor(.gray) + } + } + } + .padding(.horizontal, 10) + + FollowPackUsers(state: state, publicKeys: event.publicKeys) + .padding(.horizontal, 10) + .padding(.bottom, 20) + } + .frame(width: 350, height: state.settings.media_previews ? 330 : 150, alignment: .leading) + .background(DamusColors.neutral3) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(DamusColors.neutral1, lineWidth: 1) + ) + .padding(.top, 10) + } +} + +struct FollowPackPreview: View { + let state: DamusState + let event: FollowPackEvent + let options: EventViewOptions + @State var blur_imgs: Bool + + init(state: DamusState, ev: NostrEvent, options: EventViewOptions, blur_imgs: Bool) { + self.state = state + self.event = FollowPackEvent.parse(from: ev) + self.options = options.union(.no_mentions) + self.blur_imgs = blur_imgs + } + + var body: some View { + FollowPackPreviewBody(state: state, ev: event, options: options, header: false, blur_imgs: blur_imgs) + } +} + +let test_follow_list_event = FollowPackEvent.parse(from: NostrEvent( + content: "", + keypair: test_keypair, + kind: NostrKind.longform.rawValue, + tags: [ + ["title", "DAMUSES"], + ["description", "Damus Team"], + ["published_at", "1685638715"], + ["p", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"], + ["p", "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"], + ["p", "17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4"], + ["p", "520830c334a3f79f88cac934580d26f91a7832c6b21fb9625690ea2ed81b5626"], + ["p", "2779f3d9f42c7dee17f0e6bcdcf89a8f9d592d19e3b1bbd27ef1cffd1a7f98d1"], + ["p", "bd1e19980e2c91e6dc657e92c25762ca882eb9272d2579e221f037f93788de91"], + ["p", "e7424ad457e512fdf4764a56bf6d428a06a13a1006af1fb8e0fe32f6d03265c7"], + ["p", "b88c7f007bbf3bc2fcaeff9e513f186bab33782c0baa6a6cc12add78b9110ba3"], + ["p", "4a0510f26880d40e432f4865cb5714d9d3c200ca6ebb16b418ae6c555f574967"], + ["image", "https://damus.io/img/logo.png"], + ])! +) + + +struct FollowPackPreview_Previews: PreviewProvider { + static var previews: some View { + VStack { + FollowPackPreview(state: test_damus_state, ev: test_follow_list_event.event, options: [], blur_imgs: false) + } + .frame(height: 400) + } +} diff --git a/damus/Views/Events/FollowPack/FollowPackTimeline.swift b/damus/Views/Events/FollowPack/FollowPackTimeline.swift @@ -0,0 +1,135 @@ +// +// FollowPackTimeline.swift +// damus +// +// Created by eric on 5/6/25. +// + +import SwiftUI + +struct FollowPackTimelineView<Content: View>: View { + @ObservedObject var events: EventHolder + @Binding var loading: Bool + + let damus: DamusState + let show_friend_icon: Bool + let filter: (NostrEvent) -> Bool + let content: Content? + let apply_mute_rules: Bool + + init(events: EventHolder, loading: Binding<Bool>, headerHeight: Binding<CGFloat>, headerOffset: Binding<CGFloat>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) { + self.events = events + self._loading = loading + self.damus = damus + self.show_friend_icon = show_friend_icon + self.filter = filter + self.apply_mute_rules = apply_mute_rules + self.content = content?() + } + + init(events: EventHolder, loading: Binding<Bool>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) { + self.events = events + self._loading = loading + self.damus = damus + self.show_friend_icon = show_friend_icon + self.filter = filter + self.apply_mute_rules = apply_mute_rules + self.content = content?() + } + + var body: some View { + MainContent + } + + var MainContent: some View { + ScrollViewReader { scroller in + ScrollView(.horizontal) { + if let content { + content + } + + Color.clear + .id("startblock") + .frame(height: 0) + + FollowPackInnerView(events: events, damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules) + .redacted(reason: loading ? .placeholder : []) + .shimmer(loading) + .disabled(loading) + .background { + GeometryReader { proxy -> Color in + handle_scroll_queue(proxy, queue: self.events) + return Color.clear + } + } + } + .coordinateSpace(name: "scroll") + .onReceive(handle_notify(.scroll_to_top)) { () in + events.flush() + self.events.should_queue = false + scroll_to_event(scroller: scroller, id: "startblock", delay: 0.0, animate: true, anchor: .top) + } + } + .onAppear { + events.flush() + } + } +} + +struct FollowPackInnerView: View { + @ObservedObject var events: EventHolder + let state: DamusState + let filter: (NostrEvent) -> Bool + + init(events: EventHolder, damus: DamusState, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true) { + self.events = events + self.state = damus + self.filter = apply_mute_rules ? { filter($0) && !damus.mutelist_manager.is_event_muted($0) } : filter + } + + var event_options: EventViewOptions { + if self.state.settings.truncate_timeline_text { + return [.wide, .truncate_content] + } + + return [.wide] + } + + var body: some View { + LazyHStack(spacing: 0) { + let events = self.events.events + if events.isEmpty { + EmptyTimelineView() + } else { + let evs = events.filter(filter) + let indexed = Array(zip(evs, 0...)) + ForEach(indexed, id: \.0.id) { tup in + let ev = tup.0 + let ind = tup.1 + let blur_imgs = should_blur_images(settings: state.settings, contacts: state.contacts, ev: ev, our_pubkey: state.pubkey) + if ev.kind == NostrKind.follow_list.rawValue { + FollowPackPreview(state: state, ev: ev, options: event_options, blur_imgs: blur_imgs) + .onTapGesture { + state.nav.push(route: Route.FollowPack(followPack: ev, model: FollowPackModel(damus_state: state), blur_imgs: blur_imgs)) + } + .padding(.top, 7) + .onAppear { + let to_preload = + Array([indexed[safe: ind+1]?.0, + indexed[safe: ind+2]?.0, + indexed[safe: ind+3]?.0, + indexed[safe: ind+4]?.0, + indexed[safe: ind+5]?.0 + ].compactMap({ $0 })) + + preload_events(state: state, events: to_preload) + } + } + } + } + } + .padding(.bottom) + + } +} + diff --git a/damus/Views/Events/FollowPack/FollowPackView.swift b/damus/Views/Events/FollowPack/FollowPackView.swift @@ -0,0 +1,176 @@ +// +// FollowPackView.swift +// damus +// +// Created by eric on 4/30/25. +// + +import SwiftUI +import Kingfisher + +struct FollowPackView: View { + let state: DamusState + let event: FollowPackEvent + @StateObject var model: FollowPackModel + @State var blur_imgs: Bool + + @Environment(\.colorScheme) var colorScheme + + @ObservedObject var artifacts: NoteArtifactsModel + + init(state: DamusState, ev: FollowPackEvent, model: FollowPackModel, blur_imgs: Bool) { + self.state = state + self.event = ev + self._model = StateObject(wrappedValue: model) + self.blur_imgs = blur_imgs + + self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.event.id).artifacts_model) + } + + init(state: DamusState, ev: NostrEvent, model: FollowPackModel, blur_imgs: Bool) { + self.state = state + self.event = FollowPackEvent.parse(from: ev) + self._model = StateObject(wrappedValue: model) + self.blur_imgs = blur_imgs + + self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model) + } + + func content_filter(_ pubkeys: [Pubkey]) -> ((NostrEvent) -> Bool) { + var filters = ContentFilters.defaults(damus_state: self.state) + filters.append({ pubkeys.contains($0.pubkey) }) + return ContentFilters(filters: filters).filter + } + + enum FollowPackTabSelection: Int { + case people = 0 + case posts = 1 + } + + @State var tab_selection: FollowPackTabSelection = .people + + var body: some View { + ZStack { + ScrollView { + FollowPackHeader + + FollowPackTabs + } + } + .onAppear { + if model.events.events.isEmpty { + model.subscribe(follow_pack_users: event.publicKeys) + } + } + .onDisappear { + model.unsubscribe() + } + } + + var tabs: [(String, FollowPackTabSelection)] { + let tabs = [ + (NSLocalizedString("People", comment: "Label for filter for seeing the people in this follow pack."), FollowPackTabSelection.people), + (NSLocalizedString("Posts", comment: "Label for filter for seeing the posts from the people in this follow pack."), FollowPackTabSelection.posts) + ] + return tabs + } + + var FollowPackTabs: some View { + + VStack(spacing: 0) { + VStack(spacing: 0) { + CustomPicker(tabs: tabs, selection: $tab_selection) + Divider() + .frame(height: 1) + } + .background(colorScheme == .dark ? Color.black : Color.white) + + if tab_selection == FollowPackTabSelection.people { + LazyVStack(alignment: .leading) { + ForEach(event.publicKeys.reversed(), id: \.self) { pk in + FollowUserView(target: .pubkey(pk), damus_state: state) + } + } + .padding() + .padding(.bottom, 50) + .tag(FollowPackTabSelection.people) + .id(FollowPackTabSelection.people) + } + + if tab_selection == FollowPackTabSelection.posts { + InnerTimelineView(events: model.events, damus: state, filter: content_filter(event.publicKeys)) + } + } + .onAppear() { + model.subscribe(follow_pack_users: event.publicKeys) + } + .onDisappear { + model.unsubscribe() + } + } + + var FollowPackHeader: some View { + VStack(alignment: .leading, spacing: 10) { + + if state.settings.media_previews { + FollowPackBannerImage(state: state, options: EventViewOptions(), image: event.image, preview: false, blur_imgs: blur_imgs) + } + + Text(event.title ?? NSLocalizedString("Untitled", comment: "Title of follow list event if it is untitled.")) + .font(.title) + .padding(.horizontal, 10) + .padding(.top, 5) + + if let description = event.description { + Text(description) + .font(.body) + .foregroundColor(.gray) + .padding(.horizontal, 10) + } else { + EmptyView() + } + + HStack(alignment: .center) { + ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true) + .onTapGesture { + state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey)) + } + let profile_txn = state.profiles.lookup(id: event.event.pubkey) + let profile = profile_txn?.unsafeUnownedValue + let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey) + switch displayName { + case .one(let one): + Text(NSLocalizedString("Created by \(one)", comment: "Lets the user know who created this follow pack.")) + .font(.subheadline).foregroundColor(.gray) + + case .both(username: let username, displayName: let displayName): + HStack(spacing: 6) { + Text(NSLocalizedString("Created by \(displayName)", comment: "Lets the user know who created this follow pack.")) + .font(.subheadline).foregroundColor(.gray) + + Text(verbatim: "@\(username)") + .font(.subheadline).foregroundColor(.gray) + } + } + } + .padding(.horizontal, 10) + .padding(.bottom, 20) + + HStack(alignment: .center) { + FollowPackUsers(state: state, publicKeys: event.publicKeys) + } + .padding(.horizontal, 10) + .padding(.bottom, 20) + } + } +} + + +struct FollowPackView_Previews: PreviewProvider { + static var previews: some View { + VStack { + FollowPackView(state: test_damus_state, ev: test_follow_list_event, model: FollowPackModel(damus_state: test_damus_state), blur_imgs: false) + } + .frame(height: 400) + } +} diff --git a/damus/Views/LoadableNostrEventView.swift b/damus/Views/LoadableNostrEventView.swift @@ -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: + case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list, .follow_list: return .unknown_or_unsupported_kind } case .naddr(let naddr): diff --git a/damus/Views/Profile/ProfileView.swift b/damus/Views/Profile/ProfileView.swift @@ -123,7 +123,7 @@ struct ProfileView: View { var filters = ContentFilters.defaults(damus_state: damus_state) filters.append(fstate.filter) switch fstate { - case .posts, .posts_and_replies: + case .posts, .posts_and_replies, .follow_list: filters.append({ profile.pubkey == $0.pubkey }) case .conversations: filters.append({ profile.conversation_events.contains($0.id) } ) diff --git a/damus/Views/SearchHomeView.swift b/damus/Views/SearchHomeView.swift @@ -15,8 +15,9 @@ struct SearchHomeView: View { @State var search: String = "" @FocusState private var isFocused: Bool - var content_filter: (NostrEvent) -> Bool { - let filters = ContentFilters.defaults(damus_state: self.damus_state) + func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) { + var filters = ContentFilters.defaults(damus_state: damus_state) + filters.append(fstate.filter) return ContentFilters(filters: filters).filter } @@ -52,21 +53,20 @@ struct SearchHomeView: View { loading: $model.loading, damus: damus_state, show_friend_icon: true, - filter: { ev in - if !content_filter(ev) { - return false - } - - let event_muted = damus_state.mutelist_manager.is_event_muted(ev) - if event_muted { - return false - } - - return true - }, + filter:content_filter(FilterState.posts), content: { - AnyView(VStack { - SuggestedHashtagsView(damus_state: damus_state, max_items: 5, events: model.events) + AnyView(VStack(alignment: .leading) { + HStack { + Image(systemName: "sparkles") + .foregroundStyle(PinkGradient) + Text("Follow Packs", comment: "A label indicating that the items below it are follow packs") + .foregroundStyle(PinkGradient) + } + .padding(.top) + .padding(.horizontal) + + FollowPackTimelineView<AnyView>(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true,filter:content_filter(FilterState.follow_list) + ).padding(.bottom) Divider() .frame(height: 1) diff --git a/damus/en-US.lproj/Localizable.stringsdict b/damus/en-US.lproj/Localizable.stringsdict @@ -2,6 +2,22 @@ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> + <key>follow_pack_user_count</key> + <dict> + <key>NSStringLocalizedFormatKey</key> + <string>%#@FOLLOW_PACK_USERS@</string> + <key>FOLLOW_PACK_USERS</key> + <dict> + <key>NSStringFormatSpecTypeKey</key> + <string>NSStringPluralRuleType</string> + <key>NSStringFormatValueTypeKey</key> + <string>d</string> + <key>one</key> + <string>user</string> + <key>other</key> + <string>users</string> + </dict> + </dict> <key>followed_by_three_and_others</key> <dict> <key>NSStringLocalizedFormatKey</key> diff --git a/damusTests/LocalizationUtilTests.swift b/damusTests/LocalizationUtilTests.swift @@ -15,6 +15,7 @@ final class LocalizationUtilTests: XCTestCase { // Test cases of the localization string key, and the expected en-US strings for a count of 0, 1, and 2. let keys = [ + ["follow_pack_user_count", "users", "user", "users"], ["followers_count", "Followers", "Follower", "Followers"], ["following_count", "Following", "Following", "Following"], ["hellthread_notifications_disabled", "Hide notifications that tag more than 0 profiles", "Hide notifications that tag more than 1 profile", "Hide notifications that tag more than 2 profiles"], diff --git a/nostrdb/NdbNote.swift b/nostrdb/NdbNote.swift @@ -368,7 +368,7 @@ class NdbNote: Codable, Equatable, Hashable { // Extension to make NdbNote compatible with NostrEvent's original API extension NdbNote { var is_textlike: Bool { - return kind == 1 || kind == 42 || kind == 30023 || kind == 9802 + return kind == 1 || kind == 42 || kind == 30023 || kind == 9802 || kind == 39089 } var is_quote_repost: NoteId? {