Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eb4dd8e81 | ||
|
|
e3fec77359 | ||
|
|
7009dbf692 | ||
|
|
9a2bd3816f | ||
|
|
44c743d039 | ||
|
|
a124dc4cc4 | ||
|
|
4a8134dec0 | ||
|
|
07085449a4 | ||
|
|
9defad522c |
@@ -17,3 +17,4 @@ captures
|
|||||||
!*.xcworkspace/contents.xcworkspacedata
|
!*.xcworkspace/contents.xcworkspacedata
|
||||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.artifacts/
|
||||||
@@ -69,7 +69,7 @@ android {
|
|||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.2.5"
|
versionName = "0.2.6"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<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" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
@@ -40,6 +39,7 @@ import androidx.navigation3.ui.NavDisplay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import su.reya.coop.repository.AccountRepository
|
import su.reya.coop.repository.AccountRepository
|
||||||
import su.reya.coop.repository.ChatRepository
|
import su.reya.coop.repository.ChatRepository
|
||||||
|
import su.reya.coop.repository.SettingsRepository
|
||||||
import su.reya.coop.screens.ContactListScreen
|
import su.reya.coop.screens.ContactListScreen
|
||||||
import su.reya.coop.screens.HomeScreen
|
import su.reya.coop.screens.HomeScreen
|
||||||
import su.reya.coop.screens.ImportScreen
|
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.RelayScreen
|
||||||
import su.reya.coop.screens.RequestListScreen
|
import su.reya.coop.screens.RequestListScreen
|
||||||
import su.reya.coop.screens.ScanScreen
|
import su.reya.coop.screens.ScanScreen
|
||||||
|
import su.reya.coop.screens.SettingsScreen
|
||||||
import su.reya.coop.screens.UpdateProfileScreen
|
import su.reya.coop.screens.UpdateProfileScreen
|
||||||
import su.reya.coop.screens.chat.ChatScreen
|
import su.reya.coop.screens.chat.ChatScreen
|
||||||
import su.reya.coop.viewmodel.AccountViewModel
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
import su.reya.coop.viewmodel.ChatScreenViewModel
|
import su.reya.coop.viewmodel.ChatScreenViewModel
|
||||||
import su.reya.coop.viewmodel.ChatViewModel
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
import su.reya.coop.viewmodel.ProfileCache
|
import su.reya.coop.viewmodel.ProfileCache
|
||||||
|
import su.reya.coop.viewmodel.SettingsViewModel
|
||||||
|
|
||||||
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
|
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
|
||||||
error("No ProfileCache provided")
|
error("No ProfileCache provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val LocalSettings = staticCompositionLocalOf<Settings> {
|
||||||
|
error("No Settings provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalConnectivity = staticCompositionLocalOf<Boolean> {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||||
error("No SnackbarHostState provided")
|
error("No SnackbarHostState provided")
|
||||||
}
|
}
|
||||||
@@ -80,6 +90,8 @@ fun App(
|
|||||||
profileCache: ProfileCache,
|
profileCache: ProfileCache,
|
||||||
accountRepository: AccountRepository,
|
accountRepository: AccountRepository,
|
||||||
chatRepository: ChatRepository,
|
chatRepository: ChatRepository,
|
||||||
|
settingsRepository: SettingsRepository,
|
||||||
|
connectivityMonitor: ConnectivityMonitor,
|
||||||
) {
|
) {
|
||||||
val viewModelFactory = remember {
|
val viewModelFactory = remember {
|
||||||
object : ViewModelProvider.Factory {
|
object : ViewModelProvider.Factory {
|
||||||
@@ -93,6 +105,10 @@ fun App(
|
|||||||
accountRepository
|
accountRepository
|
||||||
)
|
)
|
||||||
|
|
||||||
|
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(
|
||||||
|
settingsRepository
|
||||||
|
)
|
||||||
|
|
||||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
}
|
}
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
@@ -103,6 +119,7 @@ fun App(
|
|||||||
|
|
||||||
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
|
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
|
||||||
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
|
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
|
||||||
|
val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? ComponentActivity
|
val activity = context as? ComponentActivity
|
||||||
@@ -114,19 +131,27 @@ fun App(
|
|||||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||||
val signerRequired = accountState.signerRequired
|
val signerRequired = accountState.signerRequired
|
||||||
|
|
||||||
|
// Get the settings
|
||||||
|
val settings by settingsViewModel.settings.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Get connectivity status
|
||||||
|
val isMobileData by connectivityMonitor.isMobileData.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Snackbar
|
// Snackbar
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
// Check if dark theme enabled
|
// 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
|
// Enabled the dynamic color scheme
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
// Enable the dynamic color scheme for Android 12+
|
// Enable the dynamic color scheme for Android 12+
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
|
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
context
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// When dark mode is enabled, use the dark color scheme
|
// When dark mode is enabled, use the dark color scheme
|
||||||
darkMode -> darkColorScheme()
|
darkMode -> darkColorScheme()
|
||||||
@@ -134,10 +159,6 @@ fun App(
|
|||||||
else -> expressiveLightColorScheme()
|
else -> expressiveLightColorScheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(enabled = backStack.size > 1) {
|
|
||||||
navigator.goBack()
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
launch {
|
launch {
|
||||||
accountViewModel.errorEvents.collect { message ->
|
accountViewModel.errorEvents.collect { message ->
|
||||||
@@ -195,11 +216,12 @@ fun App(
|
|||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalProfileCache provides profileCache,
|
LocalProfileCache provides profileCache,
|
||||||
|
LocalSettings provides settings,
|
||||||
|
LocalConnectivity provides isMobileData,
|
||||||
LocalSnackbarHostState provides snackbarHostState,
|
LocalSnackbarHostState provides snackbarHostState,
|
||||||
LocalNavigator provides navigator,
|
LocalNavigator provides navigator,
|
||||||
LocalScanResult provides qrScanResult,
|
LocalScanResult provides qrScanResult,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
onBack = {
|
onBack = {
|
||||||
@@ -272,6 +294,9 @@ fun App(
|
|||||||
entry<Screen.Relay> {
|
entry<Screen.Relay> {
|
||||||
RelayScreen(accountViewModel)
|
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.AccountRepository
|
||||||
import su.reya.coop.repository.ChatRepository
|
import su.reya.coop.repository.ChatRepository
|
||||||
import su.reya.coop.repository.MediaRepository
|
import su.reya.coop.repository.MediaRepository
|
||||||
|
import su.reya.coop.repository.SettingsRepository
|
||||||
import su.reya.coop.viewmodel.ProfileCache
|
import su.reya.coop.viewmodel.ProfileCache
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
@@ -25,16 +26,30 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
|
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
|
||||||
private val scope = MainScope()
|
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 {
|
private val accountRepository by lazy {
|
||||||
val storage = AppStore(this@MainActivity)
|
val storage = AppStore(this@MainActivity)
|
||||||
val mediaRepository = MediaRepository()
|
val mediaRepository = MediaRepository(settingsRepository)
|
||||||
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
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 {
|
private val chatRepository by lazy {
|
||||||
val mediaRepository = MediaRepository()
|
val mediaRepository = MediaRepository(settingsRepository)
|
||||||
ChatRepository(NostrManager.instance, mediaRepository, scope)
|
ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -82,6 +97,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
profileCache = profileCache,
|
profileCache = profileCache,
|
||||||
accountRepository = accountRepository,
|
accountRepository = accountRepository,
|
||||||
chatRepository = chatRepository,
|
chatRepository = chatRepository,
|
||||||
|
settingsRepository = settingsRepository,
|
||||||
|
connectivityMonitor = connectivityMonitor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,4 +58,7 @@ sealed interface Screen : NavKey {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Relay : Screen
|
data object Relay : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Settings : Screen
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import android.os.Build
|
|||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -28,8 +28,6 @@ private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
|||||||
|
|
||||||
class NostrForegroundService : Service() {
|
class NostrForegroundService : Service() {
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
|
|
||||||
private val nostr by lazy { NostrManager.instance }
|
private val nostr by lazy { NostrManager.instance }
|
||||||
private var notificationJob: Job? = null
|
private var notificationJob: Job? = null
|
||||||
|
|
||||||
@@ -191,4 +189,13 @@ class NostrForegroundService : Service() {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
serviceScope.cancel()
|
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 navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.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 openAddContactDialog by remember { mutableStateOf(false) }
|
||||||
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
|
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.os.Build
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
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_qr
|
||||||
import coop.composeapp.generated.resources.ic_request
|
import coop.composeapp.generated.resources.ic_request
|
||||||
import coop.composeapp.generated.resources.ic_scanner
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
@@ -124,13 +128,13 @@ fun HomeScreen(
|
|||||||
val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
val chatRooms by chatViewModel.chatRooms.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 isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
||||||
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.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 } }
|
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
var isRefreshing by remember { mutableStateOf(false) }
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
@@ -249,7 +253,9 @@ fun HomeScreen(
|
|||||||
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
if (!isNotificationEnabled && !isBannerDismissed) {
|
AnimatedVisibility(
|
||||||
|
visible = !isNotificationEnabled && !isBannerDismissed,
|
||||||
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -372,10 +378,14 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
items(ongoing, key = { it.id }) { room ->
|
items(ongoing, key = { it.id }) { room ->
|
||||||
ChatRoom(
|
Row(
|
||||||
room = room,
|
modifier = Modifier.animateItem()
|
||||||
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
) {
|
||||||
)
|
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 firstRoom = requests.getOrNull(0)
|
||||||
val secondRoom = requests.getOrNull(1)
|
val secondRoom = requests.getOrNull(1)
|
||||||
|
|
||||||
val firstRoomState by (firstRoom as Room).uiStateFlow(profileCache)
|
val firstRoomState by remember(firstRoom?.id) {
|
||||||
.collectAsStateWithLifecycle(RoomUiState())
|
firstRoom?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
|
||||||
val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(profileCache)
|
}.collectAsStateWithLifecycle(RoomUiState())
|
||||||
.collectAsStateWithLifecycle(RoomUiState())
|
|
||||||
|
val secondRoomState by remember(secondRoom?.id) {
|
||||||
|
(secondRoom ?: firstRoom)?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
|
||||||
|
}.collectAsStateWithLifecycle(RoomUiState())
|
||||||
|
|
||||||
val supportingText = when {
|
val supportingText = when {
|
||||||
total == 1 -> {
|
total == 1 && firstRoom != null -> {
|
||||||
val message = firstRoom.lastMessage ?: ""
|
val message = firstRoom.lastMessage ?: ""
|
||||||
"${firstRoomState.name}: $message"
|
"${firstRoomState.name}: $message"
|
||||||
}
|
}
|
||||||
@@ -644,27 +657,39 @@ fun NewRequests(requests: List<Room>) {
|
|||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val totalUnread = requests.sumOf { it.unreadCount }
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
navigator.navigate(Screen.RequestList)
|
navigator.navigate(Screen.RequestList)
|
||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Box(
|
BadgedBox(
|
||||||
modifier = Modifier
|
badge = {
|
||||||
.size(48.dp)
|
if (totalUnread > 0) {
|
||||||
.clip(MaterialShapes.Clover4Leaf.toShape()),
|
Badge {
|
||||||
contentAlignment = Alignment.Center
|
Text(totalUnread.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
Surface(
|
Box(
|
||||||
modifier = Modifier.size(48.dp),
|
modifier = Modifier
|
||||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
.size(48.dp)
|
||||||
|
.clip(MaterialShapes.Clover4Leaf.toShape()),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Surface(
|
||||||
Icon(
|
modifier = Modifier.size(48.dp),
|
||||||
painter = painterResource(Res.drawable.ic_request),
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
contentDescription = "Requests",
|
) {
|
||||||
tint = MaterialTheme.colorScheme.onTertiaryFixed
|
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 = {
|
headlineContent = {
|
||||||
Text(
|
Text(
|
||||||
text = "Requests",
|
text = "Requests",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized
|
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||||
|
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal
|
||||||
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
if (supportingText.isNotEmpty()) {
|
if (supportingText.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = supportingText,
|
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,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
@@ -695,27 +725,41 @@ fun NewRequests(requests: List<Room>) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||||
val profileCache = LocalProfileCache.current
|
val profileCache = LocalProfileCache.current
|
||||||
val roomState by room.uiStateFlow(profileCache).collectAsStateWithLifecycle(RoomUiState())
|
val roomState by remember(room.id) { room.uiStateFlow(profileCache) }
|
||||||
|
.collectAsStateWithLifecycle(RoomUiState())
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
leadingContent = {
|
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 = {
|
headlineContent = {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = roomState.name,
|
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)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = room.createdAt.ago(),
|
text = room.createdAt.ago(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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()) {
|
if (!room.lastMessage.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = room.lastMessage ?: "",
|
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,
|
overflow = TextOverflow.Ellipsis,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
@@ -748,7 +795,7 @@ fun BottomMenuList(
|
|||||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||||
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
||||||
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||||
"Settings" to { }
|
"Settings" to { navigator.navigate(Screen.Settings) }
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -101,13 +101,6 @@ fun ImportScreen(viewModel: AccountViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show import errors via snackbar
|
|
||||||
LaunchedEffect(accountState.importError) {
|
|
||||||
accountState.importError?.let {
|
|
||||||
snackbarHostState.showSnackbar(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ fun NewChatScreen(
|
|||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val qrScanResult = LocalScanResult.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("") }
|
var query by remember { mutableStateOf("") }
|
||||||
|
|
||||||
val createGroup = remember { mutableStateOf(false) }
|
val createGroup = remember { mutableStateOf(false) }
|
||||||
@@ -185,8 +186,8 @@ fun NewChatScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
text = { Text("Next") },
|
text = { Text("Next") },
|
||||||
containerColor = MaterialTheme.colorScheme.tertiary,
|
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
contentColor = MaterialTheme.colorScheme.onTertiary,
|
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 logoPainter = painterResource(Res.drawable.coop)
|
||||||
val expressiveFont = getExpressiveFontFamily()
|
val expressiveFont = getExpressiveFontFamily()
|
||||||
|
|
||||||
|
|||||||
@@ -95,14 +95,15 @@ fun RelayScreen(viewModel: AccountViewModel) {
|
|||||||
var openAddRelayDialog by remember { mutableStateOf(false) }
|
var openAddRelayDialog by remember { mutableStateOf(false) }
|
||||||
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val loadedRelayList = accountState.userRelayList
|
||||||
|
val loadedMsgRelayList = accountState.userMsgRelayList
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.loadCurrentUserRelayList()
|
viewModel.loadCurrentUserRelayList()
|
||||||
viewModel.loadCurrentUserMsgRelayList()
|
viewModel.loadCurrentUserMsgRelayList()
|
||||||
}
|
}
|
||||||
|
|
||||||
val loadedRelayList by viewModel.userRelayList.collectAsStateWithLifecycle()
|
|
||||||
val loadedMsgRelayList by viewModel.userMsgRelayList.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
LaunchedEffect(loadedRelayList) {
|
LaunchedEffect(loadedRelayList) {
|
||||||
if (loadedRelayList.isNotEmpty()) {
|
if (loadedRelayList.isNotEmpty()) {
|
||||||
relayList.clear()
|
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
|
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.combinedClickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -43,6 +38,9 @@ import coil3.compose.AsyncImage
|
|||||||
import rust.nostr.sdk.EventId
|
import rust.nostr.sdk.EventId
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
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.URL_REGEX
|
||||||
import su.reya.coop.formatAsTime
|
import su.reya.coop.formatAsTime
|
||||||
import su.reya.coop.isImageUrl
|
import su.reya.coop.isImageUrl
|
||||||
@@ -61,14 +59,27 @@ data class MessageModel(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel {
|
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 id = event.ensureId().id()!!
|
||||||
val isMine = currentUser == event.author()
|
val isMine = currentUser == event.author()
|
||||||
val content = event.content()
|
val content = event.content()
|
||||||
val replyEventIds = event.tags().eventIds()
|
val replyEventIds = event.tags().eventIds()
|
||||||
|
|
||||||
val images = URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
|
val showMedia = when (settings.media) {
|
||||||
val cleanedContent = content.removeImageUrls()
|
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 {
|
val annotatedString = buildAnnotatedString {
|
||||||
var lastIndex = 0
|
var lastIndex = 0
|
||||||
@@ -177,11 +188,7 @@ fun ChatMessage(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
if (isMessageClicked) {
|
||||||
visible = isMessageClicked,
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = fadeOut() + shrinkVertically()
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = model.timestamp,
|
text = model.timestamp,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
|||||||
@@ -90,9 +90,11 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import rust.nostr.sdk.EventId
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileCache
|
import su.reya.coop.LocalProfileCache
|
||||||
|
import su.reya.coop.LocalSettings
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
import su.reya.coop.RoomUiState
|
import su.reya.coop.RoomUiState
|
||||||
@@ -115,6 +117,7 @@ fun ChatScreen(
|
|||||||
val clipboardManager = LocalClipboard.current
|
val clipboardManager = LocalClipboard.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val profileCache = LocalProfileCache.current
|
val profileCache = LocalProfileCache.current
|
||||||
|
val settings = LocalSettings.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
@@ -146,8 +149,9 @@ fun ChatScreen(
|
|||||||
val groupedMessages =
|
val groupedMessages =
|
||||||
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
|
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
|
||||||
|
|
||||||
val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
|
val roomState by remember(id, currentUser?.publicKey) {
|
||||||
.collectAsStateWithLifecycle(RoomUiState())
|
(room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
|
||||||
|
}.collectAsStateWithLifecycle(RoomUiState())
|
||||||
|
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
|
var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
|
||||||
@@ -158,6 +162,29 @@ fun ChatScreen(
|
|||||||
label = "blurAnimation"
|
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 ->
|
val sendFile = { uri: Uri ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// Read file on IO dispatcher
|
// Read file on IO dispatcher
|
||||||
@@ -193,9 +220,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.blur(blurAmount),
|
modifier = Modifier.blur(blurAmount),
|
||||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||||
@@ -264,7 +289,7 @@ fun ChatScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||||
) {
|
) {
|
||||||
if (requireScreening) {
|
if (requireScreening && settings.screening) {
|
||||||
room?.let { ScreenerCard(accountViewModel, it) }
|
room?.let { ScreenerCard(accountViewModel, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,10 +320,18 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.animateItem(),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
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(
|
ChatMessage(
|
||||||
model = model,
|
model = model,
|
||||||
modifier = Modifier.graphicsLayer {
|
modifier = Modifier.graphicsLayer {
|
||||||
@@ -346,7 +379,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (requireScreening) {
|
when (requireScreening && settings.screening) {
|
||||||
true -> {
|
true -> {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -380,15 +413,18 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
replyingTo?.let {
|
AnimatedVisibility(visible = replyingTo != null) {
|
||||||
ReplyBox(it) { replyingTo = null }
|
replyingTo?.let {
|
||||||
|
ReplyBox(it) { replyingTo = null }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ChatInput(
|
ChatInput(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
onSend = {
|
onSend = {
|
||||||
viewModel.sendMessage(text)
|
viewModel.sendMessage(text, replyingTo?.id)
|
||||||
text = ""
|
text = ""
|
||||||
|
replyingTo = null
|
||||||
},
|
},
|
||||||
onUpload = {
|
onUpload = {
|
||||||
fileLauncher.launch("image/*")
|
fileLauncher.launch("image/*")
|
||||||
@@ -420,7 +456,6 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = selectedMessage != null,
|
visible = selectedMessage != null,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
@@ -538,7 +573,11 @@ private fun ReplyBox(model: MessageModel, onDismiss: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) {
|
private fun ReplyPreview(
|
||||||
|
event: UnsignedEvent,
|
||||||
|
isMine: Boolean = false,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
val profileCache = LocalProfileCache.current
|
val profileCache = LocalProfileCache.current
|
||||||
val profileFlow = remember(event) { profileCache.getMetadata(event.author()) }
|
val profileFlow = remember(event) { profileCache.getMetadata(event.author()) }
|
||||||
val profile by profileFlow.collectAsStateWithLifecycle()
|
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
|
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.widthIn(max = 280.dp),
|
modifier = Modifier
|
||||||
|
.widthIn(max = 280.dp)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
shape = bubbleShape,
|
shape = bubbleShape,
|
||||||
) {
|
) {
|
||||||
@@ -562,13 +603,13 @@ private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = profile?.name ?: "Unknown",
|
text = profile?.name ?: "Unknown",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(
|
fontWeight = FontWeight.SemiBold,
|
||||||
alpha = 0.6f
|
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
),
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = event.content(),
|
text = event.content(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package su.reya.coop.shared
|
package su.reya.coop.shared
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -13,6 +14,9 @@ import coil3.compose.AsyncImage
|
|||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.avatar
|
import coop.composeapp.generated.resources.avatar
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import su.reya.coop.LocalConnectivity
|
||||||
|
import su.reya.coop.LocalSettings
|
||||||
|
import su.reya.coop.MediaConfig
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Avatar(
|
fun Avatar(
|
||||||
@@ -22,17 +26,36 @@ fun Avatar(
|
|||||||
size: Dp = 48.dp,
|
size: Dp = 48.dp,
|
||||||
shape: Shape = CircleShape
|
shape: Shape = CircleShape
|
||||||
) {
|
) {
|
||||||
|
val settings = LocalSettings.current
|
||||||
|
val isMobileData = LocalConnectivity.current
|
||||||
val placeholder = painterResource(Res.drawable.avatar)
|
val placeholder = painterResource(Res.drawable.avatar)
|
||||||
|
|
||||||
AsyncImage(
|
val showMedia = when (settings.media) {
|
||||||
model = picture,
|
MediaConfig.AlwaysEnabled -> true
|
||||||
contentDescription = description,
|
MediaConfig.Disabled -> false
|
||||||
modifier = modifier
|
MediaConfig.DisabledForMobileData -> !isMobileData
|
||||||
.size(size)
|
}
|
||||||
.clip(shape),
|
|
||||||
contentScale = ContentScale.Crop,
|
if (showMedia) {
|
||||||
fallback = placeholder,
|
AsyncImage(
|
||||||
error = placeholder,
|
model = picture,
|
||||||
placeholder = placeholder
|
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,4 +4,7 @@ PRODUCT_NAME=Coop
|
|||||||
PRODUCT_BUNDLE_IDENTIFIER=su.reya.coop.Coop$(TEAM_ID)
|
PRODUCT_BUNDLE_IDENTIFIER=su.reya.coop.Coop$(TEAM_ID)
|
||||||
|
|
||||||
CURRENT_PROJECT_VERSION=1
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,32 +2,80 @@ import SwiftUI
|
|||||||
import Shared
|
import Shared
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@State private var showContent = false
|
@Environment(AppState.self) private var appState
|
||||||
var body: some View {
|
|
||||||
VStack {
|
|
||||||
Button("Click me!") {
|
|
||||||
withAnimation {
|
|
||||||
showContent = !showContent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if showContent {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
Group {
|
||||||
Image(systemName: "swift")
|
switch appState.signerRequired {
|
||||||
.font(.system(size: 200))
|
case .none:
|
||||||
.foregroundColor(.accentColor)
|
SplashView()
|
||||||
Text("SwiftUI: \(Greeting().greet())")
|
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)
|
.alert("Error", isPresented: Binding(
|
||||||
.padding()
|
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 {
|
struct SplashView: View {
|
||||||
static var previews: some View {
|
var body: some View {
|
||||||
ContentView()
|
ZStack {
|
||||||
|
Color(.systemBackground).ignoresSafeArea()
|
||||||
|
Image(systemName: "bubble.left.and.bubble.right.fill")
|
||||||
|
.font(.system(size: 64))
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,18 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<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>
|
</dict>
|
||||||
</plist>
|
</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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,33 @@ import SwiftUI
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct iOSApp: App {
|
struct iOSApp: App {
|
||||||
|
@State private var appState = AppState()
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ kotlin {
|
|||||||
iosTarget.binaries.framework {
|
iosTarget.binaries.framework {
|
||||||
baseName = "Shared"
|
baseName = "Shared"
|
||||||
isStatic = true
|
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 subject: String?,
|
||||||
val members: Set<PublicKey>,
|
val members: Set<PublicKey>,
|
||||||
val kind: RoomKind = RoomKind.default(),
|
val kind: RoomKind = RoomKind.default(),
|
||||||
val lastMessage: String? = null
|
val lastMessage: String? = null,
|
||||||
|
val unreadCount: Int = 0
|
||||||
) : Comparable<Room> {
|
) : Comparable<Room> {
|
||||||
override fun compareTo(other: Room): Int {
|
override fun compareTo(other: Room): Int {
|
||||||
return this.createdAt.asSecs().compareTo(other.createdAt.asSecs())
|
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
|
// Trigger new message notification
|
||||||
if (rumor != null) {
|
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)
|
onNewMessage(rumor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,15 @@ import kotlin.time.Duration
|
|||||||
class RelayManager(private val nostr: Nostr) {
|
class RelayManager(private val nostr: Nostr) {
|
||||||
companion object {
|
companion object {
|
||||||
val BOOTSTRAP_RELAYS = listOf(
|
val BOOTSTRAP_RELAYS = listOf(
|
||||||
"wss://relay.primal.net",
|
|
||||||
"wss://relay.ditto.pub",
|
"wss://relay.ditto.pub",
|
||||||
"wss://user.kindpag.es",
|
"wss://relay.primal.net",
|
||||||
|
"wss://relay.nostr.net",
|
||||||
|
"wss://profiles.nostr1.com",
|
||||||
)
|
)
|
||||||
|
|
||||||
val INDEXER_RELAY = listOf(
|
val INDEXER_RELAY = listOf(
|
||||||
"wss://indexer.coracle.social",
|
"wss://indexer.coracle.social",
|
||||||
|
"wss://user.kindpag.es",
|
||||||
)
|
)
|
||||||
|
|
||||||
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
||||||
|
|||||||
@@ -47,13 +47,17 @@ data class AccountState(
|
|||||||
val signerRequired: Boolean? = null,
|
val signerRequired: Boolean? = null,
|
||||||
val isNotificationBannerDismissed: Boolean = false,
|
val isNotificationBannerDismissed: Boolean = false,
|
||||||
val isImporting: 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(
|
class AccountRepository(
|
||||||
private val nostr: Nostr,
|
private val nostr: Nostr,
|
||||||
private val storage: AppStorage,
|
private val storage: AppStorage,
|
||||||
private val mediaRepository: MediaRepository,
|
private val mediaRepository: MediaRepository,
|
||||||
|
private val settingsRepository: SettingsRepository,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
@@ -72,23 +76,9 @@ class AccountRepository(
|
|||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
|
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
|
||||||
.flatMapLatest { pubkey ->
|
.flatMapLatest { if (it != null) currentUserProfileFlow(it) else flowOf(null) }
|
||||||
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
|
|
||||||
}
|
|
||||||
.stateIn(scope, SharingStarted.WhileSubscribed(5000), 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 {
|
init {
|
||||||
checkNotificationBannerDismissedStatus()
|
checkNotificationBannerDismissedStatus()
|
||||||
login()
|
login()
|
||||||
@@ -116,7 +106,7 @@ class AccountRepository(
|
|||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
|
|
||||||
// Automatically update the warning state
|
// 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) {
|
fun importIdentity(secret: String, password: String? = null) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_state.update { it.copy(isImporting = true, importError = null) }
|
_state.update { it.copy(isImporting = true) }
|
||||||
try {
|
try {
|
||||||
val (signer, decryptedSecret) = createSigner(secret, password)
|
val (signer, decryptedSecret) = createSigner(secret, password)
|
||||||
|
|
||||||
@@ -239,14 +229,14 @@ class AccountRepository(
|
|||||||
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Import failed: ${e.message}")
|
showError("Import failed: ${e.message}")
|
||||||
_state.update { it.copy(isImporting = false, importError = e.message) }
|
_state.update { it.copy(isImporting = false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connectExternalSigner() {
|
fun connectExternalSigner() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_state.update { it.copy(isImporting = true, importError = null) }
|
_state.update { it.copy(isImporting = true) }
|
||||||
try {
|
try {
|
||||||
val handler =
|
val handler =
|
||||||
externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||||
@@ -276,7 +266,7 @@ class AccountRepository(
|
|||||||
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("External signer connection failed: ${e.message}")
|
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
|
contentType: String? = null
|
||||||
) {
|
) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_state.update { it.copy(isImporting = true, importError = null) }
|
_state.update { it.copy(isImporting = true) }
|
||||||
try {
|
try {
|
||||||
val keys = Keys.generate()
|
val keys = Keys.generate()
|
||||||
val secret = keys.secretKey().toBech32()
|
val secret = keys.secretKey().toBech32()
|
||||||
@@ -307,7 +297,7 @@ class AccountRepository(
|
|||||||
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
_state.update { it.copy(signerRequired = false, isImporting = false) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Identity creation failed: ${e.message}")
|
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 {
|
scope.launch {
|
||||||
nostr.waitUntilInitialized()
|
nostr.waitUntilInitialized()
|
||||||
nostr.profiles.contactListUpdates.collect { contacts ->
|
nostr.profiles.contactListUpdates.collect { contacts ->
|
||||||
_contactList.value = contacts.toSet()
|
_state.update { it.copy(contactList = contacts.toSet()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetInternalState() {
|
fun resetInternalState() {
|
||||||
_contactList.value = emptySet()
|
_state.update { it.copy(contactList = emptySet(), isRelayListEmpty = false) }
|
||||||
_isRelayListEmpty.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addContact(address: String) {
|
fun addContact(address: String) {
|
||||||
@@ -378,12 +367,12 @@ class AccountRepository(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pubkey in _contactList.value) return@launch
|
if (pubkey in _state.value.contactList) return@launch
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val updated = _contactList.value + pubkey
|
val updated = _state.value.contactList + pubkey
|
||||||
nostr.profiles.setContactList(updated.toList())
|
nostr.profiles.setContactList(updated.toList())
|
||||||
_contactList.update { it + pubkey }
|
_state.update { it.copy(contactList = it.contactList + pubkey) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -392,12 +381,12 @@ class AccountRepository(
|
|||||||
|
|
||||||
fun removeContact(publicKey: PublicKey) {
|
fun removeContact(publicKey: PublicKey) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (publicKey !in _contactList.value) return@launch
|
if (publicKey !in _state.value.contactList) return@launch
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val updated = _contactList.value - publicKey
|
val updated = _state.value.contactList - publicKey
|
||||||
nostr.profiles.setContactList(updated.toList())
|
nostr.profiles.setContactList(updated.toList())
|
||||||
_contactList.update { it - publicKey }
|
_state.update { it.copy(contactList = it.contactList - publicKey) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -460,7 +449,7 @@ class AccountRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun dismissRelayWarning() {
|
fun dismissRelayWarning() {
|
||||||
_isRelayListEmpty.value = false
|
_state.update { it.copy(isRelayListEmpty = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refetchMsgRelays() {
|
fun refetchMsgRelays() {
|
||||||
@@ -487,7 +476,8 @@ class AccountRepository(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
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) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -550,7 +540,8 @@ class AccountRepository(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
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) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
@@ -21,6 +22,7 @@ import rust.nostr.sdk.PublicKey
|
|||||||
import rust.nostr.sdk.Tag
|
import rust.nostr.sdk.Tag
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.RoomKind
|
||||||
import su.reya.coop.nostr.Nostr
|
import su.reya.coop.nostr.Nostr
|
||||||
import su.reya.coop.roomId
|
import su.reya.coop.roomId
|
||||||
import su.reya.coop.viewmodel.ErrorHost
|
import su.reya.coop.viewmodel.ErrorHost
|
||||||
@@ -34,15 +36,12 @@ data class ChatState(
|
|||||||
class ChatRepository(
|
class ChatRepository(
|
||||||
private val nostr: Nostr,
|
private val nostr: Nostr,
|
||||||
private val mediaRepository: MediaRepository,
|
private val mediaRepository: MediaRepository,
|
||||||
|
private val settingsRepository: SettingsRepository,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
) : ErrorHost by createErrorHost() {
|
) : ErrorHost by createErrorHost() {
|
||||||
private val _state = MutableStateFlow(ChatState())
|
private val _state = MutableStateFlow(ChatState())
|
||||||
val state = _state.stateIn(
|
val state = _state.asStateFlow()
|
||||||
scope,
|
|
||||||
SharingStarted.WhileSubscribed(5000),
|
|
||||||
ChatState()
|
|
||||||
)
|
|
||||||
|
|
||||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
|
||||||
replay = 0,
|
replay = 0,
|
||||||
@@ -131,13 +130,39 @@ class ChatRepository(
|
|||||||
return _state.value.rooms[id]
|
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() {
|
fun refreshChatRooms() {
|
||||||
scope.launch(defaultDispatcher) {
|
scope.launch(defaultDispatcher) {
|
||||||
try {
|
try {
|
||||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
val dbRooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
_state.update { currentState ->
|
_state.update { currentState ->
|
||||||
val newMap = currentState.rooms.toMutableMap()
|
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)
|
currentState.copy(rooms = newMap)
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
@@ -223,16 +248,28 @@ class ChatRepository(
|
|||||||
val rooms = currentState.rooms.toMutableMap()
|
val rooms = currentState.rooms.toMutableMap()
|
||||||
val existingRoom = rooms[roomId]
|
val existingRoom = rooms[roomId]
|
||||||
|
|
||||||
|
val isFromMe = event.author() == currentUser
|
||||||
|
val newKind =
|
||||||
|
if (isFromMe) RoomKind.Ongoing else (existingRoom?.kind ?: RoomKind.Request)
|
||||||
|
|
||||||
if (existingRoom == null) {
|
if (existingRoom == null) {
|
||||||
// New room discovery
|
// 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
|
rooms[newRoom.id] = newRoom
|
||||||
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
|
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
|
||||||
// Only update preview if message is newer (handles sync/late arrivals)
|
// Only update preview if message is newer (handles sync/late arrivals)
|
||||||
rooms[roomId] = existingRoom.copy(
|
rooms[roomId] = existingRoom.copy(
|
||||||
lastMessage = event.content(),
|
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 {
|
} else {
|
||||||
// Don't update the room list state for older messages
|
// Don't update the room list state for older messages
|
||||||
return@update currentState
|
return@update currentState
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import kotlinx.serialization.json.Json
|
|||||||
import rust.nostr.sdk.AsyncNostrSigner
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
import su.reya.coop.blossom.BlossomClient
|
import su.reya.coop.blossom.BlossomClient
|
||||||
|
|
||||||
class MediaRepository {
|
class MediaRepository(
|
||||||
|
private val settingsRepository: SettingsRepository
|
||||||
|
) {
|
||||||
private val httpClient = HttpClient {
|
private val httpClient = HttpClient {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json(Json {
|
json(Json {
|
||||||
@@ -25,12 +27,9 @@ class MediaRepository {
|
|||||||
contentType: String? = "image/jpeg"
|
contentType: String? = "image/jpeg"
|
||||||
): String? {
|
): String? {
|
||||||
return try {
|
return try {
|
||||||
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient)
|
val url = settingsRepository.settings.value.blossomServer ?: "https://blossom.band"
|
||||||
val descriptor = blossom.upload(
|
val blossom = BlossomClient(url, httpClient)
|
||||||
file = file,
|
val descriptor = blossom.upload(file = file, contentType = contentType, signer = signer)
|
||||||
contentType = contentType,
|
|
||||||
signer = signer,
|
|
||||||
)
|
|
||||||
descriptor?.url
|
descriptor?.url
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
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 androidx.lifecycle.ViewModel
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.RelayMetadata
|
|
||||||
import rust.nostr.sdk.RelayUrl
|
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
import su.reya.coop.Profile
|
import su.reya.coop.Profile
|
||||||
import su.reya.coop.repository.AccountRepository
|
import su.reya.coop.repository.AccountRepository
|
||||||
@@ -16,10 +14,6 @@ class AccountViewModel(
|
|||||||
val state: StateFlow<AccountState> = repository.state
|
val state: StateFlow<AccountState> = repository.state
|
||||||
val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile
|
val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile
|
||||||
val currentUserProfile: StateFlow<Profile?> = repository.currentUserProfile
|
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 logout(onLogout: () -> Unit = {}) = repository.logout(onLogout)
|
||||||
fun dismissNotificationBanner() = repository.dismissNotificationBanner()
|
fun dismissNotificationBanner() = repository.dismissNotificationBanner()
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class ChatScreenViewModel(
|
|||||||
messages.clear()
|
messages.clear()
|
||||||
messages.addAll(initialMessages.distinctBy { it.id() })
|
messages.addAll(initialMessages.distinctBy { it.id() })
|
||||||
loading = false
|
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 (event.roomId() == id) {
|
||||||
if (messages.none { it.id() == event.id() }) {
|
if (messages.none { it.id() == event.id() }) {
|
||||||
messages.add(0, event)
|
messages.add(0, event)
|
||||||
|
chatRepository.markAsRead(id)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newOtherMessages++
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user