Files
damus/damus/Features/Chat/ChatEventView.swift
T
alltheseasGitHuballtheseasClaude Opus 4.5Daniel D'Aquino <daniel@daquino.me
9a1ae6f9b5 Consume NIP-19 relay hints for event fetching
Extract and use relay hints from bech32 entities (nevent, nprofile, naddr)
and event tag references (e, q tags) to fetch events from hinted relays
not in the user's relay pool.

Changes:
- Parse relay hints from bech32 TLV data in URLHandler
- Pass relay hints through SearchType and NoteReference enums
- Add ensureConnected() to RelayPool for ephemeral relay connections
- Implement ephemeral relay lease management with race condition protection
- Add repostTarget() helper to extract relay hints from repost e tags
- Add QuoteRef struct to preserve relay hints from q tags (NIP-10/NIP-18)
- Support relay hints in replies with author pubkey in e-tags (NIP-10)
- Implement fallback broadcast when hinted relays don't respond
- Add comprehensive test coverage for relay hint functionality
- Add DEBUG logging for relay hint tracing during development

Implementation details:
- Connect to hinted relays as ephemeral, returning early when first connects
- Use total deadline to prevent timeout accumulation across hint attempts
- Decrement lease count before suspension points to ensure atomicity
- Fall back to broadcast if hints don't resolve or respond

Closes: https://github.com/damus-io/damus/issues/1147
Changelog-Added: Added relay hint support for nevent, nprofile, naddr links and event tag references (reposts, quotes, replies)
Signed-off-by: alltheseas
Signed-off-by: Daniel D'Aquino <daniel@daquino.me>
Co-authored-by: alltheseas <alltheseas@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Daniel D'Aquino <daniel@daquino.me
2026-02-02 18:52:41 -08:00

370 lines
15 KiB
Swift

//
// ChatView.swift
// damus
//
// Created by William Casarin on 2022-04-19.
//
import SwiftUI
import EmojiKit
import EmojiPicker
import SwipeActions
fileprivate let CORNER_RADIUS: CGFloat = 10
struct ChatEventView: View {
// MARK: Parameters
let event: NostrEvent
let selected_event: NostrEvent
let prev_ev: NostrEvent?
let next_ev: NostrEvent?
let damus_state: DamusState
var thread: ThreadModel
let scroll_to_event: ((_ id: NoteId) -> Void)?
let focus_event: (() -> Void)?
let highlight_bubble: Bool
// MARK: long-press reaction control objects
/// Whether the user is actively pressing the view
@State var is_pressing = false
@State var popover_state: PopoverState = .closed {
didSet {
let generator = UIImpactFeedbackGenerator(style: popover_state.some_sheet_open() ? .heavy : .light)
generator.impactOccurred()
}
}
@State var selected_emoji: Emoji?
@State private var isOnTopHalfOfScreen: Bool = false
@StateObject private var bar: ActionBarModel
@Environment(\.swipeViewGroupSelection) var swipeViewGroupSelection
init(event: NostrEvent, selected_event: NostrEvent, prev_ev: NostrEvent?, next_ev: NostrEvent?, damus_state: DamusState, thread: ThreadModel, scroll_to_event: ((_ id: NoteId) -> Void)?, focus_event: (() -> Void)?, highlight_bubble: Bool) {
self.event = event
self.selected_event = selected_event
self.prev_ev = prev_ev
self.next_ev = next_ev
self.damus_state = damus_state
self.thread = thread
self.scroll_to_event = scroll_to_event
self.focus_event = focus_event
self.highlight_bubble = highlight_bubble
// Initialize @StateObject using wrappedValue
_bar = StateObject(wrappedValue: make_actionbar_model(ev: event.id, damus: damus_state))
}
enum PopoverState: String {
case closed
case open_emoji_selector
case open_zap_sheet
func some_sheet_open() -> Bool {
return self == .open_zap_sheet || self == .open_emoji_selector
}
}
var just_started: Bool {
return prev_ev == nil || prev_ev!.pubkey != event.pubkey
}
func next_replies_to_this() -> Bool {
guard let next = next_ev else {
return false
}
return damus_state.events.replies.lookup(next.id) != nil
}
func is_reply_to_prev(ref_id: NoteId) -> Bool {
guard let prev = prev_ev else {
return true
}
if let rep = damus_state.events.replies.lookup(event.id) {
return rep.contains(prev.id)
}
return false
}
var disable_animation: Bool {
self.damus_state.settings.disable_animation
}
var reply_quote_options: EventViewOptions {
return [.no_previews, .no_action_bar, .truncate_content_very_short, .no_show_more, .no_translate, .no_media]
}
var profile_picture_view: some View {
VStack {
ProfilePicView(pubkey: event.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation, damusState: damus_state)
.onTapGesture {
show_profile_action_sheet_if_enabled(damus_state: damus_state, pubkey: event.pubkey)
}
}
.frame(maxWidth: 32)
}
var by_other_user: Bool {
return event.pubkey != damus_state.pubkey
}
var is_ours: Bool { return !by_other_user }
// MARK: Zapping properties
var lnurl: String? {
try? damus_state.profiles.lookup_lnurl(event.pubkey)
}
var zap_target: ZapTarget {
ZapTarget.note(id: event.id, author: event.pubkey)
}
// MARK: Views
var event_bubble: some View {
ChatBubble(
direction: is_ours ? .right : .left,
stroke_content: Color.accentColor.opacity(highlight_bubble ? 1 : 0),
stroke_style: .init(lineWidth: 4),
background_style: by_other_user ? DamusColors.adaptableGrey : DamusColors.adaptablePurpleBackground
) {
VStack(alignment: .leading, spacing: 4) {
if by_other_user {
HStack {
ProfileName(pubkey: event.pubkey, damus: damus_state)
.onTapGesture {
show_profile_action_sheet_if_enabled(damus_state: damus_state, pubkey: event.pubkey)
}
.lineLimit(1)
Text(verbatim: "\(format_relative_time(event.created_at))")
.foregroundColor(.gray)
}
}
if let reply_ref = event.direct_reply_ref(),
reply_ref.note_id != selected_event.id {
ReplyQuoteView(keypair: damus_state.keypair, quoter: event, event_id: reply_ref.note_id, state: damus_state, thread: thread, options: reply_quote_options, relayHint: reply_ref.relay)
.background(is_ours ? DamusColors.adaptablePurpleBackground2 : DamusColors.adaptableGrey2)
.foregroundColor(is_ours ? Color.damusAdaptablePurpleForeground : Color.damusAdaptableBlack)
.cornerRadius(5)
.onTapGesture {
self.scroll_to_event?(reply_ref.note_id)
}
}
let blur_images = should_blur_images(settings: damus_state.settings, contacts: damus_state.contacts, ev: event, our_pubkey: damus_state.pubkey)
NoteContentView(damus_state: damus_state, event: event, blur_images: blur_images, size: .normal, options: [.truncate_content])
.padding(2)
if let mention = first_eref_mention_with_hints(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
MentionView(damus_state: damus_state, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
.background(DamusColors.adaptableWhite)
.clipShape(RoundedRectangle(cornerSize: CGSize(width: 10, height: 10)))
}
}
.frame(minWidth: 5, alignment: is_ours ? .trailing : .leading)
.padding(10)
}
.tint(Color.accentColor)
.overlay(
ZStack(alignment: is_ours ? .bottomLeading : .bottomTrailing) {
VStack {
Spacer()
self.action_bar
.padding(.horizontal, 5)
}
}
)
.onTapGesture {
if popover_state == .closed {
focus_event?()
}
else {
popover_state = .closed
let generator = UIImpactFeedbackGenerator(style: .light)
generator.impactOccurred()
}
}
}
var event_bubble_with_long_press_interaction: some View {
ZStack(alignment: is_ours ? .bottomLeading : .bottomTrailing) {
self.event_bubble
.sheet(isPresented: Binding(get: { popover_state == .open_emoji_selector }, set: { new_state in
withAnimation(new_state == true ? .easeIn(duration: 0.5) : .easeOut(duration: 0.1)) {
popover_state = new_state == true ? .open_emoji_selector : .closed
}
})) {
NavigationView {
EmojiPickerView(selectedEmoji: $selected_emoji, emojiProvider: damus_state.emoji_provider)
}.presentationDetents([.medium, .large])
}
.sheet(isPresented: Binding(get: { popover_state == .open_zap_sheet }, set: { new_state in
withAnimation(new_state == true ? .easeIn(duration: 0.5) : .easeOut(duration: 0.1)) {
popover_state = new_state == true ? .open_zap_sheet : .closed
}
})) {
ZapSheetViewIfPossible(damus_state: damus_state, target: zap_target, lnurl: lnurl)
.presentationDetents([.medium, .large])
}
.onChange(of: selected_emoji) { newSelectedEmoji in
if let newSelectedEmoji {
Task {
await send_like(emoji: newSelectedEmoji.value)
popover_state = .closed
}
}
}
}
.scaleEffect(self.popover_state.some_sheet_open() ? 1.08 : is_pressing ? 1.02 : 1)
.shadow(color: (is_pressing || self.popover_state.some_sheet_open()) ? .black.opacity(0.1) : .black.opacity(0.3), radius: (is_pressing || self.popover_state.some_sheet_open()) ? 8 : 0, y: (is_pressing || self.popover_state.some_sheet_open()) ? 15 : 0)
.onLongPressGesture(minimumDuration: 0.5, maximumDistance: 10, perform: {
withAnimation(.bouncy(duration: 0.2, extraBounce: 0.35)) {
let should_show_zap_sheet = !damus_state.settings.nozaps && damus_state.settings.onlyzaps_mode
popover_state = should_show_zap_sheet ? .open_zap_sheet : .open_emoji_selector
}
}, onPressingChanged: { is_pressing in
withAnimation(is_pressing ? .easeIn(duration: 0.5) : .easeOut(duration: 0.1)) {
self.is_pressing = is_pressing
}
})
.onChange(of: swipeViewGroupSelection.wrappedValue) { newValue in
self.is_pressing = false
}
.background(
GeometryReader { geometry in
EmptyView()
.onAppear {
let eventActionBarY = geometry.frame(in: .global).midY
let screenMidY = UIScreen.main.bounds.midY
self.isOnTopHalfOfScreen = eventActionBarY > screenMidY
}
.onChange(of: geometry.frame(in: .global).midY) { newY in
let screenMidY = UIScreen.main.bounds.midY
self.isOnTopHalfOfScreen = newY > screenMidY
}
}
)
}
func send_like(emoji: String) async {
guard let keypair = damus_state.keypair.to_full(),
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: await damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
return
}
self.bar.our_like = like_ev
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.impactOccurred()
await damus_state.nostrNetwork.postbox.send(like_ev)
}
var action_bar: some View {
return Group {
if !bar.is_empty {
HStack {
if by_other_user {
Spacer()
}
EventActionBar(damus_state: damus_state, event: event, bar: bar, options: [.no_spread, .hide_items_without_activity])
.padding(10)
.background(DamusColors.adaptableLighterGrey)
.disabled(true)
.cornerRadius(100)
.overlay(RoundedRectangle(cornerSize: CGSize(width: 100, height: 100)).stroke(DamusColors.adaptableWhite, lineWidth: 1))
.shadow(color: Color.black.opacity(0.05),radius: 3, y: 3)
.scaleEffect(0.7, anchor: is_ours ? .leading : .trailing)
if !by_other_user {
Spacer()
}
}
.padding(.vertical, -20)
}
}
}
var event_bubble_with_long_press_and_swipe_interactions: some View {
Group {
SwipeView {
self.event_bubble_with_long_press_interaction
} leadingActions: { context in
if !is_ours {
EventActionBar(
damus_state: damus_state,
event: event,
bar: bar,
options: is_ours ? [.swipe_action_menu_reverse] : [.swipe_action_menu],
swipe_context: context
)
}
} trailingActions: { context in
if is_ours {
EventActionBar(
damus_state: damus_state,
event: event,
bar: bar,
options: is_ours ? [.swipe_action_menu_reverse] : [.swipe_action_menu],
swipe_context: context
)
}
}
.swipeSpacing(-20)
.swipeActionsStyle(.mask)
.swipeMinimumDistance(40)
.swipeDragGesturePriority(.normal)
}
}
var content: some View {
return VStack {
HStack(alignment: .bottom, spacing: 4) {
if by_other_user {
self.profile_picture_view
}
else {
Spacer()
}
self.event_bubble_with_long_press_and_swipe_interactions
if !by_other_user {
self.profile_picture_view
}
else {
Spacer()
}
}
.contentShape(Rectangle())
.id(event.id)
.padding([.bottom], bar.is_empty ? 6 : 16)
}
}
var body: some View {
if [.boost, .zap, .longform].contains(where: { event.known_kind == $0 }) {
EmptyView()
} else {
self.content
}
}
}
#Preview {
return ChatEventView(event: test_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false)
}
#Preview {
return ChatEventView(event: test_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false)
}
#Preview {
return ChatEventView(event: test_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: true)
}
#Preview {
return ChatEventView(event: test_super_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false)
}