diff --git a/app/src/main/java/com/jerboa/Utils.kt b/app/src/main/java/com/jerboa/Utils.kt index fd691006f..0773e351c 100644 --- a/app/src/main/java/com/jerboa/Utils.kt +++ b/app/src/main/java/com/jerboa/Utils.kt @@ -15,6 +15,7 @@ import android.os.Environment import android.provider.MediaStore import android.util.Log import android.util.Patterns +import android.webkit.MimeTypeMap import android.widget.Toast import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.ExperimentalFoundationApi @@ -58,7 +59,9 @@ import com.jerboa.ui.components.home.SiteViewModel import com.jerboa.ui.components.person.UserTab import com.jerboa.ui.theme.SMALL_PADDING import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.ocpsoft.prettytime.PrettyTime import java.io.IOException import java.io.InputStream @@ -881,6 +884,23 @@ fun saveBitmap( } } +suspend fun saveImage(url: String, context: Context) { + Toast.makeText(context, "Saving image...", Toast.LENGTH_SHORT).show() + + val fileName = Uri.parse(url).pathSegments.last() + + val extension = MimeTypeMap.getFileExtensionFromUrl(url) + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + + withContext(Dispatchers.IO) { + URL(url).openStream().use { + saveBitmap(context, it, mimeType, fileName) + } + } + + Toast.makeText(context, "Saved image", Toast.LENGTH_SHORT).show() +} + @OptIn(ExperimentalComposeUiApi::class) fun Modifier.onAutofill(vararg autofillType: AutofillType, onFill: (String) -> Unit): Modifier = composed { val autofillNode = AutofillNode( diff --git a/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt b/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt index b7a9acc8f..f76b54767 100644 --- a/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt +++ b/app/src/main/java/com/jerboa/ui/components/common/Dialogs.kt @@ -1,15 +1,22 @@ package com.jerboa.ui.components.common +import android.content.Intent import android.util.Log +import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.outlined.BarChart import androidx.compose.material.icons.outlined.Bookmarks import androidx.compose.material.icons.outlined.BrightnessLow +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.FormatListNumbered import androidx.compose.material.icons.outlined.List import androidx.compose.material.icons.outlined.LocalFireDepartment @@ -20,6 +27,8 @@ import androidx.compose.material.icons.outlined.NewReleases import androidx.compose.material.icons.outlined.Public import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.livedata.observeAsState @@ -27,14 +36,21 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import com.jerboa.PostViewMode import com.jerboa.R import com.jerboa.UnreadOrAll import com.jerboa.datatypes.ListingType import com.jerboa.datatypes.SortType import com.jerboa.db.AppSettingsViewModel +import com.jerboa.isImage +import com.jerboa.saveImage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import okhttp3.HttpUrl.Companion.toHttpUrl @@ -321,3 +337,81 @@ fun ShowChangelog(appSettingsViewModel: AppSettingsViewModel) { } } } + +@Composable +fun PostLinkOptionsDialog( + url: String, + onDismissRequest: () -> Unit, + onOpenInBrowser: () -> Unit, +) { + val isImage = isImage(url) + + val clipboardManager = LocalClipboardManager.current + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = {}, + text = { + Column { + Text( + text = url, + modifier = Modifier + .padding(bottom = 15.dp), + color = MaterialTheme.colorScheme.primary, + ) + + IconAndTextDrawerItem( + icon = Icons.Filled.OpenInBrowser, + text = stringResource(R.string.link_dialog_open_in_browser), + onClick = { + onOpenInBrowser() + onDismissRequest() + }, + ) + IconAndTextDrawerItem( + icon = Icons.Filled.Share, + text = stringResource(R.string.link_dialog_share_link), + onClick = { + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, url) + type = "text/plain" + } + + val shareIntent = Intent.createChooser(sendIntent, null) + ContextCompat.startActivity(context, shareIntent, null) + + onDismissRequest() + }, + ) + IconAndTextDrawerItem( + icon = Icons.Outlined.ContentCopy, + text = stringResource(R.string.link_dialog_copy_link), + onClick = { + clipboardManager.setText(AnnotatedString(url)) + Toast.makeText(context, context.resources.getString(R.string.link_dialog_toast_url_copied), Toast.LENGTH_SHORT).show() + onDismissRequest() + }, + ) + + if (isImage) { + Divider(modifier = Modifier.padding(vertical = 20.dp)) + + IconAndTextDrawerItem( + icon = Icons.Outlined.Download, + text = stringResource(R.string.link_dialog_download_image), + onClick = { + coroutineScope.launch { + saveImage(url, context) + } + + onDismissRequest() + }, + ) + } + } + }, + ) +} diff --git a/app/src/main/java/com/jerboa/ui/components/common/ImageViewerDialog.kt b/app/src/main/java/com/jerboa/ui/components/common/ImageViewerDialog.kt index 2544131e1..136aa03e3 100644 --- a/app/src/main/java/com/jerboa/ui/components/common/ImageViewerDialog.kt +++ b/app/src/main/java/com/jerboa/ui/components/common/ImageViewerDialog.kt @@ -1,10 +1,6 @@ package com.jerboa.ui.components.common -import android.content.Context -import android.net.Uri import android.os.Build.VERSION.SDK_INT -import android.webkit.MimeTypeMap -import android.widget.Toast import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -43,13 +39,10 @@ import coil.ImageLoader import coil.compose.rememberAsyncImagePainter import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder -import com.jerboa.saveBitmap -import kotlinx.coroutines.Dispatchers +import com.jerboa.saveImage import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable -import java.net.URL const val backFadeTime = 300 @@ -134,7 +127,7 @@ fun ImageViewerDialog(url: String, onBackRequest: () -> Unit) { BarIcon(icon = Icons.Outlined.Download, name = "Download") { coroutineScope.launch { - SaveImage(url, context) + saveImage(url, context) } } } @@ -142,23 +135,6 @@ fun ImageViewerDialog(url: String, onBackRequest: () -> Unit) { } } -suspend fun SaveImage(url: String, context: Context) { - Toast.makeText(context, "Saving image...", Toast.LENGTH_SHORT).show() - - val fileName = Uri.parse(url).pathSegments.last() - - val extension = MimeTypeMap.getFileExtensionFromUrl(url) - val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) - - withContext(Dispatchers.IO) { - URL(url).openStream().use { - saveBitmap(context, it, mimeType, fileName) - } - } - - Toast.makeText(context, "Saved image", Toast.LENGTH_SHORT).show() -} - @Composable @Preview fun ImageActivityPreview() { diff --git a/app/src/main/java/com/jerboa/ui/components/post/PostListing.kt b/app/src/main/java/com/jerboa/ui/components/post/PostListing.kt index 8e1378b4e..fec3becb0 100644 --- a/app/src/main/java/com/jerboa/ui/components/post/PostListing.kt +++ b/app/src/main/java/com/jerboa/ui/components/post/PostListing.kt @@ -1,7 +1,9 @@ package com.jerboa.ui.components.post import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -85,6 +87,7 @@ import com.jerboa.ui.components.common.ImageViewerDialog import com.jerboa.ui.components.common.MyMarkdownText import com.jerboa.ui.components.common.PictrsThumbnailImage import com.jerboa.ui.components.common.PictrsUrlImage +import com.jerboa.ui.components.common.PostLinkOptionsDialog import com.jerboa.ui.components.common.PreviewLines import com.jerboa.ui.components.common.ScoreAndTime import com.jerboa.ui.components.common.SimpleTopAppBar @@ -255,6 +258,7 @@ fun PostTitleBlock( postView: PostView, expandedImage: Boolean, onPostLinkClick: (url: String) -> Unit, + onPostLinkLongClick: (url: String) -> Unit, account: Account?, ) { val imagePost = postView.post.url?.let { isImage(it) } ?: run { false } @@ -262,11 +266,13 @@ fun PostTitleBlock( if (imagePost && expandedImage) { PostTitleAndImageLink( postView = postView, + onImageLongClick = { onPostLinkLongClick(postView.post.url!!) }, ) } else { PostTitleAndThumbnail( postView = postView, onPostLinkClick = onPostLinkClick, + onPostLinkLongClick = onPostLinkLongClick, account = account, ) } @@ -295,9 +301,11 @@ fun PostName( ) } +@OptIn(ExperimentalFoundationApi::class) @Composable fun PostTitleAndImageLink( postView: PostView, + onImageLongClick: () -> Unit, ) { // This was tested, we know it exists val url = postView.post.url!! @@ -321,12 +329,13 @@ fun PostTitleAndImageLink( ImageViewerDialog(url, onBackRequest = { showImageDialog = false }) } - val postLinkPicMod = Modifier - .clickable { showImageDialog = true } PictrsUrlImage( url = url, nsfw = nsfwCheck(postView), - modifier = postLinkPicMod, + modifier = Modifier.combinedClickable( + onLongClick = onImageLongClick, + onClick = { showImageDialog = true }, + ), ) } @@ -334,6 +343,7 @@ fun PostTitleAndImageLink( fun PostTitleAndThumbnail( postView: PostView, onPostLinkClick: (url: String) -> Unit, + onPostLinkLongClick: (url: String) -> Unit, account: Account?, ) { Column( @@ -361,7 +371,11 @@ fun PostTitleAndThumbnail( } } } - ThumbnailTile(postView = postView, onPostLinkClick = onPostLinkClick) + ThumbnailTile( + postView = postView, + onPostLinkClick = onPostLinkClick, + onPostLinkLongClick = onPostLinkLongClick, + ) } } } @@ -372,6 +386,7 @@ fun PostBody( fullBody: Boolean, expandedImage: Boolean, onPostLinkClick: (rl: String) -> Unit, + onPostLinkLongClick: (rl: String) -> Unit, account: Account?, ) { val post = postView.post @@ -382,6 +397,7 @@ fun PostBody( postView = postView, expandedImage = expandedImage, onPostLinkClick = onPostLinkClick, + onPostLinkLongClick = onPostLinkLongClick, account = account, ) @@ -431,6 +447,7 @@ fun PreviewStoryTitleAndMetadata() { PostBody( postView = samplePostView, onPostLinkClick = {}, + onPostLinkLongClick = {}, fullBody = false, expandedImage = false, account = null, @@ -829,6 +846,18 @@ fun PostListing( ) } + var showLinkDialog by remember { mutableStateOf(null) } + + if (showLinkDialog != null) { + val url = showLinkDialog!! + + PostLinkOptionsDialog( + url = url, + onDismissRequest = { showLinkDialog = null }, + onOpenInBrowser = { onPostLinkClick(url) }, + ) + } + when (postViewMode) { PostViewMode.Card -> PostListingCard( postView = postView, @@ -850,6 +879,7 @@ fun PostListing( onReplyClick = onReplyClick, onPostClick = onPostClick, onPostLinkClick = onPostLinkClick, + onPostLinkLongClick = { showLinkDialog = it }, onSaveClick = onSaveClick, onCommunityClick = onCommunityClick, onEditPostClick = onEditPostClick, @@ -887,6 +917,7 @@ fun PostListing( onReplyClick = onReplyClick, onPostClick = onPostClick, onPostLinkClick = onPostLinkClick, + onPostLinkLongClick = { showLinkDialog = it }, onSaveClick = onSaveClick, onCommunityClick = onCommunityClick, onEditPostClick = onEditPostClick, @@ -923,6 +954,7 @@ fun PostListing( }, onPostClick = onPostClick, onPostLinkClick = onPostLinkClick, + onPostLinkLongClick = { showLinkDialog = it }, onCommunityClick = onCommunityClick, onPersonClick = onPersonClick, isModerator = isModerator, @@ -988,6 +1020,7 @@ fun PostListingList( onDownvoteClick: (postView: PostView) -> Unit, onPostClick: (postView: PostView) -> Unit, onPostLinkClick: (url: String) -> Unit, + onPostLinkLongClick: (url: String) -> Unit = { }, onCommunityClick: (community: CommunitySafe) -> Unit, onPersonClick: (personId: Int) -> Unit, isModerator: Boolean, @@ -1088,20 +1121,25 @@ fun PostListingList( ) } } - ThumbnailTile(postView, onPostLinkClick) + ThumbnailTile(postView, onPostLinkClick, onPostLinkLongClick) } } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun ThumbnailTile( postView: PostView, onPostLinkClick: (url: String) -> Unit, + onPostLinkLongClick: (url: String) -> Unit, ) { postView.post.url?.also { url -> val postLinkPicMod = Modifier .size(POST_LINK_PIC_SIZE) - .clickable { onPostLinkClick(url) } + .combinedClickable( + onLongClick = { onPostLinkLongClick(url) }, + onClick = { onPostLinkClick(url) }, + ) postView.post.thumbnail_url?.also { thumbnail -> PictrsThumbnailImage( @@ -1193,6 +1231,7 @@ fun PostListingCard( onReplyClick: (postView: PostView) -> Unit = {}, onPostClick: (postView: PostView) -> Unit, onPostLinkClick: (url: String) -> Unit, + onPostLinkLongClick: (url: String) -> Unit, onSaveClick: (postView: PostView) -> Unit, onCommunityClick: (community: CommunitySafe) -> Unit, onEditPostClick: (postView: PostView) -> Unit, @@ -1233,6 +1272,7 @@ fun PostListingCard( PostBody( postView = postView, onPostLinkClick = onPostLinkClick, + onPostLinkLongClick = onPostLinkLongClick, fullBody = fullBody, expandedImage = expandedImage, account = account, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9661fcd98..69c84870a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -288,4 +288,9 @@ Profile Show action bar by default for comments Show voting arrows in list view + Open in browser + Share link + Copy link + Download image + URL copied to clipboard