diff --git a/app/src/main/kotlin/net/primal/android/articles/feed/ArticleFeedList.kt b/app/src/main/kotlin/net/primal/android/articles/feed/ArticleFeedList.kt index a846c02e7..5d212ee98 100644 --- a/app/src/main/kotlin/net/primal/android/articles/feed/ArticleFeedList.kt +++ b/app/src/main/kotlin/net/primal/android/articles/feed/ArticleFeedList.kt @@ -52,9 +52,9 @@ import net.primal.android.core.compose.foundation.rememberLazyListStatePagingWor import net.primal.android.core.compose.heightAdjustableLoadingLazyListPlaceholder import net.primal.android.core.compose.isEmpty import net.primal.android.core.compose.isNotEmpty +import net.primal.android.core.compose.profile.approvals.ApproveBookmarkAlertDialog import net.primal.android.core.compose.pulltorefresh.PrimalPullToRefreshBox import net.primal.android.core.errors.UiError -import net.primal.android.notes.feed.note.ui.ConfirmFirstBookmarkAlertDialog import net.primal.android.theme.AppTheme import net.primal.android.thread.articles.ArticleContract import net.primal.android.thread.articles.ArticleViewModel @@ -239,7 +239,7 @@ private fun ArticleFeedLazyColumn( when { item != null -> Column { if (articleState.shouldApproveBookmark) { - ConfirmFirstBookmarkAlertDialog( + ApproveBookmarkAlertDialog( onBookmarkConfirmed = { articleEventPublisher( ArticleContract.UiEvent.BookmarkAction( diff --git a/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/ConfirmFirstBookmarkAlertDialog.kt b/app/src/main/kotlin/net/primal/android/core/compose/profile/approvals/ApproveBookmarkAlertDialog.kt similarity index 89% rename from app/src/main/kotlin/net/primal/android/notes/feed/note/ui/ConfirmFirstBookmarkAlertDialog.kt rename to app/src/main/kotlin/net/primal/android/core/compose/profile/approvals/ApproveBookmarkAlertDialog.kt index f655f2d16..375367bfa 100644 --- a/app/src/main/kotlin/net/primal/android/notes/feed/note/ui/ConfirmFirstBookmarkAlertDialog.kt +++ b/app/src/main/kotlin/net/primal/android/core/compose/profile/approvals/ApproveBookmarkAlertDialog.kt @@ -1,4 +1,4 @@ -package net.primal.android.notes.feed.note.ui +package net.primal.android.core.compose.profile.approvals import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text @@ -9,7 +9,7 @@ import net.primal.android.R import net.primal.android.theme.AppTheme @Composable -fun ConfirmFirstBookmarkAlertDialog(onClose: () -> Unit, onBookmarkConfirmed: () -> Unit) { +fun ApproveBookmarkAlertDialog(onClose: () -> Unit, onBookmarkConfirmed: () -> Unit) { AlertDialog( containerColor = AppTheme.colorScheme.surfaceVariant, onDismissRequest = onClose, diff --git a/app/src/main/kotlin/net/primal/android/core/compose/profile/approvals/ApproveFollowUnfollowProfileAlertDialog.kt b/app/src/main/kotlin/net/primal/android/core/compose/profile/approvals/ApproveFollowUnfollowProfileAlertDialog.kt new file mode 100644 index 000000000..3e4f6ed29 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/core/compose/profile/approvals/ApproveFollowUnfollowProfileAlertDialog.kt @@ -0,0 +1,82 @@ +package net.primal.android.core.compose.profile.approvals + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import net.primal.android.R +import net.primal.android.theme.AppTheme + +@Composable +fun ApproveFollowUnfollowProfileAlertDialog( + profileApproval: ProfileApproval, + onFollowApproved: () -> Unit, + onUnfollowApproved: () -> Unit, + onClose: () -> Unit, +) { + val messages = when (profileApproval) { + is ProfileApproval.Follow -> { + ApprovalMessages( + title = stringResource(id = R.string.context_confirm_follow_title), + text = stringResource(id = R.string.context_confirm_follow_text), + positive = stringResource(id = R.string.context_confirm_follow_positive), + negative = stringResource(id = R.string.context_confirm_follow_negative), + ) + } + + is ProfileApproval.Unfollow -> { + ApprovalMessages( + title = stringResource(id = R.string.context_confirm_unfollow_title), + text = stringResource(id = R.string.context_confirm_unfollow_text), + positive = stringResource(id = R.string.context_confirm_unfollow_positive), + negative = stringResource(id = R.string.context_confirm_unfollow_negative), + ) + } + } + AlertDialog( + containerColor = AppTheme.colorScheme.surfaceVariant, + onDismissRequest = onClose, + title = { + Text( + text = messages.title, + style = AppTheme.typography.titleLarge, + ) + }, + text = { + Text( + text = messages.text, + style = AppTheme.typography.bodyLarge, + ) + }, + dismissButton = { + TextButton(onClick = onClose) { + Text(text = messages.negative) + } + }, + confirmButton = { + TextButton( + onClick = when (profileApproval) { + is ProfileApproval.Follow -> onFollowApproved + is ProfileApproval.Unfollow -> onUnfollowApproved + }, + ) { + Text( + text = messages.positive, + ) + } + }, + ) +} + +private data class ApprovalMessages( + val title: String, + val text: String, + val positive: String, + val negative: String, +) + +sealed class ProfileApproval(open val profileId: String) { + data class Follow(override val profileId: String) : ProfileApproval(profileId) + data class Unfollow(override val profileId: String) : ProfileApproval(profileId) +} diff --git a/app/src/main/kotlin/net/primal/android/explore/home/people/ExplorePeople.kt b/app/src/main/kotlin/net/primal/android/explore/home/people/ExplorePeople.kt index cbab29990..2622f79fe 100644 --- a/app/src/main/kotlin/net/primal/android/explore/home/people/ExplorePeople.kt +++ b/app/src/main/kotlin/net/primal/android/explore/home/people/ExplorePeople.kt @@ -42,6 +42,7 @@ import net.primal.android.core.compose.ListNoContent import net.primal.android.core.compose.NostrUserText import net.primal.android.core.compose.button.FollowUnfollowButton import net.primal.android.core.compose.preview.PrimalPreview +import net.primal.android.core.compose.profile.approvals.ApproveFollowUnfollowProfileAlertDialog import net.primal.android.core.compose.profile.model.ProfileDetailsUi import net.primal.android.core.errors.UiError import net.primal.android.core.utils.shortened @@ -81,6 +82,29 @@ fun ExplorePeople( eventPublisher: (ExplorePeopleContract.UiEvent) -> Unit, onProfileClick: (String) -> Unit, ) { + if (state.shouldApproveProfileAction != null) { + ApproveFollowUnfollowProfileAlertDialog( + profileApproval = state.shouldApproveProfileAction, + onFollowApproved = { + eventPublisher( + ExplorePeopleContract.UiEvent.FollowUser( + userId = state.shouldApproveProfileAction.profileId, + forceUpdate = true, + ), + ) + }, + onUnfollowApproved = { + eventPublisher( + ExplorePeopleContract.UiEvent.UnfollowUser( + userId = state.shouldApproveProfileAction.profileId, + forceUpdate = true, + ), + ) + }, + onClose = { eventPublisher(ExplorePeopleContract.UiEvent.DismissConfirmFollowUnfollowAlertDialog) }, + ) + } + if (state.loading && state.people.isEmpty()) { HeightAdjustableLoadingLazyListPlaceholder( modifier = modifier.fillMaxSize(), @@ -117,12 +141,18 @@ fun ExplorePeople( onItemClick = { onProfileClick(item.profile.pubkey) }, onFollowClick = { eventPublisher( - ExplorePeopleContract.UiEvent.FollowUser(item.profile.pubkey), + ExplorePeopleContract.UiEvent.FollowUser( + userId = item.profile.pubkey, + forceUpdate = false, + ), ) }, onUnfollowClick = { eventPublisher( - ExplorePeopleContract.UiEvent.UnfollowUser(item.profile.pubkey), + ExplorePeopleContract.UiEvent.UnfollowUser( + userId = item.profile.pubkey, + forceUpdate = false, + ), ) }, ) diff --git a/app/src/main/kotlin/net/primal/android/explore/home/people/ExplorePeopleContract.kt b/app/src/main/kotlin/net/primal/android/explore/home/people/ExplorePeopleContract.kt index 01d28ee77..7e6076a36 100644 --- a/app/src/main/kotlin/net/primal/android/explore/home/people/ExplorePeopleContract.kt +++ b/app/src/main/kotlin/net/primal/android/explore/home/people/ExplorePeopleContract.kt @@ -1,5 +1,6 @@ package net.primal.android.explore.home.people +import net.primal.android.core.compose.profile.approvals.ProfileApproval import net.primal.android.core.errors.UiError import net.primal.android.explore.api.model.ExplorePeopleData @@ -10,12 +11,13 @@ interface ExplorePeopleContract { val people: List = emptyList(), val userFollowing: Set = emptySet(), val error: UiError? = null, + val shouldApproveProfileAction: ProfileApproval? = null, ) sealed class UiEvent { - data class FollowUser(val userId: String) : UiEvent() - data class UnfollowUser(val userId: String) : UiEvent() - + data class FollowUser(val userId: String, val forceUpdate: Boolean) : UiEvent() + data class UnfollowUser(val userId: String, val forceUpdate: Boolean) : UiEvent() + data object DismissConfirmFollowUnfollowAlertDialog : UiEvent() data object RefreshPeople : UiEvent() data object DismissError : UiEvent() } diff --git a/app/src/main/kotlin/net/primal/android/explore/home/people/ExplorePeopleViewModel.kt b/app/src/main/kotlin/net/primal/android/explore/home/people/ExplorePeopleViewModel.kt index f9ecc45ec..c4efc467e 100644 --- a/app/src/main/kotlin/net/primal/android/explore/home/people/ExplorePeopleViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/explore/home/people/ExplorePeopleViewModel.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.launch +import net.primal.android.core.compose.profile.approvals.ProfileApproval import net.primal.android.core.errors.UiError import net.primal.android.explore.home.people.ExplorePeopleContract.UiEvent import net.primal.android.explore.home.people.ExplorePeopleContract.UiState @@ -76,79 +77,95 @@ class ExplorePeopleViewModel @Inject constructor( viewModelScope.launch { events.collect { when (it) { - is UiEvent.FollowUser -> follow(profileId = it.userId) - is UiEvent.UnfollowUser -> unfollow(profileId = it.userId) + is UiEvent.FollowUser -> follow(profileId = it.userId, forceUpdate = it.forceUpdate) + is UiEvent.UnfollowUser -> unfollow(profileId = it.userId, forceUpdate = it.forceUpdate) UiEvent.RefreshPeople -> fetchExplorePeople() UiEvent.DismissError -> setState { copy(error = null) } + UiEvent.DismissConfirmFollowUnfollowAlertDialog -> + setState { copy(shouldApproveProfileAction = null) } } } } - private fun follow(profileId: String) = + private fun follow(profileId: String, forceUpdate: Boolean) = viewModelScope.launch { - updateStateProfileFollow(profileId) + updateStateProfileFollowAndClearApprovalFlag(profileId) val followResult = runCatching { profileRepository.follow( userId = activeAccountStore.activeUserId(), followedUserId = profileId, + forceUpdate = forceUpdate, ) } if (followResult.isFailure) { followResult.exceptionOrNull()?.let { error -> Timber.w(error) + updateStateProfileUnfollowAndClearApprovalFlag(profileId) when (error) { - is WssException, is NostrPublishException, is ProfileRepository.FollowListNotFound -> + is WssException, is NostrPublishException -> setState { copy(error = UiError.FailedToFollowUser(error)) } - is MissingRelaysException -> setState { - copy( - error = UiError.MissingRelaysConfiguration(error), - ) + is ProfileRepository.FollowListNotFound -> setState { + copy(shouldApproveProfileAction = ProfileApproval.Follow(profileId = profileId)) } + is MissingRelaysException -> + setState { copy(error = UiError.MissingRelaysConfiguration(error)) } + else -> setState { copy(error = UiError.GenericError()) } } - updateStateProfileUnfollow(profileId) } } } - private fun unfollow(profileId: String) = + private fun unfollow(profileId: String, forceUpdate: Boolean) = viewModelScope.launch { - updateStateProfileUnfollow(profileId) + updateStateProfileUnfollowAndClearApprovalFlag(profileId) val unfollowResult = runCatching { profileRepository.unfollow( userId = activeAccountStore.activeUserId(), unfollowedUserId = profileId, + forceUpdate = forceUpdate, ) } if (unfollowResult.isFailure) { + updateStateProfileFollowAndClearApprovalFlag(profileId) unfollowResult.exceptionOrNull()?.let { error -> Timber.w(error) when (error) { - is WssException, is NostrPublishException, is ProfileRepository.FollowListNotFound -> + is WssException, is NostrPublishException -> setState { copy(error = UiError.FailedToUnfollowUser(error)) } - is MissingRelaysException -> setState { - copy( - error = UiError.MissingRelaysConfiguration(error), - ) + is ProfileRepository.FollowListNotFound -> setState { + copy(shouldApproveProfileAction = ProfileApproval.Unfollow(profileId = profileId)) } + is MissingRelaysException -> + setState { copy(error = UiError.MissingRelaysConfiguration(error)) } + else -> setState { copy(error = UiError.GenericError()) } } - updateStateProfileFollow(profileId) } } } - private fun updateStateProfileUnfollow(profileId: String) = - setState { copy(userFollowing = userFollowing - profileId) } + private fun updateStateProfileUnfollowAndClearApprovalFlag(profileId: String) = + setState { + copy( + userFollowing = userFollowing - profileId, + shouldApproveProfileAction = null, + ) + } - private fun updateStateProfileFollow(profileId: String) = - setState { copy(userFollowing = userFollowing + profileId) } + private fun updateStateProfileFollowAndClearApprovalFlag(profileId: String) = + setState { + copy( + userFollowing = userFollowing + profileId, + shouldApproveProfileAction = null, + ) + } } diff --git a/app/src/main/kotlin/net/primal/android/notes/feed/note/FeedNoteCard.kt b/app/src/main/kotlin/net/primal/android/notes/feed/note/FeedNoteCard.kt index 194c3a601..c78390596 100644 --- a/app/src/main/kotlin/net/primal/android/notes/feed/note/FeedNoteCard.kt +++ b/app/src/main/kotlin/net/primal/android/notes/feed/note/FeedNoteCard.kt @@ -47,6 +47,7 @@ import net.primal.android.attachments.domain.CdnImage import net.primal.android.core.compose.AvatarThumbnailCustomBorder import net.primal.android.core.compose.PrimalDivider import net.primal.android.core.compose.preview.PrimalPreview +import net.primal.android.core.compose.profile.approvals.ApproveBookmarkAlertDialog import net.primal.android.core.errors.UiError import net.primal.android.core.ext.openUriSafely import net.primal.android.notes.feed.NoteRepostOrQuoteBottomSheet @@ -55,7 +56,6 @@ import net.primal.android.notes.feed.model.FeedPostAction import net.primal.android.notes.feed.model.FeedPostUi import net.primal.android.notes.feed.model.toNoteContentUi import net.primal.android.notes.feed.note.NoteContract.UiEvent -import net.primal.android.notes.feed.note.ui.ConfirmFirstBookmarkAlertDialog import net.primal.android.notes.feed.note.ui.FeedNoteActionsRow import net.primal.android.notes.feed.note.ui.FeedNoteHeader import net.primal.android.notes.feed.note.ui.NoteContent @@ -221,7 +221,7 @@ private fun FeedNoteCard( } if (state.shouldApproveBookmark) { - ConfirmFirstBookmarkAlertDialog( + ApproveBookmarkAlertDialog( onBookmarkConfirmed = { eventPublisher( UiEvent.BookmarkAction( diff --git a/app/src/main/kotlin/net/primal/android/profile/details/ProfileDetailsContract.kt b/app/src/main/kotlin/net/primal/android/profile/details/ProfileDetailsContract.kt index 38c420471..a8d4310d8 100644 --- a/app/src/main/kotlin/net/primal/android/profile/details/ProfileDetailsContract.kt +++ b/app/src/main/kotlin/net/primal/android/profile/details/ProfileDetailsContract.kt @@ -1,5 +1,6 @@ package net.primal.android.profile.details +import net.primal.android.core.compose.profile.approvals.ProfileApproval import net.primal.android.core.compose.profile.model.ProfileDetailsUi import net.primal.android.core.compose.profile.model.ProfileStatsUi import net.primal.android.core.errors.UiError @@ -26,6 +27,7 @@ interface ProfileDetailsContract { ProfileFeedSpec.AuthoredMedia, ), val error: ProfileError? = null, + val shouldApproveProfileAction: ProfileApproval? = null, val zapError: UiError? = null, val zappingState: ZappingState = ZappingState(), ) { @@ -48,21 +50,19 @@ interface ProfileDetailsContract { } sealed class UiEvent { - data class FollowAction(val profileId: String) : UiEvent() - data class UnfollowAction(val profileId: String) : UiEvent() data class AddProfileFeedAction( val profileId: String, val feedTitle: String, val feedDescription: String, ) : UiEvent() - data class ZapProfile( val profileId: String, val profileLnUrlDecoded: String?, val zapDescription: String? = null, val zapAmount: ULong? = null, ) : UiEvent() - + data class FollowAction(val profileId: String, val forceUpdate: Boolean) : UiEvent() + data class UnfollowAction(val profileId: String, val forceUpdate: Boolean) : UiEvent() data class RemoveProfileFeedAction(val profileId: String) : UiEvent() data class MuteAction(val profileId: String) : UiEvent() data class UnmuteAction(val profileId: String) : UiEvent() @@ -70,5 +70,6 @@ interface ProfileDetailsContract { data class ReportAbuse(val type: ReportType, val profileId: String, val noteId: String? = null) : UiEvent() data object DismissError : UiEvent() data object DismissZapError : UiEvent() + data object DismissConfirmFollowUnfollowAlertDialog : UiEvent() } } diff --git a/app/src/main/kotlin/net/primal/android/profile/details/ProfileDetailsViewModel.kt b/app/src/main/kotlin/net/primal/android/profile/details/ProfileDetailsViewModel.kt index b43396620..7d7977449 100644 --- a/app/src/main/kotlin/net/primal/android/profile/details/ProfileDetailsViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/profile/details/ProfileDetailsViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import net.primal.android.core.compose.profile.approvals.ProfileApproval import net.primal.android.core.compose.profile.model.asProfileDetailsUi import net.primal.android.core.compose.profile.model.asProfileStatsUi import net.primal.android.core.coroutines.CoroutineDispatcherProvider @@ -120,6 +121,8 @@ class ProfileDetailsViewModel @Inject constructor( ) UiEvent.DismissZapError -> setState { copy(zapError = null) } + UiEvent.DismissConfirmFollowUnfollowAlertDialog -> + setState { copy(shouldApproveProfileAction = null) } } } } @@ -300,61 +303,77 @@ class ProfileDetailsViewModel @Inject constructor( Timber.w(error) } - private fun updateStateProfileAsFollowed() = setState { copy(isProfileFollowed = true) } + private fun updateStateProfileAsFollowedAndClearApprovalFlag() = + setState { + copy(isProfileFollowed = true, shouldApproveProfileAction = null) + } - private fun updateStateProfileAsUnfollowed() = setState { copy(isProfileFollowed = false) } + private fun updateStateProfileAsUnfollowedAndClearApprovalFlag() = + setState { + copy(isProfileFollowed = false, shouldApproveProfileAction = null) + } private fun follow(followAction: UiEvent.FollowAction) = viewModelScope.launch { - updateStateProfileAsFollowed() + updateStateProfileAsFollowedAndClearApprovalFlag() try { profileRepository.follow( userId = activeAccountStore.activeUserId(), followedUserId = followAction.profileId, + forceUpdate = followAction.forceUpdate, ) } catch (error: WssException) { Timber.w(error) - updateStateProfileAsUnfollowed() + updateStateProfileAsUnfollowedAndClearApprovalFlag() setErrorState(error = ProfileError.FailedToFollowProfile(error)) } catch (error: NostrPublishException) { Timber.w(error) - updateStateProfileAsUnfollowed() + updateStateProfileAsUnfollowedAndClearApprovalFlag() setErrorState(error = ProfileError.FailedToFollowProfile(error)) } catch (error: MissingRelaysException) { Timber.w(error) - updateStateProfileAsUnfollowed() + updateStateProfileAsUnfollowedAndClearApprovalFlag() setErrorState(error = ProfileError.MissingRelaysConfiguration(error)) } catch (error: ProfileRepository.FollowListNotFound) { Timber.w(error) - updateStateProfileAsUnfollowed() - setErrorState(error = ProfileError.FailedToFollowProfile(error)) + updateStateProfileAsUnfollowedAndClearApprovalFlag() + setState { + copy( + shouldApproveProfileAction = ProfileApproval.Follow(profileId = followAction.profileId), + ) + } } } private fun unfollow(unfollowAction: UiEvent.UnfollowAction) = viewModelScope.launch { - updateStateProfileAsUnfollowed() + updateStateProfileAsUnfollowedAndClearApprovalFlag() try { profileRepository.unfollow( userId = activeAccountStore.activeUserId(), unfollowedUserId = unfollowAction.profileId, + forceUpdate = unfollowAction.forceUpdate, ) } catch (error: WssException) { Timber.w(error) - updateStateProfileAsFollowed() + updateStateProfileAsFollowedAndClearApprovalFlag() setErrorState(error = ProfileError.FailedToUnfollowProfile(error)) } catch (error: NostrPublishException) { Timber.w(error) - updateStateProfileAsFollowed() + updateStateProfileAsFollowedAndClearApprovalFlag() setErrorState(error = ProfileError.FailedToUnfollowProfile(error)) } catch (error: MissingRelaysException) { Timber.w(error) - updateStateProfileAsFollowed() + updateStateProfileAsFollowedAndClearApprovalFlag() setErrorState(error = ProfileError.MissingRelaysConfiguration(error)) } catch (error: ProfileRepository.FollowListNotFound) { Timber.w(error) - updateStateProfileAsFollowed() - setErrorState(error = ProfileError.FailedToUnfollowProfile(error)) + updateStateProfileAsFollowedAndClearApprovalFlag() + setState { + copy( + shouldApproveProfileAction = ProfileApproval.Unfollow(profileId = unfollowAction.profileId), + ) + } } } diff --git a/app/src/main/kotlin/net/primal/android/profile/details/ui/ProfileDetailsHeader.kt b/app/src/main/kotlin/net/primal/android/profile/details/ui/ProfileDetailsHeader.kt index 751870e77..db60d283f 100644 --- a/app/src/main/kotlin/net/primal/android/profile/details/ui/ProfileDetailsHeader.kt +++ b/app/src/main/kotlin/net/primal/android/profile/details/ui/ProfileDetailsHeader.kt @@ -73,8 +73,22 @@ fun ProfileDetailsHeader( onUnableToZapProfile() } }, - onFollow = { eventPublisher(ProfileDetailsContract.UiEvent.FollowAction(state.profileId)) }, - onUnfollow = { eventPublisher(ProfileDetailsContract.UiEvent.UnfollowAction(state.profileId)) }, + onFollow = { + eventPublisher( + ProfileDetailsContract.UiEvent.FollowAction( + profileId = state.profileId, + forceUpdate = false, + ), + ) + }, + onUnfollow = { + eventPublisher( + ProfileDetailsContract.UiEvent.UnfollowAction( + profileId = state.profileId, + forceUpdate = false, + ), + ) + }, onDrawerQrCodeClick = onDrawerQrCodeClick, onFollowsClick = onFollowsClick, onProfileClick = onProfileClick, diff --git a/app/src/main/kotlin/net/primal/android/profile/details/ui/ProfileDetailsScreen.kt b/app/src/main/kotlin/net/primal/android/profile/details/ui/ProfileDetailsScreen.kt index f6812e9a7..11696b454 100644 --- a/app/src/main/kotlin/net/primal/android/profile/details/ui/ProfileDetailsScreen.kt +++ b/app/src/main/kotlin/net/primal/android/profile/details/ui/ProfileDetailsScreen.kt @@ -63,6 +63,7 @@ import net.primal.android.R import net.primal.android.articles.feed.ArticleFeedList import net.primal.android.core.compose.SnackbarErrorHandler import net.primal.android.core.compose.preview.PrimalPreview +import net.primal.android.core.compose.profile.approvals.ApproveFollowUnfollowProfileAlertDialog import net.primal.android.core.compose.profile.model.ProfileDetailsUi import net.primal.android.core.compose.pulltorefresh.PrimalPullToRefreshBox import net.primal.android.core.compose.runtime.DisposableLifecycleObserverEffect @@ -297,6 +298,31 @@ fun ProfileDetailsScreen( } } + if (state.shouldApproveProfileAction != null) { + ApproveFollowUnfollowProfileAlertDialog( + profileApproval = state.shouldApproveProfileAction, + onFollowApproved = { + eventPublisher( + ProfileDetailsContract.UiEvent.FollowAction( + profileId = state.shouldApproveProfileAction.profileId, + forceUpdate = true, + ), + ) + }, + onUnfollowApproved = { + eventPublisher( + ProfileDetailsContract.UiEvent.UnfollowAction( + profileId = state.shouldApproveProfileAction.profileId, + forceUpdate = true, + ), + ) + }, + onClose = { + eventPublisher(ProfileDetailsContract.UiEvent.DismissConfirmFollowUnfollowAlertDialog) + }, + ) + } + Scaffold( snackbarHost = { SnackbarHost( diff --git a/app/src/main/kotlin/net/primal/android/profile/follows/ProfileFollowsContract.kt b/app/src/main/kotlin/net/primal/android/profile/follows/ProfileFollowsContract.kt index 598de3b2f..61167e111 100644 --- a/app/src/main/kotlin/net/primal/android/profile/follows/ProfileFollowsContract.kt +++ b/app/src/main/kotlin/net/primal/android/profile/follows/ProfileFollowsContract.kt @@ -1,5 +1,6 @@ package net.primal.android.profile.follows +import net.primal.android.core.compose.profile.approvals.ProfileApproval import net.primal.android.core.compose.profile.model.UserProfileItemUi import net.primal.android.profile.domain.ProfileFollowsType @@ -12,6 +13,7 @@ interface ProfileFollowsContract { val userFollowing: Set = emptySet(), val error: FollowsError? = null, val users: List = emptyList(), + val shouldApproveProfileAction: ProfileApproval? = null, ) { sealed class FollowsError { data class FailedToFollowUser(val cause: Throwable) : FollowsError() @@ -21,8 +23,9 @@ interface ProfileFollowsContract { } sealed class UiEvent { - data class FollowProfile(val profileId: String) : UiEvent() - data class UnfollowProfile(val profileId: String) : UiEvent() + data class FollowProfile(val profileId: String, val forceUpdate: Boolean) : UiEvent() + data class UnfollowProfile(val profileId: String, val forceUpdate: Boolean) : UiEvent() + data object DismissConfirmFollowUnfollowAlertDialog : UiEvent() data object DismissError : UiEvent() data object ReloadData : UiEvent() } diff --git a/app/src/main/kotlin/net/primal/android/profile/follows/ProfileFollowsScreen.kt b/app/src/main/kotlin/net/primal/android/profile/follows/ProfileFollowsScreen.kt index 12b73f040..7a1a46e05 100644 --- a/app/src/main/kotlin/net/primal/android/profile/follows/ProfileFollowsScreen.kt +++ b/app/src/main/kotlin/net/primal/android/profile/follows/ProfileFollowsScreen.kt @@ -25,6 +25,7 @@ import net.primal.android.core.compose.SnackbarErrorHandler import net.primal.android.core.compose.heightAdjustableLoadingLazyListPlaceholder import net.primal.android.core.compose.icons.PrimalIcons import net.primal.android.core.compose.icons.primaliconpack.ArrowBack +import net.primal.android.core.compose.profile.approvals.ApproveFollowUnfollowProfileAlertDialog import net.primal.android.explore.search.ui.FollowUnfollowVisibility import net.primal.android.explore.search.ui.UserProfileListItem import net.primal.android.profile.domain.ProfileFollowsType @@ -56,6 +57,7 @@ private fun ProfileFollowsScreen( ) { val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } + SnackbarErrorHandler( error = state.error, snackbarHostState = snackbarHostState, @@ -85,13 +87,11 @@ private fun ProfileFollowsScreen( ) }, content = { paddingValues -> - FollowsLazyColumn( - paddingValues = paddingValues, + ProfileFollowsContent( state = state, + eventPublisher = eventPublisher, + paddingValues = paddingValues, onProfileClick = onProfileClick, - onFollowProfileClick = { eventPublisher(ProfileFollowsContract.UiEvent.FollowProfile(it)) }, - onUnfollowProfileClick = { eventPublisher(ProfileFollowsContract.UiEvent.UnfollowProfile(it)) }, - onRefreshClick = { eventPublisher(ProfileFollowsContract.UiEvent.ReloadData) }, ) }, snackbarHost = { @@ -100,6 +100,60 @@ private fun ProfileFollowsScreen( ) } +@Composable +private fun ProfileFollowsContent( + state: ProfileFollowsContract.UiState, + eventPublisher: (ProfileFollowsContract.UiEvent) -> Unit, + paddingValues: PaddingValues, + onProfileClick: (String) -> Unit, +) { + if (state.shouldApproveProfileAction != null) { + ApproveFollowUnfollowProfileAlertDialog( + profileApproval = state.shouldApproveProfileAction, + onFollowApproved = { + eventPublisher( + ProfileFollowsContract.UiEvent.FollowProfile( + profileId = state.shouldApproveProfileAction.profileId, + forceUpdate = true, + ), + ) + }, + onUnfollowApproved = { + eventPublisher( + ProfileFollowsContract.UiEvent.UnfollowProfile( + profileId = state.shouldApproveProfileAction.profileId, + forceUpdate = true, + ), + ) + }, + onClose = { eventPublisher(ProfileFollowsContract.UiEvent.DismissConfirmFollowUnfollowAlertDialog) }, + ) + } + + FollowsLazyColumn( + paddingValues = paddingValues, + state = state, + onProfileClick = onProfileClick, + onFollowProfileClick = { + eventPublisher( + ProfileFollowsContract.UiEvent.FollowProfile( + profileId = it, + forceUpdate = false, + ), + ) + }, + onUnfollowProfileClick = { + eventPublisher( + ProfileFollowsContract.UiEvent.UnfollowProfile( + profileId = it, + forceUpdate = false, + ), + ) + }, + onRefreshClick = { eventPublisher(ProfileFollowsContract.UiEvent.ReloadData) }, + ) +} + @Composable private fun FollowsLazyColumn( paddingValues: PaddingValues, diff --git a/app/src/main/kotlin/net/primal/android/profile/follows/ProfileFollowsViewModel.kt b/app/src/main/kotlin/net/primal/android/profile/follows/ProfileFollowsViewModel.kt index b437718b9..48cb91058 100644 --- a/app/src/main/kotlin/net/primal/android/profile/follows/ProfileFollowsViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/profile/follows/ProfileFollowsViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import net.primal.android.core.compose.profile.approvals.ProfileApproval import net.primal.android.core.compose.profile.model.mapAsUserProfileUi import net.primal.android.core.coroutines.CoroutineDispatcherProvider import net.primal.android.core.utils.usernameUiFriendly @@ -72,10 +73,12 @@ class ProfileFollowsViewModel @Inject constructor( viewModelScope.launch { events.collect { when (it) { - is UiEvent.FollowProfile -> follow(profileId = it.profileId) - is UiEvent.UnfollowProfile -> unfollow(profileId = it.profileId) + is UiEvent.FollowProfile -> follow(profileId = it.profileId, forceUpdate = it.forceUpdate) + is UiEvent.UnfollowProfile -> unfollow(profileId = it.profileId, forceUpdate = it.forceUpdate) UiEvent.DismissError -> setState { copy(error = null) } UiEvent.ReloadData -> fetchFollows() + UiEvent.DismissConfirmFollowUnfollowAlertDialog -> + setState { copy(shouldApproveProfileAction = null) } } } } @@ -116,65 +119,77 @@ class ProfileFollowsViewModel @Inject constructor( } } - private fun updateStateProfileFollow(profileId: String) { - setState { copy(userFollowing = this.userFollowing.toMutableSet().apply { add(profileId) }) } + private fun updateStateProfileFollowAndClearApprovalFlag(profileId: String) { + setState { + copy( + userFollowing = this.userFollowing.toMutableSet().apply { add(profileId) }, + shouldApproveProfileAction = null, + ) + } } - private fun updateStateProfileUnfollow(profileId: String) { - setState { copy(userFollowing = this.userFollowing.toMutableSet().apply { remove(profileId) }) } + private fun updateStateProfileUnfollowAndClearApprovalFlag(profileId: String) { + setState { + copy( + userFollowing = this.userFollowing.toMutableSet().apply { remove(profileId) }, + shouldApproveProfileAction = null, + ) + } } - private fun follow(profileId: String) = + private fun follow(profileId: String, forceUpdate: Boolean) = viewModelScope.launch { - updateStateProfileFollow(profileId) + updateStateProfileFollowAndClearApprovalFlag(profileId) try { profileRepository.follow( userId = activeAccountStore.activeUserId(), followedUserId = profileId, + forceUpdate = forceUpdate, ) } catch (error: WssException) { Timber.w(error) setErrorState(error = UiState.FollowsError.FailedToFollowUser(error)) - updateStateProfileUnfollow(profileId) + updateStateProfileUnfollowAndClearApprovalFlag(profileId) } catch (error: NostrPublishException) { Timber.w(error) setErrorState(error = UiState.FollowsError.FailedToFollowUser(error)) - updateStateProfileUnfollow(profileId) + updateStateProfileUnfollowAndClearApprovalFlag(profileId) } catch (error: MissingRelaysException) { Timber.w(error) setErrorState(error = UiState.FollowsError.MissingRelaysConfiguration(error)) - updateStateProfileUnfollow(profileId) + updateStateProfileUnfollowAndClearApprovalFlag(profileId) } catch (error: ProfileRepository.FollowListNotFound) { Timber.w(error) - setErrorState(error = UiState.FollowsError.FailedToFollowUser(error)) - updateStateProfileUnfollow(profileId) + updateStateProfileUnfollowAndClearApprovalFlag(profileId) + setState { copy(shouldApproveProfileAction = ProfileApproval.Follow(profileId = profileId)) } } } - private fun unfollow(profileId: String) = + private fun unfollow(profileId: String, forceUpdate: Boolean) = viewModelScope.launch { - updateStateProfileUnfollow(profileId) + updateStateProfileUnfollowAndClearApprovalFlag(profileId) try { profileRepository.unfollow( userId = activeAccountStore.activeUserId(), unfollowedUserId = profileId, + forceUpdate = forceUpdate, ) } catch (error: WssException) { Timber.w(error) setErrorState(error = UiState.FollowsError.FailedToUnfollowUser(error)) - updateStateProfileFollow(profileId) + updateStateProfileFollowAndClearApprovalFlag(profileId) } catch (error: NostrPublishException) { Timber.w(error) setErrorState(error = UiState.FollowsError.FailedToUnfollowUser(error)) - updateStateProfileFollow(profileId) + updateStateProfileFollowAndClearApprovalFlag(profileId) } catch (error: MissingRelaysException) { Timber.w(error) setErrorState(error = UiState.FollowsError.MissingRelaysConfiguration(error)) - updateStateProfileFollow(profileId) + updateStateProfileFollowAndClearApprovalFlag(profileId) } catch (error: ProfileRepository.FollowListNotFound) { Timber.w(error) - setErrorState(error = UiState.FollowsError.FailedToUnfollowUser(error)) - updateStateProfileFollow(profileId) + updateStateProfileFollowAndClearApprovalFlag(profileId) + setState { copy(shouldApproveProfileAction = ProfileApproval.Unfollow(profileId = profileId)) } } } diff --git a/app/src/main/kotlin/net/primal/android/profile/repository/ProfileRepository.kt b/app/src/main/kotlin/net/primal/android/profile/repository/ProfileRepository.kt index fb812da8f..13bf342a9 100644 --- a/app/src/main/kotlin/net/primal/android/profile/repository/ProfileRepository.kt +++ b/app/src/main/kotlin/net/primal/android/profile/repository/ProfileRepository.kt @@ -111,34 +111,51 @@ class ProfileRepository @Inject constructor( } @Throws(FollowListNotFound::class, NostrPublishException::class) - suspend fun follow(userId: String, followedUserId: String) { - updateFollowList(userId = userId) { + suspend fun follow( + userId: String, + followedUserId: String, + forceUpdate: Boolean, + ) { + updateFollowList(userId = userId, forceUpdate = forceUpdate) { toMutableSet().apply { add(followedUserId) } } } @Throws(FollowListNotFound::class, NostrPublishException::class) - suspend fun unfollow(userId: String, unfollowedUserId: String) { - updateFollowList(userId = userId) { + suspend fun unfollow( + userId: String, + unfollowedUserId: String, + forceUpdate: Boolean, + ) { + updateFollowList(userId = userId, forceUpdate = forceUpdate) { toMutableSet().apply { remove(unfollowedUserId) } } } @Throws(FollowListNotFound::class, NostrPublishException::class) - private suspend fun updateFollowList(userId: String, reducer: Set.() -> Set) = - withContext(dispatchers.io()) { - val userFollowList = userAccountFetcher.fetchUserFollowListOrNull(userId = userId) - ?: throw FollowListNotFound() + private suspend fun updateFollowList( + userId: String, + forceUpdate: Boolean, + reducer: Set.() -> Set, + ) = withContext(dispatchers.io()) { + val userFollowList = userAccountFetcher.fetchUserFollowListOrNull(userId = userId) + val isEmptyFollowList = userFollowList == null || userFollowList.following.isEmpty() + if (isEmptyFollowList && !forceUpdate) { + throw FollowListNotFound() + } + if (userFollowList != null) { userRepository.updateFollowList(userId, userFollowList) - - setFollowList( - userId = userId, - contacts = userFollowList.following.reducer(), - content = userFollowList.followListEventContent ?: "", - ) } + val existingFollowing = userFollowList?.following ?: emptySet() + setFollowList( + userId = userId, + contacts = existingFollowing.reducer(), + content = userFollowList?.followListEventContent ?: "", + ) + } + @Throws(NostrPublishException::class) suspend fun setFollowList( userId: String, diff --git a/app/src/main/kotlin/net/primal/android/thread/articles/details/ArticleDetailsScreen.kt b/app/src/main/kotlin/net/primal/android/thread/articles/details/ArticleDetailsScreen.kt index 0a1cb1153..60983f246 100644 --- a/app/src/main/kotlin/net/primal/android/thread/articles/details/ArticleDetailsScreen.kt +++ b/app/src/main/kotlin/net/primal/android/thread/articles/details/ArticleDetailsScreen.kt @@ -67,6 +67,7 @@ import net.primal.android.core.compose.icons.primaliconpack.Bookmarks import net.primal.android.core.compose.icons.primaliconpack.BookmarksFilled import net.primal.android.core.compose.icons.primaliconpack.FeedNewZapFilled import net.primal.android.core.compose.icons.primaliconpack.More +import net.primal.android.core.compose.profile.approvals.ApproveBookmarkAlertDialog import net.primal.android.core.compose.runtime.DisposableLifecycleObserverEffect import net.primal.android.core.compose.zaps.ArticleTopZapsSection import net.primal.android.core.errors.UiError @@ -83,7 +84,6 @@ import net.primal.android.notes.feed.model.EventStatsUi import net.primal.android.notes.feed.model.FeedPostAction import net.primal.android.notes.feed.model.FeedPostUi import net.primal.android.notes.feed.note.FeedNoteCard -import net.primal.android.notes.feed.note.ui.ConfirmFirstBookmarkAlertDialog import net.primal.android.notes.feed.note.ui.FeedNoteActionsRow import net.primal.android.notes.feed.note.ui.ReferencedNoteCard import net.primal.android.notes.feed.note.ui.ThreadNoteStatsRow @@ -217,7 +217,7 @@ private fun ArticleDetailsScreen( } if (articleState.shouldApproveBookmark && detailsState.article != null) { - ConfirmFirstBookmarkAlertDialog( + ApproveBookmarkAlertDialog( onBookmarkConfirmed = { articleEventPublisher( ArticleContract.UiEvent.BookmarkAction( diff --git a/app/src/main/kotlin/net/primal/android/thread/articles/details/ArticleDetailsViewModel.kt b/app/src/main/kotlin/net/primal/android/thread/articles/details/ArticleDetailsViewModel.kt index c221197db..f928b8325 100644 --- a/app/src/main/kotlin/net/primal/android/thread/articles/details/ArticleDetailsViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/thread/articles/details/ArticleDetailsViewModel.kt @@ -275,11 +275,13 @@ class ArticleDetailsViewModel @Inject constructor( profileRepository.unfollow( userId = activeAccountStore.activeUserId(), unfollowedUserId = article.authorId, + forceUpdate = false, ) } else { profileRepository.follow( userId = activeAccountStore.activeUserId(), followedUserId = article.authorId, + forceUpdate = false, ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 750cb1e0c..18f53d997 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -825,6 +825,16 @@ Save Bookmark No + Saving first follow + This will result in following a total of one account. Do you wish to continue? + Save follow + No + + Saving unfollow + This will result in following a total of zero accounts. Do you wish to continue? + Save unfollow + No + Report abuse All reports posted will be publicly visible. Dismiss