ManageScope.kt (18628B) - raw
1 package me.rhunk.snapenhance.ui.manager.pages.social 2 3 import android.content.Intent 4 import androidx.compose.foundation.layout.* 5 import androidx.compose.foundation.rememberScrollState 6 import androidx.compose.foundation.verticalScroll 7 import androidx.compose.material.icons.Icons 8 import androidx.compose.material.icons.rounded.DeleteForever 9 import androidx.compose.material3.* 10 import androidx.compose.runtime.* 11 import androidx.compose.ui.Alignment 12 import androidx.compose.ui.Modifier 13 import androidx.compose.ui.graphics.Color 14 import androidx.compose.ui.text.font.FontWeight 15 import androidx.compose.ui.unit.dp 16 import androidx.compose.ui.unit.sp 17 import androidx.navigation.NavBackStackEntry 18 import androidx.navigation.compose.currentBackStackEntryAsState 19 import kotlinx.coroutines.CoroutineScope 20 import kotlinx.coroutines.Dispatchers 21 import kotlinx.coroutines.launch 22 import me.rhunk.snapenhance.common.data.FriendStreaks 23 import me.rhunk.snapenhance.common.data.MessagingFriendInfo 24 import me.rhunk.snapenhance.common.data.MessagingGroupInfo 25 import me.rhunk.snapenhance.common.data.MessagingRuleType 26 import me.rhunk.snapenhance.common.data.SocialScope 27 import me.rhunk.snapenhance.common.ui.AutoClearKeyboardFocus 28 import me.rhunk.snapenhance.common.ui.EditNoteTextField 29 import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState 30 import me.rhunk.snapenhance.common.ui.rememberAsyncMutableStateList 31 import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie 32 import me.rhunk.snapenhance.storage.* 33 import me.rhunk.snapenhance.ui.manager.Routes 34 import me.rhunk.snapenhance.ui.util.AlertDialogs 35 import me.rhunk.snapenhance.ui.util.Dialog 36 import me.rhunk.snapenhance.ui.util.coil.BitmojiImage 37 import kotlin.io.encoding.Base64 38 import kotlin.io.encoding.ExperimentalEncodingApi 39 40 class ManageScope: Routes.Route() { 41 private val dialogs by lazy { AlertDialogs(context.translation) } 42 43 private fun deleteScope(scope: SocialScope, id: String, coroutineScope: CoroutineScope) { 44 when (scope) { 45 SocialScope.FRIEND -> context.database.deleteFriend(id) 46 SocialScope.GROUP -> context.database.deleteGroup(id) 47 } 48 context.database.executeAsync { 49 coroutineScope.launch { 50 routes.navController.popBackStack() 51 } 52 } 53 } 54 55 override val topBarActions: @Composable (RowScope.() -> Unit) = topBarActions@{ 56 val navBackStackEntry by routes.navController.currentBackStackEntryAsState() 57 var deleteConfirmDialog by remember { mutableStateOf(false) } 58 val coroutineScope = rememberCoroutineScope() 59 60 if (deleteConfirmDialog) { 61 val scope = navBackStackEntry?.arguments?.getString("scope")?.let { SocialScope.getByName(it) } ?: return@topBarActions 62 val id = navBackStackEntry?.arguments?.getString("id")!! 63 64 Dialog(onDismissRequest = { 65 deleteConfirmDialog = false 66 }) { 67 remember { AlertDialogs(context.translation) }.ConfirmDialog( 68 title = translation.format("delete_scope_confirm_dialog_title", "scope" to context.translation["scopes.${scope.key}"]), 69 onDismiss = { deleteConfirmDialog = false }, 70 onConfirm = { 71 deleteScope(scope, id, coroutineScope); deleteConfirmDialog = false 72 } 73 ) 74 } 75 } 76 77 IconButton( 78 onClick = { deleteConfirmDialog = true }, 79 ) { 80 Icon( 81 imageVector = Icons.Rounded.DeleteForever, 82 contentDescription = null 83 ) 84 } 85 } 86 87 override val content: @Composable (NavBackStackEntry) -> Unit = content@{ navBackStackEntry -> 88 val scope = SocialScope.getByName(navBackStackEntry.arguments?.getString("scope")!!) 89 val id = navBackStackEntry.arguments?.getString("id")!! 90 91 Column( 92 modifier = Modifier 93 .verticalScroll(rememberScrollState()) 94 .fillMaxSize() 95 ) { 96 var bottomComposable by remember { 97 mutableStateOf(null as (@Composable () -> Unit)?) 98 } 99 var hasScope by remember { 100 mutableStateOf(null as Boolean?) 101 } 102 when (scope) { 103 SocialScope.FRIEND -> { 104 var streaks by remember { mutableStateOf(null as FriendStreaks?) } 105 val friend by rememberAsyncMutableState(null) { 106 context.database.getFriendInfo(id)?.also { 107 streaks = context.database.getFriendStreaks(id) 108 }.also { 109 hasScope = it != null 110 } 111 } 112 friend?.let { 113 Friend(id, it, streaks) { bottomComposable = it } 114 } 115 } 116 SocialScope.GROUP -> { 117 val group by rememberAsyncMutableState(null) { 118 context.database.getGroupInfo(id).also { 119 hasScope = it != null 120 } 121 } 122 group?.let { 123 Group(it) { bottomComposable = it } 124 } 125 } 126 } 127 if (hasScope == true) { 128 if (context.config.root.experimental.friendNotes.get()) { 129 NotesCard(id) 130 } 131 RulesCard(id) 132 } 133 bottomComposable?.invoke() 134 if (hasScope == false) { 135 Column( 136 modifier = Modifier.fillMaxSize(), 137 verticalArrangement = Arrangement.Center, 138 horizontalAlignment = Alignment.CenterHorizontally 139 ) { 140 Text( 141 text = translation["not_found"], 142 fontSize = 20.sp, 143 fontWeight = FontWeight.Bold 144 ) 145 } 146 } 147 } 148 } 149 150 @Composable 151 private fun NotesCard( 152 id: String 153 ) { 154 val coroutineScope = rememberCoroutineScope { Dispatchers.IO } 155 var scopeNotes by rememberAsyncMutableState(null) { 156 context.database.getScopeNotes(id) 157 } 158 159 AutoClearKeyboardFocus() 160 161 EditNoteTextField( 162 modifier = Modifier.padding(8.dp), 163 primaryColor = Color.White, 164 translation = context.translation, 165 content = scopeNotes, 166 setContent = { scopeNotes = it } 167 ) 168 169 DisposableEffect(Unit) { 170 onDispose { 171 coroutineScope.launch { 172 context.database.setScopeNotes(id, scopeNotes) 173 } 174 } 175 } 176 } 177 178 @Composable 179 private fun RulesCard( 180 id: String 181 ) { 182 Spacer(modifier = Modifier.height(16.dp)) 183 184 val rules = rememberAsyncMutableStateList(listOf()) { 185 context.database.getRules(id) 186 } 187 188 SectionTitle(translation["rules_title"]) 189 190 ContentCard { 191 MessagingRuleType.entries.forEach { ruleType -> 192 var ruleEnabled by remember(rules.size) { 193 mutableStateOf(rules.any { it.key == ruleType.key }) 194 } 195 196 val ruleState = context.config.root.rules.getRuleState(ruleType) 197 198 Row( 199 verticalAlignment = Alignment.CenterVertically, 200 modifier = Modifier.padding(all = 4.dp) 201 ) { 202 Text( 203 text = if (ruleType.listMode && ruleState != null) { 204 context.translation["rules.properties.${ruleType.key}.options.${ruleState.key}"] 205 } else context.translation["rules.properties.${ruleType.key}.name"], 206 modifier = Modifier 207 .weight(1f) 208 .padding(start = 5.dp, end = 5.dp) 209 ) 210 Switch(checked = ruleEnabled, 211 enabled = if (ruleType.listMode) ruleState != null else true, 212 onCheckedChange = { 213 context.database.setRule(id, ruleType.key, it) 214 ruleEnabled = it 215 } 216 ) 217 } 218 } 219 } 220 } 221 222 @Composable 223 private fun ContentCard(modifier: Modifier = Modifier, content: @Composable () -> Unit) { 224 ElevatedCard( 225 modifier = Modifier 226 .padding(10.dp) 227 .fillMaxWidth() 228 ) { 229 Column( 230 modifier = Modifier 231 .padding(10.dp) 232 .fillMaxWidth() 233 .then(modifier) 234 ) { 235 content() 236 } 237 } 238 } 239 240 @Composable 241 private fun SectionTitle(title: String) { 242 Text( 243 text = title, 244 maxLines = 1, 245 fontSize = 20.sp, 246 fontWeight = FontWeight.Bold, 247 modifier = Modifier 248 .offset(x = 20.dp) 249 .padding(bottom = 10.dp) 250 ) 251 } 252 253 private fun computeStreakETA(timestamp: Long): String? { 254 val now = System.currentTimeMillis() 255 val stringBuilder = StringBuilder() 256 val diff = timestamp - now 257 val seconds = diff / 1000 258 val minutes = seconds / 60 259 val hours = minutes / 60 260 val days = hours / 24 261 if (days > 0) { 262 stringBuilder.append("$days day ") 263 return stringBuilder.toString() 264 } 265 if (hours > 0) { 266 stringBuilder.append("$hours hours ") 267 return stringBuilder.toString() 268 } 269 if (minutes > 0) { 270 stringBuilder.append("$minutes minutes ") 271 return stringBuilder.toString() 272 } 273 if (seconds > 0) { 274 stringBuilder.append("$seconds seconds ") 275 return stringBuilder.toString() 276 } 277 return null 278 } 279 280 @OptIn(ExperimentalEncodingApi::class) 281 @Composable 282 private fun Friend( 283 id: String, 284 friend: MessagingFriendInfo, 285 streaks: FriendStreaks?, 286 setBottomComposable: ((@Composable () -> Unit)?) -> Unit = {} 287 ) { 288 LaunchedEffect(Unit) { 289 setBottomComposable { 290 Spacer(modifier = Modifier.height(16.dp)) 291 292 if (context.config.root.experimental.e2eEncryption.globalState == true) { 293 SectionTitle(translation["e2ee_title"]) 294 var hasSecretKey by rememberAsyncMutableState(defaultValue = false) { 295 context.e2eeImplementation.friendKeyExists(friend.userId) 296 } 297 var importDialog by remember { mutableStateOf(false) } 298 299 if (importDialog) { 300 Dialog( 301 onDismissRequest = { importDialog = false } 302 ) { 303 dialogs.RawInputDialog(onDismiss = { importDialog = false }, onConfirm = { newKey -> 304 importDialog = false 305 runCatching { 306 val key = Base64.decode(newKey) 307 if (key.size != 32) { 308 context.longToast("Invalid key size (must be 32 bytes)") 309 return@runCatching 310 } 311 312 context.coroutineScope.launch { 313 context.e2eeImplementation.storeSharedSecretKey(friend.userId, key) 314 context.longToast("Successfully imported key") 315 } 316 317 hasSecretKey = true 318 }.onFailure { 319 context.longToast("Failed to import key: ${it.message}") 320 context.log.error("Failed to import key", it) 321 } 322 }) 323 } 324 } 325 326 ContentCard { 327 Row( 328 verticalAlignment = Alignment.CenterVertically, 329 horizontalArrangement = Arrangement.spacedBy(10.dp) 330 ) { 331 if (hasSecretKey) { 332 OutlinedButton(onClick = { 333 context.coroutineScope.launch { 334 val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@launch) 335 //TODO: fingerprint auth 336 context.activity!!.startActivity(Intent.createChooser(Intent().apply { 337 action = Intent.ACTION_SEND 338 putExtra(Intent.EXTRA_TEXT, secretKey) 339 type = "text/plain" 340 }, "").apply { 341 putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf( 342 Intent().apply { 343 putExtra(Intent.EXTRA_TEXT, secretKey) 344 putExtra(Intent.EXTRA_SUBJECT, secretKey) 345 }) 346 ) 347 }) 348 } 349 }) { 350 Text( 351 text = "Export Base64", 352 maxLines = 1 353 ) 354 } 355 } 356 357 OutlinedButton(onClick = { importDialog = true }) { 358 Text( 359 text = "Import Base64", 360 maxLines = 1 361 ) 362 } 363 } 364 } 365 } 366 } 367 } 368 Column( 369 modifier = Modifier 370 .padding(5.dp) 371 .fillMaxWidth(), 372 horizontalAlignment = Alignment.CenterHorizontally 373 ) { 374 val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie( 375 friend.selfieId, friend.bitmojiId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D 376 ) 377 BitmojiImage(context = context, url = bitmojiUrl, size = 120) 378 Text( 379 text = friend.displayName ?: friend.mutableUsername, 380 maxLines = 1, 381 fontSize = 20.sp, 382 fontWeight = FontWeight.Bold 383 ) 384 Text( 385 text = friend.mutableUsername, 386 maxLines = 1, 387 fontSize = 12.sp, 388 fontWeight = FontWeight.Light 389 ) 390 } 391 392 if (context.config.root.experimental.storyLogger.get()) { 393 Row( 394 modifier = Modifier.fillMaxWidth(), 395 horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), 396 ) { 397 Button(onClick = { 398 routes.loggedStories.navigate { 399 put("id", id) 400 } 401 }) { 402 Text(translation["logged_stories_button"]) 403 } 404 } 405 406 Spacer(modifier = Modifier.height(16.dp)) 407 } 408 409 Column { 410 //streaks 411 streaks?.let { 412 var shouldNotify by remember { mutableStateOf(it.notify) } 413 SectionTitle(translation["streaks_title"]) 414 ContentCard { 415 Row( 416 verticalAlignment = Alignment.CenterVertically 417 ) { 418 Column( 419 modifier = Modifier.weight(1f), 420 ) { 421 Text( 422 text = translation.format( 423 "streaks_length_text", "length" to streaks.length.toString() 424 ), maxLines = 1 425 ) 426 Text( 427 text = computeStreakETA(streaks.expirationTimestamp)?.let { translation.format( 428 "streaks_expiration_text", 429 "eta" to it 430 ) } ?: translation["streaks_expiration_text_expired"], 431 maxLines = 1 432 ) 433 } 434 Row( 435 verticalAlignment = Alignment.CenterVertically 436 ) { 437 Text( 438 text = translation["reminder_button"], 439 maxLines = 1, 440 modifier = Modifier.padding(end = 10.dp) 441 ) 442 Switch(checked = shouldNotify, onCheckedChange = { 443 context.database.setFriendStreaksNotify(id, it) 444 shouldNotify = it 445 }) 446 } 447 } 448 } 449 } 450 } 451 } 452 453 @Composable 454 private fun Group( 455 group: MessagingGroupInfo, 456 setBottomComposable: ((@Composable () -> Unit)?) -> Unit = {} 457 ) { 458 Column( 459 modifier = Modifier 460 .padding(10.dp) 461 .fillMaxWidth(), 462 horizontalAlignment = Alignment.CenterHorizontally 463 ) { 464 Text( 465 text = group.name, maxLines = 1, fontSize = 20.sp, fontWeight = FontWeight.Bold 466 ) 467 Text( 468 text = translation.format( 469 "participants_text", "count" to group.participantsCount.toString() 470 ), maxLines = 1, fontSize = 12.sp, fontWeight = FontWeight.Light 471 ) 472 } 473 } 474 }