From ebc81e2bf0c17428a5bf75fe9feb11fed9163cdd Mon Sep 17 00:00:00 2001 From: ale Date: Sat, 24 Jan 2026 19:38:54 +0100 Subject: [PATCH] some features Signed-off-by: ale --- app/build.gradle.kts | 7 +- .../myactivitypub/MainActivity.kt | 54 ++- .../data/api/MastodonApiService.kt | 13 + .../data/repository/MastodonRepository.kt | 18 + .../ui/components/EmojiPicker.kt | 235 ++++++++++ .../ui/components/EnhancedReplyDialog.kt | 229 ++++++++++ .../ui/components/MediaPicker.kt | 137 ++++++ .../ui/components/MediaViewer.kt | 231 ++++++++++ .../ui/components/PollCreator.kt | 314 +++++++++++++ .../myactivitypub/ui/components/StatusCard.kt | 42 +- .../ui/screens/ComposeReplyScreen.kt | 411 ++++++++++++++++++ .../ui/screens/HashtagTimelineScreen.kt | 183 ++++++++ .../ui/screens/StatusDetailScreen.kt | 33 +- .../ui/viewmodel/HashtagTimelineViewModel.kt | 141 ++++++ 14 files changed, 2013 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/com/manalejandro/myactivitypub/ui/components/EmojiPicker.kt create mode 100644 app/src/main/java/com/manalejandro/myactivitypub/ui/components/EnhancedReplyDialog.kt create mode 100644 app/src/main/java/com/manalejandro/myactivitypub/ui/components/MediaPicker.kt create mode 100644 app/src/main/java/com/manalejandro/myactivitypub/ui/components/MediaViewer.kt create mode 100644 app/src/main/java/com/manalejandro/myactivitypub/ui/components/PollCreator.kt create mode 100644 app/src/main/java/com/manalejandro/myactivitypub/ui/screens/ComposeReplyScreen.kt create mode 100644 app/src/main/java/com/manalejandro/myactivitypub/ui/screens/HashtagTimelineScreen.kt create mode 100644 app/src/main/java/com/manalejandro/myactivitypub/ui/viewmodel/HashtagTimelineViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 41ed255..d9a9e6f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -56,8 +56,9 @@ dependencies { // Coil for image loading implementation("io.coil-kt:coil-compose:2.5.0") + implementation("io.coil-kt:coil-gif:2.5.0") - // ViewModel + // Retrofit for API calls implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") // Coroutines @@ -72,6 +73,10 @@ dependencies { // Browser for OAuth implementation("androidx.browser:browser:1.7.0") + // Media3 for video playback (replaces ExoPlayer) + implementation("androidx.media3:media3-exoplayer:1.2.0") + implementation("androidx.media3:media3-ui:1.2.0") + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/java/com/manalejandro/myactivitypub/MainActivity.kt b/app/src/main/java/com/manalejandro/myactivitypub/MainActivity.kt index 4e0a90e..954bc21 100644 --- a/app/src/main/java/com/manalejandro/myactivitypub/MainActivity.kt +++ b/app/src/main/java/com/manalejandro/myactivitypub/MainActivity.kt @@ -242,15 +242,24 @@ fun TimelineScreen( var showNotifications by remember { mutableStateOf(false) } var replyingToStatus by remember { mutableStateOf(null) } var selectedStatus by remember { mutableStateOf(null) } + var selectedHashtag by remember { mutableStateOf(null) } // Remember scroll state to preserve position when navigating val timelineScrollState = rememberLazyListState() // Handle back button for different states + BackHandler(enabled = replyingToStatus != null) { + replyingToStatus = null + } + BackHandler(enabled = selectedStatus != null) { selectedStatus = null } + BackHandler(enabled = selectedHashtag != null) { + selectedHashtag = null + } + BackHandler(enabled = showNotifications) { showNotifications = false } @@ -271,17 +280,6 @@ fun TimelineScreen( } } - // Reply dialog - replyingToStatus?.let { status -> - com.manalejandro.myactivitypub.ui.components.ReplyDialog( - status = status, - onDismiss = { replyingToStatus = null }, - onReply = { content -> - viewModel.postReply(content, status.id) - }, - isPosting = interactionState is InteractionState.Processing - ) - } Scaffold( topBar = { @@ -380,8 +378,36 @@ fun TimelineScreen( ) } ) { paddingValues -> - // Show status detail if selected - if (selectedStatus != null) { + // Show compose reply screen if replying + if (replyingToStatus != null) { + com.manalejandro.myactivitypub.ui.screens.ComposeReplyScreen( + status = replyingToStatus!!, + onBack = { replyingToStatus = null }, + onReply = { content, cw, visibility -> + viewModel.postReply(content, replyingToStatus!!.id) + }, + isPosting = interactionState is InteractionState.Processing + ) + } else if (selectedHashtag != null) { + // Show hashtag timeline if selected + val hashtagApiService = remember(userSession) { + createApiService(userSession?.instance ?: "mastodon.social", userSession?.accessToken) + } + val hashtagRepository = remember(hashtagApiService) { + MastodonRepository(hashtagApiService) + } + + com.manalejandro.myactivitypub.ui.screens.HashtagTimelineScreen( + hashtag = selectedHashtag!!, + repository = hashtagRepository, + onBack = { selectedHashtag = null }, + onStatusClick = { selectedStatus = it }, + onReplyClick = { replyingToStatus = it }, + onHashtagClick = { selectedHashtag = it }, + isAuthenticated = userSession != null + ) + } else if (selectedStatus != null) { + // Show status detail if selected // Create repository for status detail val detailApiService = remember(userSession) { createApiService(userSession?.instance ?: "mastodon.social", userSession?.accessToken) @@ -395,6 +421,7 @@ fun TimelineScreen( repository = detailRepository, onBackClick = { selectedStatus = null }, onReplyClick = { replyingToStatus = it }, + onHashtagClick = { selectedHashtag = it }, onStatusUpdated = { updatedStatus -> viewModel.updateStatus(updatedStatus) }, @@ -537,6 +564,7 @@ fun TimelineScreen( onReplyClick = { replyingToStatus = it }, onBoostClick = { viewModel.toggleBoost(it) }, onFavoriteClick = { viewModel.toggleFavorite(it) }, + onHashtagClick = { selectedHashtag = it }, isAuthenticated = userSession != null ) } diff --git a/app/src/main/java/com/manalejandro/myactivitypub/data/api/MastodonApiService.kt b/app/src/main/java/com/manalejandro/myactivitypub/data/api/MastodonApiService.kt index 9dd9488..3040311 100644 --- a/app/src/main/java/com/manalejandro/myactivitypub/data/api/MastodonApiService.kt +++ b/app/src/main/java/com/manalejandro/myactivitypub/data/api/MastodonApiService.kt @@ -144,4 +144,17 @@ interface MastodonApiService { */ @GET("api/v1/statuses/{id}/context") suspend fun getStatusContext(@Path("id") statusId: String): Response + + /** + * Get hashtag timeline + * @param hashtag Hashtag name (without #) + * @param limit Maximum number of statuses to return + * @param maxId Get statuses older than this ID (for pagination) + */ + @GET("api/v1/timelines/tag/{hashtag}") + suspend fun getHashtagTimeline( + @Path("hashtag") hashtag: String, + @Query("limit") limit: Int = 20, + @Query("max_id") maxId: String? = null + ): Response> } diff --git a/app/src/main/java/com/manalejandro/myactivitypub/data/repository/MastodonRepository.kt b/app/src/main/java/com/manalejandro/myactivitypub/data/repository/MastodonRepository.kt index 560c34d..8840751 100644 --- a/app/src/main/java/com/manalejandro/myactivitypub/data/repository/MastodonRepository.kt +++ b/app/src/main/java/com/manalejandro/myactivitypub/data/repository/MastodonRepository.kt @@ -192,4 +192,22 @@ class MastodonRepository(private val apiService: MastodonApiService) { } } } + + /** + * Get hashtag timeline + */ + suspend fun getHashtagTimeline(hashtag: String, limit: Int = 20, maxId: String? = null): Result> { + return withContext(Dispatchers.IO) { + try { + val response = apiService.getHashtagTimeline(hashtag, limit, maxId) + if (response.isSuccessful) { + Result.success(response.body() ?: emptyList()) + } else { + Result.failure(Exception("Error: ${response.code()} - ${response.message()}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + } } diff --git a/app/src/main/java/com/manalejandro/myactivitypub/ui/components/EmojiPicker.kt b/app/src/main/java/com/manalejandro/myactivitypub/ui/components/EmojiPicker.kt new file mode 100644 index 0000000..4da7c3d --- /dev/null +++ b/app/src/main/java/com/manalejandro/myactivitypub/ui/components/EmojiPicker.kt @@ -0,0 +1,235 @@ +package com.manalejandro.myactivitypub.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog + +/** + * Emoji picker dialog for selecting emojis + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmojiPickerDialog( + onDismiss: () -> Unit, + onEmojiSelected: (String) -> Unit +) { + val emojiCategories = remember { + mapOf( + "Smileys" to listOf( + "๐Ÿ˜€", "๐Ÿ˜ƒ", "๐Ÿ˜„", "๐Ÿ˜", "๐Ÿ˜†", "๐Ÿ˜…", "๐Ÿคฃ", "๐Ÿ˜‚", "๐Ÿ™‚", "๐Ÿ™ƒ", + "๐Ÿ˜‰", "๐Ÿ˜Š", "๐Ÿ˜‡", "๐Ÿฅฐ", "๐Ÿ˜", "๐Ÿคฉ", "๐Ÿ˜˜", "๐Ÿ˜—", "๐Ÿ˜š", "๐Ÿ˜™", + "๐Ÿ˜‹", "๐Ÿ˜›", "๐Ÿ˜œ", "๐Ÿคช", "๐Ÿ˜", "๐Ÿค‘", "๐Ÿค—", "๐Ÿคญ", "๐Ÿคซ", "๐Ÿค”" + ), + "Gestures" to listOf( + "๐Ÿ‘‹", "๐Ÿคš", "๐Ÿ–", "โœ‹", "๐Ÿ––", "๐Ÿ‘Œ", "๐Ÿค", "โœŒ", "๐Ÿคž", "๐ŸคŸ", + "๐Ÿค˜", "๐Ÿค™", "๐Ÿ‘ˆ", "๐Ÿ‘‰", "๐Ÿ‘†", "๐Ÿ–•", "๐Ÿ‘‡", "โ˜", "๐Ÿ‘", "๐Ÿ‘Ž", + "โœŠ", "๐Ÿ‘Š", "๐Ÿค›", "๐Ÿคœ", "๐Ÿ‘", "๐Ÿ™Œ", "๐Ÿ‘", "๐Ÿคฒ", "๐Ÿค", "๐Ÿ™" + ), + "Hearts" to listOf( + "โค", "๐Ÿงก", "๐Ÿ’›", "๐Ÿ’š", "๐Ÿ’™", "๐Ÿ’œ", "๐Ÿ–ค", "๐Ÿค", "๐ŸคŽ", "๐Ÿ’”", + "โฃ", "๐Ÿ’•", "๐Ÿ’ž", "๐Ÿ’“", "๐Ÿ’—", "๐Ÿ’–", "๐Ÿ’˜", "๐Ÿ’", "๐Ÿ’Ÿ", "๐Ÿ’Œ" + ), + "Animals" to listOf( + "๐Ÿถ", "๐Ÿฑ", "๐Ÿญ", "๐Ÿน", "๐Ÿฐ", "๐ŸฆŠ", "๐Ÿป", "๐Ÿผ", "๐Ÿจ", "๐Ÿฏ", + "๐Ÿฆ", "๐Ÿฎ", "๐Ÿท", "๐Ÿธ", "๐Ÿต", "๐Ÿ”", "๐Ÿง", "๐Ÿฆ", "๐Ÿค", "๐Ÿฆ†", + "๐Ÿฆ…", "๐Ÿฆ‰", "๐Ÿฆ‡", "๐Ÿบ", "๐Ÿ—", "๐Ÿด", "๐Ÿฆ„", "๐Ÿ", "๐Ÿ›", "๐Ÿฆ‹" + ), + "Food" to listOf( + "๐ŸŽ", "๐ŸŠ", "๐Ÿ‹", "๐ŸŒ", "๐Ÿ‰", "๐Ÿ‡", "๐Ÿ“", "๐Ÿˆ", "๐Ÿ’", "๐Ÿ‘", + "๐Ÿฅญ", "๐Ÿ", "๐Ÿฅฅ", "๐Ÿฅ", "๐Ÿ…", "๐Ÿฅ‘", "๐Ÿ†", "๐Ÿฅ”", "๐Ÿฅ•", "๐ŸŒฝ", + "๐ŸŒถ", "๐Ÿฅ’", "๐Ÿฅฌ", "๐Ÿฅฆ", "๐Ÿ„", "๐Ÿฅœ", "๐ŸŒฐ", "๐Ÿž", "๐Ÿฅ", "๐Ÿฅ–" + ), + "Activity" to listOf( + "โšฝ", "๐Ÿ€", "๐Ÿˆ", "โšพ", "๐ŸฅŽ", "๐ŸŽพ", "๐Ÿ", "๐Ÿ‰", "๐Ÿฅ", "๐ŸŽฑ", + "๐Ÿ“", "๐Ÿธ", "๐Ÿ’", "๐Ÿ‘", "๐Ÿฅ", "๐Ÿ", "โ›ณ", "๐Ÿน", "๐ŸŽฃ", "๐Ÿคฟ", + "๐ŸฅŠ", "๐Ÿฅ‹", "๐ŸŽฝ", "โ›ธ", "๐ŸฅŒ", "๐Ÿ›ท", "๐ŸŽฟ", "โ›ท", "๐Ÿ‚", "๐Ÿ‹" + ), + "Objects" to listOf( + "โŒš", "๐Ÿ“ฑ", "๐Ÿ’ป", "โŒจ", "๐Ÿ–ฅ", "๐Ÿ–จ", "๐Ÿ–ฑ", "๐Ÿ–ฒ", "๐Ÿ•น", "๐Ÿ—œ", + "๐Ÿ’พ", "๐Ÿ’ฟ", "๐Ÿ“€", "๐Ÿ“ผ", "๐Ÿ“ท", "๐Ÿ“ธ", "๐Ÿ“น", "๐ŸŽฅ", "๐Ÿ“ฝ", "๐ŸŽž", + "๐Ÿ“ž", "โ˜Ž", "๐Ÿ“Ÿ", "๐Ÿ“ ", "๐Ÿ“บ", "๐Ÿ“ป", "๐ŸŽ™", "๐ŸŽš", "๐ŸŽ›", "โฑ" + ), + "Symbols" to listOf( + "โค", "๐Ÿ’›", "๐Ÿ’š", "๐Ÿ’™", "๐Ÿ’œ", "๐Ÿ–ค", "๐Ÿ’”", "โฃ", "๐Ÿ’•", "๐Ÿ’ž", + "โœจ", "โญ", "๐ŸŒŸ", "๐Ÿ’ซ", "โœ”", "โœ…", "โŒ", "โŽ", "โž•", "โž–", + "โœ–", "โž—", "๐Ÿ”ด", "๐ŸŸ ", "๐ŸŸก", "๐ŸŸข", "๐Ÿ”ต", "๐ŸŸฃ", "โšซ", "โšช" + ) + ) + } + + var selectedCategory by remember { mutableStateOf("Smileys") } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 500.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Pick an emoji", + style = MaterialTheme.typography.titleLarge + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, "Close") + } + } + + HorizontalDivider() + + // Category tabs + ScrollableTabRow( + selectedTabIndex = emojiCategories.keys.indexOf(selectedCategory), + modifier = Modifier.fillMaxWidth() + ) { + emojiCategories.keys.forEach { category -> + Tab( + selected = selectedCategory == category, + onClick = { selectedCategory = category }, + text = { Text(category) } + ) + } + } + + // Emoji grid + LazyVerticalGrid( + columns = GridCells.Fixed(6), + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(emojiCategories[selectedCategory] ?: emptyList()) { emoji -> + Text( + text = emoji, + fontSize = 32.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .size(48.dp) + .clickable { + onEmojiSelected(emoji) + onDismiss() + } + .padding(4.dp) + ) + } + } + } + } + } +} + +/** + * Emoji picker sheet for bottom sheet implementation + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmojiPickerBottomSheet( + onDismiss: () -> Unit, + onEmojiSelected: (String) -> Unit +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + modifier = Modifier.fillMaxHeight(0.7f) + ) { + EmojiPickerContent( + onEmojiSelected = { emoji -> + onEmojiSelected(emoji) + onDismiss() + } + ) + } +} + +@Composable +private fun EmojiPickerContent( + onEmojiSelected: (String) -> Unit +) { + val emojiCategories = remember { + mapOf( + "Smileys" to listOf( + "๐Ÿ˜€", "๐Ÿ˜ƒ", "๐Ÿ˜„", "๐Ÿ˜", "๐Ÿ˜†", "๐Ÿ˜…", "๐Ÿคฃ", "๐Ÿ˜‚", "๐Ÿ™‚", "๐Ÿ™ƒ", + "๐Ÿ˜‰", "๐Ÿ˜Š", "๐Ÿ˜‡", "๐Ÿฅฐ", "๐Ÿ˜", "๐Ÿคฉ", "๐Ÿ˜˜", "๐Ÿ˜—", "๐Ÿ˜š", "๐Ÿ˜™", + "๐Ÿ˜‹", "๐Ÿ˜›", "๐Ÿ˜œ", "๐Ÿคช", "๐Ÿ˜", "๐Ÿค‘", "๐Ÿค—", "๐Ÿคญ", "๐Ÿคซ", "๐Ÿค”" + ), + "Gestures" to listOf( + "๐Ÿ‘‹", "๐Ÿคš", "๐Ÿ–", "โœ‹", "๐Ÿ––", "๐Ÿ‘Œ", "๐Ÿค", "โœŒ", "๐Ÿคž", "๐ŸคŸ", + "๐Ÿค˜", "๐Ÿค™", "๐Ÿ‘ˆ", "๐Ÿ‘‰", "๐Ÿ‘†", "๐Ÿ–•", "๐Ÿ‘‡", "โ˜", "๐Ÿ‘", "๐Ÿ‘Ž", + "โœŠ", "๐Ÿ‘Š", "๐Ÿค›", "๐Ÿคœ", "๐Ÿ‘", "๐Ÿ™Œ", "๐Ÿ‘", "๐Ÿคฒ", "๐Ÿค", "๐Ÿ™" + ), + "Hearts" to listOf( + "โค", "๐Ÿงก", "๐Ÿ’›", "๐Ÿ’š", "๐Ÿ’™", "๐Ÿ’œ", "๐Ÿ–ค", "๐Ÿค", "๐ŸคŽ", "๐Ÿ’”", + "โฃ", "๐Ÿ’•", "๐Ÿ’ž", "๐Ÿ’“", "๐Ÿ’—", "๐Ÿ’–", "๐Ÿ’˜", "๐Ÿ’", "๐Ÿ’Ÿ", "๐Ÿ’Œ" + ) + ) + } + + var selectedCategory by remember { mutableStateOf("Smileys") } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + // Category tabs + ScrollableTabRow( + selectedTabIndex = emojiCategories.keys.indexOf(selectedCategory), + modifier = Modifier.fillMaxWidth() + ) { + emojiCategories.keys.forEach { category -> + Tab( + selected = selectedCategory == category, + onClick = { selectedCategory = category }, + text = { Text(category) } + ) + } + } + + // Emoji grid + LazyVerticalGrid( + columns = GridCells.Fixed(6), + modifier = Modifier + .fillMaxWidth() + .height(400.dp) + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(emojiCategories[selectedCategory] ?: emptyList()) { emoji -> + Text( + text = emoji, + fontSize = 32.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .size(48.dp) + .clickable { onEmojiSelected(emoji) } + .padding(4.dp) + ) + } + } + } +} diff --git a/app/src/main/java/com/manalejandro/myactivitypub/ui/components/EnhancedReplyDialog.kt b/app/src/main/java/com/manalejandro/myactivitypub/ui/components/EnhancedReplyDialog.kt new file mode 100644 index 0000000..20d9516 --- /dev/null +++ b/app/src/main/java/com/manalejandro/myactivitypub/ui/components/EnhancedReplyDialog.kt @@ -0,0 +1,229 @@ +package com.manalejandro.myactivitypub.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.manalejandro.myactivitypub.data.models.Status + +/** + * Enhanced dialog for composing a reply to a status with full features + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EnhancedReplyDialog( + status: Status, + onDismiss: () -> Unit, + onReply: (content: String, contentWarning: String?, visibility: String) -> Unit, + isPosting: Boolean = false +) { + var replyText by remember { mutableStateOf("") } + var showCW by remember { mutableStateOf(false) } + var contentWarning by remember { mutableStateOf("") } + var visibility by remember { mutableStateOf("public") } + var showVisibilityMenu by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text("Reply to @${status.account.acct}", modifier = Modifier.weight(1f)) + + // Visibility selector + Box { + IconButton(onClick = { showVisibilityMenu = true }) { + Icon( + when (visibility) { + "public" -> Icons.Default.Public + "unlisted" -> Icons.Default.Lock + "private" -> Icons.Default.LockOpen + "direct" -> Icons.Default.Mail + else -> Icons.Default.Public + }, + contentDescription = "Visibility: $visibility" + ) + } + + DropdownMenu( + expanded = showVisibilityMenu, + onDismissRequest = { showVisibilityMenu = false } + ) { + DropdownMenuItem( + text = { Text("Public") }, + leadingIcon = { Icon(Icons.Default.Public, null) }, + onClick = { + visibility = "public" + showVisibilityMenu = false + } + ) + DropdownMenuItem( + text = { Text("Unlisted") }, + leadingIcon = { Icon(Icons.Default.Lock, null) }, + onClick = { + visibility = "unlisted" + showVisibilityMenu = false + } + ) + DropdownMenuItem( + text = { Text("Followers only") }, + leadingIcon = { Icon(Icons.Default.LockOpen, null) }, + onClick = { + visibility = "private" + showVisibilityMenu = false + } + ) + DropdownMenuItem( + text = { Text("Direct") }, + leadingIcon = { Icon(Icons.Default.Mail, null) }, + onClick = { + visibility = "direct" + showVisibilityMenu = false + } + ) + } + } + } + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + // Show original toot excerpt + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(8.dp)) { + Text( + text = "Replying to:", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = android.text.Html.fromHtml( + status.content, + android.text.Html.FROM_HTML_MODE_COMPACT + ).toString().take(100) + if (status.content.length > 100) "..." else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Content Warning field (optional) + if (showCW) { + OutlinedTextField( + value = contentWarning, + onValueChange = { contentWarning = it }, + label = { Text("Content Warning") }, + placeholder = { Text("Sensitive content warning") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isPosting, + trailingIcon = { + IconButton(onClick = { + showCW = false + contentWarning = "" + }) { + Icon(Icons.Default.Close, "Remove CW") + } + } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + // Reply text field + OutlinedTextField( + value = replyText, + onValueChange = { replyText = it }, + label = { Text("Your reply") }, + placeholder = { Text("Write your reply here...") }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp), + maxLines = 6, + enabled = !isPosting + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Action buttons bar + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + // Content Warning toggle + IconButton( + onClick = { showCW = !showCW }, + enabled = !isPosting + ) { + Icon( + Icons.Default.Warning, + contentDescription = "Content Warning", + tint = if (showCW) MaterialTheme.colorScheme.primary + else LocalContentColor.current + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + // Character count + Text( + text = "${replyText.length}/500", + style = MaterialTheme.typography.bodySmall, + color = if (replyText.length > 500) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + } + + if (replyText.length > 500) { + Text( + text = "Text is too long", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + }, + confirmButton = { + Button( + onClick = { + if (replyText.isNotBlank() && replyText.length <= 500) { + onReply( + replyText, + if (showCW && contentWarning.isNotBlank()) contentWarning else null, + visibility + ) + } + }, + enabled = replyText.isNotBlank() && replyText.length <= 500 && !isPosting + ) { + if (isPosting) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text("Reply") + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !isPosting) { + Text("Cancel") + } + } + ) +} diff --git a/app/src/main/java/com/manalejandro/myactivitypub/ui/components/MediaPicker.kt b/app/src/main/java/com/manalejandro/myactivitypub/ui/components/MediaPicker.kt new file mode 100644 index 0000000..8957013 --- /dev/null +++ b/app/src/main/java/com/manalejandro/myactivitypub/ui/components/MediaPicker.kt @@ -0,0 +1,137 @@ +package com.manalejandro.myactivitypub.ui.components + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage + +/** + * Media picker and preview component for compose screen + * Returns a lambda to trigger the picker + */ +@Composable +fun MediaPicker( + selectedMedia: List, + onMediaSelected: (List) -> Unit, + onMediaRemoved: (Uri) -> Unit, + maxMedia: Int = 4, + onLauncherReady: (() -> Unit) -> Unit = {} +) { + val multiplePhotoPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = maxMedia) + ) { uris -> + if (uris.isNotEmpty()) { + onMediaSelected(uris) + } + } + + // Expose the launcher trigger + LaunchedEffect(Unit) { + onLauncherReady { + multiplePhotoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) + ) + } + } + + Column(modifier = Modifier.fillMaxWidth()) { + if (selectedMedia.isNotEmpty()) { + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + ) { + items(selectedMedia) { uri -> + MediaPreviewItem( + uri = uri, + onRemove = { onMediaRemoved(uri) } + ) + } + + // Add more button if under limit + if (selectedMedia.size < maxMedia) { + item { + AddMoreMediaButton( + onClick = { + multiplePhotoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) + ) + } + ) + } + } + } + } + } +} + +@Composable +private fun MediaPreviewItem( + uri: Uri, + onRemove: () -> Unit +) { + Card( + modifier = Modifier.size(120.dp) + ) { + Box { + AsyncImage( + model = uri, + contentDescription = "Selected media", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + + IconButton( + onClick = onRemove, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(24.dp) + ) { + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), + shape = MaterialTheme.shapes.small + ) { + Icon( + Icons.Default.Close, + contentDescription = "Remove", + modifier = Modifier.padding(2.dp) + ) + } + } + } + } +} + +@Composable +private fun AddMoreMediaButton( + onClick: () -> Unit +) { + Card( + modifier = Modifier.size(120.dp), + onClick = onClick + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + "Add more", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } +} diff --git a/app/src/main/java/com/manalejandro/myactivitypub/ui/components/MediaViewer.kt b/app/src/main/java/com/manalejandro/myactivitypub/ui/components/MediaViewer.kt new file mode 100644 index 0000000..7fb3690 --- /dev/null +++ b/app/src/main/java/com/manalejandro/myactivitypub/ui/components/MediaViewer.kt @@ -0,0 +1,231 @@ +package com.manalejandro.myactivitypub.ui.components + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import coil.compose.AsyncImage +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder +import coil.request.ImageRequest +import com.manalejandro.myactivitypub.data.models.MediaAttachment + +/** + * Media viewer dialog for viewing images, GIFs, and videos in full screen + */ +@Composable +fun MediaViewerDialog( + media: MediaAttachment, + onDismiss: () -> Unit +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + when (media.type) { + "image", "gifv" -> { + // Display image or GIF + ImageViewer( + url = media.url, + description = media.description, + isGif = media.type == "gifv" + ) + } + "video" -> { + // Display video with controls + VideoViewer( + url = media.url, + description = media.description + ) + } + else -> { + // Fallback for unknown types + ImageViewer( + url = media.url, + description = media.description, + isGif = false + ) + } + } + + // Close button + IconButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp) + .background(Color.Black.copy(alpha = 0.5f), CircleShape) + ) { + Icon( + Icons.Default.Close, + contentDescription = "Close", + tint = Color.White + ) + } + + // Description at bottom if available + media.description?.let { desc -> + if (desc.isNotBlank()) { + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + color = Color.Black.copy(alpha = 0.7f) + ) { + Text( + text = desc, + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp) + ) + } + } + } + } + } +} + +/** + * Image viewer component with GIF support + */ +@Composable +private fun ImageViewer( + url: String, + description: String?, + isGif: Boolean +) { + val context = LocalContext.current + + AsyncImage( + model = ImageRequest.Builder(context) + .data(url) + .crossfade(true) + .apply { + if (isGif) { + // Use GIF decoder for animated GIFs + if (android.os.Build.VERSION.SDK_INT >= 28) { + decoderFactory(ImageDecoderDecoder.Factory()) + } else { + decoderFactory(GifDecoder.Factory()) + } + } + } + .build(), + contentDescription = description ?: "Media attachment", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) +} + +/** + * Video viewer component with Media3 ExoPlayer + */ +@Composable +private fun VideoViewer( + url: String, + description: String? +) { + val context = LocalContext.current + + // Create ExoPlayer instance + val exoPlayer = remember { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(Uri.parse(url))) + prepare() + playWhenReady = true + } + } + + // Release player when disposed + DisposableEffect(Unit) { + onDispose { + exoPlayer.release() + } + } + + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + player = exoPlayer + useController = true + controllerShowTimeoutMs = 3000 + controllerHideOnTouch = true + } + }, + modifier = Modifier.fillMaxSize() + ) +} + +/** + * Media preview with play button overlay for videos + */ +@Composable +fun MediaPreview( + media: MediaAttachment, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .clickable(onClick = onClick) + ) { + // Show thumbnail or image + AsyncImage( + model = media.previewUrl ?: media.url, + contentDescription = media.description ?: "Media attachment", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + + // Show play button overlay for videos + if (media.type == "video" || media.type == "gifv") { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center + ) { + Surface( + shape = CircleShape, + color = Color.White.copy(alpha = 0.9f), + modifier = Modifier.size(64.dp) + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = "Play", + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + tint = Color.Black + ) + } + } + } + } +} diff --git a/app/src/main/java/com/manalejandro/myactivitypub/ui/components/PollCreator.kt b/app/src/main/java/com/manalejandro/myactivitypub/ui/components/PollCreator.kt new file mode 100644 index 0000000..f1eb374 --- /dev/null +++ b/app/src/main/java/com/manalejandro/myactivitypub/ui/components/PollCreator.kt @@ -0,0 +1,314 @@ +package com.manalejandro.myactivitypub.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog + +/** + * Data class for poll configuration + */ +data class PollData( + val options: List = listOf("", ""), + val expiresIn: Int = 86400, // 24 hours in seconds + val multiple: Boolean = false +) + +/** + * Poll creator dialog + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PollCreatorDialog( + onDismiss: () -> Unit, + onPollCreated: (PollData) -> Unit, + initialPoll: PollData? = null +) { + var options by remember { mutableStateOf(initialPoll?.options ?: listOf("", "")) } + var expirationIndex by remember { mutableStateOf(2) } // Default: 1 day + var allowMultiple by remember { mutableStateOf(initialPoll?.multiple ?: false) } + + val expirationOptions = listOf( + "5 minutes" to 300, + "30 minutes" to 1800, + "1 hour" to 3600, + "6 hours" to 21600, + "1 day" to 86400, + "3 days" to 259200, + "7 days" to 604800 + ) + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 600.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Create a poll", + style = MaterialTheme.typography.titleLarge + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, "Close") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Poll options + Text( + "Options", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + + options.forEachIndexed { index, option -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = option, + onValueChange = { newValue -> + options = options.toMutableList().apply { + set(index, newValue) + } + }, + label = { Text("Option ${index + 1}") }, + modifier = Modifier.weight(1f), + singleLine = true + ) + + if (options.size > 2) { + IconButton( + onClick = { + options = options.toMutableList().apply { + removeAt(index) + } + } + ) { + Icon(Icons.Default.Remove, "Remove option") + } + } + } + } + + // Add option button + if (options.size < 4) { + TextButton( + onClick = { + options = options + "" + }, + modifier = Modifier.padding(vertical = 8.dp) + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Add option") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Expiration + Text( + "Poll duration", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = expirationOptions[expirationIndex].first, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + expirationOptions.forEachIndexed { index, (label, _) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { + expirationIndex = index + expanded = false + } + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Multiple choice + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = allowMultiple, + onCheckedChange = { allowMultiple = it } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Allow multiple choices") + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + val validOptions = options.filter { it.isNotBlank() } + if (validOptions.size >= 2) { + onPollCreated( + PollData( + options = validOptions, + expiresIn = expirationOptions[expirationIndex].second, + multiple = allowMultiple + ) + ) + } + }, + enabled = options.count { it.isNotBlank() } >= 2 + ) { + Text("Create") + } + } + } + } + } +} + +/** + * Poll preview card for compose screen + */ +@Composable +fun PollPreviewCard( + poll: PollData, + onRemove: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Poll", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Close, + contentDescription = "Remove poll", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + poll.options.forEachIndexed { index, option -> + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.small + ) { + Text( + text = if (option.isNotBlank()) option else "Option ${index + 1}", + modifier = Modifier.padding(12.dp), + color = if (option.isNotBlank()) + MaterialTheme.colorScheme.onSurface + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = if (poll.multiple) "Multiple choices" else "Single choice", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = formatDuration(poll.expiresIn), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +/** + * Format seconds to readable duration + */ +private fun formatDuration(seconds: Int): String { + return when { + seconds < 3600 -> "${seconds / 60} minutes" + seconds < 86400 -> "${seconds / 3600} hours" + else -> "${seconds / 86400} days" + } +} diff --git a/app/src/main/java/com/manalejandro/myactivitypub/ui/components/StatusCard.kt b/app/src/main/java/com/manalejandro/myactivitypub/ui/components/StatusCard.kt index 30fc107..59d05f7 100644 --- a/app/src/main/java/com/manalejandro/myactivitypub/ui/components/StatusCard.kt +++ b/app/src/main/java/com/manalejandro/myactivitypub/ui/components/StatusCard.kt @@ -14,6 +14,10 @@ import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -27,6 +31,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import com.manalejandro.myactivitypub.data.models.MediaAttachment import com.manalejandro.myactivitypub.data.models.Status import java.text.SimpleDateFormat import java.util.* @@ -42,8 +47,19 @@ fun StatusCard( onBoostClick: (Status) -> Unit = {}, onFavoriteClick: (Status) -> Unit = {}, onStatusClick: (Status) -> Unit = {}, + onHashtagClick: (String) -> Unit = {}, isAuthenticated: Boolean = false ) { + var selectedMedia by remember { mutableStateOf(null) } + + // Show media viewer dialog if media is selected + selectedMedia?.let { media -> + MediaViewerDialog( + media = media, + onDismiss = { selectedMedia = null } + ) + } + Card( modifier = modifier .fillMaxWidth() @@ -101,12 +117,18 @@ fun StatusCard( text = annotatedContent, style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), onClick = { offset -> + // Check for URL annotatedContent.getStringAnnotations(tag = "URL", start = offset, end = offset) .firstOrNull()?.let { annotation -> // Open URL in browser val intent = Intent(Intent.ACTION_VIEW, Uri.parse(annotation.item)) context.startActivity(intent) } + // Check for hashtag + annotatedContent.getStringAnnotations(tag = "HASHTAG", start = offset, end = offset) + .firstOrNull()?.let { annotation -> + onHashtagClick(annotation.item) + } } ) @@ -114,14 +136,13 @@ fun StatusCard( if (status.mediaAttachments.isNotEmpty()) { Spacer(modifier = Modifier.height(12.dp)) status.mediaAttachments.forEach { media -> - AsyncImage( - model = media.url, - contentDescription = media.description ?: "Media attachment", + MediaPreview( + media = media, + onClick = { selectedMedia = media }, modifier = Modifier .fillMaxWidth() .heightIn(max = 300.dp) - .clip(MaterialTheme.shapes.medium), - contentScale = ContentScale.Crop + .clip(MaterialTheme.shapes.medium) ) Spacer(modifier = Modifier.height(8.dp)) } @@ -266,8 +287,10 @@ private fun parseHtmlContent(html: String, linkColor: Color) = buildAnnotatedStr // Find all hashtags hashtagPattern.findAll(plainText).forEach { match -> val hashtagRange = match.groups[2]?.range - if (hashtagRange != null) { - allMatches.add(hashtagRange to "#${match.groups[2]?.value}") + val hashtagValue = match.groups[2]?.value + if (hashtagRange != null && hashtagValue != null) { + // Don't add # prefix as it's already in the text + allMatches.add(hashtagRange to hashtagValue) } } @@ -289,8 +312,9 @@ private fun parseHtmlContent(html: String, linkColor: Color) = buildAnnotatedStr textDecoration = TextDecoration.Underline ) ) { - pushStringAnnotation(tag = "URL", annotation = value) - append(if (value.startsWith("#")) value else plainText.substring(range)) + val tag = if (value.startsWith("http")) "URL" else "HASHTAG" + pushStringAnnotation(tag = tag, annotation = value) + append(if (value.startsWith("http")) plainText.substring(range) else "#${value}") pop() } diff --git a/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/ComposeReplyScreen.kt b/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/ComposeReplyScreen.kt new file mode 100644 index 0000000..31adbeb --- /dev/null +++ b/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/ComposeReplyScreen.kt @@ -0,0 +1,411 @@ +package com.manalejandro.myactivitypub.ui.screens +import android.net.Uri +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.manalejandro.myactivitypub.data.models.Status +import com.manalejandro.myactivitypub.ui.components.* +/** + * Full-screen compose reply interface with all features + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ComposeReplyScreen( + status: Status, + onBack: () -> Unit, + onReply: (content: String, contentWarning: String?, visibility: String) -> Unit, + isPosting: Boolean = false +) { + var replyText by remember { mutableStateOf("") } + var textFieldValue by remember { mutableStateOf(TextFieldValue("")) } + var showCW by remember { mutableStateOf(false) } + var contentWarning by remember { mutableStateOf("") } + var visibility by remember { mutableStateOf("public") } + var showVisibilityMenu by remember { mutableStateOf(false) } + // Media, emoji, and poll state + var selectedMedia by remember { mutableStateOf>(emptyList()) } + var showEmojiPicker by remember { mutableStateOf(false) } + var showPollCreator by remember { mutableStateOf(false) } + var poll by remember { mutableStateOf(null) } + var mediaLauncherTrigger by remember { mutableStateOf<(() -> Unit)?>(null) } + + val scrollState = rememberScrollState() + // Show emoji picker dialog + if (showEmojiPicker) { + EmojiPickerDialog( + onDismiss = { showEmojiPicker = false }, + onEmojiSelected = { emoji -> + // Insert emoji at cursor position + val currentText = textFieldValue.text + val selection = textFieldValue.selection + val newText = currentText.substring(0, selection.start) + + emoji + + currentText.substring(selection.end) + textFieldValue = TextFieldValue( + text = newText, + selection = androidx.compose.ui.text.TextRange(selection.start + emoji.length) + ) + replyText = newText + } + ) + } + // Show poll creator dialog + if (showPollCreator) { + PollCreatorDialog( + onDismiss = { showPollCreator = false }, + onPollCreated = { newPoll -> + poll = newPoll + showPollCreator = false + }, + initialPoll = poll + ) + } + Scaffold( + topBar = { + TopAppBar( + title = { Text("Reply") }, + navigationIcon = { + IconButton(onClick = onBack, enabled = !isPosting) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + }, + actions = { + Button( + onClick = { + if (replyText.isNotBlank() && replyText.length <= 500) { + onReply( + replyText, + if (showCW && contentWarning.isNotBlank()) contentWarning else null, + visibility + ) + } + }, + enabled = replyText.isNotBlank() && replyText.length <= 500 && !isPosting, + modifier = Modifier.padding(end = 8.dp) + ) { + if (isPosting) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text("Post") + } + } + } + ) + }, + bottomBar = { + ComposeBottomBar( + onImageClick = { + mediaLauncherTrigger?.invoke() + }, + onEmojiClick = { showEmojiPicker = true }, + onPollClick = { + if (poll == null) { + showPollCreator = true + } + }, + onCWClick = { showCW = !showCW }, + onVisibilityClick = { showVisibilityMenu = true }, + visibility = visibility, + showCW = showCW, + enabled = !isPosting + ) + } + ) { padding -> + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(scrollState) + ) { + // Original status preview + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = status.account.displayName.ifEmpty { status.account.username }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "@${status.account.acct}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = android.text.Html.fromHtml( + status.content, + android.text.Html.FROM_HTML_MODE_COMPACT + ).toString(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 4 + ) + } + } + // Replying to indicator + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Reply, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Replying to @${status.account.acct}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + Spacer(modifier = Modifier.height(8.dp)) + // Content Warning field + if (showCW) { + OutlinedTextField( + value = contentWarning, + onValueChange = { contentWarning = it }, + label = { Text("Content Warning") }, + placeholder = { Text("Describe sensitive content") }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + singleLine = true, + enabled = !isPosting, + trailingIcon = { + IconButton(onClick = { + showCW = false + contentWarning = "" + }) { + Icon(Icons.Default.Close, "Remove CW") + } + } + ) + Spacer(modifier = Modifier.height(16.dp)) + } + // Media picker - always present to register launcher + MediaPicker( + selectedMedia = selectedMedia, + onMediaSelected = { uris -> selectedMedia = uris }, + onMediaRemoved = { uri -> + selectedMedia = selectedMedia.filter { it != uri } + }, + onLauncherReady = { trigger -> + mediaLauncherTrigger = trigger + } + ) + // Poll preview + poll?.let { currentPoll -> + PollPreviewCard( + poll = currentPoll, + onRemove = { poll = null }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + // Main text field + OutlinedTextField( + value = textFieldValue, + onValueChange = { + textFieldValue = it + replyText = it.text + }, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 16.dp) + .heightIn(min = 200.dp), + placeholder = { Text("Write your reply...") }, + enabled = !isPosting + ) + Spacer(modifier = Modifier.height(16.dp)) + // Character counter and visibility + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${replyText.length}/500", + style = MaterialTheme.typography.bodySmall, + color = if (replyText.length > 500) + MaterialTheme.colorScheme.error + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + when (visibility) { + "public" -> Icons.Default.Public + "unlisted" -> Icons.Default.Lock + "private" -> Icons.Default.LockOpen + "direct" -> Icons.Default.Mail + else -> Icons.Default.Public + }, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = visibility.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + if (replyText.length > 500) { + Text( + text = "โš ๏ธ Text is too long (${replyText.length}/500)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + // Visibility menu + DropdownMenu( + expanded = showVisibilityMenu, + onDismissRequest = { showVisibilityMenu = false } + ) { + DropdownMenuItem( + text = { Text("Public") }, + leadingIcon = { Icon(Icons.Default.Public, null) }, + onClick = { + visibility = "public" + showVisibilityMenu = false + } + ) + DropdownMenuItem( + text = { Text("Unlisted") }, + leadingIcon = { Icon(Icons.Default.Lock, null) }, + onClick = { + visibility = "unlisted" + showVisibilityMenu = false + } + ) + DropdownMenuItem( + text = { Text("Followers only") }, + leadingIcon = { Icon(Icons.Default.LockOpen, null) }, + onClick = { + visibility = "private" + showVisibilityMenu = false + } + ) + DropdownMenuItem( + text = { Text("Direct") }, + leadingIcon = { Icon(Icons.Default.Mail, null) }, + onClick = { + visibility = "direct" + showVisibilityMenu = false + } + ) + } + } + } +} +/** + * Bottom bar with compose actions + */ +@Composable +fun ComposeBottomBar( + onImageClick: () -> Unit, + onEmojiClick: () -> Unit, + onPollClick: () -> Unit, + onCWClick: () -> Unit, + onVisibilityClick: () -> Unit, + visibility: String, + showCW: Boolean, + enabled: Boolean +) { + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onImageClick, enabled = enabled) { + Icon( + Icons.Default.Image, + contentDescription = "Add image", + tint = if (enabled) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } + IconButton(onClick = onEmojiClick, enabled = enabled) { + Icon( + Icons.Default.EmojiEmotions, + contentDescription = "Add emoji", + tint = if (enabled) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } + IconButton(onClick = onPollClick, enabled = enabled) { + Icon( + Icons.Default.Poll, + contentDescription = "Add poll", + tint = if (enabled) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } + IconButton(onClick = onCWClick, enabled = enabled) { + Icon( + Icons.Default.Warning, + contentDescription = "Content warning", + tint = if (showCW) MaterialTheme.colorScheme.primary + else if (enabled) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } + IconButton(onClick = onVisibilityClick, enabled = enabled) { + Icon( + when (visibility) { + "public" -> Icons.Default.Public + "unlisted" -> Icons.Default.Lock + "private" -> Icons.Default.LockOpen + "direct" -> Icons.Default.Mail + else -> Icons.Default.Public + }, + contentDescription = "Visibility: $visibility", + tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } + } + } +} diff --git a/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/HashtagTimelineScreen.kt b/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/HashtagTimelineScreen.kt new file mode 100644 index 0000000..4bf8111 --- /dev/null +++ b/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/HashtagTimelineScreen.kt @@ -0,0 +1,183 @@ +package com.manalejandro.myactivitypub.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +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.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.manalejandro.myactivitypub.data.models.Status +import com.manalejandro.myactivitypub.data.repository.MastodonRepository +import com.manalejandro.myactivitypub.ui.components.StatusCard + +/** + * Screen for displaying a timeline of statuses with a specific hashtag + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HashtagTimelineScreen( + hashtag: String, + repository: MastodonRepository, + onBack: () -> Unit, + onStatusClick: (Status) -> Unit = {}, + onReplyClick: (Status) -> Unit = {}, + onHashtagClick: (String) -> Unit = {}, + isAuthenticated: Boolean = false +) { + val viewModel: com.manalejandro.myactivitypub.ui.viewmodel.HashtagTimelineViewModel = viewModel(key = hashtag) { + com.manalejandro.myactivitypub.ui.viewmodel.HashtagTimelineViewModel(repository, hashtag) + } + + val uiState by viewModel.uiState.collectAsState() + val listState = rememberLazyListState() + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text("#$hashtag", fontWeight = FontWeight.Bold) + Text( + "Trending hashtag", + style = MaterialTheme.typography.bodySmall + ) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + } + ) + } + ) { padding -> + when (val state = uiState) { + is com.manalejandro.myactivitypub.ui.viewmodel.HashtagTimelineUiState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + is com.manalejandro.myactivitypub.ui.viewmodel.HashtagTimelineUiState.Success -> { + if (state.statuses.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "No posts found for #$hashtag", + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Be the first to post!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + state = listState, + contentPadding = PaddingValues(vertical = 8.dp) + ) { + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "#$hashtag", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${state.statuses.size} posts", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + items(state.statuses, key = { it.id }) { status -> + StatusCard( + status = status, + onStatusClick = onStatusClick, + onReplyClick = onReplyClick, + onBoostClick = { viewModel.toggleBoost(status.id) }, + onFavoriteClick = { viewModel.toggleFavorite(status.id) }, + onHashtagClick = onHashtagClick, + isAuthenticated = isAuthenticated + ) + } + + // Load more indicator + item { + if (state.hasMore) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Button(onClick = { viewModel.loadMore() }) { + Text("Load more") + } + } + } + } + } + } + } + + is com.manalejandro.myactivitypub.ui.viewmodel.HashtagTimelineUiState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "Error loading hashtag", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + state.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.loadHashtagTimeline() }) { + Text("Retry") + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/StatusDetailScreen.kt b/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/StatusDetailScreen.kt index d9199b2..50467b8 100644 --- a/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/StatusDetailScreen.kt +++ b/app/src/main/java/com/manalejandro/myactivitypub/ui/screens/StatusDetailScreen.kt @@ -36,6 +36,7 @@ fun StatusDetailScreen( onBackClick: () -> Unit, onReplyClick: (Status) -> Unit = {}, onStatusUpdated: (Status) -> Unit = {}, + onHashtagClick: (String) -> Unit = {}, isAuthenticated: Boolean = false ) { // Create ViewModel for this status @@ -133,6 +134,7 @@ fun StatusDetailScreen( onReplyClick = onReplyClick, onBoostClick = { /* Individual reply boost */ }, onFavoriteClick = { /* Individual reply favorite */ }, + onHashtagClick = onHashtagClick, isAuthenticated = isAuthenticated ) } @@ -169,6 +171,16 @@ private fun DetailedStatusCard( onFavoriteClick: (Status) -> Unit, isAuthenticated: Boolean ) { + var selectedMedia by remember { mutableStateOf(null) } + + // Show media viewer dialog if media is selected + selectedMedia?.let { media -> + com.manalejandro.myactivitypub.ui.components.MediaViewerDialog( + media = media, + onDismiss = { selectedMedia = null } + ) + } + Card( modifier = Modifier .fillMaxWidth() @@ -225,18 +237,15 @@ private fun DetailedStatusCard( if (status.mediaAttachments.isNotEmpty()) { Spacer(modifier = Modifier.height(12.dp)) status.mediaAttachments.forEach { media -> - if (media.type == "image") { - AsyncImage( - model = media.url, - contentDescription = media.description, - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 300.dp) - .clip(MaterialTheme.shapes.medium), - contentScale = ContentScale.Crop - ) - Spacer(modifier = Modifier.height(8.dp)) - } + com.manalejandro.myactivitypub.ui.components.MediaPreview( + media = media, + onClick = { selectedMedia = media }, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 300.dp) + .clip(MaterialTheme.shapes.medium) + ) + Spacer(modifier = Modifier.height(8.dp)) } } diff --git a/app/src/main/java/com/manalejandro/myactivitypub/ui/viewmodel/HashtagTimelineViewModel.kt b/app/src/main/java/com/manalejandro/myactivitypub/ui/viewmodel/HashtagTimelineViewModel.kt new file mode 100644 index 0000000..f394bdd --- /dev/null +++ b/app/src/main/java/com/manalejandro/myactivitypub/ui/viewmodel/HashtagTimelineViewModel.kt @@ -0,0 +1,141 @@ +package com.manalejandro.myactivitypub.ui.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.manalejandro.myactivitypub.data.models.Status +import com.manalejandro.myactivitypub.data.repository.MastodonRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * ViewModel for managing hashtag timeline state + */ +class HashtagTimelineViewModel( + private val repository: MastodonRepository, + private val hashtag: String +) : ViewModel() { + + private val _uiState = MutableStateFlow(HashtagTimelineUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private var maxId: String? = null + + init { + loadHashtagTimeline() + } + + /** + * Load hashtag timeline + */ + fun loadHashtagTimeline() { + viewModelScope.launch { + _uiState.value = HashtagTimelineUiState.Loading + + repository.getHashtagTimeline(hashtag).fold( + onSuccess = { statuses -> + maxId = statuses.lastOrNull()?.id + _uiState.value = HashtagTimelineUiState.Success( + statuses = statuses, + hasMore = statuses.size >= 20 + ) + }, + onFailure = { error -> + _uiState.value = HashtagTimelineUiState.Error( + error.message ?: "Failed to load hashtag timeline" + ) + } + ) + } + } + + /** + * Load more statuses + */ + fun loadMore() { + val currentState = _uiState.value as? HashtagTimelineUiState.Success ?: return + + viewModelScope.launch { + repository.getHashtagTimeline(hashtag, maxId = maxId).fold( + onSuccess = { newStatuses -> + if (newStatuses.isNotEmpty()) { + maxId = newStatuses.lastOrNull()?.id + _uiState.value = HashtagTimelineUiState.Success( + statuses = currentState.statuses + newStatuses, + hasMore = newStatuses.size >= 20 + ) + } else { + _uiState.value = currentState.copy(hasMore = false) + } + }, + onFailure = { error -> + // Keep current state but show error + println("Error loading more: ${error.message}") + } + ) + } + } + + /** + * Toggle favorite on a status + */ + fun toggleFavorite(statusId: String) { + viewModelScope.launch { + val currentState = _uiState.value as? HashtagTimelineUiState.Success ?: return@launch + val status = currentState.statuses.find { it.id == statusId } ?: return@launch + + val result = if (status.favourited) { + repository.unfavoriteStatus(statusId) + } else { + repository.favoriteStatus(statusId) + } + + result.onSuccess { updatedStatus -> + _uiState.value = HashtagTimelineUiState.Success( + statuses = currentState.statuses.map { + if (it.id == statusId) updatedStatus else it + }, + hasMore = currentState.hasMore + ) + } + } + } + + /** + * Toggle boost on a status + */ + fun toggleBoost(statusId: String) { + viewModelScope.launch { + val currentState = _uiState.value as? HashtagTimelineUiState.Success ?: return@launch + val status = currentState.statuses.find { it.id == statusId } ?: return@launch + + val result = if (status.reblogged) { + repository.unboostStatus(statusId) + } else { + repository.boostStatus(statusId) + } + + result.onSuccess { updatedStatus -> + _uiState.value = HashtagTimelineUiState.Success( + statuses = currentState.statuses.map { + if (it.id == statusId) updatedStatus else it + }, + hasMore = currentState.hasMore + ) + } + } + } +} + +/** + * UI State for the Hashtag Timeline screen + */ +sealed class HashtagTimelineUiState { + object Loading : HashtagTimelineUiState() + data class Success( + val statuses: List, + val hasMore: Boolean = false + ) : HashtagTimelineUiState() + data class Error(val message: String) : HashtagTimelineUiState() +}