Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 180 additions & 2 deletions damus.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions damus/Core/Nostr/NostrEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,15 @@ func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String =
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 7, tags: tags)
}

func make_live_chat_event(keypair: FullKeypair, content: String, root: String, dtag: String, relayURL: RelayURL?) -> NostrEvent? {
//var tags = Array(boosted.referenced_pubkeys).map({ pk in pk.tag })
var aTagBuilder = ["a", "30311:\(root):\(dtag)"]

var tags: [[String]] = [aTagBuilder]

return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 1311, tags: tags)
}

func generate_private_keypair(our_privkey: Privkey, id: NoteId, created_at: UInt32) -> FullKeypair? {
let to_hash = our_privkey.hex() + id.hex() + String(created_at)
guard let dat = to_hash.data(using: .utf8) else {
Expand Down
2 changes: 2 additions & 0 deletions damus/Core/Nostr/NostrKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ enum NostrKind: UInt32, Codable {
case boost = 6
case like = 7
case chat = 42
case live_chat = 1311
case mute_list = 10000
case relay_list = 10002
case interest_list = 10015
Expand All @@ -30,6 +31,7 @@ enum NostrKind: UInt32, Codable {
case nwc_request = 23194
case nwc_response = 23195
case http_auth = 27235
case live = 30311
case status = 30315
case follow_list = 39089
}
16 changes: 11 additions & 5 deletions damus/Features/Events/Components/EventTop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,39 @@ struct EventTop: View {
let event: NostrEvent
let pubkey: Pubkey
let is_anon: Bool
let size: EventViewKind
let options: EventViewOptions

init(state: DamusState, event: NostrEvent, pubkey: Pubkey, is_anon: Bool) {
init(state: DamusState, event: NostrEvent, pubkey: Pubkey, is_anon: Bool, size: EventViewKind, options: EventViewOptions) {
self.state = state
self.event = event
self.pubkey = pubkey
self.is_anon = is_anon
self.size = size
self.options = options
}

func ProfileName(is_anon: Bool) -> some View {
let pk = is_anon ? ANON_PUBKEY : self.pubkey
return EventProfileName(pubkey: pk, damus: state, size: .normal)
return EventProfileName(pubkey: pk, damus: state, size: size)
}

var body: some View {
HStack(alignment: .center, spacing: 0) {
ProfileName(is_anon: is_anon)
TimeDot()
RelativeTime(time: state.events.get_cache_data(event.id).relative_time)
RelativeTime(time: state.events.get_cache_data(event.id).relative_time, size: size, font_size: state.settings.font_size)
Spacer()
EventMenuContext(damus: state, event: event)
if !options.contains(.no_context_menu) {
EventMenuContext(damus: state, event: event)
}
}
.lineLimit(1)
}
}

struct EventTop_Previews: PreviewProvider {
static var previews: some View {
EventTop(state: test_damus_state, event: test_note, pubkey: test_note.pubkey, is_anon: false)
EventTop(state: test_damus_state, event: test_note, pubkey: test_note.pubkey, is_anon: false, size: .normal, options: [])
}
}
6 changes: 4 additions & 2 deletions damus/Features/Events/Components/RelativeTime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,19 @@ import SwiftUI

struct RelativeTime: View {
@ObservedObject var time: RelativeTimeModel
let size: EventViewKind
let font_size: Double

var body: some View {
Text(verbatim: "\(time.value)")
.font(.system(size: 16))
.font(eventviewsize_to_font(size, font_size: font_size))
.foregroundColor(.gray)
}
}


struct RelativeTime_Previews: PreviewProvider {
static var previews: some View {
RelativeTime(time: RelativeTimeModel())
RelativeTime(time: RelativeTimeModel(), size: .normal, font_size: 1.0)
}
}
10 changes: 6 additions & 4 deletions damus/Features/Events/EventShell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,12 @@ struct EventShell<Content: View>: View {
}

VStack(alignment: .leading) {
EventTop(state: state, event: event, pubkey: pubkey, is_anon: is_anon)

UserStatusView(status: state.profiles.profile_data(pubkey).status, show_general: state.settings.show_general_statuses, show_music: state.settings.show_music_statuses)
EventTop(state: state, event: event, pubkey: pubkey, is_anon: is_anon, size: options.contains(.small_text) ? .small : .normal, options: options)

if !options.contains(.no_status) {
UserStatusView(status: state.profiles.profile_data(pubkey).status, show_general: state.settings.show_general_statuses, show_music: state.settings.show_music_statuses)
}

if !options.contains(.no_replying_to) {
ReplyPart(events: state.events, event: event, keypair: state.keypair, ndb: state.ndb)
}
Expand All @@ -93,7 +95,7 @@ struct EventShell<Content: View>: View {
Pfp(is_anon: is_anon)

VStack(alignment: .leading, spacing: 2) {
EventTop(state: state, event: event, pubkey: pubkey, is_anon: is_anon)
EventTop(state: state, event: event, pubkey: pubkey, is_anon: is_anon, size: options.contains(.small_text) ? .small : .normal, options: options)
UserStatusView(status: state.profiles.profile_data(pubkey).status, show_general: state.settings.show_general_statuses, show_music: state.settings.show_music_statuses)
ReplyPart(events: state.events, event: event, keypair: state.keypair, ndb: state.ndb)
ProxyView(event: event)
Expand Down
2 changes: 1 addition & 1 deletion damus/Features/Events/Models/LoadableNostrEventView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class LoadableNostrEventViewModel: ObservableObject {
case .zap, .zap_request:
guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found }
return .loaded(route: Route.Zaps(target: zap.target))
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list, .follow_list, .interest_list:
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list, .follow_list, .interest_list, .live, .live_chat:
return .unknown_or_unsupported_kind
}
case .naddr(let naddr):
Expand Down
13 changes: 13 additions & 0 deletions damus/Features/Events/TextEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,13 @@ struct EventViewOptions: OptionSet {
static let truncate_content_very_short = EventViewOptions(rawValue: 1 << 11)
static let no_previews = EventViewOptions(rawValue: 1 << 12)
static let no_show_more = EventViewOptions(rawValue: 1 << 13)
static let small_text = EventViewOptions(rawValue: 1 << 14)
static let no_status = EventViewOptions(rawValue: 1 << 15)
static let no_context_menu = EventViewOptions(rawValue: 1 << 16)

static let embedded: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested]
static let embedded_text_only: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested, .no_media, .truncate_content_very_short, .no_previews]
static let live_chat: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .no_previews, .nested, .small_text, .no_status, .no_context_menu]
}

struct TextEvent: View {
Expand All @@ -51,6 +55,15 @@ struct TextEvent: View {

func EvBody(options: EventViewOptions) -> some View {
let blur_imgs = should_blur_images(settings: damus.settings, contacts: damus.contacts, ev: event, our_pubkey: damus.pubkey)
if options.contains(.small_text) {
return NoteContentView(
damus_state: damus,
event: event,
blur_images: blur_imgs,
size: .small,
options: options)
}

return NoteContentView(
damus_state: damus,
event: event,
Expand Down
93 changes: 93 additions & 0 deletions damus/Features/Live/LiveChat/Models/LiveChatModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//
// LiveChatModel.swift
// damus
//
// Created by eric on 8/7/25.
//

import Foundation

/// The data model for the LiveEventHome view
class LiveChatModel: ObservableObject {
var events: EventHolder
@Published var loading: Bool = false

let damus_state: DamusState
let root: String
let dtag: String
let live_chat_subid = UUID().description
let limit: UInt32 = 1000

init(damus_state: DamusState, root: String, dtag: String) {
self.damus_state = damus_state
self.root = root
self.dtag = dtag
self.events = EventHolder(on_queue: { ev in
preload_events(state: damus_state, events: [ev])
})
}

func filter_muted() {
events.filter { should_show_event(state: damus_state, ev: $0) }
self.objectWillChange.send()
}

func subscribe() {
loading = true
let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters)

let live_chat_filter = NostrFilter(kinds: [.live_chat])

damus_state.nostrNetwork.pool.subscribe(sub_id: live_chat_subid, filters: [live_chat_filter], handler: handle_event, to: to_relays)
}


func unsubscribe(to: RelayURL? = nil) {
loading = false
damus_state.nostrNetwork.pool.unsubscribe(sub_id: live_chat_subid, to: to.map { [$0] })
}

func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
guard case .nostr_event(let event) = conn_ev else {
return
}

switch event {
case .event(let sub_id, let ev):
guard sub_id == self.live_chat_subid else {
return
}
for tag in ev.tags {
guard tag.count >= 2 else { continue }
switch tag[0].string() {
case "a":
let atag = tag[1].string()
let split = atag.split(separator: ":")
if root != split[1] {
return
}
if dtag != split[2] {
return
}
default:
break
}
}
if should_show_event(state: damus_state, ev: ev)
{
if self.events.insert(ev) {
self.objectWillChange.send()
}
}
case .notice(let msg):
print("live events notice: \(msg)")
case .ok:
break
case .eose(let sub_id):
loading = false
break
case .auth:
break
}
}
}
136 changes: 136 additions & 0 deletions damus/Features/Live/LiveChat/Views/LiveChatHomeView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
//
// LiveChatHomeView.swift
// damus
//
// Created by eric on 8/7/25.
//

import SwiftUI

struct LiveChatHomeView: View, KeyboardReadable {
let state: DamusState
let event: LiveEvent
@StateObject var model: LiveChatModel
@State private var chat_message = ""
@FocusState private var isTextFieldFocused: Bool
@Environment(\.colorScheme) var colorScheme

func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: state)
filters.append(fstate.filter)
return ContentFilters(filters: filters).filter
}

var Footer: some View {
HStack(spacing: 0) {
ChatInput

Button(
role: .none,
action: {
send_chat()
}
) {
Label("", image: "send")
.font(.title)
}
.disabled(chat_message.isEmpty)
}
.safeAreaInset(edge: .bottom) {
Color.clear.frame(height: 10)
}
}


func send_chat() {
guard
let keypair = state.keypair.to_full(),
let liveChat = make_live_chat_event(keypair: keypair, content: chat_message, root: event.event.pubkey.hex(), dtag: event.uuid ?? "", relayURL: nil)
else {
return
}
state.nostrNetwork.postbox.send(liveChat)
chat_message = ""
end_editing()
}

var ChatInput: some View {
HStack{
TextField(NSLocalizedString("Chat", comment: "Placeholder text to prompt entry of chat message."), text: $chat_message)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.focused($isTextFieldFocused)
}
.padding(10)
.background(.secondary.opacity(0.2))
.cornerRadius(20)
.padding(.horizontal, 15)
}

func scroll_to_end(_ scroller: ScrollViewProxy, animated: Bool = false) {
if animated {
withAnimation {
scroller.scrollTo("endblock")
}
} else {
scroller.scrollTo("endblock")
}
}

var Chat: some View {
ScrollViewReader { scroller in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
let events = model.events.events
ForEach(Array(zip(events, events.indices).reversed()).filter { should_show_event(state: state, ev: $0.0)}, id: \.0.id) { (ev, ind) in
TextEvent(damus: state, event: ev, pubkey: ev.pubkey, options: .live_chat)
}
EndBlock(height: 1)
}
}
.dismissKeyboardOnTap()
.onAppear {
scroll_to_end(scroller)
}.onChange(of: model.events.events.count) { _ in
scroll_to_end(scroller, animated: true)
}
.padding(.top, 5)

Footer
.onReceive(keyboardPublisher) { visible in
guard visible else {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
scroll_to_end(scroller, animated: true)
}
}
}
}

var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Live Chat")
.fontWeight(.bold)
.padding(5)

LiveStreamViewers(state: state, currentParticipants: event.currentParticipants ?? 0, preview: false)
}

Divider()

Chat
}
.onReceive(handle_notify(.new_mutes)) { _ in
self.model.filter_muted()
}
.onAppear {
model.subscribe()
}
.onDisappear {
model.unsubscribe()
}

}
}
Loading