Connect to the websocket API (#35)

This commit is contained in:
Vincent Jousse 2017-04-25 16:27:15 +02:00 committed by Nicolas Perriault
parent ff81482572
commit 583ee2def2
12 changed files with 587 additions and 13 deletions

View File

@ -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"
},

View File

@ -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"
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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 ->
[]

6
src/Util.elm Normal file
View File

@ -0,0 +1,6 @@
module Util exposing (..)
replace : String -> String -> String -> String
replace from to str =
String.split from str |> String.join to

View File

@ -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 " ?" "&nbsp;?"
|> replace " !" "&nbsp;!"
|> replace " :" "&nbsp;:"
|> Util.replace " ?" "&nbsp;?"
|> Util.replace " !" "&nbsp;!"
|> Util.replace " :" "&nbsp;:"
|> 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)

1
tests/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/elm-stuff/

208
tests/Fixtures.elm Normal file
View File

@ -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&apos;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&apos;espère. :bicyclist:"
, statuses_count = 601
, url = "https://mamot.fr/@ploum"
, username = "ploum"
}
statusNicoToVjousse : Status
statusNicoToVjousse =
{ account = accountNico
, content = "<p><span class=\"h-card\"><a href=\"https://mamot.fr/@vjousse\" class=\"u-url mention\">@<span>vjousse</span></a></span> j&apos;ai rien touché à ce niveau là non</p>"
, 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 = "<p><span class=\"h-card\"><a href=\"https://mamot.fr/@vjousse\" class=\"u-url mention\">@<span>vjousse</span></a></span> oui j&apos;ai vu, c&apos;est super, après on est à +473 13, à un moment tu vas te prendre la tête 😂</p>"
, 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 = ""
}
]

13
tests/Main.elm Normal file
View File

@ -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

View File

@ -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"
}
]
]
]

32
tests/elm-package.json Normal file
View File

@ -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"
}