Compare commits

..
64 changed files with 3631 additions and 203 deletions
+1
View File
@@ -17,3 +17,4 @@ captures
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
node_modules/
.artifacts/
+1 -1
View File
@@ -69,7 +69,7 @@ android {
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "0.2.5"
versionName = "0.2.6"
}
packaging {
resources {
@@ -7,6 +7,8 @@
android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -0,0 +1,49 @@
package su.reya.coop
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class AndroidConnectivityMonitor(context: Context) : ConnectivityMonitor {
private val manager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val _isMobileData = MutableStateFlow(checkIsMobileData())
override val isMobileData: StateFlow<Boolean> = _isMobileData.asStateFlow()
init {
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
manager.registerNetworkCallback(
networkRequest,
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
_isMobileData.value = checkIsMobileData()
}
override fun onLost(network: Network) {
_isMobileData.value = checkIsMobileData()
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
_isMobileData.value = checkIsMobileData()
}
})
}
private fun checkIsMobileData(): Boolean {
val activeNetwork = manager.activeNetwork ?: return false
val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return false
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
}
}
@@ -4,7 +4,6 @@ import android.app.Activity
import android.content.Intent
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -40,6 +39,7 @@ import androidx.navigation3.ui.NavDisplay
import kotlinx.coroutines.launch
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.SettingsRepository
import su.reya.coop.screens.ContactListScreen
import su.reya.coop.screens.HomeScreen
import su.reya.coop.screens.ImportScreen
@@ -51,17 +51,27 @@ import su.reya.coop.screens.ProfileScreen
import su.reya.coop.screens.RelayScreen
import su.reya.coop.screens.RequestListScreen
import su.reya.coop.screens.ScanScreen
import su.reya.coop.screens.SettingsScreen
import su.reya.coop.screens.UpdateProfileScreen
import su.reya.coop.screens.chat.ChatScreen
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatScreenViewModel
import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.ProfileCache
import su.reya.coop.viewmodel.SettingsViewModel
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
error("No ProfileCache provided")
}
val LocalSettings = staticCompositionLocalOf<Settings> {
error("No Settings provided")
}
val LocalConnectivity = staticCompositionLocalOf<Boolean> {
false
}
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided")
}
@@ -80,6 +90,8 @@ fun App(
profileCache: ProfileCache,
accountRepository: AccountRepository,
chatRepository: ChatRepository,
settingsRepository: SettingsRepository,
connectivityMonitor: ConnectivityMonitor,
) {
val viewModelFactory = remember {
object : ViewModelProvider.Factory {
@@ -93,6 +105,10 @@ fun App(
accountRepository
)
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(
settingsRepository
)
else -> throw IllegalArgumentException("Unknown ViewModel class")
}
@Suppress("UNCHECKED_CAST")
@@ -103,6 +119,7 @@ fun App(
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
val context = LocalContext.current
val activity = context as? ComponentActivity
@@ -114,19 +131,27 @@ fun App(
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val signerRequired = accountState.signerRequired
// Get the settings
val settings by settingsViewModel.settings.collectAsStateWithLifecycle()
// Get connectivity status
val isMobileData by connectivityMonitor.isMobileData.collectAsStateWithLifecycle()
// Snackbar
val snackbarHostState = remember { SnackbarHostState() }
// Check if dark theme enabled
val darkMode = isSystemInDarkTheme()
val darkMode = when (settings.theme) {
Theme.Light -> false
Theme.Dark -> true
Theme.System -> isSystemInDarkTheme()
}
// Enabled the dynamic color scheme
val colorScheme = when {
// Enable the dynamic color scheme for Android 12+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
context
)
settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
// When dark mode is enabled, use the dark color scheme
darkMode -> darkColorScheme()
@@ -134,10 +159,6 @@ fun App(
else -> expressiveLightColorScheme()
}
BackHandler(enabled = backStack.size > 1) {
navigator.goBack()
}
LaunchedEffect(Unit) {
launch {
accountViewModel.errorEvents.collect { message ->
@@ -195,11 +216,12 @@ fun App(
) {
CompositionLocalProvider(
LocalProfileCache provides profileCache,
LocalSettings provides settings,
LocalConnectivity provides isMobileData,
LocalSnackbarHostState provides snackbarHostState,
LocalNavigator provides navigator,
LocalScanResult provides qrScanResult,
) {
NavDisplay(
backStack = backStack,
onBack = {
@@ -272,6 +294,9 @@ fun App(
entry<Screen.Relay> {
RelayScreen(accountViewModel)
}
entry<Screen.Settings> {
SettingsScreen(settingsViewModel)
}
}
)
}
@@ -14,6 +14,7 @@ import su.reya.coop.nostr.NostrManager
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.MediaRepository
import su.reya.coop.repository.SettingsRepository
import su.reya.coop.viewmodel.ProfileCache
import kotlin.system.exitProcess
@@ -25,16 +26,30 @@ class MainActivity : ComponentActivity() {
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
private val scope = MainScope()
private val connectivityMonitor by lazy { AndroidConnectivityMonitor(this@MainActivity) }
private val settingsRepository by lazy {
val storage = AppStore(this@MainActivity)
SettingsRepository(storage, scope)
}
private val accountRepository by lazy {
val storage = AppStore(this@MainActivity)
val mediaRepository = MediaRepository()
val mediaRepository = MediaRepository(settingsRepository)
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
AccountRepository(NostrManager.instance, storage, mediaRepository, scope, androidSigner)
AccountRepository(
NostrManager.instance,
storage,
mediaRepository,
settingsRepository,
scope,
androidSigner
)
}
private val chatRepository by lazy {
val mediaRepository = MediaRepository()
ChatRepository(NostrManager.instance, mediaRepository, scope)
val mediaRepository = MediaRepository(settingsRepository)
ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -82,6 +97,8 @@ class MainActivity : ComponentActivity() {
profileCache = profileCache,
accountRepository = accountRepository,
chatRepository = chatRepository,
settingsRepository = settingsRepository,
connectivityMonitor = connectivityMonitor,
)
}
}
@@ -58,4 +58,7 @@ sealed interface Screen : NavKey {
@Serializable
data object Relay : Screen
@Serializable
data object Settings : Screen
}
@@ -11,10 +11,10 @@ import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -28,8 +28,6 @@ private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
class NostrForegroundService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private val nostr by lazy { NostrManager.instance }
private var notificationJob: Job? = null
@@ -191,4 +189,13 @@ class NostrForegroundService : Service() {
super.onDestroy()
serviceScope.cancel()
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
if (!NotificationManagerCompat.from(this).areNotificationsEnabled()) {
Log.d("Coop", "Stopping service on task removed because notifications are disabled")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
}
@@ -73,7 +73,8 @@ fun ContactListScreen(
) {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val contactList = accountState.contactList
var openAddContactDialog by remember { mutableStateOf(false) }
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
@@ -7,6 +7,7 @@ import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -24,6 +25,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -86,6 +89,7 @@ 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
@@ -124,13 +128,13 @@ fun HomeScreen(
val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
val isRelayListEmpty by accountViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val isRelayListEmpty = accountState.isRelayListEmpty
val isBannerDismissed = accountState.isNotificationBannerDismissed
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val isBannerDismissed = accountState.isNotificationBannerDismissed
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
@@ -249,7 +253,9 @@ fun HomeScreen(
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (!isNotificationEnabled && !isBannerDismissed) {
AnimatedVisibility(
visible = !isNotificationEnabled && !isBannerDismissed,
) {
Surface(
modifier = Modifier
.fillMaxWidth()
@@ -372,10 +378,14 @@ fun HomeScreen(
}
items(ongoing, key = { it.id }) { room ->
ChatRoom(
room = room,
onClick = { navigator.navigate(Screen.Chat(room.id)) }
)
Row(
modifier = Modifier.animateItem()
) {
ChatRoom(
room = room,
onClick = { navigator.navigate(Screen.Chat(room.id)) }
)
}
}
}
}
@@ -620,13 +630,16 @@ fun NewRequests(requests: List<Room>) {
val firstRoom = requests.getOrNull(0)
val secondRoom = requests.getOrNull(1)
val firstRoomState by (firstRoom as Room).uiStateFlow(profileCache)
.collectAsStateWithLifecycle(RoomUiState())
val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(profileCache)
.collectAsStateWithLifecycle(RoomUiState())
val firstRoomState by remember(firstRoom?.id) {
firstRoom?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
}.collectAsStateWithLifecycle(RoomUiState())
val secondRoomState by remember(secondRoom?.id) {
(secondRoom ?: firstRoom)?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
}.collectAsStateWithLifecycle(RoomUiState())
val supportingText = when {
total == 1 -> {
total == 1 && firstRoom != null -> {
val message = firstRoom.lastMessage ?: ""
"${firstRoomState.name}: $message"
}
@@ -644,27 +657,39 @@ fun NewRequests(requests: List<Room>) {
else -> ""
}
val totalUnread = requests.sumOf { it.unreadCount }
ListItem(
modifier = Modifier.clickable {
navigator.navigate(Screen.RequestList)
},
leadingContent = {
Box(
modifier = Modifier
.size(48.dp)
.clip(MaterialShapes.Clover4Leaf.toShape()),
contentAlignment = Alignment.Center
BadgedBox(
badge = {
if (totalUnread > 0) {
Badge {
Text(totalUnread.toString())
}
}
}
) {
Surface(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.tertiaryContainer,
Box(
modifier = Modifier
.size(48.dp)
.clip(MaterialShapes.Clover4Leaf.toShape()),
contentAlignment = Alignment.Center
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(Res.drawable.ic_request),
contentDescription = "Requests",
tint = MaterialTheme.colorScheme.onTertiaryFixed
)
Surface(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.tertiaryContainer,
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(Res.drawable.ic_request),
contentDescription = "Requests",
tint = MaterialTheme.colorScheme.onTertiaryFixed
)
}
}
}
}
@@ -672,14 +697,19 @@ fun NewRequests(requests: List<Room>) {
headlineContent = {
Text(
text = "Requests",
style = MaterialTheme.typography.titleMediumEmphasized
style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal
)
)
},
supportingContent = {
if (supportingText.isNotEmpty()) {
Text(
text = supportingText,
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal,
color = if (totalUnread > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline
),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -695,27 +725,41 @@ fun NewRequests(requests: List<Room>) {
@Composable
fun ChatRoom(room: Room, onClick: () -> Unit) {
val profileCache = LocalProfileCache.current
val roomState by room.uiStateFlow(profileCache).collectAsStateWithLifecycle(RoomUiState())
val roomState by remember(room.id) { room.uiStateFlow(profileCache) }
.collectAsStateWithLifecycle(RoomUiState())
ListItem(
modifier = Modifier.clickable(onClick = onClick),
leadingContent = {
Avatar(picture = roomState.picture, description = roomState.picture)
BadgedBox(
badge = {
if (room.unreadCount > 0) {
Badge {
Text(room.unreadCount.toString())
}
}
}
) {
Avatar(picture = roomState.picture, description = roomState.picture)
}
},
headlineContent = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = roomState.name,
style = MaterialTheme.typography.titleMediumEmphasized,
style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = if (room.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal
),
modifier = Modifier.weight(1f)
)
Text(
text = room.createdAt.ago(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
color = MaterialTheme.colorScheme.outline,
textAlign = TextAlign.End,
)
}
},
@@ -723,7 +767,10 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
if (!room.lastMessage.isNullOrBlank()) {
Text(
text = room.lastMessage ?: "",
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = if (room.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal,
color = if (room.unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline
),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
@@ -748,7 +795,7 @@ fun BottomMenuList(
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
"Contact List" to { navigator.navigate(Screen.ContactList) },
"Relay Management" to { navigator.navigate(Screen.Relay) },
"Settings" to { }
"Settings" to { navigator.navigate(Screen.Settings) }
)
Column(
@@ -101,13 +101,6 @@ fun ImportScreen(viewModel: AccountViewModel) {
}
}
// Show import errors via snackbar
LaunchedEffect(accountState.importError) {
accountState.importError?.let {
snackbarHostState.showSnackbar(it)
}
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -75,7 +75,8 @@ fun NewChatScreen(
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val contactList by accountViewModel.contactList.collectAsStateWithLifecycle()
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val contactList = accountState.contactList
var query by remember { mutableStateOf("") }
val createGroup = remember { mutableStateOf(false) }
@@ -185,8 +186,8 @@ fun NewChatScreen(
)
},
text = { Text("Next") },
containerColor = MaterialTheme.colorScheme.tertiary,
contentColor = MaterialTheme.colorScheme.onTertiary,
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
@@ -70,13 +70,6 @@ fun OnboardingScreen(viewModel: AccountViewModel) {
}
}
// Show connection errors
LaunchedEffect(accountState.importError) {
accountState.importError?.let {
snackbarHostState.showSnackbar(it)
}
}
val logoPainter = painterResource(Res.drawable.coop)
val expressiveFont = getExpressiveFontFamily()
@@ -95,14 +95,15 @@ fun RelayScreen(viewModel: AccountViewModel) {
var openAddRelayDialog by remember { mutableStateOf(false) }
var relayToDelete by remember { mutableStateOf<String?>(null) }
val accountState by viewModel.state.collectAsStateWithLifecycle()
val loadedRelayList = accountState.userRelayList
val loadedMsgRelayList = accountState.userMsgRelayList
LaunchedEffect(Unit) {
viewModel.loadCurrentUserRelayList()
viewModel.loadCurrentUserMsgRelayList()
}
val loadedRelayList by viewModel.userRelayList.collectAsStateWithLifecycle()
val loadedMsgRelayList by viewModel.userMsgRelayList.collectAsStateWithLifecycle()
LaunchedEffect(loadedRelayList) {
if (loadedRelayList.isNotEmpty()) {
relayList.clear()
@@ -0,0 +1,291 @@
package su.reya.coop.screens
import android.content.Intent
import android.provider.Settings as AndroidSettings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.MediaConfig
import su.reya.coop.Theme
import su.reya.coop.viewmodel.SettingsViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SettingsScreen(viewModel: SettingsViewModel) {
val navigator = LocalNavigator.current
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val settings by viewModel.settings.collectAsState()
var showThemeDialog by remember { mutableStateOf(false) }
var showMediaDialog by remember { mutableStateOf(false) }
var showBlossomDialog by remember { mutableStateOf(false) }
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
text = "Settings",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
navigationIcon = {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "General",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 8.dp)
)
Column(
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
) {
SegmentedListItem(
onClick = { viewModel.update { it.copy(screening = !it.screening) } },
shapes = ListItemDefaults.segmentedShapes(index = 0, count = 4),
content = { Text("Screening") },
supportingContent = { Text("Filter unknown contacts") },
trailingContent = {
Switch(
checked = settings.screening,
onCheckedChange = { viewModel.update { s -> s.copy(screening = it) } }
)
}
)
SegmentedListItem(
onClick = { showMediaDialog = true },
shapes = ListItemDefaults.segmentedShapes(index = 1, count = 4),
content = { Text("Media Preview") },
supportingContent = {
Text(
when (settings.media) {
MediaConfig.Disabled -> "Disabled"
MediaConfig.DisabledForMobileData -> "Disabled for Mobile Data"
MediaConfig.AlwaysEnabled -> "Always Enabled"
}
)
}
)
SegmentedListItem(
onClick = { showBlossomDialog = true },
shapes = ListItemDefaults.segmentedShapes(index = 2, count = 4),
content = { Text("Blossom Server") },
supportingContent = { Text(settings.blossomServer ?: "Default") }
)
SegmentedListItem(
onClick = {
val intent = Intent(AndroidSettings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(AndroidSettings.EXTRA_APP_PACKAGE, context.packageName)
}
context.startActivity(intent)
},
shapes = ListItemDefaults.segmentedShapes(index = 3, count = 4),
content = { Text("Notifications") },
supportingContent = { Text("System notification settings") }
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Appearance",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 8.dp)
)
Column(
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
) {
SegmentedListItem(
onClick = { showThemeDialog = true },
shapes = ListItemDefaults.segmentedShapes(index = 0, count = 2),
content = { Text("Theme") },
supportingContent = { Text(settings.theme.name) }
)
SegmentedListItem(
onClick = { viewModel.update { it.copy(dynamicColor = !it.dynamicColor) } },
shapes = ListItemDefaults.segmentedShapes(index = 1, count = 2),
content = { Text("Dynamic Color") },
trailingContent = {
Switch(
checked = settings.dynamicColor,
onCheckedChange = {
viewModel.update { s -> s.copy(dynamicColor = it) }
}
)
}
)
}
}
}
}
if (showThemeDialog) {
OptionDialog(
title = "Select Theme",
options = Theme.entries.map { it.name },
selected = settings.theme.name,
onSelected = { name ->
viewModel.update { it.copy(theme = Theme.valueOf(name)) }
showThemeDialog = false
},
onDismiss = { showThemeDialog = false }
)
}
if (showMediaDialog) {
OptionDialog(
title = "Media Preview",
options = listOf("Disabled", "Disabled for Mobile Data", "Always Enabled"),
selected = when (settings.media) {
MediaConfig.Disabled -> "Disabled"
MediaConfig.DisabledForMobileData -> "Disabled for Mobile Data"
MediaConfig.AlwaysEnabled -> "Always Enabled"
},
onSelected = { choice ->
val newConfig = when (choice) {
"Disabled" -> MediaConfig.Disabled
"Disabled for Mobile Data" -> MediaConfig.DisabledForMobileData
else -> MediaConfig.AlwaysEnabled
}
viewModel.update { it.copy(media = newConfig) }
showMediaDialog = false
},
onDismiss = { showMediaDialog = false }
)
}
if (showBlossomDialog) {
var text by remember { mutableStateOf(settings.blossomServer ?: "") }
AlertDialog(
onDismissRequest = { showBlossomDialog = false },
title = { Text("Blossom Server URL") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("https://...") },
singleLine = true
)
},
confirmButton = {
TextButton(onClick = {
viewModel.update { it.copy(blossomServer = text.ifBlank { null }) }
showBlossomDialog = false
}) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = { showBlossomDialog = false }) {
Text("Cancel")
}
}
)
}
}
@Composable
fun OptionDialog(
title: String,
options: List<String>,
selected: String,
onSelected: (String) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(modifier = Modifier.selectableGroup()) {
options.forEach { text ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = (text == selected),
onClick = { onSelected(text) },
role = Role.RadioButton
)
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = (text == selected), onClick = null)
Spacer(Modifier.size(16.dp))
Text(text = text, style = MaterialTheme.typography.bodyLarge)
}
}
}
},
confirmButton = {}
)
}
@@ -1,10 +1,5 @@
package su.reya.coop.screens.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -43,6 +38,9 @@ import coil3.compose.AsyncImage
import rust.nostr.sdk.EventId
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalConnectivity
import su.reya.coop.LocalSettings
import su.reya.coop.MediaConfig
import su.reya.coop.URL_REGEX
import su.reya.coop.formatAsTime
import su.reya.coop.isImageUrl
@@ -61,14 +59,27 @@ data class MessageModel(
@Composable
fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel {
return remember(event, currentUser) {
val settings = LocalSettings.current
val isMobileData = LocalConnectivity.current
return remember(event, currentUser, settings, isMobileData) {
val id = event.ensureId().id()!!
val isMine = currentUser == event.author()
val content = event.content()
val replyEventIds = event.tags().eventIds()
val images = URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
val cleanedContent = content.removeImageUrls()
val showMedia = when (settings.media) {
MediaConfig.AlwaysEnabled -> true
MediaConfig.Disabled -> false
MediaConfig.DisabledForMobileData -> !isMobileData
}
val images = if (showMedia) {
URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
} else {
emptyList()
}
val cleanedContent = if (showMedia) content.removeImageUrls() else content
val annotatedString = buildAnnotatedString {
var lastIndex = 0
@@ -177,11 +188,7 @@ fun ChatMessage(
)
}
}
AnimatedVisibility(
visible = isMessageClicked,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
if (isMessageClicked) {
Text(
text = model.timestamp,
style = MaterialTheme.typography.labelSmall,
@@ -90,9 +90,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.EventId
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalSettings
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.RoomUiState
@@ -115,6 +117,7 @@ fun ChatScreen(
val clipboardManager = LocalClipboard.current
val navigator = LocalNavigator.current
val profileCache = LocalProfileCache.current
val settings = LocalSettings.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
@@ -146,8 +149,9 @@ fun ChatScreen(
val groupedMessages =
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
.collectAsStateWithLifecycle(RoomUiState())
val roomState by remember(id, currentUser?.publicKey) {
(room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
}.collectAsStateWithLifecycle(RoomUiState())
var text by remember { mutableStateOf("") }
var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
@@ -158,6 +162,29 @@ fun ChatScreen(
label = "blurAnimation"
)
val goToMessage = { eventId: EventId? ->
if (eventId != null) {
scope.launch {
var targetIndex = -1
var currentIndex = 0
for (group in groupedMessages.value) {
val msgInGroup = group.value
val idx = msgInGroup.indexOfFirst { it.id() == eventId }
if (idx != -1) {
targetIndex = currentIndex + idx
break
}
currentIndex += msgInGroup.size + 1
}
if (targetIndex != -1) {
listState.animateScrollToItem(targetIndex)
}
}
}
}
val sendFile = { uri: Uri ->
scope.launch {
// Read file on IO dispatcher
@@ -193,9 +220,7 @@ fun ChatScreen(
}
}
Box(
modifier = Modifier.fillMaxSize()
) {
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
modifier = Modifier.blur(blurAmount),
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
@@ -264,7 +289,7 @@ fun ChatScreen(
.fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding())
) {
if (requireScreening) {
if (requireScreening && settings.screening) {
room?.let { ScreenerCard(accountViewModel, it) }
}
@@ -295,10 +320,18 @@ fun ChatScreen(
}
Column(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.animateItem(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
replyPreview?.let { ReplyPreview(it, model.isMine) }
replyPreview?.let { previewEvent ->
ReplyPreview(
event = previewEvent,
isMine = model.isMine,
onClick = { goToMessage(previewEvent.id()) }
)
}
ChatMessage(
model = model,
modifier = Modifier.graphicsLayer {
@@ -346,7 +379,7 @@ fun ChatScreen(
}
}
when (requireScreening) {
when (requireScreening && settings.screening) {
true -> {
Row(
modifier = Modifier
@@ -380,15 +413,18 @@ fun ChatScreen(
}
else -> {
replyingTo?.let {
ReplyBox(it) { replyingTo = null }
AnimatedVisibility(visible = replyingTo != null) {
replyingTo?.let {
ReplyBox(it) { replyingTo = null }
}
}
ChatInput(
value = text,
onValueChange = { text = it },
onSend = {
viewModel.sendMessage(text)
viewModel.sendMessage(text, replyingTo?.id)
text = ""
replyingTo = null
},
onUpload = {
fileLauncher.launch("image/*")
@@ -420,7 +456,6 @@ fun ChatScreen(
}
}
)
AnimatedVisibility(
visible = selectedMessage != null,
enter = fadeIn(),
@@ -538,7 +573,11 @@ private fun ReplyBox(model: MessageModel, onDismiss: () -> Unit) {
}
@Composable
private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) {
private fun ReplyPreview(
event: UnsignedEvent,
isMine: Boolean = false,
onClick: () -> Unit
) {
val profileCache = LocalProfileCache.current
val profileFlow = remember(event) { profileCache.getMetadata(event.author()) }
val profile by profileFlow.collectAsStateWithLifecycle()
@@ -554,7 +593,9 @@ private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) {
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
Surface(
modifier = Modifier.widthIn(max = 280.dp),
modifier = Modifier
.widthIn(max = 280.dp)
.clickable(onClick = onClick),
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = bubbleShape,
) {
@@ -562,13 +603,13 @@ private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) {
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = profile?.name ?: "Unknown",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(
alpha = 0.6f
),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
text = event.content(),
@@ -1,5 +1,6 @@
package su.reya.coop.shared
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
@@ -13,6 +14,9 @@ import coil3.compose.AsyncImage
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.avatar
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalConnectivity
import su.reya.coop.LocalSettings
import su.reya.coop.MediaConfig
@Composable
fun Avatar(
@@ -22,17 +26,36 @@ fun Avatar(
size: Dp = 48.dp,
shape: Shape = CircleShape
) {
val settings = LocalSettings.current
val isMobileData = LocalConnectivity.current
val placeholder = painterResource(Res.drawable.avatar)
AsyncImage(
model = picture,
contentDescription = description,
modifier = modifier
.size(size)
.clip(shape),
contentScale = ContentScale.Crop,
fallback = placeholder,
error = placeholder,
placeholder = placeholder
)
val showMedia = when (settings.media) {
MediaConfig.AlwaysEnabled -> true
MediaConfig.Disabled -> false
MediaConfig.DisabledForMobileData -> !isMobileData
}
if (showMedia) {
AsyncImage(
model = picture,
contentDescription = description,
modifier = modifier
.size(size)
.clip(shape),
contentScale = ContentScale.Crop,
fallback = placeholder,
error = placeholder,
placeholder = placeholder
)
} else {
Image(
painter = placeholder,
contentDescription = description,
modifier = modifier
.size(size)
.clip(shape),
contentScale = ContentScale.Crop
)
}
}
+4 -1
View File
@@ -4,4 +4,7 @@ PRODUCT_NAME=Coop
PRODUCT_BUNDLE_IDENTIFIER=su.reya.coop.Coop$(TEAM_ID)
CURRENT_PROJECT_VERSION=1
MARKETING_VERSION=1.0
MARKETING_VERSION=1.0
FRAMEWORK_SEARCH_PATHS=$(inherited) "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"
OTHER_LDFLAGS=$(inherited) -framework Shared
+14
View File
@@ -0,0 +1,14 @@
import Foundation
enum AppRoute: Hashable {
case home
case requestList
case contactList
case updateProfile
case newChat
case myQr
case relay
case settings
case chat(id: Int64, screening: Bool)
case profile(pubkey: String)
}
+103
View File
@@ -0,0 +1,103 @@
import Foundation
import Shared
import UIKit
@MainActor
@Observable
final class AppState {
let bootstrap: Bootstrap
private var subscriptions: [FlowSubscription] = []
var path: [AppRoute] = []
var signerRequired: Bool?
var isSyncing = false
var partialProcessed = false
var chatRooms: [Room] = []
var accountState: AccountState?
var settings: Settings?
var currentUserProfile: Profile?
var isUpdatingProfile = false
var errorMessage: String?
let networkMonitor = NetworkMonitor()
init() {
bootstrap = Bootstrap.companion.create(storage: KeychainStorage())
}
func start() {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let dbDir = docs.appendingPathComponent("nostr", isDirectory: true)
try? FileManager.default.createDirectory(at: dbDir, withIntermediateDirectories: true)
bootstrap.start(dbPath: dbDir.path) { [weak self] event in
self?.handleNewMessage(event)
}
subscriptions.append(bootstrap.watchAccountState { [weak self] state in
self?.accountState = state
self?.signerRequired = state.signerRequired?.boolValue
})
subscriptions.append(bootstrap.watchChatRooms { [weak self] rooms in
self?.chatRooms = rooms
})
subscriptions.append(bootstrap.watchIsSyncing { [weak self] value in
self?.isSyncing = value.boolValue
})
subscriptions.append(bootstrap.watchPartialProcessed { [weak self] value in
self?.partialProcessed = value.boolValue
})
subscriptions.append(bootstrap.watchSettings { [weak self] settings in
self?.settings = settings
})
subscriptions.append(bootstrap.watchCurrentUserProfile { [weak self] profile in
self?.currentUserProfile = profile
})
subscriptions.append(bootstrap.watchIsUpdatingProfile { [weak self] value in
self?.isUpdatingProfile = value.boolValue
})
subscriptions.append(bootstrap.watchErrors { [weak self] message in
self?.errorMessage = message
})
}
func handle(_ url: URL) {
guard url.scheme == "coop" else { return }
switch url.host {
case "chat":
if let id = Int64(url.pathComponents.dropFirst().first ?? "") {
path.append(.chat(id: id, screening: false))
}
case "profile":
let pubkey = url.pathComponents.dropFirst().first ?? ""
if !pubkey.isEmpty {
path.append(.profile(pubkey: pubkey))
}
default:
break
}
}
func logout() {
bootstrap.logout()
bootstrap.resetState()
path = []
}
func resume() {
Task { try? await bootstrap.resume() }
}
func pause() {
let taskId = UIApplication.shared.beginBackgroundTask()
Task {
try? await bootstrap.pause()
UIApplication.shared.endBackgroundTask(taskId)
}
}
private func handleNewMessage(_ event: Nostr_sdk_kmpUnsignedEvent) {
NotificationService.shared.notifyNewMessage(roomId: event.roomId(), content: event.content())
}
}
+68 -20
View File
@@ -2,32 +2,80 @@ import SwiftUI
import Shared
struct ContentView: View {
@State private var showContent = false
var body: some View {
VStack {
Button("Click me!") {
withAnimation {
showContent = !showContent
}
}
@Environment(AppState.self) private var appState
if showContent {
VStack(spacing: 16) {
Image(systemName: "swift")
.font(.system(size: 200))
.foregroundColor(.accentColor)
Text("SwiftUI: \(Greeting().greet())")
var body: some View {
Group {
switch appState.signerRequired {
case .none:
SplashView()
case .some(true):
OnboardingView()
case .some(false):
NavigationStack(path: Bindable(appState).path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
destination(for: route)
}
}
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding()
.alert("Error", isPresented: Binding(
get: { appState.errorMessage != nil },
set: { if !$0 { appState.errorMessage = nil } }
)) {
Button("OK") { appState.errorMessage = nil }
} message: {
Text(appState.errorMessage ?? "")
}
.preferredColorScheme(colorScheme)
}
private var colorScheme: ColorScheme? {
switch appState.settings?.theme {
case Theme.light:
return .light
case Theme.dark:
return .dark
default:
return nil
}
}
@ViewBuilder
private func destination(for route: AppRoute) -> some View {
switch route {
case .home:
HomeView()
case .requestList:
RequestListView()
case .contactList:
ContactListView()
case .updateProfile:
UpdateProfileView()
case .newChat:
NewChatView()
case .myQr:
MyQrView()
case .relay:
RelayView()
case .settings:
SettingsView()
case .chat(let id, let screening):
ChatView(roomId: id, screening: screening)
case .profile(let pubkey):
ProfileView(pubkey: pubkey)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
struct SplashView: View {
var body: some View {
ZStack {
Color(.systemBackground).ignoresSafeArea()
Image(systemName: "bubble.left.and.bubble.right.fill")
.font(.system(size: 64))
.foregroundStyle(.tint)
}
}
}
+13
View File
@@ -4,5 +4,18 @@
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Coop uses the camera to scan QR codes for adding contacts and importing identities.</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>su.reya.coop</string>
<key>CFBundleURLSchemes</key>
<array>
<string>coop</string>
</array>
</dict>
</array>
</dict>
</plist>
@@ -0,0 +1,73 @@
import Foundation
import Security
import Shared
final class KeychainStorage: AppStorage {
private let defaults = UserDefaults.standard
private let service = "su.reya.coop"
func get(key: String, completionHandler: @escaping (String?, Error?) -> Void) {
completionHandler(defaults.string(forKey: key), nil)
}
func set(key: String, value: String, completionHandler: @escaping (Error?) -> Void) {
defaults.set(value, forKey: key)
completionHandler(nil)
}
func getSecret(key: String, completionHandler: @escaping (String?, Error?) -> Void) {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else {
completionHandler(nil, nil)
return
}
completionHandler(String(data: data, encoding: .utf8), nil)
}
func setSecret(key: String, value: String, completionHandler: @escaping (Error?) -> Void) {
let data = Data(value.utf8)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
]
if SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess {
SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary)
} else {
var add = query
add[kSecValueData] = data
add[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
SecItemAdd(add as CFDictionary, nil)
}
completionHandler(nil)
}
func clear(key: String, completionHandler: @escaping (Error?) -> Void) {
defaults.removeObject(forKey: key)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
]
SecItemDelete(query as CFDictionary)
completionHandler(nil)
}
func has(key: String, completionHandler: @escaping (KotlinBoolean?, Error?) -> Void) {
if defaults.object(forKey: key) != nil {
completionHandler(KotlinBoolean(booleanLiteral: true), nil)
return
}
getSecret(key: key) { secret, _ in
completionHandler(KotlinBoolean(booleanLiteral: secret != nil), nil)
}
}
}
@@ -0,0 +1,26 @@
import Foundation
import Shared
extension Data {
func toKotlinByteArray() -> KotlinByteArray {
let array = KotlinByteArray(size: Int32(count))
for (index, byte) in enumerated() {
array.set(index: Int32(index), value: Int8(bitPattern: byte))
}
return array
}
}
extension KotlinBoolean {
var value: Bool { boolValue }
}
extension String {
func isImageUrl() -> Bool {
ExtensionsKt.isImageUrl(self)
}
func removeImageUrls() -> String {
ExtensionsKt.removeImageUrls(self)
}
}
@@ -0,0 +1,24 @@
import Foundation
import Network
@Observable
final class NetworkMonitor {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "su.reya.coop.network")
private(set) var isMobileData = false
init() {
monitor.pathUpdateHandler = { [weak self] path in
let mobile = path.usesInterfaceType(.cellular)
Task { @MainActor in
self?.isMobileData = mobile
}
}
monitor.start(queue: queue)
}
deinit {
monitor.cancel()
}
}
@@ -0,0 +1,56 @@
import Foundation
import UserNotifications
final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationService()
private let center = UNUserNotificationCenter.current()
var onOpenChat: ((Int64) -> Void)?
override init() {
super.init()
center.delegate = self
}
func requestPermissionIfNeeded() {
center.getNotificationSettings { settings in
if settings.authorizationStatus == .notDetermined {
self.center.requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in }
}
}
}
func notifyNewMessage(roomId: Int64, content: String) {
let notificationContent = UNMutableNotificationContent()
notificationContent.title = "You received a new message"
notificationContent.body = content
notificationContent.sound = .default
notificationContent.userInfo = ["roomId": roomId]
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: notificationContent,
trigger: nil
)
center.add(request)
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
[]
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
if let roomId = response.notification.request.content.userInfo["roomId"] as? Int64 {
await MainActor.run {
onOpenChat?(roomId)
}
}
}
}
+288
View File
@@ -0,0 +1,288 @@
import PhotosUI
import SwiftUI
import Shared
struct ChatView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
let roomId: Int64
let screening: Bool
@State private var viewModel: ChatViewModel?
@State private var input = ""
@State private var photoItem: PhotosPickerItem?
@State private var authorProfiles: [String: Profile] = [:]
private var showScreener: Bool {
(viewModel?.requireScreening ?? false) && appState.settings?.screening == true
}
private var isGroup: Bool {
viewModel?.room?.isGroup() == true
}
var body: some View {
VStack(spacing: 0) {
messageList
if showScreener, let room = viewModel?.room {
ScreenerCard(
room: room,
onAccept: { viewModel?.requireScreening = false },
onReject: { dismiss() }
)
} else {
inputBar
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
if let room = viewModel?.room, let firstMember = otherMember(of: room) {
Button {
appState.path.append(.profile(pubkey: firstMember.toHex()))
} label: {
VStack(spacing: 2) {
AvatarView(
name: viewModel?.roomUi?.name ?? "?",
picture: viewModel?.roomUi?.picture,
size: 30
)
Text(viewModel?.roomUi?.name ?? "Chat")
.font(.caption)
.foregroundStyle(.primary)
}
}
} else {
Text(viewModel?.roomUi?.name ?? "Chat").font(.headline)
}
}
}
.task {
let vm = ChatViewModel(roomId: roomId, screening: screening)
viewModel = vm
vm.start(appState: appState)
}
.onDisappear {
viewModel?.stop()
}
.task(id: photoItem) {
guard let photoItem else { return }
guard let data = try? await photoItem.loadTransferable(type: Data.self) else { return }
let contentType = photoItem.supportedContentTypes.first?.preferredMIMEType ?? "image/jpeg"
viewModel?.sendImage(data, contentType: contentType, appState: appState)
self.photoItem = nil
}
}
private var messageList: some View {
ScrollView {
LazyVStack(spacing: 2) {
ForEach(groupedMessages, id: \.0) { group, messages in
Text(headerTitle(group: group, first: messages.first))
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 14)
ForEach(Array(messages.enumerated()), id: \.element.stableId) { index, event in
messageCell(event: event, at: index, in: messages)
}
}
if let last = viewModel?.messages.last, isMine(last) {
Text("Delivered")
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.top, 2)
}
}
.padding(.horizontal, 12)
}
.defaultScrollAnchor(.bottom)
.scrollDismissesKeyboard(.interactively)
.overlay {
if viewModel?.loading == true {
ProgressView()
}
}
}
@ViewBuilder
private func messageCell(
event: Nostr_sdk_kmpUnsignedEvent,
at index: Int,
in messages: [Nostr_sdk_kmpUnsignedEvent]
) -> some View {
let mine = isMine(event)
let authorHex = event.author().toHex()
let nextIsSameAuthor = index + 1 < messages.count &&
messages[index + 1].author().toHex() == authorHex
let prevIsSameAuthor = index > 0 &&
messages[index - 1].author().toHex() == authorHex
let replyId = event.tags().eventIds().first
let replied = replyId.flatMap { id in
viewModel?.messages.first { $0.id()?.toHex() == id.toHex() }
}
MessageBubble(
event: event,
isMine: mine,
showImages: showImages,
isFirstOfRun: !prevIsSameAuthor,
isLastOfRun: !nextIsSameAuthor,
showAuthorName: isGroup && !mine && !prevIsSameAuthor,
showAuthorAvatar: isGroup && !mine,
authorName: authorName(for: event),
authorPicture: authorProfiles[authorHex]?.picture,
repliedMessage: replied,
repliedAuthorName: replied.flatMap { authorName(for: $0) },
onReply: { viewModel?.replyingTo = event }
)
.padding(.bottom, nextIsSameAuthor ? 0 : 8)
}
private var inputBar: some View {
VStack(spacing: 0) {
if let replyingTo = viewModel?.replyingTo {
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.accentColor)
.frame(width: 3)
VStack(alignment: .leading, spacing: 1) {
Text("Replying to \(authorName(for: replyingTo))")
.font(.caption.bold())
Text(replyingTo.content())
.font(.caption)
.lineLimit(1)
.foregroundStyle(.secondary)
}
Spacer()
Button {
viewModel?.replyingTo = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.bar)
}
HStack(alignment: .bottom, spacing: 10) {
PhotosPicker(selection: $photoItem, matching: .images) {
Image(systemName: "plus")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(.secondary)
.frame(width: 34, height: 34)
.background(Color(.secondarySystemFill), in: Circle())
}
TextField("Message", text: $input, axis: .vertical)
.lineLimit(1...6)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background {
Capsule()
.strokeBorder(Color(.systemGray4), lineWidth: 1)
}
if !input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
viewModel?.send(input, appState: appState)
input = ""
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 32))
.foregroundStyle(Color(.systemBlue))
}
.transition(.scale.combined(with: .opacity))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.bar)
.animation(.snappy(duration: 0.2), value: input.isEmpty)
}
}
private var groupedMessages: [(String, [Nostr_sdk_kmpUnsignedEvent])] {
let messages = viewModel?.messages ?? []
var groups: [(String, [Nostr_sdk_kmpUnsignedEvent])] = []
var currentKey = ""
for message in messages {
let key = message.createdAt().formatAsGroup()
if key != currentKey {
groups.append((key, [message]))
currentKey = key
} else {
groups[groups.count - 1].1.append(message)
}
}
return groups
}
private func headerTitle(group: String, first: Nostr_sdk_kmpUnsignedEvent?) -> String {
guard let first else { return group }
return "\(group) \(first.createdAt().formatAsTime())"
}
private var showImages: Bool {
guard let media = appState.settings?.media else { return true }
switch media {
case MediaConfig.disabled:
return false
case MediaConfig.disabledformobiledata:
return !appState.networkMonitor.isMobileData
default:
return true
}
}
private func isMine(_ event: Nostr_sdk_kmpUnsignedEvent) -> Bool {
event.author().toHex() == appState.bootstrap.currentPublicKey()?.toHex()
}
private func otherMember(of room: Room) -> Nostr_sdk_kmpPublicKey? {
let selfHex = appState.bootstrap.currentPublicKey()?.toHex()
return room.members.first { $0.toHex() != selfHex } ?? room.members.first
}
private func authorName(for event: Nostr_sdk_kmpUnsignedEvent) -> String {
let hex = event.author().toHex()
if hex == appState.bootstrap.currentPublicKey()?.toHex() {
return appState.currentUserProfile?.name ?? "You"
}
if let profile = authorProfiles[hex] {
return profile.name
}
loadAuthorProfile(pubkey: event.author(), hex: hex)
return event.author().short()
}
private func loadAuthorProfile(pubkey: Nostr_sdk_kmpPublicKey, hex: String) {
guard authorProfiles[hex] == nil else { return }
let sub = appState.bootstrap.watchProfile(pubkey: pubkey) { profile in
Task { @MainActor in
if let profile {
authorProfiles[hex] = profile
}
}
}
Task { @MainActor in
try? await Task.sleep(for: .seconds(30))
sub.cancel()
}
}
}
private extension Nostr_sdk_kmpUnsignedEvent {
var stableId: String {
id()?.toHex() ?? "\(createdAt().asSecs())-\(content().hashValue)"
}
}
@@ -0,0 +1,82 @@
import SwiftUI
import Shared
@MainActor
@Observable
final class ChatViewModel {
let roomId: Int64
var messages: [Nostr_sdk_kmpUnsignedEvent] = []
var room: Room?
var roomUi: RoomUiState?
var loading = true
var replyingTo: Nostr_sdk_kmpUnsignedEvent?
var requireScreening: Bool
private var subscriptions: [FlowSubscription] = []
init(roomId: Int64, screening: Bool) {
self.roomId = roomId
self.requireScreening = screening
}
func start(appState: AppState) {
let bootstrap = appState.bootstrap
room = bootstrap.getChatRoom(id: roomId)
if let room {
subscriptions.append(bootstrap.watchRoomUi(
room: room,
currentUser: bootstrap.currentPublicKey()
) { [weak self] state in
Task { @MainActor in self?.roomUi = state }
})
}
subscriptions.append(bootstrap.watchNewEvents { [weak self] event in
Task { @MainActor in
guard let self, event.roomId() == self.roomId else { return }
if !self.messages.contains(where: { $0.id()?.toHex() == event.id()?.toHex() }) {
self.messages.append(event)
self.messages.sort { $0.createdAt().asSecs() < $1.createdAt().asSecs() }
}
appState.bootstrap.markRoomRead(id: self.roomId)
}
})
Task {
let loaded = (try? await bootstrap.loadRoomMessages(roomId: roomId)) ?? []
messages = loaded.sorted { $0.createdAt().asSecs() < $1.createdAt().asSecs() }
loading = false
bootstrap.markRoomRead(id: roomId)
bootstrap.connectRoom(id: roomId)
}
}
func stop() {
subscriptions.forEach { $0.cancel() }
subscriptions = []
}
func send(_ text: String, appState: AppState) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
if let replyTo = replyingTo, let replyId = replyTo.id() {
appState.bootstrap.sendReplyMessage(roomId: roomId, text: trimmed, replyTo: replyId)
} else {
appState.bootstrap.sendTextMessage(roomId: roomId, text: trimmed)
}
replyingTo = nil
requireScreening = false
}
func sendImage(_ data: Data, contentType: String, appState: AppState) {
appState.bootstrap.sendImageMessage(
roomId: roomId,
file: data.toKotlinByteArray(),
contentType: contentType
)
requireScreening = false
}
}
@@ -0,0 +1,177 @@
import SwiftUI
import Shared
struct MessageBubble: View {
let event: Nostr_sdk_kmpUnsignedEvent
let isMine: Bool
let showImages: Bool
let isFirstOfRun: Bool
let isLastOfRun: Bool
let showAuthorName: Bool
let showAuthorAvatar: Bool
let authorName: String?
let authorPicture: String?
let repliedMessage: Nostr_sdk_kmpUnsignedEvent?
let repliedAuthorName: String?
let onReply: () -> Void
@State private var showTimestamp = false
private var imageUrls: [URL] {
guard showImages else { return [] }
return event.content()
.components(separatedBy: .whitespacesAndNewlines)
.filter { $0.isImageUrl() }
.compactMap { URL(string: $0) }
}
private var text: String {
showImages ? event.content().removeImageUrls() : event.content()
}
var body: some View {
HStack(alignment: .bottom, spacing: 6) {
if isMine {
Spacer(minLength: 48)
} else {
avatarGutter
}
VStack(alignment: isMine ? .trailing : .leading, spacing: 2) {
if showAuthorName, let authorName {
Text(authorName)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 16)
}
if let repliedMessage {
replyPreview(repliedMessage)
}
ForEach(imageUrls, id: \.absoluteString) { url in
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFit()
case .failure:
ContentUnavailableView("Image unavailable", systemImage: "photo.badge.exclamationmark")
.frame(height: 120)
default:
ProgressView()
.frame(height: 120)
}
}
.frame(maxWidth: 260)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
if !text.isEmpty {
Text(text)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.foregroundStyle(isMine ? .white : .primary)
.background(
isMine ? Color(.systemBlue) : Color(.systemGray5),
in: BubbleShape(isMine: isMine, tail: isLastOfRun)
)
}
if showTimestamp {
Text(event.createdAt().formatAsTime())
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 16)
}
}
if !isMine { Spacer(minLength: 48) }
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeInOut(duration: 0.15)) {
showTimestamp.toggle()
}
}
.contextMenu {
Button {
UIPasteboard.general.string = event.content()
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
Button {
onReply()
} label: {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
}
}
@ViewBuilder
private var avatarGutter: some View {
if isLastOfRun, showAuthorAvatar {
AvatarView(name: authorName ?? "?", picture: authorPicture, size: 26)
} else {
Color.clear.frame(width: 26, height: 26)
}
}
private func replyPreview(_ replied: Nostr_sdk_kmpUnsignedEvent) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(repliedAuthorName ?? "Unknown")
.font(.caption2.bold())
Text(replied.content())
.font(.caption)
.lineLimit(2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
isMine ? Color(.systemBlue).opacity(0.7) : Color(.systemGray4),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.foregroundStyle(isMine ? .white : .primary)
.padding(.horizontal, 4)
}
}
struct BubbleShape: Shape {
let isMine: Bool
let tail: Bool
func path(in rect: CGRect) -> Path {
let radius: CGFloat = 18
let small: CGFloat = tail ? 4 : radius
let topLeft: CGFloat = radius
let topRight: CGFloat = radius
let bottomLeft: CGFloat = isMine ? radius : small
let bottomRight: CGFloat = isMine ? small : radius
var path = Path()
path.move(to: CGPoint(x: rect.minX + topLeft, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - topRight, y: rect.minY))
path.addArc(
center: CGPoint(x: rect.maxX - topRight, y: rect.minY + topRight),
radius: topRight, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false
)
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - bottomRight))
path.addArc(
center: CGPoint(x: rect.maxX - bottomRight, y: rect.maxY - bottomRight),
radius: bottomRight, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false
)
path.addLine(to: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY))
path.addArc(
center: CGPoint(x: rect.minX + bottomLeft, y: rect.maxY - bottomLeft),
radius: bottomLeft, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false
)
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + topLeft))
path.addArc(
center: CGPoint(x: rect.minX + topLeft, y: rect.minY + topLeft),
radius: topLeft, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false
)
path.closeSubpath()
return path
}
}
@@ -0,0 +1,96 @@
import SwiftUI
import Shared
struct ScreenerCard: View {
@Environment(AppState.self) private var appState
let room: Room
let onAccept: () -> Void
let onReject: () -> Void
@State private var profile: Profile?
@State private var isContact: Bool?
@State private var mutualCount: Int?
@State private var lastActivity: Nostr_sdk_kmpTimestamp?
@State private var subscription: FlowSubscription?
private var member: Nostr_sdk_kmpPublicKey? {
let selfHex = appState.bootstrap.currentPublicKey()?.toHex()
return room.members.first { $0.toHex() != selfHex } ?? room.members.first
}
var body: some View {
VStack(spacing: 16) {
AvatarView(
name: profile?.name ?? member?.short() ?? "?",
picture: profile?.picture,
size: 80
)
Text(profile?.name ?? member?.short() ?? "Unknown")
.font(.headline)
VStack(spacing: 8) {
indicator(
title: "In your contacts",
value: isContact.map { $0 ? "Yes" : "No" },
positive: isContact == true
)
indicator(
title: "Mutual contacts",
value: mutualCount.map { "\($0)" },
positive: (mutualCount ?? 0) > 0
)
indicator(
title: "Last public activity",
value: lastActivity?.humanReadable(),
positive: lastActivity != nil
)
}
.font(.subheadline)
HStack(spacing: 12) {
Button(role: .destructive, action: onReject) {
Text("Reject")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button(action: onAccept) {
Text("Accept")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
}
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 16))
.padding()
.task {
guard let member else { return }
subscription = appState.bootstrap.watchProfile(pubkey: member) { value in
Task { @MainActor in profile = value }
}
isContact = (try? await appState.bootstrap.verifyContact(pubkey: member))?.boolValue
mutualCount = (try? await appState.bootstrap.mutualContacts(pubkey: member))?.count
lastActivity = try? await appState.bootstrap.verifyActivity(pubkey: member)
}
.onDisappear {
subscription?.cancel()
}
}
private func indicator(title: String, value: String?, positive: Bool) -> some View {
HStack {
Image(systemName: positive ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(positive ? .green : .secondary)
Text(title)
Spacer()
if let value {
Text(value).foregroundStyle(.secondary)
} else {
ProgressView().controlSize(.mini)
}
}
}
}
@@ -0,0 +1,35 @@
import SwiftUI
struct AvatarView: View {
let name: String
let picture: String?
var size: CGFloat = 44
var body: some View {
Group {
if let picture, let url = URL(string: picture), !picture.isEmpty {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
placeholder
}
}
} else {
placeholder
}
}
.frame(width: size, height: size)
.clipShape(Circle())
}
private var placeholder: some View {
ZStack {
Circle().fill(Color(.secondarySystemFill))
Text(name.prefix(1).uppercased())
.font(.system(size: size * 0.45, weight: .semibold))
.foregroundStyle(.secondary)
}
}
}
@@ -0,0 +1,129 @@
import SwiftUI
import Shared
struct ContactListView: View {
@Environment(AppState.self) private var appState
@State private var showAddContact = false
@State private var showScanner = false
@State private var newContact = ""
@State private var validating = false
@State private var validationError: String?
@State private var removing: Nostr_sdk_kmpPublicKey?
private var contacts: [Nostr_sdk_kmpPublicKey] {
Array(appState.accountState?.contactList ?? [])
.sorted { $0.toHex() < $1.toHex() }
}
var body: some View {
List {
ForEach(contacts, id: \.self) { pubkey in
Button {
openChat(with: pubkey)
} label: {
ContactRow(pubkey: pubkey)
}
.tint(.primary)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
removing = pubkey
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
.overlay {
if contacts.isEmpty {
ContentUnavailableView(
"No contacts",
systemImage: "person.2",
description: Text("Add contacts to start chatting")
)
}
}
.navigationTitle("Contacts")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
HStack {
Button { showScanner = true } label: {
Image(systemName: "qrcode.viewfinder")
}
Button { showAddContact = true } label: {
Image(systemName: "plus")
}
}
}
}
.sheet(isPresented: $showScanner) {
ScanView { result in
showScanner = false
if let pubkey = appState.bootstrap.parsePublicKey(input: result) {
openChat(with: pubkey)
} else {
appState.errorMessage = "Invalid public key"
}
}
}
.alert("Add Contact", isPresented: $showAddContact) {
TextField("npub or user@domain", text: $newContact)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Cancel", role: .cancel) {
newContact = ""
validationError = nil
}
Button("Add") {
addContact()
}
.disabled(newContact.isEmpty || validating)
} message: {
if let validationError {
Text(validationError)
} else {
Text("Enter a nostr public key (npub) or NIP-05 address")
}
}
.alert("Remove Contact?", isPresented: Binding(
get: { removing != nil },
set: { if !$0 { removing = nil } }
)) {
Button("Cancel", role: .cancel) { removing = nil }
Button("Remove", role: .destructive) {
if let removing {
appState.bootstrap.removeContact(publicKey: removing)
}
removing = nil
}
} message: {
Text("This contact will be removed from your list.")
}
}
private func addContact() {
let value = newContact.trimmingCharacters(in: .whitespacesAndNewlines)
validating = true
Task {
defer { validating = false }
if appState.bootstrap.parsePublicKey(input: value) != nil {
appState.bootstrap.addContact(address: value)
newContact = ""
} else if value.contains("@"),
(try? await appState.bootstrap.searchByAddress(query: value)) != nil {
appState.bootstrap.addContact(address: value)
newContact = ""
} else {
validationError = "Could not find this user. Check the address and try again."
}
}
}
private func openChat(with pubkey: Nostr_sdk_kmpPublicKey) {
do {
let roomId = try appState.bootstrap.createChatRoom(recipients: [pubkey])
appState.path.append(.chat(id: roomId, screening: false))
} catch {
appState.errorMessage = error.localizedDescription
}
}
}
+118
View File
@@ -0,0 +1,118 @@
import SwiftUI
import Shared
struct HomeView: View {
@Environment(AppState.self) private var appState
@State private var showProfileSheet = false
@State private var showRelayWarning = false
private var ongoingRooms: [Room] {
appState.chatRooms.filter { $0.kind == RoomKind.ongoing }
}
private var requestRooms: [Room] {
appState.chatRooms.filter { $0.kind == RoomKind.request }
}
private var requestUnread: Int {
requestRooms.reduce(0) { $0 + Int($1.unreadCount) }
}
var body: some View {
List {
if !requestRooms.isEmpty {
Button {
appState.path.append(.requestList)
} label: {
HStack(spacing: 10) {
Circle()
.fill(requestUnread > 0 ? Color.accentColor : .clear)
.frame(width: 10, height: 10)
Image(systemName: "tray.full")
.font(.title2)
.foregroundStyle(.secondary)
.frame(width: 48, height: 48)
.background(Color(.secondarySystemFill), in: Circle())
VStack(alignment: .leading, spacing: 3) {
Text("Message Requests")
.font(.body.weight(requestUnread > 0 ? .semibold : .regular))
Text("\(requestRooms.count)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(Color(.systemGray3))
}
.padding(.vertical, 4)
}
.tint(.primary)
}
ForEach(ongoingRooms, id: \.id) { room in
Button {
appState.path.append(.chat(id: room.id, screening: false))
} label: {
RoomRow(room: room)
}
.tint(.primary)
}
}
.listStyle(.plain)
.refreshable {
appState.bootstrap.refreshChatRooms()
}
.overlay {
if !appState.partialProcessed {
ProgressView()
} else if appState.chatRooms.isEmpty {
ContentUnavailableView(
"No Messages",
systemImage: "bubble.left.and.bubble.right",
description: Text("Start a new conversation")
)
}
}
.navigationTitle("Coop")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
showProfileSheet = true
} label: {
AvatarView(
name: appState.currentUserProfile?.name ?? "?",
picture: appState.currentUserProfile?.picture,
size: 30
)
}
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
if appState.isSyncing {
ProgressView().controlSize(.small)
}
Button {
appState.path.append(.newChat)
} label: {
Image(systemName: "square.and.pencil")
}
}
}
}
.sheet(isPresented: $showProfileSheet) {
ProfileSheetView()
.presentationDetents([.medium, .large])
}
.sheet(isPresented: $showRelayWarning) {
RelayWarningSheet()
}
.onChange(of: appState.accountState?.isRelayListEmpty) { _, isEmpty in
showRelayWarning = isEmpty == true
}
}
}
@@ -0,0 +1,90 @@
import SwiftUI
struct ProfileSheetView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
@State private var showLogoutConfirm = false
var body: some View {
NavigationStack {
List {
Section {
HStack(spacing: 16) {
AvatarView(
name: appState.currentUserProfile?.name ?? "?",
picture: appState.currentUserProfile?.picture,
size: 64
)
VStack(alignment: .leading, spacing: 4) {
Text(appState.currentUserProfile?.name ?? "Loading...")
.font(.headline)
if let npub = try? appState.bootstrap.currentPublicKey()?.toBech32() {
Text(npub)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
.padding(.vertical, 4)
Button {
dismiss()
appState.path.append(.myQr)
} label: {
Label("Show QR Code", systemImage: "qrcode")
}
}
Section {
Button {
dismiss()
appState.path.append(.updateProfile)
} label: {
Label("Update Profile", systemImage: "person.crop.circle")
}
Button {
dismiss()
appState.path.append(.contactList)
} label: {
Label("Contact List", systemImage: "person.2")
}
Button {
dismiss()
appState.path.append(.relay)
} label: {
Label("Relay Management", systemImage: "globe")
}
Button {
dismiss()
appState.path.append(.settings)
} label: {
Label("Settings", systemImage: "gear")
}
}
Section {
Button(role: .destructive) {
showLogoutConfirm = true
} label: {
Label("Logout", systemImage: "rectangle.portrait.and.arrow.right")
.foregroundStyle(.red)
}
}
}
.navigationTitle("Profile")
.navigationBarTitleDisplayMode(.inline)
.alert("Logout?", isPresented: $showLogoutConfirm) {
Button("Cancel", role: .cancel) {}
Button("Logout", role: .destructive) {
dismiss()
appState.logout()
}
} message: {
Text("This will delete all local data. Make sure you have backed up your secret key.")
}
}
.tint(.primary)
}
}
@@ -0,0 +1,45 @@
import SwiftUI
struct RelayWarningSheet: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 24) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 48))
.foregroundStyle(.orange)
Text("No Messaging Relays")
.font(.title2.bold())
Text("You don't have any messaging relays configured. You won't be able to receive messages until this is resolved.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
VStack(spacing: 12) {
Button {
appState.bootstrap.refetchMsgRelays()
dismiss()
} label: {
Text("Retry")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button {
appState.bootstrap.useDefaultMsgRelayList()
dismiss()
} label: {
Text("Use Default")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.large)
}
}
.padding(32)
.presentationDetents([.medium])
}
}
@@ -0,0 +1,36 @@
import SwiftUI
import Shared
struct RequestListView: View {
@Environment(AppState.self) private var appState
private var requestRooms: [Room] {
appState.chatRooms.filter { $0.kind == RoomKind.request }
}
var body: some View {
List {
ForEach(requestRooms, id: \.id) { room in
Button {
appState.path.append(.chat(id: room.id, screening: true))
} label: {
RoomRow(room: room)
}
.tint(.primary)
}
}
.refreshable {
appState.bootstrap.refreshChatRooms()
}
.overlay {
if requestRooms.isEmpty {
ContentUnavailableView(
"No requests",
systemImage: "tray",
description: Text("New message requests will appear here")
)
}
}
.navigationTitle("Requests")
}
}
+57
View File
@@ -0,0 +1,57 @@
import SwiftUI
import Shared
struct RoomRow: View {
@Environment(AppState.self) private var appState
let room: Room
@State private var ui: RoomUiState?
@State private var subscription: FlowSubscription?
private var unread: Bool { room.unreadCount > 0 }
var body: some View {
HStack(spacing: 10) {
Circle()
.fill(unread ? Color.accentColor : .clear)
.frame(width: 10, height: 10)
AvatarView(name: ui?.name ?? "?", picture: ui?.picture, size: 48)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline) {
Text(ui?.name ?? "Loading...")
.font(.body.weight(unread ? .semibold : .regular))
.lineLimit(1)
Spacer()
Text(room.createdAt.ago())
.font(.subheadline)
.foregroundStyle(.secondary)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(Color(.systemGray3))
}
Text(room.lastMessage ?? "")
.font(.subheadline.weight(unread ? .semibold : .regular))
.foregroundStyle(unread ? .primary : .secondary)
.lineLimit(2)
}
}
.padding(.vertical, 4)
.task {
subscription = appState.bootstrap.watchRoomUi(
room: room,
currentUser: appState.bootstrap.currentPublicKey()
) { state in
Task { @MainActor in ui = state }
}
}
.onDisappear {
subscription?.cancel()
subscription = nil
}
}
}
@@ -0,0 +1,190 @@
import SwiftUI
import Shared
struct ContactRow: View {
@Environment(AppState.self) private var appState
let pubkey: Nostr_sdk_kmpPublicKey
@State private var profile: Profile?
@State private var subscription: FlowSubscription?
var body: some View {
HStack(spacing: 12) {
AvatarView(name: profile?.name ?? "?", picture: profile?.picture)
VStack(alignment: .leading, spacing: 2) {
Text(profile?.name ?? "Loading...")
.font(.headline)
.lineLimit(1)
Text(pubkey.short())
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.vertical, 2)
.task {
subscription = appState.bootstrap.watchProfile(pubkey: pubkey) { value in
Task { @MainActor in profile = value }
}
}
.onDisappear {
subscription?.cancel()
subscription = nil
}
}
}
struct NewChatView: View {
@Environment(AppState.self) private var appState
@State private var query = ""
@State private var searchResults: [Nostr_sdk_kmpPublicKey] = []
@State private var searching = false
@State private var selected: [Nostr_sdk_kmpPublicKey] = []
@State private var showScanner = false
@State private var searchTask: Task<Void, Never>?
private var contacts: [Nostr_sdk_kmpPublicKey] {
Array(appState.accountState?.contactList ?? [])
}
var body: some View {
List {
if !selected.isEmpty {
Section("To:") {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(selected, id: \.self) { pubkey in
Button {
selected.removeAll { $0.toHex() == pubkey.toHex() }
} label: {
Label(pubkey.short(), systemImage: "xmark.circle.fill")
.font(.subheadline)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color(.secondarySystemBackground), in: Capsule())
}
.tint(.primary)
}
}
}
}
}
if searching {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
let results = query.isEmpty ? contacts : searchResults
Section(query.isEmpty ? "Contacts" : "Results") {
ForEach(results, id: \.self) { pubkey in
Button {
openChat(with: pubkey)
} label: {
ContactRow(pubkey: pubkey)
}
.tint(.primary)
.swipeActions(edge: .leading) {
Button {
toggleSelection(pubkey)
} label: {
Label("Select", systemImage: "checkmark.circle")
}
.tint(.accentColor)
}
}
}
}
.navigationTitle("New Chat")
.searchable(text: $query, prompt: "npub, user@domain, or name")
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onChange(of: query) { _, newValue in
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
await performSearch(newValue)
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
HStack {
Button {
showScanner = true
} label: {
Image(systemName: "qrcode.viewfinder")
}
if !selected.isEmpty {
Button("Next") {
createGroupChat()
}
}
}
}
}
.sheet(isPresented: $showScanner) {
ScanView { result in
showScanner = false
if let pubkey = appState.bootstrap.parsePublicKey(input: result) {
openChat(with: pubkey)
} else {
appState.errorMessage = "Invalid public key"
}
}
}
}
private func performSearch(_ value: String) async {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count >= 3 else {
searchResults = []
return
}
searching = true
defer { searching = false }
if trimmed.hasPrefix("npub1") {
if let pubkey = appState.bootstrap.parsePublicKey(input: trimmed) {
searchResults = [pubkey]
}
} else if trimmed.contains("@") {
if let pubkey = try? await appState.bootstrap.searchByAddress(query: trimmed) {
searchResults = [pubkey]
} else {
searchResults = []
}
} else {
searchResults = (try? await appState.bootstrap.searchByNostr(query: trimmed)) ?? []
}
}
private func toggleSelection(_ pubkey: Nostr_sdk_kmpPublicKey) {
if let index = selected.firstIndex(where: { $0.toHex() == pubkey.toHex() }) {
selected.remove(at: index)
} else {
selected.append(pubkey)
}
}
private func openChat(with pubkey: Nostr_sdk_kmpPublicKey) {
do {
let roomId = try appState.bootstrap.createChatRoom(recipients: [pubkey])
appState.path.append(.chat(id: roomId, screening: false))
} catch {
appState.errorMessage = error.localizedDescription
}
}
private func createGroupChat() {
do {
let roomId = try appState.bootstrap.createChatRoom(recipients: selected)
appState.path.append(.chat(id: roomId, screening: false))
} catch {
appState.errorMessage = error.localizedDescription
}
}
}
@@ -0,0 +1,67 @@
import SwiftUI
struct ImportView: View {
@Environment(AppState.self) private var appState
@State private var secret = ""
@State private var password = ""
@State private var showScanner = false
private var needsPassword: Bool {
secret.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("ncryptsec1")
}
private var canImport: Bool {
!secret.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
(!needsPassword || !password.isEmpty) &&
appState.accountState?.isImporting != true
}
var body: some View {
Form {
Section {
HStack {
SecureField("nsec1... or bunker://", text: $secret)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button {
showScanner = true
} label: {
Image(systemName: "qrcode.viewfinder")
}
}
if needsPassword {
SecureField("Decrypt Password", text: $password)
}
} header: {
Text("Secret Key")
} footer: {
Text("Enter your nsec, ncryptsec (with password), or bunker:// connection string.")
}
}
.navigationTitle("Import Identity")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Import") {
appState.bootstrap.importIdentity(
secret: secret.trimmingCharacters(in: .whitespacesAndNewlines),
password: needsPassword ? password : nil
)
}
.disabled(!canImport)
}
}
.overlay {
if appState.accountState?.isImporting == true {
ProgressView()
.controlSize(.large)
}
}
.sheet(isPresented: $showScanner) {
ScanView { result in
secret = result
showScanner = false
}
}
}
}
@@ -0,0 +1,20 @@
import SwiftUI
struct NewIdentityView: View {
@Environment(AppState.self) private var appState
var body: some View {
ProfileEditorForm(
title: "Create a new identity",
confirmLabel: "Continue",
isSubmitting: appState.accountState?.isImporting == true
) { name, bio, picture, contentType in
appState.bootstrap.createIdentity(
name: name,
bio: bio,
picture: picture,
contentType: contentType
)
}
}
}
@@ -0,0 +1,69 @@
import SwiftUI
private enum OnboardingRoute: Hashable {
case newIdentity
case importIdentity
}
struct OnboardingView: View {
@State private var path: [OnboardingRoute] = []
var body: some View {
NavigationStack(path: $path) {
VStack(spacing: 32) {
Spacer()
Image(systemName: "bubble.left.and.bubble.right.fill")
.font(.system(size: 80))
.foregroundStyle(.tint)
VStack(spacing: 8) {
Text("Coop")
.font(.largeTitle.bold())
Text("Simple, fast, and reliable nostr messaging")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
Spacer()
VStack(spacing: 12) {
Button {
path.append(.newIdentity)
} label: {
Text("Start Messaging")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button {
path.append(.importIdentity)
} label: {
Text("Add an Existing Identity")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.large)
}
.padding(.horizontal)
Text("By continuing, you agree to our Terms of Service and Privacy Policy")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.padding()
.navigationDestination(for: OnboardingRoute.self) { route in
switch route {
case .newIdentity:
NewIdentityView()
case .importIdentity:
ImportView()
}
}
}
}
}
@@ -0,0 +1,103 @@
import PhotosUI
import SwiftUI
import Shared
struct ProfileEditorForm: View {
let title: String
let confirmLabel: String
let initialName: String
let initialBio: String
let initialPicture: String?
let isSubmitting: Bool
let onConfirm: (String, String?, KotlinByteArray?, String?) -> Void
@State private var name: String
@State private var bio: String
@State private var photoItem: PhotosPickerItem?
@State private var photoData: Data?
@State private var photoContentType: String?
init(
title: String,
confirmLabel: String,
initialName: String = "",
initialBio: String = "",
initialPicture: String? = nil,
isSubmitting: Bool = false,
onConfirm: @escaping (String, String?, KotlinByteArray?, String?) -> Void
) {
self.title = title
self.confirmLabel = confirmLabel
self.initialName = initialName
self.initialBio = initialBio
self.initialPicture = initialPicture
self.isSubmitting = isSubmitting
self.onConfirm = onConfirm
_name = State(initialValue: initialName)
_bio = State(initialValue: initialBio)
}
var body: some View {
Form {
Section {
HStack {
Spacer()
PhotosPicker(selection: $photoItem, matching: .images) {
if let photoData, let uiImage = UIImage(data: photoData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(width: 120, height: 120)
.clipShape(Circle())
} else {
AvatarView(name: name.isEmpty ? "?" : name, picture: initialPicture, size: 120)
.overlay(alignment: .bottomTrailing) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(.tint)
}
}
}
Spacer()
}
}
.listRowBackground(Color.clear)
Section {
TextField("What others should call you?", text: $name)
TextField("Tell others about yourself (optional)", text: $bio, axis: .vertical)
.lineLimit(3...6)
}
Section {
Button {
onConfirm(
name.trimmingCharacters(in: .whitespacesAndNewlines),
bio.isEmpty ? nil : bio,
photoData?.toKotlinByteArray(),
photoContentType
)
} label: {
HStack {
Spacer()
if isSubmitting {
ProgressView()
} else {
Text(confirmLabel)
}
Spacer()
}
}
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSubmitting)
}
}
.navigationTitle(title)
.task(id: photoItem) {
guard let photoItem else { return }
if let data = try? await photoItem.loadTransferable(type: Data.self) {
photoData = data
photoContentType = photoItem.supportedContentTypes.first?.preferredMIMEType ?? "image/jpeg"
}
}
}
}
@@ -0,0 +1,55 @@
import CoreImage.CIFilterBuiltins
import SwiftUI
struct MyQrView: View {
@Environment(AppState.self) private var appState
private var npub: String? {
try? appState.bootstrap.currentPublicKey()?.toBech32()
}
var body: some View {
VStack(spacing: 24) {
Spacer()
if let npub, let qrImage = generateQrCode(from: npub) {
Image(uiImage: qrImage)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(maxWidth: 280)
.padding()
.background(Color.white, in: RoundedRectangle(cornerRadius: 16))
Text(npub)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
ShareLink(item: npub) {
Label("Share", systemImage: "square.and.arrow.up")
}
.buttonStyle(.bordered)
} else {
ContentUnavailableView("No identity", systemImage: "qrcode")
}
Spacer()
}
.padding()
.navigationTitle("My QR Code")
.navigationBarTitleDisplayMode(.inline)
}
private func generateQrCode(from string: String) -> UIImage? {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(string.utf8)
filter.correctionLevel = "M"
guard let outputImage = filter.outputImage else { return nil }
let scaled = outputImage.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
return UIImage(cgImage: cgImage)
}
}
@@ -0,0 +1,85 @@
import SwiftUI
import Shared
struct ProfileView: View {
@Environment(AppState.self) private var appState
let pubkey: String
@State private var profile: Profile?
@State private var subscription: FlowSubscription?
@State private var parsedKey: Nostr_sdk_kmpPublicKey?
private var record: Nostr_sdk_kmpMetadataRecord? {
profile?.metadata.asRecord()
}
var body: some View {
List {
Section {
VStack(spacing: 12) {
AvatarView(
name: profile?.name ?? "?",
picture: profile?.picture,
size: 120
)
Text(profile?.name ?? "No name")
.font(.title2.bold())
Text(record?.nip05 ?? parsedKey?.short() ?? "")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
}
.listRowBackground(Color.clear)
Section("Details") {
LabeledContent("Username", value: record?.name ?? "None")
LabeledContent("Website", value: record?.website ?? "None")
LabeledContent("Lightning Address", value: record?.lud16 ?? "None")
}
Section {
Button {
openChat()
} label: {
Label("Message", systemImage: "message")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
if let npub = try? parsedKey?.toBech32() {
ShareLink(item: npub) {
Label("Share", systemImage: "square.and.arrow.up")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
.listRowBackground(Color.clear)
}
.navigationTitle("Profile")
.navigationBarTitleDisplayMode(.inline)
.task {
parsedKey = appState.bootstrap.parsePublicKey(input: pubkey)
guard let parsedKey else { return }
subscription = appState.bootstrap.watchProfile(pubkey: parsedKey) { value in
Task { @MainActor in profile = value }
}
}
.onDisappear {
subscription?.cancel()
}
}
private func openChat() {
guard let parsedKey else { return }
do {
let roomId = try appState.bootstrap.createChatRoom(recipients: [parsedKey])
appState.path.append(.chat(id: roomId, screening: false))
} catch {
appState.errorMessage = error.localizedDescription
}
}
}
@@ -0,0 +1,30 @@
import SwiftUI
import Shared
struct UpdateProfileView: View {
@Environment(AppState.self) private var appState
@Environment(\.dismiss) private var dismiss
private var record: Nostr_sdk_kmpMetadataRecord? {
appState.currentUserProfile?.metadata.asRecord()
}
var body: some View {
ProfileEditorForm(
title: "Update Profile",
confirmLabel: "Save changes",
initialName: record?.displayName ?? record?.name ?? "",
initialBio: record?.about ?? "",
initialPicture: appState.currentUserProfile?.picture,
isSubmitting: appState.isUpdatingProfile
) { name, bio, picture, contentType in
appState.bootstrap.updateProfile(
name: name,
bio: bio,
picture: picture,
contentType: contentType
)
dismiss()
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import SwiftUI
import VisionKit
struct ScanView: View {
@Environment(\.dismiss) private var dismiss
let onResult: (String) -> Void
var body: some View {
ZStack(alignment: .top) {
if DataScannerViewController.isSupported && DataScannerViewController.isAvailable {
ScannerRepresentable(onResult: onResult)
.ignoresSafeArea()
} else {
ContentUnavailableView(
"Scanner unavailable",
systemImage: "camera.fill",
description: Text("This device does not support the data scanner")
)
}
VStack {
Spacer()
RoundedRectangle(cornerRadius: 16)
.strokeBorder(Color.white, lineWidth: 3)
.frame(width: 250, height: 250)
.background(.clear)
Spacer()
}
.allowsHitTesting(false)
}
.overlay(alignment: .topLeading) {
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title)
.foregroundStyle(.white)
.padding()
}
}
}
}
private struct ScannerRepresentable: UIViewControllerRepresentable {
let onResult: (String) -> Void
func makeUIViewController(context: Context) -> DataScannerViewController {
let scanner = DataScannerViewController(
recognizedDataTypes: [.barcode(symbologies: [.qr])],
qualityLevel: .balanced,
recognizesMultipleItems: false,
isHighFrameRateTrackingEnabled: false,
isHighlightingEnabled: true
)
scanner.delegate = context.coordinator
try? scanner.startScanning()
return scanner
}
func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onResult: onResult)
}
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
let onResult: (String) -> Void
private var handled = false
init(onResult: @escaping (String) -> Void) {
self.onResult = onResult
}
func dataScanner(
_ dataScanner: DataScannerViewController,
didAdd addedItems: [RecognizedItem],
allItems: [RecognizedItem]
) {
guard !handled,
case .barcode(let barcode) = addedItems.first,
let payload = barcode.payloadStringValue
else { return }
handled = true
onResult(payload)
}
}
}
@@ -0,0 +1,169 @@
import SwiftUI
import Shared
private enum RelayRole: String, CaseIterable, Identifiable {
case messaging = "Messaging"
case inbox = "Inbox"
case outbox = "Outbox"
var id: String { rawValue }
}
struct RelayView: View {
@Environment(AppState.self) private var appState
@State private var lists: RelayLists?
@State private var subscription: FlowSubscription?
@State private var showAddRelay = false
@State private var newRelay = ""
@State private var newRelayRole = RelayRole.messaging
@State private var addError: String?
var body: some View {
List {
if let lists {
if !lists.messaging.isEmpty {
Section("Messaging Relays") {
ForEach(lists.messaging, id: \.self) { relay in
relayRow(relay)
.swipeActions(edge: .trailing) {
if lists.messaging.count > 1 {
Button(role: .destructive) {
appState.bootstrap.removeMsgRelay(relay: relay.description())
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
}
}
if !lists.inbox.isEmpty {
Section("Inbox Relays") {
ForEach(lists.inbox, id: \.self) { relay in
relayRow(relay)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
appState.bootstrap.removeRelay(relay: relay.description())
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
}
if !lists.outbox.isEmpty {
Section("Outbox Relays") {
ForEach(lists.outbox, id: \.self) { relay in
relayRow(relay)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
appState.bootstrap.removeRelay(relay: relay.description())
} label: {
Label("Remove", systemImage: "trash")
}
}
}
}
}
} else {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
}
.navigationTitle("Relays")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { showAddRelay = true } label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showAddRelay) {
NavigationStack {
Form {
Section("Relay URL") {
TextField("wss://relay.example.com", text: $newRelay)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
}
Section("Role") {
Picker("Role", selection: $newRelayRole) {
ForEach(RelayRole.allCases) { role in
Text(role.rawValue).tag(role)
}
}
.pickerStyle(.inline)
.labelsHidden()
}
if let addError {
Section {
Text(addError).foregroundStyle(.red)
}
}
}
.navigationTitle("Add Relay")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
showAddRelay = false
newRelay = ""
addError = nil
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Add") {
addRelay()
}
.disabled(!newRelay.hasPrefix("wss://"))
}
}
}
.presentationDetents([.medium])
}
.task {
appState.bootstrap.loadRelayLists()
subscription = appState.bootstrap.watchRelayLists { value in
Task { @MainActor in lists = value }
}
}
.onDisappear {
subscription?.cancel()
}
}
private func relayRow(_ relay: Nostr_sdk_kmpRelayUrl) -> some View {
HStack {
Image(systemName: "globe")
.foregroundStyle(.secondary)
Text(relay.description())
.font(.subheadline)
.lineLimit(1)
}
}
private func addRelay() {
let url = newRelay.trimmingCharacters(in: .whitespacesAndNewlines)
guard url.hasPrefix("wss://") else {
addError = "Relay URL must start with wss://"
return
}
switch newRelayRole {
case .messaging:
appState.bootstrap.addMsgRelay(relay: url)
case .inbox:
appState.bootstrap.addInboxRelay(relay: url)
case .outbox:
appState.bootstrap.addOutboxRelay(relay: url)
}
showAddRelay = false
newRelay = ""
addError = nil
}
}
@@ -0,0 +1,71 @@
import SwiftUI
import Shared
struct SettingsView: View {
@Environment(AppState.self) private var appState
@State private var blossomServer = ""
private var settings: Settings? {
appState.settings
}
var body: some View {
Form {
Section("General") {
Toggle("Filter unknown contacts", isOn: Binding(
get: { settings?.screening == true },
set: { appState.bootstrap.setScreening(enabled: $0) }
))
Picker("Media Preview", selection: Binding(
get: { settings?.media ?? MediaConfig.alwaysenabled },
set: { appState.bootstrap.setMediaConfig(media: $0) }
)) {
Text("Disabled").tag(MediaConfig.disabled)
Text("Disabled for Mobile Data").tag(MediaConfig.disabledformobiledata)
Text("Always Enabled").tag(MediaConfig.alwaysenabled)
}
HStack {
Text("Blossom Server")
Spacer()
TextField("https://blossom.band", text: $blossomServer)
.multilineTextAlignment(.trailing)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.foregroundStyle(.secondary)
.onSubmit {
appState.bootstrap.setBlossomServer(
url: blossomServer.isEmpty ? nil : blossomServer
)
}
}
Button {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
} label: {
Label("Notifications", systemImage: "bell")
}
.tint(.primary)
}
Section("Appearance") {
Picker("Theme", selection: Binding(
get: { settings?.theme ?? Theme.system },
set: { appState.bootstrap.setTheme(theme: $0) }
)) {
Text("Light").tag(Theme.light)
Text("Dark").tag(Theme.dark)
Text("System").tag(Theme.system)
}
}
}
.navigationTitle("Settings")
.onAppear {
blossomServer = settings?.blossomServer ?? ""
}
}
}
+25 -1
View File
@@ -2,9 +2,33 @@ import SwiftUI
@main
struct iOSApp: App {
@State private var appState = AppState()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
.environment(appState)
.onAppear {
appState.start()
NotificationService.shared.requestPermissionIfNeeded()
NotificationService.shared.onOpenChat = { roomId in
appState.path.append(.chat(id: roomId, screening: false))
}
}
.onOpenURL { url in
appState.handle(url)
}
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active:
appState.resume()
case .background:
appState.pause()
default:
break
}
}
}
}
}
}
+1
View File
@@ -22,6 +22,7 @@ kotlin {
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
binaryOptions["objcExportSuspendFunctionLaunchThreadRestriction"] = "none"
}
}
@@ -0,0 +1,7 @@
package su.reya.coop
import kotlinx.coroutines.flow.StateFlow
interface ConnectivityMonitor {
val isMobileData: StateFlow<Boolean>
}
@@ -30,7 +30,8 @@ data class Room(
val subject: String?,
val members: Set<PublicKey>,
val kind: RoomKind = RoomKind.default(),
val lastMessage: String? = null
val lastMessage: String? = null,
val unreadCount: Int = 0
) : Comparable<Room> {
override fun compareTo(other: Room): Int {
return this.createdAt.asSecs().compareTo(other.createdAt.asSecs())
@@ -0,0 +1,22 @@
package su.reya.coop
import kotlinx.serialization.Serializable
@Serializable
data class Settings(
val theme: Theme = Theme.System,
val dynamicColor: Boolean = true,
val media: MediaConfig = MediaConfig.AlwaysEnabled,
val screening: Boolean = true,
val blossomServer: String? = "https://blossom.band",
)
@Serializable
enum class Theme {
Light, Dark, System
}
@Serializable
enum class MediaConfig {
Disabled, DisabledForMobileData, AlwaysEnabled
}
@@ -154,7 +154,10 @@ class Nostr(
// Trigger new message notification
if (rumor != null) {
if (rumor.createdAt().asSecs() >= now.asSecs()) {
val isSelfMessage = rumor.author() == signer.publicKeyFlow.value
val isNew = rumor.createdAt().asSecs() >= now.asSecs()
if (isNew && !isSelfMessage) {
onNewMessage(rumor)
}
}
@@ -22,13 +22,15 @@ import kotlin.time.Duration
class RelayManager(private val nostr: Nostr) {
companion object {
val BOOTSTRAP_RELAYS = listOf(
"wss://relay.primal.net",
"wss://relay.ditto.pub",
"wss://user.kindpag.es",
"wss://relay.primal.net",
"wss://relay.nostr.net",
"wss://profiles.nostr1.com",
)
val INDEXER_RELAY = listOf(
"wss://indexer.coracle.social",
"wss://user.kindpag.es",
)
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
@@ -47,13 +47,17 @@ data class AccountState(
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
val isImporting: Boolean = false,
val importError: String? = null,
val isRelayListEmpty: Boolean = false,
val contactList: Set<PublicKey> = emptySet(),
val userRelayList: Map<RelayUrl, RelayMetadata?> = emptyMap(),
val userMsgRelayList: List<RelayUrl> = emptyList(),
)
class AccountRepository(
private val nostr: Nostr,
private val storage: AppStorage,
private val mediaRepository: MediaRepository,
private val settingsRepository: SettingsRepository,
private val scope: CoroutineScope,
private val externalSignerHandler: ExternalSignerHandler? = null,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
@@ -72,23 +76,9 @@ class AccountRepository(
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
.flatMapLatest { pubkey ->
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
}
.flatMapLatest { if (it != null) currentUserProfileFlow(it) else flowOf(null) }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null)
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList: StateFlow<Set<PublicKey>> = _contactList.asStateFlow()
private val _isRelayListEmpty = MutableStateFlow(false)
val isRelayListEmpty: StateFlow<Boolean> = _isRelayListEmpty.asStateFlow()
private val _userRelayList = MutableStateFlow<Map<RelayUrl, RelayMetadata?>>(emptyMap())
val userRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> = _userRelayList.asStateFlow()
private val _userMsgRelayList = MutableStateFlow<List<RelayUrl>>(emptyList())
val userMsgRelayList: StateFlow<List<RelayUrl>> = _userMsgRelayList.asStateFlow()
init {
checkNotificationBannerDismissedStatus()
login()
@@ -116,7 +106,7 @@ class AccountRepository(
} ?: emptyList()
// Automatically update the warning state
_isRelayListEmpty.value = relays.isEmpty()
_state.update { it.copy(isRelayListEmpty = relays.isEmpty()) }
}
}
}
@@ -229,7 +219,7 @@ class AccountRepository(
fun importIdentity(secret: String, password: String? = null) {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
_state.update { it.copy(isImporting = true) }
try {
val (signer, decryptedSecret) = createSigner(secret, password)
@@ -239,14 +229,14 @@ class AccountRepository(
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Import failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
_state.update { it.copy(isImporting = false) }
}
}
}
fun connectExternalSigner() {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
_state.update { it.copy(isImporting = true) }
try {
val handler =
externalSignerHandler ?: throw IllegalStateException("Signer not available")
@@ -276,7 +266,7 @@ class AccountRepository(
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("External signer connection failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
_state.update { it.copy(isImporting = false) }
}
}
}
@@ -288,7 +278,7 @@ class AccountRepository(
contentType: String? = null
) {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
_state.update { it.copy(isImporting = true) }
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
@@ -307,7 +297,7 @@ class AccountRepository(
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Identity creation failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
_state.update { it.copy(isImporting = false) }
}
}
}
@@ -355,14 +345,13 @@ class AccountRepository(
scope.launch {
nostr.waitUntilInitialized()
nostr.profiles.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
_state.update { it.copy(contactList = contacts.toSet()) }
}
}
}
fun resetInternalState() {
_contactList.value = emptySet()
_isRelayListEmpty.value = false
_state.update { it.copy(contactList = emptySet(), isRelayListEmpty = false) }
}
fun addContact(address: String) {
@@ -378,12 +367,12 @@ class AccountRepository(
return@launch
}
if (pubkey in _contactList.value) return@launch
if (pubkey in _state.value.contactList) return@launch
try {
val updated = _contactList.value + pubkey
val updated = _state.value.contactList + pubkey
nostr.profiles.setContactList(updated.toList())
_contactList.update { it + pubkey }
_state.update { it.copy(contactList = it.contactList + pubkey) }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
@@ -392,12 +381,12 @@ class AccountRepository(
fun removeContact(publicKey: PublicKey) {
scope.launch {
if (publicKey !in _contactList.value) return@launch
if (publicKey !in _state.value.contactList) return@launch
try {
val updated = _contactList.value - publicKey
val updated = _state.value.contactList - publicKey
nostr.profiles.setContactList(updated.toList())
_contactList.update { it - publicKey }
_state.update { it.copy(contactList = it.contactList - publicKey) }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
@@ -460,7 +449,7 @@ class AccountRepository(
}
fun dismissRelayWarning() {
_isRelayListEmpty.value = false
_state.update { it.copy(isRelayListEmpty = false) }
}
fun refetchMsgRelays() {
@@ -487,7 +476,8 @@ class AccountRepository(
scope.launch {
try {
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_userRelayList.value = nostr.relays.getRelayList(user)
val relayList = nostr.relays.getRelayList(user)
_state.update { it.copy(userRelayList = relayList) }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
@@ -550,7 +540,8 @@ class AccountRepository(
scope.launch {
try {
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_userMsgRelayList.value = nostr.relays.getMsgRelays(user)
val msgRelays = nostr.relays.getMsgRelays(user)
_state.update { it.copy(userMsgRelayList = msgRelays) }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
@@ -21,6 +22,7 @@ import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Room
import su.reya.coop.RoomKind
import su.reya.coop.nostr.Nostr
import su.reya.coop.roomId
import su.reya.coop.viewmodel.ErrorHost
@@ -34,15 +36,12 @@ data class ChatState(
class ChatRepository(
private val nostr: Nostr,
private val mediaRepository: MediaRepository,
private val settingsRepository: SettingsRepository,
private val scope: CoroutineScope,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() {
private val _state = MutableStateFlow(ChatState())
val state = _state.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
ChatState()
)
val state = _state.asStateFlow()
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
replay = 0,
@@ -131,13 +130,39 @@ class ChatRepository(
return _state.value.rooms[id]
}
fun markAsRead(roomId: Long) {
_state.update { currentState ->
val rooms = currentState.rooms.toMutableMap()
val room = rooms[roomId]
if (room != null && room.unreadCount > 0) {
rooms[roomId] = room.copy(unreadCount = 0)
currentState.copy(rooms = rooms)
} else {
currentState
}
}
}
fun refreshChatRooms() {
scope.launch(defaultDispatcher) {
try {
val rooms = nostr.messages.getChatRooms() ?: emptySet()
val dbRooms = nostr.messages.getChatRooms() ?: emptySet()
_state.update { currentState ->
val newMap = currentState.rooms.toMutableMap()
rooms.forEach { room -> newMap[room.id] = room }
dbRooms.forEach { dbRoom ->
val existing = newMap[dbRoom.id]
// Only update if the database version is newer or equal
if (existing == null || dbRoom.createdAt.asSecs() >= existing.createdAt.asSecs()) {
// Preserve Ongoing kind and unreadCount status if already marked as such in memory
val mergedKind =
if (existing?.kind == RoomKind.Ongoing) RoomKind.Ongoing else dbRoom.kind
val mergedUnreadCount = existing?.unreadCount ?: 0
newMap[dbRoom.id] = dbRoom.copy(
kind = mergedKind,
unreadCount = mergedUnreadCount
)
}
}
currentState.copy(rooms = newMap)
}
} catch (e: CancellationException) {
@@ -223,16 +248,28 @@ class ChatRepository(
val rooms = currentState.rooms.toMutableMap()
val existingRoom = rooms[roomId]
val isFromMe = event.author() == currentUser
val newKind =
if (isFromMe) RoomKind.Ongoing else (existingRoom?.kind ?: RoomKind.Request)
if (existingRoom == null) {
// New room discovery
val newRoom = Room.new(event, currentUser, roomId)
val newRoom = Room.new(event, currentUser, roomId).copy(
kind = newKind,
unreadCount = if (isFromMe) 0 else 1
)
rooms[newRoom.id] = newRoom
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
// Only update preview if message is newer (handles sync/late arrivals)
rooms[roomId] = existingRoom.copy(
lastMessage = event.content(),
createdAt = event.createdAt()
createdAt = event.createdAt(),
kind = newKind,
unreadCount = if (isFromMe) existingRoom.unreadCount else existingRoom.unreadCount + 1
)
} else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) {
// Even if it's an older message, if it's from me, the room is ongoing
rooms[roomId] = existingRoom.copy(kind = RoomKind.Ongoing)
} else {
// Don't update the room list state for older messages
return@update currentState
@@ -8,7 +8,9 @@ import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner
import su.reya.coop.blossom.BlossomClient
class MediaRepository {
class MediaRepository(
private val settingsRepository: SettingsRepository
) {
private val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json {
@@ -25,12 +27,9 @@ class MediaRepository {
contentType: String? = "image/jpeg"
): String? {
return try {
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient)
val descriptor = blossom.upload(
file = file,
contentType = contentType,
signer = signer,
)
val url = settingsRepository.settings.value.blossomServer ?: "https://blossom.band"
val blossom = BlossomClient(url, httpClient)
val descriptor = blossom.upload(file = file, contentType = contentType, signer = signer)
descriptor?.url
} catch (e: CancellationException) {
throw e
@@ -0,0 +1,61 @@
package su.reya.coop.repository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import su.reya.coop.AppStorage
import su.reya.coop.Settings
class SettingsRepository(
private val storage: AppStorage,
private val scope: CoroutineScope
) {
companion object {
private const val KEY_SETTINGS = "app_settings"
}
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _settings = MutableStateFlow(Settings())
val settings: StateFlow<Settings> = _settings.asStateFlow()
init {
scope.launch {
_settings.value = load()
}
}
fun update(transform: (Settings) -> Settings) {
scope.launch {
val newSettings = transform(_settings.value)
_settings.value = newSettings
save(newSettings)
}
}
private suspend fun save(settings: Settings) {
val jsonString = json.encodeToString(settings)
storage.set(KEY_SETTINGS, jsonString)
}
private suspend fun load(): Settings {
val jsonString = storage.get(KEY_SETTINGS)
return if (jsonString != null) {
try {
json.decodeFromString<Settings>(jsonString)
} catch (_: Exception) {
Settings()
}
} else {
Settings()
}
}
}
@@ -3,8 +3,6 @@ package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Timestamp
import su.reya.coop.Profile
import su.reya.coop.repository.AccountRepository
@@ -16,10 +14,6 @@ class AccountViewModel(
val state: StateFlow<AccountState> = repository.state
val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile
val currentUserProfile: StateFlow<Profile?> = repository.currentUserProfile
val contactList: StateFlow<Set<PublicKey>> = repository.contactList
val isRelayListEmpty: StateFlow<Boolean> = repository.isRelayListEmpty
val userRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> = repository.userRelayList
val userMsgRelayList: StateFlow<List<RelayUrl>> = repository.userMsgRelayList
fun logout(onLogout: () -> Unit = {}) = repository.logout(onLogout)
fun dismissNotificationBanner() = repository.dismissNotificationBanner()
@@ -42,6 +42,8 @@ class ChatScreenViewModel(
messages.clear()
messages.addAll(initialMessages.distinctBy { it.id() })
loading = false
// Mark the room as read once messages are loaded
chatRepository.markAsRead(id)
}
}
@@ -55,6 +57,7 @@ class ChatScreenViewModel(
if (event.roomId() == id) {
if (messages.none { it.id() == event.id() }) {
messages.add(0, event)
chatRepository.markAsRead(id)
}
} else {
newOtherMessages++
@@ -0,0 +1,16 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow
import su.reya.coop.Settings
import su.reya.coop.repository.SettingsRepository
class SettingsViewModel(
private val repository: SettingsRepository
) : ViewModel() {
val settings: StateFlow<Settings> = repository.settings
fun update(transform: (Settings) -> Settings) {
repository.update(transform)
}
}
@@ -0,0 +1,306 @@
package su.reya.coop
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Filter
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.NostrManager
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.AccountState
import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.MediaRepository
import su.reya.coop.repository.SettingsRepository
import su.reya.coop.viewmodel.ProfileCache
import kotlin.coroutines.resume
class FlowSubscription(private val job: Job) {
fun cancel() {
job.cancel()
}
}
data class RelayLists(
val messaging: List<RelayUrl>,
val inbox: List<RelayUrl>,
val outbox: List<RelayUrl>,
)
class Bootstrap private constructor(
val scope: CoroutineScope,
val nostr: Nostr,
val settingsRepository: SettingsRepository,
val accountRepository: AccountRepository,
val chatRepository: ChatRepository,
val profileCache: ProfileCache,
) {
companion object {
fun create(storage: AppStorage): Bootstrap {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val nostr = NostrManager.instance
val settingsRepository = SettingsRepository(storage, scope)
val mediaRepository = MediaRepository(settingsRepository)
val accountRepository = AccountRepository(
nostr = nostr,
storage = storage,
mediaRepository = mediaRepository,
settingsRepository = settingsRepository,
scope = scope,
externalSignerHandler = null,
)
val chatRepository = ChatRepository(nostr, mediaRepository, settingsRepository, scope)
val profileCache = ProfileCache(nostr)
return Bootstrap(
scope = scope,
nostr = nostr,
settingsRepository = settingsRepository,
accountRepository = accountRepository,
chatRepository = chatRepository,
profileCache = profileCache,
)
}
}
private var notificationsJob: Job? = null
private var dbPath: String? = null
private var onNewMessage: ((UnsignedEvent) -> Unit)? = null
fun start(dbPath: String, onNewMessage: (UnsignedEvent) -> Unit) {
this.dbPath = dbPath
this.onNewMessage = onNewMessage
startNotificationLoop()
}
private fun startNotificationLoop() {
if (notificationsJob?.isActive == true) return
val path = dbPath ?: return
val messageCallback = onNewMessage ?: return
notificationsJob = scope.launch {
runCatching {
nostr.init(path)
nostr.connectBootstrapRelays()
nostr.handleNotifications(
onMetadataUpdate = { pubkey, metadata ->
scope.launch { nostr.profiles.emitMetadataUpdate(pubkey, metadata) }
},
onContactListUpdate = { contacts ->
scope.launch { nostr.profiles.emitContactListUpdate(contacts) }
},
onNewMessage = { event ->
nostr.emitNewEvent(event)
messageCallback(event)
},
)
}.onFailure {
accountRepository.showError("Failed to start Nostr: ${it.message}")
}
}
}
suspend fun resume() {
startNotificationLoop()
nostr.waitUntilInitialized()
nostr.client?.connect()
val pubkey = nostr.signer.publicKeyFlow.value ?: return
runCatching { nostr.profiles.getUserMetadata() }
val relays = nostr.relays.getMsgRelays(pubkey)
if (relays.isEmpty()) return
relays.forEach { relay ->
nostr.client?.addRelay(relay)
nostr.client?.connectRelay(relay)
}
nostr.messages.updateSyncState { it.copy(isSyncing = true) }
val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(pubkey)
val target = relays.associateWith { listOf(filter) }
nostr.client?.subscribe(target = ReqTarget.manual(target), id = "gift-wraps")
}
suspend fun pause() {
nostr.client?.disconnect()
}
private fun <T> Flow<T>.watch(onEach: (T) -> Unit): FlowSubscription {
val job = scope.launch(Dispatchers.Main) { collect { onEach(it) } }
return FlowSubscription(job)
}
fun watchAccountState(onEach: (AccountState) -> Unit): FlowSubscription =
accountRepository.state.watch(onEach)
fun watchIsUpdatingProfile(onEach: (Boolean) -> Unit): FlowSubscription =
accountRepository.isUpdatingProfile.watch(onEach)
fun watchCurrentUserProfile(onEach: (Profile?) -> Unit): FlowSubscription =
accountRepository.currentUserProfile.watch(onEach)
fun watchChatRooms(onEach: (List<Room>) -> Unit): FlowSubscription =
chatRepository.chatRooms.watch(onEach)
fun watchIsSyncing(onEach: (Boolean) -> Unit): FlowSubscription =
chatRepository.isSyncing.watch(onEach)
fun watchPartialProcessed(onEach: (Boolean) -> Unit): FlowSubscription =
chatRepository.isPartialProcessedGiftWrap.watch(onEach)
fun watchSettings(onEach: (Settings) -> Unit): FlowSubscription =
settingsRepository.settings.watch(onEach)
fun watchNewEvents(onEach: (UnsignedEvent) -> Unit): FlowSubscription =
chatRepository.newEvents.watch(onEach)
fun watchErrors(onEach: (String) -> Unit): FlowSubscription =
merge(
accountRepository.errorEvents,
chatRepository.errorEvents,
profileCache.errorEvents,
).watch(onEach)
fun watchProfile(pubkey: PublicKey, onEach: (Profile?) -> Unit): FlowSubscription =
profileCache.getMetadata(pubkey).watch(onEach)
fun watchRoomUi(
room: Room,
currentUser: PublicKey?,
onEach: (RoomUiState) -> Unit
): FlowSubscription =
room.uiStateFlow(profileCache, currentUser).watch(onEach)
@Throws(IllegalArgumentException::class)
fun createChatRoom(recipients: List<PublicKey>): Long =
chatRepository.createChatRoom(recipients)
fun getChatRoom(id: Long): Room? = chatRepository.getChatRoom(id)
fun markRoomRead(id: Long) = chatRepository.markAsRead(id)
fun refreshChatRooms() = chatRepository.refreshChatRooms()
fun connectRoom(id: Long) = chatRepository.chatRoomConnect(id)
fun sendTextMessage(roomId: Long, text: String) =
chatRepository.sendMessage(roomId, text, emptyList())
fun sendReplyMessage(roomId: Long, text: String, replyTo: EventId) =
chatRepository.sendMessage(roomId, text, listOf(replyTo))
fun sendImageMessage(roomId: Long, file: ByteArray, contentType: String) =
chatRepository.sendFileMessage(roomId, file, contentType, emptyList())
suspend fun loadRoomMessages(roomId: Long): List<UnsignedEvent> =
suspendCancellableCoroutine { cont ->
chatRepository.loadChatRoomMessages(roomId) { cont.resume(it) }
}
fun importIdentity(secret: String, password: String?) =
accountRepository.importIdentity(secret, password)
fun createIdentity(name: String, bio: String?, picture: ByteArray?, contentType: String?) =
accountRepository.createIdentity(name, bio, picture, contentType)
fun updateProfile(name: String?, bio: String?, picture: ByteArray?, contentType: String?) =
accountRepository.updateProfile(name, bio, picture, contentType)
fun logout() = accountRepository.logout {}
fun dismissNotificationBanner() = accountRepository.dismissNotificationBanner()
fun addContact(address: String) = accountRepository.addContact(address)
fun removeContact(publicKey: PublicKey) = accountRepository.removeContact(publicKey)
suspend fun searchByAddress(query: String): PublicKey? =
suspendCancellableCoroutine { cont ->
accountRepository.searchByAddress(query) { cont.resume(it) }
}
suspend fun searchByNostr(query: String): List<PublicKey> =
suspendCancellableCoroutine { cont ->
accountRepository.searchByNostr(query) { cont.resume(it) }
}
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? =
suspendCancellableCoroutine { cont ->
accountRepository.verifyActivity(pubkey) { cont.resume(it) }
}
suspend fun verifyContact(pubkey: PublicKey): Boolean =
suspendCancellableCoroutine { cont ->
accountRepository.verifyContact(pubkey) { cont.resume(it) }
}
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> =
suspendCancellableCoroutine { cont ->
accountRepository.mutualContacts(pubkey) { cont.resume(it) }
}
fun refetchMsgRelays() = accountRepository.refetchMsgRelays()
fun useDefaultMsgRelayList() = accountRepository.useDefaultMsgRelayList()
fun loadRelayLists() {
accountRepository.loadCurrentUserRelayList()
accountRepository.loadCurrentUserMsgRelayList()
}
fun watchRelayLists(onEach: (RelayLists) -> Unit): FlowSubscription =
accountRepository.state.map { state ->
RelayLists(
messaging = state.userMsgRelayList,
inbox = state.userRelayList
.filter { it.value == RelayMetadata.READ || it.value == null }
.keys.toList(),
outbox = state.userRelayList
.filter { it.value == RelayMetadata.WRITE || it.value == null }
.keys.toList(),
)
}.watch(onEach)
fun addMsgRelay(relay: String) = accountRepository.addMsgRelay(relay)
fun removeMsgRelay(relay: String) = accountRepository.removeMsgRelay(relay)
fun addInboxRelay(relay: String) = accountRepository.addInboxRelay(relay)
fun addOutboxRelay(relay: String) = accountRepository.addOutboxRelay(relay)
fun removeRelay(relay: String) = accountRepository.removeRelay(relay)
fun setTheme(theme: Theme) = settingsRepository.update { it.copy(theme = theme) }
fun setMediaConfig(media: MediaConfig) = settingsRepository.update { it.copy(media = media) }
fun setScreening(enabled: Boolean) = settingsRepository.update { it.copy(screening = enabled) }
fun setBlossomServer(url: String?) = settingsRepository.update { it.copy(blossomServer = url) }
fun parsePublicKey(input: String): PublicKey? =
runCatching { PublicKey.parse(input.trim()) }.getOrNull()
fun currentPublicKey(): PublicKey? = nostr.signer.publicKeyFlow.value
fun resetState() {
accountRepository.resetInternalState()
chatRepository.resetInternalState()
}
}