diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index 8cb933205b..72483e68f1 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -31,6 +31,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.parcelize.Parcelize import java.io.File +/** + * A window over the events of a room, restricted by its [Mode], and the entry point for sending events to that room. + * + * A timeline owns SDK resources and must be closed once no longer needed, except the live timeline, which is owned by its room. + * Sending returns successfully as soon as the event has been handed to the room's send queue, not when the server has accepted it. + */ interface Timeline : AutoCloseable { data class PaginationStatus( val isPaginating: Boolean, @@ -54,18 +60,66 @@ interface Timeline : AutoCloseable { data class Thread(val threadRootId: ThreadId) : Mode } + /** What this timeline is a view over: the live room, a single event and its context, the pinned events, the media, or a thread. */ val mode: Mode + + /** + * Emits every time a membership change event is received in this timeline, without saying which one. + * Collecting it keeps the timeline subscribed to the SDK, so it should not be collected for longer than needed. + */ val membershipChangeEventReceived: Flow + + /** + * Emits every time an event coming from sync is received in this timeline, without saying which one. + * As with [membershipChangeEventReceived], collecting it keeps the timeline subscribed to the SDK. + */ val onSyncedEventReceived: Flow + + /** + * Sends a read receipt for a single event, which is how the unread state of the room is cleared. + * + * @param eventId the event the receipt points at. + * @param receiptType whether the receipt is public, private or only marks the fully read position. + */ suspend fun sendReadReceipt(eventId: EventId, receiptType: ReceiptType): Result + + /** + * Marks the whole timeline as read by sending a receipt on its latest event. + * + * @param receiptType whether the receipt is public, private or only marks the fully read position. + */ suspend fun markAsRead(receiptType: ReceiptType): Result + + /** + * Loads one more page of events and updates the matching pagination status flow. + * Fails with a `CannotPaginate` error when the corresponding status does not allow it, for instance because a pagination is already running. + * + * @param direction whether to load older or newer events. + * @return whether the end of the timeline has been reached in that direction. + */ suspend fun paginate(direction: PaginationDirection): Result + /** Whether older events are being loaded and whether more remain; pinned event timelines start with nothing more to load. */ val backwardPaginationStatus: StateFlow + + /** Whether newer events are being loaded and whether more remain; only a timeline focused on an event starts with more to load. */ val forwardPaginationStatus: StateFlow + /** + * The items to display, in timeline order, re-emitted on every change. + * Beyond the room events this also carries the virtual items the UI needs, such as date separators, loading indicators and the room beginning. + */ val timelineItems: Flow> + /** + * Sends a text message to the room this timeline belongs to. + * + * @param body the message content, as plain text. + * @param htmlBody the message content as HTML, or `null` to send it unformatted. + * @param intentionalMentions the users and rooms the message deliberately mentions, so that they are notified. + * @param msgType the Matrix message type to use, which distinguishes a normal message from an emote or a notice. + * @param asPlainText whether to send the message without any formatting, ignoring markdown in [body]. + */ suspend fun sendMessage( body: String, htmlBody: String?, @@ -74,6 +128,14 @@ interface Timeline : AutoCloseable { asPlainText: Boolean = false, ): Result + /** + * Replaces the content of a message, whether it has already been sent or is still local in the send queue. + * + * @param eventOrTransactionId the event to edit, identified by its event id once sent or by its transaction id while still local. + * @param body the new content, as plain text. + * @param htmlBody the new content as HTML, or `null` to send it unformatted. + * @param intentionalMentions the users and rooms the new content deliberately mentions. + */ suspend fun editMessage( eventOrTransactionId: EventOrTransactionId, body: String, @@ -81,12 +143,29 @@ interface Timeline : AutoCloseable { intentionalMentions: List, ): Result + /** + * Replaces the caption of an already sent media message, leaving the media itself untouched. + * + * @param eventOrTransactionId the media event to edit, identified by its event id or by its transaction id while still local. + * @param caption the new caption, or `null` to remove it. + * @param formattedCaption the new caption as HTML, or `null` to send it unformatted. + */ suspend fun editCaption( eventOrTransactionId: EventOrTransactionId, caption: String?, formattedCaption: String?, ): Result + /** + * Sends a message as a reply to another event. + * + * @param repliedToEventId the event being replied to. + * @param body the reply content, as plain text. + * @param htmlBody the reply content as HTML, or `null` to send it unformatted. + * @param intentionalMentions the users and rooms the reply deliberately mentions. + * @param fromNotification whether the reply was written from a notification rather than from the timeline. + * @param msgType the Matrix message type to use. + */ suspend fun replyMessage( repliedToEventId: EventId, body: String, @@ -96,6 +175,16 @@ interface Timeline : AutoCloseable { msgType: MsgType = MsgType.MSG_TYPE_TEXT, ): Result + /** + * Starts sending an image. The returned [MediaUploadHandler] tracks the upload and must be awaited by the caller to know the outcome. + * + * @param file the image to upload. + * @param thumbnailFile a pre-generated thumbnail, or `null` to send none. + * @param imageInfo the dimensions, size and MIME type of the image. + * @param caption the text shown along with the image, or `null` for none. + * @param formattedCaption the caption as HTML, or `null` to send it unformatted. + * @param inReplyToEventId the event this message replies to, or `null` if it is not a reply. + */ suspend fun sendImage( file: File, thumbnailFile: File?, @@ -105,6 +194,16 @@ interface Timeline : AutoCloseable { inReplyToEventId: EventId?, ): Result + /** + * Starts sending a video, with the same upload contract as [sendImage]. + * + * @param file the video to upload. + * @param thumbnailFile a pre-generated thumbnail, or `null` to send none. + * @param videoInfo the duration, dimensions, size and MIME type of the video. + * @param caption the text shown along with the video, or `null` for none. + * @param formattedCaption the caption as HTML, or `null` to send it unformatted. + * @param inReplyToEventId the event this message replies to, or `null` if it is not a reply. + */ suspend fun sendVideo( file: File, thumbnailFile: File?, @@ -114,6 +213,15 @@ interface Timeline : AutoCloseable { inReplyToEventId: EventId?, ): Result + /** + * Starts sending an audio file, with the same upload contract as [sendImage]; use [sendVoiceMessage] for a recorded voice message. + * + * @param file the audio file to upload. + * @param audioInfo the duration, size and MIME type of the audio. + * @param caption the text shown along with the audio, or `null` for none. + * @param formattedCaption the caption as HTML, or `null` to send it unformatted. + * @param inReplyToEventId the event this message replies to, or `null` if it is not a reply. + */ suspend fun sendAudio( file: File, audioInfo: AudioInfo, @@ -122,6 +230,15 @@ interface Timeline : AutoCloseable { inReplyToEventId: EventId?, ): Result + /** + * Starts sending an arbitrary file, with the same upload contract as [sendImage]. + * + * @param file the file to upload. + * @param fileInfo the size and MIME type of the file. + * @param caption the text shown along with the file, or `null` for none. + * @param formattedCaption the caption as HTML, or `null` to send it unformatted. + * @param inReplyToEventId the event this message replies to, or `null` if it is not a reply. + */ suspend fun sendFile( file: File, fileInfo: FileInfo, @@ -151,6 +268,14 @@ interface Timeline : AutoCloseable { inReplyToEventId: EventId?, ): Result + /** + * Starts sending a recorded voice message, with the same upload contract as [sendImage]. + * + * @param file the recorded audio to upload. + * @param audioInfo the duration, size and MIME type of the recording. + * @param waveform the amplitudes used to draw the waveform, normalised between 0 and 1. + * @param inReplyToEventId the event this message replies to, or `null` if it is not a reply. + */ suspend fun sendVoiceMessage( file: File, audioInfo: AudioInfo, @@ -158,6 +283,14 @@ interface Timeline : AutoCloseable { inReplyToEventId: EventId?, ): Result + /** + * Starts sending several media items as a single gallery message, with the same upload contract as [sendImage]. + * + * @param items the media making up the gallery, in the order they should appear. + * @param caption the text shown along with the gallery, or `null` for none. + * @param formattedCaption the caption as HTML, or `null` to send it unformatted. + * @param inReplyToEventId the event this message replies to, or `null` if it is not a reply. + */ suspend fun sendGallery( items: List, caption: String?, @@ -165,12 +298,36 @@ interface Timeline : AutoCloseable { inReplyToEventId: EventId?, ): Result + /** + * Redacts an event, i.e. asks the server to remove its content; it also removes a local echo that has not been sent yet. + * + * @param eventOrTransactionId the event to redact, identified by its event id once sent or by its transaction id while still local. + * @param reason the explanation recorded in the redaction event, or `null` for none. + */ suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result + /** + * Adds the given reaction to an event, or removes it if the current user had already reacted with it. + * + * @param emoji the reaction key, usually an emoji. + * @param eventOrTransactionId the event to react to, identified by its event id or by its transaction id while still local. + * @return whether the reaction is now present. + */ suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result + /** + * Forwards the content of an event to other rooms, as new messages sent by the current user. + * + * @param eventId the event whose content is forwarded. + * @param roomIds the rooms to forward it to. + */ suspend fun forwardEvent(eventId: EventId, roomIds: List): Result + /** + * Cancels an event that is still waiting in the send queue, by redacting its local echo. + * + * @param transactionId the transaction id of the local echo to drop. + */ suspend fun cancelSend(transactionId: TransactionId): Result = redactEvent(transactionId.toEventOrTransactionId(), reason = null) @@ -222,12 +379,20 @@ interface Timeline : AutoCloseable { */ suspend fun endPoll(pollStartId: EventId, text: String): Result + /** + * Resolves the content of a replied-to event so it can be rendered above the reply. + * Served from the loaded timeline items when possible, otherwise fetched through the SDK. + * + * @param eventId the event being replied to. + */ suspend fun loadReplyDetails(eventId: EventId): InReplyTo /** * Returns true if [eventId] is currently loaded in this timeline's window, even if the display * layer does not render it (e.g. filtered or state events). This distinguishes "in the window * but not displayed" from "genuinely outside the loaded window". + * + * @param eventId the event to look for. */ suspend fun isEventLoaded(eventId: EventId): Boolean @@ -237,6 +402,8 @@ interface Timeline : AutoCloseable { * * Returns `true` if we sent the request, `false` if the event was already * pinned. + * + * @param eventId the event to pin. */ suspend fun pinEvent(eventId: EventId): Result @@ -246,11 +413,13 @@ interface Timeline : AutoCloseable { * * Returns `true` if we sent the request, `false` if the event wasn't * pinned + * + * @param eventId the event to unpin. */ suspend fun unpinEvent(eventId: EventId): Result /** - * Get the latest event id of the timeline. + * Get the latest event id of the timeline, or `null` when it holds no event yet. */ suspend fun getLatestEventId(): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineProvider.kt index 28e487f527..ef9d5d794e 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineProvider.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/TimelineProvider.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.first * By default, the active timeline is the live timeline. */ fun interface TimelineProvider { + /** The timeline currently in use, or `null` while none is available yet; see [getActiveTimeline] to await a non-null value. */ fun activeTimelineFlow(): StateFlow } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt index f3982e83d2..91703f7fa4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventTimelineItem.kt @@ -60,14 +60,30 @@ data class EventTimelineItem( } } +/** + * Provides the debug information of a timeline item on demand, so the original event JSON is only fetched from the SDK when actually displayed. + */ fun interface TimelineItemDebugInfoProvider { + /** Returns the debug information of the item, including its original and latest edit JSON. */ operator fun invoke(): TimelineItemDebugInfo } +/** + * Provides the encryption warning to show next to a timeline item on demand, so the check is only run for items that are displayed. + */ fun interface MessageShieldProvider { + /** + * Returns the warning to display for this event, or `null` when there is nothing to warn about. + * + * @param strict whether to also report the weaker problems that are hidden by default. + */ operator fun invoke(strict: Boolean): MessageShield? } +/** + * Provides the send queue handle of a timeline item on demand, which is needed to retry or cancel a failed send. + */ fun interface SendHandleProvider { + /** Returns the send handle of the item, or `null` when it is not a local echo waiting in the send queue. */ operator fun invoke(): SendHandle? }