Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions android/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.gradle/
.kotlin/
build/
**/build/
local.properties

6 changes: 6 additions & 0 deletions android/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
plugins {
kotlin("jvm") version "2.2.21" apply false
kotlin("plugin.serialization") version "2.2.21" apply false
kotlin("plugin.compose") version "2.2.21" apply false
id("com.android.application") version "9.3.1" apply false
}
32 changes: 32 additions & 0 deletions android/core/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
kotlin("jvm")
kotlin("plugin.serialization")
}

kotlin {
jvmToolchain(17)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}

dependencies {
// api: :app consumes CompanionJson / StateFlow / Session types directly
api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
api("com.squareup.okhttp3:okhttp:4.12.0")

testImplementation(kotlin("test"))
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}

sourceSets.test {
resources.srcDir(rootProject.projectDir.resolve("../ios/Tests/CompanionCoreTests/Fixtures"))
}

tasks.test {
useJUnitPlatform()
}
114 changes: 114 additions & 0 deletions android/core/src/main/kotlin/com/openmausbot/companion/core/Chat.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.openmausbot.companion.core

/** A chat is a bot or a room. They share a thread, which is what every message is keyed by. */
sealed class Chat {
abstract val id: String
abstract val threadId: String
abstract val name: String
abstract val subtitle: String
abstract val unread: Boolean
abstract val busy: Boolean
abstract val color: String

data class BotChat(val bot: Bot) : Chat() {
override val id: String get() = bot.id
override val threadId: String get() = bot.threadId
override val name: String get() = bot.name
override val subtitle: String get() = bot.title
override val unread: Boolean get() = bot.unread
override val busy: Boolean get() = bot.busy == true
override val color: String get() = bot.color
}

data class RoomChat(val room: Room) : Chat() {
override val id: String get() = room.id
override val threadId: String get() = room.threadId
override val name: String get() = room.name
override val subtitle: String get() = "${room.memberIds.size} bots"
override val unread: Boolean get() = room.unread
override val busy: Boolean get() = room.busyBotId != null
override val color: String get() = "blue"
}
}

sealed interface ChatTarget {
val threadId: String

data class Bot(
val botId: String,
override val threadId: String,
) : ChatTarget

data class Room(
val roomId: String,
override val threadId: String,
) : ChatTarget
}

val Chat.target: ChatTarget
get() = when (this) {
is Chat.BotChat -> ChatTarget.Bot(bot.id, threadId)
is Chat.RoomChat -> ChatTarget.Room(room.id, threadId)
}

fun CompanionState.chat(target: ChatTarget): Chat? = when (target) {
is ChatTarget.Bot -> bot(target.botId)?.let(Chat::BotChat)
is ChatTarget.Room -> rooms.firstOrNull { it.id == target.roomId }?.let(Chat::RoomChat)
}

/**
* A chat plus the two things a roster row shows that the record itself does not carry:
* the preview line, and when the thread last moved.
*/
data class ChatSummary(
val chat: Chat,
val preview: String,
val lastActivity: Double,
val pinned: Boolean,
) {
val id: String get() = chat.id
}

/**
* Everything worth showing in the chat list: pinned first, then unread, then most
* recently active. Hidden bots stay hidden. Rooms never pin.
*/
val CompanionState.chatSummaries: List<ChatSummary>
get() {
val chats = bots.filter { it.hidden != true }.map { Chat.BotChat(it) } +
rooms.map { Chat.RoomChat(it) }
return chats
.map { chat ->
val last = visibleTranscript(chat.threadId).lastOrNull()
ChatSummary(
chat = chat,
preview = previewOf(last),
lastActivity = last?.at ?: 0.0,
pinned = pinned(chat),
)
}
.sortedWith(
compareByDescending<ChatSummary> { it.pinned }
.thenByDescending { it.chat.unread }
.thenByDescending { it.lastActivity },
)
}

private fun pinned(chat: Chat): Boolean = when (chat) {
is Chat.BotChat -> chat.bot.pinned == true
is Chat.RoomChat -> false
}

private fun previewOf(last: Message?): String {
if (last == null) return ""
return when (last.kind) {
Message.Kind.TEXT -> last.text.orEmpty()
Message.Kind.OPTIONS -> {
val card = last.card ?: return ""
if (card.isPending && card.subtitle.isNotEmpty()) card.subtitle else card.title
}
Message.Kind.ACTIVITY -> last.tool?.name.orEmpty()
Message.Kind.SCREEN -> "Screenshot"
Message.Kind.UNKNOWN -> last.text.orEmpty()
}
}
Loading
Loading