Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e90b8d4b1 | |||
| 71a8240b1d | |||
| ff383a7c6a | |||
| 15e8c984e2 |
@@ -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.1.4"
|
versionName = "0.1.5"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".CrashActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:process=":crash_handler"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M480,471L480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471L480,471ZM480,880Q447,880 423.5,856.5Q400,833 400,800L560,800Q560,833 536.5,856.5Q513,880 480,880ZM160,760L160,680L240,680L240,400Q240,316 290.5,251Q341,186 422,167Q412,189 406.5,213Q401,237 399,262Q364,283 342,319Q320,355 320,400L320,680L640,680L640,558Q660,561 680,561Q700,561 720,558L720,680L800,680L800,760L160,760ZM640,480L628,420Q616,415 605.5,409.5Q595,404 584,396L526,414L486,346L532,306Q530,293 530,280Q530,267 532,254L486,214L526,146L584,164Q595,156 605.5,150.5Q616,145 628,140L640,80L720,80L732,140Q744,145 754.5,150.5Q765,156 776,164L834,146L874,214L828,254Q830,267 830,280Q830,293 828,306L874,346L834,414L776,396Q765,404 754.5,409.5Q744,415 732,420L720,480L640,480ZM736.5,336.5Q760,313 760,280Q760,247 736.5,223.5Q713,200 680,200Q647,200 623.5,223.5Q600,247 600,280Q600,313 623.5,336.5Q647,360 680,360Q713,360 736.5,336.5Z" />
|
||||||
|
</vector>
|
||||||
@@ -189,25 +189,10 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
OnboardingScreen()
|
OnboardingScreen()
|
||||||
}
|
}
|
||||||
entry<Screen.Import> {
|
entry<Screen.Import> {
|
||||||
ImportScreen(
|
ImportScreen()
|
||||||
onSave = { secret ->
|
|
||||||
viewModel.importIdentity(secret)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
entry<Screen.NewIdentity> {
|
entry<Screen.NewIdentity> {
|
||||||
NewIdentityScreen(
|
NewIdentityScreen()
|
||||||
onSave = { name, bio, uri ->
|
|
||||||
val contentType =
|
|
||||||
uri?.let { context.contentResolver.getType(it) }
|
|
||||||
val picture = uri?.let {
|
|
||||||
context.contentResolver.openInputStream(it)?.use { input ->
|
|
||||||
input.readBytes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModel.createIdentity(name, bio, picture, contentType)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
entry<Screen.Chat> { key ->
|
entry<Screen.Chat> { key ->
|
||||||
ChatScreen(id = key.id)
|
ChatScreen(id = key.id)
|
||||||
|
|||||||
108
composeApp/src/androidMain/kotlin/su/reya/coop/CrashActivity.kt
Normal file
108
composeApp/src/androidMain/kotlin/su/reya/coop/CrashActivity.kt
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
class CrashActivity : ComponentActivity() {
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val errorText = intent.getStringExtra("error") ?: "Unknown error"
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
MaterialExpressiveTheme {
|
||||||
|
Scaffold(
|
||||||
|
content = { innerPadding ->
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"App Crashed",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Please copy the log below and send it to the developer.",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = errorText,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = {
|
||||||
|
finish();
|
||||||
|
exitProcess(0)
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text("Exit")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val clipboard =
|
||||||
|
getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
val data = ClipData.newPlainText("Crash Log", errorText)
|
||||||
|
clipboard.setPrimaryClip(data)
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text("Copy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import su.reya.coop.coop.storage.SecretStore
|
import su.reya.coop.coop.storage.SecretStore
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val viewModel: NostrViewModel by viewModels {
|
private val viewModel: NostrViewModel by viewModels {
|
||||||
@@ -23,6 +24,26 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
throwable.printStackTrace()
|
||||||
|
android.util.Log.e(
|
||||||
|
"CoopCrash",
|
||||||
|
"Uncaught exception in thread ${thread.name}",
|
||||||
|
throwable
|
||||||
|
)
|
||||||
|
|
||||||
|
// Start the Crash Activity
|
||||||
|
val intent = Intent(this, CrashActivity::class.java).apply {
|
||||||
|
putExtra("error", throwable.stackTraceToString())
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
|
||||||
|
// Exit
|
||||||
|
android.os.Process.killProcess(android.os.Process.myPid())
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
|
||||||
val splashScreen = installSplashScreen()
|
val splashScreen = installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
@@ -22,7 +24,7 @@ import java.io.File
|
|||||||
|
|
||||||
class NostrForegroundService : Service() {
|
class NostrForegroundService : Service() {
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private val nostr = NostrManager.instance
|
private val nostr by lazy { NostrManager.instance }
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
@@ -30,18 +32,30 @@ class NostrForegroundService : Service() {
|
|||||||
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
val notification = createNotification()
|
val notification = createNotification()
|
||||||
startForeground(1, notification)
|
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
} else {
|
||||||
|
startForeground(1, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
try {
|
try {
|
||||||
|
Log.d("Coop", "Starting Nostr in background")
|
||||||
|
|
||||||
|
// Create a database directory
|
||||||
val dbDir = File(filesDir, "nostr")
|
val dbDir = File(filesDir, "nostr")
|
||||||
dbDir.mkdirs()
|
dbDir.mkdirs()
|
||||||
|
|
||||||
// Initialize Nostr client
|
// Initialize Nostr client
|
||||||
nostr.init(dbDir.absolutePath)
|
nostr.init(dbDir.absolutePath)
|
||||||
// Connect to bootstrap relays
|
// Connect to bootstrap relays
|
||||||
@@ -67,10 +81,9 @@ class NostrForegroundService : Service() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("Failed to start Nostr in background: ${e.message}")
|
Log.e("Coop", "Failed to start Nostr", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
package su.reya.coop.screens
|
package su.reya.coop.screens
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
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
|
||||||
@@ -9,12 +15,15 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
@@ -36,6 +45,7 @@ import androidx.compose.material3.SegmentedListItem
|
|||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TooltipAnchorPosition
|
import androidx.compose.material3.TooltipAnchorPosition
|
||||||
import androidx.compose.material3.TooltipBox
|
import androidx.compose.material3.TooltipBox
|
||||||
import androidx.compose.material3.TooltipDefaults
|
import androidx.compose.material3.TooltipDefaults
|
||||||
@@ -61,8 +71,11 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_new_chat
|
import coop.composeapp.generated.resources.ic_new_chat
|
||||||
import coop.composeapp.generated.resources.ic_qr
|
import coop.composeapp.generated.resources.ic_qr
|
||||||
@@ -85,6 +98,7 @@ import su.reya.coop.short
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen() {
|
fun HomeScreen() {
|
||||||
|
val context = LocalContext.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
@@ -97,15 +111,32 @@ fun HomeScreen() {
|
|||||||
val userProfile by currentUserProfile.collectAsState(initial = null)
|
val userProfile by currentUserProfile.collectAsState(initial = null)
|
||||||
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
|
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
|
||||||
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
|
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
|
||||||
|
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val sheetState = rememberModalBottomSheetState()
|
val sheetState = rememberModalBottomSheetState()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
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) }
|
||||||
|
|
||||||
|
var isNotificationEnabled by remember {
|
||||||
|
mutableStateOf(NotificationManagerCompat.from(context).areNotificationsEnabled())
|
||||||
|
}
|
||||||
|
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { _ ->
|
||||||
|
// State will be updated by LifecycleResumeEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
LifecycleResumeEffect(context) {
|
||||||
|
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
|
onPauseOrDispose { }
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.getChatRooms()
|
viewModel.getChatRooms()
|
||||||
}
|
}
|
||||||
@@ -187,161 +218,229 @@ fun HomeScreen() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
content = { innerPadding ->
|
content = { innerPadding ->
|
||||||
Surface(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
||||||
.fillMaxSize()
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
.padding(top = innerPadding.calculateTopPadding()),
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
|
||||||
) {
|
) {
|
||||||
PullToRefreshBox(
|
if (!isNotificationEnabled && !isBannerDismissed) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
Surface(
|
||||||
isRefreshing = isRefreshing,
|
modifier = Modifier
|
||||||
state = pullToRefreshState,
|
.fillMaxWidth()
|
||||||
onRefresh = {
|
.padding(horizontal = 16.dp),
|
||||||
scope.launch {
|
shape = RoundedCornerShape(24.dp),
|
||||||
isRefreshing = true
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
viewModel.refreshChatRooms()
|
) {
|
||||||
isRefreshing = false
|
Column(
|
||||||
}
|
modifier = Modifier
|
||||||
},
|
.fillMaxWidth()
|
||||||
indicator = {
|
.padding(16.dp),
|
||||||
PullToRefreshDefaults.LoadingIndicator(
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
state = pullToRefreshState,
|
|
||||||
isRefreshing = isRefreshing,
|
|
||||||
modifier = Modifier.align(Alignment.TopCenter),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
if (!isPartialProcessedGiftWrap) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
LoadingIndicator()
|
|
||||||
}
|
|
||||||
} else if (chatRooms.isEmpty()) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "No chats yet",
|
text = "Get message notifications",
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
fontWeight = FontWeight.SemiBold
|
color = MaterialTheme.colorScheme.onSecondaryFixed,
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Your conversations will appear here.",
|
text = "Make sure you know when you have new messages.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.outline
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
Row(
|
||||||
} else {
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
LazyColumn(
|
) {
|
||||||
state = listState,
|
TextButton(
|
||||||
modifier = Modifier.fillMaxSize()
|
onClick = { viewModel.dismissNotificationBanner() },
|
||||||
) {
|
modifier = Modifier.weight(1f),
|
||||||
items(chatRooms.toList(), key = { it.id }) { room ->
|
) {
|
||||||
ChatRoom(
|
Text(text = "Maybe later")
|
||||||
room = room,
|
}
|
||||||
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
Button(
|
||||||
)
|
onClick = {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
} else {
|
||||||
|
// For older versions, navigate the user directly to App Notification Settings
|
||||||
|
val intent =
|
||||||
|
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||||
|
putExtra(
|
||||||
|
Settings.EXTRA_APP_PACKAGE,
|
||||||
|
context.packageName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(text = "Turn on")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Surface(
|
||||||
if (showBottomSheet) {
|
modifier = Modifier.fillMaxSize(),
|
||||||
ModalBottomSheet(
|
color = MaterialTheme.colorScheme.surface,
|
||||||
onDismissRequest = { showBottomSheet = false },
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
sheetState = sheetState,
|
) {
|
||||||
) {
|
PullToRefreshBox(
|
||||||
val pubkey = viewModel.currentUser()
|
modifier = Modifier.fillMaxSize(),
|
||||||
val shortPubkey = pubkey?.short() ?: "Not available"
|
isRefreshing = isRefreshing,
|
||||||
|
state = pullToRefreshState,
|
||||||
val userName =
|
onRefresh = {
|
||||||
userProfile?.asRecord()?.displayName
|
|
||||||
?: userProfile?.asRecord()?.name
|
|
||||||
?: "No name"
|
|
||||||
|
|
||||||
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
sheetState.hide()
|
isRefreshing = true
|
||||||
showBottomSheet = false
|
viewModel.refreshChatRooms()
|
||||||
action()
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
indicator = {
|
||||||
|
PullToRefreshDefaults.LoadingIndicator(
|
||||||
|
state = pullToRefreshState,
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
) {
|
||||||
Column(
|
if (!isPartialProcessedGiftWrap) {
|
||||||
modifier = Modifier
|
Box(
|
||||||
.padding(16.dp)
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxWidth(),
|
contentAlignment = Alignment.Center
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
) {
|
||||||
Box(
|
LoadingIndicator()
|
||||||
modifier = Modifier
|
}
|
||||||
.size(84.dp)
|
} else if (chatRooms.isEmpty()) {
|
||||||
.clip(MaterialShapes.Cookie9Sided.toShape()),
|
Box(
|
||||||
contentAlignment = Alignment.Center
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
contentAlignment = Alignment.Center
|
||||||
Avatar(
|
) {
|
||||||
picture = userProfile?.asRecord()?.picture,
|
Column(
|
||||||
description = userProfile?.asRecord()?.displayName,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
shape = MaterialShapes.Cookie9Sided.toShape(),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = userName,
|
text = "No chats yet",
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your conversations will appear here.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
}
|
||||||
Row(
|
} else {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
LazyColumn(
|
||||||
) {
|
state = listState,
|
||||||
OutlinedButton(
|
modifier = Modifier.fillMaxSize()
|
||||||
onClick = {
|
) {
|
||||||
scope.launch {
|
items(chatRooms.toList(), key = { it.id }) { room ->
|
||||||
pubkey?.let {
|
ChatRoom(
|
||||||
val bech32 = it.toBech32()
|
room = room,
|
||||||
val data = ClipData.newPlainText(bech32, bech32)
|
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||||
clipboardManager.setClipEntry(ClipEntry(data))
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(text = shortPubkey)
|
|
||||||
}
|
|
||||||
FilledIconButton(
|
|
||||||
onClick = {
|
|
||||||
dismissAndRun { navigator.navigate(Screen.MyQr) }
|
|
||||||
},
|
|
||||||
shape = MaterialShapes.Square.toShape()
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_qr),
|
|
||||||
contentDescription = "My QR"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
}
|
||||||
BottomMenuList(onDismiss = dismissAndRun)
|
}
|
||||||
|
|
||||||
|
if (showBottomSheet) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = { showBottomSheet = false },
|
||||||
|
sheetState = sheetState,
|
||||||
|
modifier = Modifier
|
||||||
|
.imePadding()
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
) {
|
||||||
|
val pubkey = viewModel.currentUser()
|
||||||
|
val shortPubkey = pubkey?.short() ?: "Not available"
|
||||||
|
|
||||||
|
val userName =
|
||||||
|
userProfile?.asRecord()?.displayName
|
||||||
|
?: userProfile?.asRecord()?.name
|
||||||
|
?: "No name"
|
||||||
|
|
||||||
|
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
|
||||||
|
scope.launch {
|
||||||
|
sheetState.hide()
|
||||||
|
showBottomSheet = false
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(84.dp)
|
||||||
|
.clip(MaterialShapes.Cookie9Sided.toShape()),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Avatar(
|
||||||
|
picture = userProfile?.asRecord()?.picture,
|
||||||
|
description = userProfile?.asRecord()?.displayName,
|
||||||
|
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = userName,
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
pubkey?.let {
|
||||||
|
val bech32 = it.toBech32()
|
||||||
|
val data =
|
||||||
|
ClipData.newPlainText(bech32, bech32)
|
||||||
|
clipboardManager.setClipEntry(ClipEntry(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(text = shortPubkey)
|
||||||
|
}
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = {
|
||||||
|
dismissAndRun { navigator.navigate(Screen.MyQr) }
|
||||||
|
},
|
||||||
|
shape = MaterialShapes.Square.toShape()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_qr),
|
||||||
|
contentDescription = "My QR"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
|
BottomMenuList(onDismiss = dismissAndRun)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.material3.toShape
|
import androidx.compose.material3.toShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -49,10 +48,11 @@ import androidx.compose.ui.text.input.ImeAction
|
|||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_scanner
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
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.Keys
|
import rust.nostr.sdk.Keys
|
||||||
@@ -69,32 +69,28 @@ import su.reya.coop.short
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ImportScreen(
|
fun ImportScreen() {
|
||||||
onSave: (secret: String) -> Unit
|
|
||||||
) {
|
|
||||||
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 focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
|
||||||
var secret by remember { mutableStateOf("") }
|
var secret by remember { mutableStateOf("") }
|
||||||
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
||||||
|
|
||||||
|
// Get metadata when pubkey changes
|
||||||
val metadata by remember(pubkey) {
|
val metadata by remember(pubkey) {
|
||||||
if (pubkey != null) {
|
pubkey?.let(viewModel::getMetadata) ?: flowOf(null)
|
||||||
viewModel.getMetadata(pubkey!!)
|
}.collectAsStateWithLifecycle(null)
|
||||||
} else {
|
|
||||||
MutableStateFlow(null)
|
|
||||||
}
|
|
||||||
}.collectAsState(null)
|
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
|
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
|
||||||
val picture = profile?.picture
|
val picture = profile?.picture
|
||||||
|
|
||||||
val isLoading by viewModel.isCreating.collectAsState()
|
|
||||||
|
|
||||||
LaunchedEffect(qrScanResult.content) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrScanResult.content?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -209,6 +205,7 @@ fun ImportScreen(
|
|||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = secret,
|
value = secret,
|
||||||
onValueChange = { secret = it },
|
onValueChange = { secret = it },
|
||||||
|
enabled = !isLoggedIn,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
@@ -221,10 +218,10 @@ fun ImportScreen(
|
|||||||
),
|
),
|
||||||
visualTransformation = PasswordVisualTransformation('*'),
|
visualTransformation = PasswordVisualTransformation('*'),
|
||||||
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
||||||
color = MaterialTheme.colorScheme.primaryFixed,
|
color = MaterialTheme.colorScheme.tertiaryFixedDim,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
),
|
),
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
|
||||||
decorationBox = { innerTextField ->
|
decorationBox = { innerTextField ->
|
||||||
Box(contentAlignment = Alignment.CenterStart) {
|
Box(contentAlignment = Alignment.CenterStart) {
|
||||||
if (secret.isEmpty()) {
|
if (secret.isEmpty()) {
|
||||||
@@ -246,24 +243,28 @@ fun ImportScreen(
|
|||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (pubkey == null) {
|
scope.launch {
|
||||||
scope.launch {
|
if (pubkey == null) {
|
||||||
viewModel.verifyIdentity(secret).let { pubkey = it }
|
viewModel.verifyIdentity(secret).let { pubkey = it }
|
||||||
|
} else {
|
||||||
|
// Import the identity
|
||||||
|
viewModel.importIdentity(secret)
|
||||||
|
// Navigate to the home screen
|
||||||
|
navigator.navigate(Screen.Home)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
onSave(secret)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(ButtonDefaults.MediumContainerHeight),
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
enabled = secret.isNotBlank() && !isLoading,
|
enabled = secret.isNotBlank() && !isLoggedIn,
|
||||||
) {
|
) {
|
||||||
if (isLoading) {
|
if (isLoggedIn) {
|
||||||
LoadingIndicator()
|
LoadingIndicator()
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = if (pubkey == null) "Verify" else "Continue",
|
text = if (pubkey == null) "Verify" else "Click again to Continue",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,45 +36,50 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.toShape
|
import androidx.compose.material3.toShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_plus
|
import coop.composeapp.generated.resources.ic_plus
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.Screen
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NewIdentityScreen(
|
fun NewIdentityScreen() {
|
||||||
onSave: (name: String, bio: String?, picture: Uri?) -> Unit
|
val context = LocalContext.current
|
||||||
) {
|
|
||||||
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
|
val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
|
||||||
var name by remember { mutableStateOf("") }
|
var name by remember { mutableStateOf("") }
|
||||||
var bio by remember { mutableStateOf("") }
|
var bio by remember { mutableStateOf("") }
|
||||||
var picture by remember { mutableStateOf<Uri?>(null) }
|
var picture by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
|
||||||
val isLoading by viewModel.isCreating.collectAsState()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.GetContent()
|
contract = ActivityResultContracts.GetContent()
|
||||||
@@ -178,6 +183,7 @@ fun NewIdentityScreen(
|
|||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
|
enabled = !isLoggedIn,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
@@ -189,10 +195,10 @@ fun NewIdentityScreen(
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||||
color = MaterialTheme.colorScheme.primaryFixed,
|
color = MaterialTheme.colorScheme.tertiaryFixedDim,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
),
|
),
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
|
||||||
decorationBox = { innerTextField ->
|
decorationBox = { innerTextField ->
|
||||||
Box(contentAlignment = Alignment.CenterStart) {
|
Box(contentAlignment = Alignment.CenterStart) {
|
||||||
if (name.isEmpty()) {
|
if (name.isEmpty()) {
|
||||||
@@ -220,6 +226,7 @@ fun NewIdentityScreen(
|
|||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = bio,
|
value = bio,
|
||||||
onValueChange = { bio = it },
|
onValueChange = { bio = it },
|
||||||
|
enabled = !isLoggedIn,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 3,
|
maxLines = 3,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
@@ -256,14 +263,35 @@ fun NewIdentityScreen(
|
|||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
onSave(name, bio, picture)
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val imageBytes = withContext(Dispatchers.IO) {
|
||||||
|
picture?.let { uri ->
|
||||||
|
context.contentResolver.openInputStream(
|
||||||
|
uri
|
||||||
|
)?.use { input -> input.readBytes() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentType =
|
||||||
|
picture?.let { context.contentResolver.getType(it) }
|
||||||
|
|
||||||
|
// Create the identity
|
||||||
|
viewModel.createIdentity(name, bio, imageBytes, contentType)
|
||||||
|
|
||||||
|
// Navigate to the home screen if successful
|
||||||
|
navigator.navigate(Screen.Home)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Error is handled by viewModel.showError inside createIdentity
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(ButtonDefaults.MediumContainerHeight),
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
enabled = name.isNotBlank() && !isLoading,
|
enabled = name.isNotBlank() && !isLoggedIn,
|
||||||
) {
|
) {
|
||||||
if (isLoading) {
|
if (isLoggedIn) {
|
||||||
LoadingIndicator()
|
LoadingIndicator()
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.2.1"
|
agp = "9.2.1"
|
||||||
android-compileSdk = "36"
|
android-compileSdk = "37"
|
||||||
android-minSdk = "24"
|
android-minSdk = "26"
|
||||||
android-targetSdk = "36"
|
android-targetSdk = "37"
|
||||||
androidx-activity = "1.13.0"
|
androidx-activity = "1.13.0"
|
||||||
androidx-appcompat = "1.7.1"
|
androidx-appcompat = "1.7.1"
|
||||||
androidx-core = "1.18.0"
|
androidx-core = "1.18.0"
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
package su.reya.coop
|
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
|
|
||||||
import io.ktor.client.plugins.websocket.webSocketSession
|
|
||||||
import io.ktor.client.request.url
|
|
||||||
import io.ktor.websocket.Frame
|
|
||||||
import io.ktor.websocket.close
|
|
||||||
import io.ktor.websocket.readBytes
|
|
||||||
import io.ktor.websocket.readText
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
|
||||||
import rust.nostr.sdk.ConnectionMode
|
|
||||||
import rust.nostr.sdk.CustomWebSocketTransport
|
|
||||||
import rust.nostr.sdk.WebSocketAdapter
|
|
||||||
import rust.nostr.sdk.WebSocketAdapterWrapper
|
|
||||||
import rust.nostr.sdk.WebSocketMessage
|
|
||||||
|
|
||||||
class KtorWebSocketAdapter(
|
|
||||||
private val client: HttpClient,
|
|
||||||
private val session: DefaultClientWebSocketSession
|
|
||||||
) : WebSocketAdapter {
|
|
||||||
|
|
||||||
override suspend fun send(msg: WebSocketMessage) {
|
|
||||||
try {
|
|
||||||
when (msg) {
|
|
||||||
is WebSocketMessage.Text -> session.send(Frame.Text(msg.text))
|
|
||||||
is WebSocketMessage.Binary -> session.send(Frame.Binary(true, msg.bytes))
|
|
||||||
is WebSocketMessage.Ping -> session.send(Frame.Ping(msg.bytes))
|
|
||||||
is WebSocketMessage.Pong -> session.send(Frame.Pong(msg.bytes))
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Attempted to send on a closed WebSocket: ${e.message}")
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun recv(): WebSocketMessage? {
|
|
||||||
return try {
|
|
||||||
when (val frame = session.incoming.receive()) {
|
|
||||||
is Frame.Text -> WebSocketMessage.Text(frame.readText())
|
|
||||||
is Frame.Binary -> WebSocketMessage.Binary(frame.readBytes())
|
|
||||||
is Frame.Ping -> WebSocketMessage.Ping(frame.readBytes())
|
|
||||||
is Frame.Pong -> WebSocketMessage.Pong(frame.readBytes())
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
} catch (e: ClosedReceiveChannelException) {
|
|
||||||
null
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun closeConnection() {
|
|
||||||
session.cancel()
|
|
||||||
session.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class CoopWebSocketClient(private val httpClient: HttpClient) : CustomWebSocketTransport {
|
|
||||||
override fun supportPing(): Boolean = false
|
|
||||||
|
|
||||||
override suspend fun connect(url: String, mode: ConnectionMode): WebSocketAdapterWrapper {
|
|
||||||
try {
|
|
||||||
val session = httpClient.webSocketSession {
|
|
||||||
url(url)
|
|
||||||
}
|
|
||||||
val adapter = KtorWebSocketAdapter(httpClient, session)
|
|
||||||
return WebSocketAdapterWrapper(adapter)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package su.reya.coop
|
|||||||
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.plugins.websocket.WebSockets
|
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.statement.HttpResponse
|
import io.ktor.client.statement.HttpResponse
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -106,17 +105,16 @@ class Nostr {
|
|||||||
// Initialize the logger for nostr client
|
// Initialize the logger for nostr client
|
||||||
initLogger(LogLevel.DEBUG)
|
initLogger(LogLevel.DEBUG)
|
||||||
|
|
||||||
|
// Initialize the database and gossip instance
|
||||||
val lmdb = NostrDatabase.lmdb(dbPath)
|
val lmdb = NostrDatabase.lmdb(dbPath)
|
||||||
val gossip = NostrGossip.inMemory()
|
val gossip = NostrGossip.inMemory()
|
||||||
|
|
||||||
|
// Set the idle timeout for relays
|
||||||
val idleTimeout = Duration.parse("5m")
|
val idleTimeout = Duration.parse("5m")
|
||||||
val httpClient = HttpClient {
|
|
||||||
install(WebSockets)
|
|
||||||
}
|
|
||||||
|
|
||||||
client =
|
client =
|
||||||
ClientBuilder()
|
ClientBuilder()
|
||||||
.signer(signer)
|
.signer(signer)
|
||||||
.websocketTransport(CoopWebSocketClient(httpClient))
|
|
||||||
.database(lmdb)
|
.database(lmdb)
|
||||||
.gossip(gossip)
|
.gossip(gossip)
|
||||||
.gossipConfig(
|
.gossipConfig(
|
||||||
|
|||||||
@@ -40,11 +40,14 @@ class NostrViewModel(
|
|||||||
private val nostr: Nostr,
|
private val nostr: Nostr,
|
||||||
private val secretStore: SecretStorage
|
private val secretStore: SecretStorage
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
private val _isNotificationBannerDismissed = MutableStateFlow(false)
|
||||||
|
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
|
||||||
|
|
||||||
private val _signerRequired = MutableStateFlow<Boolean?>(null)
|
private val _signerRequired = MutableStateFlow<Boolean?>(null)
|
||||||
val signerRequired = _signerRequired.asStateFlow()
|
val signerRequired = _signerRequired.asStateFlow()
|
||||||
|
|
||||||
private val _isCreating = MutableStateFlow(false)
|
private val _isLoggedIn = MutableStateFlow(false)
|
||||||
val isCreating = _isCreating.asStateFlow()
|
val isLoggedIn = _isLoggedIn.asStateFlow()
|
||||||
|
|
||||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
||||||
val chatRooms = _chatRooms.asStateFlow()
|
val chatRooms = _chatRooms.asStateFlow()
|
||||||
@@ -61,7 +64,7 @@ class NostrViewModel(
|
|||||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||||
val newEvents = _newEvents.asSharedFlow()
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
private val _sentReports = MutableStateFlow<Map<EventId, List<RelayUrl>>>(emptyMap())
|
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
|
||||||
val sentReport = _sentReports.asSharedFlow()
|
val sentReport = _sentReports.asSharedFlow()
|
||||||
|
|
||||||
private val _errorEvents = Channel<String>(Channel.BUFFERED)
|
private val _errorEvents = Channel<String>(Channel.BUFFERED)
|
||||||
@@ -72,6 +75,9 @@ class NostrViewModel(
|
|||||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
// Check if the notification banner has been dismissed
|
||||||
|
checkNotificationBannerDismissedStatus()
|
||||||
|
|
||||||
// Check local stored secret (secret key or bunker)
|
// Check local stored secret (secret key or bunker)
|
||||||
login()
|
login()
|
||||||
|
|
||||||
@@ -101,7 +107,13 @@ class NostrViewModel(
|
|||||||
private fun showError(message: String) {
|
private fun showError(message: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_errorEvents.send(message)
|
_errorEvents.send(message)
|
||||||
if (isCreating.value) _isCreating.value = false
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkNotificationBannerDismissedStatus() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isNotificationBannerDismissed.value =
|
||||||
|
secretStore.get("notification_banner_dismissed") == "true"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +303,13 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun dismissNotificationBanner() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
secretStore.set("notification_banner_dismissed", "true")
|
||||||
|
_isNotificationBannerDismissed.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun dismissRelayWarning() {
|
fun dismissRelayWarning() {
|
||||||
_isRelayListEmpty.value = false
|
_isRelayListEmpty.value = false
|
||||||
}
|
}
|
||||||
@@ -324,80 +343,81 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createIdentity(
|
suspend fun createIdentity(
|
||||||
name: String,
|
name: String,
|
||||||
bio: String?,
|
bio: String?,
|
||||||
picture: ByteArray?,
|
picture: ByteArray?,
|
||||||
contentType: String? = null
|
contentType: String? = null
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch {
|
_isLoggedIn.value = true
|
||||||
try {
|
try {
|
||||||
val keys = Keys.generate()
|
val keys = Keys.generate()
|
||||||
val secret = keys.secretKey().toBech32()
|
val secret = keys.secretKey().toBech32()
|
||||||
var avatarUrl = ""
|
var avatarUrl = ""
|
||||||
|
|
||||||
// Set loading state
|
// Upload picture to Blossom
|
||||||
_isCreating.value = true
|
if (picture != null) {
|
||||||
|
val blossom = BlossomClient(
|
||||||
// Upload picture to Blossom
|
url = "https://blossom.band",
|
||||||
if (picture != null) {
|
client = HttpClient {
|
||||||
val blossom = BlossomClient(
|
install(ContentNegotiation) {
|
||||||
url = "https://blossom.band",
|
json(Json {
|
||||||
client = HttpClient {
|
ignoreUnknownKeys = true
|
||||||
install(ContentNegotiation) {
|
prettyPrint = true
|
||||||
json(Json {
|
isLenient = true
|
||||||
ignoreUnknownKeys = true
|
})
|
||||||
prettyPrint = true
|
|
||||||
isLenient = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
|
||||||
val descriptor = blossom.upload(
|
val descriptor = blossom.upload(
|
||||||
file = picture,
|
file = picture,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
signer = keys
|
signer = keys
|
||||||
)
|
)
|
||||||
|
|
||||||
avatarUrl = descriptor?.url ?: ""
|
avatarUrl = descriptor?.url ?: ""
|
||||||
}
|
|
||||||
|
|
||||||
// Create identity
|
|
||||||
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
|
||||||
|
|
||||||
// Save secret to the secret storage
|
|
||||||
secretStore.set("user_signer", secret)
|
|
||||||
|
|
||||||
// Set an empty secret state
|
|
||||||
_signerRequired.value = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create identity
|
||||||
|
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
||||||
|
|
||||||
|
// Save secret to the secret storage
|
||||||
|
secretStore.set("user_signer", secret)
|
||||||
|
|
||||||
|
// Set an empty secret state
|
||||||
|
_signerRequired.value = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
_isLoggedIn.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun verifyIdentity(secret: String): PublicKey? {
|
suspend fun verifyIdentity(secret: String): PublicKey? {
|
||||||
return runCatching {
|
try {
|
||||||
val signer = createSigner(secret)
|
val signer = createSigner(secret)
|
||||||
if (secret.startsWith("bunker://")) {
|
if (secret.startsWith("bunker://")) {
|
||||||
showError("Please approve the connection.")
|
showError("Please approve the connection.")
|
||||||
}
|
}
|
||||||
signer.getPublicKeyAsync()
|
return signer.getPublicKeyAsync()
|
||||||
}.getOrNull()
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun importIdentity(secret: String) {
|
suspend fun importIdentity(secret: String) {
|
||||||
viewModelScope.launch {
|
_isLoggedIn.value = true
|
||||||
runCatching {
|
try {
|
||||||
val signer = createSigner(secret)
|
val signer = createSigner(secret)
|
||||||
nostr.setSigner(signer)
|
nostr.setSigner(signer)
|
||||||
secretStore.set("user_signer", secret)
|
secretStore.set("user_signer", secret)
|
||||||
}.onSuccess {
|
} catch (e: Exception) {
|
||||||
_signerRequired.value = false
|
showError("Error: ${e.message}")
|
||||||
}.onFailure { e ->
|
} finally {
|
||||||
showError(e.message ?: "Invalid Secret or Bunker URI")
|
_signerRequired.value = false
|
||||||
}
|
_isLoggedIn.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user