chat ui
This commit is contained in:
+5
-11
@@ -2,33 +2,27 @@ package ru.shadowsparky.vbox.backend.data.chat
|
||||
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.chat.backend.domain.MessageStorage
|
||||
import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
|
||||
import ru.shadowsparky.chat.domain.ChatRepository
|
||||
import ru.shadowsparky.chat.domain.Message
|
||||
|
||||
@Factory
|
||||
class BackendChatRepositoryFactory(
|
||||
private val storage: MessageStorage,
|
||||
private val processUserMessageUseCase: ProcessUserMessageUseCase
|
||||
) {
|
||||
class BackendChatRepositoryFactory(private val storage: MessageStorage) {
|
||||
fun create(userId: Long): BackendChatRepository {
|
||||
return BackendChatRepository(userId, storage, processUserMessageUseCase)
|
||||
return BackendChatRepository(userId, storage)
|
||||
}
|
||||
}
|
||||
|
||||
class BackendChatRepository(
|
||||
private val userId: Long,
|
||||
private val storage: MessageStorage,
|
||||
private val processUserMessageUseCase: ProcessUserMessageUseCase
|
||||
private val storage: MessageStorage
|
||||
) : ChatRepository {
|
||||
|
||||
override suspend fun query(): List<Message> {
|
||||
val history = storage.query(userId, limit = 10)
|
||||
override suspend fun query(afterId: Long?): List<Message> {
|
||||
val history = storage.query(userId, afterId, limit = 10)
|
||||
return history.sortedBy { it.timestamp }
|
||||
}
|
||||
|
||||
override suspend fun send(message: Message) {
|
||||
storage.insert(userId, message)
|
||||
processUserMessageUseCase.execute(userId)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -14,9 +14,13 @@ class SqlMessageStorage(
|
||||
) : MessageStorage {
|
||||
private val queries = appDatabase.chatQueries
|
||||
|
||||
override suspend fun query(userId: Long, limit: Int): List<Message> = withContext(dispatcherProvider.io) {
|
||||
override suspend fun query(userId: Long, afterId: Long?, limit: Int): List<Message> = withContext(dispatcherProvider.io) {
|
||||
val chatId = queries.getChatIdByUserId(userId).executeAsOneOrNull() ?: return@withContext emptyList()
|
||||
val dbMessages = queries.getChatContext(chatId = chatId, limit = limit.toLong()).executeAsList()
|
||||
val dbMessages = queries.getChatContext(
|
||||
chatId = chatId,
|
||||
afterId = afterId,
|
||||
limit = limit.toLong()
|
||||
).executeAsList()
|
||||
dbMessages.map { dbMsg ->
|
||||
Message(
|
||||
id = dbMsg.id,
|
||||
|
||||
@@ -5,10 +5,10 @@ import org.koin.core.annotation.Single
|
||||
import ru.shadowsparky.backend.data.TokenVerifier
|
||||
import ru.shadowsparky.backend.domain.JwtInfo
|
||||
import ru.shadowsparky.backend.domain.LoginVerifier
|
||||
import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
|
||||
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
|
||||
import ru.shadowsparky.vbox.backend.data.BackendUpdateFetcherFactory
|
||||
import ru.shadowsparky.vbox.backend.data.auth.AuthTokenRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.data.chat.BackendChatRepository
|
||||
import ru.shadowsparky.vbox.backend.data.chat.BackendChatRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory
|
||||
@@ -27,7 +27,8 @@ class RoutingEntryPoint(
|
||||
val movieTagFactory: MovieTagRepositoryFactory,
|
||||
val userTagFactory: UserTagRepositoryFactory,
|
||||
val updateFetcherFactory: BackendUpdateFetcherFactory,
|
||||
val chatRepositoryFactory: BackendChatRepositoryFactory
|
||||
val chatRepositoryFactory: BackendChatRepositoryFactory,
|
||||
val processUserMessageUseCase: ProcessUserMessageUseCase
|
||||
)
|
||||
|
||||
@Single
|
||||
|
||||
+1
-1
@@ -45,6 +45,6 @@ fun Routing.setupAuthMethods(
|
||||
setupSavedMovie(savedMovieFactory)
|
||||
setupTagsRouting(userTagFactory, movieTagFactory)
|
||||
setupUpdates(updateFetcherFactory)
|
||||
setupChat(chatRepositoryFactory)
|
||||
setupChat(chatRepositoryFactory, processUserMessageUseCase)
|
||||
}
|
||||
}
|
||||
|
||||
+18
-6
@@ -1,23 +1,35 @@
|
||||
package ru.shadowsparky.vbox.backend.presentation.routing
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
|
||||
import ru.shadowsparky.chat.domain.ChatRepository
|
||||
import ru.shadowsparky.chat.domain.ChatRoles
|
||||
import ru.shadowsparky.chat.domain.Message
|
||||
import ru.shadowsparky.chat.domain.MessagesResponse
|
||||
import ru.shadowsparky.http.domain.BadRequestException
|
||||
import ru.shadowsparky.vbox.backend.data.chat.BackendChatRepositoryFactory
|
||||
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
|
||||
import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX
|
||||
|
||||
fun Route.setupChat(chatRepositoryFactory: BackendChatRepositoryFactory) {
|
||||
fun Route.setupChat(
|
||||
chatRepositoryFactory: BackendChatRepositoryFactory,
|
||||
processUserMessageUseCase: ProcessUserMessageUseCase
|
||||
) {
|
||||
get("$DYNAMIC_PREFIX/chat/query") {
|
||||
val repository = chatRepositoryFactory.create(call.obtainUserId())
|
||||
call.respond(repository.query())
|
||||
val afterId = call.queryParameters[ChatRepository.ARG_AFTER_ID]?.toLong()
|
||||
call.respond(MessagesResponse(repository.query(afterId)))
|
||||
}
|
||||
post("$DYNAMIC_PREFIX/chat/send") {
|
||||
val repository = chatRepositoryFactory.create(call.obtainUserId())
|
||||
repository.send(call.receive())
|
||||
call.respond(HttpStatusCode.OK)
|
||||
val userId = call.obtainUserId()
|
||||
val info = call.receive<Message>()
|
||||
if (info.role != ChatRoles.USER) throw BadRequestException("invalid role")
|
||||
val repository = chatRepositoryFactory.create(userId)
|
||||
repository.send(info)
|
||||
call.respond(processUserMessageUseCase.execute(userId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,5 +18,6 @@ getChatContext:
|
||||
SELECT id, role, content, created_at
|
||||
FROM messages
|
||||
WHERE chat_id = :chatId
|
||||
ORDER BY created_at DESC
|
||||
AND (:afterId IS NULL OR id > :afterId)
|
||||
ORDER BY id ASC
|
||||
LIMIT :limit;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
</manifest>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
||||
<base-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates
|
||||
|
||||
@@ -27,6 +27,8 @@ kotlin {
|
||||
implementation(project(":libs:http-client"))
|
||||
implementation(project(":feature:video-player"))
|
||||
implementation(project(":feature:updater:updater-client"))
|
||||
implementation(project(":feature:chat:chat-client"))
|
||||
implementation(project(":feature:chat:chat-common"))
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package ru.shadowsparky.vbox.shared.data
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import org.koin.core.annotation.Factory
|
||||
import ru.shadowsparky.chat.domain.ChatRepository
|
||||
import ru.shadowsparky.chat.domain.Message
|
||||
import ru.shadowsparky.chat.domain.MessagesResponse
|
||||
import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX
|
||||
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
|
||||
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
|
||||
|
||||
@Factory
|
||||
class RemoteChatRepository(
|
||||
private val httpClient: HttpClient,
|
||||
private val serverConfigurationRepository: ServerConfigurationRepository
|
||||
) : ChatRepository {
|
||||
|
||||
override suspend fun query(afterId: Long?): List<Message> {
|
||||
val args: Map<String, String> = if (afterId == null) {
|
||||
emptyMap()
|
||||
} else {
|
||||
mapOf(ChatRepository.ARG_AFTER_ID to afterId.toString())
|
||||
}
|
||||
return httpClient.get<MessagesResponse>(
|
||||
"$DYNAMIC_PREFIX/chat/query",
|
||||
args,
|
||||
serverConfigurationRepository.getServerConfiguration()
|
||||
).content
|
||||
}
|
||||
|
||||
override suspend fun send(message: Message) {
|
||||
httpClient.post {
|
||||
setBody(message)
|
||||
serverConfigurationRepository.getServerConfiguration().apply(
|
||||
this,
|
||||
"$DYNAMIC_PREFIX/chat/send"
|
||||
)
|
||||
}.body<Message>()
|
||||
}
|
||||
}
|
||||
+2
@@ -12,6 +12,7 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.seiko.imageloader.LocalImageLoader
|
||||
import ru.shadowsparky.chat.presentation.ChatScreen
|
||||
import ru.shadowsparky.koin
|
||||
import ru.shadowsparky.ui.components.BackButton
|
||||
import ru.shadowsparky.ui.nav.Nav
|
||||
@@ -63,6 +64,7 @@ private fun NavInternal(root: VBoxRootComponent) {
|
||||
is Child.EditPasswordChild -> EditPasswordScreen(child.component)
|
||||
is Child.LoadingChild -> LoadingScreen(child.component)
|
||||
is Child.OfflineVideoChild -> OfflineVideoScreen(child.component)
|
||||
is Child.ChatChild -> ChatScreen(child.component)
|
||||
is Child.VideosChild,
|
||||
is Child.SearchChild,
|
||||
is Child.SettingsChild,
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation.chat
|
||||
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Named
|
||||
import ru.shadowsparky.chat.domain.ChatRepository
|
||||
import ru.shadowsparky.chat.presentation.ChatComponent
|
||||
import ru.shadowsparky.ui.nav.ComponentFactory
|
||||
import ru.shadowsparky.ui.nav.RouteNavigator
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Child
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.Route
|
||||
import ru.shadowsparky.vbox.shared.presentation.nav.RouteIds
|
||||
|
||||
@Factory
|
||||
@Named(RouteIds.CHAT)
|
||||
class ChatComponentFactory(
|
||||
private val chatRepository: ChatRepository
|
||||
) : ComponentFactory<Child.ChatChild, Route.Chat, Route> {
|
||||
override fun create(
|
||||
route: Route.Chat,
|
||||
child: ComponentContext,
|
||||
root: RouteNavigator<Route>
|
||||
): Child.ChatChild {
|
||||
val comp = ChatComponent(
|
||||
child,
|
||||
chatRepository
|
||||
)
|
||||
return Child.ChatChild(comp)
|
||||
}
|
||||
}
|
||||
+2
@@ -1,5 +1,6 @@
|
||||
package ru.shadowsparky.vbox.shared.presentation.nav
|
||||
|
||||
import ru.shadowsparky.chat.presentation.ChatComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.list.VideoScreenComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.loading.LoadingComponent
|
||||
import ru.shadowsparky.vbox.shared.presentation.offline.OfflineVideoComponent
|
||||
@@ -32,4 +33,5 @@ sealed class Child : ru.shadowsparky.ui.nav.Child {
|
||||
class LoadingChild(val component: LoadingComponent) : Child()
|
||||
class PlayerChild(val component: PlayerComponent) : Child()
|
||||
class TagChild(val component: TagComponent) : Child()
|
||||
class ChatChild(val component: ChatComponent) : Child()
|
||||
}
|
||||
|
||||
+3
@@ -21,6 +21,9 @@ sealed class Route : ru.shadowsparky.ui.nav.Route() {
|
||||
@Serializable
|
||||
data class OfflineVideo(override val routeId: String = RouteIds.OFFLINE_VIDEO) : Route()
|
||||
|
||||
@Serializable
|
||||
data class Chat(override val routeId: String = RouteIds.CHAT) : Route()
|
||||
|
||||
@Serializable
|
||||
data class Loading(
|
||||
val authRequired: Boolean = true,
|
||||
|
||||
+1
@@ -21,4 +21,5 @@ object RouteIds {
|
||||
const val REGISTER = "register"
|
||||
const val EDIT_PASSWORD = "editPassword"
|
||||
const val TAG_OVERVIEW = "tags"
|
||||
const val CHAT = "chat"
|
||||
}
|
||||
|
||||
+4
-4
@@ -37,12 +37,11 @@ class SettingsComponent(
|
||||
val doRegister: () -> Unit,
|
||||
val editPassword: () -> Unit,
|
||||
val showOffline: () -> Unit,
|
||||
val showTags: () -> Unit
|
||||
val showTags: () -> Unit,
|
||||
val showChat: () -> Unit
|
||||
) : ComponentContext by context {
|
||||
val updateComponent = updateComponentFactory.create(childContext("UpdateComponent"))
|
||||
|
||||
val hasUpdateSupport = updateComponent.hasSupport
|
||||
|
||||
val serverInfo = serverConfigurationRepository.serverConfiguration.map { it.asHttpStr() }
|
||||
private val componentScope = createComponentScope()
|
||||
|
||||
@@ -101,7 +100,8 @@ class SettingsComponentFactory(
|
||||
tokenStorage = tokenStorage,
|
||||
showOffline = { root.nav(Route.OfflineVideo()) },
|
||||
offlineSupport = offlineStorage != null,
|
||||
showTags = { root.nav(Route.Settings.TagOverview()) }
|
||||
showTags = { root.nav(Route.Settings.TagOverview()) },
|
||||
showChat = { root.nav(Route.Chat())}
|
||||
)
|
||||
return Child.SettingsChild(comp)
|
||||
}
|
||||
|
||||
+6
@@ -82,6 +82,12 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) {
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
TextPreference(
|
||||
text = "chat",
|
||||
modifier = Modifier.clickable { comp.showChat() }
|
||||
)
|
||||
}
|
||||
}
|
||||
section {
|
||||
val needAuth = tokenInfo == null
|
||||
|
||||
Reference in New Issue
Block a user