Damus Live

This PR adds Live Streaming and Live Chat to Damus via Damus Labs.

Changelog-Added: Added live stream timeline
Changelog-Added: Added live chat timeline
Changelog-Added: Added ability to create live chat event
Changelog-Added: Damus Labs Toggle

Signed-off-by: ericholguin <ericholguin@apache.org>
This commit is contained in:
ericholguin
2025-11-14 15:36:43 -08:00
committed by Daniel D’Aquino
parent a31f6bce0e
commit b8c664d354
39 changed files with 1793 additions and 92 deletions
@@ -0,0 +1,99 @@
//
// 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
var subscriptionTask: Task<Void, any Error>? = nil
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])
})
}
@MainActor
func filter_muted() {
events.filter { should_show_event(state: damus_state, ev: $0) }
self.objectWillChange.send()
}
@MainActor
func set(loading: Bool) {
self.loading = loading
}
func subscribe() {
subscriptionTask?.cancel()
subscriptionTask = Task {
await set(loading: true)
let live_chat_filter = NostrFilter(kinds: [.live_chat])
let to_relays = await damus_state.nostrNetwork.ourRelayDescriptors
.map { $0.url }
.filter { !damus_state.relay_filters.is_filtered(timeline: .search, relay_id: $0) }
for await item in damus_state.nostrNetwork.reader.advancedStream(filters: [live_chat_filter], to: to_relays) {
switch item {
case .event(let lender):
await lender.justUseACopy({ await handle_event(event: $0) })
case .eose:
continue
case .ndbEose:
await set(loading: false)
case .networkEose:
continue
}
}
}
}
@MainActor
func unsubscribe(to: RelayURL? = nil) {
set(loading: false)
subscriptionTask?.cancel()
}
func handle_event(event: NostrEvent) async {
for tag in event.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
}
}
await MainActor.run {
if should_show_event(state: damus_state, ev: event) {
if self.events.insert(event) {
self.objectWillChange.send()
}
}
}
}
}
@@ -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: {
Task { await send_chat() }
}
) {
Label("", image: "send")
.font(.title)
}
.disabled(chat_message.isEmpty)
}
.safeAreaInset(edge: .bottom) {
Color.clear.frame(height: 10)
}
}
func send_chat() async {
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
}
await 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()
}
}
}
@@ -0,0 +1,136 @@
//
// LiveChatTimeline.swift
// damus
//
// Created by eric on 8/7/25.
//
import SwiftUI
struct LiveChatTimelineView<Content: View>: View {
@ObservedObject var events: EventHolder
@Binding var loading: Bool
let damus: DamusState
let show_friend_icon: Bool
let filter: (NostrEvent) -> Bool
let content: Content?
let apply_mute_rules: Bool
init(events: EventHolder, loading: Binding<Bool>, headerHeight: Binding<CGFloat>, headerOffset: Binding<CGFloat>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
self.events = events
self._loading = loading
self.damus = damus
self.show_friend_icon = show_friend_icon
self.filter = filter
self.apply_mute_rules = apply_mute_rules
self.content = content?()
}
init(events: EventHolder, loading: Binding<Bool>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
self.events = events
self._loading = loading
self.damus = damus
self.show_friend_icon = show_friend_icon
self.filter = filter
self.apply_mute_rules = apply_mute_rules
self.content = content?()
}
func scroll_to_end(_ scroller: ScrollViewProxy, animated: Bool = false) {
if animated {
withAnimation {
scroller.scrollTo("endblock")
}
} else {
scroller.scrollTo("endblock")
}
}
var body: some View {
ScrollViewReader { scroller in
ScrollView {
if let content {
content
}
Color.clear
.id("startblock")
.frame(height: 0)
LiveChatInnerView(events: events, damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules)
.redacted(reason: loading ? .placeholder : [])
.shimmer(loading)
.disabled(loading)
.background {
GeometryReader { proxy -> Color in
handle_scroll_queue(proxy, queue: self.events)
return Color.clear
}
}
}
.coordinateSpace(name: "scroll")
.onReceive(handle_notify(.scroll_to_top)) { () in
events.flush()
self.events.set_should_queue(false)
scroll_to_event(scroller: scroller, id: "startblock", delay: 0.0, animate: true, anchor: .top)
}
}
.onAppear {
events.flush()
}
}
}
struct LiveChatInnerView: View {
@ObservedObject var events: EventHolder
let state: DamusState
let filter: (NostrEvent) -> Bool
init(events: EventHolder, damus: DamusState, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true) {
self.events = events
self.state = damus
self.filter = apply_mute_rules ? { filter($0) && !damus.mutelist_manager.is_event_muted($0) } : filter
}
var event_options: EventViewOptions {
if self.state.settings.truncate_timeline_text {
return [.wide, .truncate_content]
}
return [.wide]
}
var body: some View {
LazyVStack(spacing: 0) {
let events = self.events.events
if events.isEmpty {
EmptyTimelineView()
} else {
let evs = events.filter(filter)
let indexed = Array(zip(evs, 0...))
ForEach(indexed, id: \.0.id) { tup in
let ev = tup.0
let ind = tup.1
if ev.kind == NostrKind.live_chat.rawValue {
LiveChatView(state: state, ev: ev)
.padding(.top, 7)
.onAppear {
let to_preload =
Array([indexed[safe: ind+1]?.0,
indexed[safe: ind+2]?.0,
indexed[safe: ind+3]?.0,
indexed[safe: ind+4]?.0,
indexed[safe: ind+5]?.0
].compactMap({ $0 }))
preload_events(state: state, events: to_preload)
}
}
}
}
}
.padding(.bottom)
}
}
@@ -0,0 +1,38 @@
//
// LiveChatView.swift
// damus
//
// Created by eric on 8/7/25.
//
import SwiftUI
import Kingfisher
struct LiveChatView: View {
let state: DamusState
let event: NostrEvent
@Environment(\.colorScheme) var colorScheme
@ObservedObject var artifacts: NoteArtifactsModel
init(state: DamusState, ev: NostrEvent) {
self.state = state
self.event = ev
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model)
}
func content_filter(_ pubkeys: [Pubkey]) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: self.state)
filters.append({ pubkeys.contains($0.pubkey) })
return ContentFilters(filters: filters).filter
}
var body: some View {
VStack(alignment: .leading) {
TextEvent(damus: state, event: event, pubkey: event.pubkey, options: [.no_action_bar,.small_pfp,.wide,.no_previews,.small_text])
}
.padding(.bottom, 1)
}
}
@@ -0,0 +1,71 @@
//
// LiveEvent.swift
// damus
//
// Created by eric on 7/10/25.
//
import Foundation
enum LiveEventStatus: String {
case planned = "SCHEDULED"
case live = "LIVE"
case ended = "ENDED"
}
struct LiveEvent: Hashable {
let event: NostrEvent
var uuid: String? = nil
var title: String? = nil
var summary: String? = nil
var image: URL? = nil
var streaming: URL? = nil
var recording: URL? = nil
var starts: String? = nil
var ends: String? = nil
var status: LiveEventStatus? = nil
var currentParticipants: Int? = nil
var totalParticipants: Int? = nil
var pinned: String? = nil
var hashtags: [String]? = nil
var publicKeys: [Pubkey] = []
static func parse(from ev: NostrEvent) -> LiveEvent {
var liveEvent = LiveEvent(event: ev)
for tag in ev.tags {
guard tag.count >= 2 else { continue }
switch tag[0].string() {
case "title": liveEvent.title = tag[1].string()
case "d": liveEvent.uuid = tag[1].string()
case "image": liveEvent.image = URL(string: tag[1].string())
case "summary": liveEvent.summary = tag[1].string()
case "streaming": liveEvent.streaming = URL(string: tag[1].string())
case "recording": liveEvent.recording = URL(string: tag[1].string())
case "starts": liveEvent.starts = tag[1].string()
case "ends": liveEvent.ends = tag[1].string()
case "status":
if tag[1].string() == "planned" {
liveEvent.status = .planned
} else if tag[1].string() == "live" {
liveEvent.status = .live
} else if tag[1].string() == "ended" {
liveEvent.status = .ended
}
case "current_participants": liveEvent.currentParticipants = Int(tag[1].string())
case "total_participants": liveEvent.totalParticipants = Int(tag[1].string())
case "pinned": liveEvent.pinned = tag[1].string()
case "t":
if (liveEvent.hashtags?.append(tag[1].string())) == nil {
liveEvent.hashtags = [tag[1].string()]
}
case "p":
liveEvent.publicKeys.append(Pubkey(Data(hex: tag[1].string())))
default:
break
}
}
return liveEvent
}
}
@@ -0,0 +1,97 @@
//
// LiveEventModel.swift
// damus
//
// Created by eric on 7/25/25.
//
import Foundation
/// The data model for the LiveEventHome view
class LiveEventModel: ObservableObject {
var events: EventHolder
@Published var loading: Bool = false
let damus_state: DamusState
var subscriptionTask: Task<Void, any Error>? = nil
var seen_dtag: Set<String> = Set()
init(damus_state: DamusState) {
self.damus_state = damus_state
self.events = EventHolder(on_queue: { ev in
preload_events(state: damus_state, events: [ev])
})
}
@MainActor
func filter_muted() {
events.filter { should_show_event(state: damus_state, ev: $0) }
self.objectWillChange.send()
}
/// Helper function to set the `loading` member in the correct actor
@MainActor
private func set(loading: Bool) {
self.loading = loading
}
func subscribe() {
subscriptionTask?.cancel()
subscriptionTask = Task {
await self.set(loading: true)
var live_event_filter = NostrFilter(kinds: [.live])
live_event_filter.until = UInt32(Date.now.timeIntervalSince1970)
let calendar = Calendar.current
let twoWeeksAgo = calendar.date(byAdding: .day, value: -14, to: Date())!
live_event_filter.since = UInt32(twoWeeksAgo.timeIntervalSince1970)
let to_relays = await damus_state.nostrNetwork.ourRelayDescriptors
.map { $0.url }
.filter { !damus_state.relay_filters.is_filtered(timeline: .search, relay_id: $0) }
for await item in damus_state.nostrNetwork.reader.advancedStream(filters: [live_event_filter], to: to_relays) {
switch item {
case .event(let lender):
await lender.justUseACopy({ await handle_event(ev: $0) })
case .eose:
continue
case .ndbEose:
await self.set(loading: false)
case .networkEose:
continue
}
}
}
}
@MainActor
func unsubscribe() {
self.set(loading: false)
subscriptionTask?.cancel()
}
func handle_event(ev: NostrEvent) async {
let should_show_event = await should_show_event(state: damus_state, ev: ev)
if ev.is_textlike && should_show_event && !ev.is_reply()
{
for tag in ev.tags {
guard tag.count >= 2 else { continue }
if tag[0].string() == "d" {
if seen_dtag.contains(tag[1].string()) {
return
} else {
seen_dtag.insert(tag[1].string())
}
}
}
await MainActor.run {
if self.events.insert(ev) {
self.objectWillChange.send()
}
}
}
}
}
@@ -0,0 +1,65 @@
//
// LiveStreamBanner.swift
// damus
//
// Created by eric on 8/8/25.
//
import SwiftUI
import Kingfisher
struct LiveStreamBanner: View {
let state: DamusState
let options: EventViewOptions
var image: URL? = nil
var preview: Bool
func Placeholder(url: URL, preview: Bool) -> some View {
Group {
if let meta = state.events.lookup_img_metadata(url: url),
case .processed(let blurhash) = meta.state {
Image(uiImage: blurhash)
.resizable()
.frame(minWidth: UIScreen.main.bounds.width, minHeight: preview ? 200 : 200, maxHeight: preview ? 200 : 200)
} else {
DamusColors.adaptableWhite
}
}
}
func titleImage(url: URL, preview: Bool) -> some View {
KFAnimatedImage(url)
.callbackQueue(.dispatch(.global(qos:.background)))
.backgroundDecode(true)
.imageContext(.note, disable_animation: state.settings.disable_animation)
.image_fade(duration: 0.25)
.cancelOnDisappear(true)
.configure { view in
view.framePreloadCount = 3
}
.background {
Placeholder(url: url, preview: preview)
}
.aspectRatio(contentMode: .fill)
.frame(minWidth: UIScreen.main.bounds.width, minHeight: preview ? 200 : 200, maxHeight: preview ? 200 : 200)
.kfClickable()
.cornerRadius(1)
}
var body: some View {
if let url = image {
if (self.options.contains(.no_media)) {
EmptyView()
} else {
titleImage(url: url, preview: preview)
}
} else {
Text(NSLocalizedString("No cover image", comment: "Text letting user know there is no cover image."))
.bold()
.foregroundColor(.white)
.frame(width: UIScreen.main.bounds.width, height: 200)
.background(DamusGradient.gradient.opacity(0.75))
Divider()
}
}
}
@@ -0,0 +1,41 @@
//
// LiveStreamProfile.swift
// damus
//
// Created by eric on 8/8/25.
//
import SwiftUI
struct LiveStreamProfile: View {
var state: DamusState
var pubkey: Pubkey
var size: CGFloat = 25
var body: some View {
HStack {
ProfilePicView(pubkey: pubkey, size: size, highlight: .custom(DamusColors.neutral3, 1.0), profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true, damusState: state)
.onTapGesture {
state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
}
let profile_txn = state.profiles.lookup(id: pubkey)
let profile = profile_txn?.unsafeUnownedValue
let displayName = Profile.displayName(profile: profile, pubkey: pubkey)
switch displayName {
case .one(let one):
Text(one)
.font(.subheadline).foregroundColor(.gray)
case .both(username: let username, displayName: let displayName):
HStack(spacing: 6) {
Text(verbatim: displayName)
.font(.subheadline).foregroundColor(.gray)
Text(verbatim: "@\(username)")
.font(.subheadline).foregroundColor(.gray)
}
}
}
.padding(5)
}
}
@@ -0,0 +1,51 @@
//
// LiveStreamStatus.swift
// damus
//
// Created by eric on 8/8/25.
//
import SwiftUI
struct LiveStreamStatus: View {
let status: LiveEventStatus
let starts: String?
var body: some View {
HStack {
switch status {
case .planned:
Image("calendar")
.foregroundColor(Color.white)
if let starts = starts {
Text("\(starts)")
.foregroundColor(Color.white)
.bold()
.glow()
} else {
Text("\(status.rawValue)")
.foregroundColor(Color.white)
.bold()
}
case .live:
Image("record")
.foregroundColor(Color.red)
.glow()
Text("\(status.rawValue)")
.foregroundColor(DamusColors.adaptableWhite)
.bold()
case .ended:
Text("\(status.rawValue)")
.foregroundColor(DamusColors.adaptableWhite)
.bold()
}
}
.padding(.vertical, 2)
.padding(.horizontal, 7)
.background(DamusColors.adaptableBlack.opacity(0.5))
.cornerRadius(10)
.padding(10)
}
}
@@ -0,0 +1,36 @@
//
// LiveStreamViewers.swift
// damus
//
// Created by eric on 8/8/25.
//
import SwiftUI
struct LiveStreamViewers: View {
let state: DamusState
var currentParticipants: Int
var preview: Bool
var body: some View {
HStack(alignment: .center) {
let viewerCount = currentParticipants
let nounString = pluralizedString(key: "viewer_count", count: viewerCount)
let nounText = Text(verbatim: nounString).font(.subheadline).foregroundColor(DamusColors.adaptableWhite)
if preview {
Text("\(Text(verbatim: viewerCount.formatted()).font(.subheadline.weight(.medium))) \(nounText)", comment: "Sentence composed of 2 variables to describe how many people are viewing the live event. In source English, the first variable is the number of viewers, and the second variable is 'viewer' or 'viewers'.")
.foregroundColor(DamusColors.adaptableWhite)
} else {
Image("user")
.resizable()
.frame(width: 15, height: 15)
Text("\(Text(verbatim: viewerCount.formatted()).font(.subheadline.weight(.medium)))", comment: "number")
}
}
.padding(.vertical, preview ? 2 : 0)
.padding(.horizontal, preview ? 7 : 0)
.background(preview ? DamusColors.adaptableBlack.opacity(0.5) : .clear)
.cornerRadius(preview ? 10 : 0)
}
}
@@ -0,0 +1,50 @@
//
// LiveStreamHomeView.swift
// damus
//
// Created by eric on 7/25/25.
//
import SwiftUI
import CryptoKit
import NaturalLanguage
struct LiveStreamHomeView: View {
let damus_state: DamusState
@StateObject var model: LiveEventModel
@Environment(\.colorScheme) var colorScheme
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: damus_state)
filters.append(fstate.filter)
return ContentFilters(filters: filters).filter
}
var body: some View {
VStack {
LiveStreamTimelineView<AnyView>(events: model.events, loading: $model.loading, damus: damus_state, filter:content_filter(FilterState.live))
}
.padding(.bottom)
.refreshable {
// Fetch new information by unsubscribing and resubscribing to the relay
model.unsubscribe()
model.subscribe()
}
.onReceive(handle_notify(.new_mutes)) { _ in
self.model.filter_muted()
}
.onAppear {
model.subscribe()
}
.onDisappear {
model.unsubscribe()
}
}
}
struct LiveStreamHomeView_Previews: PreviewProvider {
static var previews: some View {
let state = test_damus_state
LiveStreamHomeView(damus_state: state, model: LiveEventModel(damus_state: state))
}
}
@@ -0,0 +1,127 @@
//
// LiveStreamPreview.swift
// damus
//
// Created by eric on 7/10/25.
//
import SwiftUI
import Kingfisher
struct LiveStreamPreviewBody: View {
let state: DamusState
let event: LiveEvent
let options: EventViewOptions
let header: Bool
@ObservedObject var artifacts: NoteArtifactsModel
init(state: DamusState, ev: LiveEvent, options: EventViewOptions, header: Bool) {
self.state = state
self.event = ev
self.options = options
self.header = header
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.event.id).artifacts_model)
}
init(state: DamusState, ev: NostrEvent, options: EventViewOptions, header: Bool) {
self.state = state
self.event = LiveEvent.parse(from: ev)
self.options = options
self.header = header
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model)
}
var body: some View {
if event.status == .live {
if let streamingURL = event.streaming {
if streamingURL.absoluteString.hasSuffix(".m3u8") {
Group {
if options.contains(.wide) {
Main.padding(.horizontal)
} else {
Main
}
}
}
}
}
}
var Main: some View {
VStack(alignment: .leading, spacing: 0) {
ZStack(alignment: .topLeading) {
if state.settings.media_previews {
LiveStreamBanner(state: state, options: options, image: event.image, preview: true)
}
VStack {
if let status = event.status {
LiveStreamStatus(status: status, starts: event.starts)
}
Spacer()
LiveStreamViewers(state: state, currentParticipants: event.currentParticipants ?? 0, preview: true)
.padding(10)
}
}
.frame(minWidth: UIScreen.main.bounds.width, minHeight: 200, maxHeight: 200)
VStack(alignment: .leading) {
LiveStreamProfile(state: state, pubkey: event.event.pubkey)
Text(event.title ?? NSLocalizedString("Untitled", comment: "Title of follow list event if it is untitled."))
.font(header ? .title : .headline)
.padding(.horizontal, 10)
EventTags(tags: event.hashtags)
}
}
}
}
struct LiveStreamPreview: View {
let state: DamusState
let event: LiveEvent
let options: EventViewOptions
init(state: DamusState, ev: NostrEvent, options: EventViewOptions) {
self.state = state
self.event = LiveEvent.parse(from: ev)
self.options = options.union(.no_mentions)
}
var body: some View {
let _ = print(event)
LiveStreamPreviewBody(state: state, ev: event, options: options, header: false)
}
}
let test_live_event = LiveEvent.parse(from: NostrEvent(
content: "",
keypair: test_keypair,
kind: NostrKind.live.rawValue,
tags: [
["title", "DAMUSES MEETING"],
["summary", "Damus Team Meeting"],
["image", "https://damus.io/img/logo.png"],
["streaming", "https://ome.mapboss.co.th/live/local_019865b4-6814-71af-b86d-17d0c96d7867/llhls.m3u8"],
["recording", "https://damus.io"],
["status", "live"],
["t", "meeting"],
["t", "damus"]
])!
)
struct LiveStreamPreview_Previews: PreviewProvider {
static var previews: some View {
VStack {
LiveStreamPreview(state: test_damus_state, ev: test_live_event.event, options: [])
}
.frame(height: 400)
}
}
@@ -0,0 +1,140 @@
//
// LiveStreamTimeline.swift
// damus
//
// Created by eric on 7/23/25.
//
import SwiftUI
struct LiveStreamTimelineView<Content: View>: View {
@ObservedObject var events: EventHolder
@Binding var loading: Bool
let damus: DamusState
let filter: (NostrEvent) -> Bool
let content: Content?
let apply_mute_rules: Bool
init(events: EventHolder, loading: Binding<Bool>, headerHeight: Binding<CGFloat>, headerOffset: Binding<CGFloat>, damus: DamusState, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
self.events = events
self._loading = loading
self.damus = damus
self.filter = filter
self.apply_mute_rules = apply_mute_rules
self.content = content?()
}
init(events: EventHolder, loading: Binding<Bool>, damus: DamusState, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
self.events = events
self._loading = loading
self.damus = damus
self.filter = filter
self.apply_mute_rules = apply_mute_rules
self.content = content?()
}
var body: some View {
ScrollViewReader { scroller in
ScrollView {
if let content {
content
}
HStack {
VStack(alignment: .leading) {
Text("Happening Now")
.font(.title2)
.fontWeight(.bold)
Text("Live events going on right now")
.font(.caption)
.foregroundColor(.gray)
}
.padding(.horizontal, 5)
Spacer()
}
Color.clear
.id("startblock")
.frame(height: 0)
LiveStreamInnerView(events: events, damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules)
.redacted(reason: loading ? .placeholder : [])
.shimmer(loading)
.disabled(loading)
.background {
GeometryReader { proxy -> Color in
handle_scroll_queue(proxy, queue: self.events)
return Color.clear
}
}
}
.coordinateSpace(name: "scroll")
.onReceive(handle_notify(.scroll_to_top)) { () in
events.flush()
self.events.set_should_queue(false)
scroll_to_event(scroller: scroller, id: "startblock", delay: 0.0, animate: true, anchor: .top)
}
}
.onAppear {
events.flush()
}
}
}
struct LiveStreamInnerView: View {
@ObservedObject var events: EventHolder
let state: DamusState
let filter: (NostrEvent) -> Bool
init(events: EventHolder, damus: DamusState, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true) {
self.events = events
self.state = damus
self.filter = apply_mute_rules ? { filter($0) && !damus.mutelist_manager.is_event_muted($0) } : filter
}
var event_options: EventViewOptions {
if self.state.settings.truncate_timeline_text {
return [.wide, .truncate_content]
}
return [.wide]
}
var body: some View {
LazyVStack(spacing: 0) {
let events = self.events.events
if events.isEmpty {
EmptyTimelineView()
} else {
let evs = events.filter(filter)
let indexed = Array(zip(evs, 0...))
ForEach(indexed, id: \.0.id) { tup in
let ev = tup.0
let ind = tup.1
if ev.kind == NostrKind.live.rawValue {
LiveStreamPreview(state: state, ev: ev, options: event_options)
.onTapGesture {
state.nav.push(route: Route.LiveEvent(LiveEvent: ev, model: LiveEventModel(damus_state: state)))
}
.padding(.top, 7)
.onAppear {
let to_preload =
Array([indexed[safe: ind+1]?.0,
indexed[safe: ind+2]?.0,
indexed[safe: ind+3]?.0,
indexed[safe: ind+4]?.0,
indexed[safe: ind+5]?.0
].compactMap({ $0 }))
preload_events(state: state, events: to_preload)
}
}
}
}
}
.padding(.bottom, 50)
}
}
@@ -0,0 +1,150 @@
//
// LiveStreamView.swift
// damus
//
// Created by eric on 7/25/25.
//
import SwiftUI
import Kingfisher
struct LiveStreamView: View {
let state: DamusState
let event: LiveEvent
@StateObject var model: LiveEventModel
@Environment(\.colorScheme) var colorScheme
@Environment(\.presentationMode) var presentationMode
@ObservedObject var artifacts: NoteArtifactsModel
@State private var dragOffset: CGSize = .zero
@State private var isDragging = false
@State private var currentVideoModel: DamusVideoPlayer?
init(state: DamusState, ev: LiveEvent, model: LiveEventModel) {
self.state = state
self.event = ev
self._model = StateObject(wrappedValue: model)
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.event.id).artifacts_model)
}
init(state: DamusState, ev: NostrEvent, model: LiveEventModel) {
self.state = state
self.event = LiveEvent.parse(from: ev)
self._model = StateObject(wrappedValue: model)
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model)
}
func content_filter(_ pubkeys: [Pubkey]) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: self.state)
filters.append({ pubkeys.contains($0.pubkey) })
return ContentFilters(filters: filters).filter
}
func setupVideoModel() {
if let streamingURL = event.streaming {
currentVideoModel = state.video.get_player(for: streamingURL)
// currentVideoModel = state.video.get_player(for: streamingURL, title: event.title ?? "Untitled", link: streamingURL.absoluteString, artist: "Nostrich", artwork: event.image?.absoluteString ?? "")
} else if let recordingURL = event.recording {
currentVideoModel = model.damus_state.video.get_player(for: recordingURL)
// currentVideoModel = model.damus_state.video.get_player(for: recordingURL, title: event.title ?? "Untitled", link: recordingURL.absoluteString, artist: "Nostrich", artwork: event.image?.absoluteString ?? "")
}
}
var dragGesture: some Gesture {
DragGesture()
.onChanged { value in
if value.translation.height > 0 {
dragOffset = value.translation
isDragging = true
}
}
.onEnded { value in
isDragging = false
if value.translation.height > 100 {
withAnimation(.easeOut(duration: 0.3)) {
dragOffset.height = UIScreen.main.bounds.height
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
presentationMode.wrappedValue.dismiss()
}
} else {
withAnimation(.spring()) {
dragOffset = .zero
}
}
}
}
var body: some View {
VStack(spacing: 0) {
LiveEventHeader
.highPriorityGesture(dragGesture, including: .all)
LiveChatHomeView(state: state, event: event, model: LiveChatModel(damus_state: state, root: event.event.pubkey.hex(), dtag: event.uuid ?? ""))
.scrollDismissesKeyboard(.immediately)
}
.offset(y: dragOffset.height)
.opacity(isDragging ? Double(1 - min(abs(dragOffset.height) / 250, 0.5)) : 1.0)
.animation(.interactiveSpring(), value: dragOffset)
.navigationBarBackButtonHidden(true)
.onAppear {
notify(.display_tabbar(false))
model.subscribe()
setupVideoModel()
}
.onDisappear {
notify(.display_tabbar(true))
model.unsubscribe()
}
}
var LiveEventHeader: some View {
VStack(alignment: .leading, spacing: 0) {
ZStack {
if let videoModel = currentVideoModel {
DamusVideoPlayerView(
model: videoModel,
coordinator: state.video,
style: .preview(on_tap: {})
)
} else {
LiveStreamBanner(state: state, options: EventViewOptions(), image: event.image, preview: false)
}
}
.frame(width: UIScreen.main.bounds.width, height: 250)
.fixedSize(horizontal: true, vertical: true)
.background(Color.black)
if !event.publicKeys.isEmpty {
LiveStreamProfile(state: state, pubkey: event.publicKeys[0], size: 35)
} else {
LiveStreamProfile(state: state, pubkey: event.event.pubkey, size: 35)
}
if let title = event.title {
Text(title)
.fixedSize(horizontal: false, vertical: true)
.fontWeight(.bold)
.padding(.horizontal, 5)
}
// TO DO: Add description in sheet
}
}
}
struct LiveStreamView_Previews: PreviewProvider {
static var previews: some View {
LiveStreamView(state: test_damus_state, ev: test_live_event, model: LiveEventModel(damus_state: test_damus_state))
.environmentObject(OrientationTracker())
}
}