fix(wallet): replace text-marker hack with proper raw event API (room.sendRaw + MsgLikeKind.Other)
- Add Timeline.sendRaw() to send custom Matrix events - Add CustomEventContent type for receiving custom events - Update TimelineEventContentMapper to handle MsgLikeKind.Other - Update TimelineItemContentFactory to intercept payment events - Rewrite DefaultPaymentEventSender to use sendRaw instead of text markers - Update TimelineItemContentPaymentFactory to parse raw JSON - Remove text-marker detection from TimelineItemContentMessageFactory - Update tests to use raw event API - Mark raw event SDK blocker as RESOLVED in BLOCKERS.md Event type: co.sulkta.payment.request (reverse-domain format) Status updates: co.sulkta.payment.status Benefits: - Proper Matrix protocol compliance - No JSON embedded in text messages - Events won't be indexed by search - Clean separation from regular messages
This commit is contained in:
parent
adee67cf0d
commit
f2b95d6b8a
10 changed files with 264 additions and 189 deletions
74
BLOCKERS.md
74
BLOCKERS.md
|
|
@ -156,55 +156,51 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Task 8: Raw Event Handling ✅ COMPLETE
|
## Task 8: Raw Event Handling ✅ COMPLETE (UPGRADED)
|
||||||
|
|
||||||
|
### ✅ RESOLVED: SDK Raw Event API
|
||||||
|
**Previous blocker:** Matrix Rust SDK did not expose raw event sending or raw JSON access.
|
||||||
|
|
||||||
|
**Resolution:** The SDK (version 26.03.24) now provides:
|
||||||
|
- `Timeline.sendRaw(eventType: String, content: String)` — Sends custom event types
|
||||||
|
- `MsgLikeKind.Other` with `eventType` field — Receives custom events
|
||||||
|
- `TimelineItemDebugInfo.originalJson` — Access to raw event JSON via debug info provider
|
||||||
|
|
||||||
|
**Implementation updated to use proper raw events instead of text markers.**
|
||||||
|
|
||||||
### Completed
|
### Completed
|
||||||
- ✅ **PaymentEventSender.kt** — Interface for sending payment events
|
- ✅ **PaymentEventSender.kt** — Interface for sending payment events
|
||||||
- ✅ **DefaultPaymentEventSender.kt** — Implementation
|
- ✅ **DefaultPaymentEventSender.kt** — Implementation using raw events
|
||||||
- Sends payment as formatted text message with JSON payload
|
- Uses `timeline.sendRaw(eventType, content)` to send custom events
|
||||||
- Format: `[cardano-payment:v1]{...json...}\n💰 Sent X ADA`
|
- Event type: `co.sulkta.payment.request` (reverse-domain format)
|
||||||
- HTML body includes data-payment attribute for future parsing
|
- Status updates: `co.sulkta.payment.status`
|
||||||
- Status updates use separate marker: `[cardano-payment-status:v1]`
|
- No text marker hack — proper Matrix custom events
|
||||||
- ✅ **TimelineItemContentPaymentFactory.kt** — Parser for payment messages
|
- ✅ **TimelineItemContentPaymentFactory.kt** — Parser for payment events
|
||||||
- `isPaymentEvent(body)` — Detects payment marker
|
- `isPaymentEventType(eventType)` — Checks for payment event type
|
||||||
- `isPaymentStatusUpdate(body)` — Detects status update marker
|
- `isStatusUpdateEventType(eventType)` — Checks for status update type
|
||||||
- `createFromBody(body, isSentByMe)` — Parses text message body
|
- `createFromRaw(json, isSentByMe)` — Parses raw JSON from custom events
|
||||||
- `createFromRaw(json, isSentByMe)` — Parses raw JSON (for future SDK extension)
|
- Supports both camelCase and snake_case field names
|
||||||
- Graceful error handling — returns null on malformed JSON
|
- Graceful error handling — returns null on malformed JSON
|
||||||
- ✅ **TimelineItemContentMessageFactory.kt** — Modified to intercept payments
|
- ✅ **TimelineEventContentMapper.kt** — Maps `MsgLikeKind.Other` to `CustomEventContent`
|
||||||
- Added paymentFactory dependency
|
- ✅ **TimelineItemContentFactory.kt** — Handles `CustomEventContent` for payments
|
||||||
- Added isSentByMe parameter to create()
|
- Gets raw JSON via `timelineItemDebugInfoProvider().originalJson`
|
||||||
- TextMessageType checks for payment marker before creating text content
|
- Delegates to paymentFactory for payment event types
|
||||||
- ✅ **TimelineItemContentFactory.kt** — Passes isSentByMe to message factory
|
- ✅ **CustomEventContent.kt** — New EventContent type for custom events
|
||||||
|
- ✅ **Timeline.sendRaw()** — Added to Timeline interface and RustTimeline implementation
|
||||||
- ✅ **FakePaymentEventSender.kt** — Test fake
|
- ✅ **FakePaymentEventSender.kt** — Test fake
|
||||||
- ✅ **TimelineItemContentPaymentFactoryTest.kt** — Unit tests
|
- ✅ **TimelineItemContentPaymentFactoryTest.kt** — Updated unit tests
|
||||||
|
|
||||||
### SDK Limitations & Approach
|
|
||||||
The Matrix Rust SDK does NOT expose:
|
|
||||||
- Raw event sending (`room.sendRawEvent()`)
|
|
||||||
- Raw JSON access for UnknownContent
|
|
||||||
|
|
||||||
**Workaround implemented:**
|
|
||||||
Instead of custom event types, we encode payment data in standard text messages:
|
|
||||||
```
|
|
||||||
[cardano-payment:v1]{"amount_lovelace":10000000,"to_address":"...","from_address":"...","tx_hash":"...","status":"pending","network":"testnet"}
|
|
||||||
💰 Sent 10 ADA
|
|
||||||
```
|
|
||||||
|
|
||||||
This approach:
|
|
||||||
- Works with existing SDK (no fork needed)
|
|
||||||
- Falls back gracefully (non-wallet clients see "💰 Sent 10 ADA")
|
|
||||||
- Can be upgraded to proper custom events when SDK exposes raw event APIs
|
|
||||||
|
|
||||||
### m.replace Status Updates
|
### m.replace Status Updates
|
||||||
**Decision:** Due to SDK limitations (no direct access to m.replace relations), status updates are sent as new messages rather than event replacements.
|
**Decision:** Status updates are sent as separate events of type `co.sulkta.payment.status`.
|
||||||
|
|
||||||
**Future improvement:** When SDK exposes event relations, refactor to use m.replace for cleaner status update thread.
|
**Future improvement:** When SDK exposes event relations, refactor to use m.replace for cleaner status update thread.
|
||||||
|
|
||||||
### Potential Issues
|
### Benefits of Raw Event Approach
|
||||||
- ⚠️ Status updates create new timeline events (not ideal, but works)
|
- ✅ Proper Matrix protocol compliance (custom event types, not hacked text)
|
||||||
- ⚠️ Payment messages may be indexed by search (contains JSON)
|
- ✅ Non-wallet clients see "Unknown event" instead of JSON-in-text
|
||||||
- ⚠️ Very long addresses in JSON may hit message length limits (unlikely in practice)
|
- ✅ Clean separation of payment events from regular messages
|
||||||
|
- ✅ Events won't be indexed by message search
|
||||||
|
- ✅ No message length limits concern
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,9 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.CustomEventContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||||
|
import io.element.android.features.wallet.impl.timeline.TimelineItemContentPaymentFactory
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
class TimelineItemContentFactory(
|
class TimelineItemContentFactory(
|
||||||
|
|
@ -49,9 +51,25 @@ class TimelineItemContentFactory(
|
||||||
private val stateFactory: TimelineItemContentStateFactory,
|
private val stateFactory: TimelineItemContentStateFactory,
|
||||||
private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory,
|
private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory,
|
||||||
private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory,
|
private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory,
|
||||||
|
private val paymentFactory: TimelineItemContentPaymentFactory,
|
||||||
private val sessionId: SessionId,
|
private val sessionId: SessionId,
|
||||||
) {
|
) {
|
||||||
suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
|
suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
|
||||||
|
val isOutgoing = sessionId == eventTimelineItem.sender
|
||||||
|
|
||||||
|
// Check for custom event types that we handle specially
|
||||||
|
val content = eventTimelineItem.content
|
||||||
|
if (content is CustomEventContent && paymentFactory.isPaymentEventType(content.eventType)) {
|
||||||
|
// Try to get raw JSON from debug info for payment events
|
||||||
|
val rawJson = eventTimelineItem.timelineItemDebugInfoProvider().originalJson
|
||||||
|
if (rawJson != null) {
|
||||||
|
val paymentContent = paymentFactory.createFromRaw(rawJson, isOutgoing)
|
||||||
|
if (paymentContent != null) {
|
||||||
|
return paymentContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return create(
|
return create(
|
||||||
itemContent = eventTimelineItem.content,
|
itemContent = eventTimelineItem.content,
|
||||||
eventId = eventTimelineItem.eventId,
|
eventId = eventTimelineItem.eventId,
|
||||||
|
|
@ -78,7 +96,6 @@ class TimelineItemContentFactory(
|
||||||
senderProfile = senderProfile,
|
senderProfile = senderProfile,
|
||||||
content = itemContent,
|
content = itemContent,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
isSentByMe = isOutgoing,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is ProfileChangeContent -> {
|
is ProfileChangeContent -> {
|
||||||
|
|
@ -100,6 +117,10 @@ class TimelineItemContentFactory(
|
||||||
is UnableToDecryptContent -> utdFactory.create(itemContent)
|
is UnableToDecryptContent -> utdFactory.create(itemContent)
|
||||||
is CallNotifyContent -> TimelineItemRtcNotificationContent()
|
is CallNotifyContent -> TimelineItemRtcNotificationContent()
|
||||||
is UnknownContent -> TimelineItemUnknownContent
|
is UnknownContent -> TimelineItemUnknownContent
|
||||||
|
is CustomEventContent -> {
|
||||||
|
// Custom events that weren't handled above (e.g., unknown custom event types)
|
||||||
|
TimelineItemUnknownContent
|
||||||
|
}
|
||||||
is LiveLocationContent -> {
|
is LiveLocationContent -> {
|
||||||
val lastKnownLocation = itemContent.locations.mapNotNull { beacon ->
|
val lastKnownLocation = itemContent.locations.mapNotNull { beacon ->
|
||||||
Location.fromGeoUri(beacon.geoUri)
|
Location.fromGeoUri(beacon.geoUri)
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||||
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||||
import io.element.android.features.wallet.impl.timeline.TimelineItemContentPaymentFactory
|
|
||||||
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||||
import io.element.android.libraries.androidutils.text.safeLinkify
|
import io.element.android.libraries.androidutils.text.safeLinkify
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
|
@ -66,14 +66,12 @@ class TimelineItemContentMessageFactory(
|
||||||
private val htmlConverterProvider: HtmlConverterProvider,
|
private val htmlConverterProvider: HtmlConverterProvider,
|
||||||
private val permalinkParser: PermalinkParser,
|
private val permalinkParser: PermalinkParser,
|
||||||
private val textPillificationHelper: TextPillificationHelper,
|
private val textPillificationHelper: TextPillificationHelper,
|
||||||
private val paymentFactory: TimelineItemContentPaymentFactory,
|
|
||||||
) {
|
) {
|
||||||
fun create(
|
fun create(
|
||||||
content: MessageContent,
|
content: MessageContent,
|
||||||
senderId: UserId,
|
senderId: UserId,
|
||||||
senderProfile: ProfileDetails,
|
senderProfile: ProfileDetails,
|
||||||
eventId: EventId?,
|
eventId: EventId?,
|
||||||
isSentByMe: Boolean = false,
|
|
||||||
): TimelineItemEventContent {
|
): TimelineItemEventContent {
|
||||||
return when (val messageType = content.type) {
|
return when (val messageType = content.type) {
|
||||||
is EmoteMessageType -> {
|
is EmoteMessageType -> {
|
||||||
|
|
@ -257,14 +255,8 @@ class TimelineItemContentMessageFactory(
|
||||||
}
|
}
|
||||||
is TextMessageType -> {
|
is TextMessageType -> {
|
||||||
val body = messageType.body.trimEnd()
|
val body = messageType.body.trimEnd()
|
||||||
// Check for Cardano payment events embedded in text messages
|
|
||||||
if (paymentFactory.isPaymentEvent(body)) {
|
|
||||||
paymentFactory.createFromBody(body, isSentByMe)
|
|
||||||
?: createTextContent(body, messageType, content.isEdited)
|
|
||||||
} else {
|
|
||||||
createTextContent(body, messageType, content.isEdited)
|
createTextContent(body, messageType, content.isEdited)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
is OtherMessageType -> {
|
is OtherMessageType -> {
|
||||||
val body = messageType.body.trimEnd()
|
val body = messageType.body.trimEnd()
|
||||||
TimelineItemTextContent(
|
TimelineItemTextContent(
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,10 @@ import kotlinx.serialization.json.Json
|
||||||
/**
|
/**
|
||||||
* Default implementation of [PaymentEventSender].
|
* Default implementation of [PaymentEventSender].
|
||||||
*
|
*
|
||||||
* Sends payment events as specially formatted text messages that can be
|
* Sends payment events as custom Matrix events using the raw event API.
|
||||||
* parsed by wallet-enabled clients while remaining readable for others.
|
|
||||||
*
|
*
|
||||||
* Message format:
|
* Event type: co.sulkta.payment.request
|
||||||
* ```
|
* Event content: JSON-serialized [PaymentEventData]
|
||||||
* [cardano-payment:v1]{"amount_lovelace":...,"to_address":"...","from_address":"...","tx_hash":"...","status":"...","network":"..."}
|
|
||||||
* 💰 Sent X ADA
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
@Inject
|
@Inject
|
||||||
@ContributesBinding(SessionScope::class)
|
@ContributesBinding(SessionScope::class)
|
||||||
|
|
@ -53,13 +49,11 @@ class DefaultPaymentEventSender : PaymentEventSender {
|
||||||
network = network,
|
network = network,
|
||||||
)
|
)
|
||||||
|
|
||||||
val fallbackText = "💰 Sent ${TimelineItemPaymentContent.formatAda(signedTx.actualAmount)}"
|
val content = json.encodeToString(paymentData)
|
||||||
val messageBody = formatPaymentMessage(paymentData, fallbackText)
|
|
||||||
|
|
||||||
return timeline.sendMessage(
|
return timeline.sendRaw(
|
||||||
body = messageBody,
|
eventType = PAYMENT_EVENT_TYPE,
|
||||||
htmlBody = formatPaymentHtml(paymentData, fallbackText),
|
content = content,
|
||||||
intentionalMentions = emptyList(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,44 +63,25 @@ class DefaultPaymentEventSender : PaymentEventSender {
|
||||||
newStatus: String,
|
newStatus: String,
|
||||||
network: String,
|
network: String,
|
||||||
): Result<Unit> {
|
): Result<Unit> {
|
||||||
// Status updates use m.relates_to with m.replace relation
|
|
||||||
// Since the SDK doesn't expose raw event editing, we send a new event
|
|
||||||
// with a reference to the original transaction
|
|
||||||
val statusData = PaymentStatusUpdateData(
|
val statusData = PaymentStatusUpdateData(
|
||||||
txHash = txHash,
|
txHash = txHash,
|
||||||
status = newStatus,
|
status = newStatus,
|
||||||
network = network,
|
network = network,
|
||||||
)
|
)
|
||||||
|
|
||||||
val statusText = when (newStatus.lowercase()) {
|
val content = json.encodeToString(statusData)
|
||||||
"confirmed" -> "✅ Payment confirmed"
|
|
||||||
"failed" -> "❌ Payment failed"
|
|
||||||
else -> "⏳ Payment $newStatus"
|
|
||||||
}
|
|
||||||
|
|
||||||
val messageBody = "[cardano-payment-status:v1]${json.encodeToString(statusData)}\n$statusText (tx: ${txHash.take(8)}...)"
|
return timeline.sendRaw(
|
||||||
|
eventType = STATUS_UPDATE_EVENT_TYPE,
|
||||||
return timeline.sendMessage(
|
content = content,
|
||||||
body = messageBody,
|
|
||||||
htmlBody = null,
|
|
||||||
intentionalMentions = emptyList(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatPaymentMessage(data: PaymentEventData, fallbackText: String): String {
|
|
||||||
val jsonPayload = json.encodeToString(data)
|
|
||||||
return "$PAYMENT_MARKER$jsonPayload\n$fallbackText"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatPaymentHtml(data: PaymentEventData, fallbackText: String): String {
|
|
||||||
val jsonPayload = json.encodeToString(data)
|
|
||||||
// Embed payment data in a data attribute for potential future parsing
|
|
||||||
return """<span data-mx-payment="$PAYMENT_MARKER" data-payment-json='$jsonPayload'>$fallbackText</span>"""
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val PAYMENT_MARKER = "[cardano-payment:v1]"
|
/** Custom event type for Cardano payment requests (reverse-domain format) */
|
||||||
const val STATUS_MARKER = "[cardano-payment-status:v1]"
|
const val PAYMENT_EVENT_TYPE = "co.sulkta.payment.request"
|
||||||
|
/** Custom event type for payment status updates */
|
||||||
|
const val STATUS_UPDATE_EVENT_TYPE = "co.sulkta.payment.status"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,20 @@ package io.element.android.features.wallet.impl.timeline
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import io.element.android.features.wallet.api.PaymentCardStatus
|
import io.element.android.features.wallet.api.PaymentCardStatus
|
||||||
import io.element.android.features.wallet.api.timeline.TimelineItemPaymentContent
|
import io.element.android.features.wallet.api.timeline.TimelineItemPaymentContent
|
||||||
|
import io.element.android.features.wallet.impl.payment.DefaultPaymentEventSender
|
||||||
import io.element.android.features.wallet.impl.payment.PaymentEventData
|
import io.element.android.features.wallet.impl.payment.PaymentEventData
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.long
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory for creating [TimelineItemPaymentContent] from payment event messages.
|
* Factory for creating [TimelineItemPaymentContent] from raw payment events.
|
||||||
*
|
*
|
||||||
* Parses messages that start with the payment marker and extracts the JSON payload.
|
* Parses custom events with type "co.sulkta.payment.request" and extracts the payment data.
|
||||||
*/
|
*/
|
||||||
@Inject
|
@Inject
|
||||||
class TimelineItemContentPaymentFactory {
|
class TimelineItemContentPaymentFactory {
|
||||||
|
|
@ -26,78 +32,34 @@ class TimelineItemContentPaymentFactory {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a message body contains a payment event.
|
* Check if an event type is a payment event.
|
||||||
*/
|
*/
|
||||||
fun isPaymentEvent(body: String): Boolean {
|
fun isPaymentEventType(eventType: String): Boolean {
|
||||||
return body.startsWith(PAYMENT_MARKER)
|
return eventType == DefaultPaymentEventSender.PAYMENT_EVENT_TYPE
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a message body contains a payment status update.
|
* Check if an event type is a payment status update.
|
||||||
*/
|
*/
|
||||||
fun isPaymentStatusUpdate(body: String): Boolean {
|
fun isStatusUpdateEventType(eventType: String): Boolean {
|
||||||
return body.startsWith(STATUS_MARKER)
|
return eventType == DefaultPaymentEventSender.STATUS_UPDATE_EVENT_TYPE
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a [TimelineItemPaymentContent] from a payment message body.
|
* Create a [TimelineItemPaymentContent] from raw JSON event content.
|
||||||
*
|
*
|
||||||
* @param body The message body starting with [PAYMENT_MARKER]
|
* @param rawJson The raw JSON content from the Matrix event
|
||||||
* @param isSentByMe Whether the current user sent this message
|
|
||||||
* @return The parsed payment content, or null if parsing failed
|
|
||||||
*/
|
|
||||||
fun createFromBody(body: String, isSentByMe: Boolean): TimelineItemPaymentContent? {
|
|
||||||
return try {
|
|
||||||
val jsonStart = body.indexOf(PAYMENT_MARKER) + PAYMENT_MARKER.length
|
|
||||||
val jsonEnd = body.indexOf('\n', jsonStart).takeIf { it != -1 } ?: body.length
|
|
||||||
val jsonPayload = body.substring(jsonStart, jsonEnd)
|
|
||||||
val fallbackText = if (jsonEnd < body.length) {
|
|
||||||
body.substring(jsonEnd + 1).trim()
|
|
||||||
} else {
|
|
||||||
"Payment"
|
|
||||||
}
|
|
||||||
|
|
||||||
val data = json.decodeFromString<PaymentEventData>(jsonPayload)
|
|
||||||
createFromData(data, isSentByMe, fallbackText)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.w(e, "Failed to parse payment event from body")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a [TimelineItemPaymentContent] from parsed payment data.
|
|
||||||
*/
|
|
||||||
fun createFromData(
|
|
||||||
data: PaymentEventData,
|
|
||||||
isSentByMe: Boolean,
|
|
||||||
fallbackText: String,
|
|
||||||
): TimelineItemPaymentContent {
|
|
||||||
return TimelineItemPaymentContent(
|
|
||||||
amountLovelace = data.amountLovelace,
|
|
||||||
toAddress = data.toAddress,
|
|
||||||
fromAddress = data.fromAddress,
|
|
||||||
txHash = data.txHash,
|
|
||||||
status = parseStatus(data.status),
|
|
||||||
network = data.network,
|
|
||||||
isSentByMe = isSentByMe,
|
|
||||||
fallbackText = fallbackText,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a [TimelineItemPaymentContent] from raw JSON.
|
|
||||||
*
|
|
||||||
* This is the method called from TimelineItemContentFactory when
|
|
||||||
* handling UnknownContent (if we had access to raw JSON).
|
|
||||||
*
|
|
||||||
* @param rawJson The raw JSON content
|
|
||||||
* @param isSentByMe Whether the current user sent this event
|
* @param isSentByMe Whether the current user sent this event
|
||||||
* @return The parsed payment content, or null if parsing failed
|
* @return The parsed payment content, or null if parsing failed
|
||||||
*/
|
*/
|
||||||
fun createFromRaw(rawJson: String, isSentByMe: Boolean): TimelineItemPaymentContent? {
|
fun createFromRaw(rawJson: String, isSentByMe: Boolean): TimelineItemPaymentContent? {
|
||||||
return try {
|
return try {
|
||||||
val data = json.decodeFromString<PaymentEventData>(rawJson)
|
// Try to parse the content field from the raw event JSON
|
||||||
|
val eventJson = json.parseToJsonElement(rawJson).jsonObject
|
||||||
|
val content = eventJson["content"]?.jsonObject ?: eventJson
|
||||||
|
|
||||||
|
val data = parsePaymentData(content)
|
||||||
|
if (data != null) {
|
||||||
TimelineItemPaymentContent(
|
TimelineItemPaymentContent(
|
||||||
amountLovelace = data.amountLovelace,
|
amountLovelace = data.amountLovelace,
|
||||||
toAddress = data.toAddress,
|
toAddress = data.toAddress,
|
||||||
|
|
@ -108,12 +70,68 @@ class TimelineItemContentPaymentFactory {
|
||||||
isSentByMe = isSentByMe,
|
isSentByMe = isSentByMe,
|
||||||
fallbackText = "💰 ${TimelineItemPaymentContent.formatAda(data.amountLovelace)}",
|
fallbackText = "💰 ${TimelineItemPaymentContent.formatAda(data.amountLovelace)}",
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.w(e, "Failed to parse payment event from raw JSON")
|
Timber.w(e, "Failed to parse payment event from raw JSON")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a [TimelineItemPaymentContent] from parsed payment data.
|
||||||
|
*/
|
||||||
|
fun createFromData(
|
||||||
|
data: PaymentEventData,
|
||||||
|
isSentByMe: Boolean,
|
||||||
|
): TimelineItemPaymentContent {
|
||||||
|
return TimelineItemPaymentContent(
|
||||||
|
amountLovelace = data.amountLovelace,
|
||||||
|
toAddress = data.toAddress,
|
||||||
|
fromAddress = data.fromAddress,
|
||||||
|
txHash = data.txHash,
|
||||||
|
status = parseStatus(data.status),
|
||||||
|
network = data.network,
|
||||||
|
isSentByMe = isSentByMe,
|
||||||
|
fallbackText = "💰 ${TimelineItemPaymentContent.formatAda(data.amountLovelace)}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parsePaymentData(content: JsonObject): PaymentEventData? {
|
||||||
|
return try {
|
||||||
|
val amountLovelace = content["amount_lovelace"]?.jsonPrimitive?.longOrNull
|
||||||
|
?: content["amountLovelace"]?.jsonPrimitive?.longOrNull
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
val toAddress = content["to_address"]?.jsonPrimitive?.content
|
||||||
|
?: content["toAddress"]?.jsonPrimitive?.content
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
val fromAddress = content["from_address"]?.jsonPrimitive?.content
|
||||||
|
?: content["fromAddress"]?.jsonPrimitive?.content
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
val txHash = content["tx_hash"]?.jsonPrimitive?.content
|
||||||
|
?: content["txHash"]?.jsonPrimitive?.content
|
||||||
|
|
||||||
|
val status = content["status"]?.jsonPrimitive?.content ?: "pending"
|
||||||
|
val network = content["network"]?.jsonPrimitive?.content ?: "mainnet"
|
||||||
|
|
||||||
|
PaymentEventData(
|
||||||
|
amountLovelace = amountLovelace,
|
||||||
|
toAddress = toAddress,
|
||||||
|
fromAddress = fromAddress,
|
||||||
|
txHash = txHash,
|
||||||
|
status = status,
|
||||||
|
network = network,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w(e, "Failed to parse payment data from JSON object")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseStatus(status: String): PaymentCardStatus {
|
private fun parseStatus(status: String): PaymentCardStatus {
|
||||||
return when (status.lowercase()) {
|
return when (status.lowercase()) {
|
||||||
"pending" -> PaymentCardStatus.PENDING
|
"pending" -> PaymentCardStatus.PENDING
|
||||||
|
|
@ -124,7 +142,9 @@ class TimelineItemContentPaymentFactory {
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val PAYMENT_MARKER = "[cardano-payment:v1]"
|
/** Custom event type for Cardano payment requests */
|
||||||
const val STATUS_MARKER = "[cardano-payment-status:v1]"
|
const val PAYMENT_EVENT_TYPE = DefaultPaymentEventSender.PAYMENT_EVENT_TYPE
|
||||||
|
/** Custom event type for payment status updates */
|
||||||
|
const val STATUS_UPDATE_EVENT_TYPE = DefaultPaymentEventSender.STATUS_UPDATE_EVENT_TYPE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,34 +8,60 @@ package io.element.android.features.wallet.impl.timeline
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.wallet.api.PaymentCardStatus
|
import io.element.android.features.wallet.api.PaymentCardStatus
|
||||||
|
import io.element.android.features.wallet.impl.payment.DefaultPaymentEventSender
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class TimelineItemContentPaymentFactoryTest {
|
class TimelineItemContentPaymentFactoryTest {
|
||||||
private val factory = TimelineItemContentPaymentFactory()
|
private val factory = TimelineItemContentPaymentFactory()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `isPaymentEvent returns true for valid payment marker`() {
|
fun `isPaymentEventType returns true for payment event type`() {
|
||||||
val body = "[cardano-payment:v1]{\"amount_lovelace\":10000000}\n💰 Sent 10 ADA"
|
assertThat(factory.isPaymentEventType(DefaultPaymentEventSender.PAYMENT_EVENT_TYPE)).isTrue()
|
||||||
assertThat(factory.isPaymentEvent(body)).isTrue()
|
assertThat(factory.isPaymentEventType("co.sulkta.payment.request")).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `isPaymentEvent returns false for regular message`() {
|
fun `isPaymentEventType returns false for other event types`() {
|
||||||
val body = "Hello, this is a regular message"
|
assertThat(factory.isPaymentEventType("m.room.message")).isFalse()
|
||||||
assertThat(factory.isPaymentEvent(body)).isFalse()
|
assertThat(factory.isPaymentEventType("m.room.member")).isFalse()
|
||||||
|
assertThat(factory.isPaymentEventType("co.other.event")).isFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `isPaymentEvent returns false for empty string`() {
|
fun `isStatusUpdateEventType returns true for status update event type`() {
|
||||||
assertThat(factory.isPaymentEvent("")).isFalse()
|
assertThat(factory.isStatusUpdateEventType(DefaultPaymentEventSender.STATUS_UPDATE_EVENT_TYPE)).isTrue()
|
||||||
|
assertThat(factory.isStatusUpdateEventType("co.sulkta.payment.status")).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `createFromBody parses valid payment event`() {
|
fun `isStatusUpdateEventType returns false for other event types`() {
|
||||||
val body = """[cardano-payment:v1]{"amount_lovelace":10000000,"to_address":"addr_test1abc","from_address":"addr_test1xyz","tx_hash":"hash123","status":"pending","network":"testnet"}
|
assertThat(factory.isStatusUpdateEventType("m.room.message")).isFalse()
|
||||||
💰 Sent 10 ADA"""
|
assertThat(factory.isStatusUpdateEventType("co.sulkta.payment.request")).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
val result = factory.createFromBody(body, isSentByMe = true)
|
@Test
|
||||||
|
fun `createFromRaw parses valid payment JSON`() {
|
||||||
|
val json = """{"amountLovelace":25000000,"toAddress":"addr1","fromAddress":"addr2","txHash":"abc123","status":"confirmed","network":"mainnet"}"""
|
||||||
|
|
||||||
|
val result = factory.createFromRaw(json, isSentByMe = false)
|
||||||
|
|
||||||
|
assertThat(result).isNotNull()
|
||||||
|
assertThat(result!!.amountLovelace).isEqualTo(25_000_000)
|
||||||
|
assertThat(result.amountAda).isEqualTo("25 ADA")
|
||||||
|
assertThat(result.toAddress).isEqualTo("addr1")
|
||||||
|
assertThat(result.fromAddress).isEqualTo("addr2")
|
||||||
|
assertThat(result.txHash).isEqualTo("abc123")
|
||||||
|
assertThat(result.status).isEqualTo(PaymentCardStatus.CONFIRMED)
|
||||||
|
assertThat(result.network).isEqualTo("mainnet")
|
||||||
|
assertThat(result.isTestnet).isFalse()
|
||||||
|
assertThat(result.isSentByMe).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createFromRaw parses snake_case field names`() {
|
||||||
|
val json = """{"amount_lovelace":10000000,"to_address":"addr_test1abc","from_address":"addr_test1xyz","tx_hash":"hash123","status":"pending","network":"testnet"}"""
|
||||||
|
|
||||||
|
val result = factory.createFromRaw(json, isSentByMe = true)
|
||||||
|
|
||||||
assertThat(result).isNotNull()
|
assertThat(result).isNotNull()
|
||||||
assertThat(result!!.amountLovelace).isEqualTo(10_000_000)
|
assertThat(result!!.amountLovelace).isEqualTo(10_000_000)
|
||||||
|
|
@ -45,25 +71,34 @@ class TimelineItemContentPaymentFactoryTest {
|
||||||
assertThat(result.status).isEqualTo(PaymentCardStatus.PENDING)
|
assertThat(result.status).isEqualTo(PaymentCardStatus.PENDING)
|
||||||
assertThat(result.network).isEqualTo("testnet")
|
assertThat(result.network).isEqualTo("testnet")
|
||||||
assertThat(result.isSentByMe).isTrue()
|
assertThat(result.isSentByMe).isTrue()
|
||||||
assertThat(result.fallbackText).isEqualTo("💰 Sent 10 ADA")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `createFromBody parses confirmed status`() {
|
fun `createFromRaw parses wrapped event JSON with content field`() {
|
||||||
val body = """[cardano-payment:v1]{"amount_lovelace":5000000,"to_address":"addr","from_address":"addr2","tx_hash":"hash","status":"confirmed","network":"mainnet"}"""
|
val json = """{"type":"co.sulkta.payment.request","content":{"amountLovelace":5000000,"toAddress":"addr","fromAddress":"addr2","txHash":"hash","status":"confirmed","network":"mainnet"}}"""
|
||||||
|
|
||||||
val result = factory.createFromBody(body, isSentByMe = false)
|
val result = factory.createFromRaw(json, isSentByMe = false)
|
||||||
|
|
||||||
|
assertThat(result).isNotNull()
|
||||||
|
assertThat(result!!.amountLovelace).isEqualTo(5_000_000)
|
||||||
|
assertThat(result.status).isEqualTo(PaymentCardStatus.CONFIRMED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createFromRaw parses confirmed status`() {
|
||||||
|
val json = """{"amountLovelace":5000000,"toAddress":"addr","fromAddress":"addr2","txHash":"hash","status":"confirmed","network":"mainnet"}"""
|
||||||
|
|
||||||
|
val result = factory.createFromRaw(json, isSentByMe = false)
|
||||||
|
|
||||||
assertThat(result).isNotNull()
|
assertThat(result).isNotNull()
|
||||||
assertThat(result!!.status).isEqualTo(PaymentCardStatus.CONFIRMED)
|
assertThat(result!!.status).isEqualTo(PaymentCardStatus.CONFIRMED)
|
||||||
assertThat(result.isSentByMe).isFalse()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `createFromBody parses failed status`() {
|
fun `createFromRaw parses failed status`() {
|
||||||
val body = """[cardano-payment:v1]{"amount_lovelace":1000000,"to_address":"a","from_address":"b","tx_hash":null,"status":"failed","network":"testnet"}"""
|
val json = """{"amountLovelace":1000000,"toAddress":"a","fromAddress":"b","txHash":null,"status":"failed","network":"testnet"}"""
|
||||||
|
|
||||||
val result = factory.createFromBody(body, isSentByMe = true)
|
val result = factory.createFromRaw(json, isSentByMe = true)
|
||||||
|
|
||||||
assertThat(result).isNotNull()
|
assertThat(result).isNotNull()
|
||||||
assertThat(result!!.status).isEqualTo(PaymentCardStatus.FAILED)
|
assertThat(result!!.status).isEqualTo(PaymentCardStatus.FAILED)
|
||||||
|
|
@ -71,34 +106,13 @@ class TimelineItemContentPaymentFactoryTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `createFromBody returns null for malformed JSON`() {
|
fun `createFromRaw defaults to pending for unknown status`() {
|
||||||
val body = "[cardano-payment:v1]{not valid json}\n💰 Sent 10 ADA"
|
val json = """{"amountLovelace":1000000,"toAddress":"a","fromAddress":"b","status":"unknown_status","network":"mainnet"}"""
|
||||||
|
|
||||||
val result = factory.createFromBody(body, isSentByMe = true)
|
val result = factory.createFromRaw(json, isSentByMe = true)
|
||||||
|
|
||||||
assertThat(result).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `createFromBody returns null for missing marker`() {
|
|
||||||
val body = """{"amount_lovelace":10000000,"to_address":"addr","from_address":"addr2","status":"pending","network":"testnet"}"""
|
|
||||||
|
|
||||||
val result = factory.createFromBody(body, isSentByMe = true)
|
|
||||||
|
|
||||||
assertThat(result).isNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `createFromRaw parses valid JSON`() {
|
|
||||||
val json = """{"amount_lovelace":25000000,"to_address":"addr1","from_address":"addr2","tx_hash":"abc123","status":"confirmed","network":"mainnet"}"""
|
|
||||||
|
|
||||||
val result = factory.createFromRaw(json, isSentByMe = false)
|
|
||||||
|
|
||||||
assertThat(result).isNotNull()
|
assertThat(result).isNotNull()
|
||||||
assertThat(result!!.amountLovelace).isEqualTo(25_000_000)
|
assertThat(result!!.status).isEqualTo(PaymentCardStatus.PENDING)
|
||||||
assertThat(result.amountAda).isEqualTo("25 ADA")
|
|
||||||
assertThat(result.status).isEqualTo(PaymentCardStatus.CONFIRMED)
|
|
||||||
assertThat(result.isTestnet).isFalse()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -111,13 +125,30 @@ class TimelineItemContentPaymentFactoryTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `isPaymentStatusUpdate returns true for valid status marker`() {
|
fun `createFromRaw returns null for missing required fields`() {
|
||||||
val body = "[cardano-payment-status:v1]{\"tx_hash\":\"abc\"}\n✅ Payment confirmed"
|
val json = """{"amountLovelace":1000000}"""
|
||||||
assertThat(factory.isPaymentStatusUpdate(body)).isTrue()
|
|
||||||
|
val result = factory.createFromRaw(json, isSentByMe = true)
|
||||||
|
|
||||||
|
assertThat(result).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `isPaymentStatusUpdate returns false for regular message`() {
|
fun `createFromRaw returns null for empty JSON`() {
|
||||||
assertThat(factory.isPaymentStatusUpdate("Hello")).isFalse()
|
val json = "{}"
|
||||||
|
|
||||||
|
val result = factory.createFromRaw(json, isSentByMe = true)
|
||||||
|
|
||||||
|
assertThat(result).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createFromRaw formats fallback text correctly`() {
|
||||||
|
val json = """{"amountLovelace":1500000,"toAddress":"a","fromAddress":"b","status":"pending","network":"mainnet"}"""
|
||||||
|
|
||||||
|
val result = factory.createFromRaw(json, isSentByMe = true)
|
||||||
|
|
||||||
|
assertThat(result).isNotNull()
|
||||||
|
assertThat(result!!.fallbackText).isEqualTo("💰 1.5 ADA")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,18 @@ interface Timeline : AutoCloseable {
|
||||||
intentionalMentions: List<IntentionalMention>,
|
intentionalMentions: List<IntentionalMention>,
|
||||||
): Result<Unit>
|
): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a raw/custom event to the room.
|
||||||
|
*
|
||||||
|
* @param eventType The event type (e.g., "co.sulkta.payment.request")
|
||||||
|
* @param content The JSON content of the event
|
||||||
|
* @return Result indicating success or failure
|
||||||
|
*/
|
||||||
|
suspend fun sendRaw(
|
||||||
|
eventType: String,
|
||||||
|
content: String,
|
||||||
|
): Result<Unit>
|
||||||
|
|
||||||
suspend fun editMessage(
|
suspend fun editMessage(
|
||||||
eventOrTransactionId: EventOrTransactionId,
|
eventOrTransactionId: EventOrTransactionId,
|
||||||
body: String,
|
body: String,
|
||||||
|
|
|
||||||
|
|
@ -118,3 +118,14 @@ data object LegacyCallInviteContent : EventContent
|
||||||
data object CallNotifyContent : EventContent
|
data object CallNotifyContent : EventContent
|
||||||
|
|
||||||
data object UnknownContent : EventContent
|
data object UnknownContent : EventContent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content for custom/unknown message-like events that we want to handle specially.
|
||||||
|
*
|
||||||
|
* @param eventType The Matrix event type (e.g., "co.sulkta.payment.request")
|
||||||
|
* @param rawJson The raw JSON content of the event, if available
|
||||||
|
*/
|
||||||
|
data class CustomEventContent(
|
||||||
|
val eventType: String,
|
||||||
|
val rawJson: String?,
|
||||||
|
) : EventContent
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,15 @@ class RustTimeline(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun sendRaw(
|
||||||
|
eventType: String,
|
||||||
|
content: String,
|
||||||
|
): Result<Unit> = withContext(dispatcher) {
|
||||||
|
runCatchingExceptions {
|
||||||
|
inner.sendRaw(eventType, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result<Unit> = withContext(dispatcher) {
|
override suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result<Unit> = withContext(dispatcher) {
|
||||||
runCatchingExceptions {
|
runCatchingExceptions {
|
||||||
inner.redactEvent(
|
inner.redactEvent(
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.CustomEventContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause
|
import io.element.android.libraries.matrix.api.timeline.item.event.UtdCause
|
||||||
import io.element.android.libraries.matrix.impl.media.map
|
import io.element.android.libraries.matrix.impl.media.map
|
||||||
import io.element.android.libraries.matrix.impl.poll.map
|
import io.element.android.libraries.matrix.impl.poll.map
|
||||||
|
|
@ -111,7 +112,14 @@ class TimelineEventContentMapper(
|
||||||
// Live location messages are a special kind of message that we want to treat as unknown content for now
|
// Live location messages are a special kind of message that we want to treat as unknown content for now
|
||||||
UnknownContent
|
UnknownContent
|
||||||
}
|
}
|
||||||
is MsgLikeKind.Other -> UnknownContent
|
is MsgLikeKind.Other -> {
|
||||||
|
// MsgLikeKind.Other contains custom event types
|
||||||
|
// Pass through the event type so downstream handlers can process it
|
||||||
|
CustomEventContent(
|
||||||
|
eventType = kind.eventType,
|
||||||
|
rawJson = null, // Raw JSON accessed via TimelineItemDebugInfoProvider
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is TimelineItemContent.ProfileChange -> {
|
is TimelineItemContent.ProfileChange -> {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue