Start switching from sync to room data
This commit is contained in:
parent
676d6c28a7
commit
29e81a88ac
|
@ -1,8 +1,11 @@
|
||||||
import Browser exposing (application, UrlRequest(..))
|
import Browser exposing (application, UrlRequest(..))
|
||||||
import Browser.Navigation as Nav
|
import Browser.Navigation as Nav
|
||||||
import Browser.Dom exposing (Viewport, setViewportOf)
|
import Browser.Dom exposing (Viewport, setViewportOf)
|
||||||
|
import Scylla.Room exposing (OpenRooms, applySync)
|
||||||
import Scylla.Sync exposing (..)
|
import Scylla.Sync exposing (..)
|
||||||
import Scylla.Sync.Events exposing (toMessageEvent, getType, getSender, getUnsigned)
|
import Scylla.Sync.Events exposing (toMessageEvent, getType, getSender, getUnsigned)
|
||||||
|
import Scylla.Sync.AccountData exposing (..)
|
||||||
|
import Scylla.ListUtils exposing (..)
|
||||||
import Scylla.Messages exposing (..)
|
import Scylla.Messages exposing (..)
|
||||||
import Scylla.Login exposing (..)
|
import Scylla.Login exposing (..)
|
||||||
import Scylla.Api exposing (..)
|
import Scylla.Api exposing (..)
|
||||||
|
@ -14,7 +17,7 @@ import Scylla.UserData exposing (..)
|
||||||
import Scylla.Notification exposing (..)
|
import Scylla.Notification exposing (..)
|
||||||
import Scylla.Storage exposing (..)
|
import Scylla.Storage exposing (..)
|
||||||
import Scylla.Markdown exposing (..)
|
import Scylla.Markdown exposing (..)
|
||||||
import Scylla.AccountData exposing (..)
|
import Scylla.Room exposing (..)
|
||||||
import Url exposing (Url)
|
import Url exposing (Url)
|
||||||
import Url.Parser exposing (parse)
|
import Url.Parser exposing (parse)
|
||||||
import Url.Builder
|
import Url.Builder
|
||||||
|
@ -55,6 +58,7 @@ init _ url key =
|
||||||
, roomNames = Dict.empty
|
, roomNames = Dict.empty
|
||||||
, connected = True
|
, connected = True
|
||||||
, searchText = ""
|
, searchText = ""
|
||||||
|
, rooms = emptyOpenRooms
|
||||||
}
|
}
|
||||||
cmd = getStoreValuePort "scylla.loginInfo"
|
cmd = getStoreValuePort "scylla.loginInfo"
|
||||||
in
|
in
|
||||||
|
@ -355,6 +359,7 @@ updateSyncResponse model r notify =
|
||||||
{ model | sync = newSync sr
|
{ model | sync = newSync sr
|
||||||
, sending = sending (mergeSyncResponse model.sync sr)
|
, sending = sending (mergeSyncResponse model.sync sr)
|
||||||
, roomNames = computeRoomsDisplayNames model.userData (newSync sr)
|
, roomNames = computeRoomsDisplayNames model.userData (newSync sr)
|
||||||
|
, rooms = applySync sr model.rooms
|
||||||
}
|
}
|
||||||
in
|
in
|
||||||
case r of
|
case r of
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
module Scylla.AccountData exposing (..)
|
|
||||||
import Scylla.Sync exposing (SyncResponse, roomAccountData)
|
|
||||||
import Scylla.Sync.AccountData exposing (AccountData)
|
|
||||||
import Scylla.Sync.Rooms exposing (JoinedRoom)
|
|
||||||
import Json.Decode as Decode
|
|
||||||
import Json.Encode as Encode
|
|
||||||
import Dict exposing (Dict)
|
|
||||||
|
|
||||||
type alias DirectMessages = Dict String String
|
|
||||||
type alias DirectMessagesRaw = Dict String (List String)
|
|
||||||
|
|
||||||
directMessagesDecoder : Decode.Decoder DirectMessages
|
|
||||||
directMessagesDecoder =
|
|
||||||
Decode.dict (Decode.list Decode.string)
|
|
||||||
|> Decode.map (invertDirectMessages)
|
|
||||||
|
|
||||||
invertDirectMessages : DirectMessagesRaw -> DirectMessages
|
|
||||||
invertDirectMessages dmr =
|
|
||||||
Dict.foldl
|
|
||||||
(\k lv acc -> List.foldl (\v -> Dict.insert v k) acc lv)
|
|
||||||
Dict.empty
|
|
||||||
dmr
|
|
||||||
|
|
39
src/Scylla/ListUtils.elm
Normal file
39
src/Scylla/ListUtils.elm
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
module Scylla.ListUtils exposing (..)
|
||||||
|
import Dict exposing (Dict)
|
||||||
|
import Set exposing (Set)
|
||||||
|
|
||||||
|
groupBy : (a -> comparable) -> List a -> Dict comparable (List a)
|
||||||
|
groupBy f xs =
|
||||||
|
let
|
||||||
|
update v ml = case ml of
|
||||||
|
Just l -> Just (v::l)
|
||||||
|
Nothing -> Just [ v ]
|
||||||
|
in
|
||||||
|
List.foldl (\v acc -> Dict.update (f v) (update v) acc) Dict.empty xs
|
||||||
|
|
||||||
|
uniqueByTailRecursive : (a -> comparable) -> List a -> Set comparable -> List a -> List a
|
||||||
|
uniqueByTailRecursive f l s acc =
|
||||||
|
case l of
|
||||||
|
x::tail ->
|
||||||
|
if Set.member (f x) s
|
||||||
|
then uniqueByTailRecursive f tail s acc
|
||||||
|
else uniqueByTailRecursive f tail (Set.insert (f x) s) (x::acc)
|
||||||
|
[] -> List.reverse acc
|
||||||
|
|
||||||
|
uniqueBy : (a -> comparable) -> List a -> List a
|
||||||
|
uniqueBy f l = uniqueByTailRecursive f l Set.empty []
|
||||||
|
|
||||||
|
findFirst : (a -> Bool) -> List a -> Maybe a
|
||||||
|
findFirst cond l = case l of
|
||||||
|
x::xs -> if cond x then Just x else findFirst cond xs
|
||||||
|
[] -> Nothing
|
||||||
|
|
||||||
|
findLast : (a -> Bool) -> List a -> Maybe a
|
||||||
|
findLast cond l = findFirst cond <| List.reverse l
|
||||||
|
|
||||||
|
findFirstBy : (a -> comparable) -> (a -> Bool) -> List a -> Maybe a
|
||||||
|
findFirstBy sortFunction cond l = findFirst cond <| List.sortBy sortFunction l
|
||||||
|
|
||||||
|
findLastBy : (a -> comparable) -> (a -> Bool) -> List a -> Maybe a
|
||||||
|
findLastBy sortFunction cond l = findLast cond <| List.sortBy sortFunction l
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
module Scylla.Model exposing (..)
|
module Scylla.Model exposing (..)
|
||||||
import Scylla.Api exposing (..)
|
import Scylla.Api exposing (..)
|
||||||
import Scylla.Sync exposing (SyncResponse, HistoryResponse, senderName, roomName, roomJoinedUsers, findFirst)
|
import Scylla.Sync exposing (SyncResponse, HistoryResponse, senderName, roomName, roomJoinedUsers)
|
||||||
|
import Scylla.ListUtils exposing (findFirst)
|
||||||
|
import Scylla.Room exposing (OpenRooms)
|
||||||
import Scylla.Sync.Rooms exposing (JoinedRoom)
|
import Scylla.Sync.Rooms exposing (JoinedRoom)
|
||||||
import Scylla.Sync.AccountData exposing (AccountData)
|
import Scylla.Sync.AccountData exposing (AccountData, directMessagesDecoder)
|
||||||
import Scylla.AccountData exposing (directMessagesDecoder)
|
|
||||||
import Scylla.Login exposing (LoginResponse, Username, Password)
|
import Scylla.Login exposing (LoginResponse, Username, Password)
|
||||||
import Scylla.UserData exposing (UserData)
|
import Scylla.UserData exposing (UserData)
|
||||||
import Scylla.Route exposing (Route(..), RoomId)
|
import Scylla.Route exposing (Route(..), RoomId)
|
||||||
|
@ -37,6 +38,7 @@ type alias Model =
|
||||||
, roomNames : Dict RoomId String
|
, roomNames : Dict RoomId String
|
||||||
, connected : Bool
|
, connected : Bool
|
||||||
, searchText : String
|
, searchText : String
|
||||||
|
, rooms : OpenRooms
|
||||||
}
|
}
|
||||||
|
|
||||||
type Msg =
|
type Msg =
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
port module Scylla.Notification exposing (..)
|
port module Scylla.Notification exposing (..)
|
||||||
import Scylla.Sync exposing (SyncResponse, joinedRoomsTimelineEvents)
|
import Scylla.Sync exposing (SyncResponse, joinedRoomsTimelineEvents)
|
||||||
import Scylla.Sync.Events exposing (RoomEvent, MessageEvent, toMessageEvent)
|
import Scylla.Sync.Events exposing (RoomEvent, MessageEvent, toMessageEvent)
|
||||||
import Scylla.AccountData exposing (..)
|
|
||||||
import Json.Decode as Decode exposing (string, field)
|
import Json.Decode as Decode exposing (string, field)
|
||||||
import Dict
|
import Dict
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
module Scylla.Room exposing (..)
|
module Scylla.Room exposing (..)
|
||||||
import Scylla.Route exposing (RoomId)
|
import Scylla.Route exposing (RoomId)
|
||||||
import Scylla.Sync exposing (SyncResponse)
|
import Scylla.Sync exposing (SyncResponse)
|
||||||
|
import Scylla.Login exposing (Username)
|
||||||
|
import Scylla.UserData exposing (UserData)
|
||||||
import Scylla.Sync.Events exposing (MessageEvent, StateEvent, toStateEvent, toMessageEvent)
|
import Scylla.Sync.Events exposing (MessageEvent, StateEvent, toStateEvent, toMessageEvent)
|
||||||
import Scylla.Sync.AccountData exposing (AccountData)
|
import Scylla.Sync.AccountData exposing (AccountData, getDirectMessages)
|
||||||
import Scylla.Sync.Rooms exposing (JoinedRoom, UnreadNotificationCounts, Ephemeral)
|
import Scylla.Sync.Rooms exposing (JoinedRoom, UnreadNotificationCounts, Ephemeral)
|
||||||
import Json.Decode as Decode exposing (Value)
|
import Json.Decode as Decode exposing (Decoder, Value, decodeValue)
|
||||||
import Dict exposing (Dict)
|
import Dict exposing (Dict)
|
||||||
|
|
||||||
type alias RoomState = Dict (String, String) Value
|
type alias RoomState = Dict (String, String) Value
|
||||||
|
@ -108,3 +110,19 @@ applySync sr or =
|
||||||
|> Maybe.withDefault Dict.empty
|
|> Maybe.withDefault Dict.empty
|
||||||
in
|
in
|
||||||
Dict.foldl applyJoinedRoom or joinedRooms
|
Dict.foldl applyJoinedRoom or joinedRooms
|
||||||
|
|
||||||
|
getStateData : (String, String) -> Decoder a -> RoomData -> Maybe a
|
||||||
|
getStateData k d rd = Dict.get k rd.roomState
|
||||||
|
|> Maybe.andThen (Result.toMaybe << decodeValue d)
|
||||||
|
|
||||||
|
getRoomName : Maybe AccountData -> Dict Username UserData -> RoomId -> RoomData -> String
|
||||||
|
getRoomName ad ud rid rd =
|
||||||
|
let
|
||||||
|
customName = getStateData ("m.room.name", "") Decode.string rd
|
||||||
|
direct = Maybe.andThen getDirectMessages ad
|
||||||
|
|> Maybe.andThen (Dict.get rid)
|
||||||
|
in
|
||||||
|
case (customName, direct) of
|
||||||
|
(Just cn, _) -> cn
|
||||||
|
(_, Just d) -> d
|
||||||
|
_ -> rid
|
||||||
|
|
|
@ -2,6 +2,7 @@ module Scylla.Sync exposing (..)
|
||||||
import Scylla.Api exposing (..)
|
import Scylla.Api exposing (..)
|
||||||
import Scylla.Login exposing (Username)
|
import Scylla.Login exposing (Username)
|
||||||
import Scylla.Route exposing (RoomId)
|
import Scylla.Route exposing (RoomId)
|
||||||
|
import Scylla.ListUtils exposing (..)
|
||||||
import Scylla.Sync.DecodeTools exposing (maybeDecode)
|
import Scylla.Sync.DecodeTools exposing (maybeDecode)
|
||||||
import Scylla.Sync.Events exposing (..)
|
import Scylla.Sync.Events exposing (..)
|
||||||
import Scylla.Sync.Rooms exposing (..)
|
import Scylla.Sync.Rooms exposing (..)
|
||||||
|
@ -51,41 +52,6 @@ historyResponseDecoder =
|
||||||
|> required "chunk" (list roomEventDecoder)
|
|> required "chunk" (list roomEventDecoder)
|
||||||
|
|
||||||
-- Business Logic: Helper Functions
|
-- Business Logic: Helper Functions
|
||||||
groupBy : (a -> comparable) -> List a -> Dict comparable (List a)
|
|
||||||
groupBy f xs =
|
|
||||||
let
|
|
||||||
update v ml = case ml of
|
|
||||||
Just l -> Just (v::l)
|
|
||||||
Nothing -> Just [ v ]
|
|
||||||
in
|
|
||||||
List.foldl (\v acc -> Dict.update (f v) (update v) acc) Dict.empty xs
|
|
||||||
|
|
||||||
uniqueByTailRecursive : (a -> comparable) -> List a -> Set comparable -> List a -> List a
|
|
||||||
uniqueByTailRecursive f l s acc =
|
|
||||||
case l of
|
|
||||||
x::tail ->
|
|
||||||
if Set.member (f x) s
|
|
||||||
then uniqueByTailRecursive f tail s acc
|
|
||||||
else uniqueByTailRecursive f tail (Set.insert (f x) s) (x::acc)
|
|
||||||
[] -> List.reverse acc
|
|
||||||
|
|
||||||
uniqueBy : (a -> comparable) -> List a -> List a
|
|
||||||
uniqueBy f l = uniqueByTailRecursive f l Set.empty []
|
|
||||||
|
|
||||||
findFirst : (a -> Bool) -> List a -> Maybe a
|
|
||||||
findFirst cond l = case l of
|
|
||||||
x::xs -> if cond x then Just x else findFirst cond xs
|
|
||||||
[] -> Nothing
|
|
||||||
|
|
||||||
findLast : (a -> Bool) -> List a -> Maybe a
|
|
||||||
findLast cond l = findFirst cond <| List.reverse l
|
|
||||||
|
|
||||||
findFirstBy : (a -> comparable) -> (a -> Bool) -> List a -> Maybe a
|
|
||||||
findFirstBy sortFunction cond l = findFirst cond <| List.sortBy sortFunction l
|
|
||||||
|
|
||||||
findLastBy : (a -> comparable) -> (a -> Bool) -> List a -> Maybe a
|
|
||||||
findLastBy sortFunction cond l = findLast cond <| List.sortBy sortFunction l
|
|
||||||
|
|
||||||
findFirstEvent : ({ a | originServerTs : Int } -> Bool) -> List { a | originServerTs : Int } -> Maybe { a | originServerTs : Int }
|
findFirstEvent : ({ a | originServerTs : Int } -> Bool) -> List { a | originServerTs : Int } -> Maybe { a | originServerTs : Int }
|
||||||
findFirstEvent = findFirstBy .originServerTs
|
findFirstEvent = findFirstBy .originServerTs
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
module Scylla.Sync.AccountData exposing (..)
|
module Scylla.Sync.AccountData exposing (..)
|
||||||
|
import Scylla.ListUtils exposing (..)
|
||||||
import Scylla.Sync.DecodeTools exposing (maybeDecode)
|
import Scylla.Sync.DecodeTools exposing (maybeDecode)
|
||||||
import Scylla.Sync.Events exposing (Event, eventDecoder)
|
import Scylla.Sync.Events exposing (Event, eventDecoder)
|
||||||
import Json.Decode as Decode exposing (Decoder, list)
|
import Json.Decode as Decode exposing (Decoder, list, decodeValue)
|
||||||
|
import Dict exposing (Dict)
|
||||||
|
|
||||||
type alias AccountData =
|
type alias AccountData =
|
||||||
{ events : Maybe (List Event)
|
{ events : Maybe (List Event)
|
||||||
|
@ -12,3 +14,27 @@ accountDataDecoder =
|
||||||
Decode.succeed AccountData
|
Decode.succeed AccountData
|
||||||
|> maybeDecode "events" (list eventDecoder)
|
|> maybeDecode "events" (list eventDecoder)
|
||||||
|
|
||||||
|
type alias DirectMessages = Dict String String
|
||||||
|
|
||||||
|
directMessagesDecoder : Decode.Decoder DirectMessages
|
||||||
|
directMessagesDecoder =
|
||||||
|
Decode.dict (Decode.list Decode.string)
|
||||||
|
|> Decode.map (invertDirectMessages)
|
||||||
|
|
||||||
|
type alias DirectMessagesRaw = Dict String (List String)
|
||||||
|
|
||||||
|
invertDirectMessages : DirectMessagesRaw -> DirectMessages
|
||||||
|
invertDirectMessages dmr =
|
||||||
|
Dict.foldl
|
||||||
|
(\k lv acc -> List.foldl (\v -> Dict.insert v k) acc lv)
|
||||||
|
Dict.empty
|
||||||
|
dmr
|
||||||
|
|
||||||
|
getAccountData : String -> Decode.Decoder a -> AccountData -> Maybe a
|
||||||
|
getAccountData key d ad = ad.events
|
||||||
|
|> Maybe.andThen (findFirst ((==) key << .type_))
|
||||||
|
|> Maybe.map .content
|
||||||
|
|> Maybe.andThen (Result.toMaybe << decodeValue d)
|
||||||
|
|
||||||
|
getDirectMessages : AccountData -> Maybe DirectMessages
|
||||||
|
getDirectMessages = getAccountData "m.direct" directMessagesDecoder
|
||||||
|
|
|
@ -3,6 +3,7 @@ import Scylla.Model exposing (..)
|
||||||
import Scylla.Sync exposing (..)
|
import Scylla.Sync exposing (..)
|
||||||
import Scylla.Sync.Events exposing (..)
|
import Scylla.Sync.Events exposing (..)
|
||||||
import Scylla.Sync.Rooms exposing (..)
|
import Scylla.Sync.Rooms exposing (..)
|
||||||
|
import Scylla.Room exposing (RoomData, emptyOpenRooms, getRoomName)
|
||||||
import Scylla.Route exposing (..)
|
import Scylla.Route exposing (..)
|
||||||
import Scylla.Fnv as Fnv
|
import Scylla.Fnv as Fnv
|
||||||
import Scylla.Messages exposing (..)
|
import Scylla.Messages exposing (..)
|
||||||
|
@ -10,6 +11,7 @@ import Scylla.Login exposing (Username)
|
||||||
import Scylla.UserData exposing (UserData)
|
import Scylla.UserData exposing (UserData)
|
||||||
import Scylla.Http exposing (fullMediaUrl)
|
import Scylla.Http exposing (fullMediaUrl)
|
||||||
import Scylla.Api exposing (ApiUrl)
|
import Scylla.Api exposing (ApiUrl)
|
||||||
|
import Scylla.ListUtils exposing (groupBy)
|
||||||
import Html.Parser
|
import Html.Parser
|
||||||
import Html.Parser.Util
|
import Html.Parser.Util
|
||||||
import Svg
|
import Svg
|
||||||
|
@ -85,11 +87,8 @@ reconnectView m = if m.connected
|
||||||
roomListView : Model -> Html Msg
|
roomListView : Model -> Html Msg
|
||||||
roomListView m =
|
roomListView m =
|
||||||
let
|
let
|
||||||
rooms = Maybe.withDefault (Dict.empty)
|
|
||||||
<| Maybe.andThen .join
|
|
||||||
<| m.sync.rooms
|
|
||||||
groups = roomGroups
|
groups = roomGroups
|
||||||
<| Dict.toList rooms
|
<| Dict.toList m.rooms
|
||||||
homeserverList = div [ class "homeservers-list" ]
|
homeserverList = div [ class "homeservers-list" ]
|
||||||
<| List.map (\(k, v) -> homeserverView m k v)
|
<| List.map (\(k, v) -> homeserverView m k v)
|
||||||
<| Dict.toList groups
|
<| Dict.toList groups
|
||||||
|
@ -106,22 +105,22 @@ roomListView m =
|
||||||
, homeserverList
|
, homeserverList
|
||||||
]
|
]
|
||||||
|
|
||||||
roomGroups : List (String, JoinedRoom) -> Dict String (List (String, JoinedRoom))
|
roomGroups : List (String, RoomData) -> Dict String (List (String, RoomData))
|
||||||
roomGroups jrs = groupBy (homeserver << Tuple.first) jrs
|
roomGroups jrs = groupBy (homeserver << Tuple.first) jrs
|
||||||
|
|
||||||
homeserverView : Model -> String -> List (String, JoinedRoom) -> Html Msg
|
homeserverView : Model -> String -> List (String, RoomData) -> Html Msg
|
||||||
homeserverView m hs rs =
|
homeserverView m hs rs =
|
||||||
let
|
let
|
||||||
roomList = div [ class "rooms-list" ]
|
roomList = div [ class "rooms-list" ]
|
||||||
<| List.map (\(rid, r) -> roomListElementView m rid r)
|
<| List.map (\(rid, r) -> roomListElementView m rid r)
|
||||||
<| List.sortBy (\(rid, r) -> roomDisplayName m.roomNames rid) rs
|
<| List.sortBy (\(rid, r) -> getRoomName m.sync.accountData m.userData rid r) rs
|
||||||
in
|
in
|
||||||
div [ class "homeserver-wrapper" ] [ h3 [] [ text hs ], roomList ]
|
div [ class "homeserver-wrapper" ] [ h3 [] [ text hs ], roomList ]
|
||||||
|
|
||||||
roomListElementView : Model -> RoomId -> JoinedRoom -> Html Msg
|
roomListElementView : Model -> RoomId -> RoomData -> Html Msg
|
||||||
roomListElementView m rid jr =
|
roomListElementView m rid rd =
|
||||||
let
|
let
|
||||||
name = roomDisplayName m.roomNames rid
|
name = getRoomName m.sync.accountData m.userData rid rd
|
||||||
isVisible = m.searchText == "" || (String.contains (String.toLower m.searchText) <| String.toLower name)
|
isVisible = m.searchText == "" || (String.contains (String.toLower m.searchText) <| String.toLower name)
|
||||||
isCurrentRoom = case currentRoomId m of
|
isCurrentRoom = case currentRoomId m of
|
||||||
Nothing -> False
|
Nothing -> False
|
||||||
|
@ -133,10 +132,10 @@ roomListElementView m rid jr =
|
||||||
, ("hidden", not isVisible)
|
, ("hidden", not isVisible)
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
<| roomNotificationCountView jr.unreadNotifications ++
|
<| roomNotificationCountView rd.unreadNotifications ++
|
||||||
[ a [ href <| roomUrl rid ] [ text name ] ]
|
[ a [ href <| roomUrl rid ] [ text name ] ]
|
||||||
|
|
||||||
roomNotificationCountView : Maybe UnreadNotificationCounts -> List (Html Msg)
|
roomNotificationCountView : UnreadNotificationCounts -> List (Html Msg)
|
||||||
roomNotificationCountView ns =
|
roomNotificationCountView ns =
|
||||||
let
|
let
|
||||||
wrap b = span
|
wrap b = span
|
||||||
|
@ -145,7 +144,7 @@ roomNotificationCountView ns =
|
||||||
, ("bright", b)
|
, ("bright", b)
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
getCount f = Maybe.withDefault 0 << Maybe.andThen f
|
getCount f = Maybe.withDefault 0 << f
|
||||||
in
|
in
|
||||||
case (getCount .notificationCount ns, getCount .highlightCount ns) of
|
case (getCount .notificationCount ns, getCount .highlightCount ns) of
|
||||||
(0, 0) -> []
|
(0, 0) -> []
|
||||||
|
|
Loading…
Reference in New Issue
Block a user