guilds, lifecycle gateway disconnector, base channels

This commit is contained in:
Cilly Leang 2026-03-21 18:53:24 +11:00
parent fcdd237809
commit 0781606a00
Signed by: cilly
GPG key ID: 6500251E087653C9
14 changed files with 292 additions and 6 deletions

View file

@ -20,7 +20,7 @@ kotlin {
sourceSets {
commonMain.dependencies {
implementation(project(":api:shared"))
api(project(":api:shared"))
implementation(libs.kermit)
implementation(libs.ktor.client.core)

View file

@ -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<UnavailableGuild>,
val guilds: List<@Serializable(GuildSerializer::class) Guild>,
val sessionId: String,
val resumeGatewayUrl: String,
// val application: Application,

View file

@ -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>(Guild::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<Guild> {
return if (element.jsonObject["unavailable"]?.jsonPrimitive?.booleanOrNull == true) {
Guild.Unavailable.serializer()
} else {
Guild.Gateway.serializer()
}
}
}

View file

@ -22,7 +22,7 @@ kotlin {
sourceSets {
commonMain.dependencies {
implementation(project(":api:shared"))
api(project(":api:shared"))
implementation(project(":common"))
implementation(libs.kermit)

View file

@ -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())

View file

@ -0,0 +1,5 @@
package moe.lava.neon.api.objects
data class Channel(
val id: Snowflake,
)

View file

@ -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<String>,
// val stickers: List<Sticker> = listOf(),
// val roles: List<Role> = listOf(),
// val emojis: List<Emoji> = 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<GuildMember>,
// val channels: List<Channel>,
// val threads: List<Channel>,
// val presences: List<Presence>,
// val voiceStates: List<VoiceState>,
// val activityInstances: List<EmbeddedActivityInstance>,
// val stageInstances: List<StageInstance>,
// val guildScheduledEvents: List<GuildScheduledEvent>,
val dataMode: String,
val properties: Available, // Client state v2, this is the below fields
// val stickers: List<Sticker>,
// val roles: List<Role>,
// val emojis: List<Emoji>,
// val soundboardSounds: List<SoundboardSound>,
// 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<String>,
//
// 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()
}

View file

@ -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)

View file

@ -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<Event.Ready>()
.flatMapConcat { it.guilds.asFlow() }
.filterIsInstance<ApiGuild.Gateway>()
.map { apiGuild ->
Guild(
id = apiGuild.id,
name = apiGuild.properties.name,
iconHash = apiGuild.properties.icon,
)
}
}

View file

@ -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<AppSettings>()
single<GatewayHandler>()
single<GuildGatewayDataSource>()
single<AuthRepository>()
single<CaptchaRepository>()
single<GatewayRepository>()
single<GuildRepository>() withOptions { createdAtStart() }
single<UserRepository>()
single<GatewayHandler>()
}

View file

@ -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)

View file

@ -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<Snowflake, Guild>()
private val joined = mutableMapOf<Snowflake, Guild>()
fun get(id: Snowflake) = cache[id]
fun getJoined() = joined.toMap()
}

View file

@ -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" }

View file

@ -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