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),
+ )
+ }
}
}
}