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 }