diff --git a/app/build.gradle b/app/build.gradle index b4466f854..758c29789 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -90,7 +90,10 @@ dependencies { // link preview implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1' - implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha03' + implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha04' + + // upload pictures: + implementation "com.google.accompanist:accompanist-permissions:0.28.0" testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d01064bdc..e112da23a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + Unit) { + val client = OkHttpClient.Builder().build() + + val body: RequestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "image", + "${UUID.randomUUID()}.png", + encodeImage(bitmap).toRequestBody("image/png".toMediaType()) + ) + .build() + + val request: Request = Request.Builder() + .url("https://api.imgur.com/3/image") + .header("Authorization", "Client-ID e6aea87296f3f96") + .post(body) + .build() + + client.newCall(request).enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + response.use { + val tree = jacksonObjectMapper().readTree(response.body!!.string()) + val url = tree.get("data").get("link").asText() + onSuccess(url) + } + } + + override fun onFailure(call: Call, e: IOException) { + e.printStackTrace() + } + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 42fa5f570..d1d4892c1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -17,48 +17,73 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue 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.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.TagLink +import com.vitorpamplona.amethyst.ui.components.UrlPreview +import com.vitorpamplona.amethyst.ui.components.imageExtension +import com.vitorpamplona.amethyst.ui.components.isValidURL +import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator +import com.vitorpamplona.amethyst.ui.components.tagIndex +import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery +import kotlinx.coroutines.delay import nostr.postr.events.TextNoteEvent -class PostViewModel: ViewModel() { - var account: Account? = null - var message by mutableStateOf("") - var replyingTo: Note? = null - - fun sendPost() { - account?.sendPost(message, replyingTo) - } -} +@OptIn(ExperimentalComposeUiApi::class) @Composable fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account) { - val postViewModel: PostViewModel = viewModel().apply { + val postViewModel: NewPostViewModel = viewModel().apply { this.replyingTo = replyingTo this.account = account } - val dialogProperties = DialogProperties() + val context = LocalContext.current + + // initialize focus reference to be able to request focus programmatically + val focusRequester = FocusRequester() + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + delay(100) + focusRequester.requestFocus() + } + Dialog( - onDismissRequest = { onClose() }, properties = dialogProperties + onDismissRequest = { onClose() }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false + ) ) { Surface( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(0.5f) + .fillMaxHeight() ) { Column( modifier = Modifier.padding(10.dp) @@ -69,7 +94,14 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account) horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - CloseButton(onCancel = onClose) + CloseButton(onCancel = { + postViewModel.cancel() + onClose() + }) + + UploadFromGallery { + postViewModel.upload(it, context) + } PostButton( onPost = { @@ -96,16 +128,26 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account) OutlinedTextField( value = postViewModel.message, - onValueChange = { postViewModel.message = it }, + onValueChange = { + postViewModel.message = it + postViewModel.urlPreview = postViewModel.findUrlInMessage() + }, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences ), - modifier = Modifier.fillMaxWidth().fillMaxHeight() + 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 = "What's on your mind?", @@ -117,8 +159,32 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account) unfocusedBorderColor = Color.Transparent, focusedBorderColor = Color.Transparent ) - ) + + val myUrlPreview = postViewModel.urlPreview + if (myUrlPreview != null) { + Column(modifier = Modifier.padding(top = 5.dp)) { + val removedParamsFromUrl = myUrlPreview.split("?")[0].toLowerCase() + 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 { + UrlPreview("https://$myUrlPreview", myUrlPreview, false) + } + } + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt new file mode 100644 index 000000000..8fb7da074 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -0,0 +1,58 @@ +package com.vitorpamplona.amethyst.ui.actions + +import android.content.Context +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.isValidURL +import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator + +class NewPostViewModel: ViewModel() { + var account: Account? = null + var replyingTo: Note? = null + + var message by mutableStateOf("") + var urlPreview by mutableStateOf(null) + + fun sendPost() { + account?.sendPost(message, replyingTo) + message = "" + urlPreview = null + } + + fun upload(it: Uri, context: Context) { + val img = if (Build.VERSION.SDK_INT < 28) { + MediaStore.Images.Media.getBitmap(context.contentResolver, it) + } else { + ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, it)) + } + + img?.let { + ImageUploader.uploadImage(img) { + message = message + "\n\n" + it + urlPreview = findUrlInMessage() + } + } + } + + fun cancel() { + message = "" + urlPreview = null + } + + fun findUrlInMessage(): String? { + return message.split('\n').firstNotNullOfOrNull { paragraph -> + paragraph.split(' ').firstOrNull { word: String -> + isValidURL(word) || noProtocolUrlValidator.matcher(word).matches() + }.apply { println("(${this})") } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt new file mode 100644 index 000000000..2e0c91ff3 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt @@ -0,0 +1,96 @@ +package com.vitorpamplona.amethyst.ui.navigation + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun UploadFromGallery(onImageChosen: (Uri) -> Unit) { + val cameraPermissionState = rememberPermissionState( + android.Manifest.permission.READ_MEDIA_IMAGES + ) + + if (cameraPermissionState.status.isGranted) { + var showGallerySelect by remember { mutableStateOf(false) } + if (showGallerySelect) { + GallerySelect( + onImageUri = { uri -> + showGallerySelect = false + if (uri != null) + onImageChosen(uri) + } + ) + } else { + Box() { + Button( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(4.dp), + onClick = { + showGallerySelect = true + } + ) { + Text("Add Image from Gallery") + } + } + } + } else { + Column { + val textToShow = if (cameraPermissionState.status.shouldShowRationale) { + // If the user has denied the permission but the rationale can be shown, + // then gently explain why the app requires this permission + "Grant access to quickly upload pictures before posting" + } else { + // If it's the first time the user lands on this feature, or the user + // doesn't want to be asked again for this permission, explain that the + // permission is required + "Grant access to quickly upload pictures before posting" + } + Text(textToShow) + Button(onClick = { cameraPermissionState.launchPermissionRequest() }) { + Text("Request permission") + } + } + } +} + + +@Composable +fun GallerySelect( + onImageUri: (Uri?) -> Unit = { } +) { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + onResult = { uri: Uri? -> + onImageUri(uri) + } + ) + + @Composable + fun LaunchGallery() { + SideEffect { + launcher.launch("image/*") + } + } + + LaunchGallery() +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt index 3b92ea1b8..9258481e7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt @@ -34,31 +34,30 @@ import com.baha.url.preview.UrlInfoItem @Composable -fun UrlPreview(url: String, urlText: String) { +fun UrlPreview(url: String, urlText: String, showUrlIfError: Boolean = true) { var urlPreviewState by remember { mutableStateOf(UrlPreviewState.Loading) } - // Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created). - LaunchedEffect(urlPreviewState) { - if (urlPreviewState == UrlPreviewState.Loading) { - val urlPreview = BahaUrlPreview(url, object : IUrlPreviewCallback { - override fun onComplete(urlInfo: UrlInfoItem) { - if (urlInfo.allFetchComplete() && urlInfo.url == url) - urlPreviewState = UrlPreviewState.Loaded(urlInfo) - else - urlPreviewState = UrlPreviewState.Empty - } - - override fun onFailed(throwable: Throwable) { - urlPreviewState = UrlPreviewState.Error("Error parsing preview for ${url}: ${throwable.message}") - } - }) - - urlPreview.fetchUrlPreview() - } - } - val uri = LocalUriHandler.current + // Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created). + LaunchedEffect(url) { + println("url preview ${url}") + BahaUrlPreview(url, object : IUrlPreviewCallback { + override fun onComplete(urlInfo: UrlInfoItem) { + println("completed ${urlInfo.title}") + if (urlInfo.allFetchComplete() && urlInfo.url == url) + urlPreviewState = UrlPreviewState.Loaded(urlInfo) + else + urlPreviewState = UrlPreviewState.Empty + } + + override fun onFailed(throwable: Throwable) { + println("failed") + urlPreviewState = UrlPreviewState.Error("Error parsing preview for ${url}: ${throwable.message}") + } + }).fetchUrlPreview() + } + Crossfade(targetState = urlPreviewState) { state -> when (state) { is UrlPreviewState.Loaded -> { @@ -97,11 +96,13 @@ fun UrlPreview(url: String, urlText: String) { } } else -> { - ClickableText( - text = AnnotatedString("$urlText "), - onClick = { runCatching { uri.openUri(url) } }, - style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary), - ) + if (showUrlIfError) { + ClickableText( + text = AnnotatedString("$urlText "), + onClick = { runCatching { uri.openUri(url) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary), + ) + } } } }