This commit is contained in:
2026-07-02 10:41:10 +07:00
parent a6d40e44f2
commit 320a986212
19 changed files with 223 additions and 238 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,19 +63,18 @@ fun ProfileScreen(pubkey: String) {
val pubkeyObj = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
val metadataFlow = remember(pubkeyObj) { nostrViewModel.getMetadata(pubkeyObj) }
val metadata by metadataFlow.collectAsStateWithLifecycle()
val profileFlow = remember(pubkeyObj) { nostrViewModel.getMetadata(pubkeyObj) }
val profile by profileFlow.collectAsStateWithLifecycle()
val profile = metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: "No name"
val nip05 = profile?.nip05 ?: pubkeyObj.short()
val picture = profile?.picture
val metadata = profile?.metadata?.asRecord()
val nip05 = metadata?.nip05 ?: pubkeyObj.short()
val picture = metadata?.picture
val details = remember(profile) {
listOf(
"Username:" to (profile?.name ?: "None"),
"Website:" to (profile?.website ?: "None"),
"₿ Lightning Address:" to (profile?.lud16 ?: "None"),
"Username:" to (metadata?.name ?: "None"),
"Website:" to (metadata?.website ?: "None"),
"₿ Lightning Address:" to (metadata?.lud16 ?: "None"),
)
}
@@ -133,7 +132,7 @@ fun ProfileScreen(pubkey: String) {
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = displayName,
text = profile?.name ?: "No name",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily()
@@ -159,7 +158,8 @@ fun ProfileScreen(pubkey: String) {
onClick = {
scope.launch {
try {
val roomId = nostrViewModel.createChatRoom(listOf(pubkeyObj))
val roomId =
nostrViewModel.createChatRoom(listOf(pubkeyObj))
navigator.navigate(Screen.Chat(roomId))
} catch (e: Exception) {
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.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.RoomKind
import su.reya.coop.Screen
import su.reya.coop.nostr.RoomKind
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable

View File

@@ -15,11 +15,9 @@ fun UpdateProfileScreen() {
val navigator = LocalNavigator.current
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 profile = metadata?.asRecord()
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
val profile = currentUser?.metadata?.asRecord()
ProfileEditor(
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 }
}