* feat(chat): add animated GIF rendering with tap-to-play animation Closes #721 * fix(#721): add missing GifImageThumbnail component and format ktlint * fix(analytics): resolve ktlint indentation error in itemsIndexed * feat(chat): add interactive play/pause toggle for animated GIFs * fix(chat): control GIF playback via Animatable start and stop
This commit is contained in:
32 files changed
+370
-118
No files matched your search
@@ -166,6 +166,7 @@ dependencies {
|
|||||||
|
|
||||||
// Image loading
|
// Image loading
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.coil.gif)
|
||||||
|
|
||||||
// Encrypted storage
|
// Encrypted storage
|
||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.m57.hermescontrol
|
package com.m57.hermescontrol
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.os.Build
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.ImageLoaderFactory
|
import coil.ImageLoaderFactory
|
||||||
|
import coil.decode.GifDecoder
|
||||||
|
import coil.decode.ImageDecoderDecoder
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
@@ -28,6 +31,12 @@ class HermesControlApp :
|
|||||||
ImageLoader
|
ImageLoader
|
||||||
.Builder(this)
|
.Builder(this)
|
||||||
.okHttpClient(OkHttpProvider.base)
|
.okHttpClient(OkHttpProvider.base)
|
||||||
.crossfade(true)
|
.components {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
add(ImageDecoderDecoder.Factory())
|
||||||
|
} else {
|
||||||
|
add(GifDecoder.Factory())
|
||||||
|
}
|
||||||
|
}.crossfade(true)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
@@ -105,10 +105,12 @@ class ServerStoreMigration(
|
|||||||
profile.copy(
|
profile.copy(
|
||||||
baseUrl =
|
baseUrl =
|
||||||
profile.baseUrl
|
profile.baseUrl
|
||||||
?: ServerEndpoint.fromLegacy(
|
?: ServerEndpoint
|
||||||
profile.host,
|
.fromLegacy(
|
||||||
profile.port,
|
profile.host,
|
||||||
).baseUrl.toString(),
|
profile.port,
|
||||||
|
).baseUrl
|
||||||
|
.toString(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
selectedProfileId = selectedProfileId,
|
selectedProfileId = selectedProfileId,
|
||||||
|
|||||||
@@ -19,19 +19,23 @@ class ServerUrlMigration : DataMigration<ServerStoreState> {
|
|||||||
state.copy(
|
state.copy(
|
||||||
baseUrl =
|
baseUrl =
|
||||||
normalizeExisting(state.baseUrl)
|
normalizeExisting(state.baseUrl)
|
||||||
?: ServerEndpoint.fromLegacy(
|
?: ServerEndpoint
|
||||||
state.host,
|
.fromLegacy(
|
||||||
state.port,
|
state.host,
|
||||||
).baseUrl.toString(),
|
state.port,
|
||||||
|
).baseUrl
|
||||||
|
.toString(),
|
||||||
connectionProfiles =
|
connectionProfiles =
|
||||||
state.connectionProfiles.map { profile ->
|
state.connectionProfiles.map { profile ->
|
||||||
profile.copy(
|
profile.copy(
|
||||||
baseUrl =
|
baseUrl =
|
||||||
normalizeExisting(profile.baseUrl)
|
normalizeExisting(profile.baseUrl)
|
||||||
?: ServerEndpoint.fromLegacy(
|
?: ServerEndpoint
|
||||||
profile.host,
|
.fromLegacy(
|
||||||
profile.port,
|
profile.host,
|
||||||
).baseUrl.toString(),
|
profile.port,
|
||||||
|
).baseUrl
|
||||||
|
.toString(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -427,10 +427,12 @@ object AuthManager {
|
|||||||
|
|
||||||
fun setBaseUrl(baseUrl: String) {
|
fun setBaseUrl(baseUrl: String) {
|
||||||
val normalized =
|
val normalized =
|
||||||
ServerEndpoint.parse(
|
ServerEndpoint
|
||||||
baseUrl,
|
.parse(
|
||||||
CleartextPolicy.ALLOW_WITH_WARNING,
|
baseUrl,
|
||||||
).baseUrl.toString()
|
CleartextPolicy.ALLOW_WITH_WARNING,
|
||||||
|
).baseUrl
|
||||||
|
.toString()
|
||||||
val selectedId =
|
val selectedId =
|
||||||
getSelectedProfileId() ?: run {
|
getSelectedProfileId() ?: run {
|
||||||
ensureDefaultSelected()
|
ensureDefaultSelected()
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ data class Attachment(
|
|||||||
val isImage: Boolean
|
val isImage: Boolean
|
||||||
get() = mimeType.startsWith("image/")
|
get() = mimeType.startsWith("image/")
|
||||||
|
|
||||||
|
val isGif: Boolean
|
||||||
|
get() =
|
||||||
|
mimeType.equals("image/gif", ignoreCase = true) ||
|
||||||
|
fileExtension == "gif" ||
|
||||||
|
uri.contains(".gif", ignoreCase = true) ||
|
||||||
|
uri.startsWith("data:image/gif", ignoreCase = true)
|
||||||
|
|
||||||
val isGateway: Boolean
|
val isGateway: Boolean
|
||||||
get() = source == AttachmentSource.GATEWAY
|
get() = source == AttachmentSource.GATEWAY
|
||||||
|
|
||||||
|
|||||||
@@ -81,8 +81,7 @@ object CookieManager {
|
|||||||
.expiresAt(
|
.expiresAt(
|
||||||
System.currentTimeMillis() +
|
System.currentTimeMillis() +
|
||||||
10L * 365 * 24 * 60 * 60 * 1000,
|
10L * 365 * 24 * 60 * 60 * 1000,
|
||||||
)
|
).hostOnlyDomain(endpoint.baseUrl.host)
|
||||||
.hostOnlyDomain(endpoint.baseUrl.host)
|
|
||||||
.path(endpoint.baseUrl.encodedPath)
|
.path(endpoint.baseUrl.encodedPath)
|
||||||
.httpOnly()
|
.httpOnly()
|
||||||
if (endpoint.baseUrl.isHttps) builder.secure()
|
if (endpoint.baseUrl.isHttps) builder.secure()
|
||||||
|
|||||||
@@ -149,7 +149,12 @@ class ServerEndpoint private constructor(
|
|||||||
val converted =
|
val converted =
|
||||||
replaceScheme(rawUrl, httpScheme).toHttpUrlOrNull()
|
replaceScheme(rawUrl, httpScheme).toHttpUrlOrNull()
|
||||||
?: return "<invalid-websocket-url>"
|
?: return "<invalid-websocket-url>"
|
||||||
val redacted = converted.newBuilder().query(null).build().toString()
|
val redacted =
|
||||||
|
converted
|
||||||
|
.newBuilder()
|
||||||
|
.query(null)
|
||||||
|
.build()
|
||||||
|
.toString()
|
||||||
return replaceScheme(redacted, socketScheme)
|
return replaceScheme(redacted, socketScheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,17 +54,39 @@ object BillingRepository {
|
|||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
private fun anyToJsonElement(value: Any?): JsonElement =
|
private fun anyToJsonElement(value: Any?): JsonElement =
|
||||||
when (value) {
|
when (value) {
|
||||||
null -> JsonNull
|
null -> {
|
||||||
is JsonElement -> value
|
JsonNull
|
||||||
is Map<*, *> ->
|
}
|
||||||
|
|
||||||
|
is JsonElement -> {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
is Map<*, *> -> {
|
||||||
JsonObject(
|
JsonObject(
|
||||||
(value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) },
|
(value as Map<String, Any?>).mapValues { (_, v) -> anyToJsonElement(v) },
|
||||||
)
|
)
|
||||||
is List<*> -> JsonArray(value.map { anyToJsonElement(it) })
|
}
|
||||||
is String -> JsonPrimitive(value)
|
|
||||||
is Boolean -> JsonPrimitive(value)
|
is List<*> -> {
|
||||||
is Number -> JsonPrimitive(value)
|
JsonArray(value.map { anyToJsonElement(it) })
|
||||||
else -> JsonPrimitive(value.toString())
|
}
|
||||||
|
|
||||||
|
is String -> {
|
||||||
|
JsonPrimitive(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Boolean -> {
|
||||||
|
JsonPrimitive(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Number -> {
|
||||||
|
JsonPrimitive(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
JsonPrimitive(value.toString())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getSubscriptionState(): SubscriptionStateResponse? {
|
suspend fun getSubscriptionState(): SubscriptionStateResponse? {
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ data class AchievementsUiState(
|
|||||||
val toastMessage: String? = null,
|
val toastMessage: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class AchievementsViewModel : ViewModel(), ToastHost {
|
class AchievementsViewModel :
|
||||||
|
ViewModel(),
|
||||||
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(AchievementsUiState())
|
private val _uiState = MutableStateFlow(AchievementsUiState())
|
||||||
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
|||||||
@@ -210,12 +210,12 @@ private fun AnalyticsContent(
|
|||||||
ModelRow(model = model)
|
ModelRow(model = model)
|
||||||
}
|
}
|
||||||
} else if ((usage?.by_model ?: emptyList()).isNotEmpty()) {
|
} else if ((usage?.by_model ?: emptyList()).isNotEmpty()) {
|
||||||
itemsIndexed(usage?.by_model ?: emptyList(), key = {
|
itemsIndexed(
|
||||||
index,
|
usage?.by_model ?: emptyList(),
|
||||||
it,
|
key = { index, it ->
|
||||||
->
|
"usage_${it.model}_$index"
|
||||||
"usage_${it.model}_$index"
|
},
|
||||||
}) { _, entry ->
|
) { _, entry ->
|
||||||
ModelEntryRow(entry = entry)
|
ModelEntryRow(entry = entry)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -232,12 +232,12 @@ private fun AnalyticsContent(
|
|||||||
|
|
||||||
if (selectedSectionTab == 1) {
|
if (selectedSectionTab == 1) {
|
||||||
if ((usage?.skills?.top_skills ?: emptyList()).isNotEmpty()) {
|
if ((usage?.skills?.top_skills ?: emptyList()).isNotEmpty()) {
|
||||||
itemsIndexed(usage?.skills?.top_skills ?: emptyList(), key = {
|
itemsIndexed(
|
||||||
index,
|
usage?.skills?.top_skills ?: emptyList(),
|
||||||
it,
|
key = { index, it ->
|
||||||
->
|
"skill_${it.skill}_$index"
|
||||||
"skill_${it.skill}_$index"
|
},
|
||||||
}) { _, skill ->
|
) { _, skill ->
|
||||||
SkillRow(skill = skill)
|
SkillRow(skill = skill)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -162,11 +162,21 @@ class BillingViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
val msg =
|
val msg =
|
||||||
when {
|
when {
|
||||||
resp.requires_action == true && resp.recovery_url != null ->
|
resp.requires_action == true && resp.recovery_url != null -> {
|
||||||
"Action required — open: ${resp.recovery_url}"
|
"Action required — open: ${resp.recovery_url}"
|
||||||
resp.payment_failed == true -> resp.message ?: "Payment failed"
|
}
|
||||||
resp.ok == true -> "Upgrade successful"
|
|
||||||
else -> resp.message ?: "Upgrade requested"
|
resp.payment_failed == true -> {
|
||||||
|
resp.message ?: "Payment failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.ok == true -> {
|
||||||
|
"Upgrade successful"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
resp.message ?: "Upgrade requested"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(isActionInFlight = false, actionMessage = msg) }
|
_uiState.update { it.copy(isActionInFlight = false, actionMessage = msg) }
|
||||||
load()
|
load()
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
@@ -78,7 +77,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
@@ -2301,24 +2299,20 @@ private fun InlineAttachment(
|
|||||||
) {
|
) {
|
||||||
val clickable = Modifier.clickable { onOpen(attachment) }
|
val clickable = Modifier.clickable { onOpen(attachment) }
|
||||||
if (attachment.isImage) {
|
if (attachment.isImage) {
|
||||||
// Image attachment — show as a rounded thumbnail; tap opens the viewer.
|
// Image / GIF attachment — show thumbnail with GIF badge & tap-to-play animation.
|
||||||
AsyncImage(
|
com.m57.hermescontrol.ui.chat.components.GifImageThumbnail(
|
||||||
model = attachment.uri,
|
model = attachment.uri,
|
||||||
contentDescription = attachment.name,
|
contentDescription = attachment.name,
|
||||||
modifier =
|
isGif = attachment.isGif,
|
||||||
Modifier
|
onClick = {
|
||||||
.fillMaxWidth()
|
onImageClick(
|
||||||
.clip(RoundedCornerShape(12.dp))
|
ImageViewerModel(
|
||||||
.clickable {
|
model = attachment.uri,
|
||||||
onImageClick(
|
name = attachment.name,
|
||||||
ImageViewerModel(
|
mimeType = if (attachment.isGif) "image/gif" else attachment.mimeType,
|
||||||
model = attachment.uri,
|
),
|
||||||
name = attachment.name,
|
)
|
||||||
mimeType = attachment.mimeType,
|
},
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
contentScale = ContentScale.FillWidth,
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Non-image file — show a card with file icon and name. Tapping fetches
|
// Non-image file — show a card with file icon and name. Tapping fetches
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ object ImageBytesResolver {
|
|||||||
) : Result
|
) : Result
|
||||||
|
|
||||||
/** Could not load the image; [message] is safe to surface to the user. */
|
/** Could not load the image; [message] is safe to surface to the user. */
|
||||||
data class Error(val message: String) : Result
|
data class Error(
|
||||||
|
val message: String,
|
||||||
|
) : Result
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun resolve(
|
suspend fun resolve(
|
||||||
@@ -102,7 +104,8 @@ object ImageBytesResolver {
|
|||||||
if (!resp.isSuccessful) return Result.Error("Download failed (HTTP ${resp.code})")
|
if (!resp.isSuccessful) return Result.Error("Download failed (HTTP ${resp.code})")
|
||||||
val bytes = resp.body.bytes()
|
val bytes = resp.body.bytes()
|
||||||
val mime =
|
val mime =
|
||||||
resp.header("Content-Type")
|
resp
|
||||||
|
.header("Content-Type")
|
||||||
?.substringBefore(";")
|
?.substringBefore(";")
|
||||||
?.ifBlank { fallbackMime }
|
?.ifBlank { fallbackMime }
|
||||||
?: fallbackMime
|
?: fallbackMime
|
||||||
|
|||||||
@@ -96,8 +96,9 @@ fun ImageViewerDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is ImageBytesResolver.Result.Error ->
|
is ImageBytesResolver.Result.Error -> {
|
||||||
String.format(loadFailedFmt, resolved.message)
|
String.format(loadFailedFmt, resolved.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
isBusy = false
|
isBusy = false
|
||||||
@@ -124,7 +125,9 @@ fun ImageViewerDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is ImageBytesResolver.Result.Error -> null
|
is ImageBytesResolver.Result.Error -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
isBusy = false
|
isBusy = false
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -269,35 +268,31 @@ fun MarkdownText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is MdBlock.Image -> {
|
is MdBlock.Image -> {
|
||||||
// AsyncImage is a Compose runtime composable — load it lazily
|
|
||||||
// behind remember so the parser stays pure and testable (the
|
|
||||||
// parseBlocks/parseInline unit tests never touch Compose).
|
|
||||||
val model: Any = remember(block.uri) { resolveImageUrl(block.uri) }
|
val model: Any = remember(block.uri) { resolveImageUrl(block.uri) }
|
||||||
androidx.compose.foundation.layout.Box(
|
val isGif =
|
||||||
|
remember(block.uri) {
|
||||||
|
block.uri.contains(".gif", ignoreCase = true) ||
|
||||||
|
block.uri.startsWith("data:image/gif", ignoreCase = true)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.padding(vertical = 4.dp),
|
||||||
.clickable(
|
|
||||||
onClick = {
|
|
||||||
onImageClick(
|
|
||||||
ImageViewerModel(
|
|
||||||
model = block.uri,
|
|
||||||
name = block.alt,
|
|
||||||
mimeType = "image/*",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
).padding(vertical = 4.dp),
|
|
||||||
) {
|
) {
|
||||||
coil.compose.AsyncImage(
|
com.m57.hermescontrol.ui.chat.components.GifImageThumbnail(
|
||||||
model = model,
|
model = model,
|
||||||
contentDescription = block.alt.ifBlank { null },
|
contentDescription = block.alt.ifBlank { null },
|
||||||
modifier =
|
isGif = isGif,
|
||||||
Modifier
|
onClick = {
|
||||||
.fillMaxWidth()
|
onImageClick(
|
||||||
.clip(RoundedCornerShape(12.dp)),
|
ImageViewerModel(
|
||||||
contentScale = androidx.compose.ui.layout.ContentScale.FillWidth,
|
model = block.uri,
|
||||||
|
name = block.alt,
|
||||||
|
mimeType = if (isGif) "image/gif" else "image/*",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,8 +200,8 @@ fun ComposerToolbar(
|
|||||||
* "xhigh", "max", "ultra", or null for model default.
|
* "xhigh", "max", "ultra", or null for model default.
|
||||||
* @return Display string such as "None", "Low", "XHigh", "Ultra", etc.
|
* @return Display string such as "None", "Low", "XHigh", "Ultra", etc.
|
||||||
*/
|
*/
|
||||||
private fun buildReasoningLabel(level: String?): String {
|
private fun buildReasoningLabel(level: String?): String =
|
||||||
return when (level) {
|
when (level) {
|
||||||
null -> "Med"
|
null -> "Med"
|
||||||
"none" -> "None"
|
"none" -> "None"
|
||||||
"minimal" -> "Minimal"
|
"minimal" -> "Minimal"
|
||||||
@@ -213,4 +213,3 @@ private fun buildReasoningLabel(level: String?): String {
|
|||||||
"ultra" -> "Ultra"
|
"ultra" -> "Ultra"
|
||||||
else -> level
|
else -> level
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GifImageThumbnail(
|
||||||
|
model: Any,
|
||||||
|
contentDescription: String?,
|
||||||
|
isGif: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var isPlaying by remember { mutableStateOf(true) }
|
||||||
|
var animatableDrawable by remember { mutableStateOf<Animatable?>(null) }
|
||||||
|
|
||||||
|
val togglePlayPause: () -> Unit = {
|
||||||
|
val nextState = !isPlaying
|
||||||
|
isPlaying = nextState
|
||||||
|
animatableDrawable?.let { anim ->
|
||||||
|
if (nextState) {
|
||||||
|
anim.start()
|
||||||
|
} else {
|
||||||
|
anim.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.clickable { onClick() },
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = model,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
onSuccess = { result ->
|
||||||
|
val drawable = result.result.drawable
|
||||||
|
if (drawable is Animatable) {
|
||||||
|
animatableDrawable = drawable
|
||||||
|
if (isPlaying) {
|
||||||
|
drawable.start()
|
||||||
|
} else {
|
||||||
|
drawable.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp)),
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isGif) {
|
||||||
|
// Play / Pause toggle badge
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(8.dp)
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
).clickable { togglePlayPause() }
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = if (isPlaying) "Pause GIF" else "Play GIF",
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = " GIF",
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center overlay button when paused
|
||||||
|
if (!isPlaying) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f),
|
||||||
|
shape = CircleShape,
|
||||||
|
).clickable { togglePlayPause() }
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = "Play GIF",
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -350,7 +350,10 @@ internal fun highlightSyntax(code: String): AnnotatedString =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class TokenPattern(val regex: Regex, val color: Color)
|
private data class TokenPattern(
|
||||||
|
val regex: Regex,
|
||||||
|
val color: Color,
|
||||||
|
)
|
||||||
|
|
||||||
private fun copyToClipboard(
|
private fun copyToClipboard(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|||||||
@@ -207,7 +207,11 @@ private fun shimmerBrush(): Brush {
|
|||||||
val base = MaterialTheme.colorScheme.surfaceVariant
|
val base = MaterialTheme.colorScheme.surfaceVariant
|
||||||
val highlight = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
|
val highlight = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
|
||||||
val transition = rememberInfiniteTransition(label = "shimmer")
|
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||||
val widthPx = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp.dp.toPx() }
|
val widthPx =
|
||||||
|
with(LocalDensity.current) {
|
||||||
|
LocalConfiguration.current.screenWidthDp.dp
|
||||||
|
.toPx()
|
||||||
|
}
|
||||||
val offset by transition.animateFloat(
|
val offset by transition.animateFloat(
|
||||||
initialValue = -widthPx,
|
initialValue = -widthPx,
|
||||||
targetValue = widthPx,
|
targetValue = widthPx,
|
||||||
|
|||||||
@@ -535,7 +535,9 @@ class ModelViewModel :
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Success -> activeProfileNameResResult.data.active
|
is NetworkResult.Success -> {
|
||||||
|
activeProfileNameResResult.data.active
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ data class ProcessesUiState(
|
|||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class ProcessesViewModel : ViewModel(), ToastHost {
|
class ProcessesViewModel :
|
||||||
|
ViewModel(),
|
||||||
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(ProcessesUiState())
|
private val _uiState = MutableStateFlow(ProcessesUiState())
|
||||||
val uiState: StateFlow<ProcessesUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<ProcessesUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ data class SessionsUiState(
|
|||||||
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
||||||
}
|
}
|
||||||
|
|
||||||
class SessionsViewModel : ViewModel(), ToastHost {
|
class SessionsViewModel :
|
||||||
|
ViewModel(),
|
||||||
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(SessionsUiState())
|
private val _uiState = MutableStateFlow(SessionsUiState())
|
||||||
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
|||||||
@@ -25,20 +25,28 @@ object LocaleContextWrapper {
|
|||||||
*/
|
*/
|
||||||
fun localeForCode(code: String): Locale =
|
fun localeForCode(code: String): Locale =
|
||||||
when {
|
when {
|
||||||
code.isEmpty() || code == SYSTEM_LANGUAGE -> Locale.getDefault()
|
code.isEmpty() || code == SYSTEM_LANGUAGE -> {
|
||||||
|
Locale.getDefault()
|
||||||
|
}
|
||||||
|
|
||||||
code.contains("-r", ignoreCase = true) -> {
|
code.contains("-r", ignoreCase = true) -> {
|
||||||
val parts = code.split("-r", ignoreCase = true)
|
val parts = code.split("-r", ignoreCase = true)
|
||||||
Locale(parts[0], parts.getOrElse(1) { "" })
|
Locale(parts[0], parts.getOrElse(1) { "" })
|
||||||
}
|
}
|
||||||
|
|
||||||
code.contains("-") -> {
|
code.contains("-") -> {
|
||||||
val parts = code.split("-")
|
val parts = code.split("-")
|
||||||
Locale(parts[0], parts.getOrElse(1) { "" })
|
Locale(parts[0], parts.getOrElse(1) { "" })
|
||||||
}
|
}
|
||||||
|
|
||||||
code.contains("_") -> {
|
code.contains("_") -> {
|
||||||
val parts = code.split("_")
|
val parts = code.split("_")
|
||||||
Locale(parts[0], parts.getOrElse(1) { "" })
|
Locale(parts[0], parts.getOrElse(1) { "" })
|
||||||
}
|
}
|
||||||
else -> Locale(code)
|
|
||||||
|
else -> {
|
||||||
|
Locale(code)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -92,18 +92,17 @@ class ChatMessageMapperTest {
|
|||||||
assertNull(createEntityWithToolStatus(null).toUiModel().toolStatus)
|
assertNull(createEntityWithToolStatus(null).toUiModel().toolStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createEntityWithRole(role: String): ChatMessageEntity {
|
private fun createEntityWithRole(role: String): ChatMessageEntity =
|
||||||
return ChatMessageEntity(
|
ChatMessageEntity(
|
||||||
id = "id",
|
id = "id",
|
||||||
sessionId = "session",
|
sessionId = "session",
|
||||||
role = role,
|
role = role,
|
||||||
content = "content",
|
content = "content",
|
||||||
timestamp = 0L,
|
timestamp = 0L,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
private fun createEntityWithToolStatus(status: String?): ChatMessageEntity {
|
private fun createEntityWithToolStatus(status: String?): ChatMessageEntity =
|
||||||
return ChatMessageEntity(
|
ChatMessageEntity(
|
||||||
id = "id",
|
id = "id",
|
||||||
sessionId = "session",
|
sessionId = "session",
|
||||||
role = "TOOL",
|
role = "TOOL",
|
||||||
@@ -111,7 +110,6 @@ class ChatMessageMapperTest {
|
|||||||
timestamp = 0L,
|
timestamp = 0L,
|
||||||
toolStatus = status,
|
toolStatus = status,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun entityToUiModelCarriesReasoningText() {
|
fun entityToUiModelCarriesReasoningText() {
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ class CallExtTest {
|
|||||||
val call = mockk<Call>()
|
val call = mockk<Call>()
|
||||||
val request = Request.Builder().url("http://localhost").build()
|
val request = Request.Builder().url("http://localhost").build()
|
||||||
val response =
|
val response =
|
||||||
Response.Builder()
|
Response
|
||||||
|
.Builder()
|
||||||
.request(request)
|
.request(request)
|
||||||
.protocol(Protocol.HTTP_1_1)
|
.protocol(Protocol.HTTP_1_1)
|
||||||
.code(200)
|
.code(200)
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ class CookieManagerTest {
|
|||||||
return jar
|
return jar
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ep(host: String): ServerEndpoint {
|
private fun ep(host: String): ServerEndpoint = ServerEndpoint.fromLegacy(host, 9119)
|
||||||
return ServerEndpoint.fromLegacy(host, 9119)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun httpsEp(
|
private fun httpsEp(
|
||||||
host: String,
|
host: String,
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ class HermesWsClientTest {
|
|||||||
every { AuthManager.serverStore } returns
|
every { AuthManager.serverStore } returns
|
||||||
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||||
every { it.getLatestState() } returns
|
every { it.getLatestState() } returns
|
||||||
com.m57.hermescontrol.data.config.ServerStoreState()
|
com.m57.hermescontrol.data.config
|
||||||
|
.ServerStoreState()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue #470: clients are built through OkHttpProvider, which now
|
// Issue #470: clients are built through OkHttpProvider, which now
|
||||||
@@ -496,7 +497,8 @@ class HermesWsClientTest {
|
|||||||
every { AuthManager.serverStore } returns
|
every { AuthManager.serverStore } returns
|
||||||
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||||
every { it.getLatestState() } returns
|
every { it.getLatestState() } returns
|
||||||
com.m57.hermescontrol.data.config.ServerStoreState(wsAuthParam = "ticket")
|
com.m57.hermescontrol.data.config
|
||||||
|
.ServerStoreState(wsAuthParam = "ticket")
|
||||||
}
|
}
|
||||||
// No bare-name session cookie present (the prefixed one is server-side).
|
// No bare-name session cookie present (the prefixed one is server-side).
|
||||||
every { AuthManager.getSessionCookie() } returns null
|
every { AuthManager.getSessionCookie() } returns null
|
||||||
|
|||||||
@@ -2032,6 +2032,9 @@ class ChatViewModelTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
assertNotNull(vm.uiState.value.openError)
|
assertNotNull(vm.uiState.value.openError)
|
||||||
assertTrue(vm.uiState.value.openError!!.contains("missing.pdf"))
|
assertTrue(
|
||||||
|
vm.uiState.value.openError!!
|
||||||
|
.contains("missing.pdf"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,4 +222,26 @@ class MarkdownTextFeatureTest {
|
|||||||
val blocks = parseBlocks(md)
|
val blocks = parseBlocks(md)
|
||||||
assertTrue("should remain a paragraph", blocks.single() is MdBlock.Paragraph)
|
assertTrue("should remain a paragraph", blocks.single() is MdBlock.Paragraph)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 13. Attachment isGif check (issue #721)
|
||||||
|
@Test
|
||||||
|
fun testAttachment_isGif() {
|
||||||
|
val gifAttachment =
|
||||||
|
com.m57.hermescontrol.data.model.Attachment(
|
||||||
|
uri = "content://media/1.gif",
|
||||||
|
name = "test.gif",
|
||||||
|
mimeType = "image/gif",
|
||||||
|
)
|
||||||
|
assertTrue(gifAttachment.isImage)
|
||||||
|
assertTrue(gifAttachment.isGif)
|
||||||
|
|
||||||
|
val nonGifAttachment =
|
||||||
|
com.m57.hermescontrol.data.model.Attachment(
|
||||||
|
uri = "content://media/1.jpg",
|
||||||
|
name = "test.jpg",
|
||||||
|
mimeType = "image/jpeg",
|
||||||
|
)
|
||||||
|
assertTrue(nonGifAttachment.isImage)
|
||||||
|
assertFalse(nonGifAttachment.isGif)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +191,9 @@ class SlashCommandDispatchRpcTest {
|
|||||||
vm.sendMessage("/help")
|
vm.sendMessage("/help")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
val last = vm.uiState.value.messages.lastOrNull()
|
val last =
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.lastOrNull()
|
||||||
assertEquals("⚠️ /help: session busy — /interrupt first", last?.content)
|
assertEquals("⚠️ /help: session busy — /interrupt first", last?.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +234,9 @@ class SlashCommandDispatchRpcTest {
|
|||||||
assertEquals(sessionId, params["session_id"])
|
assertEquals(sessionId, params["session_id"])
|
||||||
|
|
||||||
// And the slash.exec output must be surfaced to the user.
|
// And the slash.exec output must be surfaced to the user.
|
||||||
val last = vm.uiState.value.messages.lastOrNull()
|
val last =
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.lastOrNull()
|
||||||
assertEquals("STATUS: gateway reachable", last?.content)
|
assertEquals("STATUS: gateway reachable", last?.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +258,9 @@ class SlashCommandDispatchRpcTest {
|
|||||||
vm.sendMessage("/redraw")
|
vm.sendMessage("/redraw")
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
val last = vm.uiState.value.messages.lastOrNull()
|
val last =
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.lastOrNull()
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"expected a 'not supported on mobile' message, got: ${last?.content}",
|
"expected a 'not supported on mobile' message, got: ${last?.content}",
|
||||||
last?.content?.contains("not supported on mobile") == true,
|
last?.content?.contains("not supported on mobile") == true,
|
||||||
@@ -292,7 +298,9 @@ class SlashCommandDispatchRpcTest {
|
|||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Both RPCs fail -> the secondary slash.exec error must surface.
|
// Both RPCs fail -> the secondary slash.exec error must surface.
|
||||||
val last = vm.uiState.value.messages.lastOrNull()
|
val last =
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.lastOrNull()
|
||||||
assertEquals("⚠️ /status: slash worker start failed: boom", last?.content)
|
assertEquals("⚠️ /status: slash worker start failed: boom", last?.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,6 +335,11 @@ class SlashCommandDispatchRpcTest {
|
|||||||
// must NOT append an assistant bubble — so the last message is still
|
// must NOT append an assistant bubble — so the last message is still
|
||||||
// the user's own command, and only one message was added.
|
// the user's own command, and only one message was added.
|
||||||
assertEquals(before + 1, vm.uiState.value.messages.size)
|
assertEquals(before + 1, vm.uiState.value.messages.size)
|
||||||
assertEquals("/status", vm.uiState.value.messages.lastOrNull()?.content)
|
assertEquals(
|
||||||
|
"/status",
|
||||||
|
vm.uiState.value.messages
|
||||||
|
.lastOrNull()
|
||||||
|
?.content,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,7 @@ sqlcipher = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipher
|
|||||||
|
|
||||||
# Image loading
|
# Image loading
|
||||||
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
|
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
|
||||||
|
coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
|
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
|
||||||
|
|||||||
Reference in new issue
Block a user