damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit 0338297bfe33066137ee3ba2512d0a9c628138d5
parent 59cf8056bd1db41da05abefcc93dd19f5df0b40f
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 21 Aug 2023 22:12:01 -0700

Live Music & Generic Statuses

Changelog-Added: Added live music statuses
Changelog-Added: Added generic user statuses

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 32++++++++++++++++++++++++++++++++
Adamus/Components/Status/Music/MusicController.swift | 48++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Components/Status/UserStatus.swift | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Components/Status/UserStatusSheet.swift | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Components/Status/UserStatusView.swift | 37+++++++++++++++++++++++++++++++++++++
Mdamus/ContentView.swift | 28+++++++++++++++++++++++++---
Mdamus/Info.plist | 2++
Mdamus/Models/DamusState.swift | 7+++++--
Mdamus/Models/HomeModel.swift | 16+++++++++++++---
Mdamus/Nostr/NostrKind.swift | 1+
Mdamus/Nostr/Profiles.swift | 67++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mdamus/Views/Events/Components/EventTop.swift | 1-
Mdamus/Views/Events/EventProfile.swift | 7+++++--
Mdamus/Views/Events/EventShell.swift | 3++-
Mdamus/Views/Events/SelectedEventView.swift | 2+-
Mdamus/Views/Profile/EventProfileName.swift | 2+-
Adamus/Views/Profile/ProfilePopup.swift | 20++++++++++++++++++++
Mdamus/Views/SideMenuView.swift | 64+++++++++++++++++++++++++++++++++++++++-------------------------
18 files changed, 538 insertions(+), 56 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -163,11 +163,14 @@ 4C5D5C992A6AF8F80024563C /* NdbNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C90548A2A6AEDEE00811EEC /* NdbNote.swift */; }; 4C5D5C9A2A6AF8F80024563C /* NdbTagIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C9054882A6AED4700811EEC /* NdbTagIterator.swift */; }; 4C5D5C9D2A6B2CB40024563C /* AsciiCharacter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5D5C9C2A6B2CB40024563C /* AsciiCharacter.swift */; }; + 4C5E54032A9522F600FF6E60 /* UserStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5E54022A9522F600FF6E60 /* UserStatus.swift */; }; + 4C5E54062A9671F800FF6E60 /* UserStatusSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5E54052A9671F800FF6E60 /* UserStatusSheet.swift */; }; 4C5F9114283D694D0052CD1C /* FollowTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5F9113283D694D0052CD1C /* FollowTarget.swift */; }; 4C5F9116283D855D0052CD1C /* EventsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5F9115283D855D0052CD1C /* EventsModel.swift */; }; 4C5F9118283D88E40052CD1C /* FollowingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5F9117283D88E40052CD1C /* FollowingModel.swift */; }; 4C633350283D40E500B1C9C3 /* HomeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C63334F283D40E500B1C9C3 /* HomeModel.swift */; }; 4C633352283D419F00B1C9C3 /* SignalModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C633351283D419F00B1C9C3 /* SignalModel.swift */; }; + 4C64305C2A945AFF00B0C0E9 /* MusicController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C64305B2A945AFF00B0C0E9 /* MusicController.swift */; }; 4C64987C286D03E000EAE2B3 /* DirectMessagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C64987B286D03E000EAE2B3 /* DirectMessagesView.swift */; }; 4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C64987D286D082C00EAE2B3 /* DirectMessagesModel.swift */; }; 4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 4C649880286E0EE300EAE2B3 /* secp256k1 */; }; @@ -339,6 +342,7 @@ 4CF0ABEE29844B5500D66079 /* AnyEncodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF0ABED29844B5500D66079 /* AnyEncodable.swift */; }; 4CF0ABF029857E9200D66079 /* Bech32Object.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF0ABEF29857E9200D66079 /* Bech32Object.swift */; }; 4CF0ABF62985CD5500D66079 /* UserSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF0ABF52985CD5500D66079 /* UserSearch.swift */; }; + 4CF38C882A9442DC00BE01B6 /* UserStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF38C872A9442DC00BE01B6 /* UserStatusView.swift */; }; 4CFD502F2A2DA45800A229DB /* MediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFD502E2A2DA45800A229DB /* MediaView.swift */; }; 4CFF8F6329CC9AD7008DB934 /* ImageContextMenuModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6229CC9AD7008DB934 /* ImageContextMenuModifier.swift */; }; 4CFF8F6729CC9E3A008DB934 /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CFF8F6629CC9E3A008DB934 /* ImageView.swift */; }; @@ -697,11 +701,14 @@ 4C5C7E67284ED36500A22DF5 /* SearchHomeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHomeModel.swift; sourceTree = "<group>"; }; 4C5C7E69284EDE2E00A22DF5 /* SearchResultsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = "<group>"; }; 4C5D5C9C2A6B2CB40024563C /* AsciiCharacter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsciiCharacter.swift; sourceTree = "<group>"; }; + 4C5E54022A9522F600FF6E60 /* UserStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserStatus.swift; sourceTree = "<group>"; }; + 4C5E54052A9671F800FF6E60 /* UserStatusSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserStatusSheet.swift; sourceTree = "<group>"; }; 4C5F9113283D694D0052CD1C /* FollowTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowTarget.swift; sourceTree = "<group>"; }; 4C5F9115283D855D0052CD1C /* EventsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsModel.swift; sourceTree = "<group>"; }; 4C5F9117283D88E40052CD1C /* FollowingModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingModel.swift; sourceTree = "<group>"; }; 4C63334F283D40E500B1C9C3 /* HomeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeModel.swift; sourceTree = "<group>"; }; 4C633351283D419F00B1C9C3 /* SignalModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalModel.swift; sourceTree = "<group>"; }; + 4C64305B2A945AFF00B0C0E9 /* MusicController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicController.swift; sourceTree = "<group>"; }; 4C64987B286D03E000EAE2B3 /* DirectMessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessagesView.swift; sourceTree = "<group>"; }; 4C64987D286D082C00EAE2B3 /* DirectMessagesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessagesModel.swift; sourceTree = "<group>"; }; 4C684A542A7E91FE005E6031 /* LongPostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongPostTests.swift; sourceTree = "<group>"; }; @@ -894,6 +901,7 @@ 4CF0ABED29844B5500D66079 /* AnyEncodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyEncodable.swift; sourceTree = "<group>"; }; 4CF0ABEF29857E9200D66079 /* Bech32Object.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bech32Object.swift; sourceTree = "<group>"; }; 4CF0ABF52985CD5500D66079 /* UserSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSearch.swift; sourceTree = "<group>"; }; + 4CF38C872A9442DC00BE01B6 /* UserStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserStatusView.swift; sourceTree = "<group>"; }; 4CFD502E2A2DA45800A229DB /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = "<group>"; }; 4CFF8F6229CC9AD7008DB934 /* ImageContextMenuModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageContextMenuModifier.swift; sourceTree = "<group>"; }; 4CFF8F6629CC9E3A008DB934 /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = "<group>"; }; @@ -1231,6 +1239,25 @@ path = Notifications; sourceTree = "<group>"; }; + 4C5E54042A95232A00FF6E60 /* Status */ = { + isa = PBXGroup; + children = ( + 4C64305A2A945AF200B0C0E9 /* Music */, + 4CF38C872A9442DC00BE01B6 /* UserStatusView.swift */, + 4C5E54022A9522F600FF6E60 /* UserStatus.swift */, + 4C5E54052A9671F800FF6E60 /* UserStatusSheet.swift */, + ); + path = Status; + sourceTree = "<group>"; + }; + 4C64305A2A945AF200B0C0E9 /* Music */ = { + isa = PBXGroup; + children = ( + 4C64305B2A945AFF00B0C0E9 /* MusicController.swift */, + ); + path = Music; + sourceTree = "<group>"; + }; 4C687C2A2A6058450092C550 /* Search */ = { isa = PBXGroup; children = ( @@ -1635,6 +1662,7 @@ 4CE4F9DF285287A000C00DD9 /* Components */ = { isa = PBXGroup; children = ( + 4C5E54042A95232A00FF6E60 /* Status */, 4C687C2A2A6058450092C550 /* Search */, 4C7D09702A0AEF4C00943473 /* Gradients */, 31D2E846295218AF006D67F8 /* Shimmer.swift */, @@ -2293,6 +2321,7 @@ 3A23838E2A297DD200E5AA2E /* ZapButtonModel.swift in Sources */, F71694F82A6983AF001F4053 /* GrayGradient.swift in Sources */, 4C1D4FB12A7958E60024F453 /* VersionInfo.swift in Sources */, + 4C64305C2A945AFF00B0C0E9 /* MusicController.swift in Sources */, 5053ACA72A56DF3B00851AE3 /* DeveloperSettingsView.swift in Sources */, F79C7FAD29D5E9620000F946 /* EditPictureControl.swift in Sources */, 4C9F18E229AA9B6C008C55EC /* CustomizeZapView.swift in Sources */, @@ -2307,6 +2336,7 @@ 4CE1399229F0666100AC6A0B /* ShareActionButton.swift in Sources */, 4C42812C298C848200DBF26F /* TranslateView.swift in Sources */, 4C363A9C282838B9006E126D /* EventRef.swift in Sources */, + 4C5E54032A9522F600FF6E60 /* UserStatus.swift in Sources */, 4C7D095F2A098C5D00943473 /* ConnectWalletView.swift in Sources */, 3AA24802297E3DC20090C62D /* RepostView.swift in Sources */, 5C6E1DAF2A194075008FC15A /* PinkGradient.swift in Sources */, @@ -2368,6 +2398,7 @@ 4C3EA66028FF5E7700C48A62 /* node_id.c in Sources */, 4C687C212A5F7ED00092C550 /* DamusBackground.swift in Sources */, 4CA352A02A76AE80003BB08B /* Notify.swift in Sources */, + 4CF38C882A9442DC00BE01B6 /* UserStatusView.swift in Sources */, 4CE6DEE727F7A08100C66700 /* damusApp.swift in Sources */, 4C1253582A76C9060004F4B8 /* PresentSheetNotify.swift in Sources */, 4C363A962827096D006E126D /* PostBlock.swift in Sources */, @@ -2376,6 +2407,7 @@ 4CEE2AED2805B22500AB5EEF /* NostrRequest.swift in Sources */, 4C06670E28FDEAA000038D2A /* utf8.c in Sources */, 4C3EA66D28FF782800C48A62 /* amount.c in Sources */, + 4C5E54062A9671F800FF6E60 /* UserStatusSheet.swift in Sources */, F71694F42A6732B7001F4053 /* GradientFollowButton.swift in Sources */, 4C3AC7A728369BA200E1F516 /* SearchHomeView.swift in Sources */, 4CB883B0297705DD00DC99E7 /* ZapButton.swift in Sources */, diff --git a/damus/Components/Status/Music/MusicController.swift b/damus/Components/Status/Music/MusicController.swift @@ -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)) + } +} diff --git a/damus/Components/Status/UserStatus.swift b/damus/Components/Status/UserStatus.swift @@ -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 +} + diff --git a/damus/Components/Status/UserStatusSheet.swift b/damus/Components/Status/UserStatusSheet.swift @@ -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()) + } +} diff --git a/damus/Components/Status/UserStatusView.swift b/damus/Components/Status/UserStatusView.swift @@ -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()) + } +} diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -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() diff --git a/damus/Info.plist b/damus/Info.plist @@ -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> diff --git a/damus/Models/DamusState.swift b/damus/Models/DamusState.swift @@ -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 + ) } } diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -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 diff --git a/damus/Nostr/NostrKind.swift b/damus/Nostr/NostrKind.swift @@ -24,4 +24,5 @@ enum NostrKind: UInt32, Codable { case nwc_request = 23194 case nwc_response = 23195 case http_auth = 27235 + case status = 30315 } diff --git a/damus/Nostr/Profiles.swift b/damus/Nostr/Profiles.swift @@ -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) } diff --git a/damus/Views/Events/Components/EventTop.swift b/damus/Views/Events/Components/EventTop.swift @@ -34,7 +34,6 @@ struct EventTop: View { Spacer() EventMenuContext(damus: state, event: event) } - .lineLimit(1) } } diff --git a/damus/Views/Events/EventProfile.swift b/damus/Views/Events/EventProfile.swift @@ -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) + } } } } diff --git a/damus/Views/Events/EventShell.swift b/damus/Views/Events/EventShell.swift @@ -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) } } diff --git a/damus/Views/Events/SelectedEventView.swift b/damus/Views/Events/SelectedEventView.swift @@ -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) diff --git a/damus/Views/Profile/EventProfileName.swift b/damus/Views/Profile/EventProfileName.swift @@ -55,7 +55,7 @@ struct EventProfileName: View { return donation } - + var body: some View { HStack(spacing: 2) { switch current_display_name { diff --git a/damus/Views/Profile/ProfilePopup.swift b/damus/Views/Profile/ProfilePopup.swift @@ -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() + } +} diff --git a/damus/Views/SideMenuView.swift b/damus/Views/SideMenuView.swift @@ -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) - - 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) + 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) + } } } + + 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 {