diff --git a/README.md b/README.md index 522bb9939..a4ddf07ff 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Amethyst brings the best social network to your Android phone. Just insert your - [ ] Delegated Event Signing (NIP-26) - [ ] Account Creation / Backup Guidance (NIP-06) - [ ] Message Sent feedback (NIP-20) +- [ ] Polls (NIP-69) # Development Overview diff --git a/app/build.gradle b/app/build.gradle index 878ecefcd..7a512c168 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,6 +31,10 @@ android { applicationIdSuffix '.debug' versionNameSuffix '-DEBUG' resValue "string", "app_name", "@string/app_name_debug" + + lintOptions{ + disable 'MissingTranslation' + } } } compileOptions { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt new file mode 100644 index 000000000..f4e67fac6 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt @@ -0,0 +1,270 @@ +package com.vitorpamplona.amethyst.ui.actions + +import android.widget.Toast +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.ui.components.* +import com.vitorpamplona.amethyst.ui.note.ReplyInformation +import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine +import kotlinx.coroutines.delay + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) { + val pollViewModel: NewPollViewModel = viewModel() + + val context = LocalContext.current + + // initialize focus reference to be able to request focus programmatically + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + val scrollState = rememberScrollState() + + LaunchedEffect(Unit) { + pollViewModel.load(account, baseReplyTo, quote) + delay(100) + focusRequester.requestFocus() + + pollViewModel.imageUploadingError.collect { error -> + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } + } + + Dialog( + onDismissRequest = { onClose() }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false + ) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + Column( + modifier = Modifier + .padding(start = 10.dp, end = 10.dp, top = 10.dp) + .imePadding() + .weight(1f) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + ClosePollButton(onCancel = { + pollViewModel.cancel() + onClose() + }) + + PollButton( + onPost = { + pollViewModel.sendPoll() + onClose() + }, + isActive = pollViewModel.message.text.isNotBlank() && + !pollViewModel.isUploadingImage + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + ) { + if (pollViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) { + ReplyInformation(pollViewModel.replyTos, pollViewModel.mentions, account, "✖ ") { + pollViewModel.removeFromReplyList(it) + } + } + + OutlinedTextField( + value = pollViewModel.message, + onValueChange = { + pollViewModel.updateMessage(it) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences + ), + modifier = Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colors.surface, + shape = RoundedCornerShape(8.dp) + ) + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) { + keyboardController?.show() + } + }, + placeholder = { + Text( + text = stringResource(R.string.what_s_on_your_mind), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + colors = TextFieldDefaults + .outlinedTextFieldColors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent + ), + visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + + val myUrlPreview = pollViewModel.urlPreview + if (myUrlPreview != null) { + Row(modifier = Modifier.padding(top = 5.dp)) { + if (isValidURL(myUrlPreview)) { + val removedParamsFromUrl = + myUrlPreview.split("?")[0].lowercase() + if (imageExtension.matcher(removedParamsFromUrl).matches()) { + AsyncImage( + model = myUrlPreview, + contentDescription = myUrlPreview, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(15.dp)) + .border( + 1.dp, + MaterialTheme.colors.onSurface.copy(alpha = 0.12f), + RoundedCornerShape(15.dp) + ) + ) + } else if (videoExtension.matcher(removedParamsFromUrl) + .matches() + ) { + VideoView(myUrlPreview) + } else { + UrlPreview(myUrlPreview, myUrlPreview) + } + } else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) { + UrlPreview("https://$myUrlPreview", myUrlPreview) + } + } + } + } + } + + val userSuggestions = pollViewModel.userSuggestions + if (userSuggestions.isNotEmpty()) { + LazyColumn( + contentPadding = PaddingValues( + top = 10.dp + ), + modifier = Modifier.heightIn(0.dp, 300.dp) + ) { + itemsIndexed( + userSuggestions, + key = { _, item -> item.pubkeyHex } + ) { index, item -> + UserLine(item, account) { + pollViewModel.autocompleteWithUser(item) + } + } + } + } + + Row(modifier = Modifier.fillMaxWidth()) { + /*UploadFromGallery( + isUploading = pollViewModel.isUploadingImage + ) { + pollViewModel.upload(it, context) + }*/ + } + } + } + } + } +} + +@Composable +fun ClosePollButton(onCancel: () -> Unit) { + Button( + onClick = { + onCancel() + }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = Color.Gray + ) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_close), + contentDescription = stringResource(id = R.string.cancel), + modifier = Modifier.size(20.dp), + tint = Color.White + ) + } +} + +@Composable +fun PollButton(modifier: Modifier = Modifier, onPost: () -> Unit = {}, isActive: Boolean) { + Button( + modifier = modifier, + onClick = { + if (isActive) { + onPost() + } + }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray + ) + ) { + Text(text = stringResource(R.string.poll), color = Color.White) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollViewModel.kt new file mode 100644 index 000000000..f9b72569e --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollViewModel.kt @@ -0,0 +1,174 @@ +package com.vitorpamplona.amethyst.ui.actions + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.* +import com.vitorpamplona.amethyst.service.nip19.Nip19 +import com.vitorpamplona.amethyst.ui.components.isValidURL +import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator +import kotlinx.coroutines.flow.MutableSharedFlow + +class NewPollViewModel : ViewModel() { + private var account: Account? = null + private var originalNote: Note? = null + + var mentions by mutableStateOf?>(null) + var replyTos by mutableStateOf?>(null) + + var message by mutableStateOf(TextFieldValue("")) + var urlPreview by mutableStateOf(null) + var isUploadingImage by mutableStateOf(false) + val imageUploadingError = MutableSharedFlow() + + var userSuggestions by mutableStateOf>(emptyList()) + var userSuggestionAnchor: TextRange? = null + + fun load(account: Account, replyingTo: Note?, quote: Note?) { + originalNote = replyingTo + replyingTo?.let { replyNote -> + this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote) + replyNote.author?.let { replyUser -> + val currentMentions = replyNote.mentions ?: emptyList() + if (currentMentions.contains(replyUser)) { + this.mentions = currentMentions + } else { + this.mentions = currentMentions.plus(replyUser) + } + } + } + + quote?.let { + message = TextFieldValue(message.text + "\n\n@${it.idNote()}") + } + + this.account = account + } + + fun addUserToMentions(user: User) { + mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user) + } + + fun addNoteToReplyTos(note: Note) { + note.author?.let { addUserToMentions(it) } + replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note) + } + + fun tagIndex(user: User): Int { + // Postr Events assembles replies before mentions in the tag order + return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0) + } + + fun tagIndex(note: Note): Int { + // Postr Events assembles replies before mentions in the tag order + return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0) + } + + fun sendPoll() { + // adds all references to mentions and reply tos + message.text.split('\n').forEach { paragraph: String -> + paragraph.split(' ').forEach { word: String -> + val results = parseDirtyWordForKey(word) + + if (results?.key?.type == Nip19.Type.USER) { + addUserToMentions(LocalCache.getOrCreateUser(results.key.hex)) + } else if (results?.key?.type == Nip19.Type.NOTE) { + addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex)) + } else if (results?.key?.type == Nip19.Type.ADDRESS) { + val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex) + if (note != null) { + addNoteToReplyTos(note) + } + } + } + } + + // Tags the text in the correct order. + val newMessage = message.text.split('\n').map { paragraph: String -> + paragraph.split(' ').map { word: String -> + val results = parseDirtyWordForKey(word) + if (results?.key?.type == Nip19.Type.USER) { + val user = LocalCache.getOrCreateUser(results.key.hex) + + "#[${tagIndex(user)}]${results.restOfWord}" + } else if (results?.key?.type == Nip19.Type.NOTE) { + val note = LocalCache.getOrCreateNote(results.key.hex) + + "#[${tagIndex(note)}]${results.restOfWord}" + } else if (results?.key?.type == Nip19.Type.ADDRESS) { + val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex) + if (note != null) { + "#[${tagIndex(note)}]${results.restOfWord}" + } else { + word + } + } else { + word + } + }.joinToString(" ") + }.joinToString("\n") + + if (originalNote?.channel() != null) { + account?.sendChannelMessage(newMessage, originalNote!!.channel()!!.idHex, originalNote!!, mentions) + } else { + account?.sendPost(newMessage, replyTos, mentions) + } + + message = TextFieldValue("") + urlPreview = null + isUploadingImage = false + mentions = null + } + + fun cancel() { + message = TextFieldValue("") + urlPreview = null + isUploadingImage = false + mentions = null + } + + fun findUrlInMessage(): String? { + return message.text.split('\n').firstNotNullOfOrNull { paragraph -> + paragraph.split(' ').firstOrNull { word: String -> + isValidURL(word) || noProtocolUrlValidator.matcher(word).matches() + } + } + } + + fun removeFromReplyList(it: User) { + mentions = mentions?.minus(it) + } + + fun updateMessage(it: TextFieldValue) { + message = it + urlPreview = findUrlInMessage() + + if (it.selection.collapsed) { + val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") + userSuggestionAnchor = it.selection + if (lastWord.startsWith("@") && lastWord.length > 2) { + userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + } else { + userSuggestions = emptyList() + } + } + } + + fun autocompleteWithUser(item: User) { + userSuggestionAnchor?.let { + val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + val lastWordStart = it.end - lastWord.length + val wordToInsert = "@${item.pubkeyNpub()} " + + message = TextFieldValue( + message.text.replaceRange(lastWordStart, it.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) + ) + userSuggestionAnchor = null + userSuggestions = emptyList() + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewPollButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewPollButton.kt new file mode 100644 index 000000000..5923caaab --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewPollButton.kt @@ -0,0 +1,47 @@ +package com.vitorpamplona.amethyst.buttons + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.actions.NewPollView + +@Composable +fun NewPollButton(account: Account) { + var wantsToPost by remember { + mutableStateOf(false) + } + + if (wantsToPost) { + NewPollView({ wantsToPost = false }, account = account) + } + + OutlinedButton( + onClick = { wantsToPost = true }, + modifier = Modifier.size(55.dp), + shape = CircleShape, + colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary), + contentPadding = PaddingValues(0.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_poll), + null, + modifier = Modifier.size(26.dp), + tint = Color.White + ) + } +} 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 fa698b275..39f861f59 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 @@ -18,7 +18,7 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.vitorpamplona.amethyst.buttons.NewChannelButton -import com.vitorpamplona.amethyst.buttons.NewNoteButton +import com.vitorpamplona.amethyst.buttons.NewPollButton import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.AppNavigation import com.vitorpamplona.amethyst.ui.navigation.AppTopBar @@ -71,7 +71,8 @@ fun FloatingButton(navController: NavHostController, accountViewModel: AccountSt // Does nothing. } is AccountState.LoggedIn -> { - NewNoteButton(state.account) + NewPollButton(state.account) + // NewNoteButton(state.account) } } } diff --git a/app/src/main/res/drawable-hdpi/ic_poll.png b/app/src/main/res/drawable-hdpi/ic_poll.png new file mode 100644 index 000000000..ff8df419e Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_poll.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_poll.png b/app/src/main/res/drawable-mdpi/ic_poll.png new file mode 100644 index 000000000..a0282c9f3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_poll.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_poll.png b/app/src/main/res/drawable-xhdpi/ic_poll.png new file mode 100644 index 000000000..83191fa48 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_poll.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_poll.png b/app/src/main/res/drawable-xxhdpi/ic_poll.png new file mode 100644 index 000000000..b792c3025 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_poll.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_poll.png b/app/src/main/res/drawable-xxxhdpi/ic_poll.png new file mode 100644 index 000000000..826e1afef Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_poll.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f6d729ed9..fee140f6e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -220,5 +220,6 @@ https://twitter.com/<user>/status/<proof post> "<Unable to decrypt private message>\n\nYou were cited in a private/encrypted conversation between %1$s and %2$s." + Poll