refactor: Adding structure
Huge refactor to add better structure to the project. Separating features with their associated view and model structure. This should be better organization and will allow us to improve the overall architecture in the future. I forsee many more improvements that can follow this change. e.g. MVVM Arch As well as cleaning up duplicate, unused, functionality. Many files have global functions that can also be moved or be renamed. damus/ ├── Features/ │ ├── <Feature>/ │ │ ├── Views/ │ │ └── Models/ ├── Shared/ │ ├── Components/ │ ├── Media/ │ ├── Buttons/ │ ├── Extensions/ │ ├── Empty Views/ │ ├── ErrorHandling/ │ ├── Modifiers/ │ └── Utilities/ ├── Core/ │ ├── Nostr/ │ ├── NIPs/ │ ├── DIPs/ │ ├── Types/ │ ├── Networking/ │ └── Storage/ Signed-off-by: ericholguin <ericholguin@apache.org>
This commit is contained in:
committed by
Daniel D’Aquino
parent
fdbf271432
commit
65a22813a3
46
damus/Features/Notifications/Models/EventGroup.swift
Normal file
46
damus/Features/Notifications/Models/EventGroup.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// ReactionGroup.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-02-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class EventGroup {
|
||||
var events: [NostrEvent]
|
||||
|
||||
var last_event_at: UInt32 {
|
||||
guard let first = self.events.first else {
|
||||
return 0
|
||||
}
|
||||
|
||||
return first.created_at
|
||||
}
|
||||
|
||||
init(events: [NostrEvent] = []) {
|
||||
self.events = events
|
||||
}
|
||||
|
||||
func insert(_ ev: NostrEvent) -> Bool {
|
||||
return insert_uniq_sorted_event_created(events: &events, new_ev: ev)
|
||||
}
|
||||
|
||||
func would_filter(_ isIncluded: (NostrEvent) -> Bool) -> Bool {
|
||||
for ev in events {
|
||||
if !isIncluded(ev) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func filter(_ isIncluded: (NostrEvent) -> Bool) -> EventGroup? {
|
||||
let new_evs = events.filter(isIncluded)
|
||||
guard new_evs.count > 0 else {
|
||||
return nil
|
||||
}
|
||||
return EventGroup(events: new_evs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//
|
||||
// NotificationStatusModel.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-06-23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class NotificationStatusModel: ObservableObject {
|
||||
@Published var new_events: NewEventsBits = NewEventsBits()
|
||||
}
|
||||
297
damus/Features/Notifications/Models/NotificationsManager.swift
Normal file
297
damus/Features/Notifications/Models/NotificationsManager.swift
Normal file
@@ -0,0 +1,297 @@
|
||||
//
|
||||
// NotificationsManager.swift
|
||||
// damus
|
||||
//
|
||||
// Handles several aspects of notification logic (Both local and push notifications)
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2023-11-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
let EVENT_MAX_AGE_FOR_NOTIFICATION: TimeInterval = 12 * 60 * 60
|
||||
|
||||
func process_local_notification(state: HeadlessDamusState, event ev: NostrEvent) {
|
||||
guard should_display_notification(state: state, event: ev, mode: .local) else {
|
||||
// We should not display notification. Exit.
|
||||
return
|
||||
}
|
||||
|
||||
guard let local_notification = generate_local_notification_object(from: ev, state: state) else {
|
||||
return
|
||||
}
|
||||
|
||||
create_local_notification(profiles: state.profiles, notify: local_notification)
|
||||
}
|
||||
|
||||
func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent, mode: UserSettingsStore.NotificationsMode) -> Bool {
|
||||
// Do not show notification if it's coming from a mode different from the one selected by our user
|
||||
guard state.settings.notification_mode == mode else {
|
||||
return false
|
||||
}
|
||||
|
||||
if ev.known_kind == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if state.settings.notification_only_from_following,
|
||||
state.contacts.follow_state(ev.pubkey) != .follows
|
||||
{
|
||||
return false
|
||||
}
|
||||
|
||||
if state.settings.hellthread_notifications_disabled && ev.is_hellthread(max_pubkeys: state.settings.hellthread_notification_max_pubkeys) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't show notifications that match mute list.
|
||||
if state.mutelist_manager.is_event_muted(ev) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't show notifications for old events
|
||||
guard ev.age < EVENT_MAX_AGE_FOR_NOTIFICATION else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't show notifications for future events.
|
||||
// Allow notes that are created no more than 3 seconds in the future
|
||||
// to account for natural clock skew between sender and receiver.
|
||||
guard ev.age >= -3 else {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func generate_local_notification_object(from ev: NostrEvent, state: HeadlessDamusState) -> LocalNotification? {
|
||||
guard let type = ev.known_kind else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if type == .text, state.settings.mention_notification {
|
||||
let blocks = ev.blocks(state.keypair).blocks
|
||||
|
||||
for case .mention(let mention) in blocks {
|
||||
guard case .pubkey(let pk) = mention.ref, pk == state.keypair.pubkey else {
|
||||
continue
|
||||
}
|
||||
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
|
||||
return LocalNotification(type: .mention, event: ev, target: .note(ev), content: content_preview)
|
||||
}
|
||||
|
||||
if ev.referenced_ids.contains(where: { note_id in
|
||||
guard let note_author: Pubkey = state.ndb.lookup_note(note_id)?.unsafeUnownedValue?.pubkey else { return false }
|
||||
guard note_author == state.keypair.pubkey else { return false }
|
||||
return true
|
||||
}) {
|
||||
// This is a reply to one of our posts
|
||||
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
|
||||
return LocalNotification(type: .reply, event: ev, target: .note(ev), content: content_preview)
|
||||
}
|
||||
|
||||
if ev.referenced_pubkeys.contains(state.keypair.pubkey) {
|
||||
// not mentioned or replied to, just tagged
|
||||
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
|
||||
return LocalNotification(type: .tagged, event: ev, target: .note(ev), content: content_preview)
|
||||
}
|
||||
|
||||
} else if type == .boost,
|
||||
state.settings.repost_notification,
|
||||
let inner_ev = ev.get_inner_event()
|
||||
{
|
||||
let content_preview = render_notification_content_preview(ev: inner_ev, profiles: state.profiles, keypair: state.keypair)
|
||||
return LocalNotification(type: .repost, event: ev, target: .note(inner_ev), content: content_preview)
|
||||
} else if type == .like, state.settings.like_notification, let evid = ev.referenced_ids.last {
|
||||
if let txn = state.ndb.lookup_note(evid, txn_name: "local_notification_like"),
|
||||
let liked_event = txn.unsafeUnownedValue
|
||||
{
|
||||
let content_preview = render_notification_content_preview(ev: liked_event, profiles: state.profiles, keypair: state.keypair)
|
||||
return LocalNotification(type: .like, event: ev, target: .note(liked_event), content: content_preview)
|
||||
} else {
|
||||
return LocalNotification(type: .like, event: ev, target: .note_id(evid), content: "")
|
||||
}
|
||||
}
|
||||
else if type == .dm,
|
||||
state.settings.dm_notification {
|
||||
let convo = ev.decrypted(keypair: state.keypair) ?? NSLocalizedString("New encrypted direct message", comment: "Notification that the user has received a new direct message")
|
||||
return LocalNotification(type: .dm, event: ev, target: .note(ev), content: convo)
|
||||
}
|
||||
else if type == .zap,
|
||||
state.settings.zap_notification {
|
||||
return LocalNotification(type: .zap, event: ev, target: .note(ev), content: ev.content)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func create_local_notification(profiles: Profiles, notify: LocalNotification) {
|
||||
let displayName = event_author_name(profiles: profiles, pubkey: notify.event.pubkey)
|
||||
|
||||
guard let (content, identifier) = NotificationFormatter.shared.format_message(displayName: displayName, notify: notify) else { return }
|
||||
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
||||
|
||||
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
|
||||
|
||||
UNUserNotificationCenter.current().add(request) { error in
|
||||
if let error = error {
|
||||
print("Error: \(error)")
|
||||
} else {
|
||||
print("Local notification scheduled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func render_notification_content_preview(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -> String {
|
||||
|
||||
let prefix_len = 300
|
||||
let artifacts = render_note_content(ev: ev, profiles: profiles, keypair: keypair)
|
||||
|
||||
// special case for longform events
|
||||
if ev.known_kind == .longform {
|
||||
let longform = LongformEvent(event: ev)
|
||||
return longform.title ?? longform.summary ?? "Longform Event"
|
||||
}
|
||||
|
||||
switch artifacts {
|
||||
case .longform:
|
||||
// we should never hit this until we have more note types built out of parts
|
||||
// since we handle this case above in known_kind == .longform
|
||||
return String(ev.content.prefix(prefix_len))
|
||||
|
||||
case .separated(let artifacts):
|
||||
return String(NSAttributedString(artifacts.content.attributed).string.prefix(prefix_len))
|
||||
}
|
||||
}
|
||||
|
||||
func event_author_name(profiles: Profiles, pubkey: Pubkey) -> String {
|
||||
let profile_txn = profiles.lookup(id: pubkey)
|
||||
let profile = profile_txn?.unsafeUnownedValue
|
||||
return Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func get_zap(from ev: NostrEvent, state: HeadlessDamusState) async -> Zap? {
|
||||
return await withCheckedContinuation { continuation in
|
||||
process_zap_event(state: state, ev: ev) { zapres in
|
||||
continuation.resume(returning: zapres.get_zap())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func process_zap_event(state: HeadlessDamusState, ev: NostrEvent, completion: @escaping (ProcessZapResult) -> Void) {
|
||||
// These are zap notifications
|
||||
guard let ptag = get_zap_target_pubkey(ev: ev, ndb: state.ndb) else {
|
||||
completion(.failed)
|
||||
return
|
||||
}
|
||||
|
||||
// just return the zap if we already have it
|
||||
if let zap = state.zaps.zaps[ev.id], case .zap(let z) = zap {
|
||||
completion(.already_processed(z))
|
||||
return
|
||||
}
|
||||
|
||||
if let local_zapper = state.profiles.lookup_zapper(pubkey: ptag) {
|
||||
guard let zap = process_zap_event_with_zapper(state: state, ev: ev, zapper: local_zapper) else {
|
||||
completion(.failed)
|
||||
return
|
||||
}
|
||||
state.add_zap(zap: .zap(zap))
|
||||
completion(.done(zap))
|
||||
return
|
||||
}
|
||||
|
||||
guard let txn = state.profiles.lookup_with_timestamp(ptag),
|
||||
let lnurl = txn.map({ pr in pr?.lnurl }).value else {
|
||||
completion(.failed)
|
||||
return
|
||||
}
|
||||
|
||||
Task { [lnurl] in
|
||||
guard let zapper = await fetch_zapper_from_lnurl(lnurls: state.lnurls, pubkey: ptag, lnurl: lnurl) else {
|
||||
completion(.failed)
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
state.profiles.profile_data(ptag).zapper = zapper
|
||||
guard let zap = process_zap_event_with_zapper(state: state, ev: ev, zapper: zapper) else {
|
||||
completion(.failed)
|
||||
return
|
||||
}
|
||||
state.add_zap(zap: .zap(zap))
|
||||
completion(.done(zap))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// securely get the zap target's pubkey. this can be faked so we need to be
|
||||
// careful
|
||||
func get_zap_target_pubkey(ev: NostrEvent, ndb: Ndb) -> Pubkey? {
|
||||
let etags = Array(ev.referenced_ids)
|
||||
|
||||
guard let etag = etags.first else {
|
||||
// no etags, ptag-only case
|
||||
|
||||
guard let a = ev.referenced_pubkeys.just_one() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: just return data here
|
||||
return a
|
||||
}
|
||||
|
||||
// we have an e-tag
|
||||
|
||||
// ensure that there is only 1 etag to stop fake note zap attacks
|
||||
guard etags.count == 1 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// we can't trust the p tag on note zaps because they can be faked
|
||||
guard let txn = ndb.lookup_note(etag),
|
||||
let pk = txn.unsafeUnownedValue?.pubkey else {
|
||||
// We don't have the event in cache so we can't check the pubkey.
|
||||
|
||||
// We could return this as an invalid zap but that wouldn't be correct
|
||||
// all of the time, and may reject valid zaps. What we need is a new
|
||||
// unvalidated zap state, but for now we simply leak a bit of correctness...
|
||||
|
||||
return ev.referenced_pubkeys.just_one()
|
||||
}
|
||||
|
||||
return pk
|
||||
}
|
||||
|
||||
fileprivate func process_zap_event_with_zapper(state: HeadlessDamusState, ev: NostrEvent, zapper: Pubkey) -> Zap? {
|
||||
let our_keypair = state.keypair
|
||||
|
||||
guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: our_keypair.privkey) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
state.add_zap(zap: .zap(zap))
|
||||
|
||||
return zap
|
||||
}
|
||||
|
||||
enum ProcessZapResult {
|
||||
case already_processed(Zap)
|
||||
case done(Zap)
|
||||
case failed
|
||||
|
||||
func get_zap() -> Zap? {
|
||||
switch self {
|
||||
case .already_processed(let zap):
|
||||
return zap
|
||||
case .done(let zap):
|
||||
return zap
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
391
damus/Features/Notifications/Models/NotificationsModel.swift
Normal file
391
damus/Features/Notifications/Models/NotificationsModel.swift
Normal file
@@ -0,0 +1,391 @@
|
||||
//
|
||||
// NotificationsModel.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-02-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum NotificationItem {
|
||||
case repost(NoteId, EventGroup)
|
||||
case reaction(NoteId, EventGroup)
|
||||
case profile_zap(ZapGroup)
|
||||
case event_zap(NoteId, ZapGroup)
|
||||
case reply(NostrEvent)
|
||||
case damus_app_notification(DamusAppNotification)
|
||||
|
||||
var is_reply: NostrEvent? {
|
||||
if case .reply(let ev) = self {
|
||||
return ev
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var is_zap: ZapGroup? {
|
||||
switch self {
|
||||
case .profile_zap(let zapgrp):
|
||||
return zapgrp
|
||||
case .event_zap(_, let zapgrp):
|
||||
return zapgrp
|
||||
case .reaction:
|
||||
return nil
|
||||
case .reply:
|
||||
return nil
|
||||
case .repost:
|
||||
return nil
|
||||
case .damus_app_notification(_):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var last_event_at: UInt32 {
|
||||
switch self {
|
||||
case .reaction(_, let evgrp):
|
||||
return evgrp.last_event_at
|
||||
case .repost(_, let evgrp):
|
||||
return evgrp.last_event_at
|
||||
case .profile_zap(let zapgrp):
|
||||
return zapgrp.last_event_at
|
||||
case .event_zap(_, let zapgrp):
|
||||
return zapgrp.last_event_at
|
||||
case .reply(let reply):
|
||||
return reply.created_at
|
||||
case .damus_app_notification(let notification):
|
||||
return notification.last_event_at
|
||||
}
|
||||
}
|
||||
|
||||
func would_filter(_ isIncluded: (NostrEvent) -> Bool) -> Bool {
|
||||
switch self {
|
||||
case .repost(_, let evgrp):
|
||||
return evgrp.would_filter(isIncluded)
|
||||
case .reaction(_, let evgrp):
|
||||
return evgrp.would_filter(isIncluded)
|
||||
case .profile_zap(let zapgrp):
|
||||
return zapgrp.would_filter(isIncluded)
|
||||
case .event_zap(_, let zapgrp):
|
||||
return zapgrp.would_filter(isIncluded)
|
||||
case .reply(let ev):
|
||||
return !isIncluded(ev)
|
||||
case .damus_app_notification(_):
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func filter(_ isIncluded: (NostrEvent) -> Bool) -> NotificationItem? {
|
||||
switch self {
|
||||
case .repost(let evid, let evgrp):
|
||||
return evgrp.filter(isIncluded).map { .repost(evid, $0) }
|
||||
case .reaction(let evid, let evgrp):
|
||||
return evgrp.filter(isIncluded).map { .reaction(evid, $0) }
|
||||
case .profile_zap(let zapgrp):
|
||||
return zapgrp.filter(isIncluded).map { .profile_zap($0) }
|
||||
case .event_zap(let evid, let zapgrp):
|
||||
return zapgrp.filter(isIncluded).map { .event_zap(evid, $0) }
|
||||
case .reply(let ev):
|
||||
if isIncluded(ev) { return .reply(ev) }
|
||||
return nil
|
||||
case .damus_app_notification(_):
|
||||
return self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationsModel: ObservableObject, ScrollQueue {
|
||||
var incoming_zaps: [Zapping] = []
|
||||
var incoming_events: [NostrEvent] = []
|
||||
var should_queue: Bool = true
|
||||
|
||||
// mappings from events to
|
||||
var zaps: [NoteId: ZapGroup] = [:]
|
||||
var profile_zaps = ZapGroup()
|
||||
var reactions: [NoteId: EventGroup] = [:]
|
||||
var reposts: [NoteId: EventGroup] = [:]
|
||||
var replies: [NostrEvent] = []
|
||||
var incoming_app_notifications: [DamusAppNotification] = []
|
||||
var app_notifications: [DamusAppNotification] = []
|
||||
var has_app_notification = Set<DamusAppNotification.Content>()
|
||||
var has_reply = Set<NoteId>()
|
||||
var has_ev = Set<NoteId>()
|
||||
|
||||
@Published var notifications: [NotificationItem] = []
|
||||
|
||||
func set_should_queue(_ val: Bool) {
|
||||
self.should_queue = val
|
||||
}
|
||||
|
||||
func uniq_pubkeys() -> [Pubkey] {
|
||||
var pks = Set<Pubkey>()
|
||||
|
||||
for ev in incoming_events {
|
||||
pks.insert(ev.pubkey)
|
||||
}
|
||||
|
||||
for grp in reposts {
|
||||
for ev in grp.value.events {
|
||||
pks.insert(ev.pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
for ev in replies {
|
||||
pks.insert(ev.pubkey)
|
||||
}
|
||||
|
||||
for zap in incoming_zaps {
|
||||
pks.insert(zap.request.ev.pubkey)
|
||||
}
|
||||
|
||||
return Array(pks)
|
||||
}
|
||||
|
||||
func build_notifications() -> [NotificationItem] {
|
||||
var notifs: [NotificationItem] = []
|
||||
|
||||
for el in zaps {
|
||||
let evid = el.key
|
||||
let zapgrp = el.value
|
||||
|
||||
let notif: NotificationItem = .event_zap(evid, zapgrp)
|
||||
notifs.append(notif)
|
||||
}
|
||||
|
||||
if !profile_zaps.zaps.isEmpty {
|
||||
notifs.append(.profile_zap(profile_zaps))
|
||||
}
|
||||
|
||||
for el in reposts {
|
||||
let evid = el.key
|
||||
let evgrp = el.value
|
||||
|
||||
notifs.append(.repost(evid, evgrp))
|
||||
}
|
||||
|
||||
for el in reactions {
|
||||
let evid = el.key
|
||||
let evgrp = el.value
|
||||
|
||||
notifs.append(.reaction(evid, evgrp))
|
||||
}
|
||||
|
||||
for reply in replies {
|
||||
notifs.append(.reply(reply))
|
||||
}
|
||||
|
||||
for app_notification in app_notifications {
|
||||
notifs.append(.damus_app_notification(app_notification))
|
||||
}
|
||||
|
||||
notifs.sort { $0.last_event_at > $1.last_event_at }
|
||||
return notifs
|
||||
}
|
||||
|
||||
|
||||
private func insert_repost(_ ev: NostrEvent, cache: EventCache) -> Bool {
|
||||
guard let reposted_ev = ev.get_inner_event(cache: cache) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let id = reposted_ev.id
|
||||
|
||||
if let evgrp = self.reposts[id] {
|
||||
return evgrp.insert(ev)
|
||||
} else {
|
||||
let evgrp = EventGroup()
|
||||
self.reposts[id] = evgrp
|
||||
return evgrp.insert(ev)
|
||||
}
|
||||
}
|
||||
|
||||
private func insert_text(_ ev: NostrEvent) -> Bool {
|
||||
guard !has_reply.contains(ev.id) else {
|
||||
return false
|
||||
}
|
||||
|
||||
has_reply.insert(ev.id)
|
||||
replies.append(ev)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func insert_reaction(_ ev: NostrEvent) -> Bool {
|
||||
guard let id = ev.referenced_ids.last else {
|
||||
return false
|
||||
}
|
||||
|
||||
if let evgrp = self.reactions[id] {
|
||||
return evgrp.insert(ev)
|
||||
} else {
|
||||
let evgrp = EventGroup()
|
||||
self.reactions[id] = evgrp
|
||||
return evgrp.insert(ev)
|
||||
}
|
||||
}
|
||||
|
||||
private func insert_event_immediate(_ ev: NostrEvent, cache: EventCache) -> Bool {
|
||||
if ev.known_kind == .boost {
|
||||
return insert_repost(ev, cache: cache)
|
||||
} else if ev.known_kind == .like {
|
||||
return insert_reaction(ev)
|
||||
} else if ev.known_kind == .text {
|
||||
return insert_text(ev)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func insert_zap_immediate(_ zap: Zapping) -> Bool {
|
||||
switch zap.target {
|
||||
case .note(let notezt):
|
||||
let id = notezt.note_id
|
||||
if let zapgrp = self.zaps[notezt.note_id] {
|
||||
return zapgrp.insert(zap)
|
||||
} else {
|
||||
let zapgrp = ZapGroup()
|
||||
self.zaps[id] = zapgrp
|
||||
return zapgrp.insert(zap)
|
||||
}
|
||||
|
||||
case .profile:
|
||||
return profile_zaps.insert(zap)
|
||||
}
|
||||
}
|
||||
|
||||
func insert_event(_ ev: NostrEvent, damus_state: DamusState) -> Bool {
|
||||
if has_ev.contains(ev.id) {
|
||||
return false
|
||||
}
|
||||
|
||||
if should_queue {
|
||||
incoming_events.append(ev)
|
||||
has_ev.insert(ev.id)
|
||||
return true
|
||||
}
|
||||
|
||||
if insert_event_immediate(ev, cache: damus_state.events) {
|
||||
self.notifications = build_notifications()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func insert_app_notification(notification: DamusAppNotification) -> Bool {
|
||||
if has_app_notification.contains(notification.content) {
|
||||
return false
|
||||
}
|
||||
|
||||
if should_queue {
|
||||
incoming_app_notifications.append(notification)
|
||||
return true
|
||||
}
|
||||
|
||||
if insert_app_notification_immediate(notification: notification) {
|
||||
self.notifications = build_notifications()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func insert_app_notification_immediate(notification: DamusAppNotification) -> Bool {
|
||||
if has_app_notification.contains(notification.content) {
|
||||
return false
|
||||
}
|
||||
self.app_notifications.append(notification)
|
||||
has_app_notification.insert(notification.content)
|
||||
return true
|
||||
}
|
||||
|
||||
func insert_zap(_ zap: Zapping) -> Bool {
|
||||
if should_queue {
|
||||
return insert_uniq_sorted_zap_by_created(zaps: &incoming_zaps, new_zap: zap)
|
||||
}
|
||||
|
||||
if insert_zap_immediate(zap) {
|
||||
self.notifications = build_notifications()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func filter(_ isIncluded: (NostrEvent) -> Bool) {
|
||||
var changed = false
|
||||
var count = 0
|
||||
|
||||
count = incoming_events.count
|
||||
incoming_events = incoming_events.filter(isIncluded)
|
||||
changed = changed || incoming_events.count != count
|
||||
|
||||
count = profile_zaps.zaps.count
|
||||
profile_zaps.zaps = profile_zaps.zaps.filter { zap in isIncluded(zap.request.ev) }
|
||||
changed = changed || profile_zaps.zaps.count != count
|
||||
|
||||
for el in reactions {
|
||||
count = el.value.events.count
|
||||
el.value.events = el.value.events.filter(isIncluded)
|
||||
changed = changed || el.value.events.count != count
|
||||
}
|
||||
|
||||
for el in reposts {
|
||||
count = el.value.events.count
|
||||
el.value.events = el.value.events.filter(isIncluded)
|
||||
changed = changed || el.value.events.count != count
|
||||
}
|
||||
|
||||
for el in zaps {
|
||||
count = el.value.zaps.count
|
||||
el.value.zaps = el.value.zaps.filter {
|
||||
isIncluded($0.request.ev)
|
||||
}
|
||||
changed = changed || el.value.zaps.count != count
|
||||
}
|
||||
|
||||
count = replies.count
|
||||
replies = replies.filter(isIncluded)
|
||||
changed = changed || replies.count != count
|
||||
|
||||
if changed {
|
||||
self.notifications = build_notifications()
|
||||
}
|
||||
}
|
||||
|
||||
func flush(_ damus_state: DamusState) -> Bool {
|
||||
var inserted = false
|
||||
|
||||
for zap in incoming_zaps {
|
||||
inserted = insert_zap_immediate(zap) || inserted
|
||||
}
|
||||
|
||||
for event in incoming_events {
|
||||
inserted = insert_event_immediate(event, cache: damus_state.events) || inserted
|
||||
}
|
||||
|
||||
for incoming_app_notification in incoming_app_notifications {
|
||||
inserted = insert_app_notification_immediate(notification: incoming_app_notification) || inserted
|
||||
}
|
||||
|
||||
if inserted {
|
||||
self.notifications = build_notifications()
|
||||
}
|
||||
|
||||
return inserted
|
||||
}
|
||||
}
|
||||
|
||||
struct DamusAppNotification {
|
||||
let notification_timestamp: Date
|
||||
var last_event_at: UInt32 { UInt32(notification_timestamp.timeIntervalSince1970) }
|
||||
let content: Content
|
||||
|
||||
init(content: Content, timestamp: Date) {
|
||||
self.notification_timestamp = timestamp
|
||||
self.content = content
|
||||
}
|
||||
|
||||
enum Content: Hashable, Equatable {
|
||||
case purple_impending_expiration(days_remaining: Int, expiry_date: UInt64)
|
||||
case purple_expired(expiry_date: UInt64)
|
||||
}
|
||||
}
|
||||
315
damus/Features/Notifications/Models/PushNotificationClient.swift
Normal file
315
damus/Features/Notifications/Models/PushNotificationClient.swift
Normal file
@@ -0,0 +1,315 @@
|
||||
//
|
||||
// PushNotificationClient.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-05-17.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Minimum threshold the hellthread pubkey tag count setting can go down to.
|
||||
let HELLTHREAD_MIN_PUBKEYS: Int = 6
|
||||
|
||||
// Maximum threshold the hellthread pubkey tag count setting can go up to.
|
||||
let HELLTHREAD_MAX_PUBKEYS: Int = 24
|
||||
|
||||
struct PushNotificationClient {
|
||||
let keypair: Keypair
|
||||
let settings: UserSettingsStore
|
||||
private(set) var device_token: Data? = nil
|
||||
var device_token_hex: String? {
|
||||
guard let device_token else { return nil }
|
||||
return device_token.map { String(format: "%02.2hhx", $0) }.joined()
|
||||
}
|
||||
|
||||
mutating func set_device_token(new_device_token: Data) async throws {
|
||||
self.device_token = new_device_token
|
||||
if settings.enable_push_notifications && settings.notification_mode == .push {
|
||||
try await self.send_token()
|
||||
}
|
||||
}
|
||||
|
||||
func send_token() async throws {
|
||||
// Send the device token and pubkey to the server
|
||||
guard let token = device_token_hex else { return }
|
||||
|
||||
Log.info("Sending device token to server: %s", for: .push_notifications, token)
|
||||
|
||||
// create post request
|
||||
let url = self.current_push_notification_environment().api_base_url()
|
||||
.appendingPathComponent("user-info")
|
||||
.appendingPathComponent(self.keypair.pubkey.hex())
|
||||
.appendingPathComponent(token)
|
||||
|
||||
let (data, response) = try await make_nip98_authenticated_request(
|
||||
method: .put,
|
||||
url: url,
|
||||
payload: nil,
|
||||
payload_type: .json,
|
||||
auth_keypair: self.keypair
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
Log.info("Sent device token to Damus push notification server successfully", for: .push_notifications)
|
||||
default:
|
||||
Log.error("Error in sending device_token to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
|
||||
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func revoke_token() async throws {
|
||||
guard let token = device_token_hex else { return }
|
||||
|
||||
Log.info("Revoking device token from server: %s", for: .push_notifications, token)
|
||||
|
||||
let pubkey = self.keypair.pubkey
|
||||
|
||||
// create post request
|
||||
let url = self.current_push_notification_environment().api_base_url()
|
||||
.appendingPathComponent("user-info")
|
||||
.appendingPathComponent(pubkey.hex())
|
||||
.appendingPathComponent(token)
|
||||
|
||||
|
||||
let (data, response) = try await make_nip98_authenticated_request(
|
||||
method: .delete,
|
||||
url: url,
|
||||
payload: nil,
|
||||
payload_type: .json,
|
||||
auth_keypair: self.keypair
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
Log.info("Sent device token removal request to Damus push notification server successfully", for: .push_notifications)
|
||||
default:
|
||||
Log.error("Error in sending device_token removal to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
|
||||
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func set_settings(_ new_settings: NotificationSettings? = nil) async throws {
|
||||
// Send the device token and pubkey to the server
|
||||
guard let token = device_token_hex else { return }
|
||||
|
||||
Log.info("Sending notification preferences to the server", for: .push_notifications)
|
||||
|
||||
let url = self.current_push_notification_environment().api_base_url()
|
||||
.appendingPathComponent("user-info")
|
||||
.appendingPathComponent(self.keypair.pubkey.hex())
|
||||
.appendingPathComponent(token)
|
||||
.appendingPathComponent("preferences")
|
||||
|
||||
let json_payload = try JSONEncoder().encode(new_settings ?? NotificationSettings.from(settings: settings))
|
||||
|
||||
let (data, response) = try await make_nip98_authenticated_request(
|
||||
method: .put,
|
||||
url: url,
|
||||
payload: json_payload,
|
||||
payload_type: .json,
|
||||
auth_keypair: self.keypair
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
Log.info("Sent notification settings to Damus push notification server successfully", for: .push_notifications)
|
||||
default:
|
||||
Log.error("Error in sending notification settings to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
|
||||
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func get_settings() async throws -> NotificationSettings {
|
||||
// Send the device token and pubkey to the server
|
||||
guard let token = device_token_hex else {
|
||||
throw ClientError.no_device_token
|
||||
}
|
||||
|
||||
let url = self.current_push_notification_environment().api_base_url()
|
||||
.appendingPathComponent("user-info")
|
||||
.appendingPathComponent(self.keypair.pubkey.hex())
|
||||
.appendingPathComponent(token)
|
||||
.appendingPathComponent("preferences")
|
||||
|
||||
let (data, response) = try await make_nip98_authenticated_request(
|
||||
method: .get,
|
||||
url: url,
|
||||
payload: nil,
|
||||
payload_type: .json,
|
||||
auth_keypair: self.keypair
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
guard let notification_settings = NotificationSettings.from(json_data: data) else { throw ClientError.json_decoding_error }
|
||||
return notification_settings
|
||||
default:
|
||||
Log.error("Error in getting notification settings to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
|
||||
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
|
||||
}
|
||||
}
|
||||
throw ClientError.could_not_process_response
|
||||
}
|
||||
|
||||
func current_push_notification_environment() -> Environment {
|
||||
return self.settings.push_notification_environment
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Helper structures
|
||||
|
||||
extension PushNotificationClient {
|
||||
enum ClientError: Error {
|
||||
case http_response_error(status_code: Int, response: Data)
|
||||
case could_not_process_response
|
||||
case no_device_token
|
||||
case json_decoding_error
|
||||
}
|
||||
|
||||
struct NotificationSettings: Codable, Equatable {
|
||||
let zap_notifications_enabled: Bool?
|
||||
let mention_notifications_enabled: Bool?
|
||||
let repost_notifications_enabled: Bool?
|
||||
let reaction_notifications_enabled: Bool?
|
||||
let dm_notifications_enabled: Bool?
|
||||
let only_notifications_from_following_enabled: Bool?
|
||||
let hellthread_notifications_disabled: Bool?
|
||||
let hellthread_notifications_max_pubkeys: Int?
|
||||
|
||||
static func from(json_data: Data) -> Self? {
|
||||
guard let decoded = try? JSONDecoder().decode(Self.self, from: json_data) else { return nil }
|
||||
|
||||
// Normalize hellthread_notifications_max_pubkeys in case
|
||||
// it goes beyond the expected range supported on the client.
|
||||
if let max_pubkeys = decoded.hellthread_notifications_max_pubkeys, max_pubkeys < HELLTHREAD_MIN_PUBKEYS || max_pubkeys > HELLTHREAD_MAX_PUBKEYS {
|
||||
return NotificationSettings(
|
||||
zap_notifications_enabled: decoded.zap_notifications_enabled,
|
||||
mention_notifications_enabled: decoded.mention_notifications_enabled,
|
||||
repost_notifications_enabled: decoded.repost_notifications_enabled,
|
||||
reaction_notifications_enabled: decoded.reaction_notifications_enabled,
|
||||
dm_notifications_enabled: decoded.dm_notifications_enabled,
|
||||
only_notifications_from_following_enabled: decoded.only_notifications_from_following_enabled,
|
||||
hellthread_notifications_disabled: decoded.hellthread_notifications_disabled,
|
||||
hellthread_notifications_max_pubkeys: max(min(HELLTHREAD_MAX_PUBKEYS, max_pubkeys), HELLTHREAD_MIN_PUBKEYS)
|
||||
)
|
||||
}
|
||||
|
||||
return decoded
|
||||
}
|
||||
|
||||
static func from(settings: UserSettingsStore) -> Self {
|
||||
return NotificationSettings(
|
||||
zap_notifications_enabled: settings.zap_notification,
|
||||
mention_notifications_enabled: settings.mention_notification,
|
||||
repost_notifications_enabled: settings.repost_notification,
|
||||
reaction_notifications_enabled: settings.like_notification,
|
||||
dm_notifications_enabled: settings.dm_notification,
|
||||
only_notifications_from_following_enabled: settings.notification_only_from_following,
|
||||
hellthread_notifications_disabled: settings.hellthread_notifications_disabled,
|
||||
hellthread_notifications_max_pubkeys: settings.hellthread_notification_max_pubkeys
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum Environment: CaseIterable, Codable, Identifiable, StringCodable, Equatable, Hashable {
|
||||
static var allCases: [Environment] = [.local_test(host: nil), .staging, .production]
|
||||
|
||||
case local_test(host: String?)
|
||||
case staging
|
||||
case production
|
||||
|
||||
func text_description() -> String {
|
||||
switch self {
|
||||
case .local_test:
|
||||
return NSLocalizedString("Test (local)", comment: "Label indicating a local test environment for Push notification functionality (Developer feature)")
|
||||
case .production:
|
||||
return NSLocalizedString("Production", comment: "Label indicating the production environment for Push notification functionality")
|
||||
case .staging:
|
||||
return NSLocalizedString("Staging (for dev builds)", comment: "Label indicating the staging environment for Push notification functionality")
|
||||
}
|
||||
}
|
||||
|
||||
func api_base_url() -> URL {
|
||||
switch self {
|
||||
case .local_test(let host):
|
||||
URL(string: "http://\(host ?? "localhost:8000")") ?? Constants.PUSH_NOTIFICATION_SERVER_TEST_BASE_URL
|
||||
case .production:
|
||||
Constants.PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL
|
||||
case .staging:
|
||||
Constants.PUSH_NOTIFICATION_SERVER_STAGING_BASE_URL
|
||||
}
|
||||
}
|
||||
|
||||
func custom_host() -> String? {
|
||||
switch self {
|
||||
case .local_test(let host):
|
||||
return host
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
init?(from string: String) {
|
||||
switch string {
|
||||
case "local_test":
|
||||
self = .local_test(host: nil)
|
||||
case "production":
|
||||
self = .production
|
||||
case "staging":
|
||||
self = .staging
|
||||
default:
|
||||
let components = string.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
if components.count == 2 && components[0] == "local_test" {
|
||||
self = .local_test(host: String(components[1]))
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func to_string() -> String {
|
||||
switch self {
|
||||
case .local_test(let host):
|
||||
if let host {
|
||||
return "local_test:\(host)"
|
||||
}
|
||||
return "local_test"
|
||||
case .staging:
|
||||
return "staging"
|
||||
case .production:
|
||||
return "production"
|
||||
}
|
||||
}
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .local_test(let host):
|
||||
if let host {
|
||||
return "local_test:\(host)"
|
||||
}
|
||||
else {
|
||||
return "local_test"
|
||||
}
|
||||
case .production:
|
||||
return "production"
|
||||
case .staging:
|
||||
return "staging"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
damus/Features/Notifications/Models/ZapGroup.swift
Normal file
64
damus/Features/Notifications/Models/ZapGroup.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// ZapGroup.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-02-21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class ZapGroup {
|
||||
var zaps: [Zapping] = []
|
||||
var msat_total: Int64 = 0
|
||||
var zappers = Set<Pubkey>()
|
||||
|
||||
var last_event_at: UInt32 {
|
||||
guard let first = zaps.first else {
|
||||
return 0
|
||||
}
|
||||
|
||||
return first.created_at
|
||||
}
|
||||
|
||||
func zap_requests() -> [NostrEvent] {
|
||||
zaps.map { z in z.request.ev }
|
||||
}
|
||||
|
||||
func would_filter(_ isIncluded: (NostrEvent) -> Bool) -> Bool {
|
||||
for zap in zaps {
|
||||
if !isIncluded(zap.request.ev) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func filter(_ isIncluded: (NostrEvent) -> Bool) -> ZapGroup? {
|
||||
let new_zaps = zaps.filter { isIncluded($0.request.ev) }
|
||||
guard new_zaps.count > 0 else {
|
||||
return nil
|
||||
}
|
||||
let grp = ZapGroup()
|
||||
for zap in new_zaps {
|
||||
grp.insert(zap)
|
||||
}
|
||||
return grp
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func insert(_ zap: Zapping) -> Bool {
|
||||
if !insert_uniq_sorted_zap_by_created(zaps: &zaps, new_zap: zap) {
|
||||
return false
|
||||
}
|
||||
|
||||
msat_total += zap.amount
|
||||
|
||||
if !zappers.contains(zap.request.ev.pubkey) {
|
||||
zappers.insert(zap.request.ev.pubkey)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//
|
||||
// DamusAppNotificationView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-02-23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
fileprivate let DEEP_WEBSITE_LINK = false
|
||||
|
||||
// TODO: Load products in a more dynamic way (if we move forward with checkout deep linking)
|
||||
fileprivate let PURPLE_ONE_MONTH = "purple_one_month"
|
||||
fileprivate let PURPLE_ONE_YEAR = "purple_one_year"
|
||||
|
||||
struct DamusAppNotificationView: View {
|
||||
let damus_state: DamusState
|
||||
let notification: DamusAppNotification
|
||||
var relative_date: String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
if abs(notification.notification_timestamp.timeIntervalSinceNow) > 60 {
|
||||
return formatter.localizedString(for: notification.notification_timestamp, relativeTo: Date.now)
|
||||
}
|
||||
else {
|
||||
return NSLocalizedString("now", comment: "Relative time label that indicates a notification happened now")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 15) {
|
||||
AppIcon()
|
||||
.frame(width: 50, height: 50)
|
||||
.clipShape(.rect(cornerSize: CGSize(width: 10.0, height: 10.0)))
|
||||
.shadow(radius: 5, y: 5)
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(alignment: .center, spacing: 3) {
|
||||
Text("Damus", comment: "Name of the app for the title of an internal notification")
|
||||
.font(.body.weight(.bold))
|
||||
Text(verbatim: "·")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(relative_date)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
HStack(spacing: 3) {
|
||||
Image("check-circle.fill")
|
||||
.resizable()
|
||||
.frame(width: 15, height: 15)
|
||||
Text("Internal app notification", comment: "Badge indicating that a notification is an official internal app notification")
|
||||
.font(.caption2)
|
||||
.bold()
|
||||
}
|
||||
.foregroundColor(Color.white)
|
||||
.padding(.vertical, 3)
|
||||
.padding(.horizontal, 8)
|
||||
.background(PinkGradient)
|
||||
.cornerRadius(30.0)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
switch notification.content {
|
||||
case .purple_impending_expiration(let days_remaining, _):
|
||||
PurpleExpiryNotificationView(damus_state: self.damus_state, days_remaining: days_remaining, expired: false)
|
||||
case .purple_expired(expiry_date: _):
|
||||
PurpleExpiryNotificationView(damus_state: self.damus_state, days_remaining: 0, expired: true)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 15)
|
||||
|
||||
ThiccDivider()
|
||||
}
|
||||
}
|
||||
|
||||
struct PurpleExpiryNotificationView: View {
|
||||
let damus_state: DamusState
|
||||
let days_remaining: Int
|
||||
let expired: Bool
|
||||
|
||||
func try_to_open_verified_checkout(product_template_name: String) {
|
||||
Task {
|
||||
do {
|
||||
let url = try await damus_state.purple.generate_verified_ln_checkout_link(product_template_name: product_template_name)
|
||||
self.open_url(url: url)
|
||||
}
|
||||
catch {
|
||||
self.open_url(url: damus_state.purple.environment.purple_landing_page_url().appendingPathComponent("checkout"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func open_url(url: URL) {
|
||||
this_app.open(url)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(self.message())
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(eventviewsize_to_font(.normal, font_size: damus_state.settings.font_size))
|
||||
if DEEP_WEBSITE_LINK {
|
||||
// TODO: It might be better to fetch products from the server instead of hardcoding them here. As of writing this is disabled, so not a big concern.
|
||||
HStack {
|
||||
Button(action: {
|
||||
self.try_to_open_verified_checkout(product_template_name: "purple_one_month")
|
||||
}, label: {
|
||||
Text("Renew (1 mo)", comment: "Button to take user to renew subscription for one month")
|
||||
})
|
||||
.buttonStyle(GradientButtonStyle())
|
||||
Button(action: {
|
||||
self.try_to_open_verified_checkout(product_template_name: "purple_one_year")
|
||||
}, label: {
|
||||
Text("Renew (1 yr)", comment: "Button to take user to renew subscription for one year")
|
||||
})
|
||||
.buttonStyle(GradientButtonStyle())
|
||||
}
|
||||
}
|
||||
else {
|
||||
NavigationLink(destination: DamusPurpleView(damus_state: damus_state), label: {
|
||||
HStack {
|
||||
Text("Manage subscription", comment: "Button to take user to manage Damus Purple subscription")
|
||||
.font(eventviewsize_to_font(.normal, font_size: damus_state.settings.font_size))
|
||||
Image("arrow-right")
|
||||
.font(eventviewsize_to_font(.normal, font_size: damus_state.settings.font_size))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func message() -> String {
|
||||
if expired == true {
|
||||
return NSLocalizedString("Your Purple subscription has expired. Renew?", comment: "A notification message explaining to the user that their Damus Purple Subscription has expired, prompting them to renew.")
|
||||
}
|
||||
if days_remaining == 1 {
|
||||
return NSLocalizedString("Your Purple subscription expires in 1 day. Renew?", comment: "A notification message explaining to the user that their Damus Purple Subscription is expiring in one day, prompting them to renew.")
|
||||
}
|
||||
let message_format = NSLocalizedString("Your Purple subscription expires in %@ days. Renew?", comment: "A notification message explaining to the user that their Damus Purple Subscription is expiring soon, prompting them to renew.")
|
||||
return String(format: message_format, String(days_remaining))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `AppIcon` code from: https://stackoverflow.com/a/65153628 and licensed with CC BY-SA 4.0 with the following modifications:
|
||||
// - Made image resizable using `.resizable()`
|
||||
extension Bundle {
|
||||
var iconFileName: String? {
|
||||
guard let icons = infoDictionary?["CFBundleIcons"] as? [String: Any],
|
||||
let primaryIcon = icons["CFBundlePrimaryIcon"] as? [String: Any],
|
||||
let iconFiles = primaryIcon["CFBundleIconFiles"] as? [String],
|
||||
let iconFileName = iconFiles.last
|
||||
else { return nil }
|
||||
return iconFileName
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct AppIcon: View {
|
||||
var body: some View {
|
||||
Bundle.main.iconFileName
|
||||
.flatMap { UIImage(named: $0) }
|
||||
.map { Image(uiImage: $0).resizable() }
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack {
|
||||
ThiccDivider()
|
||||
DamusAppNotificationView(damus_state: test_damus_state, notification: .init(content: .purple_impending_expiration(days_remaining: 3, expiry_date: 1709156602), timestamp: Date.now))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DamusAppNotificationView(damus_state: test_damus_state, notification: .init(content: .purple_expired(expiry_date: 1709156602), timestamp: Date.now))
|
||||
}
|
||||
271
damus/Features/Notifications/Views/EventGroupView.swift
Normal file
271
damus/Features/Notifications/Views/EventGroupView.swift
Normal file
@@ -0,0 +1,271 @@
|
||||
//
|
||||
// RepostGroupView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-02-21.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
enum EventGroupType {
|
||||
case repost(EventGroup)
|
||||
case reaction(EventGroup)
|
||||
case zap(ZapGroup)
|
||||
case profile_zap(ZapGroup)
|
||||
|
||||
var is_note_zap: Bool {
|
||||
switch self {
|
||||
case .repost: return false
|
||||
case .reaction: return false
|
||||
case .zap: return true
|
||||
case .profile_zap: return false
|
||||
}
|
||||
}
|
||||
|
||||
var zap_group: ZapGroup? {
|
||||
switch self {
|
||||
case .profile_zap(let grp):
|
||||
return grp
|
||||
case .zap(let grp):
|
||||
return grp
|
||||
case .reaction:
|
||||
return nil
|
||||
case .repost:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var events: [NostrEvent] {
|
||||
switch self {
|
||||
case .repost(let grp):
|
||||
return grp.events
|
||||
case .reaction(let grp):
|
||||
return grp.events
|
||||
case .zap(let zapgrp):
|
||||
return zapgrp.zap_requests()
|
||||
case .profile_zap(let zapgrp):
|
||||
return zapgrp.zap_requests()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ReactingTo {
|
||||
case your_note
|
||||
case tagged_in
|
||||
case your_profile
|
||||
}
|
||||
|
||||
func determine_reacting_to(our_pubkey: Pubkey, ev: NostrEvent?) -> ReactingTo {
|
||||
guard let ev else {
|
||||
return .your_profile
|
||||
}
|
||||
|
||||
if ev.pubkey == our_pubkey {
|
||||
return .your_note
|
||||
}
|
||||
|
||||
return .tagged_in
|
||||
}
|
||||
|
||||
func event_group_unique_pubkeys(profiles: Profiles, group: EventGroupType) -> [Pubkey] {
|
||||
var seen = Set<Pubkey>()
|
||||
var sorted = [Pubkey]()
|
||||
|
||||
if let zapgrp = group.zap_group {
|
||||
let zaps = zapgrp.zaps
|
||||
|
||||
for i in 0..<zaps.count {
|
||||
let zap = zapgrp.zaps[i]
|
||||
let pubkey: Pubkey
|
||||
|
||||
if zap.is_anon {
|
||||
pubkey = ANON_PUBKEY
|
||||
} else {
|
||||
pubkey = zap.request.ev.pubkey
|
||||
}
|
||||
|
||||
if !seen.contains(pubkey) {
|
||||
seen.insert(pubkey)
|
||||
sorted.append(pubkey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let events = group.events
|
||||
|
||||
for i in 0..<events.count {
|
||||
let ev = events[i]
|
||||
let pubkey = ev.pubkey
|
||||
if !seen.contains(pubkey) {
|
||||
seen.insert(pubkey)
|
||||
sorted.append(pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
/**
|
||||
Returns a notification string describing user actions in response to an event group type.
|
||||
|
||||
The localization keys read by this function are the following (although some keys may not actually be used in practice):
|
||||
|
||||
"??" - returned when there are no events associated with the specified event group type.
|
||||
|
||||
"reacted_tagged_in_1" - returned when 1 reaction occurred to a post that the current user was tagged in
|
||||
"reacted_tagged_in_2" - returned when 2 reactions occurred to a post that the current user was tagged in
|
||||
"reacted_tagged_in_3" - returned when 3 or more reactions occurred to a post that the current user was tagged in
|
||||
"reacted_your_note_1" - returned when 1 reaction occurred to the current user's post
|
||||
"reacted_your_note_2" - returned when 2 reactions occurred to the current user's post
|
||||
"reacted_your_note_3" - returned when 3 or more reactions occurred to the current user's post
|
||||
"reacted_your_profile_1" - returned when 1 reaction occurred to the current user's profile
|
||||
"reacted_your_profile_2" - returned when 2 reactions occurred to the current user's profile
|
||||
"reacted_your_profile_3" - returned when 3 or more reactions occurred to the current user's profile
|
||||
|
||||
"reposted_tagged_in_1" - returned when 1 repost occurred to a post that the current user was tagged in
|
||||
"reposted_tagged_in_2" - returned when 2 reposts occurred to a post that the current user was tagged in
|
||||
"reposted_tagged_in_3" - returned when 3 or more reposts occurred to a post that the current user was tagged in
|
||||
"reposted_your_note_1" - returned when 1 repost occurred to the current user's post
|
||||
"reposted_your_note_2" - returned when 2 reposts occurred to the current user's post
|
||||
"reposted_your_note_3" - returned when 3 or more reposts occurred to the current user's post
|
||||
"reposted_your_profile_1" - returned when 1 repost occurred to the current user's profile
|
||||
"reposted_your_profile_2" - returned when 2 reposts occurred to the current user's profile
|
||||
"reposted_your_profile_3" - returned when 3 or more reposts occurred to the current user's profile
|
||||
|
||||
"zapped_tagged_in_1" - returned when 1 zap occurred to a post that the current user was tagged in
|
||||
"zapped_tagged_in_2" - returned when 2 zaps occurred to a post that the current user was tagged in
|
||||
"zapped_tagged_in_3" - returned when 3 or more zaps occurred to a post that the current user was tagged in
|
||||
"zapped_your_note_1" - returned when 1 zap occurred to the current user's post
|
||||
"zapped_your_note_2" - returned when 2 zaps occurred to the current user's post
|
||||
"zapped_your_note_3" - returned when 3 or more zaps occurred to the current user's post
|
||||
"zapped_your_profile_1" - returned when 1 zap occurred to the current user's profile
|
||||
"zapped_your_profile_2" - returned when 2 zaps occurred to the current user's profile
|
||||
"zapped_your_profile_3" - returned when 3 or more zaps occurred to the current user's profile
|
||||
*/
|
||||
func reacting_to_text(profiles: Profiles, our_pubkey: Pubkey, group: EventGroupType, ev: NostrEvent?, pubkeys: [Pubkey], locale: Locale = Locale.current) -> String {
|
||||
if group.events.count == 0 {
|
||||
return "??"
|
||||
}
|
||||
|
||||
let verb = reacting_to_verb(group: group)
|
||||
let reacting_to = determine_reacting_to(our_pubkey: our_pubkey, ev: ev)
|
||||
let localization_key = "\(verb)_\(reacting_to)_\(min(pubkeys.count, 3))"
|
||||
let format = localizedStringFormat(key: localization_key, locale: locale)
|
||||
|
||||
switch pubkeys.count {
|
||||
case 1:
|
||||
let display_name = event_author_name(profiles: profiles, pubkey: pubkeys[0])
|
||||
|
||||
return String(format: format, locale: locale, display_name)
|
||||
case 2:
|
||||
let alice_name = event_author_name(profiles: profiles, pubkey: pubkeys[0])
|
||||
let bob_name = event_author_name(profiles: profiles, pubkey: pubkeys[1])
|
||||
|
||||
return String(format: format, locale: locale, alice_name, bob_name)
|
||||
default:
|
||||
let alice_name = event_author_name(profiles: profiles, pubkey: pubkeys[0])
|
||||
let count = pubkeys.count - 1
|
||||
|
||||
return String(format: format, locale: locale, count, alice_name)
|
||||
}
|
||||
}
|
||||
|
||||
func reacting_to_verb(group: EventGroupType) -> String {
|
||||
switch group {
|
||||
case .reaction:
|
||||
return "reacted"
|
||||
case .repost:
|
||||
return "reposted"
|
||||
case .zap, .profile_zap:
|
||||
return "zapped"
|
||||
}
|
||||
}
|
||||
|
||||
struct EventGroupView: View {
|
||||
let state: DamusState
|
||||
let event: NostrEvent?
|
||||
let group: EventGroupType
|
||||
|
||||
func GroupDescription(_ pubkeys: [Pubkey]) -> some View {
|
||||
let text = reacting_to_text(profiles: state.profiles, our_pubkey: state.pubkey, group: group, ev: event, pubkeys: pubkeys)
|
||||
return Text(text)
|
||||
}
|
||||
|
||||
func ZapIcon(_ zapgrp: ZapGroup) -> some View {
|
||||
let fmt = format_msats_abbrev(zapgrp.msat_total)
|
||||
return VStack(alignment: .center) {
|
||||
Image("zap.fill")
|
||||
.foregroundColor(.orange)
|
||||
Text(fmt)
|
||||
.foregroundColor(Color.orange)
|
||||
}
|
||||
}
|
||||
|
||||
var GroupIcon: some View {
|
||||
Group {
|
||||
switch group {
|
||||
case .repost:
|
||||
Image("repost")
|
||||
.foregroundColor(DamusColors.green)
|
||||
case .reaction:
|
||||
LINEAR_GRADIENT
|
||||
.mask(Image("shaka.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
)
|
||||
.frame(width: 20, height: 20)
|
||||
case .profile_zap(let zapgrp):
|
||||
ZapIcon(zapgrp)
|
||||
case .zap(let zapgrp):
|
||||
ZapIcon(zapgrp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
GroupIcon
|
||||
.frame(width: PFP_SIZE + 10)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
let unique_pubkeys = event_group_unique_pubkeys(profiles: state.profiles, group: group)
|
||||
|
||||
ProfilePicturesView(state: state, pubkeys: unique_pubkeys)
|
||||
|
||||
if let event {
|
||||
let thread = ThreadModel(event: event, damus_state: state)
|
||||
NavigationLink(value: Route.Thread(thread: thread)) {
|
||||
VStack(alignment: .leading) {
|
||||
GroupDescription(unique_pubkeys)
|
||||
EventBody(damus_state: state, event: event, size: .normal, options: [.truncate_content])
|
||||
.padding([.top], 1)
|
||||
.padding([.trailing])
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
GroupDescription(unique_pubkeys)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding([.top], 6)
|
||||
}
|
||||
}
|
||||
|
||||
struct EventGroupView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
EventGroupView(state: test_damus_state, event: test_note, group: .repost(test_event_group))
|
||||
.frame(height: 200)
|
||||
.padding()
|
||||
|
||||
EventGroupView(state: test_damus_state, event: test_note, group: .reaction(test_event_group))
|
||||
.frame(height: 200)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
100
damus/Features/Notifications/Views/NotificationItemView.swift
Normal file
100
damus/Features/Notifications/Views/NotificationItemView.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
//
|
||||
// NotificationItemView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-02-21.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum ShowItem {
|
||||
case show(NostrEvent?)
|
||||
case dontshow(NostrEvent?)
|
||||
case show_damus_app_notification(DamusAppNotification)
|
||||
}
|
||||
|
||||
func notification_item_event(events: EventCache, notif: NotificationItem) -> ShowItem {
|
||||
switch notif {
|
||||
case .repost(let evid, _):
|
||||
return .dontshow(events.lookup(evid))
|
||||
case .reply(let ev):
|
||||
return .show(ev)
|
||||
case .reaction(let evid, _):
|
||||
return .dontshow(events.lookup(evid))
|
||||
case .event_zap(let evid, _):
|
||||
return .dontshow(events.lookup(evid))
|
||||
case .profile_zap:
|
||||
return .show(nil)
|
||||
case .damus_app_notification(let app_notification):
|
||||
return .show_damus_app_notification(app_notification)
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationItemView: View {
|
||||
let state: DamusState
|
||||
let item: NotificationItem
|
||||
|
||||
var show_item: ShowItem {
|
||||
notification_item_event(events: state.events, notif: item)
|
||||
}
|
||||
|
||||
var options: EventViewOptions {
|
||||
if state.settings.truncate_mention_text {
|
||||
return [.wide, .truncate_content]
|
||||
}
|
||||
|
||||
return [.wide]
|
||||
}
|
||||
|
||||
func Item(_ ev: NostrEvent?) -> some View {
|
||||
Group {
|
||||
switch item {
|
||||
case .repost(_, let evgrp):
|
||||
EventGroupView(state: state, event: ev, group: .repost(evgrp))
|
||||
|
||||
case .event_zap(_, let zapgrp):
|
||||
EventGroupView(state: state, event: ev, group: .zap(zapgrp))
|
||||
|
||||
case .profile_zap(let grp):
|
||||
EventGroupView(state: state, event: nil, group: .profile_zap(grp))
|
||||
|
||||
case .reaction(_, let evgrp):
|
||||
EventGroupView(state: state, event: ev, group: .reaction(evgrp))
|
||||
|
||||
case .reply(let ev):
|
||||
NavigationLink(value: Route.Thread(thread: ThreadModel(event: ev, damus_state: state))) {
|
||||
EventView(damus: state, event: ev, options: options)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
case .damus_app_notification(let notification):
|
||||
DamusAppNotificationView(damus_state: state, notification: notification)
|
||||
}
|
||||
|
||||
ThiccDivider()
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch show_item {
|
||||
case .show(let ev):
|
||||
Item(ev)
|
||||
|
||||
case .dontshow(let ev):
|
||||
if let ev {
|
||||
Item(ev)
|
||||
}
|
||||
case .show_damus_app_notification(let notification):
|
||||
DamusAppNotificationView(damus_state: state, notification: notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let test_notification_item: NotificationItem = .repost(test_note.id, test_event_group)
|
||||
|
||||
struct NotificationItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NotificationItemView(state: test_damus_state, item: test_notification_item)
|
||||
}
|
||||
}
|
||||
225
damus/Features/Notifications/Views/NotificationsView.swift
Normal file
225
damus/Features/Notifications/Views/NotificationsView.swift
Normal file
@@ -0,0 +1,225 @@
|
||||
//
|
||||
// NotificationsView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-02-21.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TipKit
|
||||
|
||||
class NotificationFilter: ObservableObject, Equatable {
|
||||
@Published var state: NotificationFilterState
|
||||
@Published var friend_filter: FriendFilter
|
||||
@Published var hellthread_notifications_disabled: Bool
|
||||
@Published var hellthread_notification_max_pubkeys: Int
|
||||
|
||||
static func == (lhs: NotificationFilter, rhs: NotificationFilter) -> Bool {
|
||||
return lhs.state == rhs.state
|
||||
&& lhs.friend_filter == rhs.friend_filter
|
||||
&& lhs.hellthread_notifications_disabled == rhs.hellthread_notifications_disabled
|
||||
&& lhs.hellthread_notification_max_pubkeys == rhs.hellthread_notification_max_pubkeys
|
||||
}
|
||||
|
||||
init(
|
||||
state: NotificationFilterState = .all,
|
||||
friend_filter: FriendFilter = .all,
|
||||
hellthread_notifications_disabled: Bool = false,
|
||||
hellthread_notification_max_pubkeys: Int = DEFAULT_HELLTHREAD_MAX_PUBKEYS
|
||||
) {
|
||||
self.state = state
|
||||
self.friend_filter = friend_filter
|
||||
self.hellthread_notifications_disabled = hellthread_notifications_disabled
|
||||
self.hellthread_notification_max_pubkeys = hellthread_notification_max_pubkeys
|
||||
}
|
||||
|
||||
func filter(contacts: Contacts, items: [NotificationItem]) -> [NotificationItem] {
|
||||
|
||||
return items.reduce(into: []) { acc, item in
|
||||
if !self.state.filter(item) {
|
||||
return
|
||||
}
|
||||
|
||||
if let item = item.filter({ ev in
|
||||
self.friend_filter.filter(contacts: contacts, pubkey: ev.pubkey) &&
|
||||
(!hellthread_notifications_disabled || !ev.is_hellthread(max_pubkeys: hellthread_notification_max_pubkeys)) &&
|
||||
// Allow notes that are created no more than 3 seconds in the future
|
||||
// to account for natural clock skew between sender and receiver.
|
||||
ev.age >= -3
|
||||
}) {
|
||||
acc.append(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NotificationFilterState: String {
|
||||
case all
|
||||
case zaps
|
||||
case replies
|
||||
|
||||
func filter(_ item: NotificationItem) -> Bool {
|
||||
switch self {
|
||||
case .all:
|
||||
return true
|
||||
case .replies:
|
||||
return item.is_reply != nil
|
||||
case .zaps:
|
||||
return item.is_zap != nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationsView: View {
|
||||
let state: DamusState
|
||||
@ObservedObject var notifications: NotificationsModel
|
||||
@StateObject var filter = NotificationFilter()
|
||||
@SceneStorage("NotificationsView.filter_state") var filter_state: NotificationFilterState = .all
|
||||
@Binding var subtitle: String?
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
let showTrustedButton = would_filter_non_friends_from_notifications(contacts: state.contacts, state: filter_state, items: self.notifications.notifications)
|
||||
TabView(selection: $filter_state) {
|
||||
NotificationTab(
|
||||
NotificationFilter(
|
||||
state: .all,
|
||||
friend_filter: filter.friend_filter,
|
||||
hellthread_notifications_disabled: state.settings.hellthread_notifications_disabled,
|
||||
hellthread_notification_max_pubkeys: state.settings.hellthread_notification_max_pubkeys
|
||||
)
|
||||
)
|
||||
.tag(NotificationFilterState.all)
|
||||
|
||||
NotificationTab(
|
||||
NotificationFilter(
|
||||
state: .zaps,
|
||||
friend_filter: filter.friend_filter,
|
||||
hellthread_notifications_disabled: state.settings.hellthread_notifications_disabled,
|
||||
hellthread_notification_max_pubkeys: state.settings.hellthread_notification_max_pubkeys
|
||||
)
|
||||
)
|
||||
.tag(NotificationFilterState.zaps)
|
||||
|
||||
NotificationTab(
|
||||
NotificationFilter(
|
||||
state: .replies,
|
||||
friend_filter: filter.friend_filter,
|
||||
hellthread_notifications_disabled: state.settings.hellthread_notifications_disabled,
|
||||
hellthread_notification_max_pubkeys: state.settings.hellthread_notification_max_pubkeys
|
||||
)
|
||||
)
|
||||
.tag(NotificationFilterState.replies)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(
|
||||
action: { state.nav.push(route: Route.NotificationSettings(settings: state.settings)) },
|
||||
label: {
|
||||
Image(systemName: "gearshape")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if showTrustedButton {
|
||||
TrustedNetworkButton(filter: $filter.friend_filter) {
|
||||
if #available(iOS 17, *) {
|
||||
TrustedNetworkButtonTip.shared.invalidate(reason: .actionPerformed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: filter.friend_filter) { val in
|
||||
state.settings.friend_filter = val
|
||||
self.subtitle = filter.friend_filter.description()
|
||||
}
|
||||
.onChange(of: filter_state) { val in
|
||||
filter.state = val
|
||||
}
|
||||
.onAppear {
|
||||
self.filter.friend_filter = state.settings.friend_filter
|
||||
self.subtitle = filter.friend_filter.description()
|
||||
filter.state = filter_state
|
||||
}
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
if #available(iOS 17, *), showTrustedButton {
|
||||
TipView(TrustedNetworkButtonTip.shared)
|
||||
.tipBackground(.clear)
|
||||
.tipViewStyle(TrustedNetworkButtonTipViewStyle())
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
CustomPicker(tabs: [
|
||||
(NSLocalizedString("All", comment: "Label for filter for all notifications."), NotificationFilterState.all),
|
||||
(NSLocalizedString("Zaps", comment: "Label for filter for zap notifications."), NotificationFilterState.zaps),
|
||||
(NSLocalizedString("Mentions", comment: "Label for filter for seeing mention notifications (replies, etc)."), NotificationFilterState.replies),
|
||||
], selection: $filter_state)
|
||||
Divider()
|
||||
.frame(height: 1)
|
||||
}
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
}
|
||||
}
|
||||
|
||||
func NotificationTab(_ filter: NotificationFilter) -> some View {
|
||||
ScrollViewReader { scroller in
|
||||
ScrollView {
|
||||
let notifs = Array(zip(1..., filter.filter(contacts: state.contacts, items: notifications.notifications)))
|
||||
if notifs.isEmpty {
|
||||
EmptyTimelineView()
|
||||
} else {
|
||||
LazyVStack(alignment: .leading) {
|
||||
Color.white.opacity(0)
|
||||
.id("startblock")
|
||||
.frame(height: 5)
|
||||
ForEach(notifs, id: \.0) { zip in
|
||||
NotificationItemView(state: state, item: zip.1)
|
||||
}
|
||||
}
|
||||
.background(GeometryReader { proxy -> Color in
|
||||
DispatchQueue.main.async {
|
||||
handle_scroll_queue(proxy, queue: self.notifications)
|
||||
}
|
||||
return Color.clear
|
||||
})
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: "scroll")
|
||||
.onReceive(handle_notify(.scroll_to_top)) { notif in
|
||||
let _ = notifications.flush(state)
|
||||
self.notifications.should_queue = false
|
||||
scroll_to_event(scroller: scroller, id: "startblock", delay: 0.0, animate: true, anchor: .top)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
let _ = notifications.flush(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NotificationsView(state: test_damus_state, notifications: NotificationsModel(), filter: NotificationFilter(), subtitle: .constant(nil))
|
||||
}
|
||||
}
|
||||
|
||||
func would_filter_non_friends_from_notifications(contacts: Contacts, state: NotificationFilterState, items: [NotificationItem]) -> Bool {
|
||||
for item in items {
|
||||
// this is only valid depending on which tab we're looking at
|
||||
if !state.filter(item) {
|
||||
continue
|
||||
}
|
||||
|
||||
if item.would_filter({ ev in FriendFilter.friends_of_friends.filter(contacts: contacts, pubkey: ev.pubkey) }) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
31
damus/Features/Notifications/Views/ProfilePicturesView.swift
Normal file
31
damus/Features/Notifications/Views/ProfilePicturesView.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// ProfilePicturesView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-02-22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ProfilePicturesView: View {
|
||||
let state: DamusState
|
||||
let pubkeys: [Pubkey]
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
ForEach(pubkeys.prefix(8), id: \.self) { pubkey in
|
||||
ProfilePicView(pubkey: pubkey, size: 32.0, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation)
|
||||
.onTapGesture {
|
||||
state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProfilePicturesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let pubkey = test_note.pubkey
|
||||
ProfilePicturesView(state: test_damus_state, pubkeys: [pubkey, pubkey])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user