diff --git a/api/gateway/build.gradle.kts b/api/gateway/build.gradle.kts index 75a3458..8004acf 100644 --- a/api/gateway/build.gradle.kts +++ b/api/gateway/build.gradle.kts @@ -20,7 +20,7 @@ kotlin { sourceSets { commonMain.dependencies { - implementation(project(":api:shared")) + api(project(":api:shared")) implementation(libs.kermit) implementation(libs.ktor.client.core) diff --git a/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/Payloads.kt b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/Payloads.kt index e65fa3b..d6a6bc6 100644 --- a/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/Payloads.kt +++ b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/Payloads.kt @@ -3,6 +3,8 @@ package moe.lava.neon.api.gateway import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement import moe.lava.neon.api.ApiConstants +import moe.lava.neon.api.gateway.content.GuildSerializer +import moe.lava.neon.api.objects.Guild import moe.lava.neon.api.objects.User sealed interface Payload { @@ -58,7 +60,8 @@ sealed interface Event { NO_AFFINE_USER_IDS, DEDUPE_USER_OBJECTS, USER_SETTINGS_PROTO, - DEBOUNCE_MESSAGE_REACTIONS + DEBOUNCE_MESSAGE_REACTIONS, + CLIENT_STATE_V2, ) }, // TODO: Client state v2 // val clientState: ClientState, @@ -107,7 +110,7 @@ sealed interface Event { data class Ready( val v: Int, val user: User, -// val guilds: List, + val guilds: List<@Serializable(GuildSerializer::class) Guild>, val sessionId: String, val resumeGatewayUrl: String, // val application: Application, diff --git a/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/content/GuildSerializer.kt b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/content/GuildSerializer.kt new file mode 100644 index 0000000..8a24075 --- /dev/null +++ b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/content/GuildSerializer.kt @@ -0,0 +1,19 @@ +package moe.lava.neon.api.gateway.content + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import moe.lava.neon.api.objects.Guild + +object GuildSerializer : JsonContentPolymorphicSerializer(Guild::class) { + override fun selectDeserializer(element: JsonElement): DeserializationStrategy { + return if (element.jsonObject["unavailable"]?.jsonPrimitive?.booleanOrNull == true) { + Guild.Unavailable.serializer() + } else { + Guild.Gateway.serializer() + } + } +} diff --git a/api/rest/build.gradle.kts b/api/rest/build.gradle.kts index 028e304..9a8e3cf 100644 --- a/api/rest/build.gradle.kts +++ b/api/rest/build.gradle.kts @@ -22,7 +22,7 @@ kotlin { sourceSets { commonMain.dependencies { - implementation(project(":api:shared")) + api(project(":api:shared")) implementation(project(":common")) implementation(libs.kermit) diff --git a/api/shared/src/commonMain/kotlin/moe/lava/neon/api/ApiConstants.kt b/api/shared/src/commonMain/kotlin/moe/lava/neon/api/ApiConstants.kt index dbbe8f2..0693766 100644 --- a/api/shared/src/commonMain/kotlin/moe/lava/neon/api/ApiConstants.kt +++ b/api/shared/src/commonMain/kotlin/moe/lava/neon/api/ApiConstants.kt @@ -29,6 +29,8 @@ object ApiConstants { namingStrategy = JsonNamingStrategy.SnakeCase ignoreUnknownKeys = true encodeDefaults = true + // TODO: Distinguish missing vs null fields + explicitNulls = false } val superProps = Base64.encode(json.encodeToString(SuperProperties()).encodeToByteArray()) diff --git a/api/shared/src/commonMain/kotlin/moe/lava/neon/api/objects/Channel.kt b/api/shared/src/commonMain/kotlin/moe/lava/neon/api/objects/Channel.kt new file mode 100644 index 0000000..92f9934 --- /dev/null +++ b/api/shared/src/commonMain/kotlin/moe/lava/neon/api/objects/Channel.kt @@ -0,0 +1,5 @@ +package moe.lava.neon.api.objects + +data class Channel( + val id: Snowflake, +) diff --git a/api/shared/src/commonMain/kotlin/moe/lava/neon/api/objects/Guild.kt b/api/shared/src/commonMain/kotlin/moe/lava/neon/api/objects/Guild.kt new file mode 100644 index 0000000..eaf73fc --- /dev/null +++ b/api/shared/src/commonMain/kotlin/moe/lava/neon/api/objects/Guild.kt @@ -0,0 +1,145 @@ +package moe.lava.neon.api.objects + +import kotlinx.serialization.Serializable +import kotlin.time.Instant + +@Serializable +sealed class Guild { + abstract val id: Snowflake + abstract val unavailable: Boolean? + + @Serializable + data class Unavailable( + override val id: Snowflake, + override val unavailable: Boolean?, + + val geoRestricted: Boolean?, + val name: String?, + val icon: String?, + ) : Guild() + + @Serializable + data class Available( + override val id: Snowflake, + override val unavailable: Boolean?, + + val name: String, + val icon: String?, + val banner: String?, + val homeHeader: String?, + val splash: String?, + val discoverySplash: String?, + val ownerId: Snowflake, + val applicationId: Snowflake?, + val description: String?, + val region: String?, + val afkChannelId: Snowflake?, + val afkTimeout: Int, + val widgetEnabled: Boolean?, + val widgetChannelId: Snowflake?, + val verificationLevel: Int, + val defaultMessageNotifications: Int, + val explicitContentFilter: Int, + val features: List, +// val stickers: List = listOf(), +// val roles: List = listOf(), +// val emojis: List = listOf(), + + val mfaLevel: Int, + val systemChannelId: Snowflake?, + val systemChannelFlags: Int, + val rulesChannelId: Snowflake?, + val publicUpdatesChannelId: Snowflake?, + val safetyAlertsChannelId: Snowflake?, + val maxPresences: Int?, + val maxMembers: Int?, + val vanityUrlCode: String?, + val premiumTier: Int, + val premiumSubscriptionCount: Int?, + val preferredLocale: String, + val maxVideoChannelUsers: Int?, + val maxStageVideoChannelUsers: Int?, + val nsfwLevel: Int, + val ownerConfiguredContentLevel: Int?, + val hubType: Int?, + val premiumProgressBarEnabled: Boolean, + val latestOnboardingQuestionId: Snowflake?, +// val incidents_data: AutomodIncidentsData?, +// val premium_features: GuildPremiumFeatures?, +// val profile: GuildIdentity?, + val approximateMemberCount: Int?, + val approximatePresenceCount: Int?, + ): Guild() + + @Serializable + data class Gateway( + override val id: Snowflake, + override val unavailable: Boolean?, + + val joinedAt: Instant, + val large: Boolean, + val geoRestricted: Boolean?, + val memberCount: Int, +// val members: List, +// val channels: List, +// val threads: List, +// val presences: List, +// val voiceStates: List, +// val activityInstances: List, +// val stageInstances: List, +// val guildScheduledEvents: List, + val dataMode: String, + val properties: Available, // Client state v2, this is the below fields +// val stickers: List, +// val roles: List, +// val emojis: List, +// val soundboardSounds: List, +// override val premiumSubscriptionCount: Int, +// +// override val id: Snowflake, +// override val unavailable: Boolean?, +// +// override val name: String, +// override val icon: String?, +// override val banner: String?, +// override val homeHeader: String?, +// override val splash: String?, +// override val discoverySplash: String?, +// override val ownerId: Snowflake, +// override val applicationId: Snowflake?, +// override val description: String?, +// override val region: String?, +// override val afkChannelId: Snowflake?, +// override val afkTimeout: Int, +// override val widgetEnabled: Boolean?, +// override val widgetChannelId: Snowflake?, +// override val verificationLevel: Int, +// override val defaultMessageNotifications: Int, +// override val explicitContentFilter: Int, +// override val features: List, +// +// override val mfaLevel: Int, +// override val systemChannelId: Snowflake?, +// override val systemChannelFlags: Int, +// override val rulesChannelId: Snowflake?, +// override val publicUpdatesChannelId: Snowflake?, +// override val safetyAlertsChannelId: Snowflake?, +// override val maxPresences: Int?, +// override val maxMembers: Int?, +// override val vanityUrlCode: String?, +// override val premiumTier: Int, +// override val preferredLocale: String, +// override val maxVideoChannelUsers: Int?, +// override val maxStageVideoChannelUsers: Int?, +// override val nsfwLevel: Int, +// override val ownerConfiguredContentLevel: Int?, +// override val hubType: Int?, +// override val premiumProgressBarEnabled: Boolean, +// override val latestOnboardingQuestionId: Snowflake?, +// // override val incidents_data: AutomodIncidentsData? +// // override val premium_features: GuildPremiumFeatures? +// // override val profile: GuildIdentity? +// override val approximateMemberCount: Int?, +// override val approximatePresenceCount: Int?, + ) : Guild() +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 97f594e..203a5c2 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -26,6 +26,9 @@ kotlin { implementation(project(":api:rest")) implementation(project(":common")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.core) + implementation(project.dependencies.platform(libs.koin.bom)) implementation(libs.koin.core) diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/data/guild/GuildGatewayDataSource.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/data/guild/GuildGatewayDataSource.kt new file mode 100644 index 0000000..f6af649 --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/data/guild/GuildGatewayDataSource.kt @@ -0,0 +1,28 @@ +package moe.lava.neon.core.data.guild + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.map +import moe.lava.neon.api.gateway.Event +import moe.lava.neon.api.gateway.GatewayHandler +import moe.lava.neon.core.model.Guild +import moe.lava.neon.api.objects.Guild as ApiGuild + +class GuildGatewayDataSource( + gateway: GatewayHandler, +) { + @OptIn(ExperimentalCoroutinesApi::class) + val flow = gateway.events + .filterIsInstance() + .flatMapConcat { it.guilds.asFlow() } + .filterIsInstance() + .map { apiGuild -> + Guild( + id = apiGuild.id, + name = apiGuild.properties.name, + iconHash = apiGuild.properties.icon, + ) + } +} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/di/CoreModule.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/di/CoreModule.kt index 600b7f2..495281e 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/di/CoreModule.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/di/CoreModule.kt @@ -3,21 +3,27 @@ package moe.lava.neon.core.di import moe.lava.neon.api.ApiClient import moe.lava.neon.api.gateway.GatewayHandler import moe.lava.neon.core.AppSettings +import moe.lava.neon.core.data.guild.GuildGatewayDataSource import moe.lava.neon.core.repository.AuthRepository import moe.lava.neon.core.repository.CaptchaRepository import moe.lava.neon.core.repository.GatewayRepository +import moe.lava.neon.core.repository.GuildRepository import moe.lava.neon.core.repository.UserRepository +import org.koin.core.module.dsl.createdAtStart +import org.koin.core.module.dsl.withOptions import org.koin.dsl.module import org.koin.plugin.module.dsl.single val coreModule = module { factory { ApiClient() } single() + single() + + single() single() single() single() + single() withOptions { createdAtStart() } single() - - single() } diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/model/Guild.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/model/Guild.kt new file mode 100644 index 0000000..c4e9d16 --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/model/Guild.kt @@ -0,0 +1,16 @@ +package moe.lava.neon.core.model + +import kotlinx.serialization.Serializable +import moe.lava.neon.api.objects.Snowflake + +@Serializable +data class Guild( + val id: Snowflake, + val name: String, + val iconHash: String?, +) + +@Serializable +data class UnavailableGuild(val e: String) + + diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/repository/GuildRepository.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/repository/GuildRepository.kt new file mode 100644 index 0000000..81427de --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/repository/GuildRepository.kt @@ -0,0 +1,28 @@ +package moe.lava.neon.core.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import moe.lava.neon.api.objects.Snowflake +import moe.lava.neon.core.data.guild.GuildGatewayDataSource +import moe.lava.neon.core.model.Guild + +class GuildRepository( + gatewaySource: GuildGatewayDataSource, +) { + init { + gatewaySource.flow + .onEach { guild -> + cache[guild.id] = guild + joined[guild.id] = guild + } + .launchIn(CoroutineScope(Dispatchers.IO)) + } + + private val cache = mutableMapOf() + private val joined = mutableMapOf() + + fun get(id: Snowflake) = cache[id] + fun getJoined() = joined.toMap() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 189f3c9..dd03d3c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -70,6 +70,7 @@ kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt index 73467fc..f5afb3e 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt @@ -11,13 +11,17 @@ import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect 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.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.launch @@ -34,6 +38,32 @@ fun Sample( onRequestLogout: () -> Unit, ) { val viewModel: SampleViewModel = koinViewModel() + + val li = LocalLifecycleOwner.current + DisposableEffect(li) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> { + viewModel.disconnect() + } + + Lifecycle.Event.ON_RESUME -> { + viewModel.connect() + } + + Lifecycle.Event.ON_DESTROY -> { + viewModel.disconnect() + } + + else -> {} + } + } + li.lifecycle.addObserver(observer) + onDispose { + li.lifecycle.removeObserver(observer) + } + } + var showContent by remember { mutableStateOf(false) } Column( modifier = Modifier