feat(chat): add animated GIF rendering with tap-to-play animation (#721) (#731)

* 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:
M57 authored and GitHub committed 2026-07-27 18:32:45 +03:00
1 parent c7f7d1dd45
commit 5c1492a80e
32 files changed
+370 -118

No files matched your search

@@ -105,10 +105,12 @@ class ServerStoreMigration(
profile.copy(
baseUrl =
profile.baseUrl
?: ServerEndpoint.fromLegacy(
profile.host,
profile.port,
).baseUrl.toString(),
?: ServerEndpoint
.fromLegacy(
profile.host,
profile.port,
).baseUrl
.toString(),
)
},
selectedProfileId = selectedProfileId,
@@ -19,19 +19,23 @@ class ServerUrlMigration : DataMigration<ServerStoreState> {
state.copy(
baseUrl =
normalizeExisting(state.baseUrl)
?: ServerEndpoint.fromLegacy(
state.host,
state.port,
).baseUrl.toString(),
?: ServerEndpoint
.fromLegacy(
state.host,
state.port,
).baseUrl
.toString(),
connectionProfiles =
state.connectionProfiles.map { profile ->
profile.copy(
baseUrl =
normalizeExisting(profile.baseUrl)
?: ServerEndpoint.fromLegacy(
profile.host,
profile.port,
).baseUrl.toString(),
?: ServerEndpoint
.fromLegacy(
profile.host,
profile.port,
).baseUrl
.toString(),
)
},
)
@@ -427,10 +427,12 @@ object AuthManager {
fun setBaseUrl(baseUrl: String) {
val normalized =
ServerEndpoint.parse(
baseUrl,
CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl.toString()
ServerEndpoint
.parse(
baseUrl,
CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl
.toString()
val selectedId =
getSelectedProfileId() ?: run {
ensureDefaultSelected()
@@ -28,6 +28,13 @@ data class Attachment(
val isImage: Boolean
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
get() = source == AttachmentSource.GATEWAY
@@ -81,8 +81,7 @@ object CookieManager {
.expiresAt(
System.currentTimeMillis() +
10L * 365 * 24 * 60 * 60 * 1000,
)
.hostOnlyDomain(endpoint.baseUrl.host)
).hostOnlyDomain(endpoint.baseUrl.host)
.path(endpoint.baseUrl.encodedPath)
.httpOnly()
if (endpoint.baseUrl.isHttps) builder.secure()
@@ -149,7 +149,12 @@ class ServerEndpoint private constructor(
val converted =
replaceScheme(rawUrl, httpScheme).toHttpUrlOrNull()
?: return "<invalid-websocket-url>"
val redacted = converted.newBuilder().query(null).build().toString()
val redacted =
converted
.newBuilder()
.query(null)
.build()
.toString()
return replaceScheme(redacted, socketScheme)
}
@@ -54,17 +54,39 @@ object BillingRepository {
@Suppress("UNCHECKED_CAST")
private fun anyToJsonElement(value: Any?): JsonElement =
when (value) {
null -> JsonNull
is JsonElement -> value
is Map<*, *> ->
null -> {
JsonNull
}
is JsonElement -> {
value
}
is Map<*, *> -> {
JsonObject(
(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 Number -> JsonPrimitive(value)
else -> JsonPrimitive(value.toString())
}
is List<*> -> {
JsonArray(value.map { anyToJsonElement(it) })
}
is String -> {
JsonPrimitive(value)
}
is Boolean -> {
JsonPrimitive(value)
}
is Number -> {
JsonPrimitive(value)
}
else -> {
JsonPrimitive(value.toString())
}
}
suspend fun getSubscriptionState(): SubscriptionStateResponse? {