From 8209c453030ec5e984a390358ac66cb57256703f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 29 Apr 2023 19:28:46 -0400 Subject: [PATCH 1/2] New Video Feed based on NIP-94 and 95 --- .../amethyst/service/NostrVideoDataSource.kt | 23 ++ .../amethyst/ui/actions/NewMediaModel.kt | 145 +++++++ .../amethyst/ui/actions/NewMediaView.kt | 226 +++++++++++ .../ui/components/ZoomableContentView.kt | 2 +- .../amethyst/ui/dal/VideoFeedFilter.kt | 40 ++ .../amethyst/ui/navigation/AppBottomBar.kt | 1 + .../amethyst/ui/navigation/AppNavigation.kt | 24 ++ .../amethyst/ui/navigation/Routes.kt | 6 + .../amethyst/ui/note/ReactionsRow.kt | 50 +-- .../amethyst/ui/screen/FeedViewModel.kt | 2 + .../amethyst/ui/screen/loggedIn/MainScreen.kt | 38 +- .../ui/screen/loggedIn/VideoScreen.kt | 356 ++++++++++++++++++ app/src/main/res/drawable/ic_video.xml | 9 + 13 files changed, 875 insertions(+), 47 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt create mode 100644 app/src/main/res/drawable/ic_video.xml diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt new file mode 100644 index 000000000..f35fe3cf6 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt @@ -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 } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt new file mode 100644 index 000000000..2c4a89999 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -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() + var mediaType by mutableStateOf(null) + + var selectedServer by mutableStateOf(null) + var description by mutableStateOf("") + + // Images and Videos + var galleryUri by mutableStateOf(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 +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt new file mode 100644 index 000000000..b50b1843e --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -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(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 + ) + ) + } + } +} 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 bb31eeb9e..775851211 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 @@ -406,7 +406,7 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List() { + lateinit var account: Account + + override fun feed(): List { + val notes = innerApplyFilter(LocalCache.notes.values) + + return sort(notes) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + private fun innerApplyFilter(collection: Collection): Set { + 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): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index db0ab3644..95048de58 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -47,6 +47,7 @@ import kotlin.time.ExperimentalTime val bottomNavigationItems = listOf( Route.Home, Route.Message, + Route.Video, Route.Search, Route.Notification ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index fc655954e..512e6c8e2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -17,9 +17,11 @@ import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter 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.NostrHomeFeedViewModel 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.loggedIn.AccountViewModel 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.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen @OptIn(ExperimentalFoundationApi::class) @Composable @@ -58,10 +61,31 @@ fun AppNavigation( GlobalFeedFilter.account = account val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel() + VideoFeedFilter.account = account + val videoFeedViewModel: NostrVideoFeedViewModel = viewModel() + NotificationFeedFilter.account = account val notifFeedViewModel: NotificationViewModel = viewModel() 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 -> composable(route.route, route.arguments, content = { val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index f0266f41a..a123dc0c4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -41,6 +41,12 @@ sealed class Route( 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( route = "Notification?scrollToTop={scrollToTop}", icon = R.drawable.ic_notifications, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 4b704985d..27c377036 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -116,6 +117,7 @@ fun ReplyReaction( baseNote: Note, accountViewModel: AccountViewModel, showCounter: Boolean = true, + iconSize: Dp = 20.dp, onPress: () -> Unit ) { val repliesState by baseNote.live().replies.observeAsState() @@ -125,7 +127,7 @@ fun ReplyReaction( val scope = rememberCoroutineScope() IconButton( - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), onClick = { if (accountViewModel.isWriteable()) { onPress() @@ -140,7 +142,7 @@ fun ReplyReaction( } } ) { - ReplyIcon() + ReplyIcon(iconSize) } if (showCounter) { @@ -153,19 +155,20 @@ fun ReplyReaction( } @Composable -private fun ReplyIcon() { +private fun ReplyIcon(iconSize: Dp = 15.dp) { Icon( painter = painterResource(R.drawable.ic_comment), null, - modifier = Modifier.size(15.dp), + modifier = Modifier.size(iconSize), tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } @Composable -private fun BoostReaction( +public fun BoostReaction( baseNote: Note, accountViewModel: AccountViewModel, + iconSize: Dp = 20.dp, onQuotePress: () -> Unit ) { val boostsState by baseNote.live().boosts.observeAsState() @@ -178,7 +181,7 @@ private fun BoostReaction( var wantsToBoost by remember { mutableStateOf(false) } IconButton( - modifier = Modifier.then(Modifier.size(20.dp)), + modifier = Modifier.then(Modifier.size(iconSize)), onClick = { if (accountViewModel.isWriteable()) { if (accountViewModel.hasBoosted(baseNote)) { @@ -215,14 +218,14 @@ private fun BoostReaction( Icon( painter = painterResource(R.drawable.ic_retweeted), null, - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), tint = Color.Unspecified ) } else { Icon( painter = painterResource(R.drawable.ic_retweet), null, - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), tint = grayTint ) } @@ -238,7 +241,9 @@ private fun BoostReaction( @Composable fun LikeReaction( baseNote: Note, - accountViewModel: AccountViewModel + accountViewModel: AccountViewModel, + iconSize: Dp = 20.dp, + heartSize: Dp = 16.dp ) { val reactionsState by baseNote.live().reactions.observeAsState() val reactedNote = reactionsState?.note ?: return @@ -248,7 +253,7 @@ fun LikeReaction( val scope = rememberCoroutineScope() IconButton( - modifier = Modifier.then(Modifier.size(20.dp)), + modifier = Modifier.then(Modifier.size(iconSize)), onClick = { if (accountViewModel.isWriteable()) { if (accountViewModel.hasReactedTo(baseNote)) { @@ -271,14 +276,14 @@ fun LikeReaction( Icon( painter = painterResource(R.drawable.ic_liked), null, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(heartSize), tint = Color.Unspecified ) } else { Icon( painter = painterResource(R.drawable.ic_like), null, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(heartSize), tint = grayTint ) } @@ -296,7 +301,9 @@ fun LikeReaction( fun ZapReaction( baseNote: Note, accountViewModel: AccountViewModel, - textModifier: Modifier = Modifier + textModifier: Modifier = Modifier, + iconSize: Dp = 20.dp, + animationSize: Dp = 14.dp ) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return @@ -326,8 +333,7 @@ fun ZapReaction( Row( verticalAlignment = CenterVertically, - modifier = Modifier - .then(Modifier.size(20.dp)) + modifier = Modifier.size(iconSize) .combinedClickable( role = Role.Button, interactionSource = remember { MutableInteractionSource() }, @@ -427,7 +433,7 @@ fun ZapReaction( Icon( imageVector = Icons.Default.Bolt, contentDescription = stringResource(R.string.zaps), - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), tint = BitcoinOrange ) } else { @@ -435,14 +441,14 @@ fun ZapReaction( Icon( imageVector = Icons.Outlined.Bolt, contentDescription = stringResource(id = R.string.zaps), - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), tint = grayTint ) } else { Spacer(Modifier.width(3.dp)) CircularProgressIndicator( progress = zappingProgress, - modifier = Modifier.size(14.dp), + modifier = Modifier.size(animationSize), strokeWidth = 2.dp ) } @@ -466,18 +472,18 @@ fun ZapReaction( } @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 grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) IconButton( - modifier = Modifier.size(20.dp), + modifier = Modifier.size(iconSize), onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") } ) { Icon( imageVector = Icons.Outlined.BarChart, null, - modifier = Modifier.size(19.dp), + modifier = Modifier.size(barChartSize), tint = grayTint ) } @@ -490,7 +496,7 @@ private fun ViewCountReaction(idHex: String) { .memoryCachePolicy(CachePolicy.ENABLED) .build(), contentDescription = stringResource(R.string.view_count), - modifier = Modifier.height(24.dp), + modifier = Modifier.height(numberSize), colorFilter = ColorFilter.tint(grayTint) ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 60daf1072..1290d4ed4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -23,6 +23,7 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter +import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -34,6 +35,7 @@ import kotlinx.coroutines.launch class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter) class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter) class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter) +class NostrVideoFeedViewModel : FeedViewModel(VideoFeedFilter) class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter) class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter) class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 0adfec2b0..937332cdc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -88,34 +88,24 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun fun FloatingButtons(navController: NavHostController, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel) { val accountState by accountStateViewModel.accountContent.collectAsState() - if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) { - Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is AccountState.LoggedInViewOnly -> { - // Does nothing. - } - is AccountState.LoggedOff -> { - // Does nothing. - } - is AccountState.LoggedIn -> { + Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> + when (state) { + is AccountState.LoggedInViewOnly -> { + // Does nothing. + } + is AccountState.LoggedOff -> { + // Does nothing. + } + is AccountState.LoggedIn -> { + if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) { NewNoteButton(state.account, accountViewModel, navController) } - } - } - } - - 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 -> { + if (currentRoute(navController) == Route.Message.base) { NewChannelButton(state.account) } + if (currentRoute(navController)?.substringBefore("?") == Route.Video.base) { + NewImageButton(accountViewModel, navController) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt new file mode 100644 index 000000000..70106f2f0 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -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>, + 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(null) + } + + var wantsToQuote by remember { + mutableStateOf(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(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 + ) + } +} diff --git a/app/src/main/res/drawable/ic_video.xml b/app/src/main/res/drawable/ic_video.xml new file mode 100644 index 000000000..124419fa2 --- /dev/null +++ b/app/src/main/res/drawable/ic_video.xml @@ -0,0 +1,9 @@ + + + From ab330fea9693ba940643e52e465d7d604718cf45 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 29 Apr 2023 19:29:42 -0400 Subject: [PATCH 2/2] v0.39.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 3b8cf318c..f48bbafc0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 132 - versionName "0.38.0" + versionCode 133 + versionName "0.39.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables {