add basic ios implementation

This commit is contained in:
2026-08-04 08:24:40 +07:00
parent 7009dbf692
commit e3fec77359
34 changed files with 2671 additions and 22 deletions
+1
View File
@@ -22,6 +22,7 @@ kotlin {
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
binaryOptions["objcExportSuspendFunctionLaunchThreadRestriction"] = "none"
}
}
@@ -0,0 +1,263 @@
package su.reya.coop
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import rust.nostr.sdk.EventId
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.NostrManager
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.AccountState
import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.MediaRepository
import su.reya.coop.repository.SettingsRepository
import su.reya.coop.viewmodel.ProfileCache
import kotlin.coroutines.resume
class FlowSubscription(private val job: Job) {
fun cancel() {
job.cancel()
}
}
data class RelayLists(
val messaging: List<RelayUrl>,
val inbox: List<RelayUrl>,
val outbox: List<RelayUrl>,
)
class IosBootstrap private constructor(
val scope: CoroutineScope,
val nostr: Nostr,
val settingsRepository: SettingsRepository,
val accountRepository: AccountRepository,
val chatRepository: ChatRepository,
val profileCache: ProfileCache,
) {
companion object {
fun create(storage: AppStorage): IosBootstrap {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val nostr = NostrManager.instance
val settingsRepository = SettingsRepository(storage, scope)
val mediaRepository = MediaRepository(settingsRepository)
val accountRepository = AccountRepository(
nostr = nostr,
storage = storage,
mediaRepository = mediaRepository,
settingsRepository = settingsRepository,
scope = scope,
externalSignerHandler = null,
)
val chatRepository = ChatRepository(nostr, mediaRepository, settingsRepository, scope)
val profileCache = ProfileCache(nostr)
return IosBootstrap(
scope = scope,
nostr = nostr,
settingsRepository = settingsRepository,
accountRepository = accountRepository,
chatRepository = chatRepository,
profileCache = profileCache,
)
}
}
private var notificationsJob: Job? = null
fun start(dbPath: String, onNewMessage: (UnsignedEvent) -> Unit) {
if (notificationsJob?.isActive == true) return
notificationsJob = scope.launch {
runCatching {
nostr.init(dbPath)
nostr.connectBootstrapRelays()
nostr.handleNotifications(
onMetadataUpdate = { pubkey, metadata ->
scope.launch { nostr.profiles.emitMetadataUpdate(pubkey, metadata) }
},
onContactListUpdate = { contacts ->
scope.launch { nostr.profiles.emitContactListUpdate(contacts) }
},
onNewMessage = { event ->
nostr.emitNewEvent(event)
onNewMessage(event)
},
)
}.onFailure {
accountRepository.showError("Failed to start Nostr: ${it.message}")
}
}
}
private fun <T> Flow<T>.watch(onEach: (T) -> Unit): FlowSubscription {
val job = scope.launch(Dispatchers.Main) { collect { onEach(it) } }
return FlowSubscription(job)
}
fun watchAccountState(onEach: (AccountState) -> Unit): FlowSubscription =
accountRepository.state.watch(onEach)
fun watchIsUpdatingProfile(onEach: (Boolean) -> Unit): FlowSubscription =
accountRepository.isUpdatingProfile.watch(onEach)
fun watchCurrentUserProfile(onEach: (Profile?) -> Unit): FlowSubscription =
accountRepository.currentUserProfile.watch(onEach)
fun watchChatRooms(onEach: (List<Room>) -> Unit): FlowSubscription =
chatRepository.chatRooms.watch(onEach)
fun watchIsSyncing(onEach: (Boolean) -> Unit): FlowSubscription =
chatRepository.isSyncing.watch(onEach)
fun watchPartialProcessed(onEach: (Boolean) -> Unit): FlowSubscription =
chatRepository.isPartialProcessedGiftWrap.watch(onEach)
fun watchSettings(onEach: (Settings) -> Unit): FlowSubscription =
settingsRepository.settings.watch(onEach)
fun watchNewEvents(onEach: (UnsignedEvent) -> Unit): FlowSubscription =
chatRepository.newEvents.watch(onEach)
fun watchErrors(onEach: (String) -> Unit): FlowSubscription =
merge(
accountRepository.errorEvents,
chatRepository.errorEvents,
profileCache.errorEvents,
).watch(onEach)
fun watchProfile(pubkey: PublicKey, onEach: (Profile?) -> Unit): FlowSubscription =
profileCache.getMetadata(pubkey).watch(onEach)
fun watchRoomUi(
room: Room,
currentUser: PublicKey?,
onEach: (RoomUiState) -> Unit
): FlowSubscription =
room.uiStateFlow(profileCache, currentUser).watch(onEach)
@Throws(IllegalArgumentException::class)
fun createChatRoom(recipients: List<PublicKey>): Long =
chatRepository.createChatRoom(recipients)
fun getChatRoom(id: Long): Room? = chatRepository.getChatRoom(id)
fun markRoomRead(id: Long) = chatRepository.markAsRead(id)
fun refreshChatRooms() = chatRepository.refreshChatRooms()
fun connectRoom(id: Long) = chatRepository.chatRoomConnect(id)
fun sendTextMessage(roomId: Long, text: String) =
chatRepository.sendMessage(roomId, text, emptyList())
fun sendReplyMessage(roomId: Long, text: String, replyTo: EventId) =
chatRepository.sendMessage(roomId, text, listOf(replyTo))
fun sendImageMessage(roomId: Long, file: ByteArray, contentType: String) =
chatRepository.sendFileMessage(roomId, file, contentType, emptyList())
suspend fun loadRoomMessages(roomId: Long): List<UnsignedEvent> =
suspendCancellableCoroutine { cont ->
chatRepository.loadChatRoomMessages(roomId) { cont.resume(it) }
}
fun importIdentity(secret: String, password: String?) =
accountRepository.importIdentity(secret, password)
fun createIdentity(name: String, bio: String?, picture: ByteArray?, contentType: String?) =
accountRepository.createIdentity(name, bio, picture, contentType)
fun updateProfile(name: String?, bio: String?, picture: ByteArray?, contentType: String?) =
accountRepository.updateProfile(name, bio, picture, contentType)
fun logout() = accountRepository.logout {}
fun dismissNotificationBanner() = accountRepository.dismissNotificationBanner()
fun addContact(address: String) = accountRepository.addContact(address)
fun removeContact(publicKey: PublicKey) = accountRepository.removeContact(publicKey)
suspend fun searchByAddress(query: String): PublicKey? =
suspendCancellableCoroutine { cont ->
accountRepository.searchByAddress(query) { cont.resume(it) }
}
suspend fun searchByNostr(query: String): List<PublicKey> =
suspendCancellableCoroutine { cont ->
accountRepository.searchByNostr(query) { cont.resume(it) }
}
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? =
suspendCancellableCoroutine { cont ->
accountRepository.verifyActivity(pubkey) { cont.resume(it) }
}
suspend fun verifyContact(pubkey: PublicKey): Boolean =
suspendCancellableCoroutine { cont ->
accountRepository.verifyContact(pubkey) { cont.resume(it) }
}
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> =
suspendCancellableCoroutine { cont ->
accountRepository.mutualContacts(pubkey) { cont.resume(it) }
}
fun refetchMsgRelays() = accountRepository.refetchMsgRelays()
fun useDefaultMsgRelayList() = accountRepository.useDefaultMsgRelayList()
fun loadRelayLists() {
accountRepository.loadCurrentUserRelayList()
accountRepository.loadCurrentUserMsgRelayList()
}
fun watchRelayLists(onEach: (RelayLists) -> Unit): FlowSubscription =
accountRepository.state.map { state ->
RelayLists(
messaging = state.userMsgRelayList,
inbox = state.userRelayList
.filter { it.value == RelayMetadata.READ || it.value == null }
.keys.toList(),
outbox = state.userRelayList
.filter { it.value == RelayMetadata.WRITE || it.value == null }
.keys.toList(),
)
}.watch(onEach)
fun addMsgRelay(relay: String) = accountRepository.addMsgRelay(relay)
fun removeMsgRelay(relay: String) = accountRepository.removeMsgRelay(relay)
fun addInboxRelay(relay: String) = accountRepository.addInboxRelay(relay)
fun addOutboxRelay(relay: String) = accountRepository.addOutboxRelay(relay)
fun removeRelay(relay: String) = accountRepository.removeRelay(relay)
fun setTheme(theme: Theme) = settingsRepository.update { it.copy(theme = theme) }
fun setMediaConfig(media: MediaConfig) = settingsRepository.update { it.copy(media = media) }
fun setScreening(enabled: Boolean) = settingsRepository.update { it.copy(screening = enabled) }
fun setBlossomServer(url: String?) = settingsRepository.update { it.copy(blossomServer = url) }
fun parsePublicKey(input: String): PublicKey? =
runCatching { PublicKey.parse(input.trim()) }.getOrNull()
fun currentPublicKey(): PublicKey? = nostr.signer.publicKeyFlow.value
fun resetState() {
accountRepository.resetInternalState()
chatRepository.resetInternalState()
}
}