diff --git a/app/build.gradle b/app/build.gradle
index 5a206becf..5e5be2e08 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -137,7 +137,7 @@ dependencies {
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05'
// view videos
- implementation 'com.google.android.exoplayer:exoplayer:2.18.5'
+ implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
// Load images from the web.
implementation "io.coil-kt:coil-compose:$coil_version"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 724d0252e..b7becbfa0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,7 @@
+
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt
index a7543e56f..c4e6ff8ee 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt
@@ -18,7 +18,7 @@ class FileHeader(
val description: String? = null
) {
companion object {
- fun prepare(fileUrl: String, mimeType: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
+ fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
try {
val imageData = URL(fileUrl).readBytes()
val sha256 = MessageDigest.getInstance("SHA-256")
@@ -55,7 +55,7 @@ class FileHeader(
null
}
- onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, ""))
+ onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, description))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()
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 e5068b3d5..37904090f 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
@@ -1,5 +1,9 @@
package com.vitorpamplona.amethyst.ui.actions
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Build
+import android.util.Size
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
@@ -13,10 +17,15 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CurrencyBitcoin
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.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
@@ -25,15 +34,18 @@ 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.graphics.asImageBitmap
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.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDirection
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.compose.viewModel
@@ -48,7 +60,9 @@ import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
@OptIn(ExperimentalComposeUiApi::class)
@Composable
@@ -188,6 +202,19 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
+ val url = postViewModel.contentToAddUrl
+ if (url != null) {
+ ImageVideoDescription(
+ url,
+ onAdd = { description ->
+ postViewModel.upload(url, description, context)
+ },
+ onCancel = {
+ postViewModel.contentToAddUrl = null
+ }
+ )
+ }
+
val user = postViewModel.account?.userProfile()
val lud16 = user?.info?.lnAddress()
@@ -277,7 +304,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(bottom = 10.dp)
) {
- postViewModel.upload(it, context)
+ postViewModel.selectImage(it)
}
if (postViewModel.canUsePoll) {
@@ -457,3 +484,149 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
)
}
}
+
+@Composable
+fun ImageVideoDescription(
+ uri: Uri,
+ onAdd: (String) -> Unit,
+ onCancel: () -> Unit
+) {
+ val resolver = LocalContext.current.contentResolver
+ val mediaType = resolver.getType(uri) ?: ""
+ val scope = rememberCoroutineScope()
+
+ val isImage = mediaType.startsWith("image")
+ val isVideo = mediaType.startsWith("video")
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 30.dp, end = 30.dp)
+ .clip(shape = RoundedCornerShape(10.dp))
+ .border(
+ 1.dp,
+ MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
+ RoundedCornerShape(15.dp)
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(30.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp)
+ ) {
+ Text(
+ text = stringResource(
+ if (isImage) {
+ R.string.content_description_add_image
+ } else {
+ if (isVideo) {
+ R.string.content_description_add_video
+ } else {
+ R.string.content_description_add_document
+ }
+ }
+ ),
+ fontSize = 20.sp,
+ fontWeight = FontWeight.W500,
+ modifier = Modifier
+ .padding(start = 10.dp)
+ .weight(1.0f)
+ )
+
+ IconButton(
+ modifier = Modifier.size(30.dp),
+ onClick = onCancel
+ ) {
+ Icon(
+ imageVector = Icons.Default.Cancel,
+ null,
+ modifier = Modifier
+ .padding(end = 5.dp)
+ .size(30.dp),
+ tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ }
+ }
+
+ Divider()
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp)
+ ) {
+ if (mediaType.startsWith("image")) {
+ AsyncImage(
+ model = uri.toString(),
+ contentDescription = uri.toString(),
+ contentScale = ContentScale.FillWidth,
+ modifier = Modifier
+ .padding(top = 4.dp)
+ .fillMaxWidth()
+ )
+ } else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ var bitmap by remember { mutableStateOf(null) }
+
+ LaunchedEffect(key1 = uri) {
+ scope.launch(Dispatchers.IO) {
+ bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
+ }
+ }
+
+ bitmap?.let {
+ Image(
+ bitmap = it.asImageBitmap(),
+ contentDescription = "some useful description",
+ contentScale = ContentScale.FillWidth,
+ modifier = Modifier
+ .padding(top = 4.dp)
+ .fillMaxWidth()
+ )
+ }
+ } else {
+ VideoView(uri)
+ }
+ }
+
+ var message by remember { mutableStateOf("") }
+
+ OutlinedTextField(
+ label = { Text(text = stringResource(R.string.content_description)) },
+ modifier = Modifier.fillMaxWidth(),
+ value = message,
+ onValueChange = { message = it },
+ placeholder = {
+ Text(
+ text = stringResource(R.string.content_description_example),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+ },
+ keyboardOptions = KeyboardOptions.Default.copy(
+ capitalization = KeyboardCapitalization.Sentences
+ )
+ )
+
+ Button(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 10.dp),
+ onClick = {
+ onAdd(message)
+ },
+ shape = RoundedCornerShape(15.dp),
+ colors = ButtonDefaults.buttonColors(
+ backgroundColor = MaterialTheme.colors.primary
+ )
+ ) {
+ Text(text = stringResource(R.string.add_content), color = Color.White, fontSize = 20.sp)
+ }
+ }
+ }
+}
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
index 5fccd7a43..600d60df1 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt
@@ -38,6 +38,9 @@ open class NewPostViewModel : ViewModel() {
var userSuggestions by mutableStateOf>(emptyList())
var userSuggestionAnchor: TextRange? = null
+ // Images and Videos
+ var contentToAddUrl by mutableStateOf(null)
+
// Polls
var canUsePoll by mutableStateOf(false)
var wantsPoll by mutableStateOf(false)
@@ -84,6 +87,7 @@ open class NewPostViewModel : ViewModel() {
canAddInvoice = account.userProfile().info?.lnAddress() != null
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
+ contentToAddUrl = null
this.account = account
}
@@ -105,46 +109,15 @@ open class NewPostViewModel : ViewModel() {
cancel()
}
- fun upload(it: Uri, context: Context) {
+ fun upload(it: Uri, description: String, context: Context) {
isUploadingImage = true
+ contentToAddUrl = null
ImageUploader.uploadImage(
uri = it,
contentResolver = context.contentResolver,
onSuccess = { imageUrl, mimeType ->
- viewModelScope.launch(Dispatchers.IO) {
- // Images don't seem to be ready immediately after upload
-
- if (mimeType?.startsWith("image/") == true) {
- delay(2000)
- } else {
- delay(5000)
- }
-
- FileHeader.prepare(
- imageUrl,
- mimeType,
- onReady = {
- val note = account?.sendHeader(it)
-
- isUploadingImage = false
-
- if (note == null) {
- message = TextFieldValue(message.text + "\n\n" + imageUrl)
- } else {
- message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote())
- }
-
- urlPreview = findUrlInMessage()
- },
- onError = {
- isUploadingImage = false
- viewModelScope.launch {
- imageUploadingError.emit("Failed to upload the image / video")
- }
- }
- )
- }
+ createNIP97Record(imageUrl, mimeType, description)
},
onError = {
isUploadingImage = false
@@ -157,6 +130,7 @@ open class NewPostViewModel : ViewModel() {
open fun cancel() {
message = TextFieldValue("")
+ contentToAddUrl = null
urlPreview = null
isUploadingImage = false
mentions = null
@@ -220,7 +194,7 @@ open class NewPostViewModel : ViewModel() {
fun canPost(): Boolean {
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
- (!wantsPoll || pollOptions.values.all { it.isNotEmpty() })
+ (!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null
}
fun includePollHashtagInMessage(include: Boolean, hashtag: String) {
@@ -235,4 +209,45 @@ open class NewPostViewModel : ViewModel() {
)
}
}
+
+ fun createNIP97Record(imageUrl: String, mimeType: String?, description: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ // Images don't seem to be ready immediately after upload
+
+ if (mimeType?.startsWith("image/") == true) {
+ delay(2000)
+ } else {
+ delay(5000)
+ }
+
+ FileHeader.prepare(
+ imageUrl,
+ mimeType,
+ description,
+ onReady = {
+ val note = account?.sendHeader(it)
+
+ isUploadingImage = false
+
+ if (note == null) {
+ message = TextFieldValue(message.text + "\n\n" + imageUrl)
+ } else {
+ message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote())
+ }
+
+ urlPreview = findUrlInMessage()
+ },
+ onError = {
+ isUploadingImage = false
+ viewModelScope.launch {
+ imageUploadingError.emit("Failed to upload the image / video")
+ }
+ }
+ )
+ }
+ }
+
+ fun selectImage(uri: Uri) {
+ contentToAddUrl = uri
+ }
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt
index ab67d171d..21b0efb53 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
+import android.net.Uri
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxWidth
@@ -16,6 +17,7 @@ import androidx.lifecycle.LifecycleEventObserver
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
+import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
@@ -23,16 +25,33 @@ import com.google.android.exoplayer2.ui.StyledPlayerView
import com.vitorpamplona.amethyst.VideoCache
@Composable
-fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) {
+fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
+ VideoView(Uri.parse(videoUri), description, onDialog)
+}
+
+@Composable
+fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
val context = LocalContext.current
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
val exoPlayer = remember(videoUri) {
+ val mediaBuilder = MediaItem.Builder().setUri(videoUri)
+
+ description?.let {
+ mediaBuilder.setMediaMetadata(
+ MediaMetadata.Builder().setDisplayTitle(it).build()
+ )
+ }
+
+ val media = mediaBuilder.build()
+
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
setMediaSource(
- ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(MediaItem.fromUri(videoUri))
+ ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
+ media
+ )
)
prepare()
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt
index 69b053577..8f8c15cfa 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt
@@ -175,7 +175,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List
}
}
} else {
- VideoView(content.url) { dialogOpen = true }
+ VideoView(content.url, content.description) { dialogOpen = true }
}
if (dialogOpen) {
@@ -322,7 +322,7 @@ private fun RenderImageOrVideo(content: ZoomableContent) {
}
} else {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
- VideoView(content.url)
+ VideoView(content.url, content.description)
}
}
}
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 784d61262..fe3602a31 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
@@ -233,7 +233,7 @@ fun ChannelScreen(
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(start = 5.dp)
) {
- channelScreenModel.upload(it, context)
+ channelScreenModel.upload(it, "", context)
}
},
colors = TextFieldDefaults.textFieldColors(
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt
index 07381c5e7..5dd3bb2ca 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt
@@ -194,7 +194,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(start = 5.dp)
) {
- chatRoomScreenModel.upload(it, context)
+ chatRoomScreenModel.upload(it, "", context)
}
},
colors = TextFieldDefaults.textFieldColors(
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3e3109e95..504fcfae0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -297,4 +297,11 @@
Image is the same since the post
Image has changed. The author might not have seen the change
+ Add Image
+ Add Video
+ Add Document
+ Create and Add
+ Description of the contents
+ A blue boat in a white sandy beach at sunset
+