diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt new file mode 100644 index 000000000..51dc0461d --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -0,0 +1,351 @@ +package com.vitorpamplona.amethyst.ui.actions + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.ui.note.ChannelName +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController: NavController) { + val searchBarViewModel: SearchBarViewModel = viewModel() + + Dialog( + onDismissRequest = { + NostrSearchEventOrUserDataSource.clear() + searchBarViewModel.clean() + onClose() + }, + properties = DialogProperties( + dismissOnClickOutside = false + ) + ) { + Surface() { + Column( + modifier = Modifier.padding(10.dp).heightIn(min = 500.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = { + searchBarViewModel.clean() + NostrSearchEventOrUserDataSource.clear() + onClose() + }) + + Text( + text = stringResource(R.string.channel_list_join_conversation), + fontWeight = FontWeight.Bold + ) + + Text( + text = "", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + fontWeight = FontWeight.Bold + ) + } + + Spacer(modifier = Modifier.height(15.dp)) + + RenderSeach(searchBarViewModel, account, navController) + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun RenderSeach( + searchBarViewModel: SearchBarViewModel, + account: Account, + navController: NavController +) { + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + + // initialize focus reference to be able to request focus programmatically + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + val onlineSearch = NostrSearchEventOrUserDataSource + + val dbState = LocalCache.live.observeAsState() + val db = dbState.value ?: return + + val lifeCycleOwner = LocalLifecycleOwner.current + + // Create a channel for processing search queries. + val searchTextChanges = remember { + Channel(Channel.CONFLATED) + } + + LaunchedEffect(db) { + withContext(Dispatchers.IO) { + if (searchBarViewModel.isSearching()) { + searchBarViewModel.invalidateData() + } + } + } + + LaunchedEffect(Unit) { + delay(100) + focusRequester.requestFocus() + + // Wait for text changes to stop for 300 ms before firing off search. + withContext(Dispatchers.IO) { + searchTextChanges.receiveAsFlow() + .filter { it.isNotBlank() } + .distinctUntilChanged() + .debounce(300) + .collectLatest { + if (it.length >= 2) { + onlineSearch.search(it.trim()) + } + + searchBarViewModel.invalidateData() + + // makes sure to show the top of the search + scope.launch(Dispatchers.Main) { + listState.animateScrollToItem(0) + } + } + } + } + + DisposableEffect(Unit) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Join Start") + NostrSearchEventOrUserDataSource.start() + searchBarViewModel.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Join Stop") + NostrSearchEventOrUserDataSource.clear() + NostrSearchEventOrUserDataSource.stop() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + } + } + + // LAST ROW + Row( + modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.channel_list_user_or_group_id)) }, + value = searchBarViewModel.searchValue, + onValueChange = { + searchBarViewModel.updateSearchValue(it) + scope.launch(Dispatchers.IO) { + searchTextChanges.trySend(it) + } + }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.ic_search), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified + ) + }, + modifier = Modifier + .weight(1f, true) + .defaultMinSize(minHeight = 20.dp) + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) { + keyboardController?.show() + } + }, + placeholder = { + Text( + text = stringResource(R.string.channel_list_user_or_group_id_demo), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + trailingIcon = { + if (searchBarViewModel.isTrailingIconVisible) { + IconButton( + onClick = { + searchBarViewModel.clean() + onlineSearch.clear() + } + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.clear) + ) + } + } + } + ) + } + + if (searchBarViewModel.searchValue.isNotBlank()) { + Row( + modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp) + ) { + LazyColumn( + modifier = Modifier.fillMaxHeight(), + contentPadding = PaddingValues( + top = 10.dp, + bottom = 10.dp + ), + state = listState + ) { + itemsIndexed( + searchBarViewModel.searchResults.value, + key = { _, item -> "u" + item.pubkeyHex } + ) { _, item -> + UserComposeForChat( + item, + account = account, + navController = navController + ) + } + + itemsIndexed( + searchBarViewModel.searchResultsChannels.value, + key = { _, item -> "c" + item.idHex } + ) { _, item -> + ChannelName( + channelIdHex = item.idHex, + channelPicture = item.profilePicture(), + channelTitle = { + Text( + "${item.info.name}", + fontWeight = FontWeight.Bold + ) + }, + channelLastTime = null, + channelLastContent = item.info.about, + false, + onClick = { navController.navigate("Channel/${item.idHex}") } + ) + } + } + } + } +} + +@Composable +fun UserComposeForChat( + baseUser: User, + account: Account, + navController: NavController +) { + Column( + modifier = + Modifier.clickable( + onClick = { navController.navigate("Room/${baseUser.pubkeyHex}") } + ) + ) { + Row( + modifier = Modifier + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + UserPicture(baseUser, navController, account.userProfile(), 55.dp) + + Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + UsernameDisplay(baseUser) + } + + val baseUserState by baseUser.live().metadata.observeAsState() + val user = baseUserState?.user ?: return + + Text( + user.info?.about ?: "", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt similarity index 50% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt index 771b5f658..7a4d3dad8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt @@ -6,69 +6,78 @@ import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.navigation.NavController import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.ui.actions.NewPollView +import com.vitorpamplona.amethyst.ui.actions.JoinUserOrChannelView +import com.vitorpamplona.amethyst.ui.actions.NewChannelView @Composable -fun FabColumn(account: Account) { +fun ChannelFabColumn(account: Account, navController: NavController) { var isOpen by remember { mutableStateOf(false) } - var wantsToPoll by remember { + + var wantsToJoinChannelOrUser by remember { mutableStateOf(false) } - var wantsToPost by remember { + + var wantsToCreateChannel by remember { mutableStateOf(false) } + if (wantsToCreateChannel) { + NewChannelView({ wantsToCreateChannel = false }, account = account) + } + + if (wantsToJoinChannelOrUser) { + JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, account = account, navController = navController) + } + Column() { if (isOpen) { OutlinedButton( - onClick = { - wantsToPoll = true - isOpen = false - }, - modifier = Modifier.size(45.dp), + onClick = { wantsToJoinChannelOrUser = true; isOpen = false }, + modifier = Modifier.size(55.dp), shape = CircleShape, colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary), - contentPadding = PaddingValues(0.dp) + contentPadding = PaddingValues(bottom = 3.dp) ) { - Icon( - painter = painterResource(R.drawable.ic_poll), - null, - modifier = Modifier.size(26.dp), - tint = Color.White + Text( + text = stringResource(R.string.channel_list_join_channel), + color = Color.White, + textAlign = TextAlign.Center ) } Spacer(modifier = Modifier.height(20.dp)) OutlinedButton( - onClick = { - wantsToPost = true - isOpen = false - }, - modifier = Modifier.size(45.dp), + onClick = { wantsToCreateChannel = true; isOpen = false }, + modifier = Modifier.size(55.dp), shape = CircleShape, colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary), - contentPadding = PaddingValues(0.dp) + contentPadding = PaddingValues(bottom = 3.dp) ) { - Icon( - painter = painterResource(R.drawable.ic_lists), - null, - modifier = Modifier.size(26.dp), - tint = Color.White + Text( + text = stringResource(R.string.channel_list_create_channel), + color = Color.White, + textAlign = TextAlign.Center ) } Spacer(modifier = Modifier.height(20.dp)) } + OutlinedButton( onClick = { isOpen = !isOpen }, modifier = Modifier.size(55.dp), @@ -77,19 +86,11 @@ fun FabColumn(account: Account) { contentPadding = PaddingValues(0.dp) ) { Icon( - painter = painterResource(R.drawable.ic_compose), - null, + imageVector = Icons.Outlined.Add, + contentDescription = stringResource(R.string.new_channel), modifier = Modifier.size(26.dp), tint = Color.White ) } } - - if (wantsToPost) { - // NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account) - } - - if (wantsToPoll) { - NewPollView({ wantsToPoll = false }, account = account) - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 63e0e4d92..d2cdff261 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -386,7 +386,6 @@ private fun JoinButton(account: Account, channel: Channel, navController: NavCon modifier = Modifier.padding(horizontal = 3.dp), onClick = { account.joinChannel(channel.idHex) - navController.navigate(Route.Message.route) }, shape = RoundedCornerShape(20.dp), colors = ButtonDefaults diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 94c960493..54579a4d4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -24,7 +24,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController -import com.vitorpamplona.amethyst.buttons.NewChannelButton +import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn import com.vitorpamplona.amethyst.ui.buttons.NewNoteButton import com.vitorpamplona.amethyst.ui.navigation.* import com.vitorpamplona.amethyst.ui.navigation.AccountSwitchBottomSheet @@ -101,7 +101,7 @@ fun FloatingButtons(navController: NavHostController, accountViewModel: AccountV NewNoteButton(state.account, accountViewModel, navController) } if (currentRoute(navController) == Route.Message.base) { - NewChannelButton(state.account) + ChannelFabColumn(state.account, navController) } if (currentRoute(navController)?.substringBefore("?") == Route.Video.base) { NewImageButton(accountViewModel, navController) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 50c42780f..9de6c008b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -396,4 +396,10 @@ for %1$s Notify: + + Join Conversation + User or Group\'s ID + npub, nevent or hex + Create + Join