diff --git a/app/src/main/java/com/jerboa/JerboaAppState.kt b/app/src/main/java/com/jerboa/JerboaAppState.kt index 3a07f4b02..e8ac2c779 100644 --- a/app/src/main/java/com/jerboa/JerboaAppState.kt +++ b/app/src/main/java/com/jerboa/JerboaAppState.kt @@ -132,7 +132,7 @@ class JerboaAppState( navController.navigate(Route.COMMENT_REPLY) } - fun toComment(id: CommunityId) { + fun toComment(id: CommentId) { navController.navigate(Route.CommentArgs.makeRoute(id = "$id")) } diff --git a/app/src/main/java/com/jerboa/MainActivity.kt b/app/src/main/java/com/jerboa/MainActivity.kt index d9f9b1d4b..5ad77431f 100644 --- a/app/src/main/java/com/jerboa/MainActivity.kt +++ b/app/src/main/java/com/jerboa/MainActivity.kt @@ -68,6 +68,7 @@ import com.jerboa.ui.components.remove.comment.CommentRemoveActivity import com.jerboa.ui.components.remove.post.PostRemoveActivity import com.jerboa.ui.components.report.comment.CreateCommentReportActivity import com.jerboa.ui.components.report.post.CreatePostReportActivity +import com.jerboa.ui.components.reports.ReportsActivity import com.jerboa.ui.components.settings.SettingsActivity import com.jerboa.ui.components.settings.about.AboutActivity import com.jerboa.ui.components.settings.account.AccountSettingsActivity @@ -451,6 +452,29 @@ class MainActivity : AppCompatActivity() { ) } + composable( + route = Route.REGISTRATION_APPLICATIONS, + ) { + RegistrationApplicationsActivity( + appState = appState, + accountViewModel = accountViewModel, + siteViewModel = siteViewModel, + drawerState = drawerState, + ) + } + + composable( + route = Route.REPORTS, + ) { + ReportsActivity( + appState = appState, + accountViewModel = accountViewModel, + siteViewModel = siteViewModel, + drawerState = drawerState, + blurNSFW = appSettings.blurNSFW.toEnum(), + ) + } + composable( route = Route.POST, deepLinks = diff --git a/app/src/main/java/com/jerboa/Utils.kt b/app/src/main/java/com/jerboa/Utils.kt index 2a8ddca76..6c4e0b317 100644 --- a/app/src/main/java/com/jerboa/Utils.kt +++ b/app/src/main/java/com/jerboa/Utils.kt @@ -980,6 +980,57 @@ fun findAndUpdateApplication( } } +fun findAndUpdatePostReport( + reports: List, + updatedReport: PostReportView, +): List { + val foundIndex = + reports.indexOfFirst { + it.post_report.id == updatedReport.post_report.id + } + return if (foundIndex != -1) { + val mutable = reports.toMutableList() + mutable[foundIndex] = updatedReport + mutable.toList() + } else { + reports + } +} + +fun findAndUpdateCommentReport( + reports: List, + updatedReport: CommentReportView, +): List { + val foundIndex = + reports.indexOfFirst { + it.comment_report.id == updatedReport.comment_report.id + } + return if (foundIndex != -1) { + val mutable = reports.toMutableList() + mutable[foundIndex] = updatedReport + mutable.toList() + } else { + reports + } +} + +fun findAndUpdatePrivateMessageReport( + reports: List, + updatedReport: PrivateMessageReportView, +): List { + val foundIndex = + reports.indexOfFirst { + it.private_message_report.id == updatedReport.private_message_report.id + } + return if (foundIndex != -1) { + val mutable = reports.toMutableList() + mutable[foundIndex] = updatedReport + mutable.toList() + } else { + reports + } +} + fun showBlockPersonToast( blockPersonRes: ApiState, ctx: Context, diff --git a/app/src/main/java/com/jerboa/datatypes/Others.kt b/app/src/main/java/com/jerboa/datatypes/Others.kt index 4b13f5d00..06017cd43 100644 --- a/app/src/main/java/com/jerboa/datatypes/Others.kt +++ b/app/src/main/java/com/jerboa/datatypes/Others.kt @@ -180,3 +180,12 @@ data class PostFeatureData( val type: PostFeatureType, val featured: Boolean, ) + +/** + * Says which type of users can view which bottom app bar tabs. + */ +enum class UserViewType { + Normal, + AdminOnly, + AdminOrMod, +} diff --git a/app/src/main/java/com/jerboa/datatypes/SampleData.kt b/app/src/main/java/com/jerboa/datatypes/SampleData.kt index 735aadb86..bc8215864 100644 --- a/app/src/main/java/com/jerboa/datatypes/SampleData.kt +++ b/app/src/main/java/com/jerboa/datatypes/SampleData.kt @@ -9,6 +9,8 @@ import it.vercruysse.lemmyapi.v0x19.datatypes.Comment import it.vercruysse.lemmyapi.v0x19.datatypes.CommentAggregates import it.vercruysse.lemmyapi.v0x19.datatypes.CommentReply import it.vercruysse.lemmyapi.v0x19.datatypes.CommentReplyView +import it.vercruysse.lemmyapi.v0x19.datatypes.CommentReport +import it.vercruysse.lemmyapi.v0x19.datatypes.CommentReportView import it.vercruysse.lemmyapi.v0x19.datatypes.CommentView import it.vercruysse.lemmyapi.v0x19.datatypes.Community import it.vercruysse.lemmyapi.v0x19.datatypes.CommunityAggregates @@ -23,8 +25,12 @@ import it.vercruysse.lemmyapi.v0x19.datatypes.PersonMentionView import it.vercruysse.lemmyapi.v0x19.datatypes.PersonView import it.vercruysse.lemmyapi.v0x19.datatypes.Post import it.vercruysse.lemmyapi.v0x19.datatypes.PostAggregates +import it.vercruysse.lemmyapi.v0x19.datatypes.PostReport +import it.vercruysse.lemmyapi.v0x19.datatypes.PostReportView import it.vercruysse.lemmyapi.v0x19.datatypes.PostView import it.vercruysse.lemmyapi.v0x19.datatypes.PrivateMessage +import it.vercruysse.lemmyapi.v0x19.datatypes.PrivateMessageReport +import it.vercruysse.lemmyapi.v0x19.datatypes.PrivateMessageReportView import it.vercruysse.lemmyapi.v0x19.datatypes.PrivateMessageView import it.vercruysse.lemmyapi.v0x19.datatypes.RegistrationApplication import it.vercruysse.lemmyapi.v0x19.datatypes.RegistrationApplicationView @@ -195,6 +201,25 @@ val samplePerson2 = instance_id = 0, ) +val samplePerson3 = + Person( + id = 33478, + name = "witch_power", + display_name = null, + banned = false, + published = "2021-08-08T01:47:44.437708", + updated = "2021-10-11T07:14:53.548707", + actor_id = "https://lemmy.ml/u/witch_power", + bio = null, + local = true, + banner = null, + deleted = false, + matrix_user_id = null, + bot_account = false, + ban_expires = null, + instance_id = 0, + ) + val sampleLocalUser = LocalUser( id = 24, person_id = 82, @@ -694,3 +719,73 @@ val sampleDeniedRegistrationApplicationView = creator_local_user = sampleLocalUser, admin = samplePerson2, ) + +val samplePostReport = + PostReport( + creator_id = 28, + id = 89, + original_post_name = samplePost.name, + post_id = samplePost.id, + published = samplePost.published, + reason = "This post is *peak* **cringe**", + resolved = true, + resolver_id = samplePerson3.id, + ) + +val samplePostReportView = + PostReportView( + post_creator = samplePerson, + creator = samplePerson2, + resolver = samplePerson3, + post = samplePost, + post_report = samplePostReport, + community = sampleCommunity, + counts = samplePostAggregates, + creator_banned_from_community = false, + ) + +val sampleCommentReport = + CommentReport( + creator_id = 28, + id = 89, + original_comment_text = sampleComment.content, + comment_id = sampleComment.id, + published = sampleComment.published, + reason = "This is a bad comment, remove it plz.", + resolved = true, + resolver_id = samplePerson3.id, + ) + +val sampleCommentReportView = + CommentReportView( + comment_creator = samplePerson, + creator = samplePerson2, + resolver = samplePerson3, + post = samplePost, + comment = sampleComment, + comment_report = sampleCommentReport, + community = sampleCommunity, + counts = sampleCommentAggregates, + creator_banned_from_community = false, + ) + +val samplePrivateMessageReport = + PrivateMessageReport( + creator_id = 28, + id = 89, + original_pm_text = samplePrivateMessage.content, + private_message_id = samplePrivateMessage.id, + published = sampleComment.published, + reason = "This PM is from a spammer", + resolved = true, + resolver_id = samplePerson3.id, + ) + +val samplePrivateMessageReportView = + PrivateMessageReportView( + private_message_report = samplePrivateMessageReport, + private_message = samplePrivateMessage, + private_message_creator = samplePerson, + creator = samplePerson2, + resolver = samplePerson3, + ) diff --git a/app/src/main/java/com/jerboa/db/entity/Account.kt b/app/src/main/java/com/jerboa/db/entity/Account.kt index 53efdffb9..49f912dac 100644 --- a/app/src/main/java/com/jerboa/db/entity/Account.kt +++ b/app/src/main/java/com/jerboa/db/entity/Account.kt @@ -3,6 +3,7 @@ package com.jerboa.db.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import com.jerboa.datatypes.UserViewType import com.jerboa.feat.AccountVerificationState @Entity @@ -53,3 +54,13 @@ fun Account.isAnon(): Boolean { fun Account.isReady(): Boolean { return this.verificationState == AccountVerificationState.CHECKS_COMPLETE.ordinal } + +fun Account.userViewType(): UserViewType { + return if (isAdmin) { + UserViewType.AdminOnly + } else if (isMod) { + UserViewType.AdminOrMod + } else { + UserViewType.Normal + } +} diff --git a/app/src/main/java/com/jerboa/model/ReportsViewModel.kt b/app/src/main/java/com/jerboa/model/ReportsViewModel.kt new file mode 100644 index 000000000..13f46e279 --- /dev/null +++ b/app/src/main/java/com/jerboa/model/ReportsViewModel.kt @@ -0,0 +1,335 @@ +package com.jerboa.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras +import com.jerboa.api.API +import com.jerboa.api.ApiState +import com.jerboa.api.toApiState +import com.jerboa.db.entity.Account +import com.jerboa.db.entity.isAnon +import com.jerboa.findAndUpdateCommentReport +import com.jerboa.findAndUpdatePostReport +import com.jerboa.findAndUpdatePrivateMessageReport +import com.jerboa.getDeduplicateMerge +import it.vercruysse.lemmyapi.v0x19.datatypes.* +import kotlinx.coroutines.launch + +class ReportsViewModel(account: Account, siteViewModel: SiteViewModel) : ViewModel() { + var postReportsRes: ApiState by mutableStateOf( + ApiState.Empty, + ) + private set + + var commentReportsRes: ApiState by mutableStateOf( + ApiState.Empty, + ) + private set + + var messageReportsRes: ApiState by mutableStateOf( + ApiState.Empty, + ) + private set + + private var resolvePostReportRes: ApiState by mutableStateOf( + ApiState.Empty, + ) + + private var resolveCommentReportRes: ApiState by mutableStateOf( + ApiState.Empty, + ) + + private var resolveMessageReportRes: ApiState by mutableStateOf( + ApiState.Empty, + ) + + private var pagePostReports by mutableLongStateOf(1) + private var pageCommentReports by mutableLongStateOf(1) + private var pageMessageReports by mutableLongStateOf(1) + + var unresolvedOnly by mutableStateOf(true) + private set + + fun resetPagePostReports() { + pagePostReports = 1 + } + + fun resetPageCommentReports() { + pageCommentReports = 1 + } + + fun resetPageMessageReports() { + pageMessageReports = 1 + } + + fun updateUnresolvedOnly(unresolvedOnly: Boolean) { + this.unresolvedOnly = unresolvedOnly + } + + fun resetPages() { + resetPagePostReports() + resetPageCommentReports() + resetPageMessageReports() + } + + fun getFormPostReports(): ListPostReports { + return ListPostReports( + unresolved_only = unresolvedOnly, + page = pagePostReports, + ) + } + + fun getFormCommentReports(): ListCommentReports { + return ListCommentReports( + unresolved_only = unresolvedOnly, + page = pageCommentReports, + ) + } + + fun getFormMessageReports(): ListPrivateMessageReports { + return ListPrivateMessageReports( + unresolved_only = unresolvedOnly, + page = pageMessageReports, + ) + } + + fun listPostReports( + form: ListPostReports, + state: ApiState = ApiState.Loading, + ) { + viewModelScope.launch { + postReportsRes = state + postReportsRes = API.getInstance().listPostReports(form).toApiState() + } + } + + fun appendPostReports() { + viewModelScope.launch { + val oldRes = postReportsRes + when (oldRes) { + is ApiState.Success -> postReportsRes = ApiState.Appending(oldRes.data) + else -> return@launch + } + + pagePostReports += 1 + val newRes = API.getInstance().listPostReports(getFormPostReports()).toApiState() + + postReportsRes = + when (newRes) { + is ApiState.Success -> { + val mergedReplies = + getDeduplicateMerge( + oldRes.data.post_reports, + newRes.data.post_reports, + ) { it.post_report.id } + + ApiState.Success(oldRes.data.copy(post_reports = mergedReplies)) + } + + else -> { + pagePostReports -= 1 + oldRes + } + } + } + } + + fun listCommentReports( + form: ListCommentReports, + state: ApiState = ApiState.Loading, + ) { + viewModelScope.launch { + commentReportsRes = state + commentReportsRes = API.getInstance().listCommentReports(form).toApiState() + } + } + + fun appendCommentReports() { + viewModelScope.launch { + val oldRes = commentReportsRes + when (oldRes) { + is ApiState.Success -> commentReportsRes = ApiState.Appending(oldRes.data) + else -> return@launch + } + + pageCommentReports += 1 + val newRes = API.getInstance().listCommentReports(getFormCommentReports()).toApiState() + + commentReportsRes = + when (newRes) { + is ApiState.Success -> { + val mergedReplies = + getDeduplicateMerge( + oldRes.data.comment_reports, + newRes.data.comment_reports, + ) { it.comment_report.id } + + ApiState.Success(oldRes.data.copy(comment_reports = mergedReplies)) + } + + else -> { + pageCommentReports -= 1 + oldRes + } + } + } + } + + fun listMessageReports( + form: ListPrivateMessageReports, + state: ApiState = ApiState.Loading, + ) { + viewModelScope.launch { + messageReportsRes = state + messageReportsRes = API.getInstance().listPrivateMessageReports(form).toApiState() + } + } + + fun appendMessageReports() { + viewModelScope.launch { + val oldRes = messageReportsRes + when (oldRes) { + is ApiState.Success -> messageReportsRes = ApiState.Appending(oldRes.data) + else -> return@launch + } + + pageMessageReports += 1 + val newRes = API.getInstance().listPrivateMessageReports(getFormMessageReports()).toApiState() + + messageReportsRes = + when (newRes) { + is ApiState.Success -> { + val mergedReplies = + getDeduplicateMerge( + oldRes.data.private_message_reports, + newRes.data.private_message_reports, + ) { it.private_message_report.id } + + ApiState.Success(oldRes.data.copy(private_message_reports = mergedReplies)) + } + + else -> { + pageMessageReports -= 1 + oldRes + } + } + } + } + + fun resolvePostReport(form: ResolvePostReport) { + viewModelScope.launch { + resolvePostReportRes = ApiState.Loading + resolvePostReportRes = API.getInstance().resolvePostReport(form).toApiState() + + when (val resolveRes = resolvePostReportRes) { + is ApiState.Success -> { + when (val existing = postReportsRes) { + is ApiState.Success -> { + val newReports = + findAndUpdatePostReport( + existing.data.post_reports, + resolveRes.data.post_report_view, + ) + val newRes = ApiState.Success(existing.data.copy(post_reports = newReports)) + postReportsRes = newRes + } + + else -> {} + } + } + + else -> {} + } + } + } + + fun resolveCommentReport(form: ResolveCommentReport) { + viewModelScope.launch { + resolveCommentReportRes = ApiState.Loading + resolveCommentReportRes = API.getInstance().resolveCommentReport(form).toApiState() + + when (val resolveRes = resolveCommentReportRes) { + is ApiState.Success -> { + when (val existing = commentReportsRes) { + is ApiState.Success -> { + val newReports = + findAndUpdateCommentReport( + existing.data.comment_reports, + resolveRes.data.comment_report_view, + ) + val newRes = ApiState.Success(existing.data.copy(comment_reports = newReports)) + commentReportsRes = newRes + } + + else -> {} + } + } + + else -> {} + } + } + } + + fun resolveMessageReport(form: ResolvePrivateMessageReport) { + viewModelScope.launch { + resolveMessageReportRes = ApiState.Loading + resolveMessageReportRes = API.getInstance().resolvePrivateMessageReport(form).toApiState() + + when (val resolveRes = resolveMessageReportRes) { + is ApiState.Success -> { + when (val existing = messageReportsRes) { + is ApiState.Success -> { + val newReports = + findAndUpdatePrivateMessageReport( + existing.data.private_message_reports, + resolveRes.data.private_message_report_view, + ) + val newRes = ApiState.Success(existing.data.copy(private_message_reports = newReports)) + messageReportsRes = newRes + } + + else -> {} + } + } + + else -> {} + } + } + } + + init { + if (!account.isAnon()) { + this.resetPages() + this.listPostReports( + this.getFormPostReports(), + ) + this.listCommentReports( + this.getFormCommentReports(), + ) + this.listMessageReports( + this.getFormMessageReports(), + ) + siteViewModel.fetchUnreadReportCount() + } + } + + companion object { + class Factory( + private val account: Account, + private val siteViewModel: SiteViewModel, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: Class, + extras: CreationExtras, + ): T { + return ReportsViewModel(account, siteViewModel) as T + } + } + } +} diff --git a/app/src/main/java/com/jerboa/model/SiteViewModel.kt b/app/src/main/java/com/jerboa/model/SiteViewModel.kt index 57184e75c..b8ed8cbcd 100644 --- a/app/src/main/java/com/jerboa/model/SiteViewModel.kt +++ b/app/src/main/java/com/jerboa/model/SiteViewModel.kt @@ -20,6 +20,8 @@ import com.jerboa.db.entity.isAnon import com.jerboa.db.repository.AccountRepository import com.jerboa.jerboaApplication import it.vercruysse.lemmyapi.v0x19.datatypes.CommunityFollowerView +import it.vercruysse.lemmyapi.v0x19.datatypes.GetReportCount +import it.vercruysse.lemmyapi.v0x19.datatypes.GetReportCountResponse import it.vercruysse.lemmyapi.v0x19.datatypes.GetSiteResponse import it.vercruysse.lemmyapi.v0x19.datatypes.GetUnreadCountResponse import it.vercruysse.lemmyapi.v0x19.datatypes.GetUnreadRegistrationApplicationCountResponse @@ -36,9 +38,11 @@ class SiteViewModel(private val accountRepository: AccountRepository) : ViewMode private var unreadCountRes: ApiState by mutableStateOf(ApiState.Empty) private var unreadAppCountRes: ApiState by mutableStateOf(ApiState.Empty) + private var unreadReportCountRes: ApiState by mutableStateOf(ApiState.Empty) val unreadCount by derivedStateOf { getUnreadCountTotal() } val unreadAppCount by derivedStateOf { getUnreadAppCountTotal() } + val unreadReportCount by derivedStateOf { getUnreadReportCountTotal() } lateinit var saveUserSettings: SaveUserSettings @@ -64,6 +68,7 @@ class SiteViewModel(private val accountRepository: AccountRepository) : ViewMode if (it.isAnon()) { // Reset the unread counts if we're anonymous unreadCountRes = ApiState.Empty unreadAppCountRes = ApiState.Empty + unreadReportCountRes = ApiState.Empty } else { fetchUnreadCounts() @@ -71,6 +76,11 @@ class SiteViewModel(private val accountRepository: AccountRepository) : ViewMode if (it.isAdmin) { fetchUnreadAppCount() } + + // if you're an admin or a mod, fetch the report counts + if (it.isAdmin || it.isMod) { + fetchUnreadReportCount() + } } } } @@ -125,6 +135,15 @@ class SiteViewModel(private val accountRepository: AccountRepository) : ViewMode } } + fun fetchUnreadReportCount() { + viewModelScope.launch { + viewModelScope.launch { + unreadReportCountRes = ApiState.Loading + unreadReportCountRes = API.getInstance().getReportCount(GetReportCount()).toApiState() + } + } + } + private fun getUnreadCountTotal(): Long { return when (val res = unreadCountRes) { is ApiState.Success -> { @@ -146,6 +165,17 @@ class SiteViewModel(private val accountRepository: AccountRepository) : ViewMode } } + private fun getUnreadReportCountTotal(): Long? { + return when (val res = unreadReportCountRes) { + is ApiState.Success -> { + val unreads = res.data + unreads.post_reports + unreads.comment_reports + (unreads.private_message_reports ?: 0) + } + + else -> null + } + } + fun updateUnreadCounts( dReplies: Int = 0, dMentions: Int = 0, diff --git a/app/src/main/java/com/jerboa/ui/components/common/AppBars.kt b/app/src/main/java/com/jerboa/ui/components/common/AppBars.kt index 864c0181c..7a5b5ab0e 100644 --- a/app/src/main/java/com/jerboa/ui/components/common/AppBars.kt +++ b/app/src/main/java/com/jerboa/ui/components/common/AppBars.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.dp import com.jerboa.R +import com.jerboa.datatypes.UserViewType import com.jerboa.datatypes.samplePerson import com.jerboa.datatypes.samplePost import com.jerboa.db.entity.Account @@ -104,9 +105,10 @@ fun SimpleTopAppBarPreview() { fun BottomAppBarAll( selectedTab: NavTab, onSelect: (NavTab) -> Unit, - amAdmin: Boolean, + userViewType: UserViewType, unreadCounts: Long, unreadAppCount: Long?, + unreadReportCount: Long?, showTextDescriptionsInNavbar: Boolean, ) { // Check for preview mode @@ -128,14 +130,19 @@ fun BottomAppBarAll( NavigationBar( modifier = modifier, ) { - // Hide non-admin tabs - val tabs = if (amAdmin) NavTab.entries else NavTab.entries.filter { !it.adminOnly } + // Hide tabs according to permissions + val tabs = when (userViewType) { + UserViewType.Normal -> NavTab.entries.filter { it.userViewType == UserViewType.Normal } + UserViewType.AdminOrMod -> NavTab.entries.filter { it.userViewType != UserViewType.AdminOnly } + UserViewType.AdminOnly -> NavTab.entries + } for (tab in tabs) { val selected = tab == selectedTab val iconBadgeCount = when (tab) { NavTab.Inbox -> unreadCounts NavTab.RegistrationApplications -> unreadAppCount + NavTab.Reports -> unreadReportCount else -> null } @@ -179,7 +186,8 @@ fun BottomAppBarAllPreview() { onSelect = {}, unreadCounts = 30, unreadAppCount = 2, - amAdmin = true, + unreadReportCount = 8, + userViewType = UserViewType.AdminOnly, showTextDescriptionsInNavbar = true, ) } @@ -192,7 +200,8 @@ fun BottomAppBarAllNoDescriptionsPreview() { onSelect = {}, unreadCounts = 30, unreadAppCount = null, - amAdmin = false, + unreadReportCount = null, + userViewType = UserViewType.Normal, showTextDescriptionsInNavbar = false, ) } diff --git a/app/src/main/java/com/jerboa/ui/components/common/Route.kt b/app/src/main/java/com/jerboa/ui/components/common/Route.kt index 3690f64f9..450139d7b 100644 --- a/app/src/main/java/com/jerboa/ui/components/common/Route.kt +++ b/app/src/main/java/com/jerboa/ui/components/common/Route.kt @@ -55,6 +55,7 @@ object Route { const val CRASH_LOGS = "crashLogs" const val REGISTRATION_APPLICATIONS = "registrationApplications" + const val REPORTS = "reports" val VIEW = ViewArgs.route diff --git a/app/src/main/java/com/jerboa/ui/components/drawer/Drawer.kt b/app/src/main/java/com/jerboa/ui/components/drawer/Drawer.kt index bfabd0bc7..dc19f6468 100644 --- a/app/src/main/java/com/jerboa/ui/components/drawer/Drawer.kt +++ b/app/src/main/java/com/jerboa/ui/components/drawer/Drawer.kt @@ -78,6 +78,7 @@ fun Drawer( follows: List, unreadCount: Long, unreadAppCount: Long?, + unreadReportCount: Long?, accountViewModel: AccountViewModel, onAddAccount: () -> Unit, onSwitchAccountClick: (account: Account) -> Unit, @@ -109,6 +110,7 @@ fun Drawer( follows = follows, unreadCount = unreadCount, unreadAppCount = unreadAppCount, + unreadReportCount = unreadReportCount, myUserInfo = myUserInfo, showAccountAddMode = showAccountAddMode, onAddAccount = onAddAccount, @@ -139,6 +141,7 @@ fun DrawerContent( myUserInfo: MyUserInfo?, unreadCount: Long, unreadAppCount: Long?, + unreadReportCount: Long?, blurNSFW: BlurNSFW, showBottomNav: Boolean, closeDrawer: () -> Unit, @@ -168,6 +171,7 @@ fun DrawerContent( onCommunityClick = onCommunityClick, unreadCount = unreadCount, unreadAppCount = unreadAppCount, + unreadReportCount = unreadReportCount, onClickSettings = onClickSettings, blurNSFW = blurNSFW, showBottomNav = showBottomNav, @@ -185,6 +189,7 @@ fun DrawerItemsMain( onCommunityClick: (community: Community) -> Unit, unreadCount: Long, unreadAppCount: Long?, + unreadReportCount: Long?, blurNSFW: BlurNSFW, showBottomNav: Boolean, closeDrawer: () -> Unit, @@ -244,6 +249,7 @@ fun DrawerItemsMain( iconBadgeCount = when (it) { NavTab.Inbox -> unreadCount NavTab.RegistrationApplications -> unreadAppCount + NavTab.Reports -> unreadReportCount else -> null }, contentDescription = stringResource(id = it.contentDescriptionId), @@ -302,6 +308,7 @@ fun DrawerItemsMainPreview() { onCommunityClick = {}, onClickSettings = {}, unreadCount = 2, + unreadReportCount = 5, unreadAppCount = null, blurNSFW = BlurNSFW.NSFW, showBottomNav = false, diff --git a/app/src/main/java/com/jerboa/ui/components/drawer/DrawerActivity.kt b/app/src/main/java/com/jerboa/ui/components/drawer/DrawerActivity.kt index e5dd3f1e5..62741509d 100644 --- a/app/src/main/java/com/jerboa/ui/components/drawer/DrawerActivity.kt +++ b/app/src/main/java/com/jerboa/ui/components/drawer/DrawerActivity.kt @@ -67,6 +67,7 @@ fun MainDrawer( follows = follows.toList(), unreadCount = siteViewModel.unreadCount, unreadAppCount = siteViewModel.unreadAppCount, + unreadReportCount = siteViewModel.unreadReportCount, accountViewModel = accountViewModel, onAddAccount = onClickLogin, isOpen = drawerState.isOpen, diff --git a/app/src/main/java/com/jerboa/ui/components/home/BottomNavActivity.kt b/app/src/main/java/com/jerboa/ui/components/home/BottomNavActivity.kt index d69337a87..55684a7dc 100644 --- a/app/src/main/java/com/jerboa/ui/components/home/BottomNavActivity.kt +++ b/app/src/main/java/com/jerboa/ui/components/home/BottomNavActivity.kt @@ -8,12 +8,14 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AppRegistration import androidx.compose.material.icons.filled.Bookmarks import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Flag import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.AppRegistration import androidx.compose.material.icons.outlined.Bookmarks import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Flag import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Search @@ -45,8 +47,10 @@ import androidx.navigation.compose.rememberNavController import arrow.core.Either import com.jerboa.JerboaAppState import com.jerboa.R +import com.jerboa.datatypes.UserViewType import com.jerboa.db.entity.AnonAccount import com.jerboa.db.entity.AppSettings +import com.jerboa.db.entity.userViewType import com.jerboa.feat.doIfReadyElseDisplayInfo import com.jerboa.model.AccountViewModel import com.jerboa.model.AppSettingsViewModel @@ -61,6 +65,7 @@ import com.jerboa.ui.components.drawer.MainDrawer import com.jerboa.ui.components.inbox.InboxActivity import com.jerboa.ui.components.person.PersonProfileActivity import com.jerboa.ui.components.registrationapplications.RegistrationApplicationsActivity +import com.jerboa.ui.components.reports.ReportsActivity import kotlinx.coroutines.launch enum class NavTab( @@ -68,53 +73,65 @@ enum class NavTab( val iconOutlined: ImageVector, val iconFilled: ImageVector, val contentDescriptionId: Int, - val adminOnly: Boolean, + val userViewType: UserViewType, + val needsLogin: Boolean, ) { Home( textId = R.string.bottomBar_label_home, iconOutlined = Icons.Outlined.Home, iconFilled = Icons.Filled.Home, contentDescriptionId = R.string.bottomBar_home, - adminOnly = false, + userViewType = UserViewType.Normal, + needsLogin = false, ), Search( textId = R.string.bottomBar_label_search, iconOutlined = Icons.Outlined.Search, iconFilled = Icons.Filled.Search, contentDescriptionId = R.string.bottomBar_search, - adminOnly = false, + userViewType = UserViewType.Normal, + needsLogin = false, ), Inbox( textId = R.string.bottomBar_label_inbox, iconOutlined = Icons.Outlined.Email, iconFilled = Icons.Filled.Email, contentDescriptionId = R.string.bottomBar_inbox, - adminOnly = false, + userViewType = UserViewType.Normal, + needsLogin = true, ), RegistrationApplications( R.string.applications_request_shorthand, Icons.Outlined.AppRegistration, Icons.Filled.AppRegistration, R.string.bottomBar_registrations, - adminOnly = true, + userViewType = UserViewType.AdminOnly, + needsLogin = true, + ), + Reports( + R.string.reports, + Icons.Outlined.Flag, + Icons.Filled.Flag, + R.string.bottomBar_reports, + userViewType = UserViewType.AdminOrMod, + needsLogin = true, ), Saved( textId = R.string.bottomBar_label_bookmarks, iconOutlined = Icons.Outlined.Bookmarks, iconFilled = Icons.Filled.Bookmarks, contentDescriptionId = R.string.bottomBar_bookmarks, - adminOnly = false, + userViewType = UserViewType.Normal, + needsLogin = true, ), Profile( textId = R.string.bottomBar_label_profile, iconOutlined = Icons.Outlined.Person, iconFilled = Icons.Filled.Person, contentDescriptionId = R.string.bottomBar_profile, - adminOnly = false, + userViewType = UserViewType.Normal, + needsLogin = true, ), - ; - - fun needsLogin() = this == Inbox || this == Saved || this == Profile || this == RegistrationApplications } @OptIn( @@ -158,7 +175,7 @@ fun BottomNavActivity( } val onSelectTab: (NavTab) -> Unit = { tab: NavTab -> - if (tab.needsLogin()) { + if (tab.needsLogin) { account.doIfReadyElseDisplayInfo( appState, ctx, @@ -207,8 +224,9 @@ fun BottomNavActivity( selectedTab = selectedTab, unreadCounts = siteViewModel.unreadCount, unreadAppCount = siteViewModel.unreadAppCount, + unreadReportCount = siteViewModel.unreadReportCount, showTextDescriptionsInNavbar = appSettings.showTextDescriptionsInNavbar, - amAdmin = account.isAdmin, + userViewType = account.userViewType(), onSelect = onSelectTab, ) } @@ -275,6 +293,25 @@ fun BottomNavActivity( ) } + composable(route = NavTab.RegistrationApplications.name) { + RegistrationApplicationsActivity( + appState = appState, + accountViewModel = accountViewModel, + siteViewModel = siteViewModel, + drawerState = drawerState, + ) + } + + composable(route = NavTab.Reports.name) { + ReportsActivity( + appState = appState, + accountViewModel = accountViewModel, + siteViewModel = siteViewModel, + drawerState = drawerState, + blurNSFW = appSettings.blurNSFW.toEnum(), + ) + } + composable(route = NavTab.Saved.name) { PersonProfileActivity( personArg = Either.Left(account.id), diff --git a/app/src/main/java/com/jerboa/ui/components/inbox/Inbox.kt b/app/src/main/java/com/jerboa/ui/components/inbox/Inbox.kt index 9fc4f63ea..684671c8a 100644 --- a/app/src/main/java/com/jerboa/ui/components/inbox/Inbox.kt +++ b/app/src/main/java/com/jerboa/ui/components/inbox/Inbox.kt @@ -1,5 +1,3 @@ -@file:OptIn(ExperimentalMaterial3Api::class) - package com.jerboa.ui.components.inbox import androidx.compose.foundation.layout.Box diff --git a/app/src/main/java/com/jerboa/ui/components/reports/CommentReportItem.kt b/app/src/main/java/com/jerboa/ui/components/reports/CommentReportItem.kt new file mode 100644 index 000000000..25873d90f --- /dev/null +++ b/app/src/main/java/com/jerboa/ui/components/reports/CommentReportItem.kt @@ -0,0 +1,121 @@ +package com.jerboa.ui.components.reports + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.jerboa.datatypes.sampleCommentReportView +import com.jerboa.ui.components.comment.CommentBody +import com.jerboa.ui.components.comment.CommentNodeHeader +import com.jerboa.ui.theme.MEDIUM_PADDING +import com.jerboa.ui.theme.SMALL_PADDING +import it.vercruysse.lemmyapi.dto.SubscribedType +import it.vercruysse.lemmyapi.v0x19.datatypes.CommentId +import it.vercruysse.lemmyapi.v0x19.datatypes.CommentReportView +import it.vercruysse.lemmyapi.v0x19.datatypes.CommentView +import it.vercruysse.lemmyapi.v0x19.datatypes.PersonId +import it.vercruysse.lemmyapi.v0x19.datatypes.ResolveCommentReport + +@Composable +fun CommentReportItem( + commentReportView: CommentReportView, + onResolveClick: (ResolveCommentReport) -> Unit, + onPersonClick: (PersonId) -> Unit, + onCommentClick: (CommentId) -> Unit, + showAvatar: Boolean, + showScores: Boolean, +) { + // Build a comment-view using the content at the time it was reported, + // not the current state. + val origComment = commentReportView.comment.copy( + content = commentReportView.comment_report.original_comment_text, + published = commentReportView.comment_report.published, + ) + + val commentView = CommentView( + comment = origComment, + post = commentReportView.post, + creator = commentReportView.comment_creator, + creator_banned_from_community = commentReportView.creator_banned_from_community, + my_vote = commentReportView.my_vote, + subscribed = SubscribedType.NotSubscribed, + community = commentReportView.community, + counts = commentReportView.counts, + creator_blocked = false, + creator_is_admin = false, + creator_is_moderator = false, + saved = false, + ) + + Column( + modifier = + Modifier.padding( + vertical = MEDIUM_PADDING, + horizontal = MEDIUM_PADDING, + ), + verticalArrangement = Arrangement.Absolute.spacedBy(MEDIUM_PADDING), + ) { + // Don't use the full CommentNode, as you don't need any of the actions there + CommentNodeHeader( + commentView = commentView, + myVote = commentView.my_vote, + score = commentView.counts.score, + onPersonClick = onPersonClick, + showAvatar = showAvatar, + showScores = showScores, + collapsedCommentsCount = 0, + isExpanded = true, + onClick = { onCommentClick(commentView.comment.id) }, + onLongClick = {}, + ) + + CommentBody( + comment = commentView.comment, + viewSource = false, + onClick = { onCommentClick(commentView.comment.id) }, + onLongClick = { false }, + ) + + ReportCreatorBlock(commentReportView.creator, onPersonClick, showAvatar) + + ReportReasonBlock(commentReportView.comment_report.reason) + + commentReportView.resolver?.let { resolver -> + ReportResolverBlock( + resolver = resolver, + resolved = commentReportView.comment_report.resolved, + onPersonClick = onPersonClick, + showAvatar = showAvatar, + ) + } + + ResolveButtonBlock( + resolved = commentReportView.comment_report.resolved, + onResolveClick = { + onResolveClick( + ResolveCommentReport( + report_id = commentReportView.comment_report.id, + resolved = !commentReportView.comment_report.resolved, + ), + ) + }, + ) + } + HorizontalDivider(modifier = Modifier.padding(bottom = SMALL_PADDING)) +} + +@Preview +@Composable +fun CommentReportItemPreview() { + CommentReportItem( + commentReportView = sampleCommentReportView, + onPersonClick = {}, + onResolveClick = {}, + onCommentClick = {}, + showAvatar = false, + showScores = true, + ) +} diff --git a/app/src/main/java/com/jerboa/ui/components/reports/MessageReportItem.kt b/app/src/main/java/com/jerboa/ui/components/reports/MessageReportItem.kt new file mode 100644 index 000000000..b364bcf19 --- /dev/null +++ b/app/src/main/java/com/jerboa/ui/components/reports/MessageReportItem.kt @@ -0,0 +1,102 @@ +package com.jerboa.ui.components.reports +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.jerboa.R +import com.jerboa.datatypes.samplePrivateMessageReportView +import com.jerboa.ui.components.common.MyMarkdownText +import com.jerboa.ui.components.common.TimeAgo +import com.jerboa.ui.theme.MEDIUM_PADDING +import com.jerboa.ui.theme.SMALL_PADDING +import it.vercruysse.lemmyapi.v0x19.datatypes.PersonId +import it.vercruysse.lemmyapi.v0x19.datatypes.PrivateMessageReportView +import it.vercruysse.lemmyapi.v0x19.datatypes.ResolvePrivateMessageReport + +@Composable +fun MessageReportItem( + messageReportView: PrivateMessageReportView, + onResolveClick: (ResolvePrivateMessageReport) -> Unit, + onPersonClick: (PersonId) -> Unit, + showAvatar: Boolean, +) { + Column( + modifier = + Modifier.padding( + vertical = MEDIUM_PADDING, + horizontal = MEDIUM_PADDING, + ), + verticalArrangement = Arrangement.Absolute.spacedBy(MEDIUM_PADDING), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + ReportCreatorBlock(messageReportView.creator, onPersonClick, showAvatar) + TimeAgo( + published = messageReportView.private_message_report.published, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Absolute.spacedBy(MEDIUM_PADDING), + ) { + Text( + text = stringResource(R.string.message) + ": ", + style = MaterialTheme.typography.labelLarge, + ) + + MyMarkdownText( + markdown = messageReportView.private_message_report.original_pm_text, + onClick = {}, + ) + } + + ReportReasonBlock(messageReportView.private_message_report.reason) + + messageReportView.resolver?.let { resolver -> + ReportResolverBlock( + resolver = resolver, + resolved = messageReportView.private_message_report.resolved, + onPersonClick = onPersonClick, + showAvatar = showAvatar, + ) + } + + ResolveButtonBlock( + resolved = messageReportView.private_message_report.resolved, + onResolveClick = { + onResolveClick( + ResolvePrivateMessageReport( + report_id = messageReportView.private_message_report.id, + resolved = !messageReportView.private_message_report.resolved, + ), + ) + }, + ) + } + HorizontalDivider(modifier = Modifier.padding(bottom = SMALL_PADDING)) +} + +@Preview +@Composable +fun MessageReportItemPreview() { + MessageReportItem( + messageReportView = samplePrivateMessageReportView, + onPersonClick = {}, + onResolveClick = {}, + showAvatar = false, + ) +} diff --git a/app/src/main/java/com/jerboa/ui/components/reports/PostReportItem.kt b/app/src/main/java/com/jerboa/ui/components/reports/PostReportItem.kt new file mode 100644 index 000000000..5464a1f4d --- /dev/null +++ b/app/src/main/java/com/jerboa/ui/components/reports/PostReportItem.kt @@ -0,0 +1,156 @@ +package com.jerboa.ui.components.reports + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.jerboa.JerboaAppState +import com.jerboa.datatypes.samplePostReportView +import com.jerboa.db.entity.Account +import com.jerboa.db.entity.AnonAccount +import com.jerboa.feat.BlurNSFW +import com.jerboa.rememberJerboaAppState +import com.jerboa.ui.components.post.PostBody +import com.jerboa.ui.components.post.PostHeaderLine +import com.jerboa.ui.theme.MEDIUM_PADDING +import com.jerboa.ui.theme.SMALL_PADDING +import it.vercruysse.lemmyapi.dto.SubscribedType +import it.vercruysse.lemmyapi.v0x19.datatypes.Community +import it.vercruysse.lemmyapi.v0x19.datatypes.PersonId +import it.vercruysse.lemmyapi.v0x19.datatypes.PostReportView +import it.vercruysse.lemmyapi.v0x19.datatypes.PostView +import it.vercruysse.lemmyapi.v0x19.datatypes.ResolvePostReport + +@Composable +fun PostReportItem( + appState: JerboaAppState, + postReportView: PostReportView, + onResolveClick: (ResolvePostReport) -> Unit, + onPersonClick: (PersonId) -> Unit, + onPostClick: (PostView) -> Unit, + onCommunityClick: (Community) -> Unit, + showAvatar: Boolean, + blurNSFW: BlurNSFW, + showScores: Boolean, + account: Account, +) { + // Build a post-view using the content at the time it was reported, + // not the current state. + val origPost = postReportView.post.copy( + name = postReportView.post_report.original_post_name, + url = postReportView.post_report.original_post_url, + body = postReportView.post_report.original_post_body, + published = postReportView.post_report.published, + ) + + val postView = PostView( + post = origPost, + creator = postReportView.post_creator, + creator_banned_from_community = postReportView.creator_banned_from_community, + subscribed = SubscribedType.NotSubscribed, + community = postReportView.community, + my_vote = postReportView.my_vote, + counts = postReportView.counts, + creator_blocked = false, + creator_is_admin = false, + creator_is_moderator = false, + read = false, + saved = false, + unread_comments = 0, + ) + + Column( + modifier = + Modifier.padding( + vertical = MEDIUM_PADDING, + horizontal = MEDIUM_PADDING, + ), + verticalArrangement = Arrangement.Absolute.spacedBy(MEDIUM_PADDING), + ) { + // These are taken from Post.Card . Don't use the full PostListing, as you don't + // need any of the actions there + + // Need to make this clickable + Box( + modifier = Modifier + .clickable { onPostClick(postView) }, + ) { + PostHeaderLine( + postView = postView, + myVote = postView.my_vote, + score = postView.counts.score, + onCommunityClick = onCommunityClick, + onPersonClick = onPersonClick, + showCommunityName = true, + showAvatar = showAvatar, + blurNSFW = blurNSFW, + showScores = showScores, + fullBody = false, + ) + } + + // Title + metadata + PostBody( + postView = postView, + fullBody = false, + viewSource = false, + expandedImage = false, + account = account, + useCustomTabs = false, + usePrivateTabs = false, + blurNSFW = blurNSFW, + showPostLinkPreview = true, + appState = appState, + clickBody = { onPostClick(postView) }, + showIfRead = true, + ) + + ReportCreatorBlock(postReportView.creator, onPersonClick, showAvatar) + + ReportReasonBlock(postReportView.post_report.reason) + + postReportView.resolver?.let { resolver -> + ReportResolverBlock( + resolver = resolver, + resolved = postReportView.post_report.resolved, + onPersonClick = onPersonClick, + showAvatar = showAvatar, + ) + } + + ResolveButtonBlock( + resolved = postReportView.post_report.resolved, + onResolveClick = { + onResolveClick( + ResolvePostReport( + report_id = postReportView.post_report.id, + resolved = !postReportView.post_report.resolved, + ), + ) + }, + ) + } + HorizontalDivider(modifier = Modifier.padding(bottom = SMALL_PADDING)) +} + +@Preview +@Composable +fun PostReportItemPreview() { + PostReportItem( + postReportView = samplePostReportView, + onPersonClick = {}, + onPostClick = {}, + onCommunityClick = {}, + onResolveClick = {}, + showAvatar = false, + blurNSFW = BlurNSFW.NSFW, + showScores = true, + account = AnonAccount, + appState = rememberJerboaAppState(), + ) +} diff --git a/app/src/main/java/com/jerboa/ui/components/reports/Reports.kt b/app/src/main/java/com/jerboa/ui/components/reports/Reports.kt new file mode 100644 index 000000000..177e7f9fe --- /dev/null +++ b/app/src/main/java/com/jerboa/ui/components/reports/Reports.kt @@ -0,0 +1,218 @@ +package com.jerboa.ui.components.reports + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.jerboa.R +import com.jerboa.UnreadOrAll +import com.jerboa.datatypes.getLocalizedUnreadOrAllName +import com.jerboa.ui.components.common.MyMarkdownText +import com.jerboa.ui.components.common.UnreadOrAllOptionsDropDown +import com.jerboa.ui.components.person.PersonProfileLink +import com.jerboa.ui.theme.MEDIUM_PADDING +import it.vercruysse.lemmyapi.v0x19.datatypes.Person +import it.vercruysse.lemmyapi.v0x19.datatypes.PersonId + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReportsHeader( + openDrawer: () -> Unit, + selectedUnreadOrAll: UnreadOrAll, + onClickUnreadOrAll: (UnreadOrAll) -> Unit, + unreadCount: Long? = null, + scrollBehavior: TopAppBarScrollBehavior, +) { + var showUnreadOrAllOptions by remember { mutableStateOf(false) } + + TopAppBar( + scrollBehavior = scrollBehavior, + title = { + ReportsHeaderTitle( + unreadCount = unreadCount, + selectedUnreadOrAll = selectedUnreadOrAll, + ) + }, + navigationIcon = { + IconButton(onClick = openDrawer) { + Icon( + Icons.Outlined.Menu, + contentDescription = stringResource(R.string.home_menu), + ) + } + }, + actions = { + Box { + IconButton(onClick = { + showUnreadOrAllOptions = !showUnreadOrAllOptions + }) { + Icon( + Icons.Outlined.FilterList, + contentDescription = stringResource(R.string.inbox_filter), + ) + } + + UnreadOrAllOptionsDropDown( + expanded = showUnreadOrAllOptions, + selectedUnreadOrAll = selectedUnreadOrAll, + onDismissRequest = { showUnreadOrAllOptions = false }, + onClickUnreadOrAll = { + showUnreadOrAllOptions = false + onClickUnreadOrAll(it) + }, + ) + } + }, + ) +} + +@Composable +fun ReportsHeaderTitle( + selectedUnreadOrAll: UnreadOrAll, + unreadCount: Long? = null, +) { + var title = stringResource(R.string.reports) + val ctx = LocalContext.current + if (unreadCount != null && unreadCount > 0) { + title = "$title ($unreadCount)" + } + Column { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = getLocalizedUnreadOrAllName(ctx, selectedUnreadOrAll), + style = MaterialTheme.typography.titleMedium, + ) + } +} + +@Composable +fun ReportCreatorBlock( + reportCreator: Person, + onPersonClick: (PersonId) -> Unit, + showAvatar: Boolean, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Absolute.spacedBy(MEDIUM_PADDING), + ) { + Text( + text = stringResource(R.string.reporter) + ": ", + style = MaterialTheme.typography.labelLarge, + ) + PersonProfileLink( + person = reportCreator, + onClick = onPersonClick, + showAvatar = showAvatar, + ) + } +} + +@Composable +fun ReportReasonBlock(reason: String) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Absolute.spacedBy(MEDIUM_PADDING), + ) { + Text( + text = stringResource(R.string.reason) + ": ", + style = MaterialTheme.typography.labelLarge, + ) + + MyMarkdownText( + markdown = reason, + onClick = {}, + ) + } +} + +@Composable +fun ReportResolverBlock( + resolver: Person, + resolved: Boolean, + onPersonClick: (PersonId) -> Unit, + showAvatar: Boolean, +) { + val resolvedStr = stringResource(if (resolved) R.string.resolved_by else R.string.unresolved_by) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "$resolvedStr: ", + style = MaterialTheme.typography.labelLarge, + ) + PersonProfileLink( + person = resolver, + onClick = onPersonClick, + showAvatar = showAvatar, + ) + } +} + +@Composable +fun ResolveButtonBlock( + resolved: Boolean, + onResolveClick: () -> Unit, +) { + TextButton( + onClick = onResolveClick, + colors = ButtonDefaults.textButtonColors( + contentColor = if (resolved) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + ), + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + ) + } +} + +@Preview +@Composable +fun ResolveButtonPreview() { + ResolveButtonBlock( + resolved = false, + onResolveClick = {}, + ) +} + +@Preview +@Composable +fun UnResolveButtonPreview() { + ResolveButtonBlock( + resolved = true, + onResolveClick = {}, + ) +} diff --git a/app/src/main/java/com/jerboa/ui/components/reports/ReportsActivity.kt b/app/src/main/java/com/jerboa/ui/components/reports/ReportsActivity.kt new file mode 100644 index 000000000..644335c2f --- /dev/null +++ b/app/src/main/java/com/jerboa/ui/components/reports/ReportsActivity.kt @@ -0,0 +1,527 @@ +package com.jerboa.ui.components.reports + +import android.content.Context +import android.util.Log +import androidx.annotation.StringRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.DrawerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.zIndex +import androidx.lifecycle.viewmodel.compose.viewModel +import com.jerboa.JerboaAppState +import com.jerboa.R +import com.jerboa.UnreadOrAll +import com.jerboa.api.ApiState +import com.jerboa.db.entity.Account +import com.jerboa.feat.BlurNSFW +import com.jerboa.feat.doIfReadyElseDisplayInfo +import com.jerboa.isScrolledToEnd +import com.jerboa.model.AccountViewModel +import com.jerboa.model.ReportsViewModel +import com.jerboa.model.SiteViewModel +import com.jerboa.ui.components.common.ApiEmptyText +import com.jerboa.ui.components.common.ApiErrorText +import com.jerboa.ui.components.common.JerboaPullRefreshIndicator +import com.jerboa.ui.components.common.JerboaSnackbarHost +import com.jerboa.ui.components.common.LoadingBar +import com.jerboa.ui.components.common.getCurrentAccount +import com.jerboa.ui.components.common.isLoading +import com.jerboa.ui.components.common.isRefreshing +import com.jerboa.ui.components.common.simpleVerticalScrollbar +import com.jerboa.unreadOrAllFromBool +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReportsActivity( + appState: JerboaAppState, + drawerState: DrawerState, + siteViewModel: SiteViewModel, + accountViewModel: AccountViewModel, + blurNSFW: BlurNSFW, +) { + Log.d("jerboa", "got to reports activity") + + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val ctx = LocalContext.current + val account = getCurrentAccount(accountViewModel) + + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) + + val reportsViewModel: ReportsViewModel = + viewModel(factory = ReportsViewModel.Companion.Factory(account, siteViewModel)) + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + snackbarHost = { JerboaSnackbarHost(snackbarHostState) }, + topBar = { + ReportsHeader( + scrollBehavior = scrollBehavior, + unreadCount = siteViewModel.unreadReportCount, + openDrawer = { + scope.launch { + drawerState.open() + } + }, + selectedUnreadOrAll = unreadOrAllFromBool(reportsViewModel.unresolvedOnly), + onClickUnreadOrAll = { unreadOrAll -> + account.doIfReadyElseDisplayInfo( + appState, + ctx, + snackbarHostState, + scope, + siteViewModel, + accountViewModel, + loginAsToast = true, + ) { + reportsViewModel.resetPages() + reportsViewModel.updateUnresolvedOnly(unreadOrAll == UnreadOrAll.Unread) + reportsViewModel.listPostReports( + reportsViewModel.getFormPostReports(), + ) + reportsViewModel.listCommentReports( + reportsViewModel.getFormCommentReports(), + ) + reportsViewModel.listMessageReports( + reportsViewModel.getFormMessageReports(), + ) + } + }, + ) + }, + content = { + ReportsTabs( + padding = it, + appState = appState, + reportsViewModel = reportsViewModel, + siteViewModel = siteViewModel, + ctx = ctx, + account = account, + scope = scope, + blurNSFW = blurNSFW, + snackbarHostState = snackbarHostState, + ) + }, + ) +} + +enum class ReportsTab( + @StringRes val textId: Int, +) { + Posts(R.string.person_profile_activity_posts), + Comments(R.string.post_activity_comments), + Messages(R.string.inbox_activity_messages), +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) +@Composable +fun ReportsTabs( + appState: JerboaAppState, + reportsViewModel: ReportsViewModel, + siteViewModel: SiteViewModel, + ctx: Context, + account: Account, + scope: CoroutineScope, + snackbarHostState: SnackbarHostState, + padding: PaddingValues, + blurNSFW: BlurNSFW, +) { + val pagerState = rememberPagerState { ReportsTab.entries.size } + + Column( + modifier = Modifier.padding(padding), + ) { + TabRow( + selectedTabIndex = pagerState.currentPage, + tabs = { + ReportsTab.entries.forEachIndexed { index, tab -> + Tab( + selected = pagerState.currentPage == index, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { Text(text = stringResource(id = tab.textId)) }, + ) + } + }, + ) + HorizontalPager( + state = pagerState, + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxSize(), + ) { tabIndex -> + when (tabIndex) { + ReportsTab.Posts.ordinal -> { + val listState = rememberLazyListState() + + // observer when reached end of list + val endOfListReached by remember { + derivedStateOf { + listState.isScrolledToEnd() + } + } + + // act when end of list reached + if (endOfListReached) { + LaunchedEffect(Unit) { + account.doIfReadyElseDisplayInfo( + appState, + ctx, + snackbarHostState, + scope, + siteViewModel, + ) { + reportsViewModel.appendPostReports() + } + } + } + + val refreshing = reportsViewModel.postReportsRes.isRefreshing() + + val refreshState = + rememberPullRefreshState( + refreshing = refreshing, + onRefresh = { + account.doIfReadyElseDisplayInfo( + appState, + ctx, + snackbarHostState, + scope, + siteViewModel, + ) { + reportsViewModel.resetPagePostReports() + reportsViewModel.listPostReports( + reportsViewModel.getFormPostReports(), + ApiState.Refreshing, + ) + siteViewModel.fetchUnreadReportCount() + } + }, + ) + + Box(modifier = Modifier.pullRefresh(refreshState)) { + JerboaPullRefreshIndicator( + refreshing, + refreshState, + Modifier + .align(Alignment.TopCenter) + .zIndex(100F), + ) + + if (reportsViewModel.postReportsRes.isLoading()) { + LoadingBar() + } + when (val reportsRes = reportsViewModel.postReportsRes) { + ApiState.Empty -> ApiEmptyText() + is ApiState.Failure -> ApiErrorText(reportsRes.msg) + is ApiState.Holder -> { + val reports = reportsRes.data.post_reports + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxSize() + .simpleVerticalScrollbar(listState), + ) { + items( + reports, + key = { report -> report.post_report.id }, + contentType = { "postReport" }, + ) { reportView -> + PostReportItem( + appState = appState, + postReportView = reportView, + account = account, + blurNSFW = blurNSFW, + showScores = siteViewModel.showScores(), + showAvatar = siteViewModel.showAvatar(), + onCommunityClick = { community -> + appState.toCommunity(id = community.id) + }, + onPersonClick = appState::toProfile, + onPostClick = { pv -> + appState.toPost(id = pv.post.id) + }, + onResolveClick = { form -> + account.doIfReadyElseDisplayInfo( + appState, + ctx, + snackbarHostState, + scope, + siteViewModel, + ) { + reportsViewModel.resolvePostReport( + form = form, + ) + } + }, + ) + } + } + } + + else -> {} + } + } + } + + ReportsTab.Comments.ordinal -> { + val listState = rememberLazyListState() + + // observer when reached end of list + val endOfListReached by remember { + derivedStateOf { + listState.isScrolledToEnd() + } + } + + // act when end of list reached + if (endOfListReached) { + LaunchedEffect(Unit) { + account.doIfReadyElseDisplayInfo( + appState, + ctx, + snackbarHostState, + scope, + siteViewModel, + ) { + reportsViewModel.appendCommentReports() + } + } + } + + val loading = reportsViewModel.commentReportsRes.isLoading() + + val refreshing = reportsViewModel.commentReportsRes.isRefreshing() + + val refreshState = + rememberPullRefreshState( + refreshing = refreshing, + onRefresh = { + account.doIfReadyElseDisplayInfo( + appState, + ctx, + snackbarHostState, + scope, + siteViewModel, + ) { + reportsViewModel.resetPageCommentReports() + reportsViewModel.listCommentReports( + reportsViewModel.getFormCommentReports(), + ApiState.Refreshing, + ) + siteViewModel.fetchUnreadReportCount() + } + }, + ) + Box( + modifier = + Modifier + .pullRefresh(refreshState) + .fillMaxSize(), + ) { + JerboaPullRefreshIndicator( + refreshing, + refreshState, + Modifier + .align(Alignment.TopCenter) + .zIndex(100F), + ) + if (loading) { + LoadingBar() + } + + when (val reportsRes = reportsViewModel.commentReportsRes) { + ApiState.Empty -> ApiEmptyText() + is ApiState.Failure -> ApiErrorText(reportsRes.msg) + is ApiState.Holder -> { + val reports = reportsRes.data.comment_reports + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxSize() + .simpleVerticalScrollbar(listState), + ) { + items( + reports, + key = { report -> report.comment_report.id }, + contentType = { "commentReport" }, + ) { reportView -> + CommentReportItem( + commentReportView = reportView, + showScores = siteViewModel.showScores(), + showAvatar = siteViewModel.showAvatar(), + onPersonClick = appState::toProfile, + onCommentClick = appState::toComment, + onResolveClick = { form -> + account.doIfReadyElseDisplayInfo( + appState, + ctx, + snackbarHostState, + scope, + siteViewModel, + ) { + reportsViewModel.resolveCommentReport( + form = form, + ) + } + }, + ) + } + } + } + + else -> {} + } + } + } + + ReportsTab.Messages.ordinal -> { + val listState = rememberLazyListState() + + // observer when reached end of list + val endOfListReached by remember { + derivedStateOf { + listState.isScrolledToEnd() + } + } + + // act when end of list reached + if (endOfListReached) { + LaunchedEffect(Unit) { + account.doIfReadyElseDisplayInfo( + appState, + ctx, + snackbarHostState, + scope, + siteViewModel, + ) { + reportsViewModel.appendMessageReports() + } + } + } + + val loading = reportsViewModel.messageReportsRes.isLoading() + val refreshing = reportsViewModel.messageReportsRes.isRefreshing() + + val refreshState = + rememberPullRefreshState( + refreshing = refreshing, + onRefresh = { + account.doIfReadyElseDisplayInfo( + appState, + ctx, + snackbarHostState, + scope, + siteViewModel, + ) { + reportsViewModel.resetPageMessageReports() + reportsViewModel.listMessageReports( + reportsViewModel.getFormMessageReports(), + ApiState.Refreshing, + ) + siteViewModel.fetchUnreadReportCount() + } + }, + ) + Box( + modifier = + Modifier + .pullRefresh(refreshState) + .fillMaxSize(), + ) { + JerboaPullRefreshIndicator( + refreshing, + refreshState, + Modifier + .align(Alignment.TopCenter) + .zIndex(100F), + ) + + if (loading) { + LoadingBar() + } + when (val reportsRes = reportsViewModel.messageReportsRes) { + ApiState.Empty -> ApiEmptyText() + is ApiState.Failure -> ApiErrorText(reportsRes.msg) + is ApiState.Holder -> { + val reports = reportsRes.data.private_message_reports + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxSize() + .simpleVerticalScrollbar(listState), + ) { + items( + reports, + key = { report -> report.private_message_report.id }, + contentType = { "messageReport" }, + ) { reportView -> + + MessageReportItem( + messageReportView = reportView, + onResolveClick = { form -> + account.doIfReadyElseDisplayInfo( + appState, + ctx, + snackbarHostState, + scope, + siteViewModel, + ) { + reportsViewModel.resolveMessageReport( + form = form, + ) + } + }, + onPersonClick = appState::toProfile, + showAvatar = siteViewModel.showAvatar(), + ) + } + } + } + + else -> {} + } + } + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dbb262e1c..1f5fdf385 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,6 +36,7 @@ Go home Go to my inbox Go to registrations + Go to reports Saved Home Inbox @@ -453,4 +454,12 @@ Answer Deny reason Apps + Reports + Reporter + Resolved by + Unresolve + Unresolved by + Reason + Creator + Message