kopia lustrzana https://github.com/vitorpamplona/amethyst
Image uploading and Image/URL previews on new posts.
rodzic
250e970aca
commit
bf827fd1f4
|
@ -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'
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
|
|
@ -31,7 +31,7 @@ object NostrAccountDataSource: NostrDataSource("AccountData") {
|
|||
fun createAccountFilter(): JsonFilter {
|
||||
return JsonFilter(
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 4), // 4 days
|
||||
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 4 days
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
|
||||
object ImageUploader {
|
||||
private fun encodeImage(bitmap: Bitmap): ByteArray {
|
||||
val byteArrayOutPutStream = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutPutStream)
|
||||
return byteArrayOutPutStream.toByteArray()
|
||||
}
|
||||
|
||||
fun uploadImage(bitmap: Bitmap, onSuccess: (String) -> 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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<PostViewModel>().apply {
|
||||
val postViewModel: NewPostViewModel = viewModel<NewPostViewModel>().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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<String?>(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})") }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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>(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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue