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