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 @@ -8,10 +8,12 @@ import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
import com.interlinedlist.android.feature.lists.domain.FieldType
import com.interlinedlist.android.feature.lists.domain.ListPresence
import com.interlinedlist.android.feature.lists.domain.ListRow
import com.interlinedlist.android.feature.lists.domain.ListSchema
import com.interlinedlist.android.feature.lists.domain.ListSummary
import com.interlinedlist.android.feature.lists.domain.SchemaField
import com.interlinedlist.android.feature.lists.ui.presence.ListPresenceTestTags
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
Expand Down Expand Up @@ -148,6 +150,37 @@ class ListDetailScreenTest {
composeRule.onNodeWithTag(ListDetailTestTags.BREADCRUMB).assertDoesNotExist()
}

@Test
fun showsWhoElseIsInTheList() {
setScreen(
ListDetailUiState(
summary = ListSummary("L1", "Reading", null, 0, null, false, null),
schema = schema,
rows = emptyList(),
isLoading = false,
presence = listOf(ListPresence("u2", displayName = "Casey", username = "casey")),
isCollaborative = true,
),
)

composeRule.onNodeWithTag(ListPresenceTestTags.ROW).assertIsDisplayed()
composeRule.onNodeWithTag(ListPresenceTestTags.avatar("u2")).assertIsDisplayed()
}

@Test
fun hidesPresence_whenNobodyElseIsHere() {
setScreen(
ListDetailUiState(
summary = ListSummary("L1", "Reading", null, 0, null, false, null),
schema = schema,
rows = emptyList(),
isLoading = false,
),
)

composeRule.onNodeWithTag(ListPresenceTestTags.ROW).assertDoesNotExist()
}

@Test
fun overflowOffersANewChildList() {
var requested = false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.interlinedlist.android.feature.lists.ui.presence

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
import com.interlinedlist.android.feature.lists.domain.ListPresence
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* The "who else is here" cluster on the list detail screen: an avatar per person,
* a "+N" chip past the cap, and nothing at all when nobody else is present.
*/
@RunWith(AndroidJUnit4::class)
class ListPresenceIndicatorTest {

@get:Rule
val composeRule = createComposeRule()

private fun person(id: String, name: String) = ListPresence(id, displayName = name, username = name.lowercase())

private fun setContent(participants: List<ListPresence>) {
composeRule.setContent {
InterlinedListTheme { ListPresenceIndicator(participants = participants) }
}
}

@Test
fun rendersAnAvatarPerPerson() {
setContent(listOf(person("u2", "Casey"), person("u3", "Robin")))

composeRule.onNodeWithTag(ListPresenceTestTags.ROW).assertIsDisplayed()
composeRule.onNodeWithTag(ListPresenceTestTags.avatar("u2")).assertIsDisplayed()
composeRule.onNodeWithTag(ListPresenceTestTags.avatar("u3")).assertIsDisplayed()
composeRule.onNodeWithText("C").assertIsDisplayed()
composeRule.onNodeWithText("R").assertIsDisplayed()
}

@Test
fun collapsesTheTailIntoAnOverflowChip() {
setContent((1..5).map { person("u$it", "Person $it") })

composeRule.onNodeWithTag(ListPresenceTestTags.OVERFLOW).assertIsDisplayed()
composeRule.onNodeWithText("+2").assertIsDisplayed()
}

@Test
fun showsNothingWhenNobodyElseIsHere() {
setContent(emptyList())

composeRule.onNodeWithTag(ListPresenceTestTags.ROW).assertDoesNotExist()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.interlinedlist.android.feature.lists.data.remote.dto.CreateViewReques
import com.interlinedlist.android.feature.lists.data.remote.dto.ListDto
import com.interlinedlist.android.feature.lists.data.remote.dto.ListViewEnvelope
import com.interlinedlist.android.feature.lists.data.remote.dto.RowDto
import com.interlinedlist.android.feature.lists.data.remote.dto.RowVersionsRequest
import com.interlinedlist.android.feature.lists.data.remote.dto.RowWriteRequest
import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateFolderRequest
import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateListRequest
Expand All @@ -33,6 +34,7 @@ import com.interlinedlist.android.feature.lists.domain.InviteRole
import com.interlinedlist.android.feature.lists.domain.ListConnection
import com.interlinedlist.android.feature.lists.domain.ListDetail
import com.interlinedlist.android.feature.lists.domain.ListFolder
import com.interlinedlist.android.feature.lists.domain.ListFreshness
import com.interlinedlist.android.feature.lists.domain.ListInvite
import com.interlinedlist.android.feature.lists.domain.ListRow
import com.interlinedlist.android.feature.lists.domain.ListSchema
Expand Down Expand Up @@ -306,7 +308,7 @@ class DefaultListsRepository @Inject constructor(
override suspend fun addRow(listId: String, values: Map<String, String>): ApiResult<ListRow> =
withContext(dispatchers.io) {
safeApiCall(json) { api.createRow(listId, RowWriteRequest(values.toJsonData())) }
.map { it.row ?: it.data ?: RowDto(id = "", data = kotlinx.serialization.json.JsonObject(emptyMap())) }
.map { it.row ?: it.data ?: RowDto(id = "") }
.map(RowMapper::fromDto)
}

Expand All @@ -316,7 +318,7 @@ class DefaultListsRepository @Inject constructor(
values: Map<String, String>,
): ApiResult<ListRow> = withContext(dispatchers.io) {
safeApiCall(json) { api.updateRow(listId, rowId, RowWriteRequest(values.toJsonData())) }
.map { it.row ?: it.data ?: RowDto(id = rowId, data = kotlinx.serialization.json.JsonObject(emptyMap())) }
.map { it.row ?: it.data ?: RowDto(id = rowId) }
.map(RowMapper::fromDto)
}

Expand All @@ -325,6 +327,24 @@ class DefaultListsRepository @Inject constructor(
safeApiCall(json) { api.deleteRow(listId, rowId) }.map { }
}

override suspend fun pollFreshness(
listId: String,
rowVersions: Map<String, Int>,
focusedRowId: String?,
): ApiResult<ListFreshness> = withContext(dispatchers.io) {
val body = RowVersionsRequest(
// The server rejects more than 500 rows per request outright, so the
// oldest-held window is what gets watched rather than losing the poll.
rowVersions = if (rowVersions.size <= MAX_POLLED_ROWS) {
rowVersions
} else {
rowVersions.entries.take(MAX_POLLED_ROWS).associate { it.key to it.value }
},
focusedRowId = focusedRowId,
)
safeApiCall(json) { api.pollRowVersions(listId, body) }.map(FreshnessMapper::fromDto)
}

override suspend fun getFolders(): ApiResult<List<ListFolder>> = withContext(dispatchers.io) {
safeApiCall(json) { api.getFolders() }
.map { response -> response.items.map(ListMapper::folderFromDto) }
Expand Down Expand Up @@ -670,6 +690,9 @@ class DefaultListsRepository @Inject constructor(
/** Safety net for a breadcrumb walk: deep nesting is not worth the requests. */
const val MAX_PARENT_CHAIN = 10

/** The freshness poll's documented ceiling — beyond it the server returns 400. */
const val MAX_POLLED_ROWS = 500

/** Matches the server's own copy for the 403 a free owner receives. */
const val NOT_SUBSCRIBED_MESSAGE = "Subscribe to invite people to lists."

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.interlinedlist.android.feature.lists.data

import com.interlinedlist.android.feature.lists.data.remote.dto.PresentUserDto
import com.interlinedlist.android.feature.lists.data.remote.dto.RowVersionsResponse
import com.interlinedlist.android.feature.lists.domain.ListFreshness
import com.interlinedlist.android.feature.lists.domain.ListPresence

/**
* Projects the freshness poll's response into the domain.
*
* Changed rows arrive in exactly the shape `GET /api/lists/{id}/data` returns, so
* they go through [RowMapper] untouched and a repainted row is indistinguishable
* from a fetched one. Presence entries with no resolvable user id are dropped
* rather than rendered as a blank avatar.
*/
object FreshnessMapper {

fun fromDto(dto: RowVersionsResponse): ListFreshness = ListFreshness(
changed = dto.changed.filter { it.id.isNotBlank() }.map(RowMapper::fromDto),
deletedRowIds = dto.deletedIds,
presence = dto.users.mapNotNull(::presenceFromDto),
collaborative = dto.collaborative,
)

private fun presenceFromDto(dto: PresentUserDto): ListPresence? {
val userId = dto.resolvedUserId ?: return null
return ListPresence(
userId = userId,
displayName = dto.resolvedName,
username = dto.username,
focusedRowId = dto.focusedRowId,
color = dto.color,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.interlinedlist.android.feature.lists.domain.InviteRole
import com.interlinedlist.android.feature.lists.domain.ListConnection
import com.interlinedlist.android.feature.lists.domain.ListDetail
import com.interlinedlist.android.feature.lists.domain.ListFolder
import com.interlinedlist.android.feature.lists.domain.ListFreshness
import com.interlinedlist.android.feature.lists.domain.ListInvite
import com.interlinedlist.android.feature.lists.domain.ListRow
import com.interlinedlist.android.feature.lists.domain.ListSchema
Expand Down Expand Up @@ -146,6 +147,22 @@ interface ListsRepository {

suspend fun deleteRow(listId: String, rowId: String): ApiResult<Unit>

/**
* One combined freshness poll and presence heartbeat for an open list.
*
* [rowVersions] are the versions of the rows currently held, keyed by row id;
* the server answers with only what moved, so the caller repaints those rows
* instead of refetching the table. [focusedRowId] publishes which row the user
* is on so other people see it. Rows whose version is unknown are not asked
* about — quoting a made-up version would have the server return the whole
* table on every beat.
*/
suspend fun pollFreshness(
listId: String,
rowVersions: Map<String, Int>,
focusedRowId: String? = null,
): ApiResult<ListFreshness>

suspend fun getFolders(): ApiResult<List<ListFolder>>

suspend fun createFolder(name: String, parentId: String?): ApiResult<ListFolder>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ object RowMapper {
fun fromDto(dto: RowDto): ListRow =
ListRow(
id = dto.id,
values = dto.data.mapValues { (_, value) -> displayString(value) },
values = dto.fields.mapValues { (_, value) -> displayString(value) },
version = dto.version,
)

/** Coerces any JSON value to a human-readable string. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import com.interlinedlist.android.feature.lists.data.remote.dto.ListViewsRespons
import com.interlinedlist.android.feature.lists.data.remote.dto.ListsResponse
import com.interlinedlist.android.feature.lists.data.remote.dto.RefreshResultDto
import com.interlinedlist.android.feature.lists.data.remote.dto.RowEnvelope
import com.interlinedlist.android.feature.lists.data.remote.dto.RowVersionsRequest
import com.interlinedlist.android.feature.lists.data.remote.dto.RowVersionsResponse
import com.interlinedlist.android.feature.lists.data.remote.dto.RowWriteRequest
import com.interlinedlist.android.feature.lists.data.remote.dto.CreateShareLinkRequest
import com.interlinedlist.android.feature.lists.data.remote.dto.RowsResponse
Expand Down Expand Up @@ -175,6 +177,17 @@ interface ListsApi {
@Body body: RowWriteRequest,
): RowEnvelope

/**
* Combined freshness poll and presence heartbeat for the grid. A `POST`
* because it writes the caller's heartbeat and because the row versions
* belong in a body. At most 500 rows per request.
*/
@POST("api/lists/{id}/data/versions")
suspend fun pollRowVersions(
@Path("id") id: String,
@Body body: RowVersionsRequest,
): RowVersionsResponse

@DELETE("api/lists/{id}/data/{rowId}")
suspend fun deleteRow(
@Path("id") id: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.interlinedlist.android.feature.lists.data.remote.dto

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive

/**
* Body for `POST /api/lists/{id}/data/versions`: the row versions we are holding
* plus the row the user is on. Both are optional — the server accepts an empty
* body — but without [rowVersions] it has nothing to compare against, so a poll
* that sends none can only answer the presence half.
*/
@Serializable
data class RowVersionsRequest(
val rowVersions: Map<String, Int> = emptyMap(),
val focusedRowId: String? = null,
)

/**
* Someone present in the list. Field names follow the documented
* `{ userId, name, username, color, focusedRowId }`; `id`/`displayName` are
* tolerated too, because `users` could not be observed live (it needs a second
* person on the list) and the user objects the same endpoint *does* return —
* `createdByUser` / `lastEditedByUser` — are keyed `{ id, username, displayName,
* avatar }`.
*/
@Serializable
data class PresentUserDto(
val userId: String? = null,
val id: String? = null,
val name: String? = null,
val displayName: String? = null,
val username: String? = null,
val avatar: String? = null,
val color: String? = null,
val focusedRowId: String? = null,
) {
val resolvedUserId: String? get() = (userId ?: id)?.takeIf { it.isNotBlank() }
val resolvedName: String? get() = displayName?.takeIf { it.isNotBlank() } ?: name?.takeIf { it.isNotBlank() }
}

/**
* Response for the freshness poll, verified live:
* `changed` holds whole rows in the same shape `GET .../data` returns (keyed
* `rowData`, with `version`), `deleted` is a bare array of row id strings, and
* `collaborative` is the server's own flag. Only `users` could not be observed —
* it needs a second person on the list.
*
* `deleted` is still decoded as raw JSON so an array of `{ "id": … }` objects
* would be understood rather than failing the whole poll.
*/
@Serializable
data class RowVersionsResponse(
val changed: List<RowDto> = emptyList(),
val deleted: List<JsonElement> = emptyList(),
val users: List<PresentUserDto> = emptyList(),
val collaborative: Boolean = false,
) {
val deletedIds: List<String>
get() = deleted.mapNotNull { element ->
when (element) {
is JsonPrimitive -> element.content.takeIf { it.isNotBlank() }
is JsonObject -> element.jsonObject["id"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
else -> null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,28 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject

/**
* A single data row. `data` is the dynamic key→value map keyed by schema field
* keys; it is kept as a [JsonObject] and projected to display strings by the
* mapper, so any schema is supported without a fixed shape.
* A single data row. The field map is a dynamic key→value object keyed by schema
* field keys; it is kept as a [JsonObject] and projected to display strings by
* the mapper, so any schema is supported without a fixed shape.
*
* The live API sends it as **`rowData`** (confirmed against `GET .../data`, the
* create/update echo and the freshness poll), while `data` is accepted as well
* since some payloads have been modelled that way; [fields] picks whichever is
* present.
*
* [version] is the row's optimistic-concurrency counter — what the grid's
* freshness poll compares against. It is absent on sources that do not version
* rows (a GitHub-backed list).
*/
@Serializable
data class RowDto(
val id: String,
val data: JsonObject = JsonObject(emptyMap()),
)
val rowData: JsonObject? = null,
val data: JsonObject? = null,
val version: Int? = null,
) {
val fields: JsonObject get() = rowData ?: data ?: JsonObject(emptyMap())
}

/** Envelope for `GET /api/lists/{id}/data`. */
@Serializable
Expand Down
Loading
Loading