chore: refactor the internal structure #33

Merged
reya merged 9 commits from refactor-core into master 2026-07-03 01:20:37 +00:00
19 changed files with 223 additions and 238 deletions
Showing only changes of commit 320a986212 - Show all commits

View File

@@ -21,7 +21,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import su.reya.coop.nostr.NostrManager import su.reya.coop.nostr.NostrManager
import su.reya.coop.nostr.roomId
import java.io.File import java.io.File
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES" private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"

View File

@@ -70,7 +70,6 @@ import coop.composeapp.generated.resources.ic_cancel
import coop.composeapp.generated.resources.ic_check_circle import coop.composeapp.generated.resources.ic_check_circle
import coop.composeapp.generated.resources.ic_send import coop.composeapp.generated.resources.ic_send
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
@@ -80,15 +79,14 @@ import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.nostr.Room import su.reya.coop.formatAsGroupHeader
import su.reya.coop.nostr.formatAsGroupHeader import su.reya.coop.humanReadable
import su.reya.coop.nostr.humanReadable import su.reya.coop.rememberUiState
import su.reya.coop.nostr.roomId import su.reya.coop.roomId
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.shared.nameFlow
import su.reya.coop.shared.pictureFlow
import su.reya.coop.short import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -101,6 +99,9 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
// Get current user
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
// Get chat room by ID // Get chat room by ID
val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle()
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } } val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
@@ -120,15 +121,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
return return
} }
val roomState by (room as Room).rememberUiState(nostrViewModel, currentUser?.publicKey)
val displayName by remember(room) {
room?.nameFlow(nostrViewModel) ?: flowOf("Loading...")
}.collectAsStateWithLifecycle("Loading...")
val picture by remember(room) {
room?.pictureFlow(nostrViewModel) ?: flowOf(null)
}.collectAsStateWithLifecycle(null)
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var newOtherMessages by remember { mutableIntStateOf(0) } var newOtherMessages by remember { mutableIntStateOf(0) }
@@ -212,14 +205,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
LoadingIndicator(modifier = Modifier.size(32.dp)) LoadingIndicator(modifier = Modifier.size(32.dp))
} else { } else {
Avatar( Avatar(
picture = picture, picture = roomState.picture,
description = displayName, description = roomState.name,
size = 32.dp, size = 32.dp,
) )
} }
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text( Text(
text = displayName, text = roomState.name,
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }
@@ -279,7 +272,10 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
items = messagesInGroup, items = messagesInGroup,
key = { it.id()?.toBech32() ?: it.hashCode() } key = { it.id()?.toBech32() ?: it.hashCode() }
) { ) {
ChatMessage(it) ChatMessage(
rumor = it,
isMine = currentUser?.publicKey == it.author()
)
} }
item { item {
DateSeparator(dateHeader) DateSeparator(dateHeader)
@@ -381,12 +377,8 @@ fun ScreenerCard(room: Room) {
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) } var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
var lastActivity by remember { mutableStateOf<Timestamp?>(null) } var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsStateWithLifecycle() val profile by profileFlow.collectAsStateWithLifecycle()
val profile = metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: "No name"
val picture = profile?.picture
LaunchedEffect(pubkey) { LaunchedEffect(pubkey) {
scope.launch { scope.launch {
@@ -412,7 +404,7 @@ fun ScreenerCard(room: Room) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Avatar( Avatar(
picture = picture, picture = profile?.picture,
description = "Profile picture", description = "Profile picture",
modifier = Modifier.size(120.dp), modifier = Modifier.size(120.dp),
shape = MaterialShapes.Cookie12Sided.toShape(), shape = MaterialShapes.Cookie12Sided.toShape(),
@@ -422,7 +414,7 @@ fun ScreenerCard(room: Room) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
text = displayName, text = profile?.name ?: "No name",
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy( style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily() fontFamily = getExpressiveFontFamily()
@@ -511,12 +503,9 @@ fun DateSeparator(date: String) {
@Composable @Composable
fun ChatMessage( fun ChatMessage(
rumor: UnsignedEvent rumor: UnsignedEvent,
isMine: Boolean = false,
) { ) {
val nostrViewModel = LocalNostrViewModel.current
val currentUser = nostrViewModel.nostr.signer.currentUser
val isMine = rumor.author() == currentUser
val bubbleShape = if (isMine) { val bubbleShape = if (isMine) {
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp) RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
} else { } else {
@@ -542,17 +531,7 @@ fun ChatMessage(
color = containerColor, color = containerColor,
contentColor = contentColor, contentColor = contentColor,
shape = bubbleShape, shape = bubbleShape,
modifier = Modifier modifier = Modifier.widthIn(max = 280.dp)
.widthIn(max = 280.dp)
.clickable(
onClick = {
val id = rumor.id()
if (id != null) {
val sent = nostrViewModel.isMessageSent(id)
println("Sent: $sent")
}
}
)
) { ) {
Text( Text(
text = rumor.content(), text = rumor.content(),

View File

@@ -284,12 +284,8 @@ fun ContactListItem(
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsState(initial = null) val profile by profileFlow.collectAsState(initial = null)
val profile = metadata?.asRecord()
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
val picture = profile?.picture
SegmentedListItem( SegmentedListItem(
onClick = onClick, onClick = onClick,
@@ -300,15 +296,15 @@ fun ContactListItem(
), ),
leadingContent = { leadingContent = {
Avatar( Avatar(
picture = picture, picture = profile?.picture,
description = displayName, description = profile?.name,
size = 36.dp size = 36.dp
) )
}, },
supportingContent = { Text(text = pubkey.short()) }, supportingContent = { Text(text = pubkey.short()) },
content = { content = {
Text( Text(
text = displayName, text = profile?.name ?: "No name",
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }

View File

@@ -87,7 +87,6 @@ import coop.composeapp.generated.resources.ic_new_chat
import coop.composeapp.generated.resources.ic_qr import coop.composeapp.generated.resources.ic_qr
import coop.composeapp.generated.resources.ic_request import coop.composeapp.generated.resources.ic_request
import coop.composeapp.generated.resources.ic_scanner import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
@@ -95,15 +94,13 @@ import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.RoomKind
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.nostr.Room import su.reya.coop.ago
import su.reya.coop.nostr.RoomKind import su.reya.coop.rememberUiState
import su.reya.coop.nostr.ago
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.shared.nameFlow
import su.reya.coop.shared.pictureFlow
import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -120,11 +117,9 @@ fun HomeScreen() {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
val currentUser = nostrViewModel.nostr.signer.currentUser ?: return val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
val currentUserProfile = nostrViewModel.getMetadata(currentUser)
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by nostrViewModel.chatRooms.collectAsStateWithLifecycle()
val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle() val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val isSyncing by nostrViewModel.isSyncing.collectAsStateWithLifecycle() val isSyncing by nostrViewModel.isSyncing.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by nostrViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() val isPartialProcessedGiftWrap by nostrViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
@@ -209,8 +204,8 @@ fun HomeScreen() {
// User // User
IconButton(onClick = { showBottomSheet = true }) { IconButton(onClick = { showBottomSheet = true }) {
Avatar( Avatar(
picture = userProfile?.asRecord()?.picture, picture = userProfile?.picture,
description = userProfile?.asRecord()?.displayName, description = userProfile?.name ?: "No name",
size = 32.dp, size = 32.dp,
) )
} }
@@ -391,14 +386,6 @@ fun HomeScreen() {
onDismissRequest = { showBottomSheet = false }, onDismissRequest = { showBottomSheet = false },
sheetState = sheetState, sheetState = sheetState,
) { ) {
val pubkey = nostrViewModel.nostr.signer.currentUser
val shortPubkey = pubkey?.short() ?: "Not available"
val userName =
userProfile?.asRecord()?.displayName
?: userProfile?.asRecord()?.name
?: "No name"
val dismissAndRun: (suspend () -> Unit) -> Unit = { action -> val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
scope.launch { scope.launch {
sheetState.hide() sheetState.hide()
@@ -423,8 +410,8 @@ fun HomeScreen() {
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Avatar( Avatar(
picture = userProfile?.asRecord()?.picture, picture = userProfile?.picture,
description = userProfile?.asRecord()?.displayName, description = userProfile?.name ?: "No name",
shape = MaterialShapes.Cookie9Sided.toShape(), shape = MaterialShapes.Cookie9Sided.toShape(),
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@@ -434,7 +421,7 @@ fun HomeScreen() {
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = userName, text = userProfile?.name ?: "No name",
style = MaterialTheme.typography.titleLargeEmphasized, style = MaterialTheme.typography.titleLargeEmphasized,
) )
} }
@@ -445,16 +432,15 @@ fun HomeScreen() {
OutlinedButton( OutlinedButton(
onClick = { onClick = {
scope.launch { scope.launch {
pubkey?.let { userProfile?.publicKey?.let {
val bech32 = it.toBech32() val bech32 = it.toBech32()
val data = val data = ClipData.newPlainText(bech32, bech32)
ClipData.newPlainText(bech32, bech32)
clipboardManager.setClipEntry(ClipEntry(data)) clipboardManager.setClipEntry(ClipEntry(data))
} }
} }
}, },
) { ) {
Text(text = shortPubkey) Text(text = userProfile?.shortPublicKey ?: "Unknown")
} }
FilledIconButton( FilledIconButton(
onClick = { onClick = {
@@ -585,7 +571,7 @@ fun HomeScreen() {
scope.launch { scope.launch {
isBusy = true isBusy = true
try { try {
nostrViewModel.refetchMsgRelays(currentUser) nostrViewModel.refetchMsgRelays()
} catch (e: Exception) { } catch (e: Exception) {
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}") snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
} }
@@ -634,28 +620,23 @@ fun NewRequests(requests: List<Room>) {
val firstRoom = requests.getOrNull(0) val firstRoom = requests.getOrNull(0)
val secondRoom = requests.getOrNull(1) val secondRoom = requests.getOrNull(1)
val firstName by remember(firstRoom) { val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel)
firstRoom?.nameFlow(nostrViewModel) ?: flowOf("") val secondRoomState by (secondRoom as Room).rememberUiState(nostrViewModel)
}.collectAsStateWithLifecycle("Loading...")
val secondName by remember(secondRoom) {
secondRoom?.nameFlow(nostrViewModel) ?: flowOf("")
}.collectAsStateWithLifecycle("")
val supportingText = when { val supportingText = when {
total == 1 && firstRoom != null -> { total == 1 -> {
val message = firstRoom.lastMessage ?: "" val message = firstRoom.lastMessage ?: ""
"$firstName: $message" "${firstRoomState.name}: $message"
} }
total == 2 -> { total == 2 -> {
"$firstName and $secondName" "${firstRoomState.name} and ${secondRoomState.name}"
} }
total > 2 -> { total > 2 -> {
val othersCount = total - 2 val othersCount = total - 2
val othersText = if (othersCount == 1) "1 other" else "$othersCount others" val othersText = if (othersCount == 1) "1 other" else "$othersCount others"
"$firstName, $secondName and $othersText" "${firstRoomState.name}, ${secondRoomState.name} and $othersText"
} }
else -> "" else -> ""
@@ -712,13 +693,12 @@ fun NewRequests(requests: List<Room>) {
@Composable @Composable
fun ChatRoom(room: Room, onClick: () -> Unit) { fun ChatRoom(room: Room, onClick: () -> Unit) {
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val displayName by remember(room) { room.nameFlow(nostrViewModel) }.collectAsStateWithLifecycle("Loading...") val roomState by room.rememberUiState(nostrViewModel)
val picture by remember(room) { room.pictureFlow(nostrViewModel) }.collectAsStateWithLifecycle(null)
ListItem( ListItem(
modifier = Modifier.clickable(onClick = onClick), modifier = Modifier.clickable(onClick = onClick),
leadingContent = { leadingContent = {
Avatar(picture = picture, description = displayName) Avatar(picture = roomState.picture, description = roomState.picture)
}, },
headlineContent = { headlineContent = {
Row( Row(
@@ -726,7 +706,7 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = displayName, text = roomState.name,
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )

View File

@@ -65,7 +65,6 @@ import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -82,15 +81,10 @@ fun ImportScreen() {
var secret by remember { mutableStateOf("") } var secret by remember { mutableStateOf("") }
var pubkey by remember { mutableStateOf<PublicKey?>(null) } var pubkey by remember { mutableStateOf<PublicKey?>(null) }
// Get metadata when pubkey changes val profile by remember(pubkey) {
val metadata by remember(pubkey) {
pubkey?.let(nostrViewModel::getMetadata) ?: flowOf(null) pubkey?.let(nostrViewModel::getMetadata) ?: flowOf(null)
}.collectAsStateWithLifecycle(null) }.collectAsStateWithLifecycle(null)
val profile = metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
val picture = profile?.picture
LaunchedEffect(qrScanResult.content) { LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result -> qrScanResult.content?.let { result ->
runCatching { runCatching {
@@ -164,7 +158,7 @@ fun ImportScreen() {
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Avatar( Avatar(
picture = picture, picture = profile?.picture,
description = "Profile picture", description = "Profile picture",
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
shape = MaterialShapes.Cookie9Sided.toShape(), shape = MaterialShapes.Cookie9Sided.toShape(),
@@ -172,7 +166,7 @@ fun ImportScreen() {
} }
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text( Text(
text = displayName, text = profile?.name ?: "",
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy( style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily() fontFamily = getExpressiveFontFamily()

View File

@@ -13,8 +13,10 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import io.github.alexzhirkevich.qrose.rememberQrCodePainter import io.github.alexzhirkevich.qrose.rememberQrCodePainter
@@ -27,8 +29,8 @@ import su.reya.coop.LocalSnackbarHostState
fun MyQrScreen() { fun MyQrScreen() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val accountViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val currentUser = accountViewModel.nostr.signer.currentUser ?: return val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -59,7 +61,7 @@ fun MyQrScreen() {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Image( Image(
painter = rememberQrCodePainter(currentUser.toBech32()), painter = rememberQrCodePainter(currentUser?.publicKey?.toBech32() ?: ""),
contentDescription = "My QR" contentDescription = "My QR"
) )
} }

View File

@@ -289,12 +289,8 @@ fun ReceiverChip(
onRemove: () -> Unit onRemove: () -> Unit
) { ) {
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsState(initial = null) val profile by profileFlow.collectAsState(initial = null)
val profile = metadata?.asRecord()
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
val picture = profile?.picture
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
InputChip( InputChip(
@@ -302,7 +298,7 @@ fun ReceiverChip(
onClick = onRemove, onClick = onRemove,
label = { label = {
Text( Text(
text = displayName, text = profile?.name ?: "No name",
style = MaterialTheme.typography.bodyMedium.copy( style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
) )
@@ -310,8 +306,8 @@ fun ReceiverChip(
}, },
avatar = { avatar = {
Avatar( Avatar(
picture = picture, picture = profile?.picture,
description = displayName, description = profile?.name ?: "No name",
size = 24.dp size = 24.dp
) )
}, },
@@ -375,12 +371,8 @@ fun ContactListItem(
onLongClick: () -> Unit onLongClick: () -> Unit
) { ) {
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val metadataFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsState(initial = null) val profile by profileFlow.collectAsState(initial = null)
val profile = metadata?.asRecord()
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
val picture = profile?.picture
SegmentedListItem( SegmentedListItem(
selected = isSelected, selected = isSelected,
@@ -392,15 +384,15 @@ fun ContactListItem(
), ),
leadingContent = { leadingContent = {
Avatar( Avatar(
picture = picture, picture = profile?.picture,
description = displayName, description = profile?.name ?: "",
size = 36.dp size = 36.dp
) )
}, },
supportingContent = { Text(text = pubkey.short()) }, supportingContent = { Text(text = pubkey.short()) },
content = { content = {
Text( Text(
text = displayName, text = profile?.name ?: "",
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }

View File

@@ -63,19 +63,18 @@ fun ProfileScreen(pubkey: String) {
val pubkeyObj = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return val pubkeyObj = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
val metadataFlow = remember(pubkeyObj) { nostrViewModel.getMetadata(pubkeyObj) } val profileFlow = remember(pubkeyObj) { nostrViewModel.getMetadata(pubkeyObj) }
val metadata by metadataFlow.collectAsStateWithLifecycle() val profile by profileFlow.collectAsStateWithLifecycle()
val profile = metadata?.asRecord() val metadata = profile?.metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: "No name" val nip05 = metadata?.nip05 ?: pubkeyObj.short()
val nip05 = profile?.nip05 ?: pubkeyObj.short() val picture = metadata?.picture
val picture = profile?.picture
val details = remember(profile) { val details = remember(profile) {
listOf( listOf(
"Username:" to (profile?.name ?: "None"), "Username:" to (metadata?.name ?: "None"),
"Website:" to (profile?.website ?: "None"), "Website:" to (metadata?.website ?: "None"),
"₿ Lightning Address:" to (profile?.lud16 ?: "None"), "₿ Lightning Address:" to (metadata?.lud16 ?: "None"),
) )
} }
@@ -133,7 +132,7 @@ fun ProfileScreen(pubkey: String) {
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( Text(
text = displayName, text = profile?.name ?: "No name",
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy( style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily() fontFamily = getExpressiveFontFamily()
@@ -159,7 +158,8 @@ fun ProfileScreen(pubkey: String) {
onClick = { onClick = {
scope.launch { scope.launch {
try { try {
val roomId = nostrViewModel.createChatRoom(listOf(pubkeyObj)) val roomId =
nostrViewModel.createChatRoom(listOf(pubkeyObj))
navigator.navigate(Screen.Chat(roomId)) navigator.navigate(Screen.Chat(roomId))
} catch (e: Exception) { } catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) } e.message?.let { snackbarHostState.showSnackbar(it) }

View File

@@ -40,8 +40,8 @@ import kotlinx.coroutines.launch
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.RoomKind
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.nostr.RoomKind
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable

View File

@@ -15,11 +15,9 @@ fun UpdateProfileScreen() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentUser = nostrViewModel.nostr.signer.currentUser ?: return
val metadata by nostrViewModel.getMetadata(currentUser).collectAsStateWithLifecycle()
val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false) val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false)
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
val profile = metadata?.asRecord() val profile = currentUser?.metadata?.asRecord()
ProfileEditor( ProfileEditor(
title = "Update profile", title = "Update profile",

View File

@@ -1,45 +0,0 @@
package su.reya.coop.shared
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import rust.nostr.sdk.PublicKey
import su.reya.coop.nostr.Room
import su.reya.coop.short
import su.reya.coop.NostrViewModel
fun Room.nameFlow(
nostrViewModel: NostrViewModel,
currentUser: PublicKey? = null
): Flow<String> {
// Return early if there's a custom subject/room name
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
if (displayMembers.isEmpty()) return flowOf("Unknown")
return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { metadataArray ->
val names = metadataArray.mapIndexed { i, metadata ->
val profile = metadata?.asRecord()
profile?.displayName?.takeIf { it.isNotBlank() }
?: profile?.name?.takeIf { it.isNotBlank() }
?: displayMembers[i].short()
}
if (isGroup()) {
val combined = names.joinToString(", ")
val extraCount = members.size - names.size
if (extraCount > 0) "$combined, +$extraCount" else combined
} else {
val name = names.first()
if (displayMembers.first() == currentUser) "$name (you)" else name
}
}
}
fun Room.pictureFlow(nostrViewModel: NostrViewModel): Flow<String?> {
val firstMember = members.firstOrNull() ?: return flowOf(null)
return nostrViewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
}

View File

@@ -3,6 +3,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary) alias(libs.plugins.androidLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
kotlin("plugin.serialization") version libs.versions.kotlin.get() kotlin("plugin.serialization") version libs.versions.kotlin.get()
} }

View File

@@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@@ -17,6 +18,8 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -31,7 +34,6 @@ import rust.nostr.sdk.EventId
import rust.nostr.sdk.Keys import rust.nostr.sdk.Keys
import rust.nostr.sdk.Kind import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.Metadata
import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
@@ -44,16 +46,14 @@ import su.reya.coop.blossom.BlossomClient
import su.reya.coop.nostr.ExternalSignerHandler import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.ExternalSignerProxy import su.reya.coop.nostr.ExternalSignerProxy
import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.Room
import su.reya.coop.nostr.SignerPermissions import su.reya.coop.nostr.SignerPermissions
import su.reya.coop.nostr.roomId
import su.reya.coop.storage.SecretStorage import su.reya.coop.storage.SecretStorage
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class NostrViewModel( class NostrViewModel(
val nostr: Nostr, private val nostr: Nostr,
private val secretStore: SecretStorage, private val secretStore: SecretStorage,
private val externalSignerHandler: ExternalSignerHandler? = null, private val externalSignerHandler: ExternalSignerHandler? = null,
) : ViewModel() { ) : ViewModel() {
@@ -87,21 +87,25 @@ class NostrViewModel(
private val _errorEvents = Channel<String>(Channel.BUFFERED) private val _errorEvents = Channel<String>(Channel.BUFFERED)
val errorEvents = _errorEvents.receiveAsFlow() val errorEvents = _errorEvents.receiveAsFlow()
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>() private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED) private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>() private val seenPublicKeys = mutableSetOf<PublicKey>()
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } val isSyncing = nostr.messages.messageSyncState
.map { it.isSyncing }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
init {
// Skip the splash screen if a user is already logged in
if (nostr.signer.currentUser != null) {
_signerRequired.value = false
}
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile = nostr.signer.publicKeyFlow
.flatMapLatest { pubkey ->
if (pubkey != null) getMetadata(pubkey) else flowOf(null)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
init {
// Check if the notification banner has been dismissed // Check if the notification banner has been dismissed
checkNotificationBannerDismissedStatus() checkNotificationBannerDismissedStatus()
@@ -183,11 +187,9 @@ class NostrViewModel(
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId } val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
if (existingRoom == null) { if (existingRoom == null) {
val currentUser = nostr.signer.currentUser val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
if (currentUser != null) { val newRoom = Room.new(event, currentUser)
val newRoom = Room.new(event, currentUser) _chatRooms.update { (it + newRoom).sortedDescending().toSet() }
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
}
} else { } else {
updateRoomList(roomId, event) updateRoomList(roomId, event)
} }
@@ -206,7 +208,7 @@ class NostrViewModel(
// Observe metadata updates // Observe metadata updates
launch { launch {
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) -> nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
updateMetadata(pubkey, metadata) updateMetadata(pubkey, Profile(pubkey, metadata))
} }
} }
} }
@@ -257,7 +259,7 @@ class NostrViewModel(
val results = nostr.profiles.getAllCacheMetadata() val results = nostr.profiles.getAllCacheMetadata()
results.forEach { (pubkey, metadata) -> results.forEach { (pubkey, metadata) ->
// Update the metadata state // Update the metadata state
updateMetadata(pubkey, metadata) updateMetadata(pubkey, Profile(pubkey, metadata))
// Update seenPublicKeys to avoid duplicate requests // Update seenPublicKeys to avoid duplicate requests
seenPublicKeys.add(pubkey) seenPublicKeys.add(pubkey)
} }
@@ -295,9 +297,9 @@ class NostrViewModel(
private fun observeSignerAndCheckRelays() { private fun observeSignerAndCheckRelays() {
viewModelScope.launch { viewModelScope.launch {
while (true) { while (true) {
val pubkey = nostr.signer.currentUser val currentUser = nostr.signer.getPublicKeyAsync()
if (pubkey != null) { if (currentUser != null) {
// Get chat rooms // Get chat rooms
val rooms = nostr.messages.getChatRooms() ?: emptySet() val rooms = nostr.messages.getChatRooms() ?: emptySet()
if (rooms.isNotEmpty()) { if (rooms.isNotEmpty()) {
@@ -312,7 +314,7 @@ class NostrViewModel(
delay(2.seconds) delay(2.seconds)
// Check if the relay list is empty // Check if the relay list is empty
val relays = nostr.relays.getMsgRelays(pubkey) val relays = nostr.relays.getMsgRelays(currentUser)
if (relays.isEmpty()) _isRelayListEmpty.value = true if (relays.isEmpty()) _isRelayListEmpty.value = true
break break
@@ -331,22 +333,17 @@ class NostrViewModel(
} }
} }
private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) { private fun updateMetadata(pubkey: PublicKey, profile: Profile) {
_metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
} }
fun getMetadata(pubkey: PublicKey): StateFlow<Metadata?> { fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) } val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) }
if (flow.value == null) { if (flow.value == null) requestMetadata(pubkey)
requestMetadata(pubkey)
}
return flow.asStateFlow() return flow.asStateFlow()
} }
fun currentUser(): PublicKey? {
return nostr.signer.currentUser
}
fun logout() { fun logout() {
viewModelScope.launch { viewModelScope.launch {
try { try {
@@ -445,7 +442,7 @@ class NostrViewModel(
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
// Update the metadata state after successfully published // Update the metadata state after successfully published
updateMetadata(currentUser, newMetadata) updateMetadata(currentUser, Profile(currentUser, newMetadata))
// Update local state // Update local state
_isBusy.value = false _isBusy.value = false
} catch (e: Exception) { } catch (e: Exception) {
@@ -573,8 +570,10 @@ class NostrViewModel(
return externalSignerHandler?.isAvailable() == true return externalSignerHandler?.isAvailable() == true
} }
suspend fun refetchMsgRelays(pubkey: PublicKey) { suspend fun refetchMsgRelays() {
val relays = nostr.relays.fetchMsgRelays(pubkey) val currentUser = nostr.signer.getPublicKeyAsync() ?: return
val relays = nostr.relays.fetchMsgRelays(currentUser)
if (relays.isNotEmpty()) dismissRelayWarning() if (relays.isNotEmpty()) dismissRelayWarning()
} }
@@ -717,10 +716,13 @@ class NostrViewModel(
fun createChatRoom(to: List<PublicKey>): Long { fun createChatRoom(to: List<PublicKey>): Long {
try { try {
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in") if (to.isEmpty()) {
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required") throw IllegalArgumentException("At least one recipient is required")
}
val currentUser = nostr.signer.currentUser ?: throw Exception("User not found") // Get current user
val currentUser = nostr.signer.publicKeyFlow.value
?: throw IllegalStateException("User not signed in")
// Construct the rumor event // Construct the rumor event
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "") val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")

View File

@@ -0,0 +1,20 @@
package su.reya.coop
import rust.nostr.sdk.Metadata
import rust.nostr.sdk.PublicKey
data class Profile(
val publicKey: PublicKey,
val metadata: Metadata
) {
private val record by lazy { metadata.asRecord() }
val name: String
get() = record.displayName ?: record.name ?: publicKey.short()
val picture: String?
get() = record.picture
val shortPublicKey: String
get() = publicKey.short()
}

View File

@@ -1,5 +1,12 @@
package su.reya.coop.nostr package su.reya.coop
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus import kotlinx.datetime.minus
@@ -75,9 +82,60 @@ data class Room(
return this.copy(lastMessage = message) return this.copy(lastMessage = message)
} }
fun isGroup(): Boolean { fun isGroup(): Boolean = members.size > 1
return members.size > 1 }
data class RoomUiState(
val name: String = "Loading...",
val picture: String? = null,
val isGroup: Boolean = false
)
fun Room.uiStateFlow(
nostrViewModel: NostrViewModel,
currentUser: PublicKey? = null
): Flow<RoomUiState> {
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
if (!subject.isNullOrBlank()) {
return flowOf(RoomUiState(name = subject, isGroup = isGroup()))
} }
return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles ->
val names = profiles.mapIndexed { i, profile -> profile?.name ?: displayMembers[i].short() }
val name = when {
isGroup() -> {
val combined = names.joinToString(", ")
val extra = members.size - names.size
if (extra > 0) "$combined, +$extra" else combined
}
else -> {
val first = names.firstOrNull() ?: "Unknown"
if (displayMembers.firstOrNull() == currentUser) "$first (you)" else first
}
}
RoomUiState(
name = name,
picture = profiles.firstOrNull()?.picture,
isGroup = isGroup()
)
}
}
@Composable
fun Room.rememberUiState(
viewModel: NostrViewModel,
currentUser: PublicKey? = null
): State<RoomUiState> {
return remember(this, currentUser) {
uiStateFlow(
viewModel,
currentUser
)
}.collectAsStateWithLifecycle(RoomUiState())
} }
fun UnsignedEvent.roomId(): Long { fun UnsignedEvent.roomId(): Long {

View File

@@ -24,6 +24,9 @@ import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.nip17ExtractRelayList import rust.nostr.sdk.nip17ExtractRelayList
import rust.nostr.sdk.nip59MakeGiftWrapAsync import rust.nostr.sdk.nip59MakeGiftWrapAsync
import su.reya.coop.Room
import su.reya.coop.RoomKind
import su.reya.coop.roomId
import kotlin.time.Duration import kotlin.time.Duration
data class MessageSyncState( data class MessageSyncState(
@@ -47,7 +50,9 @@ class MessageManager(private val nostr: Nostr) {
suspend fun getUserMessages(msgRelayList: Event) { suspend fun getUserMessages(msgRelayList: Event) {
try { try {
val author = signer.currentUser ?: throw IllegalStateException("User not signed in") val author =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val relays = nip17ExtractRelayList(msgRelayList) val relays = nip17ExtractRelayList(msgRelayList)
// Ensure relay connections // Ensure relay connections

View File

@@ -136,7 +136,7 @@ class Nostr {
fun isSignedByUser(event: Event): Boolean { fun isSignedByUser(event: Event): Boolean {
return try { return try {
signer.currentUser == event.author() signer.publicKeyFlow.value == event.author()
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to check if event is signed by user: ${e.message}") println("Failed to check if event is signed by user: ${e.message}")
false false

View File

@@ -49,7 +49,8 @@ class ProfileManager(private val nostr: Nostr) {
suspend fun getUserMetadata() { suspend fun getUserMetadata() {
try { try {
val author = signer.currentUser ?: throw IllegalStateException("User not signed in") val author =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
// Get the latest metadata event // Get the latest metadata event
val metadataFilter = val metadataFilter =
@@ -146,7 +147,8 @@ class ProfileManager(private val nostr: Nostr) {
bio: String? = null, bio: String? = null,
picture: String? = null picture: String? = null
): Metadata { ): Metadata {
val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in") val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
try { try {
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord() val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()

View File

@@ -1,5 +1,7 @@
package su.reya.coop.nostr package su.reya.coop.nostr
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@@ -16,9 +18,8 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
@Volatile @Volatile
private var signer: AsyncNostrSigner = initialSigner private var signer: AsyncNostrSigner = initialSigner
@Volatile private val _publicKeyFlow = MutableStateFlow<PublicKey?>(null)
var currentUser: PublicKey? = null val publicKeyFlow = _publicKeyFlow.asStateFlow()
private set
/** /**
* Get the current signer. * Get the current signer.
@@ -37,7 +38,7 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
throw IllegalStateException("Failed to get public key from signer", e) throw IllegalStateException("Failed to get public key from signer", e)
} }
signer = newSigner signer = newSigner
currentUser = pubkey _publicKeyFlow.value = pubkey
} }
override suspend fun getPublicKeyAsync(): PublicKey? { override suspend fun getPublicKeyAsync(): PublicKey? {