9a1ae6f9b5
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
370 lines
15 KiB
Swift
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)
|
|
}
|