diff --git a/core/analytics/src/main/java/org/mozilla/social/core/analytics/AnalyticsIdentifiers.kt b/core/analytics/src/main/java/org/mozilla/social/core/analytics/AnalyticsIdentifiers.kt index 1f679c3a6..44a2199a1 100644 --- a/core/analytics/src/main/java/org/mozilla/social/core/analytics/AnalyticsIdentifiers.kt +++ b/core/analytics/src/main/java/org/mozilla/social/core/analytics/AnalyticsIdentifiers.kt @@ -98,4 +98,6 @@ object AnalyticsIdentifiers { const val SEARCH_HASHTAG_CLICKED = "search.hashtag.clicked" const val SEARCH_ACCOUNT_FOLLOW = "search.account.follow" const val SEARCH_HASHTAG_FOLLOW = "search.hashtag.follow" + + const val HASHTAG_FOLLOW = "hashtag.follow" } diff --git a/core/database/src/main/java/org/mozilla/social/core/database/dao/HashTagsDao.kt b/core/database/src/main/java/org/mozilla/social/core/database/dao/HashTagsDao.kt index 732791b4d..c0f368c43 100644 --- a/core/database/src/main/java/org/mozilla/social/core/database/dao/HashTagsDao.kt +++ b/core/database/src/main/java/org/mozilla/social/core/database/dao/HashTagsDao.kt @@ -2,6 +2,7 @@ package org.mozilla.social.core.database.dao import androidx.room.Dao import androidx.room.Query +import kotlinx.coroutines.flow.Flow import org.mozilla.social.core.database.model.entities.DatabaseHashTagEntity @Dao @@ -16,4 +17,10 @@ interface HashTagsDao : BaseDao { hashTag: String, isFollowing: Boolean, ) + + @Query( + "SELECT * FROM hashtags " + + "WHERE name = :name", + ) + fun getHashTagFlow(name: String): Flow } \ No newline at end of file diff --git a/core/network/mastodon/src/main/java/org/mozilla/social/core/network/mastodon/TagsApi.kt b/core/network/mastodon/src/main/java/org/mozilla/social/core/network/mastodon/TagsApi.kt index 7bd26cd45..dbc56d97a 100644 --- a/core/network/mastodon/src/main/java/org/mozilla/social/core/network/mastodon/TagsApi.kt +++ b/core/network/mastodon/src/main/java/org/mozilla/social/core/network/mastodon/TagsApi.kt @@ -1,6 +1,7 @@ package org.mozilla.social.core.network.mastodon import org.mozilla.social.core.network.mastodon.model.NetworkHashTag +import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path @@ -15,4 +16,9 @@ interface TagsApi { suspend fun unfollowHashTag( @Path("hashTag") hashTag: String, ): NetworkHashTag + + @GET("api/v1/tags/{hashTag}") + suspend fun getHashTag( + @Path("hashTag") hashTag: String, + ): NetworkHashTag } \ No newline at end of file diff --git a/core/repository/mastodon/src/main/java/org/mozilla/social/core/repository/mastodon/HashtagRepository.kt b/core/repository/mastodon/src/main/java/org/mozilla/social/core/repository/mastodon/HashtagRepository.kt index 717ff98cc..d13832cda 100644 --- a/core/repository/mastodon/src/main/java/org/mozilla/social/core/repository/mastodon/HashtagRepository.kt +++ b/core/repository/mastodon/src/main/java/org/mozilla/social/core/repository/mastodon/HashtagRepository.kt @@ -1,5 +1,7 @@ package org.mozilla.social.core.repository.mastodon +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import org.mozilla.social.common.annotations.PreferUseCase import org.mozilla.social.core.database.dao.HashTagsDao import org.mozilla.social.core.model.HashTag @@ -20,6 +22,12 @@ class HashtagRepository( hashTag: HashTag, ) = dao.upsert(hashTag.toDatabaseModel()) + fun getHashTagFlow( + hashTag: String + ): Flow = dao.getHashTagFlow(hashTag).map { + it.toExternalModel() + } + @PreferUseCase suspend fun updateFollowing( hashTag: String, @@ -29,11 +37,18 @@ class HashtagRepository( isFollowing, ) + @PreferUseCase suspend fun followHashTag( hashTag: String, ): HashTag = api.followHashTag(hashTag).toExternalModel() + @PreferUseCase suspend fun unfollowHashTag( hashTag: String, ): HashTag = api.unfollowHashTag(hashTag).toExternalModel() + + @PreferUseCase + suspend fun getHashTag( + hashTag: String + ): HashTag = api.getHashTag(hashTag).toExternalModel() } \ No newline at end of file diff --git a/core/ui/common/src/main/java/org/mozilla/social/core/ui/common/following/FollowingButton.kt b/core/ui/common/src/main/java/org/mozilla/social/core/ui/common/following/FollowingButton.kt index 0343a98e1..bbebfef5b 100644 --- a/core/ui/common/src/main/java/org/mozilla/social/core/ui/common/following/FollowingButton.kt +++ b/core/ui/common/src/main/java/org/mozilla/social/core/ui/common/following/FollowingButton.kt @@ -14,9 +14,10 @@ import org.mozilla.social.core.ui.common.text.SmallTextLabel fun FollowingButton( onButtonClicked: () -> Unit, isFollowing: Boolean, + modifier: Modifier = Modifier, ) { MoSoToggleButton( - modifier = Modifier.height(32.dp), + modifier = modifier.height(32.dp), onClick = { onButtonClicked() }, toggleState = if (isFollowing) { ToggleButtonState.Secondary diff --git a/core/usecase/mastodon/src/main/java/org/mozilla/social/core/usecase/mastodon/MastodonUsecaseModule.kt b/core/usecase/mastodon/src/main/java/org/mozilla/social/core/usecase/mastodon/MastodonUsecaseModule.kt index f33ec65d6..28859867c 100644 --- a/core/usecase/mastodon/src/main/java/org/mozilla/social/core/usecase/mastodon/MastodonUsecaseModule.kt +++ b/core/usecase/mastodon/src/main/java/org/mozilla/social/core/usecase/mastodon/MastodonUsecaseModule.kt @@ -20,6 +20,7 @@ import org.mozilla.social.core.usecase.mastodon.auth.IsSignedInFlow import org.mozilla.social.core.usecase.mastodon.auth.Login import org.mozilla.social.core.usecase.mastodon.auth.Logout import org.mozilla.social.core.usecase.mastodon.hashtag.FollowHashTag +import org.mozilla.social.core.usecase.mastodon.hashtag.GetHashTag import org.mozilla.social.core.usecase.mastodon.hashtag.UnfollowHashTag import org.mozilla.social.core.usecase.mastodon.report.Report import org.mozilla.social.core.usecase.mastodon.search.SearchAll @@ -209,4 +210,10 @@ val mastodonUsecaseModule = singleOf(::GetDomain) singleOf(::SearchAll) singleOf(::GetInReplyToAccountNames) + + single { + GetHashTag( + hashtagRepository = get(), + ) + } } diff --git a/core/usecase/mastodon/src/main/java/org/mozilla/social/core/usecase/mastodon/hashtag/GetHashTag.kt b/core/usecase/mastodon/src/main/java/org/mozilla/social/core/usecase/mastodon/hashtag/GetHashTag.kt new file mode 100644 index 000000000..ec87c2960 --- /dev/null +++ b/core/usecase/mastodon/src/main/java/org/mozilla/social/core/usecase/mastodon/hashtag/GetHashTag.kt @@ -0,0 +1,62 @@ +package org.mozilla.social.core.usecase.mastodon.hashtag + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.mozilla.social.common.Resource +import org.mozilla.social.common.annotations.PreferUseCase +import org.mozilla.social.core.model.HashTag +import org.mozilla.social.core.repository.mastodon.HashtagRepository +import timber.log.Timber + +class GetHashTag( + private val hashtagRepository: HashtagRepository, + private val dispatcherIo: CoroutineDispatcher = Dispatchers.IO, +) { + + @OptIn(PreferUseCase::class) + suspend operator fun invoke( + name: String, + coroutineScope: CoroutineScope, + ): Flow> = + flow { + emit(Resource.Loading()) + val deferred = CompletableDeferred>() + // the hashtag from the server might be lower case, so we need to assign this + // to whatever we get back from the server + var realName: String = name + coroutineScope.launch(dispatcherIo) { + try { + val hashtag = hashtagRepository.getHashTag(realName) + realName = hashtag.name + hashtagRepository.insert(hashtag) + deferred.complete( + Resource.Loaded( + Unit + ) + ) + } catch (e: Exception) { + Timber.e(e) + deferred.complete(Resource.Error(e)) + } + } + when (val deferredResult = deferred.await()) { + is Resource.Error -> emit(Resource.Error(deferredResult.exception)) + else -> { + try { + emitAll(hashtagRepository.getHashTagFlow(realName).map { Resource.Loaded(it) }) + } catch (e: Exception) { + Timber.e(e) + emit(Resource.Error(e)) + } + } + } + } +} \ No newline at end of file diff --git a/feature/hashtag/build.gradle.kts b/feature/hashtag/build.gradle.kts index 1b18f9208..4b198c584 100644 --- a/feature/hashtag/build.gradle.kts +++ b/feature/hashtag/build.gradle.kts @@ -30,4 +30,6 @@ dependencies { implementation(libs.protobuf.kotlin.lite) implementation(libs.androidx.room) + + implementation(libs.jakewharton.timber) } diff --git a/feature/hashtag/src/main/java/org/mozilla/social/feature/hashtag/HashTagInteractions.kt b/feature/hashtag/src/main/java/org/mozilla/social/feature/hashtag/HashTagInteractions.kt index 3460c1e59..028df03dc 100644 --- a/feature/hashtag/src/main/java/org/mozilla/social/feature/hashtag/HashTagInteractions.kt +++ b/feature/hashtag/src/main/java/org/mozilla/social/feature/hashtag/HashTagInteractions.kt @@ -2,4 +2,6 @@ package org.mozilla.social.feature.hashtag interface HashTagInteractions { fun onScreenViewed() = Unit + fun onFollowClicked(name: String, isFollowing: Boolean) = Unit + fun onRetryClicked() = Unit } diff --git a/feature/hashtag/src/main/java/org/mozilla/social/feature/hashtag/HashTagModule.kt b/feature/hashtag/src/main/java/org/mozilla/social/feature/hashtag/HashTagModule.kt index 24726e351..e56535e6e 100644 --- a/feature/hashtag/src/main/java/org/mozilla/social/feature/hashtag/HashTagModule.kt +++ b/feature/hashtag/src/main/java/org/mozilla/social/feature/hashtag/HashTagModule.kt @@ -28,6 +28,9 @@ val hashTagModule = analytics = get(), userAccountId = get(), timelineRepository = get(), + unfollowHashTag = get(), + followHashTag = get(), + getHashTag = get(), ) } } diff --git a/feature/hashtag/src/main/java/org/mozilla/social/feature/hashtag/HashTagScreen.kt b/feature/hashtag/src/main/java/org/mozilla/social/feature/hashtag/HashTagScreen.kt index 2d6f07b9c..bb5757723 100644 --- a/feature/hashtag/src/main/java/org/mozilla/social/feature/hashtag/HashTagScreen.kt +++ b/feature/hashtag/src/main/java/org/mozilla/social/feature/hashtag/HashTagScreen.kt @@ -1,16 +1,26 @@ package org.mozilla.social.feature.hashtag import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.PagingData import kotlinx.coroutines.flow.Flow import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf +import org.mozilla.social.common.Resource +import org.mozilla.social.core.model.HashTag import org.mozilla.social.core.ui.common.MoSoSurface import org.mozilla.social.core.ui.common.appbar.MoSoCloseableTopAppBar +import org.mozilla.social.core.ui.common.error.GenericError +import org.mozilla.social.core.ui.common.following.FollowingButton +import org.mozilla.social.core.ui.common.loading.MaxSizeLoading import org.mozilla.social.core.ui.postcard.PostCardInteractions import org.mozilla.social.core.ui.postcard.PostCardList import org.mozilla.social.core.ui.postcard.PostCardUiState @@ -20,10 +30,15 @@ internal fun HashTagScreen( hashTag: String, viewModel: HashTagViewModel = koinViewModel(parameters = { parametersOf(hashTag) }), ) { + + val uiState: Resource by viewModel.uiState.collectAsStateWithLifecycle() + HashTagScreen( + uiState = uiState, hashTag = hashTag, feed = viewModel.feed, postCardInteractions = viewModel.postCardDelegate, + hashTagInteractions = viewModel, ) LaunchedEffect(Unit) { @@ -33,9 +48,11 @@ internal fun HashTagScreen( @Composable private fun HashTagScreen( + uiState: Resource, hashTag: String, feed: Flow>, postCardInteractions: PostCardInteractions, + hashTagInteractions: HashTagInteractions, ) { MoSoSurface { Column( @@ -43,14 +60,42 @@ private fun HashTagScreen( ) { MoSoCloseableTopAppBar( title = "#$hashTag", + actions = { + if (uiState is Resource.Loaded) { + FollowingButton( + modifier = Modifier + .padding(end = 8.dp), + onButtonClicked = { + hashTagInteractions.onFollowClicked( + hashTag, + uiState.data.following + ) + }, + isFollowing = uiState.data.following + ) + } + } ) - PostCardList( - feed = feed, - postCardInteractions = postCardInteractions, - pullToRefreshEnabled = true, - isFullScreenLoading = true, - ) + when (uiState) { + is Resource.Loading -> { + MaxSizeLoading() + } + is Resource.Loaded -> { + PostCardList( + feed = feed, + postCardInteractions = postCardInteractions, + pullToRefreshEnabled = true, + isFullScreenLoading = true, + ) + } + is Resource.Error -> { + GenericError( + modifier = Modifier.fillMaxSize(), + onRetryClicked = { hashTagInteractions.onRetryClicked() } + ) + } + } } } } diff --git a/feature/hashtag/src/main/java/org/mozilla/social/feature/hashtag/HashTagViewModel.kt b/feature/hashtag/src/main/java/org/mozilla/social/feature/hashtag/HashTagViewModel.kt index 5a96ef44b..813e2f349 100644 --- a/feature/hashtag/src/main/java/org/mozilla/social/feature/hashtag/HashTagViewModel.kt +++ b/feature/hashtag/src/main/java/org/mozilla/social/feature/hashtag/HashTagViewModel.kt @@ -5,26 +5,53 @@ import androidx.lifecycle.viewModelScope import androidx.paging.ExperimentalPagingApi import androidx.paging.cachedIn import androidx.paging.map +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import org.koin.core.parameter.parametersOf -import org.koin.java.KoinJavaComponent.inject +import org.mozilla.social.common.Resource +import org.mozilla.social.common.utils.edit import org.mozilla.social.core.analytics.Analytics import org.mozilla.social.core.analytics.AnalyticsIdentifiers +import org.mozilla.social.core.analytics.EngagementType +import org.mozilla.social.core.model.HashTag import org.mozilla.social.core.repository.mastodon.TimelineRepository import org.mozilla.social.core.repository.paging.HashTagTimelineRemoteMediator import org.mozilla.social.core.ui.postcard.PostCardDelegate import org.mozilla.social.core.ui.postcard.toPostCardUiState import org.mozilla.social.core.usecase.mastodon.account.GetLoggedInUserAccountId +import org.mozilla.social.core.usecase.mastodon.hashtag.FollowHashTag +import org.mozilla.social.core.usecase.mastodon.hashtag.GetHashTag +import org.mozilla.social.core.usecase.mastodon.hashtag.UnfollowHashTag +import timber.log.Timber class HashTagViewModel( private val analytics: Analytics, timelineRepository: TimelineRepository, - hashTag: String, + private val hashTag: String, userAccountId: GetLoggedInUserAccountId, -) : ViewModel(), HashTagInteractions { - private val hashTagTimelineRemoteMediator: HashTagTimelineRemoteMediator by inject( - HashTagTimelineRemoteMediator::class.java, - ) { parametersOf(hashTag) } + private val unfollowHashTag: UnfollowHashTag, + private val followHashTag: FollowHashTag, + private val getHashTag: GetHashTag, +) : ViewModel(), HashTagInteractions, KoinComponent { + + private val hashTagTimelineRemoteMediator: HashTagTimelineRemoteMediator by inject { + parametersOf(hashTag) + } + + val postCardDelegate: PostCardDelegate by inject { + parametersOf(viewModelScope, AnalyticsIdentifiers.FEED_PREFIX_HASHTAG) + } + + private var getHashTagJob: Job? = null + + private val _uiState = MutableStateFlow>(Resource.Loading()) + val uiState = _uiState.asStateFlow() @OptIn(ExperimentalPagingApi::class) val feed = timelineRepository.getHashtagTimelinePager( @@ -36,13 +63,51 @@ class HashTagViewModel( } }.cachedIn(viewModelScope) + init { + loadHashTag() + } + + private fun loadHashTag() { + getHashTagJob?.cancel() + getHashTagJob = viewModelScope.launch { + getHashTag( + name = hashTag, + coroutineScope = viewModelScope, + ).collect { resource -> + _uiState.update { resource } + } + } + } + override fun onScreenViewed() { analytics.uiImpression( uiIdentifier = AnalyticsIdentifiers.HASHTAG_SCREEN_IMPRESSION, ) } - val postCardDelegate: PostCardDelegate by inject( - PostCardDelegate::class.java, - ) { parametersOf(viewModelScope, AnalyticsIdentifiers.FEED_PREFIX_HASHTAG) } + override fun onFollowClicked(name: String, isFollowing: Boolean) { + viewModelScope.launch { + if (isFollowing) { + try { + unfollowHashTag(name) + } catch (e: UnfollowHashTag.UnfollowFailedException) { + Timber.e(e) + } + } else { + try { + followHashTag(name) + } catch (e: FollowHashTag.FollowFailedException) { + Timber.e(e) + } + } + } + analytics.uiEngagement( + engagementType = EngagementType.GENERAL, + uiIdentifier = AnalyticsIdentifiers.HASHTAG_FOLLOW, + ) + } + + override fun onRetryClicked() { + loadHashTag() + } }