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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,33 +54,43 @@ class DefaultMessagesRepository @Inject constructor(
override fun observeScheduled(): Flow<List<Message>> =
messageDao.observeScheduled().map { rows -> rows.map { it.toDomain() } }

override suspend fun refreshFeed(): ApiResult<Boolean> = withContext(dispatchers.io) {
when (val result = safeCall { api.getMessages(limit = PaginationDto.DEFAULT_LIMIT, offset = 0) }) {
/**
* Loads the head of the feed (no cursor) and replaces the cached feed with it,
* restarting keyset pagination. Returns the next page's opaque cursor.
*/
override suspend fun refreshFeed(): ApiResult<String?> = withContext(dispatchers.io) {
when (val result = safeCall { api.getMessages(limit = PaginationDto.DEFAULT_LIMIT) }) {
is ApiResult.Success -> {
val page = result.data
val entities = page.data.mapIndexed { index, dto ->
val entities = page.rows.mapIndexed { index, dto ->
dto.toDomain(currentUserId()).toEntity(feedOrder = index.toLong())
}
messageDao.clearFeed()
messageDao.insertAll(entities)
ApiResult.Success(page.pagination.hasMore)
ApiResult.Success(page.nextCursor)
}
is ApiResult.Failure -> result
}
}

override suspend fun loadMoreFeed(currentCount: Int): ApiResult<Boolean> = withContext(dispatchers.io) {
/**
* Appends the page following [cursor] to the tail of the cached feed. The
* cursor is opaque: it goes back to the API exactly as it arrived. Rows are
* keyed by id, so a row the server happens to repeat updates in place rather
* than duplicating.
*/
override suspend fun loadMoreFeed(cursor: String): ApiResult<String?> = withContext(dispatchers.io) {
when (val result = safeCall {
api.getMessages(limit = PaginationDto.DEFAULT_LIMIT, offset = currentCount)
api.getMessages(limit = PaginationDto.DEFAULT_LIMIT, cursor = cursor)
}) {
is ApiResult.Success -> {
val page = result.data
val base = (messageDao.maxFeedOrder() ?: -1L) + 1L
val entities = page.data.mapIndexed { index, dto ->
val entities = page.rows.mapIndexed { index, dto ->
dto.toDomain(currentUserId()).toEntity(feedOrder = base + index)
}
messageDao.insertAll(entities)
ApiResult.Success(page.pagination.hasMore)
ApiResult.Success(page.nextCursor)
}
is ApiResult.Failure -> result
}
Expand Down Expand Up @@ -172,7 +182,7 @@ class DefaultMessagesRepository @Inject constructor(
override suspend fun refreshReplies(messageId: String): ApiResult<Unit> = withContext(dispatchers.io) {
when (val result = safeCall { api.getReplies(messageId) }) {
is ApiResult.Success -> {
val entities = result.data.data.mapIndexed { index, dto ->
val entities = result.data.rows.mapIndexed { index, dto ->
dto.toDomain(currentUserId())
.copy(parentId = messageId)
.toEntity(feedOrder = index.toLong())
Expand Down Expand Up @@ -374,7 +384,7 @@ class DefaultMessagesRepository @Inject constructor(
api.search(query = query, limit = PaginationDto.DEFAULT_LIMIT, offset = 0)
}) {
is ApiResult.Success ->
ApiResult.Success(result.data.data.map { it.toDomain(currentUserId()) })
ApiResult.Success(result.data.rows.map { it.toDomain(currentUserId()) })
is ApiResult.Failure -> result
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,18 @@ interface MessagesRepository {

/**
* Refreshes the first page of the feed from the API and replaces the cached
* feed. Returns whether more pages are available.
* feed, restarting keyset pagination from the top. Returns the opaque cursor
* for the next page, or null when the feed ends here.
*/
suspend fun refreshFeed(): ApiResult<Boolean>
suspend fun refreshFeed(): ApiResult<String?>

/**
* Fetches and appends the next feed page after [currentCount] items.
* Returns whether still more pages remain.
* Fetches the page that follows [cursor] and appends it to the cached feed.
* [cursor] is the opaque token a previous [refreshFeed]/[loadMoreFeed]
* returned and is handed to the API verbatim — never construct or parse one.
* Returns the cursor for the page after this one, or null at the end.
*/
suspend fun loadMoreFeed(currentCount: Int): ApiResult<Boolean>
suspend fun loadMoreFeed(cursor: String): ApiResult<String?>

/**
* Creates a new top-level message and caches it. Optionally attaches already
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,18 @@ import retrofit2.http.Query
*/
interface MessagesApi {

/** Feed of top-level messages, newest first, offset/limit paginated. */
/**
* Feed of top-level messages, newest first, **keyset (cursor) paginated**.
*
* [cursor] is the previous response's `nextCursor`, passed back verbatim; it
* is opaque and must never be constructed, parsed or modified. A null
* [cursor] loads the first page (Retrofit omits the query parameter). The
* endpoint has no `offset` parameter.
*/
@GET("api/messages")
suspend fun getMessages(
@Query("limit") limit: Int,
@Query("offset") offset: Int,
@Query("cursor") cursor: String? = null,
): MessagesResponse

/** Creates a new message (or a reply when `parentId` is set). The created
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,43 @@ import com.interlinedlist.android.feature.messages.domain.CrossPostStatus
import kotlinx.serialization.Serializable

/**
* Paginated list envelope shared by the feed, replies, and search endpoints:
* `{ data: [...], pagination: { total, limit, offset, hasMore } }`.
* Paginated list envelope shared by the feed, replies, and search endpoints.
*
* The feed is documented as `{ messages: [...], pagination: {...} }` while the
* sibling list endpoints wrap their rows in `data`; both keys are accepted and
* read through [rows].
*/
@Serializable
data class MessagesResponse(
val data: List<MessageDto> = emptyList(),
val messages: List<MessageDto> = emptyList(),
val pagination: PaginationDto = PaginationDto(),
)
) {
/** The message rows under whichever key the endpoint used. */
val rows: List<MessageDto> get() = data.ifEmpty { messages }

/**
* The opaque cursor to request the next page with, or null at the end of the
* list. Hand it straight back as `cursor`: never parse or modify it.
*/
val nextCursor: String? get() = pagination.nextCursor?.takeIf { it.isNotBlank() }
}

/** Pagination cursor returned alongside a list of messages. */
/**
* Pagination envelope returned alongside a list of messages.
*
* The feed uses keyset pagination: [nextCursor] is an **opaque** token that is
* passed back verbatim as the next request's `cursor`, and `null` means the end
* of the list. [total] and [offset] only appear on the legacy offset path and are
* omitted once a cursor is in play.
*/
@Serializable
data class PaginationDto(
val total: Int = 0,
val limit: Int = DEFAULT_LIMIT,
val offset: Int = 0,
val hasMore: Boolean = false,
val nextCursor: String? = null,
) {
companion object {
const val DEFAULT_LIMIT = 20
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,11 @@ enum class ModerationAction { BLOCK, MUTE, REPORT }
private data class FeedTransientState(
val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false,
val canLoadMore: Boolean = false,
/**
* Opaque keyset cursor for the next feed page, or null at the end of the feed
* (and until the first refresh returns). Held verbatim: never parsed or built.
*/
val nextCursor: String? = null,
val errorMessage: String? = null,
val subscriptionRequired: Boolean = false,
val isComposeOpen: Boolean = false,
Expand All @@ -130,6 +134,9 @@ private data class FeedTransientState(
) {
/** A per-message override always wins over the account default. */
val composeVisibility: MessageVisibility get() = visibilityOverride ?: defaultVisibility

/** More pages remain exactly while the server handed back a cursor. */
val canLoadMore: Boolean get() = nextCursor != null
}

@HiltViewModel
Expand Down Expand Up @@ -217,12 +224,21 @@ class MessagesFeedViewModel @Inject constructor(
}
}

/** Reloads the head of the feed, discarding the stored paging cursor. */
fun refresh() {
transient.update { it.copy(isRefreshing = true, errorMessage = null, subscriptionRequired = false) }
transient.update {
it.copy(
isRefreshing = true,
// Paging restarts from the top; the old cursor no longer applies.
nextCursor = null,
errorMessage = null,
subscriptionRequired = false,
)
}
viewModelScope.launch {
when (val result = repository.refreshFeed()) {
is ApiResult.Success -> transient.update {
it.copy(isRefreshing = false, canLoadMore = result.data)
it.copy(isRefreshing = false, nextCursor = result.data)
}
is ApiResult.Failure -> transient.update {
it.copy(isRefreshing = false).withError(result.error)
Expand All @@ -231,14 +247,16 @@ class MessagesFeedViewModel @Inject constructor(
}
}

/** Appends the page after the stored cursor; a no-op at the end of the feed. */
fun loadMore() {
val current = uiState.value
if (current.isLoadingMore || !current.canLoadMore) return
val current = transient.value
val cursor = current.nextCursor ?: return
if (current.isLoadingMore || current.isRefreshing) return
transient.update { it.copy(isLoadingMore = true) }
viewModelScope.launch {
when (val result = repository.loadMoreFeed(currentCount = current.messages.size)) {
when (val result = repository.loadMoreFeed(cursor)) {
is ApiResult.Success -> transient.update {
it.copy(isLoadingMore = false, canLoadMore = result.data)
it.copy(isLoadingMore = false, nextCursor = result.data)
}
is ApiResult.Failure -> transient.update {
it.copy(isLoadingMore = false).withError(result.error)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class DefaultMessagesRepositoryTest {
}

@Test
fun `refreshFeed caches messages and reports hasMore`() = runTest(dispatcher) {
fun `refreshFeed caches messages and reports the next cursor`() = runTest(dispatcher) {
enqueueJson(
200,
"""
Expand All @@ -81,7 +81,7 @@ class DefaultMessagesRepositoryTest {
"digCount": 2, "replyCount": 1 },
{ "id": "2", "content": "second", "author": { "id": "me", "username": "me" } }
],
"pagination": { "total": 5, "limit": 20, "offset": 0, "hasMore": true }
"pagination": { "limit": 20, "hasMore": true, "nextCursor": "page-2" }
}
""".trimIndent(),
)
Expand All @@ -90,7 +90,7 @@ class DefaultMessagesRepositoryTest {
val result = repo.refreshFeed()

assertThat(result).isInstanceOf(ApiResult.Success::class.java)
assertThat((result as ApiResult.Success).data).isTrue() // hasMore
assertThat((result as ApiResult.Success).data).isEqualTo("page-2")
val cached = repo.observeFeed().first()
assertThat(cached.map { it.id }).containsExactly("1", "2").inOrder()
// Author id "me" matches the session user -> flagged mine.
Expand All @@ -114,26 +114,27 @@ class DefaultMessagesRepositoryTest {
enqueueJson(
200,
"""{ "data": [ { "id": "1", "content": "a" } ],
"pagination": { "total": 3, "limit": 20, "offset": 0, "hasMore": true } }""",
"pagination": { "limit": 20, "hasMore": true, "nextCursor": "after-1" } }""",
)
enqueueJson(
200,
"""{ "data": [ { "id": "2", "content": "b" } ],
"pagination": { "total": 3, "limit": 20, "offset": 1, "hasMore": false } }""",
"pagination": { "limit": 20, "hasMore": false, "nextCursor": null } }""",
)
val repo = repository()
repo.refreshFeed()
val cursor = (repo.refreshFeed() as ApiResult.Success).data!!

val more = repo.loadMoreFeed(currentCount = 1)
val more = repo.loadMoreFeed(cursor)

assertThat((more as ApiResult.Success).data).isFalse() // no more pages
assertThat((more as ApiResult.Success).data).isNull() // end of the feed
val ids = repo.observeFeed().first().map { it.id }
assertThat(ids).containsExactly("1", "2").inOrder()

// Second request carried the offset from the current feed size.
// Second request carried the previous page's cursor, verbatim.
server.takeRequest()
val secondPath = server.takeRequest().path
assertThat(secondPath).contains("offset=1")
val second = server.takeRequest().requestUrl!!
assertThat(second.queryParameter("cursor")).isEqualTo("after-1")
assertThat(second.queryParameter("offset")).isNull()
}

@Test
Expand Down
Loading
Loading