feat: support session-level hidden flag in SessionListResponse and SessionsScreen (#1033)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
aa28d96b5d
commit
908255f5ad
10 files changed
+316
-20
No files matched your search
@@ -83,6 +83,10 @@ data class SessionInfo(
|
||||
// Uses LenientNullableBooleanSerializer to tolerate both JSON booleans and raw SQLite ints (issue #966).
|
||||
@Serializable(with = LenientNullableBooleanSerializer::class)
|
||||
val pinned: Boolean? = null,
|
||||
// Session-level hidden flag (issue #1019): background agent sessions or internal routines.
|
||||
// Uses LenientNullableBooleanSerializer to tolerate booleans, raw SQLite ints (0/1), and strings.
|
||||
@Serializable(with = LenientNullableBooleanSerializer::class)
|
||||
val hidden: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -95,10 +99,11 @@ data class SessionStatsResponse(
|
||||
|
||||
@Serializable
|
||||
data class SessionRenameRequest(
|
||||
// Both fields are optional: the backend PATCH accepts any subset
|
||||
// (SessionRename model: title/archived/pinned, omitted = unchanged).
|
||||
// All fields are optional: the backend PATCH accepts any subset
|
||||
// (SessionRename model: title/archived/pinned/hidden, omitted = unchanged).
|
||||
val title: String? = null,
|
||||
val pinned: Boolean? = null,
|
||||
val hidden: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -216,6 +216,14 @@ interface HermesApiService {
|
||||
@Body body: SessionRenameRequest,
|
||||
): Response<Unit>
|
||||
|
||||
// Hide/unhide rides the same PATCH /api/sessions/{id} — body carries only
|
||||
// {hidden} (backend SessionRename model, any subset accepted; issue #1019).
|
||||
@PATCH("api/sessions/{id}")
|
||||
suspend fun setSessionHidden(
|
||||
@Path("id", encoded = true) sessionId: String,
|
||||
@Body body: SessionRenameRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/sessions/bulk-delete")
|
||||
suspend fun bulkDeleteSessions(
|
||||
@Body body: BulkDeleteRequest,
|
||||
|
||||
@@ -46,6 +46,8 @@ import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SelectAll
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
@@ -59,6 +61,7 @@ import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -290,7 +293,7 @@ private fun displayedSessions(state: SessionsUiState): List<SessionTreeItem> =
|
||||
)
|
||||
}
|
||||
} else {
|
||||
flattenSessionTree(state.sessions)
|
||||
flattenSessionTree(state.displaySessions)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@@ -313,6 +316,7 @@ fun SessionsScreen(
|
||||
state.isSearchMode,
|
||||
state.searchQuery,
|
||||
state.sessions,
|
||||
state.showHidden,
|
||||
state.searchResults,
|
||||
) {
|
||||
displayedSessions(state)
|
||||
@@ -521,6 +525,29 @@ fun SessionsScreen(
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadSessions() },
|
||||
actions = {
|
||||
if (state.hasHiddenSessions) {
|
||||
IconButton(
|
||||
onClick = { viewModel.toggleShowHidden() },
|
||||
modifier = Modifier.testTag("sessions_action_toggle_hidden"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (state.showHidden) {
|
||||
Icons.Filled.VisibilityOff
|
||||
} else {
|
||||
Icons.Filled.Visibility
|
||||
},
|
||||
contentDescription =
|
||||
if (state.showHidden) {
|
||||
stringResource(R.string.sessions_hide_hidden)
|
||||
} else {
|
||||
stringResource(R.string.sessions_show_hidden)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
) {
|
||||
// Single scrolling column: search bar pinned on top, list fills below.
|
||||
@@ -685,6 +712,16 @@ fun SessionsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
state.displaySessions.isEmpty() -> {
|
||||
EmptyState(
|
||||
title = stringResource(R.string.history_empty_title),
|
||||
subtitle = stringResource(R.string.sessions_all_hidden_desc),
|
||||
icon = Icons.Filled.VisibilityOff,
|
||||
actionLabel = stringResource(R.string.sessions_show_hidden),
|
||||
onAction = { viewModel.toggleShowHidden() },
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// ── Stats row ───────────────────────────────────────
|
||||
@@ -801,6 +838,7 @@ fun SessionsScreen(
|
||||
isSelected = session.id in state.selectedIds,
|
||||
isDeleting = session.id in state.deletingSessionIds,
|
||||
isPinned = session.pinned == true,
|
||||
isHidden = session.hidden == true,
|
||||
highlightBackground = primaryContainer,
|
||||
highlightForeground = onPrimaryContainer,
|
||||
onCardClick = {
|
||||
@@ -822,6 +860,7 @@ fun SessionsScreen(
|
||||
)
|
||||
},
|
||||
onTogglePin = { viewModel.togglePin(session.id) },
|
||||
onToggleHide = { viewModel.toggleHide(session.id) },
|
||||
onDelete = { viewModel.requestDeleteSession(session.id) },
|
||||
)
|
||||
}
|
||||
@@ -969,6 +1008,7 @@ private fun SessionCard(
|
||||
isSelected: Boolean,
|
||||
isDeleting: Boolean,
|
||||
isPinned: Boolean,
|
||||
isHidden: Boolean = false,
|
||||
highlightBackground: Color,
|
||||
highlightForeground: Color,
|
||||
onCardClick: () -> Unit,
|
||||
@@ -976,6 +1016,7 @@ private fun SessionCard(
|
||||
onSelect: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onTogglePin: () -> Unit,
|
||||
onToggleHide: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
@@ -1095,6 +1136,13 @@ private fun SessionCard(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (session.hidden == true) {
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.sessions_hidden_badge),
|
||||
status = StatusBadgeType.NEUTRAL,
|
||||
)
|
||||
}
|
||||
if (!session.status.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
StatusBadge(
|
||||
@@ -1116,6 +1164,11 @@ private fun SessionCard(
|
||||
menuExpanded = false
|
||||
onTogglePin()
|
||||
},
|
||||
isHidden = isHidden,
|
||||
onToggleHide = {
|
||||
menuExpanded = false
|
||||
onToggleHide()
|
||||
},
|
||||
onSelect = {
|
||||
menuExpanded = false
|
||||
onSelect()
|
||||
@@ -1301,6 +1354,8 @@ private fun SessionActionMenu(
|
||||
isDeleting: Boolean,
|
||||
isPinned: Boolean = false,
|
||||
onTogglePin: (() -> Unit)? = null,
|
||||
isHidden: Boolean = false,
|
||||
onToggleHide: (() -> Unit)? = null,
|
||||
onSelect: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
@@ -1324,6 +1379,33 @@ private fun SessionActionMenu(
|
||||
onClick = onTogglePin,
|
||||
)
|
||||
}
|
||||
if (onToggleHide != null) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (isHidden) {
|
||||
R.string.sessions_action_unhide
|
||||
} else {
|
||||
R.string.sessions_action_hide
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (isHidden) {
|
||||
Icons.Filled.Visibility
|
||||
} else {
|
||||
Icons.Filled.VisibilityOff
|
||||
},
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = onToggleHide,
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.sessions_action_select)) },
|
||||
leadingIcon = { Icon(Icons.Filled.CheckCircle, contentDescription = null) },
|
||||
|
||||
@@ -89,8 +89,15 @@ data class SessionsUiState(
|
||||
val isSearching: Boolean = false,
|
||||
val searchResults: List<SessionSearchResult> = emptyList(),
|
||||
val searchError: String? = null,
|
||||
val showHidden: Boolean = false,
|
||||
) {
|
||||
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
||||
|
||||
val hasHiddenSessions: Boolean
|
||||
get() = sessions.any { it.hidden == true }
|
||||
|
||||
val displaySessions: List<SessionInfo>
|
||||
get() = if (showHidden) sessions else sessions.filter { it.hidden != true }
|
||||
}
|
||||
|
||||
class SessionsViewModel :
|
||||
@@ -315,6 +322,10 @@ class SessionsViewModel :
|
||||
|
||||
// ── Bulk selection ───────────────────────────────────────────────────
|
||||
|
||||
fun toggleShowHidden() {
|
||||
_uiState.update { it.copy(showHidden = !it.showHidden) }
|
||||
}
|
||||
|
||||
fun toggleSelecting() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -455,6 +466,49 @@ class SessionsViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hide / unhide (issue #1019) ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Toggle the hidden flag on a session via PATCH /api/sessions/{id} ({hidden}).
|
||||
*/
|
||||
fun toggleHide(sessionId: String) {
|
||||
val session = _uiState.value.sessions.find { it.id == sessionId } ?: return
|
||||
val targetHidden = session.hidden != true
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.setSessionHidden(
|
||||
sessionId = sessionId,
|
||||
body = SessionRenameRequest(hidden = targetHidden),
|
||||
)
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
sessions =
|
||||
it.sessions
|
||||
.map { s -> if (s.id == sessionId) s.copy(hidden = targetHidden) else s }
|
||||
.pinnedFirst(),
|
||||
toastMessage =
|
||||
if (targetHidden) {
|
||||
"Session hidden"
|
||||
} else {
|
||||
"Session unhidden"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(toastMessage = "Hide failed: ${result.error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete (single) ──────────────────────────────────────────────────
|
||||
|
||||
fun requestDeleteSession(sessionId: String) {
|
||||
|
||||
@@ -406,6 +406,12 @@
|
||||
<string name="sessions_action_select_all">전체 선택</string>
|
||||
<string name="sessions_action_deselect_all">선택 해제</string>
|
||||
<string name="sessions_action_delete_n">%1$d개 삭제</string>
|
||||
<string name="sessions_action_hide">숨기기</string>
|
||||
<string name="sessions_action_unhide">숨김 해제</string>
|
||||
<string name="sessions_show_hidden">숨겨진 세션 표시</string>
|
||||
<string name="sessions_hide_hidden">숨겨진 세션 숨기기</string>
|
||||
<string name="sessions_hidden_badge">숨김</string>
|
||||
<string name="sessions_all_hidden_desc">모든 세션이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 세션을 표시하세요.</string>
|
||||
|
||||
<!-- Toolsets Screen -->
|
||||
<string name="toolsets_empty_title">툴셋이 없습니다</string>
|
||||
|
||||
@@ -523,6 +523,12 @@
|
||||
<string name="sessions_action_rename">重命名</string>
|
||||
<string name="sessions_action_pin">置顶</string>
|
||||
<string name="sessions_action_unpin">取消置顶</string>
|
||||
<string name="sessions_action_hide">隐藏</string>
|
||||
<string name="sessions_action_unhide">取消隐藏</string>
|
||||
<string name="sessions_show_hidden">显示已隐藏的会话</string>
|
||||
<string name="sessions_hide_hidden">隐藏已隐藏的会话</string>
|
||||
<string name="sessions_hidden_badge">已隐藏</string>
|
||||
<string name="sessions_all_hidden_desc">所有会话当前均已隐藏。点击下方以显示已隐藏的会话。</string>
|
||||
<string name="sessions_pinned_indicator">已置顶</string>
|
||||
<string name="sessions_fork_indicator">分支会话</string>
|
||||
<string name="sessions_rename_title">重命名会话</string>
|
||||
|
||||
@@ -599,6 +599,12 @@
|
||||
<string name="sessions_action_rename">Rename</string>
|
||||
<string name="sessions_action_pin">Pin</string>
|
||||
<string name="sessions_action_unpin">Unpin</string>
|
||||
<string name="sessions_action_hide">Hide</string>
|
||||
<string name="sessions_action_unhide">Unhide</string>
|
||||
<string name="sessions_show_hidden">Show hidden sessions</string>
|
||||
<string name="sessions_hide_hidden">Hide hidden sessions</string>
|
||||
<string name="sessions_hidden_badge">Hidden</string>
|
||||
<string name="sessions_all_hidden_desc">All sessions are currently hidden. Tap below to show hidden sessions.</string>
|
||||
<string name="sessions_pinned_indicator">Pinned</string>
|
||||
<string name="sessions_fork_indicator">Forked session</string>
|
||||
<string name="sessions_rename_title">Rename Session</string>
|
||||
|
||||
@@ -312,6 +312,41 @@ class ModelSerializationTest {
|
||||
assertEquals(false, s8.pinned)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSessionInfoDeserialization_hiddenToleratesIntAndBooleanAndString() {
|
||||
val s1 = json.decodeFromString<SessionInfo>("""{"id": "s1", "hidden": 0}""")
|
||||
assertEquals(false, s1.hidden)
|
||||
|
||||
val s2 = json.decodeFromString<SessionInfo>("""{"id": "s2", "hidden": 1}""")
|
||||
assertEquals(true, s2.hidden)
|
||||
|
||||
val s3 = json.decodeFromString<SessionInfo>("""{"id": "s3", "hidden": false}""")
|
||||
assertEquals(false, s3.hidden)
|
||||
|
||||
val s4 = json.decodeFromString<SessionInfo>("""{"id": "s4", "hidden": true}""")
|
||||
assertEquals(true, s4.hidden)
|
||||
|
||||
val s5 = json.decodeFromString<SessionInfo>("""{"id": "s5", "hidden": null}""")
|
||||
assertNull(s5.hidden)
|
||||
|
||||
val s6 = json.decodeFromString<SessionInfo>("""{"id": "s6"}""")
|
||||
assertNull(s6.hidden)
|
||||
|
||||
val s7 = json.decodeFromString<SessionInfo>("""{"id": "s7", "hidden": "1"}""")
|
||||
assertEquals(true, s7.hidden)
|
||||
|
||||
val s8 = json.decodeFromString<SessionInfo>("""{"id": "s8", "hidden": "false"}""")
|
||||
assertEquals(false, s8.hidden)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSessionRenameRequestSerialization_includesHidden() {
|
||||
val req = SessionRenameRequest(title = "new name", hidden = true)
|
||||
val encoded = json.encodeToString(SessionRenameRequest.serializer(), req)
|
||||
assertTrue(encoded.contains("\"hidden\":true"))
|
||||
assertTrue(encoded.contains("\"title\":\"new name\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSessionMessagesResponseDeserialization_missingMessages_causesNullOnNonNullableField() {
|
||||
val jsonStr = "{}"
|
||||
|
||||
@@ -31,13 +31,19 @@ class RefreshOnChangeTest {
|
||||
|
||||
private class TestViewModel : ViewModel() {
|
||||
val state = MutableStateFlow("initial")
|
||||
var job: kotlinx.coroutines.Job? = null
|
||||
|
||||
init {
|
||||
refreshOnChange(
|
||||
eventType = ChangeEvents.CRON,
|
||||
apiCall = { NetworkResult.Success("refreshed") },
|
||||
onSuccess = { state.value = it },
|
||||
)
|
||||
job =
|
||||
refreshOnChange(
|
||||
eventType = ChangeEvents.CRON,
|
||||
apiCall = { NetworkResult.Success("refreshed") },
|
||||
onSuccess = { state.value = it },
|
||||
)
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
job?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,23 +61,31 @@ class RefreshOnChangeTest {
|
||||
fun testRefreshOnChange_updatesOnMatchingEvent() =
|
||||
runTest(testDispatcher) {
|
||||
val viewModel = TestViewModel()
|
||||
// Let the init coroutine run so the collector actually subscribes
|
||||
// before the event fires (replay=0 — events before subscription
|
||||
// are dropped, matching production where VMs subscribe at
|
||||
// construction and events arrive afterwards).
|
||||
advanceUntilIdle()
|
||||
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.CRON))
|
||||
advanceUntilIdle()
|
||||
assertEquals("refreshed", viewModel.state.value)
|
||||
try {
|
||||
// Let the init coroutine run so the collector actually subscribes
|
||||
// before the event fires (replay=0 — events before subscription
|
||||
// are dropped, matching production where VMs subscribe at
|
||||
// construction and events arrive afterwards).
|
||||
advanceUntilIdle()
|
||||
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.CRON))
|
||||
advanceUntilIdle()
|
||||
assertEquals("refreshed", viewModel.state.value)
|
||||
} finally {
|
||||
viewModel.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRefreshOnChange_ignoresOtherEventTypes() =
|
||||
runTest(testDispatcher) {
|
||||
val viewModel = TestViewModel()
|
||||
advanceUntilIdle()
|
||||
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS))
|
||||
advanceUntilIdle()
|
||||
assertEquals("initial", viewModel.state.value)
|
||||
try {
|
||||
advanceUntilIdle()
|
||||
ChangeEventHub.emit(WsEvent.ChangeEvent(ChangeEvents.SESSIONS))
|
||||
advanceUntilIdle()
|
||||
assertEquals("initial", viewModel.state.value)
|
||||
} finally {
|
||||
viewModel.cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,4 +281,84 @@ class SessionsViewModelTest {
|
||||
.contains("Pin failed"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `displaySessions filters out hidden sessions by default and shows them on toggleShowHidden`() {
|
||||
val vm = createViewModel()
|
||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
||||
Response.success(
|
||||
SessionListResponse(
|
||||
sessions =
|
||||
listOf(
|
||||
SessionInfo("s-visible-1", hidden = false),
|
||||
SessionInfo("s-hidden", hidden = true),
|
||||
SessionInfo("s-visible-2", hidden = null),
|
||||
),
|
||||
total = 3,
|
||||
),
|
||||
)
|
||||
vm.loadSessions()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertTrue(vm.uiState.value.hasHiddenSessions)
|
||||
assertFalse(vm.uiState.value.showHidden)
|
||||
assertEquals(
|
||||
listOf("s-visible-1", "s-visible-2"),
|
||||
vm.uiState.value.displaySessions
|
||||
.map { it.id },
|
||||
)
|
||||
|
||||
vm.toggleShowHidden()
|
||||
assertTrue(vm.uiState.value.showHidden)
|
||||
assertEquals(
|
||||
listOf("s-visible-1", "s-hidden", "s-visible-2"),
|
||||
vm.uiState.value.displaySessions
|
||||
.map { it.id },
|
||||
)
|
||||
|
||||
vm.toggleShowHidden()
|
||||
assertFalse(vm.uiState.value.showHidden)
|
||||
assertEquals(
|
||||
listOf("s-visible-1", "s-visible-2"),
|
||||
vm.uiState.value.displaySessions
|
||||
.map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggleHide updates hidden status and surfaces toast`() {
|
||||
val vm = createViewModel()
|
||||
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
||||
Response.success(
|
||||
SessionListResponse(
|
||||
sessions = listOf(SessionInfo("s-1", hidden = false)),
|
||||
total = 1,
|
||||
),
|
||||
)
|
||||
vm.loadSessions()
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
coEvery { mockApi.setSessionHidden(any(), any()) } returns Response.success(Unit)
|
||||
vm.toggleHide("s-1")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
true,
|
||||
vm.uiState.value.sessions
|
||||
.first()
|
||||
.hidden,
|
||||
)
|
||||
assertEquals("Session hidden", vm.uiState.value.toastMessage)
|
||||
|
||||
vm.toggleHide("s-1")
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
false,
|
||||
vm.uiState.value.sessions
|
||||
.first()
|
||||
.hidden,
|
||||
)
|
||||
assertEquals("Session unhidden", vm.uiState.value.toastMessage)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user