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>
This commit is contained in:
committed by
Daniel D’Aquino
parent
f436291209
commit
414c67a919
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
damus/Models/FollowPackEvent.swift
Normal file
39
damus/Models/FollowPackEvent.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
77
damus/Models/FollowPackModel.swift
Normal file
77
damus/Models/FollowPackModel.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,8 @@ class HomeModel: ContactsDelegate {
|
||||
break
|
||||
case .relay_list:
|
||||
break // This will be handled by `UserRelayListManager`
|
||||
case .follow_list:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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!
|
||||
|
||||
|
||||
@@ -30,4 +30,5 @@ enum NostrKind: UInt32, Codable {
|
||||
case nwc_response = 23195
|
||||
case http_auth = 27235
|
||||
case status = 30315
|
||||
case follow_list = 39089
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
242
damus/Views/Events/FollowPack/FollowPackPreview.swift
Normal file
242
damus/Views/Events/FollowPack/FollowPackPreview.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
135
damus/Views/Events/FollowPack/FollowPackTimeline.swift
Normal file
135
damus/Views/Events/FollowPack/FollowPackTimeline.swift
Normal file
@@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
176
damus/Views/Events/FollowPack/FollowPackView.swift
Normal file
176
damus/Views/Events/FollowPack/FollowPackView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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) } )
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user