refactor
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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), "")
|
||||||
|
|||||||
20
shared/src/commonMain/kotlin/su/reya/coop/Profile.kt
Normal file
20
shared/src/commonMain/kotlin/su/reya/coop/Profile.kt
Normal 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()
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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? {
|
||||||
|
|||||||
Reference in New Issue
Block a user