From 2ebbb86c92f2cb60d75af2ea2d2705c3cb2e13cf Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Sat, 22 Aug 2026 14:16:49 -0700 Subject: [PATCH] Open comments over the card instead of pushing the gallery detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A feed card and the gallery detail screen render the same GalleryCard, so tapping through from a feed showed you what you were already looking at, one navigation later. iOS never makes that push: onNavigate in GalleryCardView is only the fallback for a missing onCommentTap, and every feed passes one. The feed, the hashtag feed and search now do the same — the comment button opens a sheet over the card, and nothing in a feed reaches the detail screen. It stays as the destination for deep links, notifications and the profile grid, where a thumbnail is all you have. GalleryCommentsViewModel backs the sheet for a gallery that has no detail screen behind it. The subject switches under one live view model as you open one card after another, the way StoryCommentsViewModel already worked, rather than each gallery getting an instance that outlives its sheet. It reports the thread size back so a card's count matches what the sheet just showed. Losing the push meant the card had to carry what the detail screen did. A photo tap now reveals its alt text over the photo, with the ALT badge iOS shows, since the tap that opens it is otherwise invisible; without a description the tap does nothing rather than flashing an empty panel. Your own gallery gets the delete entry in the overflow, and report is hidden there — it offered both before. Search results also gained the favorite toggle: SearchScreen never passed onFavoriteToggle and SearchViewModel had no toggleFavorite, so the heart was decorative, which mattered little while the card was a doorway to the detail screen and matters now that it is the whole view. Comment failures are shown inside the sheet rather than by the host. A snackbar raised behind a modal sheet is a message nobody reads; the story viewer wasn't even trying, dropping its comment errors on the floor. The feed's disk cache is now written back on every local change. Favoriting updated memory and the server but never the cache, so a cold start rendered the pre-favorite state and only corrected itself when the refresh landed — on a phone, 285ms of empty heart, and permanently if the refresh fails, since refreshIfStale then holds off for five minutes. That is why a gallery could read as unfavorited in the feed and favorited in its detail at the same time. Comment counts and deletes are filed the same way. Co-Authored-By: Claude Opus 5 --- .../social/grain/GalleryCardAltTextTest.kt | 81 ++++++ .../social/grain/feature/feed/FeedScreen.kt | 55 +++- .../grain/feature/feed/FeedViewModel.kt | 50 ++++ .../grain/feature/feed/HashtagFeedScreen.kt | 52 +++- .../feature/feed/HashtagFeedViewModel.kt | 32 ++- .../feature/gallery/GalleryCommentSheet.kt | 65 +++++ .../gallery/GalleryCommentsViewModel.kt | 251 ++++++++++++++++++ .../feature/gallery/GalleryDetailScreen.kt | 7 +- .../grain/feature/search/SearchScreen.kt | 65 ++++- .../grain/feature/search/SearchViewModel.kt | 76 +++++- .../feature/stories/StoryViewerScreen.kt | 2 + .../social/grain/navigation/GrainNavHost.kt | 3 - .../grain/ui/components/CommentSheet.kt | 26 ++ .../social/grain/ui/components/GalleryCard.kt | 92 ++++++- 14 files changed, 830 insertions(+), 27 deletions(-) create mode 100644 app/src/androidTest/java/social/grain/GalleryCardAltTextTest.kt create mode 100644 app/src/main/java/social/grain/feature/gallery/GalleryCommentSheet.kt create mode 100644 app/src/main/java/social/grain/feature/gallery/GalleryCommentsViewModel.kt diff --git a/app/src/androidTest/java/social/grain/GalleryCardAltTextTest.kt b/app/src/androidTest/java/social/grain/GalleryCardAltTextTest.kt new file mode 100644 index 0000000..93a10d0 --- /dev/null +++ b/app/src/androidTest/java/social/grain/GalleryCardAltTextTest.kt @@ -0,0 +1,81 @@ +package social.grain + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import social.grain.data.model.AspectRatio +import social.grain.data.model.GrainGallery +import social.grain.data.model.GrainPhoto +import social.grain.data.model.GrainProfile +import social.grain.ui.components.GalleryCard +import social.grain.ui.theme.GrainTheme + +private const val ALT = "A heron standing in shallow water at dawn" + +/** Long enough to clear the platform's double-tap timeout. */ +private const val DOUBLE_TAP_WINDOW_MS = 1000L + +/** + * The card carries a photo's description itself. + * + * The feed no longer pushes a detail screen, so a tap on the photo has to be + * what reveals the alt text — if this regresses, the description becomes + * unreachable to anyone not using a screen reader. + */ +@RunWith(AndroidJUnit4::class) +class GalleryCardAltTextTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun gallery(alt: String?) = GrainGallery( + uri = "at://did:plc:test/social.grain.gallery/1", + creator = GrainProfile(did = "did:plc:test", handle = "heron.example"), + items = listOf( + GrainPhoto( + uri = "at://did:plc:test/social.grain.photo/1", + alt = alt, + aspectRatio = AspectRatio(3, 2), + ), + ), + indexedAt = "2026-08-01T00:00:00Z", + ) + + @Test + fun tappingThePhotoTogglesTheAltText() { + composeRule.setContent { GrainTheme { GalleryCard(gallery = gallery(ALT)) } } + + composeRule.onNodeWithText(ALT).assertDoesNotExist() + + composeRule.onNodeWithContentDescription(ALT).performClick() + // The carousel also listens for a double tap, so a single tap is only + // reported once that window has passed. + composeRule.mainClock.advanceTimeBy(DOUBLE_TAP_WINDOW_MS) + composeRule.onNodeWithText(ALT).assertIsDisplayed() + + composeRule.onNodeWithText(ALT).performClick() + composeRule.waitForIdle() + composeRule.onNodeWithText(ALT).assertDoesNotExist() + } + + @Test + fun theBadgeAlsoOpensTheAltText() { + composeRule.setContent { GrainTheme { GalleryCard(gallery = gallery(ALT)) } } + + composeRule.onNodeWithContentDescription("Show alt text").performClick() + composeRule.onNodeWithText(ALT).assertIsDisplayed() + } + + @Test + fun aPhotoWithNoAltShowsNoBadge() { + composeRule.setContent { GrainTheme { GalleryCard(gallery = gallery(null)) } } + + composeRule.onNodeWithText("ALT").assertDoesNotExist() + } +} diff --git a/app/src/main/java/social/grain/feature/feed/FeedScreen.kt b/app/src/main/java/social/grain/feature/feed/FeedScreen.kt index a4f6aaa..e006262 100644 --- a/app/src/main/java/social/grain/feature/feed/FeedScreen.kt +++ b/app/src/main/java/social/grain/feature/feed/FeedScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -46,6 +47,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import social.grain.data.api.ReportSubject +import social.grain.feature.gallery.GalleryCommentSheet import social.grain.feature.moderation.ReportSheet import social.grain.feature.stories.StoryStrip import social.grain.ui.components.GalleryCard @@ -53,7 +55,6 @@ import social.grain.ui.components.GalleryCard @OptIn(ExperimentalMaterial3Api::class) @Composable fun FeedScreen( - onOpenGallery: (String) -> Unit, onOpenProfile: (String) -> Unit, onCreate: () -> Unit, onOpenStories: (String) -> Unit, @@ -70,6 +71,11 @@ fun FeedScreen( val snackbarHostState = remember { SnackbarHostState() } var feedMenuOpen by remember { mutableStateOf(false) } var reportSubject by remember { mutableStateOf(null) } + // The gallery whose comments are open, if any — the card already shows + // everything the detail screen would, so the feed opens comments over it + // rather than pushing a second copy of what's on screen. + var commentSheetUri by remember { mutableStateOf(null) } + var deleteUri by remember { mutableStateOf(null) } // Prefetch a page ahead of the viewport rather than at the very bottom, so // a fast scroll doesn't hit an empty list while the request is in flight. @@ -214,18 +220,27 @@ fun FeedScreen( } items(state.galleries, key = { it.uri }) { gallery -> + val isOwn = gallery.creator.did == state.viewerDid GalleryCard( gallery = gallery, labelDefinitions = state.labelDefinitions, - onOpen = { onOpenGallery(gallery.uri) }, onProfileTap = onOpenProfile, onFavoriteToggle = { viewModel.toggleFavorite(gallery.uri) }, onFavoritesTap = { onOpenFavorites(gallery.uri) }, - onCommentTap = { onOpenGallery(gallery.uri) }, + onCommentTap = { commentSheetUri = gallery.uri }, onMentionTap = onOpenProfile, onHashtagTap = onOpenHashtag, - onReport = { - reportSubject = ReportSubject.Record(gallery.uri, gallery.cid) + // Your own gallery is yours to delete, not to + // report — the overflow shows one or the other. + onReport = if (isOwn) { + null + } else { + { reportSubject = ReportSubject.Record(gallery.uri, gallery.cid) } + }, + onDelete = if (isOwn) { + { deleteUri = gallery.uri } + } else { + null }, onShare = { context.startActivity( @@ -258,7 +273,37 @@ fun FeedScreen( } } + commentSheetUri?.let { uri -> + GalleryCommentSheet( + galleryUri = uri, + onDismiss = { commentSheetUri = null }, + onProfileTap = onOpenProfile, + onHashtagTap = onOpenHashtag, + onReport = { comment -> + reportSubject = ReportSubject.Record(comment.uri, comment.cid) + }, + onCommentCountChanged = { count -> viewModel.setCommentCount(uri, count) }, + ) + } + reportSubject?.let { subject -> ReportSheet(subject = subject, onDismiss = { reportSubject = null }) } + + deleteUri?.let { uri -> + AlertDialog( + onDismissRequest = { deleteUri = null }, + title = { Text("Delete gallery?") }, + text = { Text("This will permanently delete this gallery and all its photos.") }, + confirmButton = { + TextButton(onClick = { + deleteUri = null + viewModel.deleteGallery(uri) + }) { Text("Delete") } + }, + dismissButton = { + TextButton(onClick = { deleteUri = null }) { Text("Cancel") } + }, + ) + } } diff --git a/app/src/main/java/social/grain/feature/feed/FeedViewModel.kt b/app/src/main/java/social/grain/feature/feed/FeedViewModel.kt index cfd59fe..41c100f 100644 --- a/app/src/main/java/social/grain/feature/feed/FeedViewModel.kt +++ b/app/src/main/java/social/grain/feature/feed/FeedViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import social.grain.data.api.FavoriteService +import social.grain.data.api.GalleryService import social.grain.data.api.PinnedFeed import social.grain.data.api.XrpcClient import social.grain.data.api.getFeed @@ -237,16 +238,62 @@ class FeedViewModel @Inject constructor( } else { FavoriteService.delete(existing, client, context) } + persistFeed() } catch (e: Exception) { updateGallery(uri) { it.copy(viewer = GalleryViewerState(fav = existing), favCount = previousCount) } + persistFeed() } } } + /** Keep a card's comment count in step with the sheet opened over it. */ + fun setCommentCount(uri: String, count: Int) { + updateGallery(uri) { it.copy(commentCount = count) } + persistFeed() + } + + /** + * Delete one of the viewer's own galleries from the feed. + * + * The card goes on the write landing rather than before it: unlike a + * favourite, there's no way to put it back if the delete fails. + */ + fun deleteGallery(uri: String) { + viewModelScope.launch { + val context = auth.authContext() ?: return@launch + runCatching { GalleryService.delete(uri, client, context) } + .onSuccess { + _state.update { s -> + s.copy(galleries = s.galleries.filterNot { it.uri == uri }) + } + persistFeed() + } + .onFailure { + Log.w(TAG, "Couldn't delete gallery $uri", it) + _state.update { it.copy(errorMessage = "Couldn't delete that gallery.") } + } + } + } + fun dismissError() = _state.update { it.copy(errorMessage = null) } + /** + * Write the visible first page back to disk. + * + * The cache is what the next cold start renders, and the refresh that would + * correct it lands a frame or two later — or not at all, on a bad + * connection, since [refreshIfStale] then holds off for five minutes. A + * heart the reader filled by hand has to survive that, so every change made + * locally is filed as well as sent. + */ + private fun persistFeed() { + val feedId = _state.value.selectedFeedId + val page = _state.value.galleries.take(CACHED_PAGE) + viewModelScope.launch { feedCache.save(page, feedId) } + } + private fun updateGallery(uri: String, transform: (GrainGallery) -> GrainGallery) { _state.update { current -> current.copy( @@ -320,3 +367,6 @@ class FeedViewModel @Inject constructor( /** How old the feed has to be before returning to the app re-reads it. */ private const val STALE_AFTER_MS = 5 * 60 * 1000L + +/** Galleries kept on disk — one page, matching what a refresh files. */ +private const val CACHED_PAGE = 30 diff --git a/app/src/main/java/social/grain/feature/feed/HashtagFeedScreen.kt b/app/src/main/java/social/grain/feature/feed/HashtagFeedScreen.kt index 8214140..9e78aa0 100644 --- a/app/src/main/java/social/grain/feature/feed/HashtagFeedScreen.kt +++ b/app/src/main/java/social/grain/feature/feed/HashtagFeedScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -20,6 +21,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable @@ -37,6 +39,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import social.grain.data.api.ReportSubject +import social.grain.feature.gallery.GalleryCommentSheet import social.grain.feature.moderation.ReportSheet import social.grain.ui.components.GalleryCard @@ -45,7 +48,6 @@ import social.grain.ui.components.GalleryCard @Composable fun HashtagFeedScreen( onBack: () -> Unit, - onOpenGallery: (String) -> Unit, onOpenProfile: (String) -> Unit, onOpenHashtag: (String) -> Unit, onOpenFavorites: (String) -> Unit = {}, @@ -56,6 +58,9 @@ fun HashtagFeedScreen( val listState = rememberLazyListState() val snackbarHostState = remember { SnackbarHostState() } var reportSubject by remember { mutableStateOf(null) } + // Comments open over the card here too — the same reasoning as the feed. + var commentSheetUri by remember { mutableStateOf(null) } + var deleteUri by remember { mutableStateOf(null) } val shouldLoadMore by remember { derivedStateOf { @@ -123,18 +128,25 @@ fun HashtagFeedScreen( } items(state.galleries, key = { it.uri }) { gallery -> + val isOwn = gallery.creator.did == state.viewerDid GalleryCard( gallery = gallery, labelDefinitions = state.labelDefinitions, - onOpen = { onOpenGallery(gallery.uri) }, onProfileTap = onOpenProfile, onFavoriteToggle = { viewModel.toggleFavorite(gallery.uri) }, - onCommentTap = { onOpenGallery(gallery.uri) }, + onCommentTap = { commentSheetUri = gallery.uri }, onFavoritesTap = { onOpenFavorites(gallery.uri) }, onMentionTap = onOpenProfile, onHashtagTap = onOpenHashtag, - onReport = { - reportSubject = ReportSubject.Record(gallery.uri, gallery.cid) + onReport = if (isOwn) { + null + } else { + { reportSubject = ReportSubject.Record(gallery.uri, gallery.cid) } + }, + onDelete = if (isOwn) { + { deleteUri = gallery.uri } + } else { + null }, onShare = { context.startActivity( @@ -164,7 +176,37 @@ fun HashtagFeedScreen( } } + commentSheetUri?.let { uri -> + GalleryCommentSheet( + galleryUri = uri, + onDismiss = { commentSheetUri = null }, + onProfileTap = onOpenProfile, + onHashtagTap = onOpenHashtag, + onReport = { comment -> + reportSubject = ReportSubject.Record(comment.uri, comment.cid) + }, + onCommentCountChanged = { count -> viewModel.setCommentCount(uri, count) }, + ) + } + reportSubject?.let { subject -> ReportSheet(subject = subject, onDismiss = { reportSubject = null }) } + + deleteUri?.let { uri -> + AlertDialog( + onDismissRequest = { deleteUri = null }, + title = { Text("Delete gallery?") }, + text = { Text("This will permanently delete this gallery and all its photos.") }, + confirmButton = { + TextButton(onClick = { + deleteUri = null + viewModel.deleteGallery(uri) + }) { Text("Delete") } + }, + dismissButton = { + TextButton(onClick = { deleteUri = null }) { Text("Cancel") } + }, + ) + } } diff --git a/app/src/main/java/social/grain/feature/feed/HashtagFeedViewModel.kt b/app/src/main/java/social/grain/feature/feed/HashtagFeedViewModel.kt index 6be0e08..c2a91f0 100644 --- a/app/src/main/java/social/grain/feature/feed/HashtagFeedViewModel.kt +++ b/app/src/main/java/social/grain/feature/feed/HashtagFeedViewModel.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import social.grain.data.api.FavoriteService +import social.grain.data.api.GalleryService import social.grain.data.api.XrpcClient import social.grain.data.api.describeLabels import social.grain.data.api.getFeed @@ -22,6 +23,7 @@ import javax.inject.Inject data class HashtagFeedUiState( val tag: String = "", val galleries: List = emptyList(), + val viewerDid: String? = null, val labelDefinitions: List = emptyList(), val isLoading: Boolean = true, val isRefreshing: Boolean = false, @@ -49,7 +51,9 @@ class HashtagFeedViewModel @Inject constructor( private val tag: String = savedStateHandle.get("tag").orEmpty() - private val _state = MutableStateFlow(HashtagFeedUiState(tag = tag)) + private val _state = MutableStateFlow( + HashtagFeedUiState(tag = tag, viewerDid = auth.state.value.did), + ) val state: StateFlow = _state.asStateFlow() private var cursor: String? = null @@ -163,6 +167,32 @@ class HashtagFeedViewModel @Inject constructor( } } + /** Keep a card's comment count in step with the sheet opened over it. */ + fun setCommentCount(uri: String, count: Int) { + updateGallery(uri) { it.copy(commentCount = count) } + } + + /** + * Delete one of the viewer's own galleries. + * + * The card goes on the write landing rather than before it: unlike a + * favourite, there's no way to put it back if the delete fails. + */ + fun deleteGallery(uri: String) { + viewModelScope.launch { + val context = auth.authContext() ?: return@launch + runCatching { GalleryService.delete(uri, client, context) } + .onSuccess { + _state.update { s -> + s.copy(galleries = s.galleries.filterNot { it.uri == uri }) + } + } + .onFailure { + _state.update { it.copy(errorMessage = "Couldn't delete that gallery.") } + } + } + } + fun dismissError() = _state.update { it.copy(errorMessage = null) } private fun updateGallery(uri: String, transform: (GrainGallery) -> GrainGallery) { diff --git a/app/src/main/java/social/grain/feature/gallery/GalleryCommentSheet.kt b/app/src/main/java/social/grain/feature/gallery/GalleryCommentSheet.kt new file mode 100644 index 0000000..b407df7 --- /dev/null +++ b/app/src/main/java/social/grain/feature/gallery/GalleryCommentSheet.kt @@ -0,0 +1,65 @@ +package social.grain.feature.gallery + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import social.grain.data.model.GrainComment +import social.grain.ui.components.CommentSheet + +/** + * The comment sheet for a gallery that isn't on screen as a detail view — the + * feed opens it over the card rather than pushing the whole gallery, as iOS + * does from its comment button. + */ +@Composable +fun GalleryCommentSheet( + galleryUri: String, + onDismiss: () -> Unit, + onProfileTap: (String) -> Unit, + onHashtagTap: (String) -> Unit = {}, + onReport: ((GrainComment) -> Unit)? = null, + /** Reports the thread's size back so the card's count matches the sheet. */ + onCommentCountChanged: (Int) -> Unit = {}, + viewModel: GalleryCommentsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(galleryUri) { viewModel.open(galleryUri) } + + LaunchedEffect(state.galleryUri, state.hasLoaded, state.totalCount) { + if (state.galleryUri == galleryUri && state.hasLoaded) { + onCommentCountChanged(state.totalCount) + } + } + + CommentSheet( + threads = state.threads, + viewerDid = state.viewerDid, + isPosting = state.isPosting, + hasLoaded = state.hasLoaded, + isLoadingMore = state.isLoadingMore, + onDismiss = onDismiss, + onPost = viewModel::postComment, + onDelete = viewModel::deleteComment, + onToggleFavorite = viewModel::toggleFavorite, + onProfileTap = { did -> + onDismiss() + onProfileTap(did) + }, + onLoadMore = viewModel::loadMore, + onHashtagTap = { tag -> + onDismiss() + onHashtagTap(tag) + }, + onReport = onReport?.let { report -> + { comment -> + onDismiss() + report(comment) + } + }, + errorMessage = state.errorMessage, + onErrorDismissed = viewModel::dismissError, + ) +} diff --git a/app/src/main/java/social/grain/feature/gallery/GalleryCommentsViewModel.kt b/app/src/main/java/social/grain/feature/gallery/GalleryCommentsViewModel.kt new file mode 100644 index 0000000..ca44da9 --- /dev/null +++ b/app/src/main/java/social/grain/feature/gallery/GalleryCommentsViewModel.kt @@ -0,0 +1,251 @@ +package social.grain.feature.gallery + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import social.grain.data.api.CommentService +import social.grain.data.api.FavoriteService +import social.grain.data.api.XrpcClient +import social.grain.data.api.getCommentThread +import social.grain.data.auth.AuthManager +import social.grain.data.model.CommentThread +import social.grain.data.model.CommentViewerState +import social.grain.data.model.GrainComment +import social.grain.data.model.asThreads +import javax.inject.Inject + +private const val TAG = "GalleryComments" + +data class GalleryCommentsUiState( + val galleryUri: String? = null, + val comments: List = emptyList(), + val totalCount: Int = 0, + val viewerDid: String? = null, + val hasLoaded: Boolean = false, + val isLoadingMore: Boolean = false, + val isPosting: Boolean = false, + val errorMessage: String? = null, +) { + val threads: List get() = comments.asThreads() +} + +/** + * Comments on a gallery read in place — from the feed's comment button, where + * there's no detail screen to carry [GalleryDetailViewModel]. + * + * The subject changes underneath one live view model as the reader opens the + * sheet on one card after another, rather than each gallery getting its own + * instance that would outlive its sheet. + */ +@HiltViewModel +class GalleryCommentsViewModel @Inject constructor( + private val client: XrpcClient, + private val auth: AuthManager, +) : ViewModel() { + + private val _state = MutableStateFlow( + GalleryCommentsUiState(viewerDid = auth.state.value.did), + ) + val state: StateFlow = _state.asStateFlow() + + private var cursor: String? = null + private var hasMore = true + + /** Point at a gallery and read its thread. Called each time the sheet opens. */ + fun open(uri: String) { + val sameGallery = uri == _state.value.galleryUri + cursor = null + hasMore = true + _state.update { + it.copy( + galleryUri = uri, + // Reopening the same gallery keeps what's already on screen so + // the refetch doesn't flash a spinner over it. + comments = if (sameGallery) it.comments else emptyList(), + totalCount = if (sameGallery) it.totalCount else 0, + hasLoaded = sameGallery && it.hasLoaded, + viewerDid = auth.state.value.did, + ) + } + loadComments(uri) + } + + fun loadMore() { + val uri = _state.value.galleryUri ?: return + val next = cursor ?: return + if (!hasMore || _state.value.isLoadingMore) return + + viewModelScope.launch { + _state.update { it.copy(isLoadingMore = true) } + runCatching { client.getCommentThread(uri, cursor = next, auth = auth.authContext()) } + .onSuccess { response -> + if (_state.value.galleryUri != uri) return@onSuccess + cursor = response.cursor + hasMore = response.cursor != null + _state.update { + it.copy( + comments = it.comments + response.comments, + isLoadingMore = false, + ) + } + } + .onFailure { + Log.w(TAG, "Couldn't load more comments for $uri", it) + _state.update { it.copy(isLoadingMore = false) } + } + } + } + + /** + * Post a comment, then re-read the thread. + * + * The appview assigns the view's shape — author profile, fav state, the + * canonical `createdAt` — so there's nothing honest to insert optimistically. + */ + fun postComment(text: String, replyTo: GrainComment?) { + val uri = _state.value.galleryUri ?: return + val trimmed = text.trim() + if (trimmed.isEmpty() || _state.value.isPosting) return + + viewModelScope.launch { + _state.update { it.copy(isPosting = true) } + val context = auth.authContext() + val repo = auth.state.value.did + if (context == null || repo == null) { + _state.update { it.copy(isPosting = false, errorMessage = "Sign in to comment.") } + return@launch + } + + runCatching { + CommentService.create( + subject = uri, + text = trimmed, + repo = repo, + replyTo = replyTo?.uri, + client = client, + auth = context, + ) + } + .onSuccess { + _state.update { it.copy(isPosting = false) } + loadComments(uri) + } + .onFailure { + Log.w(TAG, "Couldn't post comment on $uri", it) + _state.update { + it.copy(isPosting = false, errorMessage = "Couldn't post that comment.") + } + } + } + } + + fun deleteComment(comment: GrainComment) { + viewModelScope.launch { + val context = auth.authContext() ?: return@launch + val previous = _state.value.comments + val previousCount = _state.value.totalCount + _state.update { current -> + val remaining = current.comments.filterNot { + it.uri == comment.uri || it.replyTo == comment.uri + } + current.copy( + comments = remaining, + totalCount = (current.totalCount - (previous.size - remaining.size)) + .coerceAtLeast(0), + ) + } + runCatching { CommentService.delete(comment.uri, client, context) } + .onFailure { + Log.w(TAG, "Couldn't delete comment ${comment.uri}", it) + _state.update { + it.copy( + comments = previous, + totalCount = previousCount, + errorMessage = "Couldn't delete that comment.", + ) + } + } + } + } + + fun toggleFavorite(comment: GrainComment) { + val existing = comment.viewer?.fav + val previousCount = comment.favCount ?: 0 + + updateComment(comment.uri) { + it.copy( + viewer = CommentViewerState(fav = if (existing == null) PENDING else null), + favCount = if (existing == null) { + previousCount + 1 + } else { + (previousCount - 1).coerceAtLeast(0) + }, + ) + } + + viewModelScope.launch { + val context = auth.authContext() ?: return@launch + val repo = auth.state.value.did ?: return@launch + runCatching { + if (existing == null) { + val created = FavoriteService.create(comment.uri, repo, client, context) + updateComment(comment.uri) { + it.copy(viewer = CommentViewerState(fav = created.uri)) + } + } else { + FavoriteService.delete(existing, client, context) + } + }.onFailure { + updateComment(comment.uri) { + it.copy(viewer = CommentViewerState(fav = existing), favCount = previousCount) + } + } + } + } + + fun dismissError() = _state.update { it.copy(errorMessage = null) } + + private fun loadComments(uri: String) { + viewModelScope.launch { + runCatching { client.getCommentThread(uri, auth = auth.authContext()) } + .onSuccess { response -> + // The reader may have moved to another card's sheet while + // this was in flight. + if (_state.value.galleryUri != uri) return@onSuccess + cursor = response.cursor + hasMore = response.cursor != null + _state.update { + it.copy( + comments = response.comments, + totalCount = response.totalCount ?: response.comments.size, + hasLoaded = true, + ) + } + } + .onFailure { error -> + Log.w(TAG, "Couldn't load comments for $uri", error) + if (_state.value.galleryUri != uri) return@onFailure + _state.update { + it.copy(hasLoaded = true, errorMessage = "Couldn't load comments.") + } + } + } + } + + private fun updateComment(uri: String, transform: (GrainComment) -> GrainComment) { + _state.update { current -> + current.copy(comments = current.comments.map { if (it.uri == uri) transform(it) else it }) + } + } + + private companion object { + /** Stands in for a favorite URI until the create call answers. */ + const val PENDING = "pending" + } +} diff --git a/app/src/main/java/social/grain/feature/gallery/GalleryDetailScreen.kt b/app/src/main/java/social/grain/feature/gallery/GalleryDetailScreen.kt index fa5897f..4fcfe0f 100644 --- a/app/src/main/java/social/grain/feature/gallery/GalleryDetailScreen.kt +++ b/app/src/main/java/social/grain/feature/gallery/GalleryDetailScreen.kt @@ -66,7 +66,10 @@ fun GalleryDetailScreen( if (state.isDeleted) onBack() } - LaunchedEffect(state.errorMessage) { + // While the sheet is up the snackbar is behind it, so the sheet shows its + // own failures inline and this only covers the screen underneath. + LaunchedEffect(state.errorMessage, showComments) { + if (showComments) return@LaunchedEffect state.errorMessage?.let { snackbarHostState.showSnackbar(it) viewModel.dismissError() @@ -179,6 +182,8 @@ fun GalleryDetailScreen( showComments = false reportSubject = ReportSubject.Record(comment.uri, comment.cid) }, + errorMessage = state.errorMessage, + onErrorDismissed = viewModel::dismissError, ) } diff --git a/app/src/main/java/social/grain/feature/search/SearchScreen.kt b/app/src/main/java/social/grain/feature/search/SearchScreen.kt index bc2f74d..f57bba6 100644 --- a/app/src/main/java/social/grain/feature/search/SearchScreen.kt +++ b/app/src/main/java/social/grain/feature/search/SearchScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -48,6 +49,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import social.grain.data.api.ActorListItem import social.grain.data.api.ReportSubject +import social.grain.feature.gallery.GalleryCommentSheet import social.grain.feature.moderation.ReportSheet import social.grain.ui.components.Avatar import social.grain.ui.components.GalleryCard @@ -55,7 +57,6 @@ import social.grain.ui.components.GalleryCard @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchScreen( - onOpenGallery: (String) -> Unit, onOpenProfile: (String) -> Unit, onOpenFavorites: (String) -> Unit, onOpenHashtag: (String) -> Unit = {}, @@ -63,6 +64,10 @@ fun SearchScreen( ) { val state by viewModel.state.collectAsStateWithLifecycle() var reportSubject by remember { mutableStateOf(null) } + // As in the feed, a result's comments open over the card rather than + // pushing a detail screen that would show the same card again. + var commentSheetUri by remember { mutableStateOf(null) } + var deleteUri by remember { mutableStateOf(null) } Scaffold( topBar = { @@ -144,19 +149,33 @@ fun SearchScreen( modifier = Modifier.fillMaxSize(), ) { items(state.galleries, key = { it.uri }) { gallery -> + val isOwn = gallery.creator.did == state.viewerDid GalleryCard( gallery = gallery, - onOpen = { - viewModel.commitQuery() - onOpenGallery(gallery.uri) - }, onProfileTap = onOpenProfile, + onFavoriteToggle = { viewModel.toggleFavorite(gallery.uri) }, onFavoritesTap = { onOpenFavorites(gallery.uri) }, + onCommentTap = { + // Engaging with a result is what makes + // the term worth remembering, which the + // navigation used to stand for. + viewModel.commitQuery() + commentSheetUri = gallery.uri + }, onMentionTap = onOpenProfile, onHashtagTap = onOpenHashtag, - onReport = { - reportSubject = - ReportSubject.Record(gallery.uri, gallery.cid) + onReport = if (isOwn) { + null + } else { + { + reportSubject = + ReportSubject.Record(gallery.uri, gallery.cid) + } + }, + onDelete = if (isOwn) { + { deleteUri = gallery.uri } + } else { + null }, ) } @@ -183,9 +202,39 @@ fun SearchScreen( } } + commentSheetUri?.let { uri -> + GalleryCommentSheet( + galleryUri = uri, + onDismiss = { commentSheetUri = null }, + onProfileTap = onOpenProfile, + onHashtagTap = onOpenHashtag, + onReport = { comment -> + reportSubject = ReportSubject.Record(comment.uri, comment.cid) + }, + onCommentCountChanged = { count -> viewModel.setCommentCount(uri, count) }, + ) + } + reportSubject?.let { subject -> ReportSheet(subject = subject, onDismiss = { reportSubject = null }) } + + deleteUri?.let { uri -> + AlertDialog( + onDismissRequest = { deleteUri = null }, + title = { Text("Delete gallery?") }, + text = { Text("This will permanently delete this gallery and all its photos.") }, + confirmButton = { + TextButton(onClick = { + deleteUri = null + viewModel.deleteGallery(uri) + }) { Text("Delete") } + }, + dismissButton = { + TextButton(onClick = { deleteUri = null }) { Text("Cancel") } + }, + ) + } } @Composable diff --git a/app/src/main/java/social/grain/feature/search/SearchViewModel.kt b/app/src/main/java/social/grain/feature/search/SearchViewModel.kt index f0c7d93..477dcd9 100644 --- a/app/src/main/java/social/grain/feature/search/SearchViewModel.kt +++ b/app/src/main/java/social/grain/feature/search/SearchViewModel.kt @@ -12,12 +12,15 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import social.grain.data.api.ActorListItem +import social.grain.data.api.FavoriteService +import social.grain.data.api.GalleryService import social.grain.data.api.XrpcClient import social.grain.data.api.searchGalleries import social.grain.data.api.searchProfiles import social.grain.data.auth.AuthManager import social.grain.data.local.RecentProfileSearch import social.grain.data.local.RecentSearchStore +import social.grain.data.model.GalleryViewerState import social.grain.data.model.GrainGallery import javax.inject.Inject @@ -34,6 +37,7 @@ data class SearchUiState( val hasSearched: Boolean = false, val recentProfiles: List = emptyList(), val recentTerms: List = emptyList(), + val viewerDid: String? = null, ) { val isEmptyQuery: Boolean get() = query.isBlank() @@ -47,7 +51,7 @@ class SearchViewModel @Inject constructor( private val recents: RecentSearchStore, ) : ViewModel() { - private val _state = MutableStateFlow(SearchUiState()) + private val _state = MutableStateFlow(SearchUiState(viewerDid = auth.state.value.did)) val state: StateFlow = _state.asStateFlow() private var searchJob: Job? = null @@ -123,6 +127,73 @@ class SearchViewModel @Inject constructor( reloadRecents() } + /** + * Toggle a favourite on a result, updating the card before the write lands + * and reverting it if the write fails — the same contract the feed's cards + * have, which results now need too: search shows the card and nothing else. + */ + fun toggleFavorite(uri: String) { + val gallery = _state.value.galleries.firstOrNull { it.uri == uri } ?: return + val existing = gallery.viewer?.fav + val previousCount = gallery.favCount ?: 0 + + updateGallery(uri) { + it.copy( + viewer = GalleryViewerState(fav = if (existing == null) PLACEHOLDER_FAV else null), + favCount = if (existing == null) { + previousCount + 1 + } else { + (previousCount - 1).coerceAtLeast(0) + }, + ) + } + + viewModelScope.launch { + val context = auth.authContext() ?: return@launch + val repo = auth.state.value.did ?: return@launch + runCatching { + if (existing == null) { + val created = FavoriteService.create(uri, repo, client, context) + updateGallery(uri) { it.copy(viewer = GalleryViewerState(fav = created.uri)) } + } else { + FavoriteService.delete(existing, client, context) + } + }.onFailure { + Log.w(TAG, "Couldn't toggle favorite on $uri", it) + updateGallery(uri) { + it.copy(viewer = GalleryViewerState(fav = existing), favCount = previousCount) + } + } + } + } + + /** Keep a card's comment count in step with the sheet opened over it. */ + fun setCommentCount(uri: String, count: Int) { + updateGallery(uri) { it.copy(commentCount = count) } + } + + /** Delete one of the viewer's own galleries, on the write landing. */ + fun deleteGallery(uri: String) { + viewModelScope.launch { + val context = auth.authContext() ?: return@launch + runCatching { GalleryService.delete(uri, client, context) } + .onSuccess { + _state.update { s -> + s.copy(galleries = s.galleries.filterNot { it.uri == uri }) + } + } + .onFailure { Log.w(TAG, "Couldn't delete gallery $uri", it) } + } + } + + private fun updateGallery(uri: String, transform: (GrainGallery) -> GrainGallery) { + _state.update { current -> + current.copy( + galleries = current.galleries.map { if (it.uri == uri) transform(it) else it }, + ) + } + } + private suspend fun runSearch(query: String) { _state.update { it.copy(isSearching = true) } val context = auth.authContext() @@ -167,5 +238,8 @@ class SearchViewModel @Inject constructor( private companion object { const val SEARCH_DEBOUNCE_MS = 300L + + /** Stands in for a favourite URI until the create call answers. */ + const val PLACEHOLDER_FAV = "pending" } } diff --git a/app/src/main/java/social/grain/feature/stories/StoryViewerScreen.kt b/app/src/main/java/social/grain/feature/stories/StoryViewerScreen.kt index 37dae2d..6b2b605 100644 --- a/app/src/main/java/social/grain/feature/stories/StoryViewerScreen.kt +++ b/app/src/main/java/social/grain/feature/stories/StoryViewerScreen.kt @@ -310,6 +310,8 @@ fun StoryViewerScreen( showComments = false reportSubject = ReportSubject.Record(comment.uri, comment.cid) }, + errorMessage = comments.errorMessage, + onErrorDismissed = commentsViewModel::dismissError, ) } diff --git a/app/src/main/java/social/grain/navigation/GrainNavHost.kt b/app/src/main/java/social/grain/navigation/GrainNavHost.kt index c30a0c0..2079379 100644 --- a/app/src/main/java/social/grain/navigation/GrainNavHost.kt +++ b/app/src/main/java/social/grain/navigation/GrainNavHost.kt @@ -197,7 +197,6 @@ fun GrainNavHost( .getStateFlow(STORIES_VIEWED_KEY, null) .collectAsState() FeedScreen( - onOpenGallery = { uri -> navController.navigate(Routes.gallery(uri)) }, onOpenProfile = { did -> navController.navigate(Routes.profile(did)) }, onCreate = { navController.navigate(Routes.CREATE) }, onOpenStories = { did -> navController.navigate(Routes.stories(did)) }, @@ -256,7 +255,6 @@ fun GrainNavHost( composable(Routes.SEARCH) { SearchScreen( - onOpenGallery = { navController.navigate(Routes.gallery(it)) }, onOpenProfile = { navController.navigate(Routes.profile(it)) }, onOpenFavorites = { uri -> navController.navigate( @@ -387,7 +385,6 @@ fun GrainNavHost( ) { HashtagFeedScreen( onBack = { navController.popBackStack() }, - onOpenGallery = { navController.navigate(Routes.gallery(it)) }, onOpenProfile = { navController.navigate(Routes.profile(it)) }, onOpenHashtag = { navController.navigate(Routes.hashtag(it)) }, onOpenFavorites = { uri -> diff --git a/app/src/main/java/social/grain/ui/components/CommentSheet.kt b/app/src/main/java/social/grain/ui/components/CommentSheet.kt index 6ea6013..b1ebc46 100644 --- a/app/src/main/java/social/grain/ui/components/CommentSheet.kt +++ b/app/src/main/java/social/grain/ui/components/CommentSheet.kt @@ -1,6 +1,7 @@ package social.grain.ui.components import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -58,6 +59,7 @@ import social.grain.data.model.GrainComment import social.grain.ui.theme.HeartColor import social.grain.util.compactCount import social.grain.util.relativeTime +import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -77,6 +79,10 @@ fun CommentSheet( /** Null hides the entry — the sheet is also shown to a signed-out reader, * who has nothing to file a report with. */ onReport: ((GrainComment) -> Unit)? = null, + /** Shown inside the sheet rather than by the host: a snackbar raised behind + * a modal sheet is a message the reader never sees. */ + errorMessage: String? = null, + onErrorDismissed: () -> Unit = {}, ) { // Full height rather than iOS's medium detent: a partially expanded sheet // sizes to half the screen regardless of content, which would push the @@ -191,6 +197,23 @@ fun CommentSheet( } } + errorMessage?.let { message -> + LaunchedEffect(message) { + delay(ERROR_DURATION_MS) + onErrorDismissed() + } + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.errorContainer) + .clickable(onClick = onErrorDismissed) + .padding(horizontal = 16.dp, vertical = 10.dp), + ) + } + Composer( text = commentText, onTextChange = { commentText = it }, @@ -418,3 +441,6 @@ private fun CommentRow( /** Leaves a strip of the gallery visible above the sheet. */ private const val SHEET_HEIGHT_FRACTION = 0.92f + +/** How long a failure sits above the composer before it clears itself. */ +private const val ERROR_DURATION_MS = 4000L diff --git a/app/src/main/java/social/grain/ui/components/GalleryCard.kt b/app/src/main/java/social/grain/ui/components/GalleryCard.kt index b35e84a..27f7f62 100644 --- a/app/src/main/java/social/grain/ui/components/GalleryCard.kt +++ b/app/src/main/java/social/grain/ui/components/GalleryCard.kt @@ -1,8 +1,11 @@ package social.grain.ui.components +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -21,7 +24,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Favorite @@ -52,10 +58,13 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -132,7 +141,6 @@ fun GalleryCard( pagerState = pagerState, blurred = blurMedia, onRevealMedia = { revealed = true }, - onOpen = onOpen, onDoubleTap = onFavoriteToggle, ) } @@ -291,7 +299,6 @@ private fun PhotoCarousel( pagerState: androidx.compose.foundation.pager.PagerState, blurred: Boolean, onRevealMedia: () -> Unit, - onOpen: () -> Unit, onDoubleTap: () -> Unit, ) { val photos = gallery.items.orEmpty() @@ -314,6 +321,11 @@ private fun PhotoCarousel( ratios[pagerState.currentPage.coerceIn(ratios.indices)].coerceAtLeast(0.05f) } + // Tapping a photo shows its alt text over it, as on iOS — the feed has no + // detail screen behind it to carry the description. + var showingAlt by remember(gallery.uri) { mutableStateOf(false) } + LaunchedEffect(pagerState.currentPage) { showingAlt = false } + // Bumped per double tap so a second tap restarts the burst rather than // joining the one already running. var heartBurst by remember { mutableIntStateOf(0) } @@ -349,7 +361,16 @@ private fun PhotoCarousel( .fillMaxSize() .pointerInput(gallery.uri) { detectTapGestures( - onTap = { if (blurred) onRevealMedia() else onOpen() }, + onTap = { + val alt = photos[page].alt + when { + blurred -> onRevealMedia() + // A photo with no description has nothing + // to reveal, so the tap does nothing rather + // than flashing an empty panel. + !alt.isNullOrBlank() -> showingAlt = !showingAlt + } + }, onDoubleTap = { offset -> if (!blurred) { heartPosition = offset @@ -371,6 +392,22 @@ private fun PhotoCarousel( contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize(), ) + + // Scoped to the page rather than the pager so the text covers + // the photo it describes and travels with it on a swipe. + AnimatedVisibility( + visible = showingAlt && pagerState.currentPage == page, + enter = fadeIn(tween(ALT_FADE_MS)), + exit = fadeOut(tween(ALT_FADE_MS)), + // Measured against the page rather than sizing it: the + // panel covers the photo, it doesn't reshape the card. + modifier = Modifier.matchParentSize(), + ) { + AltTextOverlay( + text = photos[page].alt.orEmpty(), + onDismiss = { showingAlt = false }, + ) + } } } @@ -418,6 +455,52 @@ private fun PhotoCarousel( .padding(bottom = 10.dp), ) } + + // The badge is what tells a reader the description is there at all — + // the tap gesture that opens it is otherwise invisible. + val currentAlt = photos.getOrNull(pagerState.currentPage)?.alt + if (!blurred && !currentAlt.isNullOrBlank()) { + Text( + text = "ALT", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 10.dp, bottom = 10.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color.Black.copy(alpha = 0.6f)) + .clickable { showingAlt = !showingAlt } + .padding(horizontal = 6.dp, vertical = 3.dp) + .semantics { + contentDescription = if (showingAlt) "Hide alt text" else "Show alt text" + }, + ) + } + } +} + +/** The photo's description, over the photo, dismissed by a tap anywhere on it. */ +@Composable +private fun AltTextOverlay(text: String, onDismiss: () -> Unit) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)) + .clickable(onClick = onDismiss), + contentAlignment = Alignment.Center, + ) { + // Scrollable rather than clipped: an alt text long enough to overflow a + // portrait photo is exactly the one worth reading in full. + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(20.dp), + ) } } @@ -652,4 +735,7 @@ private const val CAPTION_LINES = 3 /** How long the burst holds at full size before it scales up and fades. */ private const val HEART_HOLD_MS = 400L +/** Matches the 0.2s ease iOS fades the alt panel with. */ +private const val ALT_FADE_MS = 200 + private val HEART_SIZE = 88.dp -- 2.51.2