This patch depends on: Migrate Lists.swift to use new MuteItem - Makes request for new mute list type (kind:10000) - Processing new mute list type (kind:10000) Related: https://github.com/damus-io/damus/issues/1718 Related: https://github.com/damus-io/damus/issues/856 Lighting Address: fishcharlie@strike.me Signed-off-by: Charlie Fish <contact@charlie.fish> Reviewed-by: William Casarin <jb55@jb55.com> Signed-off-by: William Casarin <jb55@jb55.com>
1161 lines
38 KiB
Swift
1161 lines
38 KiB
Swift
//
|
|
// HomeModel.swift
|
|
// damus
|
|
//
|
|
// Created by William Casarin on 2022-05-24.
|
|
//
|
|
|
|
import Foundation
|
|
import UIKit
|
|
|
|
enum Resubscribe {
|
|
case following
|
|
case unfollowing(FollowRef)
|
|
}
|
|
|
|
enum HomeResubFilter {
|
|
case pubkey(Pubkey)
|
|
case hashtag(String)
|
|
|
|
init?(from: FollowRef) {
|
|
switch from {
|
|
case .hashtag(let ht): self = .hashtag(ht.string())
|
|
case .pubkey(let pk): self = .pubkey(pk)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func filter(contacts: Contacts, ev: NostrEvent) -> Bool {
|
|
switch self {
|
|
case .pubkey(let pk):
|
|
return ev.pubkey == pk
|
|
case .hashtag(let ht):
|
|
if contacts.is_friend(ev.pubkey) {
|
|
return false
|
|
}
|
|
return ev.referenced_hashtags.contains(where: { ref_ht in
|
|
ht == ref_ht.hashtag
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
class HomeModel {
|
|
// Don't trigger a user notification for events older than a certain age
|
|
static let event_max_age_for_notification: TimeInterval = EVENT_MAX_AGE_FOR_NOTIFICATION
|
|
|
|
var damus_state: DamusState
|
|
|
|
// NDBTODO: let's get rid of this entirely, let nostrdb handle it
|
|
var has_event: [String: Set<NoteId>] = [:]
|
|
var deleted_events: Set<NoteId> = Set()
|
|
var last_event_of_kind: [String: [UInt32: NostrEvent]] = [:]
|
|
var done_init: Bool = false
|
|
var incoming_dms: [NostrEvent] = []
|
|
let dm_debouncer = Debouncer(interval: 0.5)
|
|
let resub_debouncer = Debouncer(interval: 3.0)
|
|
var should_debounce_dms = true
|
|
|
|
let home_subid = UUID().description
|
|
let contacts_subid = UUID().description
|
|
let notifications_subid = UUID().description
|
|
let dms_subid = UUID().description
|
|
let init_subid = UUID().description
|
|
let profiles_subid = UUID().description
|
|
|
|
var loading: Bool = false
|
|
|
|
var signal = SignalModel()
|
|
|
|
var notifications = NotificationsModel()
|
|
var notification_status = NotificationStatusModel()
|
|
var events: EventHolder = EventHolder()
|
|
var zap_button: ZapButtonModel = ZapButtonModel()
|
|
|
|
init() {
|
|
self.damus_state = DamusState.empty
|
|
self.setup_debouncer()
|
|
filter_events()
|
|
events.on_queue = preloader
|
|
//self.events = EventHolder(on_queue: preloader)
|
|
}
|
|
|
|
func preloader(ev: NostrEvent) {
|
|
preload_events(state: self.damus_state, events: [ev])
|
|
}
|
|
|
|
var pool: RelayPool {
|
|
return damus_state.pool
|
|
}
|
|
|
|
var dms: DirectMessagesModel {
|
|
return damus_state.dms
|
|
}
|
|
|
|
func has_sub_id_event(sub_id: String, ev_id: NoteId) -> Bool {
|
|
if !has_event.keys.contains(sub_id) {
|
|
has_event[sub_id] = Set()
|
|
return false
|
|
}
|
|
|
|
return has_event[sub_id]!.contains(ev_id)
|
|
}
|
|
|
|
func setup_debouncer() {
|
|
// turn off debouncer after initial load
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
|
|
self.should_debounce_dms = false
|
|
}
|
|
}
|
|
|
|
func resubscribe(_ resubbing: Resubscribe) {
|
|
if self.should_debounce_dms {
|
|
// don't resub on initial load
|
|
return
|
|
}
|
|
|
|
print("hit resub debouncer")
|
|
|
|
resub_debouncer.debounce {
|
|
print("resub")
|
|
self.unsubscribe_to_home_filters()
|
|
|
|
switch resubbing {
|
|
case .following:
|
|
break
|
|
case .unfollowing(let r):
|
|
if let filter = HomeResubFilter(from: r) {
|
|
self.events.filter { ev in !filter.filter(contacts: self.damus_state.contacts, ev: ev) }
|
|
}
|
|
}
|
|
|
|
self.subscribe_to_home_filters()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func process_event(sub_id: String, relay_id: String, ev: NostrEvent) {
|
|
if has_sub_id_event(sub_id: sub_id, ev_id: ev.id) {
|
|
return
|
|
}
|
|
|
|
let last_k = get_last_event_of_kind(relay_id: relay_id, kind: ev.kind)
|
|
if last_k == nil || ev.created_at > last_k!.created_at {
|
|
last_event_of_kind[relay_id]?[ev.kind] = ev
|
|
}
|
|
|
|
guard let kind = ev.known_kind else {
|
|
return
|
|
}
|
|
|
|
switch kind {
|
|
case .chat, .longform, .text:
|
|
handle_text_event(sub_id: sub_id, ev)
|
|
case .contacts:
|
|
handle_contact_event(sub_id: sub_id, relay_id: relay_id, ev: ev)
|
|
case .metadata:
|
|
// profile metadata processing is handled by nostrdb
|
|
break
|
|
case .list_deprecated:
|
|
handle_old_list_event(ev)
|
|
case .mute_list:
|
|
handle_mute_list_event(ev)
|
|
case .boost:
|
|
handle_boost_event(sub_id: sub_id, ev)
|
|
case .like:
|
|
handle_like_event(ev)
|
|
case .dm:
|
|
handle_dm(ev)
|
|
case .delete:
|
|
handle_delete_event(ev)
|
|
case .zap:
|
|
handle_zap_event(ev)
|
|
case .zap_request:
|
|
break
|
|
case .nwc_request:
|
|
break
|
|
case .nwc_response:
|
|
handle_nwc_response(ev, relay: relay_id)
|
|
case .http_auth:
|
|
break
|
|
case .status:
|
|
handle_status_event(ev)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func handle_status_event(_ ev: NostrEvent) {
|
|
guard let st = UserStatus(ev: ev) else {
|
|
return
|
|
}
|
|
|
|
// don't process expired events
|
|
if let expires = st.expires_at, Date.now >= expires {
|
|
return
|
|
}
|
|
|
|
let pdata = damus_state.profiles.profile_data(ev.pubkey)
|
|
|
|
// don't use old events
|
|
if st.type == .music,
|
|
let music = pdata.status.music,
|
|
ev.created_at < music.created_at {
|
|
return
|
|
} else if st.type == .general,
|
|
let general = pdata.status.general,
|
|
ev.created_at < general.created_at {
|
|
return
|
|
}
|
|
|
|
pdata.status.update_status(st)
|
|
}
|
|
|
|
func handle_nwc_response(_ ev: NostrEvent, relay: String) {
|
|
Task { @MainActor in
|
|
// TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time
|
|
guard let nwc_str = damus_state.settings.nostr_wallet_connect,
|
|
let nwc = WalletConnectURL(str: nwc_str),
|
|
let resp = await FullWalletResponse(from: ev, nwc: nwc) else {
|
|
return
|
|
}
|
|
|
|
// since command results are not returned for ephemeral events,
|
|
// remove the request from the postbox which is likely failing over and over
|
|
if damus_state.postbox.remove_relayer(relay_id: nwc.relay.id, event_id: resp.req_id) {
|
|
print("nwc: got response, removed \(resp.req_id) from the postbox [\(relay)]")
|
|
} else {
|
|
print("nwc: \(resp.req_id) not found in the postbox, nothing to remove [\(relay)]")
|
|
}
|
|
|
|
guard resp.response.error == nil else {
|
|
print("nwc error: \(resp.response)")
|
|
nwc_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp)
|
|
return
|
|
}
|
|
|
|
print("nwc success: \(resp.response.result.debugDescription) [\(relay)]")
|
|
nwc_success(state: self.damus_state, resp: resp)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func handle_zap_event(_ ev: NostrEvent) {
|
|
process_zap_event(state: damus_state, ev: ev) { zapres in
|
|
guard case .done(let zap) = zapres,
|
|
zap.target.pubkey == self.damus_state.keypair.pubkey,
|
|
should_show_event(keypair: self.damus_state.keypair, hellthreads: self.damus_state.muted_threads, contacts: self.damus_state.contacts, ev: zap.request.ev) else {
|
|
return
|
|
}
|
|
|
|
if !self.notifications.insert_zap(.zap(zap)) {
|
|
return
|
|
}
|
|
|
|
guard let new_bits = handle_last_events(new_events: self.notification_status.new_events, ev: ev, timeline: .notifications, shouldNotify: true) else {
|
|
return
|
|
}
|
|
|
|
if self.damus_state.settings.zap_vibration {
|
|
// Generate zap vibration
|
|
zap_vibrate(zap_amount: zap.invoice.amount)
|
|
}
|
|
|
|
if self.damus_state.settings.zap_notification {
|
|
// Create in-app local notification for zap received.
|
|
switch zap.target {
|
|
case .profile(let profile_id):
|
|
create_in_app_profile_zap_notification(profiles: self.damus_state.profiles, zap: zap, profile_id: profile_id)
|
|
case .note(let note_target):
|
|
create_in_app_event_zap_notification(profiles: self.damus_state.profiles, zap: zap, evId: note_target.note_id)
|
|
}
|
|
}
|
|
|
|
self.notification_status.new_events = new_bits
|
|
}
|
|
|
|
}
|
|
|
|
func filter_events() {
|
|
events.filter { ev in
|
|
!damus_state.contacts.is_muted(ev.pubkey)
|
|
}
|
|
|
|
self.dms.dms = dms.dms.filter { ev in
|
|
!damus_state.contacts.is_muted(ev.pubkey)
|
|
}
|
|
|
|
notifications.filter { ev in
|
|
if damus_state.settings.onlyzaps_mode && ev.known_kind == NostrKind.like {
|
|
return false
|
|
}
|
|
|
|
return !damus_state.contacts.is_muted(ev.pubkey) && !damus_state.muted_threads.isMutedThread(ev, keypair: damus_state.keypair)
|
|
}
|
|
}
|
|
|
|
func handle_delete_event(_ ev: NostrEvent) {
|
|
self.deleted_events.insert(ev.id)
|
|
}
|
|
|
|
func handle_contact_event(sub_id: String, relay_id: String, ev: NostrEvent) {
|
|
process_contact_event(state: self.damus_state, ev: ev)
|
|
|
|
if sub_id == init_subid {
|
|
pool.send(.unsubscribe(init_subid), to: [relay_id])
|
|
if !done_init {
|
|
done_init = true
|
|
send_home_filters(relay_id: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
func handle_boost_event(sub_id: String, _ ev: NostrEvent) {
|
|
var boost_ev_id = ev.last_refid()
|
|
|
|
if let inner_ev = ev.get_inner_event(cache: damus_state.events) {
|
|
boost_ev_id = inner_ev.id
|
|
|
|
Task {
|
|
guard validate_event(ev: inner_ev) == .ok else {
|
|
return
|
|
}
|
|
|
|
if inner_ev.is_textlike {
|
|
DispatchQueue.main.async {
|
|
self.handle_text_event(sub_id: sub_id, ev)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
guard let e = boost_ev_id else {
|
|
return
|
|
}
|
|
|
|
switch self.damus_state.boosts.add_event(ev, target: e) {
|
|
case .already_counted:
|
|
break
|
|
case .success(let n):
|
|
let boosted = Counted(event: ev, id: e, total: n)
|
|
notify(.reposted(boosted))
|
|
notify(.update_stats(note_id: e))
|
|
}
|
|
}
|
|
|
|
func handle_like_event(_ ev: NostrEvent) {
|
|
guard let e = ev.last_refid() else {
|
|
// no id ref? invalid like event
|
|
return
|
|
}
|
|
|
|
if damus_state.settings.onlyzaps_mode {
|
|
return
|
|
}
|
|
|
|
switch damus_state.likes.add_event(ev, target: e) {
|
|
case .already_counted:
|
|
break
|
|
case .success(let n):
|
|
handle_notification(ev: ev)
|
|
let liked = Counted(event: ev, id: e, total: n)
|
|
notify(.liked(liked))
|
|
notify(.update_stats(note_id: e))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func handle_event(relay_id: String, conn_event: NostrConnectionEvent) {
|
|
switch conn_event {
|
|
case .ws_event(let ev):
|
|
switch ev {
|
|
case .connected:
|
|
if !done_init {
|
|
self.loading = true
|
|
send_initial_filters(relay_id: relay_id)
|
|
} else {
|
|
//remove_bootstrap_nodes(damus_state)
|
|
send_home_filters(relay_id: relay_id)
|
|
}
|
|
|
|
// connect to nwc relays when connected
|
|
if let nwc_str = damus_state.settings.nostr_wallet_connect,
|
|
let r = pool.get_relay(relay_id),
|
|
r.descriptor.variant == .nwc,
|
|
let nwc = WalletConnectURL(str: nwc_str),
|
|
nwc.relay.id == relay_id
|
|
{
|
|
subscribe_to_nwc(url: nwc, pool: pool)
|
|
}
|
|
case .error(let merr):
|
|
let desc = String(describing: merr)
|
|
if desc.contains("Software caused connection abort") {
|
|
pool.reconnect(to: [relay_id])
|
|
}
|
|
case .disconnected:
|
|
pool.reconnect(to: [relay_id])
|
|
default:
|
|
break
|
|
}
|
|
|
|
update_signal_from_pool(signal: self.signal, pool: damus_state.pool)
|
|
case .nostr_event(let ev):
|
|
switch ev {
|
|
case .event(let sub_id, let ev):
|
|
// globally handle likes
|
|
/*
|
|
let always_process = sub_id == notifications_subid || sub_id == contacts_subid || sub_id == home_subid || sub_id == dms_subid || sub_id == init_subid || ev.known_kind == .like || ev.known_kind == .boost || ev.known_kind == .zap || ev.known_kind == .contacts || ev.known_kind == .metadata
|
|
if !always_process {
|
|
// TODO: other views like threads might have their own sub ids, so ignore those events... or should we?
|
|
return
|
|
}
|
|
*/
|
|
|
|
self.process_event(sub_id: sub_id, relay_id: relay_id, ev: ev)
|
|
case .notice(let msg):
|
|
print(msg)
|
|
|
|
case .eose(let sub_id):
|
|
guard let txn = NdbTxn(ndb: damus_state.ndb) else {
|
|
return
|
|
}
|
|
|
|
if sub_id == dms_subid {
|
|
var dms = dms.dms.flatMap { $0.events }
|
|
dms.append(contentsOf: incoming_dms)
|
|
load_profiles(context: "dms", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(dms), damus_state: damus_state, txn: txn)
|
|
} else if sub_id == notifications_subid {
|
|
load_profiles(context: "notifications", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_keys(notifications.uniq_pubkeys()), damus_state: damus_state, txn: txn)
|
|
} else if sub_id == home_subid {
|
|
load_profiles(context: "home", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus_state, txn: txn)
|
|
}
|
|
|
|
self.loading = false
|
|
break
|
|
|
|
case .ok:
|
|
break
|
|
case .auth:
|
|
break
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
|
|
/// Send the initial filters, just our contact list mostly
|
|
func send_initial_filters(relay_id: String) {
|
|
let filter = NostrFilter(kinds: [.contacts], limit: 1, authors: [damus_state.pubkey])
|
|
let subscription = NostrSubscribe(filters: [filter], sub_id: init_subid)
|
|
pool.send(.subscribe(subscription), to: [relay_id])
|
|
}
|
|
|
|
/// After initial connection or reconnect, send subscription filters for the home timeline, DMs, and notifications
|
|
func send_home_filters(relay_id: String?) {
|
|
// TODO: since times should be based on events from a specific relay
|
|
// perhaps we could mark this in the relay pool somehow
|
|
|
|
let friends = get_friends()
|
|
|
|
var contacts_filter = NostrFilter(kinds: [.metadata])
|
|
contacts_filter.authors = friends
|
|
|
|
var our_contacts_filter = NostrFilter(kinds: [.contacts, .metadata])
|
|
our_contacts_filter.authors = [damus_state.pubkey]
|
|
|
|
var our_old_blocklist_filter = NostrFilter(kinds: [.list_deprecated])
|
|
our_old_blocklist_filter.parameter = ["mute"]
|
|
our_old_blocklist_filter.authors = [damus_state.pubkey]
|
|
|
|
var our_blocklist_filter = NostrFilter(kinds: [.mute_list])
|
|
our_blocklist_filter.authors = [damus_state.pubkey]
|
|
|
|
var dms_filter = NostrFilter(kinds: [.dm])
|
|
|
|
var our_dms_filter = NostrFilter(kinds: [.dm])
|
|
|
|
// friends only?...
|
|
//dms_filter.authors = friends
|
|
dms_filter.limit = 500
|
|
dms_filter.pubkeys = [ damus_state.pubkey ]
|
|
our_dms_filter.authors = [ damus_state.pubkey ]
|
|
|
|
var notifications_filter_kinds: [NostrKind] = [
|
|
.text,
|
|
.boost,
|
|
.zap,
|
|
]
|
|
if !damus_state.settings.onlyzaps_mode {
|
|
notifications_filter_kinds.append(.like)
|
|
}
|
|
var notifications_filter = NostrFilter(kinds: notifications_filter_kinds)
|
|
notifications_filter.pubkeys = [damus_state.pubkey]
|
|
notifications_filter.limit = 500
|
|
|
|
var notifications_filters = [notifications_filter]
|
|
var contacts_filters = [contacts_filter, our_contacts_filter, our_blocklist_filter, our_old_blocklist_filter]
|
|
var dms_filters = [dms_filter, our_dms_filter]
|
|
let last_of_kind = get_last_of_kind(relay_id: relay_id)
|
|
|
|
contacts_filters = update_filters_with_since(last_of_kind: last_of_kind, filters: contacts_filters)
|
|
notifications_filters = update_filters_with_since(last_of_kind: last_of_kind, filters: notifications_filters)
|
|
dms_filters = update_filters_with_since(last_of_kind: last_of_kind, filters: dms_filters)
|
|
|
|
//print_filters(relay_id: relay_id, filters: [home_filters, contacts_filters, notifications_filters, dms_filters])
|
|
|
|
subscribe_to_home_filters(relay_id: relay_id)
|
|
|
|
let relay_ids = relay_id.map { [$0] }
|
|
|
|
pool.send(.subscribe(.init(filters: contacts_filters, sub_id: contacts_subid)), to: relay_ids)
|
|
pool.send(.subscribe(.init(filters: notifications_filters, sub_id: notifications_subid)), to: relay_ids)
|
|
pool.send(.subscribe(.init(filters: dms_filters, sub_id: dms_subid)), to: relay_ids)
|
|
}
|
|
|
|
func get_last_of_kind(relay_id: String?) -> [UInt32: NostrEvent] {
|
|
return relay_id.flatMap { last_event_of_kind[$0] } ?? [:]
|
|
}
|
|
|
|
func unsubscribe_to_home_filters() {
|
|
pool.send(.unsubscribe(home_subid))
|
|
}
|
|
|
|
func get_friends() -> [Pubkey] {
|
|
var friends = damus_state.contacts.get_friend_list()
|
|
friends.insert(damus_state.pubkey)
|
|
return Array(friends)
|
|
}
|
|
|
|
func subscribe_to_home_filters(friends fs: [Pubkey]? = nil, relay_id: String? = nil) {
|
|
// TODO: separate likes?
|
|
var home_filter_kinds: [NostrKind] = [
|
|
.text, .longform, .boost
|
|
]
|
|
if !damus_state.settings.onlyzaps_mode {
|
|
home_filter_kinds.append(.like)
|
|
}
|
|
|
|
// only pull status data if we care for it
|
|
if damus_state.settings.show_music_statuses || damus_state.settings.show_general_statuses {
|
|
home_filter_kinds.append(.status)
|
|
}
|
|
|
|
let friends = fs ?? get_friends()
|
|
var home_filter = NostrFilter(kinds: home_filter_kinds)
|
|
// include our pubkey as well even if we're not technically a friend
|
|
home_filter.authors = friends
|
|
home_filter.limit = 500
|
|
|
|
var home_filters = [home_filter]
|
|
|
|
let followed_hashtags = Array(damus_state.contacts.get_followed_hashtags())
|
|
if followed_hashtags.count != 0 {
|
|
var hashtag_filter = NostrFilter.filter_hashtag(followed_hashtags)
|
|
hashtag_filter.limit = 100
|
|
home_filters.append(hashtag_filter)
|
|
}
|
|
|
|
let relay_ids = relay_id.map { [$0] }
|
|
home_filters = update_filters_with_since(last_of_kind: get_last_of_kind(relay_id: relay_id), filters: home_filters)
|
|
let sub = NostrSubscribe(filters: home_filters, sub_id: home_subid)
|
|
|
|
pool.send(.subscribe(sub), to: relay_ids)
|
|
}
|
|
|
|
func handle_mute_list_event(_ ev: NostrEvent) {
|
|
// we only care about our mutelist
|
|
guard ev.pubkey == damus_state.pubkey else {
|
|
return
|
|
}
|
|
|
|
// we only care about the most recent mutelist
|
|
if let mutelist = damus_state.contacts.mutelist {
|
|
if ev.created_at <= mutelist.created_at {
|
|
return
|
|
}
|
|
}
|
|
|
|
damus_state.contacts.set_mutelist(ev)
|
|
}
|
|
|
|
func handle_old_list_event(_ ev: NostrEvent) {
|
|
// we only care about our lists
|
|
guard ev.pubkey == damus_state.pubkey else {
|
|
return
|
|
}
|
|
|
|
// we only care about the most recent mutelist
|
|
if let mutelist = damus_state.contacts.mutelist {
|
|
if ev.created_at <= mutelist.created_at {
|
|
return
|
|
}
|
|
}
|
|
|
|
guard ev.referenced_params.contains(where: { p in p.param.matches_str("mute") }) else {
|
|
return
|
|
}
|
|
|
|
damus_state.contacts.set_mutelist(ev)
|
|
}
|
|
|
|
func get_last_event_of_kind(relay_id: String, kind: UInt32) -> NostrEvent? {
|
|
guard let m = last_event_of_kind[relay_id] else {
|
|
last_event_of_kind[relay_id] = [:]
|
|
return nil
|
|
}
|
|
|
|
return m[kind]
|
|
}
|
|
|
|
func handle_notification(ev: NostrEvent) {
|
|
// don't show notifications from ourselves
|
|
guard ev.pubkey != damus_state.pubkey,
|
|
event_has_our_pubkey(ev, our_pubkey: self.damus_state.pubkey),
|
|
should_show_event(keypair: self.damus_state.keypair, hellthreads: damus_state.muted_threads, contacts: damus_state.contacts, ev: ev) else {
|
|
return
|
|
}
|
|
|
|
damus_state.events.insert(ev)
|
|
|
|
if let inner_ev = ev.get_inner_event(cache: damus_state.events) {
|
|
damus_state.events.insert(inner_ev)
|
|
}
|
|
|
|
if !notifications.insert_event(ev, damus_state: damus_state) {
|
|
return
|
|
}
|
|
|
|
if handle_last_event(ev: ev, timeline: .notifications) {
|
|
process_local_notification(state: damus_state, event: ev)
|
|
}
|
|
|
|
}
|
|
|
|
@discardableResult
|
|
func handle_last_event(ev: NostrEvent, timeline: Timeline, shouldNotify: Bool = true) -> Bool {
|
|
if let new_bits = handle_last_events(new_events: self.notification_status.new_events, ev: ev, timeline: timeline, shouldNotify: shouldNotify) {
|
|
self.notification_status.new_events = new_bits
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
func insert_home_event(_ ev: NostrEvent) {
|
|
if events.insert(ev) {
|
|
handle_last_event(ev: ev, timeline: .home)
|
|
}
|
|
}
|
|
|
|
|
|
func handle_text_event(sub_id: String, _ ev: NostrEvent) {
|
|
guard should_show_event(keypair: damus_state.keypair, hellthreads: damus_state.muted_threads, contacts: damus_state.contacts, ev: ev) else {
|
|
return
|
|
}
|
|
|
|
// TODO: will we need to process this in other places like zap request contents, etc?
|
|
process_image_metadatas(cache: damus_state.events, ev: ev)
|
|
damus_state.replies.count_replies(ev, keypair: self.damus_state.keypair)
|
|
damus_state.events.insert(ev)
|
|
|
|
if sub_id == home_subid {
|
|
insert_home_event(ev)
|
|
} else if sub_id == notifications_subid {
|
|
handle_notification(ev: ev)
|
|
}
|
|
}
|
|
|
|
func got_new_dm(notifs: NewEventsBits, ev: NostrEvent) {
|
|
notification_status.new_events = notifs
|
|
|
|
guard should_display_notification(state: damus_state, event: ev),
|
|
let notification_object = generate_local_notification_object(from: ev, state: damus_state)
|
|
else {
|
|
return
|
|
}
|
|
|
|
create_local_notification(profiles: damus_state.profiles, notify: notification_object)
|
|
}
|
|
|
|
func handle_dm(_ ev: NostrEvent) {
|
|
guard should_show_event(keypair: damus_state.keypair, hellthreads: damus_state.muted_threads, contacts: damus_state.contacts, ev: ev) else {
|
|
return
|
|
}
|
|
|
|
damus_state.events.insert(ev)
|
|
|
|
if !should_debounce_dms {
|
|
self.incoming_dms.append(ev)
|
|
if let notifs = handle_incoming_dms(prev_events: notification_status.new_events, dms: self.dms, our_pubkey: self.damus_state.pubkey, evs: self.incoming_dms) {
|
|
got_new_dm(notifs: notifs, ev: ev)
|
|
}
|
|
self.incoming_dms = []
|
|
return
|
|
}
|
|
|
|
incoming_dms.append(ev)
|
|
|
|
dm_debouncer.debounce { [self] in
|
|
if let notifs = handle_incoming_dms(prev_events: notification_status.new_events, dms: self.dms, our_pubkey: self.damus_state.pubkey, evs: self.incoming_dms) {
|
|
got_new_dm(notifs: notifs, ev: ev)
|
|
}
|
|
self.incoming_dms = []
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
func update_signal_from_pool(signal: SignalModel, pool: RelayPool) {
|
|
if signal.max_signal != pool.relays.count {
|
|
signal.max_signal = pool.relays.count
|
|
}
|
|
|
|
if signal.signal != pool.num_connected {
|
|
signal.signal = pool.num_connected
|
|
}
|
|
}
|
|
|
|
func add_contact_if_friend(contacts: Contacts, ev: NostrEvent) {
|
|
if !contacts.is_friend(ev.pubkey) {
|
|
return
|
|
}
|
|
|
|
contacts.add_friend_contact(ev)
|
|
}
|
|
|
|
func load_our_contacts(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
|
|
let contacts = state.contacts
|
|
let new_refs = Set<FollowRef>(ev.referenced_follows)
|
|
let old_refs = m_old_ev.map({ old_ev in Set(old_ev.referenced_follows) }) ?? Set()
|
|
|
|
let diff = new_refs.symmetricDifference(old_refs)
|
|
for ref in diff {
|
|
if new_refs.contains(ref) {
|
|
notify(.followed(ref))
|
|
switch ref {
|
|
case .pubkey(let pk):
|
|
contacts.add_friend_pubkey(pk)
|
|
case .hashtag:
|
|
// I guess I could cache followed hashtags here... whatever
|
|
break
|
|
}
|
|
} else {
|
|
notify(.unfollowed(ref))
|
|
switch ref {
|
|
case .pubkey(let pk):
|
|
contacts.remove_friend(pk)
|
|
case .hashtag: break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
func abbrev_ids(_ ids: [String]) -> String {
|
|
if ids.count > 5 {
|
|
let n = ids.count - 5
|
|
return "[" + ids[..<5].joined(separator: ",") + ", ... (\(n) more)]"
|
|
}
|
|
return "\(ids)"
|
|
}
|
|
|
|
func abbrev_field<T: CustomStringConvertible>(_ n: String, _ field: T?) -> String {
|
|
guard let field = field else {
|
|
return ""
|
|
}
|
|
|
|
return "\(n):\(field.description)"
|
|
}
|
|
|
|
func abbrev_ids_field(_ n: String, _ ids: [String]?) -> String {
|
|
guard let ids = ids else {
|
|
return ""
|
|
}
|
|
|
|
return "\(n): \(abbrev_ids(ids))"
|
|
}
|
|
|
|
/*
|
|
func print_filter(_ f: NostrFilter) {
|
|
let fmt = [
|
|
abbrev_ids_field("ids", f.ids),
|
|
abbrev_field("kinds", f.kinds),
|
|
abbrev_ids_field("authors", f.authors),
|
|
abbrev_ids_field("referenced_ids", f.referenced_ids),
|
|
abbrev_ids_field("pubkeys", f.pubkeys),
|
|
abbrev_field("since", f.since),
|
|
abbrev_field("until", f.until),
|
|
abbrev_field("limit", f.limit)
|
|
].filter({ !$0.isEmpty }).joined(separator: ",")
|
|
|
|
print("Filter(\(fmt))")
|
|
}
|
|
|
|
func print_filters(relay_id: String?, filters groups: [[NostrFilter]]) {
|
|
let relays = relay_id ?? "relays"
|
|
print("connected to \(relays) with filters:")
|
|
for group in groups {
|
|
for filter in group {
|
|
print_filter(filter)
|
|
}
|
|
}
|
|
print("-----")
|
|
}
|
|
*/
|
|
|
|
// TODO: remove this, let nostrdb handle all validation
|
|
func guard_valid_event(events: EventCache, ev: NostrEvent, callback: @escaping () -> Void) {
|
|
let validated = events.is_event_valid(ev.id)
|
|
|
|
switch validated {
|
|
case .unknown:
|
|
Task.detached(priority: .medium) {
|
|
let result = validate_event(ev: ev)
|
|
|
|
DispatchQueue.main.async {
|
|
events.store_event_validation(evid: ev.id, validated: result)
|
|
guard result == .ok else {
|
|
return
|
|
}
|
|
callback()
|
|
}
|
|
}
|
|
|
|
case .ok:
|
|
callback()
|
|
|
|
case .bad_id, .bad_sig:
|
|
break
|
|
}
|
|
}
|
|
|
|
func robohash(_ pk: Pubkey) -> String {
|
|
return "https://robohash.org/" + pk.hex()
|
|
}
|
|
|
|
func load_our_stuff(state: DamusState, ev: NostrEvent) {
|
|
guard ev.pubkey == state.pubkey else {
|
|
return
|
|
}
|
|
|
|
// only use new stuff
|
|
if let current_ev = state.contacts.event {
|
|
guard ev.created_at > current_ev.created_at else {
|
|
return
|
|
}
|
|
}
|
|
|
|
let m_old_ev = state.contacts.event
|
|
state.contacts.event = ev
|
|
|
|
load_our_contacts(state: state, m_old_ev: m_old_ev, ev: ev)
|
|
load_our_relays(state: state, m_old_ev: m_old_ev, ev: ev)
|
|
}
|
|
|
|
func process_contact_event(state: DamusState, ev: NostrEvent) {
|
|
load_our_stuff(state: state, ev: ev)
|
|
add_contact_if_friend(contacts: state.contacts, ev: ev)
|
|
}
|
|
|
|
func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
|
|
let bootstrap_dict: [String: RelayInfo] = [:]
|
|
let old_decoded = m_old_ev.flatMap { decode_json_relays($0.content) } ?? state.bootstrap_relays.reduce(into: bootstrap_dict) { (d, r) in
|
|
d[r] = .rw
|
|
}
|
|
|
|
guard let decoded: [String: RelayInfo] = decode_json_relays(ev.content) else {
|
|
return
|
|
}
|
|
|
|
var changed = false
|
|
|
|
var new = Set<String>()
|
|
for key in decoded.keys {
|
|
new.insert(key)
|
|
}
|
|
|
|
var old = Set<String>()
|
|
for key in old_decoded.keys {
|
|
old.insert(key)
|
|
}
|
|
|
|
let diff = old.symmetricDifference(new)
|
|
|
|
let new_relay_filters = load_relay_filters(state.pubkey) == nil
|
|
for d in diff {
|
|
changed = true
|
|
if new.contains(d) {
|
|
if let url = RelayURL(d) {
|
|
let descriptor = RelayDescriptor(url: url, info: decoded[d] ?? .rw)
|
|
add_new_relay(model_cache: state.relay_model_cache, relay_filters: state.relay_filters, pool: state.pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: state.settings.developer_mode)
|
|
}
|
|
} else {
|
|
state.pool.remove_relay(d)
|
|
}
|
|
}
|
|
|
|
if changed {
|
|
save_bootstrap_relays(pubkey: state.pubkey, relays: Array(new))
|
|
state.pool.connect()
|
|
notify(.relays_changed)
|
|
}
|
|
}
|
|
|
|
func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, pool: RelayPool, descriptor: RelayDescriptor, new_relay_filters: Bool, logging_enabled: Bool) {
|
|
try? pool.add_relay(descriptor)
|
|
let url = descriptor.url
|
|
|
|
let relay_id = url.id
|
|
guard model_cache.model(withURL: url) == nil else {
|
|
return
|
|
}
|
|
|
|
Task.detached(priority: .background) {
|
|
guard let meta = try? await fetch_relay_metadata(relay_id: relay_id) else {
|
|
return
|
|
}
|
|
|
|
await MainActor.run {
|
|
let model = RelayModel(url, metadata: meta)
|
|
model_cache.insert(model: model)
|
|
|
|
if logging_enabled {
|
|
pool.setLog(model.log, for: relay_id)
|
|
}
|
|
|
|
// if this is the first time adding filters, we should filter non-paid relays
|
|
if new_relay_filters && !meta.is_paid {
|
|
relay_filters.insert(timeline: .search, relay_id: relay_id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func fetch_relay_metadata(relay_id: String) async throws -> RelayMetadata? {
|
|
var urlString = relay_id.replacingOccurrences(of: "wss://", with: "https://")
|
|
urlString = urlString.replacingOccurrences(of: "ws://", with: "http://")
|
|
|
|
guard let url = URL(string: urlString) else {
|
|
return nil
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.setValue("application/nostr+json", forHTTPHeaderField: "Accept")
|
|
|
|
var res: (Data, URLResponse)? = nil
|
|
|
|
res = try await URLSession.shared.data(for: request)
|
|
|
|
guard let data = res?.0 else {
|
|
return nil
|
|
}
|
|
|
|
let nip11 = try JSONDecoder().decode(RelayMetadata.self, from: data)
|
|
return nip11
|
|
}
|
|
|
|
@discardableResult
|
|
func handle_incoming_dm(ev: NostrEvent, our_pubkey: Pubkey, dms: DirectMessagesModel, prev_events: NewEventsBits) -> (Bool, NewEventsBits?) {
|
|
var inserted = false
|
|
var found = false
|
|
|
|
let ours = ev.pubkey == our_pubkey
|
|
var i = 0
|
|
|
|
var the_pk = ev.pubkey
|
|
if ours {
|
|
if let ref_pk = ev.referenced_pubkeys.first {
|
|
the_pk = ref_pk
|
|
} else {
|
|
// self dm!?
|
|
print("TODO: handle self dm?")
|
|
}
|
|
}
|
|
|
|
for model in dms.dms {
|
|
if model.pubkey == the_pk {
|
|
found = true
|
|
inserted = insert_uniq_sorted_event(events: &(dms.dms[i].events), new_ev: ev) {
|
|
$0.created_at < $1.created_at
|
|
}
|
|
|
|
break
|
|
}
|
|
i += 1
|
|
}
|
|
|
|
if !found {
|
|
let model = DirectMessageModel(events: [ev], our_pubkey: our_pubkey, pubkey: the_pk)
|
|
dms.dms.append(model)
|
|
inserted = true
|
|
}
|
|
|
|
var new_bits: NewEventsBits? = nil
|
|
if inserted {
|
|
new_bits = handle_last_events(new_events: prev_events, ev: ev, timeline: .dms, shouldNotify: !ours)
|
|
}
|
|
|
|
return (inserted, new_bits)
|
|
}
|
|
|
|
@discardableResult
|
|
func handle_incoming_dms(prev_events: NewEventsBits, dms: DirectMessagesModel, our_pubkey: Pubkey, evs: [NostrEvent]) -> NewEventsBits? {
|
|
var inserted = false
|
|
|
|
var new_events: NewEventsBits? = nil
|
|
|
|
for ev in evs {
|
|
let res = handle_incoming_dm(ev: ev, our_pubkey: our_pubkey, dms: dms, prev_events: prev_events)
|
|
inserted = res.0 || inserted
|
|
if let new = res.1 {
|
|
new_events = new
|
|
}
|
|
}
|
|
|
|
if inserted {
|
|
let new_dms = Array(dms.dms.filter({ $0.events.count > 0 })).sorted { a, b in
|
|
return a.events.last!.created_at > b.events.last!.created_at
|
|
}
|
|
|
|
dms.dms = new_dms
|
|
}
|
|
|
|
return new_events
|
|
}
|
|
|
|
func determine_event_notifications(_ ev: NostrEvent) -> NewEventsBits {
|
|
guard let kind = ev.known_kind else {
|
|
return []
|
|
}
|
|
|
|
if kind == .zap {
|
|
return [.zaps]
|
|
}
|
|
|
|
if kind == .boost {
|
|
return [.reposts]
|
|
}
|
|
|
|
if kind == .text {
|
|
return [.mentions]
|
|
}
|
|
|
|
if kind == .like {
|
|
return [.likes]
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
func timeline_to_notification_bits(_ timeline: Timeline, ev: NostrEvent?) -> NewEventsBits {
|
|
switch timeline {
|
|
case .home:
|
|
return [.home]
|
|
case .notifications:
|
|
if let ev {
|
|
return determine_event_notifications(ev)
|
|
}
|
|
return [.notifications]
|
|
case .search:
|
|
return [.search]
|
|
case .dms:
|
|
return [.dms]
|
|
}
|
|
}
|
|
|
|
/// A helper to determine if we need to notify the user of new events
|
|
func handle_last_events(new_events: NewEventsBits, ev: NostrEvent, timeline: Timeline, shouldNotify: Bool = true) -> NewEventsBits? {
|
|
let last_ev = get_last_event(timeline)
|
|
|
|
if last_ev == nil || last_ev!.created_at < ev.created_at {
|
|
save_last_event(ev, timeline: timeline)
|
|
if shouldNotify {
|
|
return new_events.union(timeline_to_notification_bits(timeline, ev: ev))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
|
|
/// Sometimes we get garbage in our notifications. Ensure we have our pubkey on this event
|
|
func event_has_our_pubkey(_ ev: NostrEvent, our_pubkey: Pubkey) -> Bool {
|
|
return ev.referenced_pubkeys.contains(our_pubkey)
|
|
}
|
|
|
|
func should_show_event(event: NostrEvent, damus_state: DamusState) -> Bool {
|
|
return should_show_event(
|
|
keypair: damus_state.keypair,
|
|
hellthreads: damus_state.muted_threads,
|
|
contacts: damus_state.contacts,
|
|
ev: event
|
|
)
|
|
}
|
|
|
|
func should_show_event(keypair: Keypair, hellthreads: MutedThreadsManager, contacts: Contacts, ev: NostrEvent) -> Bool {
|
|
if contacts.is_muted(ev.pubkey) {
|
|
return false
|
|
}
|
|
|
|
if hellthreads.isMutedThread(ev, keypair: keypair) {
|
|
return false
|
|
}
|
|
|
|
return ev.should_show_event
|
|
}
|
|
|
|
func zap_vibrate(zap_amount: Int64) {
|
|
let sats = zap_amount / 1000
|
|
var vibration_generator: UIImpactFeedbackGenerator
|
|
if sats >= 10000 {
|
|
vibration_generator = UIImpactFeedbackGenerator(style: .heavy)
|
|
} else if sats >= 1000 {
|
|
vibration_generator = UIImpactFeedbackGenerator(style: .medium)
|
|
} else {
|
|
vibration_generator = UIImpactFeedbackGenerator(style: .light)
|
|
}
|
|
vibration_generator.impactOccurred()
|
|
}
|
|
|
|
func create_in_app_profile_zap_notification(profiles: Profiles, zap: Zap, locale: Locale = Locale.current, profile_id: Pubkey) {
|
|
let content = UNMutableNotificationContent()
|
|
|
|
content.title = NotificationFormatter.zap_notification_title(zap)
|
|
content.body = NotificationFormatter.zap_notification_body(profiles: profiles, zap: zap, locale: locale)
|
|
content.sound = UNNotificationSound.default
|
|
content.userInfo = LossyLocalNotification(type: .profile_zap, mention: .pubkey(profile_id)).to_user_info()
|
|
|
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
|
|
|
let request = UNNotificationRequest(identifier: "myZapNotification", content: content, trigger: trigger)
|
|
|
|
UNUserNotificationCenter.current().add(request) { error in
|
|
if let error = error {
|
|
print("Error: \(error)")
|
|
} else {
|
|
print("Local notification scheduled")
|
|
}
|
|
}
|
|
}
|
|
|
|
func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale: Locale = Locale.current, evId: NoteId) {
|
|
let content = UNMutableNotificationContent()
|
|
|
|
content.title = NotificationFormatter.zap_notification_title(zap)
|
|
content.body = NotificationFormatter.zap_notification_body(profiles: profiles, zap: zap, locale: locale)
|
|
content.sound = UNNotificationSound.default
|
|
content.userInfo = LossyLocalNotification(type: .zap, mention: .note(evId)).to_user_info()
|
|
|
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
|
|
|
|
let request = UNNotificationRequest(identifier: "myZapNotification", content: content, trigger: trigger)
|
|
|
|
UNUserNotificationCenter.current().add(request) { error in
|
|
if let error = error {
|
|
print("Error: \(error)")
|
|
} else {
|
|
print("Local notification scheduled")
|
|
}
|
|
}
|
|
}
|