feat(inbox): private messages (#18)

This commit is contained in:
Diego Beraldin 2023-09-15 23:42:44 +02:00 committed by GitHub
parent 98da3be105
commit 19ecdf92fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1529 additions and 270 deletions

View File

@ -12,3 +12,4 @@ typealias LocalUserId = Int
typealias CustomEmojiId = Int
typealias PersonMentionId = Int
typealias CommentReplyId = Int
typealias PrivateMessageId = Int

View File

@ -0,0 +1,14 @@
package com.github.diegoberaldin.raccoonforlemmy.core.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class CreatePrivateMessageForm(
@SerialName("content")
val content: String,
@SerialName("recipient_id")
val recipientId: PersonId,
@SerialName("auth")
val auth: String,
)

View File

@ -0,0 +1,14 @@
package com.github.diegoberaldin.raccoonforlemmy.core.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class MarkPrivateMessageAsReadForm(
@SerialName("private_message_id")
val privateMessageId: PrivateMessageId,
@SerialName("read")
val read: Boolean,
@SerialName("auth")
val auth: String,
)

View File

@ -0,0 +1,28 @@
package com.github.diegoberaldin.raccoonforlemmy.core.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PrivateMessage(
@SerialName("id")
val id: PrivateMessageId,
@SerialName("creator_id")
val creatorId: PersonId,
@SerialName("recipient_id")
val recipientId: PersonId,
@SerialName("content")
val content: String,
@SerialName("deleted")
val deleted: Boolean,
@SerialName("read")
val read: Boolean,
@SerialName("published")
val published: String,
@SerialName("? ")
val updated: String? = null,
@SerialName("ap_id")
val apId: String,
@SerialName("local")
val local: Boolean,
)

View File

@ -0,0 +1,10 @@
package com.github.diegoberaldin.raccoonforlemmy.core.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PrivateMessageResponse(
@SerialName("private_message_view")
val privateMessageView: PrivateMessageView,
)

View File

@ -0,0 +1,14 @@
package com.github.diegoberaldin.raccoonforlemmy.core.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PrivateMessageView(
@SerialName("private_message")
val privateMessage: PrivateMessage,
@SerialName("creator")
val creator: Person,
@SerialName("recipient")
val recipient: Person,
)

View File

@ -0,0 +1,10 @@
package com.github.diegoberaldin.raccoonforlemmy.core.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PrivateMessagesResponse(
@SerialName("private_messages")
val privateMessages: List<PrivateMessageView>,
)

View File

@ -4,6 +4,7 @@ import com.github.diegoberaldin.raccoonforlemmy.core.api.service.AuthService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.CommentService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.CommunityService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.PostService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.PrivateMessageService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.SearchService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.SiteService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.UserService
@ -48,6 +49,9 @@ internal class DefaultServiceProvider : ServiceProvider {
override lateinit var search: SearchService
private set
override lateinit var privateMessages: PrivateMessageService
private set
private val baseUrl: String get() = "https://$currentInstance/api/$VERSION/"
private val client = HttpClient {
defaultRequest {
@ -88,5 +92,6 @@ internal class DefaultServiceProvider : ServiceProvider {
site = ktorfit.create()
comment = ktorfit.create()
search = ktorfit.create()
privateMessages = ktorfit.create()
}
}

View File

@ -4,6 +4,7 @@ import com.github.diegoberaldin.raccoonforlemmy.core.api.service.AuthService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.CommentService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.CommunityService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.PostService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.PrivateMessageService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.SearchService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.SiteService
import com.github.diegoberaldin.raccoonforlemmy.core.api.service.UserService
@ -18,6 +19,7 @@ interface ServiceProvider {
val site: SiteService
val comment: CommentService
val search: SearchService
val privateMessages: PrivateMessageService
fun changeInstance(value: String)
}

View File

@ -0,0 +1,30 @@
package com.github.diegoberaldin.raccoonforlemmy.core.api.service
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.CreatePrivateMessageForm
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.MarkPrivateMessageAsReadForm
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.PrivateMessageResponse
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.PrivateMessagesResponse
import de.jensklingenberg.ktorfit.Response
import de.jensklingenberg.ktorfit.http.Body
import de.jensklingenberg.ktorfit.http.GET
import de.jensklingenberg.ktorfit.http.Headers
import de.jensklingenberg.ktorfit.http.POST
import de.jensklingenberg.ktorfit.http.Query
interface PrivateMessageService {
@GET("private_message/list")
suspend fun getPrivateMessages(
@Query("auth") auth: String? = null,
@Query("page") page: Int? = null,
@Query("limit") limit: Int? = null,
@Query("unread_only") unreadOnly: Boolean? = null,
): Response<PrivateMessagesResponse>
@POST("private_message")
@Headers("Content-Type: application/json")
suspend fun createPrivateMessage(@Body form: CreatePrivateMessageForm): Response<PrivateMessageResponse>
@POST("private_message/mark_as_read")
@Headers("Content-Type: application/json")
suspend fun markPrivateMessageAsRead(@Body form: MarkPrivateMessageAsReadForm): Response<PrivateMessageResponse>
}

View File

@ -208,11 +208,13 @@ class PostDetailScreen(
model.reduce(PostDetailMviModel.Intent.Refresh)
})
Box(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
.nestedScroll(fabNestedScrollConnection).pullRefresh(pullRefreshState),
modifier = Modifier
.padding(padding)
.nestedScroll(scrollBehavior.nestedScrollConnection)
.nestedScroll(fabNestedScrollConnection)
.pullRefresh(pullRefreshState),
) {
LazyColumn(
modifier = Modifier.padding(padding),
verticalArrangement = Arrangement.spacedBy(Spacing.xs),
) {
item {

View File

@ -0,0 +1,11 @@
package com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data
data class PrivateMessageModel(
val id: Int = 0,
val content: String? = null,
val creator: UserModel? = null,
val recipient: UserModel? = null,
val publishDate: String? = null,
val updateDate: String? = null,
val read: Boolean = false,
)

View File

@ -0,0 +1,53 @@
package com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.CreatePrivateMessageForm
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.MarkPrivateMessageAsReadForm
import com.github.diegoberaldin.raccoonforlemmy.core.api.provider.ServiceProvider
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.PrivateMessageModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.utils.toModel
class PrivateMessageRepository(
private val serviceProvider: ServiceProvider,
) {
suspend fun getAll(
auth: String? = null,
page: Int,
limit: Int = PostsRepository.DEFAULT_PAGE_SIZE,
unreadOnly: Boolean = true,
): List<PrivateMessageModel> = runCatching {
val response = serviceProvider.privateMessages.getPrivateMessages(
auth = auth,
limit = limit,
page = page,
unreadOnly = unreadOnly,
)
val dto = response.body() ?: return@runCatching emptyList()
dto.privateMessages.map { it.toModel() }
}.getOrElse { emptyList() }
suspend fun create(
message: String,
auth: String? = null,
recipiendId: Int,
) {
val data = CreatePrivateMessageForm(
content = message,
auth = auth.orEmpty(),
recipientId = recipiendId,
)
serviceProvider.privateMessages.createPrivateMessage(data)
}
suspend fun markAsRead(
messageId: Int,
auth: String? = null,
read: Boolean = true,
) {
val data = MarkPrivateMessageAsReadForm(
privateMessageId = messageId,
auth = auth.orEmpty(),
read = read,
)
serviceProvider.privateMessages.markPrivateMessageAsRead(data)
}
}

View File

@ -3,6 +3,7 @@ package com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.di
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.CommentRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.CommunityRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.PostsRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.PrivateMessageRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.SiteRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.UserRepository
import org.koin.core.module.dsl.singleOf
@ -25,4 +26,5 @@ val repositoryModule = module {
singleOf(::UserRepository)
singleOf(::SiteRepository)
singleOf(::CommentRepository)
singleOf(::PrivateMessageRepository)
}

View File

@ -11,6 +11,7 @@ import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.Person
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.PersonAggregates
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.PersonMentionView
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.PostView
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.PrivateMessageView
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.SortType.Active
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.SortType.Hot
import com.github.diegoberaldin.raccoonforlemmy.core.api.dto.SortType.MostComments
@ -30,6 +31,7 @@ import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.CommunityModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.ListingType
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.PersonMentionModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.PostModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.PrivateMessageModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.SortType
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.UserModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.UserScoreModel
@ -184,6 +186,16 @@ internal fun CommentReplyView.toModel() = PersonMentionModel(
publishDate = commentReply.published,
)
internal fun PrivateMessageView.toModel() = PrivateMessageModel(
id = privateMessage.id,
content = privateMessage.content,
creator = creator.toModel(),
recipient = recipient.toModel(),
publishDate = privateMessage.published,
updateDate = privateMessage.updated,
read = privateMessage.read,
)
internal fun String.toHost(): String = this.replace("https://", "").let {
val index = it.indexOf("/")
if (index < 0) {

View File

@ -29,6 +29,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -38,11 +39,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.bottomSheet.LocalBottomSheetNavigator
import com.github.diegoberaldin.racconforlemmy.core.utils.onClick
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.di.getThemeRepository
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.bindToLifecycle
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.communitydetail.CommunityDetailScreen
@ -144,125 +148,134 @@ class PostListScreen : Screen {
verticalArrangement = Arrangement.spacedBy(Spacing.xs),
) {
itemsIndexed(uiState.posts) { idx, post ->
SwipeableCard(
modifier = Modifier.fillMaxWidth(),
onGestureBegin = {
model.reduce(PostListMviModel.Intent.HapticIndication)
},
onDismissToStart = {
model.reduce(PostListMviModel.Intent.UpVotePost(idx))
},
onDismissToEnd = {
model.reduce(PostListMviModel.Intent.DownVotePost(idx))
},
backgroundColor = {
when (it) {
DismissValue.DismissedToStart -> MaterialTheme.colorScheme.secondary
DismissValue.DismissedToEnd -> MaterialTheme.colorScheme.tertiary
else -> Color.Transparent
}
},
swipeContent = { direction ->
val icon = when (direction) {
DismissDirection.StartToEnd -> Icons.Default.ArrowCircleDown
DismissDirection.EndToStart -> Icons.Default.ArrowCircleUp
}
val (iconModifier, iconTint) = when {
direction == DismissDirection.StartToEnd && post.myVote < 0 -> {
Modifier.background(
color = Color.Transparent,
shape = CircleShape,
) to MaterialTheme.colorScheme.onTertiary
val themeRepository = remember { getThemeRepository() }
val fontScale by themeRepository.contentFontScale.collectAsState()
CompositionLocalProvider(
LocalDensity provides Density(
density = LocalDensity.current.density,
fontScale = fontScale,
),
) {
SwipeableCard(
modifier = Modifier.fillMaxWidth(),
onGestureBegin = {
model.reduce(PostListMviModel.Intent.HapticIndication)
},
onDismissToStart = {
model.reduce(PostListMviModel.Intent.UpVotePost(idx))
},
onDismissToEnd = {
model.reduce(PostListMviModel.Intent.DownVotePost(idx))
},
backgroundColor = {
when (it) {
DismissValue.DismissedToStart -> MaterialTheme.colorScheme.secondary
DismissValue.DismissedToEnd -> MaterialTheme.colorScheme.tertiary
else -> Color.Transparent
}
},
swipeContent = { direction ->
val icon = when (direction) {
DismissDirection.StartToEnd -> Icons.Default.ArrowCircleDown
DismissDirection.EndToStart -> Icons.Default.ArrowCircleUp
}
val (iconModifier, iconTint) = when {
direction == DismissDirection.StartToEnd && post.myVote < 0 -> {
Modifier.background(
color = Color.Transparent,
shape = CircleShape,
) to MaterialTheme.colorScheme.onTertiary
}
direction == DismissDirection.StartToEnd -> {
Modifier.background(
color = MaterialTheme.colorScheme.onTertiary,
shape = CircleShape,
) to MaterialTheme.colorScheme.tertiary
}
direction == DismissDirection.StartToEnd -> {
Modifier.background(
color = MaterialTheme.colorScheme.onTertiary,
shape = CircleShape,
) to MaterialTheme.colorScheme.tertiary
}
direction == DismissDirection.EndToStart && post.myVote > 0 -> {
Modifier.background(
color = Color.Transparent,
shape = CircleShape,
) to MaterialTheme.colorScheme.onSecondary
}
direction == DismissDirection.EndToStart && post.myVote > 0 -> {
Modifier.background(
color = Color.Transparent,
shape = CircleShape,
) to MaterialTheme.colorScheme.onSecondary
}
else -> {
Modifier.background(
color = MaterialTheme.colorScheme.onSecondary,
shape = CircleShape,
) to MaterialTheme.colorScheme.secondary
else -> {
Modifier.background(
color = MaterialTheme.colorScheme.onSecondary,
shape = CircleShape,
) to MaterialTheme.colorScheme.secondary
}
}
}
Icon(
modifier = iconModifier,
imageVector = icon,
contentDescription = null,
tint = iconTint,
)
},
content = {
PostCard(
modifier = Modifier.onClick {
navigator?.push(
PostDetailScreen(post),
)
},
post = post,
blurNsfw = uiState.blurNsfw,
onOpenCommunity = { community ->
navigator?.push(
CommunityDetailScreen(community),
)
},
onOpenCreator = { user ->
navigator?.push(
UserDetailScreen(user),
)
},
onUpVote = {
model.reduce(
PostListMviModel.Intent.UpVotePost(
index = idx,
feedback = true,
),
)
},
onDownVote = {
model.reduce(
PostListMviModel.Intent.DownVotePost(
index = idx,
feedback = true,
),
)
},
onSave = {
model.reduce(
PostListMviModel.Intent.SavePost(
index = idx,
feedback = true,
),
)
},
onReply = {
val screen = CreateCommentScreen(
originalPost = post,
)
notificationCenter.addObserver({
model.reduce(PostListMviModel.Intent.Refresh)
}, key, screen.key)
bottomSheetNavigator.show(screen)
},
onImageClick = { url ->
navigator?.push(
ZoomableImageScreen(url),
)
},
)
},
)
Icon(
modifier = iconModifier,
imageVector = icon,
contentDescription = null,
tint = iconTint,
)
},
content = {
PostCard(
modifier = Modifier.onClick {
navigator?.push(
PostDetailScreen(post),
)
},
post = post,
blurNsfw = uiState.blurNsfw,
onOpenCommunity = { community ->
navigator?.push(
CommunityDetailScreen(community),
)
},
onOpenCreator = { user ->
navigator?.push(
UserDetailScreen(user),
)
},
onUpVote = {
model.reduce(
PostListMviModel.Intent.UpVotePost(
index = idx,
feedback = true,
),
)
},
onDownVote = {
model.reduce(
PostListMviModel.Intent.DownVotePost(
index = idx,
feedback = true,
),
)
},
onSave = {
model.reduce(
PostListMviModel.Intent.SavePost(
index = idx,
feedback = true,
),
)
},
onReply = {
val screen = CreateCommentScreen(
originalPost = post,
)
notificationCenter.addObserver({
model.reduce(PostListMviModel.Intent.Refresh)
}, key, screen.key)
bottomSheetNavigator.show(screen)
},
onImageClick = { url ->
navigator?.push(
ZoomableImageScreen(url),
)
},
)
},
)
}
}
item {
if (!uiState.loading && !uiState.refreshing && uiState.canFetchMore) {

View File

@ -2,20 +2,38 @@ package com.github.diegoberaldin.raccoonforlemmy.feature.inbox.di
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.main.InboxViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.mentions.InboxMentionsViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.detail.InboxChatViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.list.InboxMessagesViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.replies.InboxRepliesViewModel
import org.koin.java.KoinJavaComponent
import org.koin.core.parameter.parametersOf
import org.koin.java.KoinJavaComponent.inject
actual fun getInboxViewModel(): InboxViewModel {
val res: InboxViewModel by KoinJavaComponent.inject(InboxViewModel::class.java)
val res: InboxViewModel by inject(InboxViewModel::class.java)
return res
}
actual fun getInboxRepliesViewModel(): InboxRepliesViewModel {
val res: InboxRepliesViewModel by KoinJavaComponent.inject(InboxRepliesViewModel::class.java)
val res: InboxRepliesViewModel by inject(InboxRepliesViewModel::class.java)
return res
}
actual fun getInboxMentionsViewModel(): InboxMentionsViewModel {
val res: InboxMentionsViewModel by KoinJavaComponent.inject(InboxMentionsViewModel::class.java)
val res: InboxMentionsViewModel by inject(InboxMentionsViewModel::class.java)
return res
}
actual fun getInboxMessagesViewModel(): InboxMessagesViewModel {
val res: InboxMessagesViewModel by inject(InboxMessagesViewModel::class.java)
return res
}
actual fun getInboxChatViewModel(otherUserId: Int): InboxChatViewModel {
val res: InboxChatViewModel by inject(
InboxChatViewModel::class.java,
parameters = {
parametersOf(otherUserId)
},
)
return res
}

View File

@ -7,6 +7,10 @@ import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.main.InboxMviModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.main.InboxViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.mentions.InboxMentionsMviModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.mentions.InboxMentionsViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.detail.InboxChatMviModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.detail.InboxChatViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.list.InboxMessagesMviModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.list.InboxMessagesViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.replies.InboxRepliesMviModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.replies.InboxRepliesViewModel
import org.koin.dsl.module
@ -44,4 +48,26 @@ val inboxTabModule = module {
notificationCenter = get(),
)
}
factory {
InboxMessagesViewModel(
mvi = DefaultMviModel(InboxMessagesMviModel.UiState()),
identityRepository = get(),
siteRepository = get(),
messageRepository = get(),
coordinator = get(),
notificationCenter = get(),
)
}
factory {
InboxChatViewModel(
otherUserId = it[0],
mvi = DefaultMviModel(InboxChatMviModel.UiState()),
identityRepository = get(),
siteRepository = get(),
userRepository = get(),
messageRepository = get(),
coordinator = get(),
notificationCenter = get(),
)
}
}

View File

@ -2,6 +2,8 @@ package com.github.diegoberaldin.raccoonforlemmy.feature.inbox.di
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.main.InboxViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.mentions.InboxMentionsViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.detail.InboxChatViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.list.InboxMessagesViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.replies.InboxRepliesViewModel
expect fun getInboxViewModel(): InboxViewModel
@ -9,3 +11,7 @@ expect fun getInboxViewModel(): InboxViewModel
expect fun getInboxRepliesViewModel(): InboxRepliesViewModel
expect fun getInboxMentionsViewModel(): InboxMentionsViewModel
expect fun getInboxMessagesViewModel(): InboxMessagesViewModel
expect fun getInboxChatViewModel(otherUserId: Int): InboxChatViewModel

View File

@ -37,7 +37,7 @@ import com.github.diegoberaldin.raccoonforlemmy.core.commonui.modals.InboxTypeSh
import com.github.diegoberaldin.raccoonforlemmy.core.notifications.di.getNotificationCenter
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.di.getInboxViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.mentions.InboxMentionsScreen
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.InboxMessagesScreen
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.list.InboxMessagesScreen
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.replies.InboxRepliesScreen
import com.github.diegoberaldin.raccoonforlemmy.resources.MR
import com.github.diegoberaldin.raccoonforlemmy.resources.di.getLanguageRepository
@ -69,7 +69,6 @@ object InboxScreen : Tab {
}
}
Scaffold(
modifier = Modifier.padding(Spacing.xxs),
topBar = {

View File

@ -9,8 +9,7 @@ interface InboxMentionsMviModel :
sealed interface Intent {
object Refresh : Intent
object LoadNextPage : Intent
data class ChangeUnreadOnly(val unread: Boolean) : Intent
data class MarkMentionAsRead(val read: Boolean, val mentionId: Int) : Intent
data class MarkAsRead(val read: Boolean, val mentionId: Int) : Intent
object HapticIndication : Intent
}

View File

@ -25,7 +25,6 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@ -45,16 +44,9 @@ import com.github.diegoberaldin.raccoonforlemmy.core.commonui.di.getNavigationCo
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.postdetail.PostDetailScreen
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.userdetail.UserDetailScreen
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.di.getInboxMentionsViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.main.InboxMviModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.main.InboxViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class InboxMentionsScreen : Tab {
var parentModel: InboxViewModel? = null
override val options: TabOptions
@Composable get() {
return TabOptions(1u, "")
@ -66,28 +58,8 @@ class InboxMentionsScreen : Tab {
val model = rememberScreenModel { getInboxMentionsViewModel() }
model.bindToLifecycle(key)
val uiState by model.uiState.collectAsState()
val parentUiState by (parentModel?.uiState
?: MutableStateFlow(InboxMviModel.UiState())).collectAsState()
val navigator = remember { getNavigationCoordinator().getRootNavigator() }
LaunchedEffect(parentModel) {
parentModel?.also { parentModel ->
parentModel.uiState.onEach {
if (it.unreadOnly != model.uiState.value.unreadOnly) {
model.reduce(InboxMentionsMviModel.Intent.ChangeUnreadOnly(unread = it.unreadOnly))
}
}.launchIn(this)
parentModel.effects.onEach {
when (it) {
InboxMviModel.Effect.Refresh -> {
model.reduce(InboxMentionsMviModel.Intent.Refresh)
}
}
}.launchIn(this)
}
}
val pullRefreshState = rememberPullRefreshState(uiState.refreshing, {
model.reduce(InboxMentionsMviModel.Intent.Refresh)
})
@ -113,7 +85,7 @@ class InboxMentionsScreen : Tab {
},
onDismissToStart = {
model.reduce(
InboxMentionsMviModel.Intent.MarkMentionAsRead(
InboxMentionsMviModel.Intent.MarkAsRead(
read = true,
mentionId = mention.id,
),
@ -121,7 +93,7 @@ class InboxMentionsScreen : Tab {
},
onDismissToEnd = {
model.reduce(
InboxMentionsMviModel.Intent.MarkMentionAsRead(
InboxMentionsMviModel.Intent.MarkAsRead(
read = false,
mentionId = mention.id,
),

View File

@ -59,8 +59,7 @@ class InboxMentionsViewModel(
when (intent) {
InboxMentionsMviModel.Intent.LoadNextPage -> loadNextPage()
InboxMentionsMviModel.Intent.Refresh -> refresh()
is InboxMentionsMviModel.Intent.ChangeUnreadOnly -> changeUnreadOnly(intent.unread)
is InboxMentionsMviModel.Intent.MarkMentionAsRead -> {
is InboxMentionsMviModel.Intent.MarkAsRead -> {
markAsRead(read = intent.read, mentionId = intent.mentionId)
}

View File

@ -1,32 +0,0 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
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 cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
class InboxMessagesScreen : Tab {
override val options: TabOptions
@Composable get() {
return TabOptions(2u, "")
}
@Composable
override fun Content() {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "\uD83D\uDEA7 Work in progress! \uD83D\uDEA7",
style = MaterialTheme.typography.titleLarge,
)
}
}
}

View File

@ -0,0 +1,26 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.detail
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.MviModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.PrivateMessageModel
interface InboxChatMviModel :
MviModel<InboxChatMviModel.Intent, InboxChatMviModel.UiState, InboxChatMviModel.SideEffect> {
sealed interface Intent {
object LoadNextPage : Intent
data class SetNewMessageContent(val value: String) : Intent
object SubmitNewMessage : Intent
}
data class UiState(
val refreshing: Boolean = false,
val loading: Boolean = false,
val canFetchMore: Boolean = true,
val currentUserId: Int = 0,
val otherUserName: String = "",
val otherUserAvatar: String? = null,
val messages: List<PrivateMessageModel> = emptyList(),
val newMessageContent: String = "",
)
sealed interface SideEffect
}

View File

@ -0,0 +1,226 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.detail
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import com.github.diegoberaldin.racconforlemmy.core.utils.onClick
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.di.getThemeRepository
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.CornerSize
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.bindToLifecycle
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.di.getNavigationCoordinator
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.di.getInboxChatViewModel
import io.kamel.image.KamelImage
import io.kamel.image.asyncPainterResource
class InboxChatScreen(
private val otherUserId: Int,
) : Screen {
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
override fun Content() {
val model = rememberScreenModel { getInboxChatViewModel(otherUserId) }
model.bindToLifecycle(key)
val uiState by model.uiState.collectAsState()
val navigator = remember { getNavigationCoordinator().getRootNavigator() }
Scaffold(
modifier = Modifier.background(MaterialTheme.colorScheme.surface).padding(Spacing.xs),
topBar = {
TopAppBar(
title = {
Row(
horizontalArrangement = Arrangement.spacedBy(Spacing.xxs),
verticalAlignment = Alignment.CenterVertically,
) {
val iconSize = 23.dp
val avatar = uiState.otherUserAvatar.orEmpty()
if (avatar.isNotEmpty()) {
val painterResource = asyncPainterResource(data = avatar)
KamelImage(
modifier = Modifier.padding(Spacing.xxxs).size(iconSize)
.clip(RoundedCornerShape(iconSize / 2)),
resource = painterResource,
contentDescription = null,
contentScale = ContentScale.FillBounds,
)
}
Text(
modifier = Modifier.padding(horizontal = Spacing.s),
text = uiState.otherUserName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
navigationIcon = {
Image(
modifier = Modifier.onClick {
navigator?.pop()
},
imageVector = Icons.Default.ArrowBack,
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
)
},
)
},
) { padding ->
if (uiState.currentUserId != 0) {
Box(
modifier = Modifier.padding(padding)
) {
Column {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(Spacing.xs),
reverseLayout = true,
) {
item {
Spacer(modifier = Modifier.height(Spacing.s))
}
items(uiState.messages) { message ->
val themeRepository = remember { getThemeRepository() }
val fontScale by themeRepository.contentFontScale.collectAsState()
CompositionLocalProvider(
LocalDensity provides Density(
density = LocalDensity.current.density,
fontScale = fontScale,
),
) {
val isMyMessage = message.creator?.id == uiState.currentUserId
val content = message.content.orEmpty()
val date = message.publishDate.orEmpty()
MessageCard(
isMyMessage = isMyMessage,
content = content,
date = date,
)
}
}
item {
if (!uiState.loading && !uiState.refreshing && uiState.canFetchMore) {
model.reduce(InboxChatMviModel.Intent.LoadNextPage)
}
if (uiState.loading && !uiState.refreshing) {
Box(
modifier = Modifier.fillMaxWidth().padding(Spacing.xs),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(25.dp),
color = MaterialTheme.colorScheme.primary,
)
}
}
}
item {
Spacer(modifier = Modifier.height(Spacing.xxxl))
}
}
Row(
modifier = Modifier,
verticalAlignment = Alignment.CenterVertically,
) {
val focusManager = LocalFocusManager.current
Box(
modifier = Modifier.weight(1f).background(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f),
shape = RoundedCornerShape(CornerSize.l)
).padding(Spacing.s)
) {
BasicTextField(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 20.dp, max = 200.dp),
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface),
value = uiState.newMessageContent,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
autoCorrect = false,
imeAction = ImeAction.Send,
),
keyboardActions = KeyboardActions(
onSend = {
model.reduce(InboxChatMviModel.Intent.SubmitNewMessage)
focusManager.clearFocus()
}
),
onValueChange = { value ->
model.reduce(
InboxChatMviModel.Intent.SetNewMessageContent(
value
)
)
},
)
}
IconButton(
onClick = {
model.reduce(InboxChatMviModel.Intent.SubmitNewMessage)
focusManager.clearFocus()
},
) {
Icon(
imageVector = Icons.Default.Send,
contentDescription = null,
)
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,163 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.detail
import cafe.adriel.voyager.core.model.ScreenModel
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.DefaultMviModel
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.MviModel
import com.github.diegoberaldin.raccoonforlemmy.core.notifications.NotificationCenter
import com.github.diegoberaldin.raccoonforlemmy.domain.identity.repository.IdentityRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.CommentRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.PrivateMessageRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.SiteRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.UserRepository
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.InboxCoordinator
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.main.InboxMviModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class InboxChatViewModel(
private val otherUserId: Int,
private val mvi: DefaultMviModel<InboxChatMviModel.Intent, InboxChatMviModel.UiState, InboxChatMviModel.SideEffect>,
private val identityRepository: IdentityRepository,
private val siteRepository: SiteRepository,
private val messageRepository: PrivateMessageRepository,
private val userRepository: UserRepository,
private val coordinator: InboxCoordinator,
private val notificationCenter: NotificationCenter,
) : ScreenModel,
MviModel<InboxChatMviModel.Intent, InboxChatMviModel.UiState, InboxChatMviModel.SideEffect> by mvi {
private var currentPage: Int = 1
override fun onStarted() {
mvi.onStarted()
mvi.scope.launch {
coordinator.effects.onEach {
when (it) {
InboxMviModel.Effect.Refresh -> refresh()
}
}.launchIn(this)
notificationCenter.events.onEach { evt ->
when (evt) {
NotificationCenter.Event.Logout -> {
mvi.updateState { it.copy(messages = emptyList()) }
}
else -> Unit
}
}.launchIn(this)
launch(Dispatchers.IO) {
val auth = identityRepository.authToken.value.orEmpty()
launch {
val currentUserId = siteRepository.getCurrentUser(auth)?.id ?: 0
mvi.updateState { it.copy(currentUserId = currentUserId) }
}
launch {
val user = userRepository.get(
id = otherUserId,
auth = auth,
)
mvi.updateState {
it.copy(
otherUserName = user?.name.orEmpty(),
otherUserAvatar = user?.avatar,
)
}
}
}
}
}
override fun reduce(intent: InboxChatMviModel.Intent) {
when (intent) {
InboxChatMviModel.Intent.LoadNextPage -> loadNextPage()
is InboxChatMviModel.Intent.SetNewMessageContent -> setNewMessageContent(intent.value)
InboxChatMviModel.Intent.SubmitNewMessage -> submitNewMessage()
}
}
private fun refresh() {
currentPage = 1
mvi.updateState { it.copy(canFetchMore = true, refreshing = true) }
loadNextPage()
}
private fun loadNextPage() {
val currentState = mvi.uiState.value
if (!currentState.canFetchMore || currentState.loading) {
mvi.updateState { it.copy(refreshing = false) }
return
}
mvi.scope.launch(Dispatchers.IO) {
mvi.updateState { it.copy(loading = true) }
val auth = identityRepository.authToken.value
val refreshing = currentState.refreshing
val itemList = messageRepository.getAll(
auth = auth,
page = currentPage,
unreadOnly = false,
).filter {
it.creator?.id == otherUserId || it.recipient?.id == otherUserId
}.onEach {
if (!it.read) {
launch {
markAsRead(true, it.id)
}
}
}
currentPage++
val canFetchMore = itemList.size >= CommentRepository.DEFAULT_PAGE_SIZE
mvi.updateState {
val newItems = if (refreshing) {
itemList
} else {
it.messages + itemList
}
it.copy(
messages = newItems,
loading = false,
canFetchMore = canFetchMore,
refreshing = false,
)
}
}
}
private fun markAsRead(read: Boolean, messageId: Int) {
val auth = identityRepository.authToken.value
mvi.scope.launch(Dispatchers.IO) {
messageRepository.markAsRead(
read = read,
messageId = messageId,
auth = auth,
)
refresh()
}
}
private fun setNewMessageContent(text: String) {
mvi.updateState { it.copy(newMessageContent = text) }
}
private fun submitNewMessage() {
val text = uiState.value.newMessageContent
if (text.isNotEmpty()) {
mvi.scope.launch {
val auth = identityRepository.authToken.value
messageRepository.create(
message = text,
auth = auth,
recipiendId = otherUserId,
)
mvi.updateState { it.copy(newMessageContent = "") }
refresh()
}
}
}
}

View File

@ -0,0 +1,173 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.detail
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Icon
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.graphics.Path
import androidx.compose.ui.unit.dp
import com.github.diegoberaldin.racconforlemmy.core.utils.DateTime
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.CornerSize
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
import com.github.diegoberaldin.raccoonforlemmy.resources.MR
import dev.icerock.moko.resources.compose.stringResource
@Composable
internal fun MessageCard(
isMyMessage: Boolean = false,
content: String = "",
date: String = "",
) {
val color = if (isMyMessage) {
MaterialTheme.colorScheme.tertiary
} else {
MaterialTheme.colorScheme.secondary
}
val textColor = if (isMyMessage) {
MaterialTheme.colorScheme.onTertiary
} else {
MaterialTheme.colorScheme.onSecondary
}
val longDistance = Spacing.l
val mediumDistance = Spacing.s
Box {
Canvas(
modifier = Modifier.size(mediumDistance).let {
if (isMyMessage) {
it.align(Alignment.TopEnd)
} else {
it.align(Alignment.TopStart)
}
}
) {
if (isMyMessage) {
val path = Path().apply {
moveTo(0f, 0f)
lineTo(size.width, 0f)
lineTo(0f, size.height)
close()
}
drawPath(path = path, color = color)
} else {
val path = Path().apply {
moveTo(size.width, 0f)
lineTo(0f, 0f)
lineTo(size.width, size.height)
close()
}
drawPath(path = path, color = color)
}
}
Box(
modifier = Modifier.let {
if (isMyMessage) {
it.padding(start = longDistance, end = mediumDistance)
} else {
it.padding(end = longDistance, start = mediumDistance)
}
}.background(
color = color, shape = RoundedCornerShape(
topStart = if (isMyMessage) CornerSize.m else 0.dp,
topEnd = if (isMyMessage) 0.dp else CornerSize.m,
bottomStart = CornerSize.m,
bottomEnd = CornerSize.m,
)
).fillMaxWidth().padding(Spacing.s)
) {
Column {
Text(
text = content,
color = textColor,
)
Row(
horizontalArrangement = Arrangement.spacedBy(Spacing.xxxs),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = Modifier.weight(1f))
if (date.isNotEmpty()) {
val buttonModifier = Modifier.size(22.dp).padding(3.dp)
Icon(
modifier = buttonModifier.padding(1.dp),
imageVector = Icons.Default.Schedule,
contentDescription = null,
tint = textColor,
)
Text(
text = date.let {
when {
it.isEmpty() -> it
!it.endsWith("Z") -> {
DateTime.getPrettyDate(
iso8601Timestamp = it + "Z",
yearLabel = stringResource(
MR.strings.profile_year_short
),
monthLabel = stringResource(
MR.strings.profile_month_short
),
dayLabel = stringResource(MR.strings.profile_day_short),
hourLabel = stringResource(
MR.strings.post_hour_short
),
minuteLabel = stringResource(
MR.strings.post_minute_short
),
secondLabel = stringResource(
MR.strings.post_second_short
),
)
}
else -> {
DateTime.getPrettyDate(
iso8601Timestamp = it,
yearLabel = stringResource(
MR.strings.profile_year_short
),
monthLabel = stringResource(
MR.strings.profile_month_short
),
dayLabel = stringResource(MR.strings.profile_day_short),
hourLabel = stringResource(
MR.strings.post_hour_short
),
minuteLabel = stringResource(
MR.strings.post_minute_short
),
secondLabel = stringResource(
MR.strings.post_second_short
),
)
}
}
},
style = MaterialTheme.typography.labelMedium,
color = textColor,
)
} else {
Text(
text = "",
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}
}

View File

@ -0,0 +1,166 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.list
import androidx.compose.foundation.background
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Icon
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.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.github.diegoberaldin.racconforlemmy.core.utils.DateTime
import com.github.diegoberaldin.racconforlemmy.core.utils.onClick
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.UserModel
import com.github.diegoberaldin.raccoonforlemmy.resources.MR
import dev.icerock.moko.resources.compose.stringResource
import io.kamel.image.KamelImage
import io.kamel.image.asyncPainterResource
@Composable
internal fun ChatCard(
user: UserModel?,
lastMessage: String,
lastMessageDate: String? = null,
modifier: Modifier = Modifier,
onOpenUser: ((UserModel) -> Unit)? = null,
onOpen: (() -> Unit)? = null,
) {
Row(
modifier = modifier
.padding(Spacing.xs)
.onClick {
onOpen?.invoke()
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.m),
) {
val creatorName = user?.name.orEmpty()
val creatorHost = user?.host.orEmpty()
val creatorAvatar = user?.avatar.orEmpty()
val iconSize = 46.dp
if (creatorAvatar.isNotEmpty()) {
val painterResource = asyncPainterResource(data = creatorAvatar)
KamelImage(
modifier = Modifier
.padding(Spacing.xxxs)
.size(iconSize)
.clip(RoundedCornerShape(iconSize / 2))
.onClick {
if (user != null) {
onOpenUser?.invoke(user)
}
},
resource = painterResource,
contentDescription = null,
contentScale = ContentScale.FillBounds,
)
} else {
Box(
modifier = Modifier
.padding(Spacing.xxxs)
.size(iconSize)
.background(
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(iconSize / 2),
).onClick {
if (user != null) {
onOpenUser?.invoke(user)
}
},
contentAlignment = Alignment.Center,
) {
Text(
text = creatorName.firstOrNull()?.toString().orEmpty().uppercase(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(Spacing.xxs)
) {
// user name
Text(
text = buildString {
append(creatorName)
if (creatorHost.isNotEmpty()) {
append("@$creatorHost")
}
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// last message text
Text(
text = lastMessage,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.9f),
)
// last message date
if (lastMessageDate != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(Spacing.xxs),
verticalAlignment = Alignment.CenterVertically,
) {
val buttonModifier = Modifier.size(24.dp).padding(3.25.dp)
Icon(
modifier = buttonModifier.padding(1.dp),
imageVector = Icons.Default.Schedule,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
text = lastMessageDate.let {
when {
it.isEmpty() -> it
!it.endsWith("Z") -> {
DateTime.getPrettyDate(
iso8601Timestamp = it + "Z",
yearLabel = stringResource(MR.strings.profile_year_short),
monthLabel = stringResource(MR.strings.profile_month_short),
dayLabel = stringResource(MR.strings.profile_day_short),
hourLabel = stringResource(MR.strings.post_hour_short),
minuteLabel = stringResource(MR.strings.post_minute_short),
secondLabel = stringResource(MR.strings.post_second_short),
)
}
else -> {
DateTime.getPrettyDate(
iso8601Timestamp = it,
yearLabel = stringResource(MR.strings.profile_year_short),
monthLabel = stringResource(MR.strings.profile_month_short),
dayLabel = stringResource(MR.strings.profile_day_short),
hourLabel = stringResource(MR.strings.post_hour_short),
minuteLabel = stringResource(MR.strings.post_minute_short),
secondLabel = stringResource(MR.strings.post_second_short),
)
}
}
},
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}

View File

@ -0,0 +1,23 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.list
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.MviModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.PrivateMessageModel
interface InboxMessagesMviModel :
MviModel<InboxMessagesMviModel.Intent, InboxMessagesMviModel.UiState, InboxMessagesMviModel.SideEffect> {
sealed interface Intent {
object Refresh : Intent
object LoadNextPage : Intent
}
data class UiState(
val refreshing: Boolean = false,
val loading: Boolean = false,
val canFetchMore: Boolean = true,
val unreadOnly: Boolean = true,
val currentUserId: Int = 0,
val chats: List<PrivateMessageModel> = emptyList(),
)
sealed interface SideEffect
}

View File

@ -0,0 +1,129 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.list
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.di.getThemeRepository
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.bindToLifecycle
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.di.getNavigationCoordinator
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.userdetail.UserDetailScreen
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.di.getInboxMessagesViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.detail.InboxChatScreen
class InboxMessagesScreen : Tab {
override val options: TabOptions
@Composable get() {
return TabOptions(2u, "")
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
override fun Content() {
val model = rememberScreenModel { getInboxMessagesViewModel() }
model.bindToLifecycle(key)
val uiState by model.uiState.collectAsState()
val navigator = remember { getNavigationCoordinator().getRootNavigator() }
val pullRefreshState = rememberPullRefreshState(uiState.refreshing, {
model.reduce(InboxMessagesMviModel.Intent.Refresh)
})
Box(
modifier = Modifier.pullRefresh(pullRefreshState),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(Spacing.xs),
) {
items(uiState.chats) { chat ->
val themeRepository = remember { getThemeRepository() }
val fontScale by themeRepository.contentFontScale.collectAsState()
CompositionLocalProvider(
LocalDensity provides Density(
density = LocalDensity.current.density,
fontScale = fontScale,
),
) {
val otherUser = if (chat.creator?.id == uiState.currentUserId) {
chat.recipient
} else {
chat.creator
}
ChatCard(
user = otherUser,
lastMessage = chat.content.orEmpty(),
lastMessageDate = chat.publishDate,
onOpenUser = { user ->
navigator?.push(
UserDetailScreen(user)
)
},
onOpen = {
val userId = otherUser?.id
if (userId != null) {
navigator?.push(
InboxChatScreen(userId)
)
}
}
)
}
}
item {
if (!uiState.loading && !uiState.refreshing && uiState.canFetchMore) {
model.reduce(InboxMessagesMviModel.Intent.LoadNextPage)
}
if (uiState.loading && !uiState.refreshing) {
Box(
modifier = Modifier.fillMaxWidth().padding(Spacing.xs),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(25.dp),
color = MaterialTheme.colorScheme.primary,
)
}
}
}
item {
Spacer(modifier = Modifier.height(Spacing.xxxl))
}
}
PullRefreshIndicator(
refreshing = uiState.refreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter),
backgroundColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
)
}
}
}

View File

@ -0,0 +1,120 @@
package com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.list
import cafe.adriel.voyager.core.model.ScreenModel
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.DefaultMviModel
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.MviModel
import com.github.diegoberaldin.raccoonforlemmy.core.notifications.NotificationCenter
import com.github.diegoberaldin.raccoonforlemmy.domain.identity.repository.IdentityRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.CommentRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.PrivateMessageRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.SiteRepository
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.InboxCoordinator
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.main.InboxMviModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class InboxMessagesViewModel(
private val mvi: DefaultMviModel<InboxMessagesMviModel.Intent, InboxMessagesMviModel.UiState, InboxMessagesMviModel.SideEffect>,
private val identityRepository: IdentityRepository,
private val siteRepository: SiteRepository,
private val messageRepository: PrivateMessageRepository,
private val coordinator: InboxCoordinator,
private val notificationCenter: NotificationCenter,
) : ScreenModel,
MviModel<InboxMessagesMviModel.Intent, InboxMessagesMviModel.UiState, InboxMessagesMviModel.SideEffect> by mvi {
private var currentPage: Int = 1
override fun onStarted() {
mvi.onStarted()
mvi.scope.launch {
coordinator.effects.onEach {
when (it) {
InboxMviModel.Effect.Refresh -> refresh()
}
}.launchIn(this)
coordinator.unreadOnly.onEach {
if (it != uiState.value.unreadOnly) {
changeUnreadOnly(it)
}
}.launchIn(this)
notificationCenter.events.onEach { evt ->
when (evt) {
NotificationCenter.Event.Logout -> {
mvi.updateState { it.copy(chats = emptyList()) }
}
else -> Unit
}
}.launchIn(this)
launch(Dispatchers.IO) {
val auth = identityRepository.authToken.value.orEmpty()
val currentUserId = siteRepository.getCurrentUser(auth)?.id ?: 0
mvi.updateState { it.copy(currentUserId = currentUserId) }
}
}
}
override fun reduce(intent: InboxMessagesMviModel.Intent) {
when (intent) {
InboxMessagesMviModel.Intent.LoadNextPage -> loadNextPage()
InboxMessagesMviModel.Intent.Refresh -> refresh()
}
}
private fun refresh() {
currentPage = 1
mvi.updateState { it.copy(canFetchMore = true, refreshing = true) }
loadNextPage()
}
private fun changeUnreadOnly(value: Boolean) {
mvi.updateState { it.copy(unreadOnly = value) }
refresh()
}
private fun loadNextPage() {
val currentState = mvi.uiState.value
if (!currentState.canFetchMore || currentState.loading) {
mvi.updateState { it.copy(refreshing = false) }
return
}
mvi.scope.launch(Dispatchers.IO) {
mvi.updateState { it.copy(loading = true) }
val auth = identityRepository.authToken.value
val refreshing = currentState.refreshing
val unreadOnly = currentState.unreadOnly
val itemList = messageRepository.getAll(
auth = auth,
page = currentPage,
unreadOnly = unreadOnly,
).groupBy {
val creatorId = it.creator?.id ?: 0
val recipientId = it.recipient?.id ?: 0
listOf(creatorId, recipientId).sorted().toString()
}.mapNotNull {
val messages = it.value.sortedBy { m -> m.publishDate }
messages.lastOrNull()
}
currentPage++
val canFetchMore = itemList.size >= CommentRepository.DEFAULT_PAGE_SIZE
mvi.updateState {
val newItems = if (refreshing) {
itemList
} else {
it.chats + itemList
}
it.copy(
chats = newItems,
loading = false,
canFetchMore = canFetchMore,
refreshing = false,
)
}
}
}
}

View File

@ -9,8 +9,7 @@ interface InboxRepliesMviModel :
sealed interface Intent {
object Refresh : Intent
object LoadNextPage : Intent
data class ChangeUnreadOnly(val unread: Boolean) : Intent
data class MarkMentionAsRead(val read: Boolean, val mentionId: Int) : Intent
data class MarkAsRead(val read: Boolean, val mentionId: Int) : Intent
object HapticIndication : Intent
}

View File

@ -25,16 +25,20 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.di.getThemeRepository
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
import com.github.diegoberaldin.raccoonforlemmy.core.architecture.bindToLifecycle
import com.github.diegoberaldin.raccoonforlemmy.core.commonui.communitydetail.CommunityDetailScreen
@ -70,83 +74,92 @@ class InboxRepliesScreen : Tab {
verticalArrangement = Arrangement.spacedBy(Spacing.xs),
) {
items(uiState.replies) { mention ->
SwipeableCard(
modifier = Modifier.fillMaxWidth(),
backgroundColor = {
when (it) {
DismissValue.DismissedToStart -> MaterialTheme.colorScheme.secondary
DismissValue.DismissedToEnd -> MaterialTheme.colorScheme.tertiary
else -> Color.Transparent
}
},
onGestureBegin = {
model.reduce(InboxRepliesMviModel.Intent.HapticIndication)
},
onDismissToStart = {
model.reduce(
InboxRepliesMviModel.Intent.MarkMentionAsRead(
read = true,
mentionId = mention.id,
),
)
},
onDismissToEnd = {
model.reduce(
InboxRepliesMviModel.Intent.MarkMentionAsRead(
read = false,
mentionId = mention.id,
),
)
},
swipeContent = { direction ->
val icon = when (direction) {
DismissDirection.StartToEnd -> Icons.Default.MarkChatUnread
DismissDirection.EndToStart -> Icons.Default.MarkChatRead
}
val (iconModifier, iconTint) = when (direction) {
DismissDirection.StartToEnd -> {
Modifier.background(
color = MaterialTheme.colorScheme.onTertiary,
shape = CircleShape,
) to MaterialTheme.colorScheme.tertiary
val themeRepository = remember { getThemeRepository() }
val fontScale by themeRepository.contentFontScale.collectAsState()
CompositionLocalProvider(
LocalDensity provides Density(
density = LocalDensity.current.density,
fontScale = fontScale,
),
) {
SwipeableCard(
modifier = Modifier.fillMaxWidth(),
backgroundColor = {
when (it) {
DismissValue.DismissedToStart -> MaterialTheme.colorScheme.secondary
DismissValue.DismissedToEnd -> MaterialTheme.colorScheme.tertiary
else -> Color.Transparent
}
},
onGestureBegin = {
model.reduce(InboxRepliesMviModel.Intent.HapticIndication)
},
onDismissToStart = {
model.reduce(
InboxRepliesMviModel.Intent.MarkAsRead(
read = true,
mentionId = mention.id,
),
)
},
onDismissToEnd = {
model.reduce(
InboxRepliesMviModel.Intent.MarkAsRead(
read = false,
mentionId = mention.id,
),
)
},
swipeContent = { direction ->
val icon = when (direction) {
DismissDirection.StartToEnd -> Icons.Default.MarkChatUnread
DismissDirection.EndToStart -> Icons.Default.MarkChatRead
}
val (iconModifier, iconTint) = when (direction) {
DismissDirection.StartToEnd -> {
Modifier.background(
color = MaterialTheme.colorScheme.onTertiary,
shape = CircleShape,
) to MaterialTheme.colorScheme.tertiary
}
else -> {
Modifier.background(
color = MaterialTheme.colorScheme.onSecondary,
shape = CircleShape,
) to MaterialTheme.colorScheme.secondary
}
}
else -> {
Modifier.background(
color = MaterialTheme.colorScheme.onSecondary,
shape = CircleShape,
) to MaterialTheme.colorScheme.secondary
}
}
Icon(
modifier = iconModifier.padding(Spacing.xs),
imageVector = icon,
contentDescription = null,
tint = iconTint,
)
},
content = {
InboxMentionCard(
mention = mention,
onOpenPost = { post ->
navigator?.push(
PostDetailScreen(post),
)
},
onOpenCreator = { user ->
navigator?.push(
UserDetailScreen(user),
)
},
onOpenCommunity = { community ->
navigator?.push(
CommunityDetailScreen(community),
)
},
)
},
)
Icon(
modifier = iconModifier.padding(Spacing.xs),
imageVector = icon,
contentDescription = null,
tint = iconTint,
)
},
content = {
InboxMentionCard(
mention = mention,
onOpenPost = { post ->
navigator?.push(
PostDetailScreen(post),
)
},
onOpenCreator = { user ->
navigator?.push(
UserDetailScreen(user),
)
},
onOpenCommunity = { community ->
navigator?.push(
CommunityDetailScreen(community),
)
},
)
},
)
}
}
item {
if (!uiState.loading && !uiState.refreshing && uiState.canFetchMore) {

View File

@ -62,11 +62,8 @@ class InboxRepliesViewModel(
when (intent) {
InboxRepliesMviModel.Intent.LoadNextPage -> loadNextPage()
InboxRepliesMviModel.Intent.Refresh -> refresh()
is InboxRepliesMviModel.Intent.ChangeUnreadOnly -> {
changeUnreadOnly(intent.unread)
}
is InboxRepliesMviModel.Intent.MarkMentionAsRead -> {
is InboxRepliesMviModel.Intent.MarkAsRead -> {
markAsRead(read = intent.read, replyId = intent.mentionId)
}

View File

@ -2,9 +2,12 @@ package com.github.diegoberaldin.raccoonforlemmy.feature.inbox.di
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.main.InboxViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.mentions.InboxMentionsViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.detail.InboxChatViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.messages.list.InboxMessagesViewModel
import com.github.diegoberaldin.raccoonforlemmy.feature.inbox.replies.InboxRepliesViewModel
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.parameter.parametersOf
actual fun getInboxViewModel() = InboxScreenModelHelper.model
@ -12,8 +15,21 @@ actual fun getInboxRepliesViewModel() = InboxScreenModelHelper.repliesModel
actual fun getInboxMentionsViewModel() = InboxScreenModelHelper.mentionsModel
actual fun getInboxMessagesViewModel() = InboxScreenModelHelper.messagesModel
actual fun getInboxChatViewModel(otherUserId: Int) =
InboxScreenModelHelper.getChatViewModel(otherUserId)
object InboxScreenModelHelper : KoinComponent {
val model: InboxViewModel by inject()
val repliesModel: InboxRepliesViewModel by inject()
val mentionsModel: InboxMentionsViewModel by inject()
val messagesModel: InboxMessagesViewModel by inject()
fun getChatViewModel(otherUserId: Int): InboxChatViewModel {
val model: InboxChatViewModel by inject(
parameters = { parametersOf(otherUserId) }
)
return model
}
}