@@ -0,0 +1,23 @@
|
|||||||
|
package com.vitorpamplona.amethyst.service
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.FileStorageEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||||
|
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||||
|
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||||
|
|
||||||
|
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||||
|
fun createGlobalFilter() = TypedFilter(
|
||||||
|
types = setOf(FeedType.GLOBAL),
|
||||||
|
filter = JsonFilter(
|
||||||
|
kinds = listOf(FileHeaderEvent.kind, FileStorageEvent.kind),
|
||||||
|
limit = 200
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val videoFeedChannel = requestNewChannel()
|
||||||
|
|
||||||
|
override fun updateChannelFilters() {
|
||||||
|
videoFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.actions
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.vitorpamplona.amethyst.model.*
|
||||||
|
import com.vitorpamplona.amethyst.service.FileHeader
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
open class NewMediaModel : ViewModel() {
|
||||||
|
var account: Account? = null
|
||||||
|
|
||||||
|
var isUploadingImage by mutableStateOf(false)
|
||||||
|
val imageUploadingError = MutableSharedFlow<String?>()
|
||||||
|
var mediaType by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
var selectedServer by mutableStateOf<ServersAvailable?>(null)
|
||||||
|
var description by mutableStateOf("")
|
||||||
|
|
||||||
|
// Images and Videos
|
||||||
|
var galleryUri by mutableStateOf<Uri?>(null)
|
||||||
|
|
||||||
|
open fun load(account: Account, uri: Uri, contentType: String?) {
|
||||||
|
this.account = account
|
||||||
|
this.galleryUri = uri
|
||||||
|
this.mediaType = contentType
|
||||||
|
this.selectedServer = defaultServer()
|
||||||
|
|
||||||
|
if (selectedServer == ServersAvailable.IMGUR) {
|
||||||
|
selectedServer = ServersAvailable.IMGUR_NIP_94
|
||||||
|
} else if (selectedServer == ServersAvailable.NOSTRIMG) {
|
||||||
|
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun upload(context: Context, onClose: () -> Unit) {
|
||||||
|
isUploadingImage = true
|
||||||
|
|
||||||
|
val contentResolver = context.contentResolver
|
||||||
|
val uri = galleryUri ?: return
|
||||||
|
val serverToUse = selectedServer ?: return
|
||||||
|
|
||||||
|
if (selectedServer == ServersAvailable.NIP95) {
|
||||||
|
val contentType = contentResolver.getType(uri)
|
||||||
|
contentResolver.openInputStream(uri)?.use {
|
||||||
|
createNIP95Record(it.readBytes(), contentType, description, onClose)
|
||||||
|
}
|
||||||
|
?: viewModelScope.launch {
|
||||||
|
imageUploadingError.emit("Failed to upload the image / video")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ImageUploader.uploadImage(
|
||||||
|
uri = uri,
|
||||||
|
server = serverToUse,
|
||||||
|
contentResolver = contentResolver,
|
||||||
|
onSuccess = { imageUrl, mimeType ->
|
||||||
|
createNIP94Record(imageUrl, mimeType, description, onClose)
|
||||||
|
},
|
||||||
|
onError = {
|
||||||
|
isUploadingImage = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
imageUploadingError.emit("Failed to upload the image / video")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun cancel() {
|
||||||
|
galleryUri = null
|
||||||
|
isUploadingImage = false
|
||||||
|
mediaType = null
|
||||||
|
|
||||||
|
description = ""
|
||||||
|
selectedServer = account?.defaultFileServer
|
||||||
|
}
|
||||||
|
|
||||||
|
fun canPost(): Boolean {
|
||||||
|
return !isUploadingImage && galleryUri != null && selectedServer != null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, onClose: () -> Unit) {
|
||||||
|
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
|
||||||
|
cancel()
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
onError = {
|
||||||
|
isUploadingImage = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
imageUploadingError.emit("Failed to upload the image / video")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, onClose: () -> Unit) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
FileHeader.prepare(
|
||||||
|
bytes,
|
||||||
|
"",
|
||||||
|
mimeType,
|
||||||
|
description,
|
||||||
|
onReady = {
|
||||||
|
account?.sendNip95(bytes, headerInfo = it)
|
||||||
|
isUploadingImage = false
|
||||||
|
cancel()
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
onError = {
|
||||||
|
isUploadingImage = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
imageUploadingError.emit("Failed to upload the image / video")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isImage() = mediaType?.startsWith("image")
|
||||||
|
fun isVideo() = mediaType?.startsWith("video")
|
||||||
|
fun defaultServer() = account?.defaultFileServer
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
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.Image
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
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.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.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
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 androidx.navigation.NavController
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.*
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewModel, navController: NavController) {
|
||||||
|
val account = accountViewModel.accountLiveData.value?.account ?: return
|
||||||
|
val resolver = LocalContext.current.contentResolver
|
||||||
|
val postViewModel: NewMediaModel = viewModel()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val scroolState = rememberScrollState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val mediaType = resolver.getType(uri) ?: ""
|
||||||
|
postViewModel.load(account, uri, mediaType)
|
||||||
|
delay(100)
|
||||||
|
|
||||||
|
postViewModel.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()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight().imePadding()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
CloseButton(onCancel = {
|
||||||
|
postViewModel.cancel()
|
||||||
|
onClose()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (postViewModel.isUploadingImage) {
|
||||||
|
LoadingAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
PostButton(
|
||||||
|
onPost = {
|
||||||
|
postViewModel.upload(context) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isActive = postViewModel.canPost()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(scroolState)
|
||||||
|
) {
|
||||||
|
ImageVideoPost(postViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ImageVideoPost(postViewModel: NewMediaModel) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val fileServers = listOf(
|
||||||
|
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||||
|
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||||
|
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||||
|
)
|
||||||
|
|
||||||
|
val fileServerOptions = fileServers.map { it.second }
|
||||||
|
val fileServerExplainers = fileServers.map { it.third }
|
||||||
|
val resolver = LocalContext.current.contentResolver
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 10.dp)
|
||||||
|
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||||
|
) {
|
||||||
|
if (postViewModel.isImage() == true) {
|
||||||
|
AsyncImage(
|
||||||
|
model = postViewModel.galleryUri.toString(),
|
||||||
|
contentDescription = postViewModel.galleryUri.toString(),
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 4.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||||
|
)
|
||||||
|
} else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(key1 = postViewModel.galleryUri) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
postViewModel.galleryUri?.let {
|
||||||
|
bitmap = resolver.loadThumbnail(it, 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 {
|
||||||
|
postViewModel.galleryUri?.let {
|
||||||
|
VideoView(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
TextSpinner(
|
||||||
|
label = stringResource(id = R.string.file_server),
|
||||||
|
placeholder = fileServers.filter { it.first == postViewModel.defaultServer() }.firstOrNull()?.second ?: fileServers[0].second,
|
||||||
|
options = fileServerOptions,
|
||||||
|
explainers = fileServerExplainers,
|
||||||
|
onSelect = {
|
||||||
|
postViewModel.selectedServer = fileServers[it].first
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||||
|
.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postViewModel.selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
|
||||||
|
postViewModel.selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||||
|
postViewModel.selectedServer == ServersAvailable.NIP95
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
label = { Text(text = stringResource(R.string.content_description)) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||||
|
value = postViewModel.description,
|
||||||
|
onValueChange = { postViewModel.description = 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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -406,7 +406,7 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RenderImageOrVideo(content: ZoomableContent) {
|
fun RenderImageOrVideo(content: ZoomableContent) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.dal
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.service.model.*
|
||||||
|
|
||||||
|
object VideoFeedFilter : AdditiveFeedFilter<Note>() {
|
||||||
|
lateinit var account: Account
|
||||||
|
|
||||||
|
override fun feed(): List<Note> {
|
||||||
|
val notes = innerApplyFilter(LocalCache.notes.values)
|
||||||
|
|
||||||
|
return sort(notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||||
|
return innerApplyFilter(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||||
|
val now = System.currentTimeMillis() / 1000
|
||||||
|
|
||||||
|
return collection
|
||||||
|
.asSequence()
|
||||||
|
.filter {
|
||||||
|
it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent
|
||||||
|
}
|
||||||
|
.filter { account.isAcceptable(it) }
|
||||||
|
.filter {
|
||||||
|
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
|
||||||
|
it.createdAt()!! <= now
|
||||||
|
}
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sort(collection: Set<Note>): List<Note> {
|
||||||
|
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ import kotlin.time.ExperimentalTime
|
|||||||
val bottomNavigationItems = listOf(
|
val bottomNavigationItems = listOf(
|
||||||
Route.Home,
|
Route.Home,
|
||||||
Route.Message,
|
Route.Message,
|
||||||
|
Route.Video,
|
||||||
Route.Search,
|
Route.Search,
|
||||||
Route.Notification
|
Route.Notification
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
|
|||||||
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
||||||
|
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
|
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
|
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen
|
||||||
@@ -34,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -58,10 +61,31 @@ fun AppNavigation(
|
|||||||
GlobalFeedFilter.account = account
|
GlobalFeedFilter.account = account
|
||||||
val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel()
|
val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel()
|
||||||
|
|
||||||
|
VideoFeedFilter.account = account
|
||||||
|
val videoFeedViewModel: NostrVideoFeedViewModel = viewModel()
|
||||||
|
|
||||||
NotificationFeedFilter.account = account
|
NotificationFeedFilter.account = account
|
||||||
val notifFeedViewModel: NotificationViewModel = viewModel()
|
val notifFeedViewModel: NotificationViewModel = viewModel()
|
||||||
|
|
||||||
NavHost(navController, startDestination = Route.Home.route) {
|
NavHost(navController, startDestination = Route.Home.route) {
|
||||||
|
Route.Video.let { route ->
|
||||||
|
composable(route.route, route.arguments, content = {
|
||||||
|
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||||
|
|
||||||
|
VideoScreen(
|
||||||
|
videoFeedView = videoFeedViewModel,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
navController = navController,
|
||||||
|
scrollToTop = scrollToTop
|
||||||
|
)
|
||||||
|
|
||||||
|
// Avoids running scroll to top when back button is pressed
|
||||||
|
if (scrollToTop) {
|
||||||
|
it.arguments?.remove("scrollToTop")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
Route.Search.let { route ->
|
Route.Search.let { route ->
|
||||||
composable(route.route, route.arguments, content = {
|
composable(route.route, route.arguments, content = {
|
||||||
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ sealed class Route(
|
|||||||
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false })
|
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false })
|
||||||
)
|
)
|
||||||
|
|
||||||
|
object Video : Route(
|
||||||
|
route = "Video?scrollToTop={scrollToTop}",
|
||||||
|
icon = R.drawable.ic_video,
|
||||||
|
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false })
|
||||||
|
)
|
||||||
|
|
||||||
object Notification : Route(
|
object Notification : Route(
|
||||||
route = "Notification?scrollToTop={scrollToTop}",
|
route = "Notification?scrollToTop={scrollToTop}",
|
||||||
icon = R.drawable.ic_notifications,
|
icon = R.drawable.ic_notifications,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import androidx.compose.ui.res.painterResource
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -116,6 +117,7 @@ fun ReplyReaction(
|
|||||||
baseNote: Note,
|
baseNote: Note,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
showCounter: Boolean = true,
|
showCounter: Boolean = true,
|
||||||
|
iconSize: Dp = 20.dp,
|
||||||
onPress: () -> Unit
|
onPress: () -> Unit
|
||||||
) {
|
) {
|
||||||
val repliesState by baseNote.live().replies.observeAsState()
|
val repliesState by baseNote.live().replies.observeAsState()
|
||||||
@@ -125,7 +127,7 @@ fun ReplyReaction(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(iconSize),
|
||||||
onClick = {
|
onClick = {
|
||||||
if (accountViewModel.isWriteable()) {
|
if (accountViewModel.isWriteable()) {
|
||||||
onPress()
|
onPress()
|
||||||
@@ -140,7 +142,7 @@ fun ReplyReaction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
ReplyIcon()
|
ReplyIcon(iconSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showCounter) {
|
if (showCounter) {
|
||||||
@@ -153,19 +155,20 @@ fun ReplyReaction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReplyIcon() {
|
private fun ReplyIcon(iconSize: Dp = 15.dp) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_comment),
|
painter = painterResource(R.drawable.ic_comment),
|
||||||
null,
|
null,
|
||||||
modifier = Modifier.size(15.dp),
|
modifier = Modifier.size(iconSize),
|
||||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BoostReaction(
|
public fun BoostReaction(
|
||||||
baseNote: Note,
|
baseNote: Note,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
|
iconSize: Dp = 20.dp,
|
||||||
onQuotePress: () -> Unit
|
onQuotePress: () -> Unit
|
||||||
) {
|
) {
|
||||||
val boostsState by baseNote.live().boosts.observeAsState()
|
val boostsState by baseNote.live().boosts.observeAsState()
|
||||||
@@ -178,7 +181,7 @@ private fun BoostReaction(
|
|||||||
var wantsToBoost by remember { mutableStateOf(false) }
|
var wantsToBoost by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
modifier = Modifier.then(Modifier.size(20.dp)),
|
modifier = Modifier.then(Modifier.size(iconSize)),
|
||||||
onClick = {
|
onClick = {
|
||||||
if (accountViewModel.isWriteable()) {
|
if (accountViewModel.isWriteable()) {
|
||||||
if (accountViewModel.hasBoosted(baseNote)) {
|
if (accountViewModel.hasBoosted(baseNote)) {
|
||||||
@@ -215,14 +218,14 @@ private fun BoostReaction(
|
|||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_retweeted),
|
painter = painterResource(R.drawable.ic_retweeted),
|
||||||
null,
|
null,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(iconSize),
|
||||||
tint = Color.Unspecified
|
tint = Color.Unspecified
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_retweet),
|
painter = painterResource(R.drawable.ic_retweet),
|
||||||
null,
|
null,
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(iconSize),
|
||||||
tint = grayTint
|
tint = grayTint
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -238,7 +241,9 @@ private fun BoostReaction(
|
|||||||
@Composable
|
@Composable
|
||||||
fun LikeReaction(
|
fun LikeReaction(
|
||||||
baseNote: Note,
|
baseNote: Note,
|
||||||
accountViewModel: AccountViewModel
|
accountViewModel: AccountViewModel,
|
||||||
|
iconSize: Dp = 20.dp,
|
||||||
|
heartSize: Dp = 16.dp
|
||||||
) {
|
) {
|
||||||
val reactionsState by baseNote.live().reactions.observeAsState()
|
val reactionsState by baseNote.live().reactions.observeAsState()
|
||||||
val reactedNote = reactionsState?.note ?: return
|
val reactedNote = reactionsState?.note ?: return
|
||||||
@@ -248,7 +253,7 @@ fun LikeReaction(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
modifier = Modifier.then(Modifier.size(20.dp)),
|
modifier = Modifier.then(Modifier.size(iconSize)),
|
||||||
onClick = {
|
onClick = {
|
||||||
if (accountViewModel.isWriteable()) {
|
if (accountViewModel.isWriteable()) {
|
||||||
if (accountViewModel.hasReactedTo(baseNote)) {
|
if (accountViewModel.hasReactedTo(baseNote)) {
|
||||||
@@ -271,14 +276,14 @@ fun LikeReaction(
|
|||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_liked),
|
painter = painterResource(R.drawable.ic_liked),
|
||||||
null,
|
null,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(heartSize),
|
||||||
tint = Color.Unspecified
|
tint = Color.Unspecified
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_like),
|
painter = painterResource(R.drawable.ic_like),
|
||||||
null,
|
null,
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(heartSize),
|
||||||
tint = grayTint
|
tint = grayTint
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -296,7 +301,9 @@ fun LikeReaction(
|
|||||||
fun ZapReaction(
|
fun ZapReaction(
|
||||||
baseNote: Note,
|
baseNote: Note,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
textModifier: Modifier = Modifier
|
textModifier: Modifier = Modifier,
|
||||||
|
iconSize: Dp = 20.dp,
|
||||||
|
animationSize: Dp = 14.dp
|
||||||
) {
|
) {
|
||||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||||
val account = accountState?.account ?: return
|
val account = accountState?.account ?: return
|
||||||
@@ -326,8 +333,7 @@ fun ZapReaction(
|
|||||||
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = CenterVertically,
|
verticalAlignment = CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier.size(iconSize)
|
||||||
.then(Modifier.size(20.dp))
|
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
role = Role.Button,
|
role = Role.Button,
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
@@ -427,7 +433,7 @@ fun ZapReaction(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Bolt,
|
imageVector = Icons.Default.Bolt,
|
||||||
contentDescription = stringResource(R.string.zaps),
|
contentDescription = stringResource(R.string.zaps),
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(iconSize),
|
||||||
tint = BitcoinOrange
|
tint = BitcoinOrange
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -435,14 +441,14 @@ fun ZapReaction(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Outlined.Bolt,
|
imageVector = Icons.Outlined.Bolt,
|
||||||
contentDescription = stringResource(id = R.string.zaps),
|
contentDescription = stringResource(id = R.string.zaps),
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(iconSize),
|
||||||
tint = grayTint
|
tint = grayTint
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.width(3.dp))
|
Spacer(Modifier.width(3.dp))
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
progress = zappingProgress,
|
progress = zappingProgress,
|
||||||
modifier = Modifier.size(14.dp),
|
modifier = Modifier.size(animationSize),
|
||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -466,18 +472,18 @@ fun ZapReaction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ViewCountReaction(idHex: String) {
|
public fun ViewCountReaction(idHex: String, iconSize: Dp = 20.dp, barChartSize: Dp = 19.dp, numberSize: Dp = 24.dp) {
|
||||||
val uri = LocalUriHandler.current
|
val uri = LocalUriHandler.current
|
||||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(iconSize),
|
||||||
onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") }
|
onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") }
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Outlined.BarChart,
|
imageVector = Icons.Outlined.BarChart,
|
||||||
null,
|
null,
|
||||||
modifier = Modifier.size(19.dp),
|
modifier = Modifier.size(barChartSize),
|
||||||
tint = grayTint
|
tint = grayTint
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -490,7 +496,7 @@ private fun ViewCountReaction(idHex: String) {
|
|||||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||||
.build(),
|
.build(),
|
||||||
contentDescription = stringResource(R.string.view_count),
|
contentDescription = stringResource(R.string.view_count),
|
||||||
modifier = Modifier.height(24.dp),
|
modifier = Modifier.height(numberSize),
|
||||||
colorFilter = ColorFilter.tint(grayTint)
|
colorFilter = ColorFilter.tint(grayTint)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
|
|||||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
|
||||||
|
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -34,6 +35,7 @@ import kotlinx.coroutines.launch
|
|||||||
class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter)
|
class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter)
|
||||||
class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter)
|
class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter)
|
||||||
class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter)
|
class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter)
|
||||||
|
class NostrVideoFeedViewModel : FeedViewModel(VideoFeedFilter)
|
||||||
class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter)
|
class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter)
|
||||||
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
|
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
|
||||||
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)
|
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)
|
||||||
|
|||||||
@@ -88,34 +88,24 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
|
|||||||
fun FloatingButtons(navController: NavHostController, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel) {
|
fun FloatingButtons(navController: NavHostController, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel) {
|
||||||
val accountState by accountStateViewModel.accountContent.collectAsState()
|
val accountState by accountStateViewModel.accountContent.collectAsState()
|
||||||
|
|
||||||
if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) {
|
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||||
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
|
when (state) {
|
||||||
when (state) {
|
is AccountState.LoggedInViewOnly -> {
|
||||||
is AccountState.LoggedInViewOnly -> {
|
// Does nothing.
|
||||||
// Does nothing.
|
}
|
||||||
}
|
is AccountState.LoggedOff -> {
|
||||||
is AccountState.LoggedOff -> {
|
// Does nothing.
|
||||||
// Does nothing.
|
}
|
||||||
}
|
is AccountState.LoggedIn -> {
|
||||||
is AccountState.LoggedIn -> {
|
if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) {
|
||||||
NewNoteButton(state.account, accountViewModel, navController)
|
NewNoteButton(state.account, accountViewModel, navController)
|
||||||
}
|
}
|
||||||
}
|
if (currentRoute(navController) == Route.Message.base) {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentRoute(navController) == Route.Message.base) {
|
|
||||||
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
|
|
||||||
when (state) {
|
|
||||||
is AccountState.LoggedInViewOnly -> {
|
|
||||||
// Does nothing.
|
|
||||||
}
|
|
||||||
is AccountState.LoggedOff -> {
|
|
||||||
// Does nothing.
|
|
||||||
}
|
|
||||||
is AccountState.LoggedIn -> {
|
|
||||||
NewChannelButton(state.account)
|
NewChannelButton(state.account)
|
||||||
}
|
}
|
||||||
|
if (currentRoute(navController)?.substringBefore("?") == Route.Video.base) {
|
||||||
|
NewImageButton(accountViewModel, navController)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.foundation.pager.VerticalPager
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.ButtonDefaults
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.IconButton
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.OutlinedButton
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
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.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
|
import com.google.accompanist.permissions.isGranted
|
||||||
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||||
|
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||||
|
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.GallerySelect
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||||
|
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||||
|
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.LikeReaction
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.ViewCountReaction
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.ZapReaction
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.FeedError
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.FeedState
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VideoScreen(
|
||||||
|
videoFeedView: NostrVideoFeedViewModel,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
navController: NavController,
|
||||||
|
scrollToTop: Boolean = false
|
||||||
|
) {
|
||||||
|
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||||
|
val account = accountViewModel.accountLiveData.value?.account ?: return
|
||||||
|
|
||||||
|
VideoFeedFilter.account = account
|
||||||
|
|
||||||
|
LaunchedEffect(accountViewModel) {
|
||||||
|
VideoFeedFilter.account = account
|
||||||
|
NostrVideoDataSource.resetFilters()
|
||||||
|
videoFeedView.invalidateData()
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(accountViewModel) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
|
println("Video Start")
|
||||||
|
VideoFeedFilter.account = account
|
||||||
|
NostrVideoDataSource.start()
|
||||||
|
videoFeedView.invalidateData()
|
||||||
|
}
|
||||||
|
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||||
|
println("Video Stop")
|
||||||
|
NostrVideoDataSource.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lifeCycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose {
|
||||||
|
lifeCycleOwner.lifecycle.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxHeight()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(vertical = 0.dp)
|
||||||
|
) {
|
||||||
|
FeedView(videoFeedView, accountViewModel, navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FeedView(
|
||||||
|
videoFeedView: NostrVideoFeedViewModel,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
navController: NavController
|
||||||
|
) {
|
||||||
|
val feedState by videoFeedView.feedContent.collectAsState()
|
||||||
|
|
||||||
|
Box() {
|
||||||
|
Column {
|
||||||
|
Crossfade(
|
||||||
|
targetState = feedState,
|
||||||
|
animationSpec = tween(durationMillis = 100)
|
||||||
|
) { state ->
|
||||||
|
when (state) {
|
||||||
|
is FeedState.Empty -> {
|
||||||
|
FeedEmpty {}
|
||||||
|
}
|
||||||
|
|
||||||
|
is FeedState.FeedError -> {
|
||||||
|
FeedError(state.errorMessage) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
is FeedState.Loaded -> {
|
||||||
|
SlidingCarousel(
|
||||||
|
state.feed,
|
||||||
|
accountViewModel,
|
||||||
|
navController
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is FeedState.Loading -> {
|
||||||
|
LoadingFeed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SlidingCarousel(
|
||||||
|
feed: MutableState<List<Note>>,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
navController: NavController
|
||||||
|
) {
|
||||||
|
val pagerState: PagerState = remember { PagerState() }
|
||||||
|
|
||||||
|
VerticalPager(
|
||||||
|
pageCount = feed.value.size,
|
||||||
|
state = pagerState,
|
||||||
|
beyondBoundsPageCount = 1,
|
||||||
|
modifier = Modifier.fillMaxSize(1f)
|
||||||
|
) { index ->
|
||||||
|
feed.value.getOrNull(index)?.let { note ->
|
||||||
|
RenderVideoOrPictureNote(note, accountViewModel, navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderVideoOrPictureNote(
|
||||||
|
note: Note,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
navController: NavController
|
||||||
|
) {
|
||||||
|
val noteEvent = note.event
|
||||||
|
|
||||||
|
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||||
|
val account = accountState?.account ?: return
|
||||||
|
val loggedIn = account.userProfile()
|
||||||
|
|
||||||
|
var moreActionsExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize(1f)) {
|
||||||
|
Row(Modifier.weight(1f, true), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (noteEvent is FileHeaderEvent) {
|
||||||
|
FileHeaderDisplay(note)
|
||||||
|
} else if (noteEvent is FileStorageHeaderEvent) {
|
||||||
|
FileStorageHeaderDisplay(note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.fillMaxSize(1f)) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.Bottom) {
|
||||||
|
Column(Modifier.size(45.dp), verticalArrangement = Arrangement.Center) {
|
||||||
|
NoteAuthorPicture(note, navController, loggedIn, 45.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(start = 10.dp, end = 10.dp)
|
||||||
|
.height(45.dp)
|
||||||
|
.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
NoteUsernameDisplay(note, Modifier.weight(1f))
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
onClick = { moreActionsExpanded = true }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(15.dp),
|
||||||
|
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||||
|
)
|
||||||
|
|
||||||
|
NoteDropDownMenu(note, moreActionsExpanded, { moreActionsExpanded = false }, accountViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.width(65.dp)
|
||||||
|
.padding(bottom = 10.dp),
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.Center) {
|
||||||
|
ReactionsColumn(note, accountViewModel, navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
|
||||||
|
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||||
|
val account = accountState?.account ?: return
|
||||||
|
|
||||||
|
var wantsToReplyTo by remember {
|
||||||
|
mutableStateOf<Note?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wantsToQuote by remember {
|
||||||
|
mutableStateOf<Note?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantsToReplyTo != null) {
|
||||||
|
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account, accountViewModel, navController)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantsToQuote != null) {
|
||||||
|
NewPostView({ wantsToQuote = null }, null, wantsToQuote, account, accountViewModel, navController)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 75.dp, end = 20.dp)) {
|
||||||
|
/*
|
||||||
|
ReplyReaction(baseNote, accountViewModel, iconSize = 40.dp) {
|
||||||
|
wantsToReplyTo = baseNote
|
||||||
|
}
|
||||||
|
BoostReaction(baseNote, accountViewModel, iconSize = 40.dp) {
|
||||||
|
wantsToQuote = baseNote
|
||||||
|
}*/
|
||||||
|
LikeReaction(baseNote, accountViewModel, iconSize = 40.dp, heartSize = 35.dp)
|
||||||
|
ZapReaction(baseNote, accountViewModel, iconSize = 40.dp, animationSize = 35.dp)
|
||||||
|
ViewCountReaction(baseNote.idHex, iconSize = 40.dp, barChartSize = 39.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
@Composable
|
||||||
|
fun NewImageButton(accountViewModel: AccountViewModel, navController: NavController) {
|
||||||
|
var wantsToPost by remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pickedURI by remember {
|
||||||
|
mutableStateOf<Uri?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantsToPost) {
|
||||||
|
val cameraPermissionState =
|
||||||
|
rememberPermissionState(
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Manifest.permission.READ_MEDIA_IMAGES
|
||||||
|
} else {
|
||||||
|
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cameraPermissionState.status.isGranted) {
|
||||||
|
var showGallerySelect by remember { mutableStateOf(false) }
|
||||||
|
if (showGallerySelect) {
|
||||||
|
GallerySelect(
|
||||||
|
onImageUri = { uri ->
|
||||||
|
wantsToPost = false
|
||||||
|
showGallerySelect = false
|
||||||
|
pickedURI = uri
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
showGallerySelect = true
|
||||||
|
} else {
|
||||||
|
LaunchedEffect(key1 = accountViewModel) {
|
||||||
|
cameraPermissionState.launchPermissionRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pickedURI?.let {
|
||||||
|
NewMediaView(it, onClose = { pickedURI = null }, accountViewModel = accountViewModel, navController = navController)
|
||||||
|
}
|
||||||
|
|
||||||
|
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_compose),
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(26.dp),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="122.88dp"
|
||||||
|
android:height="111.34dp"
|
||||||
|
android:viewportWidth="122.88"
|
||||||
|
android:viewportHeight="111.34">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF9C9C9C"
|
||||||
|
android:pathData="M23.59,0h75.7a23.68,23.68 0,0 1,23.59 23.59L122.88,87.75A23.56,23.56 0,0 1,116 104.41l-0.22,0.2a23.53,23.53 0,0 1,-16.44 6.73L23.59,111.34a23.53,23.53 0,0 1,-16.66 -6.93l-0.2,-0.22A23.46,23.46 0,0 1,0 87.75L0,23.59A23.66,23.66 0,0 1,23.59 0ZM54,47.73 L79.25,65.36a3.79,3.79 0,0 1,0.14 6.3L54.22,89.05a3.75,3.75 0,0 1,-2.4 0.87A3.79,3.79 0,0 1,48 86.13L48,50.82h0A3.77,3.77 0,0 1,54 47.73ZM7.35,26.47h14L30.41,7.35L23.59,7.35A16.29,16.29 0,0 0,7.35 23.59v2.88ZM37.05,7.35 L28,26.47L53.36,26.47L62.43,7.38v0ZM69.05,7.35L59.92,26.47h24.7L93.7,7.35ZM100.37,7.35L91.26,26.47h24.27L115.53,23.59a16.32,16.32 0,0 0,-15.2 -16.21ZM115.57,34.03L7.35,34.03L7.35,87.75A16.21,16.21 0,0 0,12 99.05l0.17,0.16A16.19,16.19 0,0 0,23.59 104h75.7a16.21,16.21 0,0 0,11.3 -4.6l0.16,-0.18a16.17,16.17 0,0 0,4.78 -11.46L115.53,34.06Z"/>
|
||||||
|
</vector>
|
||||||
Reference in New Issue
Block a user