mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
InvitePeopleEvents -> InvitePeopleEvent
This commit is contained in:
+2
-2
@@ -21,7 +21,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.createroom.impl.R
|
import io.element.android.features.createroom.impl.R
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleEvents
|
import io.element.android.features.invitepeople.api.InvitePeopleEvent
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleState
|
import io.element.android.features.invitepeople.api.InvitePeopleState
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleStatePreviewParam
|
import io.element.android.features.invitepeople.api.InvitePeopleStatePreviewParam
|
||||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||||
@@ -56,7 +56,7 @@ fun AddPeopleView(
|
|||||||
Button(
|
Button(
|
||||||
text = stringResource(CommonStrings.action_finish),
|
text = stringResource(CommonStrings.action_finish),
|
||||||
onClick = {
|
onClick = {
|
||||||
state.eventSink(InvitePeopleEvents.SendInvites)
|
state.eventSink(InvitePeopleEvent.SendInvites)
|
||||||
},
|
},
|
||||||
enabled = state.canInvite,
|
enabled = state.canInvite,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
+4
-4
@@ -11,8 +11,8 @@ package io.element.android.features.invitepeople.api
|
|||||||
/**
|
/**
|
||||||
* Events the invite people UI sends to its presenter through [InvitePeopleState.eventSink].
|
* Events the invite people UI sends to its presenter through [InvitePeopleState.eventSink].
|
||||||
*/
|
*/
|
||||||
interface InvitePeopleEvents {
|
interface InvitePeopleEvent {
|
||||||
data object SendInvites : InvitePeopleEvents
|
data object SendInvites : InvitePeopleEvent
|
||||||
data object CloseSearch : InvitePeopleEvents
|
data object CloseSearch : InvitePeopleEvent
|
||||||
data object ClearError : InvitePeopleEvents
|
data object ClearError : InvitePeopleEvent
|
||||||
}
|
}
|
||||||
+1
-1
@@ -28,5 +28,5 @@ interface InvitePeopleState {
|
|||||||
val createRoomFromDmAction: AsyncAction<RoomId>
|
val createRoomFromDmAction: AsyncAction<RoomId>
|
||||||
|
|
||||||
/** Where the UI sends its events. */
|
/** Where the UI sends its events. */
|
||||||
val eventSink: (InvitePeopleEvents) -> Unit
|
val eventSink: (InvitePeopleEvent) -> Unit
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -27,7 +27,7 @@ private data class PreviewInvitePeopleState(
|
|||||||
override val isSearchActive: Boolean,
|
override val isSearchActive: Boolean,
|
||||||
override val sendInvitesAction: AsyncAction<Unit>,
|
override val sendInvitesAction: AsyncAction<Unit>,
|
||||||
override val createRoomFromDmAction: AsyncAction<RoomId>,
|
override val createRoomFromDmAction: AsyncAction<RoomId>,
|
||||||
override val eventSink: (InvitePeopleEvents) -> Unit,
|
override val eventSink: (InvitePeopleEvent) -> Unit,
|
||||||
) : InvitePeopleState
|
) : InvitePeopleState
|
||||||
|
|
||||||
private fun aPreviewInvitePeopleState(
|
private fun aPreviewInvitePeopleState(
|
||||||
@@ -35,7 +35,7 @@ private fun aPreviewInvitePeopleState(
|
|||||||
isSearchActive: Boolean = false,
|
isSearchActive: Boolean = false,
|
||||||
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
createRoomFromDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
|
createRoomFromDmAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
|
||||||
eventSink: (InvitePeopleEvents) -> Unit = {},
|
eventSink: (InvitePeopleEvent) -> Unit = {},
|
||||||
) = PreviewInvitePeopleState(
|
) = PreviewInvitePeopleState(
|
||||||
canInvite = canInvite,
|
canInvite = canInvite,
|
||||||
isSearchActive = isSearchActive,
|
isSearchActive = isSearchActive,
|
||||||
|
|||||||
+2
-2
@@ -8,10 +8,10 @@
|
|||||||
|
|
||||||
package io.element.android.features.invitepeople.impl
|
package io.element.android.features.invitepeople.impl
|
||||||
|
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleEvents
|
import io.element.android.features.invitepeople.api.InvitePeopleEvent
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
||||||
sealed interface DefaultInvitePeopleEvents : InvitePeopleEvents {
|
sealed interface DefaultInvitePeopleEvents : InvitePeopleEvent {
|
||||||
data class ToggleUser(val user: MatrixUser) : DefaultInvitePeopleEvents
|
data class ToggleUser(val user: MatrixUser) : DefaultInvitePeopleEvents
|
||||||
data class OnSearchActiveChanged(val active: Boolean) : DefaultInvitePeopleEvents
|
data class OnSearchActiveChanged(val active: Boolean) : DefaultInvitePeopleEvents
|
||||||
data object DismissUnknownUsersModal : DefaultInvitePeopleEvents
|
data object DismissUnknownUsersModal : DefaultInvitePeopleEvents
|
||||||
|
|||||||
+5
-5
@@ -24,7 +24,7 @@ import dev.zacsweers.metro.Assisted
|
|||||||
import dev.zacsweers.metro.AssistedFactory
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleEvents
|
import io.element.android.features.invitepeople.api.InvitePeopleEvent
|
||||||
import io.element.android.features.invitepeople.api.InvitePeoplePresenter
|
import io.element.android.features.invitepeople.api.InvitePeoplePresenter
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleState
|
import io.element.android.features.invitepeople.api.InvitePeopleState
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
@@ -179,7 +179,7 @@ class DefaultInvitePeoplePresenter(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleEvent(event: InvitePeopleEvents) {
|
fun handleEvent(event: InvitePeopleEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
// Dedicated `when` for exhaustivity.
|
// Dedicated `when` for exhaustivity.
|
||||||
is DefaultInvitePeopleEvents -> when (event) {
|
is DefaultInvitePeopleEvents -> when (event) {
|
||||||
@@ -207,7 +207,7 @@ class DefaultInvitePeoplePresenter(
|
|||||||
sendInvitesAction.value = AsyncAction.Uninitialized
|
sendInvitesAction.value = AsyncAction.Uninitialized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is InvitePeopleEvents.SendInvites -> {
|
is InvitePeopleEvent.SendInvites -> {
|
||||||
if (unknownUsers.isNotEmpty() && sendInvitesAction.value !is ConfirmingUnknownUserInvitation) {
|
if (unknownUsers.isNotEmpty() && sendInvitesAction.value !is ConfirmingUnknownUserInvitation) {
|
||||||
sendInvitesAction.value = ConfirmingUnknownUserInvitation(
|
sendInvitesAction.value = ConfirmingUnknownUserInvitation(
|
||||||
unknownUsers
|
unknownUsers
|
||||||
@@ -224,11 +224,11 @@ class DefaultInvitePeoplePresenter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is InvitePeopleEvents.CloseSearch -> {
|
is InvitePeopleEvent.CloseSearch -> {
|
||||||
searchActive = false
|
searchActive = false
|
||||||
queryState.clearText()
|
queryState.clearText()
|
||||||
}
|
}
|
||||||
is InvitePeopleEvents.ClearError -> {
|
is InvitePeopleEvent.ClearError -> {
|
||||||
sendInvitesAction.value = AsyncAction.Uninitialized
|
sendInvitesAction.value = AsyncAction.Uninitialized
|
||||||
createRoomFromDmAction.value = AsyncAction.Uninitialized
|
createRoomFromDmAction.value = AsyncAction.Uninitialized
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@
|
|||||||
package io.element.android.features.invitepeople.impl
|
package io.element.android.features.invitepeople.impl
|
||||||
|
|
||||||
import androidx.compose.foundation.text.input.TextFieldState
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleEvents
|
import io.element.android.features.invitepeople.api.InvitePeopleEvent
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleState
|
import io.element.android.features.invitepeople.api.InvitePeopleState
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
@@ -29,5 +29,5 @@ data class DefaultInvitePeopleState(
|
|||||||
override val sendInvitesAction: AsyncAction<Unit>,
|
override val sendInvitesAction: AsyncAction<Unit>,
|
||||||
override val createRoomFromDmAction: AsyncAction<RoomId>,
|
override val createRoomFromDmAction: AsyncAction<RoomId>,
|
||||||
val suggestions: ImmutableList<InvitableUser>,
|
val suggestions: ImmutableList<InvitableUser>,
|
||||||
override val eventSink: (InvitePeopleEvents) -> Unit
|
override val eventSink: (InvitePeopleEvent) -> Unit
|
||||||
) : InvitePeopleState
|
) : InvitePeopleState
|
||||||
|
|||||||
+2
-2
@@ -30,7 +30,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleEvents
|
import io.element.android.features.invitepeople.api.InvitePeopleEvent
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
|
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||||
@@ -164,7 +164,7 @@ private fun InvitePeopleContentView(
|
|||||||
InvitePeopleConfirmModal(
|
InvitePeopleConfirmModal(
|
||||||
users = state.sendInvitesAction.users,
|
users = state.sendInvitesAction.users,
|
||||||
onDismiss = { state.eventSink.invoke(DefaultInvitePeopleEvents.DismissUnknownUsersModal) },
|
onDismiss = { state.eventSink.invoke(DefaultInvitePeopleEvents.DismissUnknownUsersModal) },
|
||||||
onInvite = { state.eventSink.invoke(InvitePeopleEvents.SendInvites) },
|
onInvite = { state.eventSink.invoke(InvitePeopleEvent.SendInvites) },
|
||||||
onRemove = { state.eventSink.invoke(DefaultInvitePeopleEvents.RemoveUnknownUsers) }
|
onRemove = { state.eventSink.invoke(DefaultInvitePeopleEvents.RemoveUnknownUsers) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-9
@@ -11,7 +11,7 @@ package io.element.android.features.invitepeople.impl
|
|||||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||||
import app.cash.turbine.ReceiveTurbine
|
import app.cash.turbine.ReceiveTurbine
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleEvents
|
import io.element.android.features.invitepeople.api.InvitePeopleEvent
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
@@ -94,7 +94,7 @@ internal class DefaultInvitePeoplePresenterTest {
|
|||||||
assertThat(resultState.isSearchActive).isTrue()
|
assertThat(resultState.isSearchActive).isTrue()
|
||||||
resultState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
|
resultState.searchQuery.setTextAndPlaceCursorAtEnd("some query")
|
||||||
assertThat(awaitItemAsDefault().searchQuery.text.toString()).isEqualTo("some query")
|
assertThat(awaitItemAsDefault().searchQuery.text.toString()).isEqualTo("some query")
|
||||||
resultState.eventSink(InvitePeopleEvents.CloseSearch)
|
resultState.eventSink(InvitePeopleEvent.CloseSearch)
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
awaitItemAsDefault().also {
|
awaitItemAsDefault().also {
|
||||||
assertThat(it.isSearchActive).isFalse()
|
assertThat(it.isSearchActive).isFalse()
|
||||||
@@ -420,7 +420,7 @@ internal class DefaultInvitePeoplePresenterTest {
|
|||||||
// The results are updated...
|
// The results are updated...
|
||||||
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||||
// Send invites
|
// Send invites
|
||||||
initialState.eventSink(InvitePeopleEvents.SendInvites)
|
initialState.eventSink(InvitePeopleEvent.SendInvites)
|
||||||
|
|
||||||
// Can't invite in the loading state
|
// Can't invite in the loading state
|
||||||
awaitItem().run {
|
awaitItem().run {
|
||||||
@@ -474,7 +474,7 @@ internal class DefaultInvitePeoplePresenterTest {
|
|||||||
// The results are updated...
|
// The results are updated...
|
||||||
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||||
// Send invites
|
// Send invites
|
||||||
initialState.eventSink(InvitePeopleEvents.SendInvites)
|
initialState.eventSink(InvitePeopleEvent.SendInvites)
|
||||||
|
|
||||||
// Can't invite in the loading state
|
// Can't invite in the loading state
|
||||||
awaitItem().run {
|
awaitItem().run {
|
||||||
@@ -655,7 +655,7 @@ internal class DefaultInvitePeoplePresenterTest {
|
|||||||
// If we do not have their identity cached, or fail to fetch it, we should mark them as unknown.
|
// If we do not have their identity cached, or fail to fetch it, we should mark them as unknown.
|
||||||
awaitItemAsDefault().run {
|
awaitItemAsDefault().run {
|
||||||
assertThat(selectedUsers).containsExactly(alice, bob, charlie)
|
assertThat(selectedUsers).containsExactly(alice, bob, charlie)
|
||||||
eventSink(InvitePeopleEvents.SendInvites)
|
eventSink(InvitePeopleEvent.SendInvites)
|
||||||
}
|
}
|
||||||
|
|
||||||
getUserIdentityResult.assertions().isCalledExactly(3).withSequence(
|
getUserIdentityResult.assertions().isCalledExactly(3).withSequence(
|
||||||
@@ -668,7 +668,7 @@ internal class DefaultInvitePeoplePresenterTest {
|
|||||||
awaitItemAsDefault().run {
|
awaitItemAsDefault().run {
|
||||||
assertThat(sendInvitesAction).isInstanceOf(ConfirmingUnknownUserInvitation::class.java)
|
assertThat(sendInvitesAction).isInstanceOf(ConfirmingUnknownUserInvitation::class.java)
|
||||||
assertThat(canInvite).isTrue()
|
assertThat(canInvite).isTrue()
|
||||||
eventSink(InvitePeopleEvents.SendInvites)
|
eventSink(InvitePeopleEvent.SendInvites)
|
||||||
}
|
}
|
||||||
|
|
||||||
delay(1_000)
|
delay(1_000)
|
||||||
@@ -738,7 +738,7 @@ internal class DefaultInvitePeoplePresenterTest {
|
|||||||
searchResults.users().map { Pair(it.matrixUser, it.isSelected) }
|
searchResults.users().map { Pair(it.matrixUser, it.isSelected) }
|
||||||
).containsExactly(Pair(alice, true), Pair(bob, true))
|
).containsExactly(Pair(alice, true), Pair(bob, true))
|
||||||
|
|
||||||
eventSink(InvitePeopleEvents.SendInvites)
|
eventSink(InvitePeopleEvent.SendInvites)
|
||||||
}
|
}
|
||||||
|
|
||||||
getUserIdentityResult.assertions().isCalledExactly(3).withSequence(
|
getUserIdentityResult.assertions().isCalledExactly(3).withSequence(
|
||||||
@@ -803,7 +803,7 @@ internal class DefaultInvitePeoplePresenterTest {
|
|||||||
|
|
||||||
awaitItemAsDefault().run {
|
awaitItemAsDefault().run {
|
||||||
assertThat(selectedUsers).containsExactly(alice, bob, charlie)
|
assertThat(selectedUsers).containsExactly(alice, bob, charlie)
|
||||||
eventSink(InvitePeopleEvents.SendInvites)
|
eventSink(InvitePeopleEvent.SendInvites)
|
||||||
}
|
}
|
||||||
|
|
||||||
getUserIdentityResult.assertions().isCalledExactly(3).withSequence(
|
getUserIdentityResult.assertions().isCalledExactly(3).withSequence(
|
||||||
@@ -856,7 +856,7 @@ internal class DefaultInvitePeoplePresenterTest {
|
|||||||
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(alice))
|
initialState.eventSink(DefaultInvitePeopleEvents.ToggleUser(alice))
|
||||||
|
|
||||||
// And we send the invites
|
// And we send the invites
|
||||||
initialState.eventSink(InvitePeopleEvents.SendInvites)
|
initialState.eventSink(InvitePeopleEvent.SendInvites)
|
||||||
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -20,7 +20,7 @@ import dev.zacsweers.metro.Assisted
|
|||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import im.vector.app.features.analytics.plan.MobileScreen
|
import im.vector.app.features.analytics.plan.MobileScreen
|
||||||
import io.element.android.annotations.ContributesNode
|
import io.element.android.annotations.ContributesNode
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleEvents
|
import io.element.android.features.invitepeople.api.InvitePeopleEvent
|
||||||
import io.element.android.features.invitepeople.api.InvitePeoplePresenter
|
import io.element.android.features.invitepeople.api.InvitePeoplePresenter
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleRenderer
|
import io.element.android.features.invitepeople.api.InvitePeopleRenderer
|
||||||
import io.element.android.libraries.architecture.callback
|
import io.element.android.libraries.architecture.callback
|
||||||
@@ -81,7 +81,7 @@ class RoomInviteMembersNode(
|
|||||||
ProgressDialog(text = stringResource(CommonStrings.common_creating_room))
|
ProgressDialog(text = stringResource(CommonStrings.common_creating_room))
|
||||||
},
|
},
|
||||||
onErrorDismiss = {
|
onErrorDismiss = {
|
||||||
state.eventSink(InvitePeopleEvents.ClearError)
|
state.eventSink(InvitePeopleEvent.ClearError)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -22,7 +22,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleEvents
|
import io.element.android.features.invitepeople.api.InvitePeopleEvent
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleState
|
import io.element.android.features.invitepeople.api.InvitePeopleState
|
||||||
import io.element.android.features.invitepeople.api.InvitePeopleStatePreviewParam
|
import io.element.android.features.invitepeople.api.InvitePeopleStatePreviewParam
|
||||||
import io.element.android.features.roomdetails.impl.R
|
import io.element.android.features.roomdetails.impl.R
|
||||||
@@ -49,13 +49,13 @@ fun RoomInviteMembersView(
|
|||||||
RoomInviteMembersTopBar(
|
RoomInviteMembersTopBar(
|
||||||
onBackClick = {
|
onBackClick = {
|
||||||
if (state.isSearchActive) {
|
if (state.isSearchActive) {
|
||||||
state.eventSink(InvitePeopleEvents.CloseSearch)
|
state.eventSink(InvitePeopleEvent.CloseSearch)
|
||||||
} else {
|
} else {
|
||||||
onBackClick()
|
onBackClick()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSubmitClick = {
|
onSubmitClick = {
|
||||||
state.eventSink(InvitePeopleEvents.SendInvites)
|
state.eventSink(InvitePeopleEvent.SendInvites)
|
||||||
},
|
},
|
||||||
canSend = state.canInvite,
|
canSend = state.canInvite,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user