diff --git a/public/index.html b/public/index.html index e69e126..4339cf0 100644 --- a/public/index.html +++ b/public/index.html @@ -26,21 +26,30 @@ var app = Elm.App.init({flags: "https://pbx-provisioning.sailmaker.fenix.lgbt/dashboard-kitchen.json"}); const simpleUserDelegate = { + onServerConnect: () => { + app.ports.newConnectionState.send("connected"); + }, + onServerDisconnect: (error) => { + app.ports.newConnectionState.send(error ? "failed" : "disconnected"); + }, onCallCreated: () => { - console.log('Call created'); + app.ports.newCallState.send("ringing"); + }, + onCallReceived: () => { + app.ports.newCallState.send("ringing"); }, onCallAnswered: () => { - console.log('Call answered'); - }, - onCallHangup: () => { - console.log('Call hung up'); + app.ports.newCallState.send("on call"); }, onCallHold: (held) => { - console.log('Call hold: ${held}') + app.ports.newCallState.send("on hold"); + }, + onCallHangup: () => { + app.ports.newCallState.send(null); } } var simpleUser; - app.ports.connectPhone.subscribe(function(config) { + app.ports.connect.subscribe(function(config) { console.log('Connect', config); const simpleUserOptions = { delegate: simpleUserDelegate, @@ -63,6 +72,9 @@ ); simpleUser.connect(); }); + app.ports.dial.subscribe(function(address) { + simpleUser.call(address); + }); diff --git a/src/App.elm b/src/App.elm index 4497d1d..e75c5d2 100644 --- a/src/App.elm +++ b/src/App.elm @@ -1,66 +1,19 @@ -port module App exposing (main) +module App exposing (main) import Browser import Element as E +import Element.Background as Background import Html import Html.Attributes import Http import Json.Decode -import Json.Decode.Pipeline as JDP - - -type alias Contact = - { name : String - , number : String - } - - -decodeContact : Json.Decode.Decoder Contact -decodeContact = - Json.Decode.succeed Contact - |> JDP.required "name" Json.Decode.string - |> JDP.required "number" Json.Decode.string - - -type alias Configuration = - { name : String - , websocket : String - , domain : String - , username : String - , password : String - , voicemail : Maybe String - , contacts : List Contact - } - - -decodeConfiguration : Json.Decode.Decoder Configuration -decodeConfiguration = - Json.Decode.succeed Configuration - |> JDP.required "name" Json.Decode.string - |> JDP.required "websocket" Json.Decode.string - |> JDP.required "domain" Json.Decode.string - |> JDP.required "username" Json.Decode.string - |> JDP.required "password" Json.Decode.string - |> JDP.required "voicemail" (Json.Decode.nullable Json.Decode.string) - |> JDP.required "contacts" (Json.Decode.list decodeContact) - - -type alias CallerId = - { name : Maybe String - , number : String - } - - -type ExtensionState - = Disconnected - | Ringing CallerId - | Connected CallerId +import Phone type Extension = Unconfigured | ConfigurationError - | Configured Configuration ExtensionState + | Configured Phone.Model type alias Model = @@ -69,7 +22,9 @@ type alias Model = type Msg - = GotConfiguration (Result Http.Error Configuration) + = GotConfiguration (Result Http.Error Phone.Configuration) + | GotConnectionState Json.Decode.Value + | PhoneMsg Phone.Msg main = @@ -86,7 +41,7 @@ init provisioningUrl = ( { extension = Unconfigured } , Http.get { url = provisioningUrl - , expect = Http.expectJson GotConfiguration decodeConfiguration + , expect = Http.expectJson GotConfiguration Phone.decodeConfiguration } ) @@ -95,8 +50,8 @@ view : Model -> Browser.Document Msg view model = { title = "Dashboard" , body = - [ E.layout [] <| - E.column [] + [ E.layout [ E.height E.fill ] <| + E.column [ E.height E.fill, E.width E.fill ] [ E.html (Html.audio [ Html.Attributes.id "remoteAudio" ] []) , case model.extension of Unconfigured -> @@ -105,8 +60,8 @@ view model = ConfigurationError -> E.text "Configuration failed." - Configured config state -> - E.text "Ready!" + Configured phone -> + E.map PhoneMsg <| Phone.view phone ] ] } @@ -116,15 +71,32 @@ update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of GotConfiguration (Ok config) -> - ( { model | extension = Configured config Disconnected }, connectPhone config ) + ( { model | extension = Configured (Phone.init config) }, Phone.connect config ) GotConfiguration (Err _) -> ( { model | extension = ConfigurationError }, Cmd.none ) + GotConnectionState value -> + ( model, Cmd.none ) + + PhoneMsg phoneMsg -> + case model.extension of + Configured phone -> + let + ( newPhone, phoneCmd ) = + Phone.update phoneMsg phone + in + ( { model | extension = Configured newPhone }, Cmd.map PhoneMsg phoneCmd ) + + _ -> + ( model, Cmd.none ) + subscriptions : Model -> Sub Msg subscriptions model = - Sub.none + case model.extension of + Configured phone -> + Sub.map PhoneMsg <| Phone.subscriptions phone - -port connectPhone : Configuration -> Cmd msg + _ -> + Sub.none diff --git a/src/Phone.elm b/src/Phone.elm new file mode 100644 index 0000000..f743558 --- /dev/null +++ b/src/Phone.elm @@ -0,0 +1,244 @@ +port module Phone exposing + ( Configuration + , ConnectionState + , Contact + , Model + , Msg + , connect + , decodeConfiguration + , decodeConnectionState + , init + , newConnectionState + , subscriptions + , update + , view + ) + +import Element as E +import Element.Background as Background +import Element.Border as Border +import Element.Events as Events +import Element.Font as Font +import Json.Decode +import Json.Decode.Pipeline as JDP + + +type alias Contact = + { name : String + , number : String + } + + +type alias Configuration = + { name : String + , websocket : String + , domain : String + , username : String + , password : String + , voicemail : Maybe String + , contacts : List Contact + } + + +type ConnectionState + = Disconnected + | ConnectionFailed + | Connected + + +type CallDirection + = Incoming + | Outgoing + + +type CallState + = Ringing + | OnCall + | OnHold + + +type alias Model = + { configuration : Configuration + , connection : ConnectionState + , call : Maybe CallState + } + + +type Msg + = NewConnectionState Json.Decode.Value + | NewCallState Json.Decode.Value + | ContactSelected Contact + + +decodeContact : Json.Decode.Decoder Contact +decodeContact = + Json.Decode.succeed Contact + |> JDP.required "name" Json.Decode.string + |> JDP.required "number" Json.Decode.string + + +decodeConfiguration : Json.Decode.Decoder Configuration +decodeConfiguration = + Json.Decode.succeed Configuration + |> JDP.required "name" Json.Decode.string + |> JDP.required "websocket" Json.Decode.string + |> JDP.required "domain" Json.Decode.string + |> JDP.required "username" Json.Decode.string + |> JDP.required "password" Json.Decode.string + |> JDP.required "voicemail" (Json.Decode.nullable Json.Decode.string) + |> JDP.required "contacts" (Json.Decode.list decodeContact) + + +decodeConnectionState : Json.Decode.Decoder ConnectionState +decodeConnectionState = + let + fromString : String -> Json.Decode.Decoder ConnectionState + fromString state = + case state of + "connected" -> + Json.Decode.succeed Connected + + "disconnected" -> + Json.Decode.succeed Disconnected + + "failed" -> + Json.Decode.succeed ConnectionFailed + + _ -> + Json.Decode.fail <| "Unexpected connection state " ++ state + in + Json.Decode.string + |> Json.Decode.andThen fromString + + +decodeCallState : Json.Decode.Decoder CallState +decodeCallState = + let + fromString : String -> Json.Decode.Decoder CallState + fromString state = + case state of + "ringing" -> + Json.Decode.succeed Ringing + + "on call" -> + Json.Decode.succeed OnCall + + "on hold" -> + Json.Decode.succeed OnHold + + _ -> + Json.Decode.fail <| "Unexpected call state " ++ state + in + Json.Decode.string + |> Json.Decode.andThen fromString + + +init : Configuration -> Model +init config = + { configuration = config + , connection = Disconnected + , call = Nothing + } + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + NewConnectionState value -> + case Json.Decode.decodeValue decodeConnectionState value of + Ok connection -> + ( { model | connection = connection }, Cmd.none ) + + Err _ -> + ( model, Cmd.none ) + + NewCallState value -> + case Json.Decode.decodeValue (Json.Decode.nullable decodeCallState) value of + Ok call -> + ( { model | call = call }, Cmd.none ) + + Err _ -> + ( model, Cmd.none ) + + ContactSelected contact -> + case model.connection of + Connected -> + ( model, dial ("sip:" ++ contact.number ++ "@" ++ model.configuration.domain) ) + + _ -> + ( model, Cmd.none ) + + +view : Model -> E.Element Msg +view model = + let + viewContact : Contact -> E.Element Msg + viewContact contact = + E.row + [ E.width E.fill + , E.padding 30 + , E.spacing 30 + , Border.widthEach { bottom = 1, top = 0, left = 0, right = 0 } + , E.pointer + , E.mouseOver [ Background.color <| E.rgb255 200 200 200 ] + , Events.onClick <| ContactSelected contact + ] + [ E.el [] <| E.text contact.number + , E.el [ E.width E.fill ] <| E.text contact.name + ] + in + E.column [ E.width E.fill, E.height E.fill ] + [ E.row [ E.width E.fill, E.padding 10, Border.solid, Border.widthEach { bottom = 3, top = 0, left = 0, right = 0 } ] + [ E.el [ E.width <| E.px 50 ] <| + E.text <| + case model.connection of + Disconnected -> + "◯" + + _ -> + "🟢" + , E.el [ E.centerX ] <| E.text model.configuration.name + ] + , case model.call of + Just call -> + E.el + [ E.centerX + , E.centerY + , Font.size 120 + , Font.color <| + case call of + Ringing -> + E.rgb255 255 255 0 + + OnCall -> + E.rgb255 0 255 0 + + OnHold -> + E.rgb255 0 0 255 + ] + <| + E.text "☎" + + Nothing -> + E.column [ E.width E.fill ] <| List.map viewContact model.configuration.contacts + ] + + +subscriptions : Model -> Sub Msg +subscriptions model = + Sub.batch + [ newConnectionState NewConnectionState + , newCallState NewCallState + ] + + +port connect : Configuration -> Cmd msg + + +port dial : String -> Cmd msg + + +port newConnectionState : (Json.Decode.Value -> msg) -> Sub msg + + +port newCallState : (Json.Decode.Value -> msg) -> Sub msg