This commit is contained in:
2026-08-06 16:42:40 +07:00
parent e3fec77359
commit 7eb4dd8e81
8 changed files with 340 additions and 199 deletions
+15 -2
View File
@@ -1,10 +1,11 @@
import Foundation
import Shared
import UIKit
@MainActor
@Observable
final class AppState {
let bootstrap: IosBootstrap
let bootstrap: Bootstrap
private var subscriptions: [FlowSubscription] = []
@@ -22,7 +23,7 @@ final class AppState {
let networkMonitor = NetworkMonitor()
init() {
bootstrap = IosBootstrap.companion.create(storage: IosAppStorage())
bootstrap = Bootstrap.companion.create(storage: KeychainStorage())
}
func start() {
@@ -84,6 +85,18 @@ final class AppState {
path = []
}
func resume() {
Task { try? await bootstrap.resume() }
}
func pause() {
let taskId = UIApplication.shared.beginBackgroundTask()
Task {
try? await bootstrap.pause()
UIApplication.shared.endBackgroundTask(taskId)
}
}
private func handleNewMessage(_ event: Nostr_sdk_kmpUnsignedEvent) {
NotificationService.shared.notifyNewMessage(roomId: event.roomId(), content: event.content())
}
@@ -2,7 +2,7 @@ import Foundation
import Security
import Shared
final class IosAppStorage: AppStorage {
final class KeychainStorage: AppStorage {
private let defaults = UserDefaults.standard
private let service = "su.reya.coop"
+103 -51
View File
@@ -12,12 +12,16 @@ struct ChatView: View {
@State private var viewModel: ChatViewModel?
@State private var input = ""
@State private var photoItem: PhotosPickerItem?
@State private var authorNames: [String: String] = [:]
@State private var authorProfiles: [String: Profile] = [:]
private var showScreener: Bool {
(viewModel?.requireScreening ?? false) && appState.settings?.screening == true
}
private var isGroup: Bool {
viewModel?.room?.isGroup() == true
}
var body: some View {
VStack(spacing: 0) {
messageList
@@ -32,7 +36,6 @@ struct ChatView: View {
inputBar
}
}
.navigationTitle(viewModel?.roomUi?.name ?? "Chat")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
@@ -44,7 +47,7 @@ struct ChatView: View {
AvatarView(
name: viewModel?.roomUi?.name ?? "?",
picture: viewModel?.roomUi?.picture,
size: 28
size: 30
)
Text(viewModel?.roomUi?.name ?? "Chat")
.font(.caption)
@@ -75,33 +78,31 @@ struct ChatView: View {
private var messageList: some View {
ScrollView {
LazyVStack(spacing: 8) {
LazyVStack(spacing: 2) {
ForEach(groupedMessages, id: \.0) { group, messages in
Text(group)
.font(.caption)
Text(headerTitle(group: group, first: messages.first))
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 14)
ForEach(messages, id: \.stableId) { event in
let isMine = event.author().toHex() == appState.bootstrap.currentPublicKey()?.toHex()
let replyId = event.tags().eventIds().first
let replied = replyId.flatMap { id in
viewModel?.messages.first { $0.id()?.toHex() == id.toHex() }
}
MessageBubble(
event: event,
isMine: isMine,
showImages: showImages,
repliedMessage: replied,
repliedAuthorName: replied.flatMap { authorName(for: $0) },
onReply: { viewModel?.replyingTo = event }
)
ForEach(Array(messages.enumerated()), id: \.element.stableId) { index, event in
messageCell(event: event, at: index, in: messages)
}
}
if let last = viewModel?.messages.last, isMine(last) {
Text("Delivered")
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.top, 2)
}
}
.padding(.horizontal)
.padding(.horizontal, 12)
}
.defaultScrollAnchor(.bottom)
.scrollDismissesKeyboard(.interactively)
.overlay {
if viewModel?.loading == true {
ProgressView()
@@ -109,8 +110,42 @@ struct ChatView: View {
}
}
@ViewBuilder
private func messageCell(
event: Nostr_sdk_kmpUnsignedEvent,
at index: Int,
in messages: [Nostr_sdk_kmpUnsignedEvent]
) -> some View {
let mine = isMine(event)
let authorHex = event.author().toHex()
let nextIsSameAuthor = index + 1 < messages.count &&
messages[index + 1].author().toHex() == authorHex
let prevIsSameAuthor = index > 0 &&
messages[index - 1].author().toHex() == authorHex
let replyId = event.tags().eventIds().first
let replied = replyId.flatMap { id in
viewModel?.messages.first { $0.id()?.toHex() == id.toHex() }
}
MessageBubble(
event: event,
isMine: mine,
showImages: showImages,
isFirstOfRun: !prevIsSameAuthor,
isLastOfRun: !nextIsSameAuthor,
showAuthorName: isGroup && !mine && !prevIsSameAuthor,
showAuthorAvatar: isGroup && !mine,
authorName: authorName(for: event),
authorPicture: authorProfiles[authorHex]?.picture,
repliedMessage: replied,
repliedAuthorName: replied.flatMap { authorName(for: $0) },
onReply: { viewModel?.replyingTo = event }
)
.padding(.bottom, nextIsSameAuthor ? 0 : 8)
}
private var inputBar: some View {
VStack(spacing: 8) {
VStack(spacing: 0) {
if let replyingTo = viewModel?.replyingTo {
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 2)
@@ -132,37 +167,47 @@ struct ChatView: View {
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
.padding(.top, 8)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.bar)
}
HStack(spacing: 12) {
HStack(alignment: .bottom, spacing: 10) {
PhotosPicker(selection: $photoItem, matching: .images) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(.tint)
Image(systemName: "plus")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(.secondary)
.frame(width: 34, height: 34)
.background(Color(.secondarySystemFill), in: Circle())
}
TextField("Message", text: $input, axis: .vertical)
.lineLimit(1...5)
.padding(.horizontal, 12)
.lineLimit(1...6)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Color(.secondarySystemBackground), in: Capsule())
.background {
Capsule()
.strokeBorder(Color(.systemGray4), lineWidth: 1)
}
Button {
viewModel?.send(input, appState: appState)
input = ""
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
.foregroundStyle(input.isEmpty ? Color(.systemGray3) : Color.accentColor)
if !input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
viewModel?.send(input, appState: appState)
input = ""
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 32))
.foregroundStyle(Color(.systemBlue))
}
.transition(.scale.combined(with: .opacity))
}
.disabled(input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding(.horizontal)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.bar)
.animation(.snappy(duration: 0.2), value: input.isEmpty)
}
.background(.bar)
}
private var groupedMessages: [(String, [Nostr_sdk_kmpUnsignedEvent])] {
@@ -182,6 +227,11 @@ struct ChatView: View {
return groups
}
private func headerTitle(group: String, first: Nostr_sdk_kmpUnsignedEvent?) -> String {
guard let first else { return group }
return "\(group) \(first.createdAt().formatAsTime())"
}
private var showImages: Bool {
guard let media = appState.settings?.media else { return true }
switch media {
@@ -194,6 +244,10 @@ struct ChatView: View {
}
}
private func isMine(_ event: Nostr_sdk_kmpUnsignedEvent) -> Bool {
event.author().toHex() == appState.bootstrap.currentPublicKey()?.toHex()
}
private func otherMember(of room: Room) -> Nostr_sdk_kmpPublicKey? {
let selfHex = appState.bootstrap.currentPublicKey()?.toHex()
return room.members.first { $0.toHex() != selfHex } ?? room.members.first
@@ -204,21 +258,19 @@ struct ChatView: View {
if hex == appState.bootstrap.currentPublicKey()?.toHex() {
return appState.currentUserProfile?.name ?? "You"
}
if let cached = authorNames[hex] {
return cached
if let profile = authorProfiles[hex] {
return profile.name
}
let short = event.author().short()
loadAuthorName(pubkey: event.author(), hex: hex)
return short
loadAuthorProfile(pubkey: event.author(), hex: hex)
return event.author().short()
}
private func loadAuthorName(pubkey: Nostr_sdk_kmpPublicKey, hex: String) {
guard authorNames[hex] == nil else { return }
authorNames[hex] = pubkey.short()
private func loadAuthorProfile(pubkey: Nostr_sdk_kmpPublicKey, hex: String) {
guard authorProfiles[hex] == nil else { return }
let sub = appState.bootstrap.watchProfile(pubkey: pubkey) { profile in
Task { @MainActor in
if let profile {
authorNames[hex] = profile.name
authorProfiles[hex] = profile
}
}
}
+106 -54
View File
@@ -5,6 +5,12 @@ struct MessageBubble: View {
let event: Nostr_sdk_kmpUnsignedEvent
let isMine: Bool
let showImages: Bool
let isFirstOfRun: Bool
let isLastOfRun: Bool
let showAuthorName: Bool
let showAuthorAvatar: Bool
let authorName: String?
let authorPicture: String?
let repliedMessage: Nostr_sdk_kmpUnsignedEvent?
let repliedAuthorName: String?
let onReply: () -> Void
@@ -24,26 +30,23 @@ struct MessageBubble: View {
}
var body: some View {
HStack {
if isMine { Spacer(minLength: 40) }
HStack(alignment: .bottom, spacing: 6) {
if isMine {
Spacer(minLength: 48)
} else {
avatarGutter
}
VStack(alignment: isMine ? .trailing : .leading, spacing: 2) {
if showAuthorName, let authorName {
Text(authorName)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 16)
}
VStack(alignment: isMine ? .trailing : .leading, spacing: 4) {
if let repliedMessage {
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.accentColor)
.frame(width: 3)
VStack(alignment: .leading, spacing: 1) {
Text(repliedAuthorName ?? "Unknown")
.font(.caption.bold())
Text(repliedMessage.content())
.font(.caption)
.lineLimit(2)
.foregroundStyle(.secondary)
}
}
.padding(8)
.background(.black.opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
replyPreview(repliedMessage)
}
ForEach(imageUrls, id: \.absoluteString) { url in
@@ -60,66 +63,115 @@ struct MessageBubble: View {
}
}
.frame(maxWidth: 260)
.clipShape(RoundedRectangle(cornerRadius: 12))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
if !text.isEmpty {
Text(text)
.padding(.horizontal, 12)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
isMine ? Color.accentColor : Color(.secondarySystemBackground),
in: BubbleShape(isMine: isMine)
)
.foregroundStyle(isMine ? .white : .primary)
.background(
isMine ? Color(.systemBlue) : Color(.systemGray5),
in: BubbleShape(isMine: isMine, tail: isLastOfRun)
)
}
if showTimestamp {
Text(event.createdAt().formatAsTime())
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeInOut(duration: 0.15)) {
showTimestamp.toggle()
}
}
.contextMenu {
Button {
UIPasteboard.general.string = event.content()
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
Button {
onReply()
} label: {
Label("Reply", systemImage: "arrowshape.turn.up.left")
.padding(.horizontal, 16)
}
}
if !isMine { Spacer(minLength: 40) }
if !isMine { Spacer(minLength: 48) }
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeInOut(duration: 0.15)) {
showTimestamp.toggle()
}
}
.contextMenu {
Button {
UIPasteboard.general.string = event.content()
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
Button {
onReply()
} label: {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
}
}
@ViewBuilder
private var avatarGutter: some View {
if isLastOfRun, showAuthorAvatar {
AvatarView(name: authorName ?? "?", picture: authorPicture, size: 26)
} else {
Color.clear.frame(width: 26, height: 26)
}
}
private func replyPreview(_ replied: Nostr_sdk_kmpUnsignedEvent) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(repliedAuthorName ?? "Unknown")
.font(.caption2.bold())
Text(replied.content())
.font(.caption)
.lineLimit(2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
isMine ? Color(.systemBlue).opacity(0.7) : Color(.systemGray4),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.foregroundStyle(isMine ? .white : .primary)
.padding(.horizontal, 4)
}
}
struct BubbleShape: Shape {
let isMine: Bool
let tail: Bool
func path(in rect: CGRect) -> Path {
let radius: CGFloat = 16
let tail: CGFloat = 4
let corners: UIRectCorner = isMine
? [.topLeft, .topRight, .bottomLeft]
: [.topLeft, .topRight, .bottomRight]
let radius: CGFloat = 18
let small: CGFloat = tail ? 4 : radius
let path = UIBezierPath(
roundedRect: rect,
byRoundingCorners: corners,
cornerRadii: CGSize(width: radius, height: radius)
let topLeft: CGFloat = radius
let topRight: CGFloat = radius
let bottomLeft: CGFloat = isMine ? radius : small
let bottomRight: CGFloat = isMine ? small : radius
var path = Path()
path.move(to: CGPoint(x: rect.minX + topLeft, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - topRight, y: rect.minY))
path.addArc(
center: CGPoint(x: rect.maxX - topRight, y: rect.minY + topRight),
radius: topRight, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false
)
_ = tail
return Path(path.cgPath)
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - bottomRight))
path.addArc(
center: CGPoint(x: rect.maxX - bottomRight, y: rect.maxY - bottomRight),
radius: bottomRight, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false
)
path.addLine(to: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY))
path.addArc(
center: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY - bottomLeft),
radius: bottomLeft, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false
)
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + topLeft))
path.addArc(
center: CGPoint(x: rect.minX + topLeft, y: rect.minY + topLeft),
radius: topLeft, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false
)
path.closeSubpath()
return path
}
}
+30 -63
View File
@@ -4,7 +4,6 @@ import Shared
struct HomeView: View {
@Environment(AppState.self) private var appState
@State private var showProfileSheet = false
@State private var showScanner = false
@State private var showRelayWarning = false
private var ongoingRooms: [Room] {
@@ -25,31 +24,32 @@ struct HomeView: View {
Button {
appState.path.append(.requestList)
} label: {
HStack(spacing: 12) {
HStack(spacing: 10) {
Circle()
.fill(requestUnread > 0 ? Color.accentColor : .clear)
.frame(width: 10, height: 10)
Image(systemName: "tray.full")
.font(.title2)
.foregroundStyle(.tint)
.frame(width: 44)
.foregroundStyle(.secondary)
.frame(width: 48, height: 48)
.background(Color(.secondarySystemFill), in: Circle())
VStack(alignment: .leading, spacing: 2) {
Text("New Requests")
.font(.headline)
Text("\(requestRooms.count) request\(requestRooms.count == 1 ? "" : "s")")
VStack(alignment: .leading, spacing: 3) {
Text("Message Requests")
.font(.body.weight(requestUnread > 0 ? .semibold : .regular))
Text("\(requestRooms.count)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
if requestUnread > 0 {
Text("\(requestUnread)")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentColor, in: Capsule())
}
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(Color(.systemGray3))
}
.padding(.vertical, 4)
}
.tint(.primary)
}
@@ -72,75 +72,42 @@ struct HomeView: View {
ProgressView()
} else if appState.chatRooms.isEmpty {
ContentUnavailableView(
"No chats yet",
"No Messages",
systemImage: "bubble.left.and.bubble.right",
description: Text("Start a new chat to begin messaging")
description: Text("Start a new conversation")
)
}
}
.navigationTitle("Coop")
.toolbar {
ToolbarItem(placement: .principal) {
HStack(spacing: 8) {
Text("Coop").font(.headline)
if appState.isSyncing {
ProgressView().controlSize(.small)
}
}
}
ToolbarItem(placement: .topBarLeading) {
Button {
showScanner = true
} label: {
Image(systemName: "qrcode.viewfinder")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showProfileSheet = true
} label: {
AvatarView(
name: appState.currentUserProfile?.name ?? "?",
picture: appState.currentUserProfile?.picture,
size: 32
size: 30
)
}
}
}
.safeAreaInset(edge: .bottom) {
Button {
appState.path.append(.newChat)
} label: {
Label("New Chat", systemImage: "square.and.pencil")
.font(.headline)
.padding(.horizontal, 20)
.padding(.vertical, 12)
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
if appState.isSyncing {
ProgressView().controlSize(.small)
}
Button {
appState.path.append(.newChat)
} label: {
Image(systemName: "square.and.pencil")
}
}
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.capsule)
.padding(.bottom, 8)
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.trailing)
}
.sheet(isPresented: $showProfileSheet) {
ProfileSheetView()
.presentationDetents([.medium, .large])
}
.sheet(isPresented: $showScanner) {
ScanView { result in
showScanner = false
if let pubkey = appState.bootstrap.parsePublicKey(input: result) {
do {
let roomId = try appState.bootstrap.createChatRoom(recipients: [pubkey])
appState.path.append(.chat(id: roomId, screening: false))
} catch {
appState.errorMessage = error.localizedDescription
}
} else {
appState.errorMessage = "Invalid public key"
}
}
}
.sheet(isPresented: $showRelayWarning) {
RelayWarningSheet()
}
+26 -23
View File
@@ -7,34 +7,37 @@ struct RoomRow: View {
@State private var ui: RoomUiState?
@State private var subscription: FlowSubscription?
private var unread: Bool { room.unreadCount > 0 }
var body: some View {
HStack(spacing: 12) {
AvatarView(name: ui?.name ?? "?", picture: ui?.picture)
HStack(spacing: 10) {
Circle()
.fill(unread ? Color.accentColor : .clear)
.frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 2) {
Text(ui?.name ?? "Loading...")
.font(.headline)
.lineLimit(1)
Text(room.lastMessage ?? "")
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
AvatarView(name: ui?.name ?? "?", picture: ui?.picture, size: 48)
Spacer()
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline) {
Text(ui?.name ?? "Loading...")
.font(.body.weight(unread ? .semibold : .regular))
.lineLimit(1)
VStack(alignment: .trailing, spacing: 4) {
Text(room.createdAt.ago())
.font(.caption)
.foregroundStyle(.secondary)
if room.unreadCount > 0 {
Text("\(room.unreadCount)")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentColor, in: Capsule())
Spacer()
Text(room.createdAt.ago())
.font(.subheadline)
.foregroundStyle(.secondary)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(Color(.systemGray3))
}
Text(room.lastMessage ?? "")
.font(.subheadline.weight(unread ? .semibold : .regular))
.foregroundStyle(unread ? .primary : .secondary)
.lineLimit(2)
}
}
.padding(.vertical, 4)
+11
View File
@@ -3,6 +3,7 @@ import SwiftUI
@main
struct iOSApp: App {
@State private var appState = AppState()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
@@ -18,6 +19,16 @@ struct iOSApp: App {
.onOpenURL { url in
appState.handle(url)
}
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active:
appState.resume()
case .background:
appState.pause()
default:
break
}
}
}
}
}
@@ -10,9 +10,13 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Filter
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.nostr.Nostr
@@ -37,7 +41,7 @@ data class RelayLists(
val outbox: List<RelayUrl>,
)
class IosBootstrap private constructor(
class Bootstrap private constructor(
val scope: CoroutineScope,
val nostr: Nostr,
val settingsRepository: SettingsRepository,
@@ -46,7 +50,7 @@ class IosBootstrap private constructor(
val profileCache: ProfileCache,
) {
companion object {
fun create(storage: AppStorage): IosBootstrap {
fun create(storage: AppStorage): Bootstrap {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val nostr = NostrManager.instance
val settingsRepository = SettingsRepository(storage, scope)
@@ -61,7 +65,7 @@ class IosBootstrap private constructor(
)
val chatRepository = ChatRepository(nostr, mediaRepository, settingsRepository, scope)
val profileCache = ProfileCache(nostr)
return IosBootstrap(
return Bootstrap(
scope = scope,
nostr = nostr,
settingsRepository = settingsRepository,
@@ -73,11 +77,23 @@ class IosBootstrap private constructor(
}
private var notificationsJob: Job? = null
private var dbPath: String? = null
private var onNewMessage: ((UnsignedEvent) -> Unit)? = null
fun start(dbPath: String, onNewMessage: (UnsignedEvent) -> Unit) {
this.dbPath = dbPath
this.onNewMessage = onNewMessage
startNotificationLoop()
}
private fun startNotificationLoop() {
if (notificationsJob?.isActive == true) return
val path = dbPath ?: return
val messageCallback = onNewMessage ?: return
notificationsJob = scope.launch {
runCatching {
nostr.init(dbPath)
nostr.init(path)
nostr.connectBootstrapRelays()
nostr.handleNotifications(
onMetadataUpdate = { pubkey, metadata ->
@@ -88,7 +104,7 @@ class IosBootstrap private constructor(
},
onNewMessage = { event ->
nostr.emitNewEvent(event)
onNewMessage(event)
messageCallback(event)
},
)
}.onFailure {
@@ -97,6 +113,33 @@ class IosBootstrap private constructor(
}
}
suspend fun resume() {
startNotificationLoop()
nostr.waitUntilInitialized()
nostr.client?.connect()
val pubkey = nostr.signer.publicKeyFlow.value ?: return
runCatching { nostr.profiles.getUserMetadata() }
val relays = nostr.relays.getMsgRelays(pubkey)
if (relays.isEmpty()) return
relays.forEach { relay ->
nostr.client?.addRelay(relay)
nostr.client?.connectRelay(relay)
}
nostr.messages.updateSyncState { it.copy(isSyncing = true) }
val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(pubkey)
val target = relays.associateWith { listOf(filter) }
nostr.client?.subscribe(target = ReqTarget.manual(target), id = "gift-wraps")
}
suspend fun pause() {
nostr.client?.disconnect()
}
private fun <T> Flow<T>.watch(onEach: (T) -> Unit): FlowSubscription {
val job = scope.launch(Dispatchers.Main) { collect { onEach(it) } }
return FlowSubscription(job)