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:
committed by
Daniel D’Aquino
parent
a31f6bce0e
commit
b8c664d354
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user