Live Music & Generic Statuses

Changelog-Added: Added live music statuses
Changelog-Added: Added generic user statuses
This commit is contained in:
William Casarin
2023-08-21 22:12:01 -07:00
parent 59cf8056bd
commit 0338297bfe
18 changed files with 537 additions and 55 deletions

View File

@@ -0,0 +1,48 @@
//
// MusicController.swift
// damus
//
// Created by William Casarin on 2023-08-21.
//
import SwiftUI
import MediaPlayer
enum MusicState {
case playback_state(MPMusicPlaybackState)
case song(MPMediaItem?)
}
class MusicController {
let player: MPMusicPlayerController
let onChange: (MusicState) -> ()
init(onChange: @escaping (MusicState) -> ()) {
player = .systemMusicPlayer
player.beginGeneratingPlaybackNotifications()
self.onChange = onChange
print("Playback State: \(player.playbackState)")
print("Now Playing Item: \(player.nowPlayingItem?.title ?? "None")")
NotificationCenter.default.addObserver(self, selector: #selector(self.songChanged(notification:)), name: .MPMusicPlayerControllerNowPlayingItemDidChange, object: player)
NotificationCenter.default.addObserver(self, selector: #selector(self.playbackStatusChanged(notification:)), name: .MPMusicPlayerControllerPlaybackStateDidChange, object: player)
}
deinit {
print("deinit musiccontroller")
}
@objc
func songChanged(notification: Notification) {
onChange(.song(player.nowPlayingItem))
}
@objc
func playbackStatusChanged(notification: Notification) {
onChange(.playback_state(player.playbackState))
}
}

View File

@@ -0,0 +1,141 @@
//
// UserStatus.swift
// damus
//
// Created by William Casarin on 2023-08-22.
//
import Foundation
import MediaPlayer
struct Song {
let started_playing: Date
let content: String
}
struct UserStatus {
let type: UserStatusType
let expires_at: Date?
let content: String
func to_note(keypair: FullKeypair) -> NostrEvent? {
return make_user_status_note(status: self, keypair: keypair)
}
init(type: UserStatusType, expires_at: Date?, content: String) {
self.type = type
self.expires_at = expires_at
self.content = content
}
init?(ev: NostrEvent) {
guard let tag = ev.referenced_params.just_one() else {
return nil
}
let str = tag.param.string()
if str == "general" {
self.type = .general
} else if str == "music" {
self.type = .music
} else {
return nil
}
if let tag = ev.tags.first(where: { t in t.count >= 2 && t[0].matches_str("expiration") }),
tag.count == 2,
let expires = UInt32(tag[1].string())
{
self.expires_at = Date(timeIntervalSince1970: TimeInterval(expires))
} else {
self.expires_at = nil
}
self.content = ev.content
}
}
enum UserStatusType: String {
case music
case general
}
class UserStatusModel: ObservableObject {
@Published var general: UserStatus?
@Published var music: UserStatus?
func update_status(_ s: UserStatus) {
switch s.type {
case .music:
self.music = s
case .general:
self.general = s
}
}
var _playing_enabled: Bool
var playing_enabled: Bool {
set {
var new_val = newValue
if newValue {
MPMediaLibrary.requestAuthorization { astatus in
switch astatus {
case .notDetermined: new_val = false
case .denied: new_val = false
case .restricted: new_val = false
case .authorized: new_val = true
@unknown default:
new_val = false
}
}
}
if new_val != playing_enabled {
_playing_enabled = new_val
self.objectWillChange.send()
}
}
get {
return _playing_enabled
}
}
init(playing: UserStatus? = nil, status: UserStatus? = nil) {
self.general = status
self.music = playing
self._playing_enabled = false
self.playing_enabled = false
}
static var current_track: String? {
let player = MPMusicPlayerController.systemMusicPlayer
guard let nowPlayingItem = player.nowPlayingItem else { return nil }
return nowPlayingItem.title
}
}
func make_user_status_note(status: UserStatus, keypair: FullKeypair, expiry: Date? = nil) -> NostrEvent?
{
var tags: [[String]] = [ ["d", status.type.rawValue] ]
if let expiry {
tags.append(["expiration", String(UInt32(expiry.timeIntervalSince1970))])
} else if let expiry = status.expires_at {
tags.append(["expiration", String(UInt32(expiry.timeIntervalSince1970))])
}
let kind = NostrKind.status.rawValue
guard let ev = NostrEvent(content: status.content, keypair: keypair.to_keypair(), kind: kind, tags: tags) else {
return nil
}
return ev
}

View File

@@ -0,0 +1,116 @@
//
// UserStatusSheet.swift
// damus
//
// Created by William Casarin on 2023-08-23.
//
import SwiftUI
enum StatusDuration: String, CaseIterable {
case never = "Never"
case thirty_mins = "30 Minutes"
case hour = "1 Hour"
case four_hours = "4 Hours"
case day = "1 Day"
case week = "1 Week"
var expiration: Date? {
switch self {
case .never:
return nil
case .thirty_mins:
return Date.now.addingTimeInterval(60 * 30)
case .hour:
return Date.now.addingTimeInterval(60 * 60)
case .four_hours:
return Date.now.addingTimeInterval(60 * 60 * 4)
case .day:
return Date.now.addingTimeInterval(60 * 60 * 24)
case .week:
return Date.now.addingTimeInterval(60 * 60 * 24 * 7)
}
}
}
struct UserStatusSheet: View {
let postbox: PostBox
let keypair: Keypair
@State var duration: StatusDuration = .never
@ObservedObject var status: UserStatusModel
@Environment(\.dismiss) var dismiss
var status_binding: Binding<String> {
Binding(get: {
status.general?.content ?? ""
}, set: { v in
status.general = UserStatus(type: .general, expires_at: duration.expiration, content: v)
})
}
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text("Set Status")
.font(.largeTitle)
TextField(text: status_binding, label: {
Text("📋 Working")
})
HStack {
Text("Clear status")
Spacer()
Picker("Duration", selection: $duration) {
ForEach(StatusDuration.allCases, id: \.self) { d in
Text("\(d.rawValue)")
.tag(d)
}
}
}
Toggle(isOn: $status.playing_enabled, label: {
Text("Broadcast music playing on Apple Music")
})
HStack(alignment: .center) {
Button(action: {
dismiss()
}, label: {
Text("Cancel")
})
Spacer()
Button(action: {
guard let status = self.status.general,
let kp = keypair.to_full(),
let ev = make_user_status_note(status: status, keypair: kp, expiry: duration.expiration)
else {
return
}
postbox.send(ev)
dismiss()
}, label: {
Text("Save")
})
.buttonStyle(GradientButtonStyle())
}
.padding([.top], 30)
Spacer()
}
.padding(30)
}
}
struct UserStatusSheet_Previews: PreviewProvider {
static var previews: some View {
UserStatusSheet(postbox: PostBox(pool: RelayPool()), keypair: Keypair(pubkey: .empty, privkey: nil), status: .init())
}
}

View File

@@ -0,0 +1,37 @@
//
// UserStatus.swift
// damus
//
// Created by William Casarin on 2023-08-21.
//
import SwiftUI
import MediaPlayer
struct UserStatusView: View {
@ObservedObject var status: UserStatusModel
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let general = status.general {
Text(verbatim: "\(general.content)")
.foregroundColor(.gray)
.font(.callout.italic())
}
if let playing = status.music {
Text(verbatim: "🎵\(playing.content)")
.foregroundColor(.gray)
.font(.callout.italic())
}
}
}
}
struct UserStatusView_Previews: PreviewProvider {
static var previews: some View {
UserStatusView(status: .init())
}
}

View File

@@ -7,6 +7,7 @@
import SwiftUI
import AVKit
import MediaPlayer
struct TimestampedProfile {
let profile: Profile
@@ -30,6 +31,7 @@ enum Sheets: Identifiable {
case zap(ZapSheet)
case select_wallet(SelectWallet)
case filter
case user_status
case suggestedUsers
static func zap(target: ZapTarget, lnurl: String) -> Sheets {
@@ -43,6 +45,7 @@ enum Sheets: Identifiable {
var id: String {
switch self {
case .report: return "report"
case .user_status: return "user_status"
case .post(let action): return "post-" + (action.ev?.id.hex() ?? "")
case .event(let ev): return "event-" + ev.id.hex()
case .zap(let sheet): return "zap-" + hex_encode(sheet.target.id)
@@ -315,6 +318,8 @@ struct ContentView: View {
MaybeReportView(target: target)
case .post(let action):
PostView(action: action, damus_state: damus_state!)
case .user_status:
UserStatusSheet(postbox: damus_state!.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
case .event:
EventDetailView()
case .zap(let zapsheet):
@@ -647,14 +652,32 @@ struct ContentView: View {
muted_threads: MutedThreadsManager(keypair: keypair),
wallet: WalletModel(settings: settings),
nav: self.navigationCoordinator,
user_search_cache: user_search_cache
user_search_cache: user_search_cache,
music: MusicController(onChange: music_changed)
)
home.damus_state = self.damus_state!
pool.connect()
}
func music_changed(_ state: MusicState) {
guard let damus_state else { return }
switch state {
case .playback_state:
break
case .song(let song):
guard let song, let kp = damus_state.keypair.to_full() else { return }
let pdata = damus_state.profiles.profile_data(damus_state.pubkey)
let music = UserStatus(type: .music, expires_at: Date.now.addingTimeInterval(song.playbackDuration), content: "\(song.title ?? "Unknown") - \(song.artist ?? "Unknown")")
pdata.status.music = music
guard let ev = music.to_note(keypair: kp) else { return }
damus_state.postbox.send(ev)
}
}
}
struct ContentView_Previews: PreviewProvider {
@@ -744,7 +767,6 @@ func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [Nos
func setup_notifications() {
UIApplication.shared.registerForRemoteNotifications()
let center = UNUserNotificationCenter.current()

View File

@@ -68,6 +68,8 @@
</dict>
<key>NSCameraUsageDescription</key>
<string>Damus needs access to your camera if you want to upload photos from it</string>
<key>NSAppleMusicUsageDescription</key>
<string>Damus needs access to your media library for playback statuses</string>
<key>NSMicrophoneUsageDescription</key>
<string>Damus needs access to your microphone if you want to upload recorded videos from it</string>
</dict>

View File

@@ -32,7 +32,8 @@ struct DamusState {
let wallet: WalletModel
let nav: NavigationCoordinator
let user_search_cache: UserSearchCache
let music: MusicController?
@discardableResult
func add_zap(zap: Zapping) -> Bool {
// store generic zap mapping
@@ -87,6 +88,8 @@ struct DamusState {
muted_threads: MutedThreadsManager(keypair: kp),
wallet: WalletModel(settings: UserSettingsStore()),
nav: NavigationCoordinator(),
user_search_cache: user_search_cache)
user_search_cache: user_search_cache,
music: nil
)
}
}

View File

@@ -190,9 +190,19 @@ class HomeModel {
handle_nwc_response(ev, relay: relay_id)
case .http_auth:
break
case .status:
handle_status_event(ev)
}
}
func handle_status_event(_ ev: NostrEvent) {
guard let st = UserStatus(ev: ev) else {
return
}
damus_state.profiles.profile_data(ev.pubkey).status.update_status(st)
}
func handle_nwc_response(_ ev: NostrEvent, relay: String) {
Task { @MainActor in
// TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time
@@ -502,7 +512,7 @@ class HomeModel {
func subscribe_to_home_filters(friends fs: [Pubkey]? = nil, relay_id: String? = nil) {
// TODO: separate likes?
var home_filter_kinds: [NostrKind] = [
.text, .longform, .boost
.text, .longform, .boost, .status
]
if !damus_state.settings.onlyzaps_mode {
home_filter_kinds.append(.like)
@@ -1401,7 +1411,7 @@ func process_zap_event(damus_state: DamusState, ev: NostrEvent, completion: @esc
}
DispatchQueue.main.async {
damus_state.profiles.zappers[ptag] = zapper
damus_state.profiles.profile_data(ptag).zapper = zapper
guard let zap = process_zap_event_with_zapper(damus_state: damus_state, ev: ev, zapper: zapper) else {
completion(.failed)
return

View File

@@ -24,4 +24,5 @@ enum NostrKind: UInt32, Codable {
case nwc_request = 23194
case nwc_response = 23195
case http_auth = 27235
case status = 30315
}

View File

@@ -7,6 +7,36 @@
import Foundation
class ValidationModel: ObservableObject {
@Published var validated: NIP05?
init() {
self.validated = nil
}
}
class ProfileDataModel: ObservableObject {
@Published var profile: TimestampedProfile?
init() {
self.profile = nil
}
}
class ProfileData {
var status: UserStatusModel
var profile_model: ProfileDataModel
var validation_model: ValidationModel
var zapper: Pubkey?
init() {
status = .init()
profile_model = .init()
validation_model = .init()
zapper = nil
}
}
class Profiles {
static let db_freshness_threshold: TimeInterval = 24 * 60 * 60
@@ -21,10 +51,9 @@ class Profiles {
qos: .userInteractive,
attributes: .concurrent)
private var profiles: [Pubkey: TimestampedProfile] = [:]
private var validated: [Pubkey: NIP05] = [:]
private var profiles: [Pubkey: ProfileData] = [:]
var nip05_pubkey: [String: Pubkey] = [:]
var zappers: [Pubkey: Pubkey] = [:]
private let database = ProfileDatabase()
@@ -36,36 +65,40 @@ class Profiles {
func is_validated(_ pk: Pubkey) -> NIP05? {
validated_queue.sync {
validated[pk]
self.profile_data(pk).validation_model.validated
}
}
func invalidate_nip05(_ pk: Pubkey) {
validated_queue.async(flags: .barrier) {
self.validated.removeValue(forKey: pk)
self.profile_data(pk).validation_model.validated = nil
}
}
func set_validated(_ pk: Pubkey, nip05: NIP05?) {
validated_queue.async(flags: .barrier) {
self.validated[pk] = nip05
self.profile_data(pk).validation_model.validated = nip05
}
}
func enumerated() -> EnumeratedSequence<[Pubkey: TimestampedProfile]> {
return profiles_queue.sync {
return profiles.enumerated()
func profile_data(_ pubkey: Pubkey) -> ProfileData {
guard let data = profiles[pubkey] else {
let data = ProfileData()
profiles[pubkey] = data
return data
}
return data
}
func lookup_zapper(pubkey: Pubkey) -> Pubkey? {
zappers[pubkey]
profile_data(pubkey).zapper
}
func add(id: Pubkey, profile: TimestampedProfile) {
profiles_queue.async(flags: .barrier) {
let old_timestamped_profile = self.profiles[id]
self.profiles[id] = profile
let old_timestamped_profile = self.profile_data(id).profile_model.profile
self.profile_data(id).profile_model.profile = profile
self.user_search_cache.updateProfile(id: id, profiles: self, oldProfile: old_timestamped_profile?.profile, newProfile: profile.profile)
}
@@ -81,21 +114,21 @@ class Profiles {
func lookup(id: Pubkey) -> Profile? {
var profile: Profile?
profiles_queue.sync {
profile = profiles[id]?.profile
profile = self.profile_data(id).profile_model.profile?.profile
}
return profile ?? database.get(id: id)
}
func lookup_with_timestamp(id: Pubkey) -> TimestampedProfile? {
profiles_queue.sync {
return profiles[id]
return self.profile_data(id).profile_model.profile
}
}
func has_fresh_profile(id: Pubkey) -> Bool {
var profile: Profile?
profiles_queue.sync {
profile = profiles[id]?.profile
profile = self.profile_data(id).profile_model.profile?.profile
}
if profile != nil {
return true
@@ -113,6 +146,6 @@ class Profiles {
func invalidate_zapper_cache(pubkey: Pubkey, profiles: Profiles, lnurl: LNUrls) {
profiles.zappers.removeValue(forKey: pubkey)
profiles.profile_data(pubkey).zapper = nil
lnurl.endpoints.removeValue(forKey: pubkey)
}

View File

@@ -34,7 +34,6 @@ struct EventTop: View {
Spacer()
EventMenuContext(damus: state, event: event)
}
.lineLimit(1)
}
}

View File

@@ -43,8 +43,11 @@ struct EventProfile: View {
ProfilePicView(pubkey: pubkey, size: pfp_size, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation)
}
}
EventProfileName(pubkey: pubkey, profile: profile, damus: damus_state, size: size)
VStack(alignment: .leading) {
EventProfileName(pubkey: pubkey, profile: profile, damus: damus_state, size: size)
UserStatusView(status: damus_state.profiles.profile_data(pubkey).status)
}
}
}
}

View File

@@ -93,8 +93,9 @@ struct EventShell<Content: View>: View {
HStack(spacing: 10) {
Pfp(is_anon: is_anon)
VStack {
VStack(alignment: .leading, spacing: 2) {
EventTop(state: state, event: event, pubkey: pubkey, is_anon: is_anon)
UserStatusView(status: state.profiles.profile_data(pubkey).status)
ReplyPart(events: state.events, event: event, privkey: state.keypair.privkey, profiles: state.profiles)
}
}

View File

@@ -49,7 +49,7 @@ struct SelectedEventView: View {
.padding(.horizontal)
.minimumScaleFactor(0.75)
.lineLimit(1)
if event_is_reply(event.event_refs(damus.keypair.privkey)) {
ReplyDescription(event: event, replying_to: replying_to, profiles: damus.profiles)
.padding(.horizontal)

View File

@@ -55,7 +55,7 @@ struct EventProfileName: View {
return donation
}
var body: some View {
HStack(spacing: 2) {
switch current_display_name {

View File

@@ -0,0 +1,20 @@
//
// ProfilePopup.swift
// damus
//
// Created by William Casarin on 2023-08-21.
//
import SwiftUI
struct ProfilePopup: View {
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
}
}
struct ProfilePopup_Previews: PreviewProvider {
static var previews: some View {
ProfilePopup()
}
}

View File

@@ -83,23 +83,37 @@ struct SideMenuView: View {
var TopProfile: some View {
let profile = damus_state.profiles.lookup(id: damus_state.pubkey)
return HStack {
ProfilePicView(pubkey: damus_state.pubkey, size: 60, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
return VStack(alignment: .leading, spacing: verticalSpacing) {
HStack {
ProfilePicView(pubkey: damus_state.pubkey, size: 60, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
VStack(alignment: .leading) {
if let display_name = profile?.display_name {
Text(display_name)
.foregroundColor(textColor())
.font(.title)
.lineLimit(1)
}
if let name = profile?.name {
Text("@" + name)
.foregroundColor(DamusColors.mediumGrey)
.font(.body)
.lineLimit(1)
VStack(alignment: .leading) {
if let display_name = profile?.display_name {
Text(display_name)
.foregroundColor(textColor())
.font(.title)
.lineLimit(1)
}
if let name = profile?.name {
Text("@" + name)
.foregroundColor(DamusColors.mediumGrey)
.font(.body)
.lineLimit(1)
}
}
}
navLabel(title: NSLocalizedString("Set Status", comment: "Sidebar menu label to set user status"), img: "add-reaction")
.font(.title2)
.foregroundColor(textColor())
.frame(maxWidth: .infinity, alignment: .leading)
.dynamicTypeSize(.xSmall)
.onTapGesture {
present_sheet(.user_status)
}
UserStatusView(status: damus_state.profiles.profile_data(damus_state.pubkey).status)
.dynamicTypeSize(.xSmall)
}
}
@@ -190,17 +204,17 @@ struct SideMenuView: View {
}
}
@ViewBuilder
func navLabel(title: String, img: String) -> some View {
Image(img)
.tint(DamusColors.adaptableBlack)
Text(title)
.font(.title2)
.foregroundColor(textColor())
.frame(maxWidth: .infinity, alignment: .leading)
.dynamicTypeSize(.xSmall)
HStack {
Image(img)
.tint(DamusColors.adaptableBlack)
Text(title)
.font(.title2)
.foregroundColor(textColor())
.frame(maxWidth: .infinity, alignment: .leading)
.dynamicTypeSize(.xSmall)
}
}
struct SideMenuLabelStyle: LabelStyle {