From 583ee2def2a99c706969dc0d32735b155df335b7 Mon Sep 17 00:00:00 2001 From: Vincent Jousse Date: Tue, 25 Apr 2017 16:27:15 +0200 Subject: [PATCH] Connect to the websocket API (#35) --- elm-package.json | 3 +- package.json | 3 +- src/Main.elm | 4 +- src/Mastodon.elm | 170 +++++++++++++++++++++++++++++ src/Model.elm | 60 +++++++++++ src/Util.elm | 6 ++ src/ViewHelper.elm | 13 +-- tests/.gitignore | 1 + tests/Fixtures.elm | 208 ++++++++++++++++++++++++++++++++++++ tests/Main.elm | 13 +++ tests/NotificationTests.elm | 87 +++++++++++++++ tests/elm-package.json | 32 ++++++ 12 files changed, 587 insertions(+), 13 deletions(-) create mode 100644 src/Util.elm create mode 100644 tests/.gitignore create mode 100644 tests/Fixtures.elm create mode 100644 tests/Main.elm create mode 100644 tests/NotificationTests.elm create mode 100644 tests/elm-package.json diff --git a/elm-package.json b/elm-package.json index 2952b79..b8f478b 100644 --- a/elm-package.json +++ b/elm-package.json @@ -15,8 +15,9 @@ "elm-lang/html": "2.0.0 <= v < 3.0.0", "elm-lang/http": "1.0.0 <= v < 2.0.0", "elm-lang/navigation": "2.1.0 <= v < 3.0.0", + "elm-lang/websocket": "1.0.2 <= v < 2.0.0", "evancz/url-parser": "2.0.1 <= v < 3.0.0", - "jinjor/elm-html-parser": "1.1.4 <= v < 2.0.0", + "jinjor/elm-html-parser": "1.1.5 <= v < 2.0.0", "lukewestby/elm-http-builder": "5.1.0 <= v < 6.0.0", "rluiten/elm-date-extra": "8.5.0 <= v < 9.0.0" }, diff --git a/package.json b/package.json index 4a6d120..c0bec35 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "debug": "node_modules/.bin/elm-live src/Main.elm --dir=public/ --output=public/app.js --debug", "deploy": "npm run build && node_modules/.bin/gh-pages --dist build/", "start": "node_modules/.bin/elm-live src/Main.elm --dir=public/ --output=public/app.js", - "test": "node_modules/.bin/elm-make src/Main.elm --warn --output app.js" + "test": "node_modules/.bin/elm-test" }, "repository": { "type": "git", @@ -26,6 +26,7 @@ "devDependencies": { "elm": "^0.18.0", "elm-live": "^2.7.4", + "elm-test": "^0.18.2", "gh-pages": "^0.12.0" } } diff --git a/src/Main.elm b/src/Main.elm index 07c3c4c..1966601 100644 --- a/src/Main.elm +++ b/src/Main.elm @@ -2,7 +2,7 @@ module Main exposing (..) import Navigation import View exposing (view) -import Model exposing (Flags, Model, Msg(..), init, update) +import Model exposing (Flags, Model, Msg(..), init, update, subscriptions) main : Program Flags Model Msg @@ -11,5 +11,5 @@ main = { init = init , view = view , update = update - , subscriptions = always Sub.none + , subscriptions = subscriptions } diff --git a/src/Mastodon.elm b/src/Mastodon.elm index 6dbc98b..cd8fc16 100644 --- a/src/Mastodon.elm +++ b/src/Mastodon.elm @@ -12,7 +12,9 @@ module Mastodon , Reblog(..) , Status , StatusRequestBody + , StreamType(..) , Tag + , WebSocketEventResult(..) , reblog , unreblog , favourite @@ -22,6 +24,7 @@ module Mastodon , registrationEncoder , aggregateNotifications , clientEncoder + , decodeWebSocketMessage , getAuthorizationUrl , getAccessToken , fetchAccount @@ -31,6 +34,11 @@ module Mastodon , fetchUserTimeline , postStatus , send + , subscribeToWebSockets + , websocketEventDecoder + , notificationDecoder + , addNotificationToAggregates + , notificationToAggregate ) import Http @@ -38,6 +46,8 @@ import HttpBuilder import Json.Decode.Pipeline as Pipe import Json.Decode as Decode import Json.Encode as Encode +import Util +import WebSocket import List.Extra exposing (groupWhile) @@ -82,6 +92,12 @@ type alias Client = } +type alias WebSocketEvent = + { event : String + , payload : String + } + + type Error = MastodonError StatusCode StatusMsg String | ServerError StatusCode StatusMsg String @@ -211,6 +227,18 @@ type alias Request a = HttpBuilder.RequestBuilder a +type WebSocketEventResult a b c + = EventError a + | NotificationResult b + | StatusResult c + + +type StreamType + = UserStream + | LocalPublicStream + | GlobalPublicStream + + -- Msg @@ -367,6 +395,13 @@ statusDecoder = |> Pipe.required "visibility" Decode.string +websocketEventDecoder : Decode.Decoder WebSocketEvent +websocketEventDecoder = + Pipe.decode WebSocketEvent + |> Pipe.required "event" Decode.string + |> Pipe.required "payload" Decode.string + + -- Internal helpers @@ -448,6 +483,77 @@ fetch client endpoint decoder = -- Public API +notificationToAggregate : Notification -> NotificationAggregate +notificationToAggregate notification = + NotificationAggregate + notification.type_ + notification.status + [ notification.account ] + notification.created_at + + +addNotificationToAggregates : Notification -> List NotificationAggregate -> List NotificationAggregate +addNotificationToAggregates notification aggregates = + let + addNewAccountToSameStatus : NotificationAggregate -> Notification -> NotificationAggregate + addNewAccountToSameStatus aggregate notification = + case ( aggregate.status, notification.status ) of + ( Just aggregateStatus, Just notificationStatus ) -> + if aggregateStatus.id == notificationStatus.id then + { aggregate | accounts = notification.account :: aggregate.accounts } + else + aggregate + + ( _, _ ) -> + aggregate + + {- + Let's try to find an already existing aggregate, matching the notification + we are trying to add. + If we find any aggregate, we modify it inplace. If not, we return the + aggregates unmodified + -} + newAggregates = + aggregates + |> List.map + (\aggregate -> + case ( aggregate.type_, notification.type_ ) of + {- + Notification and aggregate are of the follow type. + Add the new following account. + -} + ( "follow", "follow" ) -> + { aggregate | accounts = notification.account :: aggregate.accounts } + + {- + Notification is of type follow, but current aggregate + is of another type. Let's continue then. + -} + ( _, "follow" ) -> + aggregate + + {- + If both types are the same check if we should + add the new account. + -} + ( aggregateType, notificationType ) -> + if aggregateType == notificationType then + addNewAccountToSameStatus aggregate notification + else + aggregate + ) + in + {- + If we did no modification to the old aggregates it's + because we didn't found any match. So me have to create + a new aggregate + -} + if newAggregates == aggregates then + notificationToAggregate (notification) :: aggregates + else + newAggregates + + aggregateNotifications : List Notification -> List NotificationAggregate aggregateNotifications notifications = let @@ -600,3 +706,67 @@ unfavourite client id = HttpBuilder.post (client.server ++ "/api/v1/statuses/" ++ (toString id) ++ "/unfavourite") |> HttpBuilder.withHeader "Authorization" ("Bearer " ++ client.token) |> HttpBuilder.withExpect (Http.expectJson statusDecoder) + + +subscribeToWebSockets : Client -> StreamType -> (String -> a) -> Sub a +subscribeToWebSockets client streamType message = + let + type_ = + case streamType of + UserStream -> + "user" + + LocalPublicStream -> + "public:local" + + GlobalPublicStream -> + "public:local" + + url = + (Util.replace "https" "wss" client.server) + ++ "/api/v1/streaming/?access_token=" + ++ client.token + ++ "&stream=" + ++ type_ + in + WebSocket.listen url message + + + +{- + Sorry for this beast, but the websocket connection return messages + containing an escaped JSON string under the `payload` key. This JSON string + can either represent a `Notification` when the event field of the returned json + is equal to 'notification' or a `Status` when the string is equal to + 'update'. + If someone has a better way of doing this, I'me all for it +-} + + +decodeWebSocketMessage : String -> WebSocketEventResult String (Result String Notification) (Result String Status) +decodeWebSocketMessage message = + let + websocketEvent = + Decode.decodeString + websocketEventDecoder + message + in + case websocketEvent of + Ok event -> + if event.event == "notification" then + NotificationResult + (Decode.decodeString + notificationDecoder + event.payload + ) + else if event.event == "update" then + StatusResult + (Decode.decodeString + statusDecoder + event.payload + ) + else + EventError "Unknown event type for WebSocket" + + Err error -> + EventError error diff --git a/src/Model.elm b/src/Model.elm index 6317d71..514a745 100644 --- a/src/Model.elm +++ b/src/Model.elm @@ -61,6 +61,9 @@ type | Unreblog Int | Unreblogged (Result Mastodon.Error Mastodon.Status) | UserTimeline (Result Mastodon.Error (List Mastodon.Status)) + | NewWebsocketUserMessage String + | NewWebsocketGlobalMessage String + | NewWebsocketLocalMessage String | ViewerEvent ViewerMsg @@ -535,3 +538,60 @@ update msg model = Err error -> { model | notifications = [], errors = (errorText error) :: model.errors } ! [] + + NewWebsocketUserMessage message -> + case (Mastodon.decodeWebSocketMessage message) of + Mastodon.EventError error -> + { model | errors = error :: model.errors } ! [] + + Mastodon.NotificationResult result -> + case result of + Ok notification -> + { model | notifications = Mastodon.addNotificationToAggregates notification model.notifications } ! [] + + Err error -> + { model | errors = error :: model.errors } ! [] + + Mastodon.StatusResult result -> + case result of + Ok status -> + { model | userTimeline = status :: model.userTimeline } ! [] + + Err error -> + { model | errors = error :: model.errors } ! [] + + NewWebsocketLocalMessage message -> + -- @TODO + model ! [] + + NewWebsocketGlobalMessage message -> + -- @TODO + model ! [] + + +subscriptions : Model -> Sub Msg +subscriptions model = + Sub.batch <| + case model.client of + Just client -> + [ Mastodon.subscribeToWebSockets + client + Mastodon.UserStream + NewWebsocketUserMessage + ] + ++ (if model.useGlobalTimeline then + [ Mastodon.subscribeToWebSockets + client + Mastodon.GlobalPublicStream + NewWebsocketGlobalMessage + ] + else + [ Mastodon.subscribeToWebSockets + client + Mastodon.LocalPublicStream + NewWebsocketLocalMessage + ] + ) + + Nothing -> + [] diff --git a/src/Util.elm b/src/Util.elm new file mode 100644 index 0000000..d1fd0f8 --- /dev/null +++ b/src/Util.elm @@ -0,0 +1,6 @@ +module Util exposing (..) + + +replace : String -> String -> String -> String +replace from to str = + String.split from str |> String.join to diff --git a/src/ViewHelper.elm b/src/ViewHelper.elm index 2219a29..cb3538d 100644 --- a/src/ViewHelper.elm +++ b/src/ViewHelper.elm @@ -3,7 +3,6 @@ module ViewHelper ( formatContent , getMentionForLink , onClickWithPreventAndStop - , replace , toVirtualDom ) @@ -14,6 +13,7 @@ import HtmlParser import Json.Decode as Decode import Mastodon import Model exposing (Msg(OnLoadUserAccount)) +import Util -- Custom Events @@ -34,18 +34,13 @@ onClickWithPreventAndStop msg = formatContent : String -> List Mastodon.Mention -> List (Html Msg) formatContent content mentions = content - |> replace " ?" " ?" - |> replace " !" " !" - |> replace " :" " :" + |> Util.replace " ?" " ?" + |> Util.replace " !" " !" + |> Util.replace " :" " :" |> HtmlParser.parse |> toVirtualDom mentions -replace : String -> String -> String -> String -replace from to str = - String.split from str |> String.join to - - {-| Converts nodes to virtual dom nodes. -} toVirtualDom : List Mastodon.Mention -> List HtmlParser.Node -> List (Html Msg) diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..aee9810 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1 @@ +/elm-stuff/ diff --git a/tests/Fixtures.elm b/tests/Fixtures.elm new file mode 100644 index 0000000..5f2e611 --- /dev/null +++ b/tests/Fixtures.elm @@ -0,0 +1,208 @@ +module Fixtures exposing (..) + +import Mastodon exposing (Account, Notification, NotificationAggregate, Status) + + +accountSkro : Account +accountSkro = + { acct = "SkroZoC" + , avatar = "https://mamot.fr/system/accounts/avatars/000/001/391/original/76be3c9d1b34f59b.jpeg?1493042489" + , created_at = "2017-04-24T20:25:37.398Z" + , display_name = "Skro" + , followers_count = 77 + , following_count = 80 + , header = "https://mamot.fr/system/accounts/headers/000/001/391/original/9fbb4ac980f04fe1.gif?1493042489" + , id = 1391 + , locked = False + , note = "N'importe quoi très vite en 500 caractères. La responsabilité du triumvirat de ZoC ne peut être engagée." + , statuses_count = 161 + , url = "https://mamot.fr/@SkroZoC" + , username = "SkroZoC" + } + + +accountVjousse : Account +accountVjousse = + { acct = "vjousse" + , avatar = "https://mamot.fr/system/accounts/avatars/000/026/303/original/b72c0dd565e5bc1e.png?1492698808" + , created_at = "2017-04-20T14:31:05.751Z" + , display_name = "Vincent Jousse" + , followers_count = 68 + , following_count = 31 + , header = "https://mamot.fr/headers/original/missing.png" + , id = 26303 + , locked = False + , note = "Libriste, optimiste et utopiste. On est bien tintin." + , statuses_count = 88 + , url = "https://mamot.fr/@vjousse" + , username = "vjousse" + } + + +accountNico : Account +accountNico = + { acct = "n1k0" + , avatar = "https://mamot.fr/system/accounts/avatars/000/017/784/original/40052904e484d9c0.jpg?1492158615" + , created_at = "2017-04-14T08:28:59.706Z" + , display_name = "NiKo`" + , followers_count = 162 + , following_count = 79 + , header = "https://mamot.fr/system/accounts/headers/000/017/784/original/ea87200d852018a8.jpg?1492158674" + , id = 17784 + , locked = False + , note = "Transforme sa procrastination en pouets, la plupart du temps en français." + , statuses_count = 358 + , url = "https://mamot.fr/@n1k0" + , username = "n1k0" + } + + +accountPloum : Account +accountPloum = + { acct = "ploum" + , avatar = "https://mamot.fr/system/accounts/avatars/000/006/840/original/593a817d651d9253.jpg?1491814416" + , created_at = "2017-04-08T09:37:34.931Z" + , display_name = "ploum" + , followers_count = 1129 + , following_count = 91 + , header = "https://mamot.fr/system/accounts/headers/000/006/840/original/7e0adc1f754dafbe.jpg?1491814416" + , id = 6840 + , locked = False + , note = "Futurologue, conférencier, blogueur et écrivain électronique. Du moins, je l'espère. :bicyclist:" + , statuses_count = 601 + , url = "https://mamot.fr/@ploum" + , username = "ploum" + } + + +statusNicoToVjousse : Status +statusNicoToVjousse = + { account = accountNico + , content = "

@vjousse j'ai rien touché à ce niveau là non

" + , created_at = "2017-04-24T20:16:20.922Z" + , favourited = Nothing + , favourites_count = 0 + , id = 737932 + , in_reply_to_account_id = Just 26303 + , in_reply_to_id = Just 737425 + , media_attachments = [] + , mentions = + [ { id = 26303 + , url = "https://mamot.fr/@vjousse" + , username = "vjousse" + , acct = "vjousse" + } + ] + , reblog = Nothing + , reblogged = Nothing + , reblogs_count = 0 + , sensitive = Just False + , spoiler_text = "" + , tags = [] + , uri = "tag:mamot.fr,2017-04-24:objectId=737932:objectType=Status" + , url = "https://mamot.fr/@n1k0/737932" + , visibility = "public" + } + + +statusNicoToVjousseAgain : Status +statusNicoToVjousseAgain = + { account = accountNico + , content = "

@vjousse oui j'ai vu, c'est super, après on est à +473 −13, à un moment tu vas te prendre la tête 😂

" + , created_at = "2017-04-25T07:41:23.492Z" + , favourited = Nothing + , favourites_count = 0 + , id = 752169 + , in_reply_to_account_id = Just 26303 + , in_reply_to_id = Just 752153 + , media_attachments = [] + , mentions = + [ { id = 26303 + , url = "https://mamot.fr/@vjousse" + , username = "vjousse" + , acct = "vjousse" + } + ] + , reblog = Nothing + , reblogged = Nothing + , reblogs_count = 0 + , sensitive = Just False + , spoiler_text = "" + , tags = [] + , uri = "tag:mamot.fr,2017-04-25:objectId=752169:objectType=Status" + , url = "https://mamot.fr/@n1k0/752169" + , visibility = "public" + } + + +notificationNicoMentionVjousse : Notification +notificationNicoMentionVjousse = + { id = 224284 + , type_ = "mention" + , created_at = "2017-04-24T20:16:20.973Z" + , account = accountNico + , status = Just statusNicoToVjousse + } + + +notificationNicoMentionVjousseAgain : Notification +notificationNicoMentionVjousseAgain = + { id = 226516 + , type_ = "mention" + , created_at = "2017-04-25T07:41:23.546Z" + , account = accountNico + , status = Just statusNicoToVjousseAgain + } + + +notificationNicoFollowsVjousse : Notification +notificationNicoFollowsVjousse = + { id = 224257 + , type_ = "follow" + , created_at = "2017-04-24T20:13:47.431Z" + , account = accountNico + , status = Nothing + } + + +notificationSkroFollowsVjousse : Notification +notificationSkroFollowsVjousse = + { id = 224 + , type_ = "follow" + , created_at = "2017-04-24T19:12:47.431Z" + , account = accountSkro + , status = Nothing + } + + +notificationPloumFollowsVjousse : Notification +notificationPloumFollowsVjousse = + { id = 220 + , type_ = "follow" + , created_at = "2017-04-24T18:12:47.431Z" + , account = accountPloum + , status = Nothing + } + + +accounts : List Account +accounts = + [ accountSkro, accountVjousse, accountNico ] + + +notifications : List Notification +notifications = + [ notificationNicoMentionVjousse + , notificationNicoFollowsVjousse + , notificationSkroFollowsVjousse + ] + + +notificationAggregates : List NotificationAggregate +notificationAggregates = + [ { type_ = "mention" + , status = Nothing + , accounts = [] + , created_at = "" + } + ] diff --git a/tests/Main.elm b/tests/Main.elm new file mode 100644 index 0000000..ddb9070 --- /dev/null +++ b/tests/Main.elm @@ -0,0 +1,13 @@ +port module Main exposing (..) + +import NotificationTests +import Test.Runner.Node exposing (run, TestProgram) +import Json.Encode exposing (Value) + + +main : TestProgram +main = + run emit NotificationTests.all + + +port emit : ( String, Value ) -> Cmd msg diff --git a/tests/NotificationTests.elm b/tests/NotificationTests.elm new file mode 100644 index 0000000..aed7c55 --- /dev/null +++ b/tests/NotificationTests.elm @@ -0,0 +1,87 @@ +module NotificationTests exposing (..) + +import Test exposing (..) +import Expect +import String +import Mastodon +import Fixtures + + +all : Test +all = + describe "Notification test suite" + [ describe "Aggegate test" + [ test "Aggregate Notifications" <| + \() -> + Fixtures.notifications + |> Mastodon.aggregateNotifications + |> Expect.equal + [ { type_ = "mention" + , status = Just Fixtures.statusNicoToVjousse + , accounts = [ Fixtures.accountNico ] + , created_at = "2017-04-24T20:16:20.973Z" + } + , { type_ = "follow" + , status = Nothing + , accounts = [ Fixtures.accountNico, Fixtures.accountSkro ] + , created_at = "2017-04-24T20:13:47.431Z" + } + ] + , test "Add follows notification to aggregate" <| + \() -> + Fixtures.notifications + |> Mastodon.aggregateNotifications + |> (Mastodon.addNotificationToAggregates Fixtures.notificationPloumFollowsVjousse) + |> Expect.equal + [ { type_ = "mention" + , status = Just Fixtures.statusNicoToVjousse + , accounts = [ Fixtures.accountNico ] + , created_at = "2017-04-24T20:16:20.973Z" + } + , { type_ = "follow" + , status = Nothing + , accounts = [ Fixtures.accountPloum, Fixtures.accountNico, Fixtures.accountSkro ] + , created_at = "2017-04-24T20:13:47.431Z" + } + ] + , test "Add mention notification to aggregate" <| + \() -> + Fixtures.notifications + |> Mastodon.aggregateNotifications + |> (Mastodon.addNotificationToAggregates Fixtures.notificationNicoMentionVjousse) + |> Expect.equal + [ { type_ = "mention" + , status = Just Fixtures.statusNicoToVjousse + , accounts = [ Fixtures.accountNico, Fixtures.accountNico ] + , created_at = "2017-04-24T20:16:20.973Z" + } + , { type_ = "follow" + , status = Nothing + , accounts = [ Fixtures.accountNico, Fixtures.accountSkro ] + , created_at = "2017-04-24T20:13:47.431Z" + } + ] + , test "Add new mention notification to aggregate" <| + \() -> + Fixtures.notifications + |> Mastodon.aggregateNotifications + |> (Mastodon.addNotificationToAggregates Fixtures.notificationNicoMentionVjousseAgain) + |> Expect.equal + [ { type_ = "mention" + , status = Just Fixtures.statusNicoToVjousseAgain + , accounts = [ Fixtures.accountNico ] + , created_at = "2017-04-25T07:41:23.546Z" + } + , { type_ = "mention" + , status = Just Fixtures.statusNicoToVjousse + , accounts = [ Fixtures.accountNico ] + , created_at = "2017-04-24T20:16:20.973Z" + } + , { type_ = "follow" + , status = Nothing + , accounts = [ Fixtures.accountNico, Fixtures.accountSkro ] + , created_at = "2017-04-24T20:13:47.431Z" + } + ] + ] + ] diff --git a/tests/elm-package.json b/tests/elm-package.json new file mode 100644 index 0000000..274d653 --- /dev/null +++ b/tests/elm-package.json @@ -0,0 +1,32 @@ +{ + "version": "1.0.0", + "summary": "Sample Elm Test", + "repository": "https://github.com/user/project.git", + "license": "BSD-3-Clause", + "source-directories": [ + ".", + "../src" + ], + "exposed-modules": [], + "dependencies": { + "elm-community/json-extra": "2.0.0 <= v < 3.0.0", + "elm-lang/html": "2.0.0 <= v < 3.0.0", + "mgold/elm-random-pcg": "4.0.2 <= v < 5.0.0", + "elm-lang/core": "5.0.0 <= v < 6.0.0", + "elm-community/elm-test": "3.0.0 <= v < 4.0.0", + "rtfeldman/node-test-runner": "3.0.0 <= v < 4.0.0", + + "NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0", + "elm-community/list-extra": "6.0.0 <= v < 7.0.0", + "elm-lang/core": "5.1.1 <= v < 6.0.0", + "elm-lang/dom": "1.1.1 <= v < 2.0.0", + "elm-lang/html": "2.0.0 <= v < 3.0.0", + "elm-lang/http": "1.0.0 <= v < 2.0.0", + "elm-lang/navigation": "2.1.0 <= v < 3.0.0", + "elm-lang/websocket": "1.0.2 <= v < 2.0.0", + "evancz/url-parser": "2.0.1 <= v < 3.0.0", + "jinjor/elm-html-parser": "1.1.5 <= v < 2.0.0", + "lukewestby/elm-http-builder": "5.1.0 <= v < 6.0.0" + }, + "elm-version": "0.18.0 <= v < 0.19.0" +}