├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── elm-package.json ├── package.json ├── src ├── Stylesheets.elm ├── Twitch.elm ├── Twitch │ ├── Chat.elm │ ├── Chat │ │ ├── Badges.elm │ │ ├── Channel.elm │ │ ├── Chatters.elm │ │ ├── Css.elm │ │ ├── Header.elm │ │ ├── MessageLine.elm │ │ ├── MessageLine │ │ │ └── View.elm │ │ ├── Parser.elm │ │ ├── Properties.elm │ │ ├── SendText.elm │ │ └── Types.elm │ └── Request.elm ├── index.html └── index.js └── webpack.config.js /.env.example: -------------------------------------------------------------------------------- 1 | TWITCH_USERNAME=justinfan12345 2 | TWITCH_OAUTH_TOKEN=oauth:xxxx 3 | TWITCH_CHANNEL=lirik 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | elm-stuff/ 2 | node_modules/ 3 | dist/ 4 | .env 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Paramander 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twitch Chat in Elm 2 | 3 | This project was done in order to test whether Elm can be used to replace 4 | certain well established pieces of Javascript. One of these is the Twitch 5 | chat. Recently, Twitch has upgraded their chat servers to also support 6 | WebSockets. 7 | 8 | 9 | ## Features 10 | 11 | - [x] Receiving messages 12 | - [x] Sending messages 13 | - [x] Emotes 14 | - [x] Subscriber badges 15 | - [x] Turbo badges 16 | - [x] Mod badges 17 | - [x] Global mod badges 18 | - [x] Admin badges 19 | - [x] Staff badges 20 | - [x] Broadcaster badges 21 | - [x] Bits badges (wrong implementation, will have to use the new [badges.twitch.tv](https://badges.twitch.tv) endpoint) 22 | - [ ] Cheers 23 | - [x] Resubscribe notices 24 | - [x] Subscribe notices 25 | - [ ] Timeout/ban actions 26 | - [x] `/me` actions 27 | - [ ] Emote picker 28 | - [ ] Viewer list 29 | - [x] `@` tagging 30 | 31 | ## JSONP 32 | 33 | The recommended way of consuming the Twitch API is JSONP. `elm-lang/http` has no way 34 | of using JSONP. That's why this project includes some Native Elm code to 35 | solve this. 36 | 37 | ## Try it out 38 | 39 | The whole setup is automated using `npm`. Make sure you have `node` > 4 and `npm` installed. 40 | 41 | ``` 42 | $ git clone https://gitlab.com/paramanders/elm-twitch-chat.git 43 | $ cd elm-twitch-chat 44 | $ npm install 45 | ``` 46 | 47 | If you haven't installed Elm globally yet, make sure to do so 48 | 49 | ``` 50 | $ npm install -g elm 51 | ``` 52 | 53 | And install this project's Elm dependencies using `elm-github-install`. This 54 | is needed, because `elm-jsonp` is downloaded that exposes Native modules, which 55 | can't be downloaded from the package server. 56 | 57 | ``` 58 | $ ./node_modules/.bin/elm-github-install 59 | ``` 60 | 61 | 62 | ## Serve locally 63 | 64 | You can serve the application locally using Webpack. But first, you are required 65 | to enter your username, oauth token and the channel you want to connect to into 66 | your `.env` file. Just copy the example, and fill in your credentials: 67 | 68 | ``` 69 | cp .env.example .env 70 | ``` 71 | 72 | To get an oauth token, you can use [https://twitchapps.com/tmi](https://twitchapps.com/tmi). 73 | 74 | After inserting your username, oauth token and channel, run the command to serve 75 | the application on [localhost:8000](http://localhost:8000): 76 | 77 | ``` 78 | $ npm start 79 | ``` 80 | 81 | and it should look like this: 82 | 83 |  84 | -------------------------------------------------------------------------------- /elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "summary": "Partial Twitch chat implementation using WebSockets", 4 | "repository": "https://github.com/paramanders/elm-twitch-chat.git", 5 | "license": "MIT", 6 | "source-directories": [ 7 | "src" 8 | ], 9 | "exposed-modules": [ 10 | "Twitch.Chat" 11 | ], 12 | "dependencies": { 13 | "Bogdanp/elm-combine": "3.1.1 <= v < 4.0.0", 14 | "elm-community/json-extra": "2.0.0 <= v < 3.0.0", 15 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 16 | "elm-lang/dom": "1.1.1 <= v < 2.0.0", 17 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 18 | "elm-lang/http": "1.0.0 <= v < 2.0.0", 19 | "elm-lang/svg": "2.0.0 <= v < 3.0.0", 20 | "elm-lang/websocket": "1.0.2 <= v < 2.0.0", 21 | "paramanders/elm-jsonp": "2.0.0 <= v < 3.0.0", 22 | "rtfeldman/elm-css": "7.0.0 <= v < 8.0.0", 23 | "rtfeldman/elm-css-helpers": "2.0.1 <= v < 3.0.0", 24 | "thebritican/elm-autocomplete": "4.0.3 <= v < 5.0.0" 25 | }, 26 | "elm-version": "0.18.0 <= v < 0.19.0" 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elm-twitch-chat-example", 3 | "version": "1.0.0", 4 | "description": "An Elm implementation of Twitch WebSocket chat", 5 | "author": "Tolga Paksoy", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "webpack-dev-server --port 8000 --hot --content-base src/", 9 | "build": "rimraf dist && webpack", 10 | "watch": "webpack --watch" 11 | }, 12 | "devDependencies": { 13 | "autoprefixer": "6.3.7", 14 | "copy-webpack-plugin": "3.0.1", 15 | "css-loader": "0.23.1", 16 | "dotenv-webpack": "^1.4.3", 17 | "elm-css": "0.6.0", 18 | "elm-css-webpack-loader": "2.0.2", 19 | "elm-github-install": "^0.1.0", 20 | "elm-hot-loader": "^0.5.4", 21 | "elm-webpack-loader": "^4.1.1", 22 | "extract-text-webpack-plugin": "^1.0.1", 23 | "file-loader": "0.9.0", 24 | "html-webpack-plugin": "2.22.0", 25 | "postcss-loader": "0.10.0", 26 | "rimraf": "2.5.3", 27 | "style-loader": "0.13.1", 28 | "unminified-webpack-plugin": "1.1.0", 29 | "webpack": "^1.14.0", 30 | "webpack-dev-server": "^1.14.0", 31 | "webpack-merge": "^2.4.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Stylesheets.elm: -------------------------------------------------------------------------------- 1 | port module Stylesheets exposing (..) 2 | 3 | import Css.File exposing (..) 4 | import Twitch.Chat.Css 5 | import Platform exposing (program) 6 | 7 | 8 | port files : CssFileStructure -> Cmd msg 9 | 10 | 11 | cssFiles : CssFileStructure 12 | cssFiles = 13 | toFileStructure [ ( "styles.css", compile [ Twitch.Chat.Css.css ] ) ] 14 | 15 | 16 | main : Program Never () msg 17 | main = 18 | program 19 | { init = ( (), files cssFiles ) 20 | , update = \_ _ -> ( (), Cmd.none ) 21 | , subscriptions = \_ -> Sub.none 22 | } 23 | -------------------------------------------------------------------------------- /src/Twitch.elm: -------------------------------------------------------------------------------- 1 | module Twitch exposing (..) 2 | 3 | import Html exposing (programWithFlags) 4 | import Twitch.Chat exposing (Chat, Msg) 5 | 6 | 7 | type alias Flags = 8 | { username : String 9 | , oauth : String 10 | , channel : String 11 | } 12 | 13 | 14 | main : Program Flags Chat Msg 15 | main = 16 | programWithFlags 17 | { init = (\({ username, oauth, channel } as flags) -> Twitch.Chat.init username oauth channel) 18 | , update = Twitch.Chat.update 19 | , view = Twitch.Chat.view 20 | , subscriptions = Twitch.Chat.subscriptions 21 | } 22 | -------------------------------------------------------------------------------- /src/Twitch/Chat.elm: -------------------------------------------------------------------------------- 1 | module Twitch.Chat exposing (Chat, Msg(..), init, update, view, subscriptions) 2 | 3 | {-| This module exposes the core Twitch Chat functionality. 4 | 5 | @docs init 6 | 7 | @docs Msg, Chat, update, view, subscriptions 8 | -} 9 | 10 | import Dict exposing (Dict) 11 | import Dom.Scroll as Scroll 12 | import Html exposing (..) 13 | import Html.Events 14 | import Html.Keyed 15 | import Http 16 | import Json.Decode as JD 17 | import Task exposing (Task) 18 | import Twitch.Chat.Badges exposing (Badges) 19 | import Twitch.Chat.Channel 20 | import Twitch.Chat.Chatters exposing (Chatter) 21 | import Twitch.Chat.Css as Css exposing (class, id) 22 | import Twitch.Chat.Header exposing (Header) 23 | import Twitch.Chat.MessageLine as MessageLine exposing (takeMessageId) 24 | import Twitch.Chat.Properties exposing (Properties) 25 | import Twitch.Chat.SendText as SendText exposing (UserMessage) 26 | import Twitch.Chat.Types exposing (Message(SystemMessage)) 27 | import WebSocket 28 | 29 | 30 | {-| The messages our `Chat` responds to. 31 | 32 | * `NoOp`: This is the "empty" state change. 33 | the chat messages. 34 | * `ChatTaskResponse`: Respond to the HTTP requests that are needed to setup chat. Right now, 35 | only `Badges` are loaded from the Twitch API. 36 | * `ServerError`: Respond to HTTP errors when processing `ChatTaskResponse`. 37 | * `ChildMessageLineMsg`: This is an intermediary type constructor to route `MessageLine.Msg` to its own module 38 | * `ChildSendTextMsg`: This is an intermediary type constructor to route `SendText.Msg` to its own module 39 | * `ChatScrolled`: means the user has scrolled the chat div up or down 40 | -} 41 | type Msg 42 | = NoOp 43 | | ChatTaskResponse (Result Http.Error ChatTaskType) 44 | | ChildMessageLineMsg MessageLine.Msg 45 | | ChildSendTextMsg SendText.Msg 46 | | ChatScrolled OnScrollEvent 47 | 48 | 49 | {-| The model state for Twitch Chat. 50 | -} 51 | type alias Chat = 52 | { channelName : String 53 | , username : String 54 | , oauth : String 55 | , header : Header 56 | , mProperties : Maybe Properties 57 | , mBadges : Maybe Badges 58 | , userMessage : UserMessage 59 | , chatMessages : List Message 60 | , shouldScroll : Bool 61 | , chatters : Dict String (List Chatter) 62 | } 63 | 64 | 65 | {-| Convenience type for chaining multiple chat requests together 66 | -} 67 | type alias ChatTaskType = 68 | { channel : Twitch.Chat.Channel.Channel 69 | , badges : Badges 70 | , chatters : Twitch.Chat.Chatters.Response 71 | } 72 | 73 | 74 | {-| Convenience type for keeping track of the scroll state of 75 | chat div. 76 | -} 77 | type alias OnScrollEvent = 78 | { height : Float 79 | , top : Float 80 | , clientHeight : Float 81 | } 82 | 83 | 84 | {-| The subscriptions for WebSocket connections to the Twitch WebSocket IRC server. 85 | 86 | ## Note 87 | 88 | Interesting to note here is our usage of two connections. One connection is for 89 | receiving the chat messages. The other connection is for sending our chat messages 90 | from the chat textarea. 91 | 92 | The reasoning behind this is Twitch does not "echo" back your own written messages 93 | with the necessary IRC metadata tags. So badges, emotes and bits are up to you to 94 | decorate. A workaround for this is opening two connections. This allows you to receive 95 | your own messages back with all IRC metadata tags. 96 | 97 | This approach needed another workaround. The [`elm-lang/websocket`](http://package.elm-lang.org/packages/elm-lang/websocket/1.0.1/) 98 | implementation distinguishes websockets by their URL. So to have a "sender" and 99 | "receiver" websocket running seperately, their URLs must be different. This is 100 | solved by appending an empty query `"?"` behind Twitch's websocket URL so it 101 | reads `"ws://irc-ws.chat.twitch.tv:80?"`. 102 | -} 103 | subscriptions : Chat -> Sub Msg 104 | subscriptions ({ userMessage } as model) = 105 | Sub.batch 106 | [ Sub.map ChildMessageLineMsg (MessageLine.subscriptions receiveWsUrl) 107 | , Sub.map ChildSendTextMsg (SendText.subscriptions userMessage) 108 | ] 109 | 110 | 111 | {-| Initialize the login for connecting to Twitch Chat. 112 | 113 | It requires: 114 | 115 | * a username 116 | * an OAuth token with the scope `chat_scope` 117 | * a channel name __all lowercase__ 118 | -} 119 | init : String -> String -> String -> ( Chat, Cmd Msg ) 120 | init username oauth channelName = 121 | let 122 | ( userMessage, userMessageCmd ) = 123 | SendText.init sendWsUrl channelName 124 | 125 | header = 126 | Twitch.Chat.Header.init channelName 127 | 128 | model = 129 | { channelName = channelName 130 | , username = username 131 | , oauth = oauth 132 | , header = header 133 | , mProperties = Nothing 134 | , mBadges = Nothing 135 | , userMessage = userMessage 136 | , shouldScroll = False 137 | , chatters = Dict.empty 138 | , chatMessages = [ SystemMessage "Connecting to chat room..." ] 139 | } 140 | in 141 | model 142 | ! [ initTasks channelName 143 | |> Task.attempt ChatTaskResponse 144 | , Cmd.map ChildSendTextMsg userMessageCmd 145 | ] 146 | 147 | 148 | receiveWsUrl : String 149 | receiveWsUrl = 150 | "ws://irc-ws.chat.twitch.tv:80" 151 | 152 | 153 | sendWsUrl : String 154 | sendWsUrl = 155 | "ws://irc-ws.chat.twitch.tv:80?" 156 | 157 | 158 | initTasks : String -> Task Http.Error ChatTaskType 159 | initTasks channelName = 160 | let 161 | channelTask = 162 | Twitch.Chat.Channel.getChannel channelName 163 | 164 | badgesTask = 165 | Task.map (.id >> toString) channelTask 166 | |> Task.andThen 167 | (\aResult -> 168 | (Twitch.Chat.Badges.getGlobalBadges 169 | |> Task.andThen 170 | (\bResult -> 171 | Twitch.Chat.Badges.getSubscriberBadges aResult bResult 172 | ) 173 | ) 174 | ) 175 | 176 | chattersTask = 177 | Twitch.Chat.Chatters.getChatters channelName 178 | in 179 | Task.map3 ChatTaskType 180 | channelTask 181 | badgesTask 182 | chattersTask 183 | 184 | 185 | {-| Respond to events and model state changes. 186 | 187 | -} 188 | update : Msg -> Chat -> ( Chat, Cmd Msg ) 189 | update msg model = 190 | case msg of 191 | NoOp -> 192 | model 193 | ! [] 194 | 195 | ChildMessageLineMsg childMsg -> 196 | let 197 | ( message, messageCmd ) = 198 | MessageLine.update childMsg receiveWsUrl 199 | 200 | scrollCmd = 201 | if model.shouldScroll then 202 | Cmd.none 203 | else 204 | scrollChat 205 | in 206 | { model 207 | | chatMessages = 208 | (model.chatMessages ++ [ message ]) 209 | |> dropMessagesIfNeeded 210 | } 211 | ! [ Cmd.map ChildMessageLineMsg messageCmd 212 | , scrollCmd 213 | ] 214 | 215 | ChildSendTextMsg childMsg -> 216 | let 217 | ( userMessage, userMessageCmd ) = 218 | SendText.update childMsg model.userMessage 219 | in 220 | { model | userMessage = userMessage } 221 | ! [ Cmd.map ChildSendTextMsg userMessageCmd ] 222 | 223 | ChatTaskResponse (Ok { badges, chatters }) -> 224 | let 225 | joinCommands ip = 226 | Cmd.batch 227 | [ WebSocket.send ip <| "JOIN #" ++ model.channelName 228 | , WebSocket.send ip "CAP REQ :twitch.tv/tags" 229 | , WebSocket.send ip "CAP REQ :twitch.tv/commands" 230 | , WebSocket.send ip <| "NICK " ++ model.username 231 | , WebSocket.send ip <| "PASS " ++ model.oauth 232 | ] 233 | 234 | loginCmds = 235 | [ joinCommands sendWsUrl 236 | , joinCommands receiveWsUrl 237 | ] 238 | 239 | sendTextModel = 240 | model.userMessage 241 | in 242 | { model 243 | | mBadges = Just badges 244 | , chatters = chatters.chatters 245 | , userMessage = 246 | { sendTextModel 247 | | chatters = 248 | Dict.values chatters.chatters 249 | |> List.concat 250 | } 251 | , chatMessages = 252 | List.tail model.chatMessages 253 | |> Maybe.withDefault [] 254 | |> (::) (SystemMessage "Connected to chat room.") 255 | } 256 | ! loginCmds 257 | 258 | ChatTaskResponse (Err err) -> 259 | let 260 | newMessages = 261 | model.chatMessages ++ [ SystemMessage <| toString err ] 262 | in 263 | { model | chatMessages = newMessages } 264 | ! [] 265 | 266 | ChatScrolled event -> 267 | { model 268 | | shouldScroll = event.top < (event.height * 0.99 - event.clientHeight) 269 | } 270 | ! [] 271 | 272 | 273 | {-| Render the our model state `Chat` in `Html`. 274 | -} 275 | view : Chat -> Html Msg 276 | view model = 277 | div 278 | [ class [ Css.Container ] 279 | ] 280 | [ Twitch.Chat.Header.view model.header 281 | , div 282 | [ class [ Css.ChatRoom ] 283 | ] 284 | [ Html.Keyed.node "div" 285 | [ id Css.ChatDiv 286 | , class [ Css.ChatMessages ] 287 | , onScroll ChatScrolled 288 | ] 289 | (List.map 290 | (MessageLine.view receiveWsUrl model.mBadges) 291 | model.chatMessages 292 | ) 293 | , Html.map ChildSendTextMsg (SendText.view model.userMessage) 294 | ] 295 | ] 296 | 297 | 298 | dropMessagesIfNeeded : List a -> List a 299 | dropMessagesIfNeeded list = 300 | list 301 | |> List.length 302 | >> flip (-) 100 303 | >> flip List.drop list 304 | 305 | 306 | onScroll : (OnScrollEvent -> msg) -> Attribute msg 307 | onScroll tagger = 308 | JD.map tagger onScrollJsonParser 309 | |> Html.Events.on "scroll" 310 | 311 | 312 | onScrollJsonParser : JD.Decoder OnScrollEvent 313 | onScrollJsonParser = 314 | JD.map3 OnScrollEvent 315 | (JD.at [ "target", "scrollHeight" ] JD.float) 316 | (JD.at [ "target", "scrollTop" ] JD.float) 317 | (JD.at [ "target", "clientHeight" ] JD.float) 318 | 319 | 320 | scrollChat : Cmd Msg 321 | scrollChat = 322 | Scroll.toBottom (toString Css.ChatDiv) 323 | |> Task.attempt (always NoOp) 324 | -------------------------------------------------------------------------------- /src/Twitch/Chat/Badges.elm: -------------------------------------------------------------------------------- 1 | module Twitch.Chat.Badges exposing (..) 2 | 3 | import Dict exposing (Dict) 4 | import Http 5 | import Json.Decode as JD exposing (Decoder, field) 6 | import Json.Decode.Extra exposing ((|:)) 7 | import String 8 | import Task exposing (Task) 9 | import Twitch.Request as Request 10 | 11 | 12 | type alias Url = 13 | String 14 | 15 | 16 | type alias Badges = 17 | { badgeSets : BadgeSets } 18 | 19 | 20 | type alias BadgeSets = 21 | { bits : BadgeVersions 22 | , globalMod : BadgeVersions 23 | , admin : BadgeVersions 24 | , broadcaster : BadgeVersions 25 | , mod : BadgeVersions 26 | , staff : BadgeVersions 27 | , turbo : BadgeVersions 28 | , premium : BadgeVersions 29 | , subscriber : Maybe BadgeVersions 30 | } 31 | 32 | 33 | type alias BadgeVersions = 34 | Dict String BadgeProperties 35 | 36 | 37 | type alias BadgeProperties = 38 | { imageUrl1x : Url 39 | , imageUrl2x : Url 40 | , imageUrl4x : Url 41 | , description : String 42 | , title : String 43 | , clickAction : String 44 | , clickUrl : String 45 | } 46 | 47 | 48 | getGlobalBadges : Task Http.Error Badges 49 | getGlobalBadges = 50 | getBadges "global" globalBadgeSetsDecoder 51 | 52 | 53 | getSubscriberBadges : String -> Badges -> Task Http.Error Badges 54 | getSubscriberBadges channelId globalBadges = 55 | subscriberBadgeSetsDecoder globalBadges.badgeSets 56 | |> getBadges ("channels/" ++ channelId) 57 | 58 | 59 | getBadges : String -> Decoder BadgeSets -> Task Http.Error Badges 60 | getBadges channelId decoder = 61 | let 62 | url = 63 | String.join "/" 64 | [ "https://badges.twitch.tv/v1/badges" 65 | , channelId 66 | , "display" 67 | ] 68 | in 69 | Request.attempt (badgesDecoder decoder) url 70 | 71 | 72 | badgesDecoder : Decoder BadgeSets -> Decoder Badges 73 | badgesDecoder decoder = 74 | JD.map Badges 75 | (field "badge_sets" decoder) 76 | 77 | 78 | globalBadgeSetsDecoder : Decoder BadgeSets 79 | globalBadgeSetsDecoder = 80 | JD.succeed BadgeSets 81 | |: (field "bits" badgeVersionsDecoder) 82 | |: (field "global_mod" badgeVersionsDecoder) 83 | |: (field "admin" badgeVersionsDecoder) 84 | |: (field "broadcaster" badgeVersionsDecoder) 85 | |: (field "moderator" badgeVersionsDecoder) 86 | |: (field "staff" badgeVersionsDecoder) 87 | |: (field "turbo" badgeVersionsDecoder) 88 | |: (field "premium" badgeVersionsDecoder) 89 | |: (field "subscriber" <| JD.succeed Nothing) 90 | 91 | 92 | subscriberBadgeSetsDecoder : BadgeSets -> Decoder BadgeSets 93 | subscriberBadgeSetsDecoder badges = 94 | JD.map (BadgeSets badges.bits badges.globalMod badges.admin badges.broadcaster badges.mod badges.staff badges.turbo badges.premium) 95 | (field "subscriber" <| JD.maybe badgeVersionsDecoder) 96 | 97 | 98 | badgeVersionsDecoder : Decoder BadgeVersions 99 | badgeVersionsDecoder = 100 | JD.at [ "versions" ] <| JD.dict badgePropertiesDecoder 101 | 102 | 103 | badgePropertiesDecoder : Decoder BadgeProperties 104 | badgePropertiesDecoder = 105 | JD.map7 BadgeProperties 106 | (field "image_url_1x" JD.string) 107 | (field "image_url_2x" JD.string) 108 | (field "image_url_4x" JD.string) 109 | (field "description" JD.string) 110 | (field "title" JD.string) 111 | (field "click_action" JD.string) 112 | (field "click_url" JD.string) 113 | -------------------------------------------------------------------------------- /src/Twitch/Chat/Channel.elm: -------------------------------------------------------------------------------- 1 | module Twitch.Chat.Channel exposing (..) 2 | 3 | import Http 4 | import Json.Decode as JD exposing (Decoder, field) 5 | import String 6 | import Task exposing (Task) 7 | import Twitch.Request as Request 8 | 9 | 10 | type alias Channel = 11 | { id : Int 12 | , name : String 13 | , displayName : String 14 | , game : String 15 | } 16 | 17 | 18 | getChannel : String -> Task Http.Error Channel 19 | getChannel channelName = 20 | let 21 | url = 22 | String.join "/" 23 | [ "https://api.twitch.tv/kraken/channels" 24 | , channelName 25 | ] 26 | in 27 | Request.attempt channelDecoder url 28 | 29 | 30 | channelDecoder : Decoder Channel 31 | channelDecoder = 32 | JD.map4 Channel 33 | (field "_id" JD.int) 34 | (field "name" JD.string) 35 | (field "display_name" JD.string) 36 | (field "game" JD.string) 37 | -------------------------------------------------------------------------------- /src/Twitch/Chat/Chatters.elm: -------------------------------------------------------------------------------- 1 | module Twitch.Chat.Chatters exposing (..) 2 | 3 | {-| This module is for querying the TMI to periodically 4 | fetch current users in a channel. 5 | -} 6 | 7 | import Dict exposing (Dict) 8 | import Http 9 | import Json.Decode as JD exposing (Decoder, field) 10 | import Task exposing (Task) 11 | import Twitch.Request as Request 12 | 13 | 14 | type alias Chatter = 15 | String 16 | 17 | 18 | type alias Response = 19 | { count : Int 20 | , chatters : Dict String (List Chatter) 21 | } 22 | 23 | 24 | getChatters : String -> Task Http.Error Response 25 | getChatters channelName = 26 | let 27 | url = 28 | String.join "/" 29 | [ "http://tmi.twitch.tv/group/user" 30 | , channelName 31 | , "chatters" 32 | ] 33 | in 34 | Request.attempt (JD.at [ "data" ] responseDecoder) url 35 | 36 | 37 | responseDecoder : Decoder Response 38 | responseDecoder = 39 | JD.map2 Response 40 | (field "chatter_count" JD.int) 41 | (field "chatters" (JD.dict chattersDecoder)) 42 | 43 | 44 | chattersDecoder : Decoder (List Chatter) 45 | chattersDecoder = 46 | JD.list JD.string 47 | -------------------------------------------------------------------------------- /src/Twitch/Chat/Css.elm: -------------------------------------------------------------------------------- 1 | module Twitch.Chat.Css exposing (..) 2 | 3 | import Css exposing (..) 4 | import Css.Elements exposing (..) 5 | import Css.Namespace exposing (namespace) 6 | import Html.CssHelpers 7 | import Twitch.Chat.Types exposing (Tag(..)) 8 | 9 | 10 | { id, class, classList } = 11 | Html.CssHelpers.withNamespace twitchChatNamespace 12 | 13 | 14 | type Ids 15 | = Twitch 16 | | ChatDiv 17 | 18 | 19 | type Classes 20 | = Header 21 | | HeaderButton 22 | | RoomTitle 23 | | Container 24 | | ChatRoom 25 | | ChatMessages 26 | | ChatInterface 27 | | TextareaContain 28 | | ButtonsContainer 29 | | Submit 30 | | Message 31 | | PrivateMessage 32 | | ResubMessage 33 | | SystemMessage 34 | | Badges 35 | | BadgeImg 36 | | From 37 | | Colon 38 | | Content 39 | | Notice 40 | | TooltipWrapper 41 | | EmoteWrapper 42 | | Emote 43 | | BalloonWrapper 44 | | BalloonTooltip 45 | | Suggestions 46 | | Highlighted 47 | | Suggestion 48 | 49 | 50 | zIndex : Int -> Mixin 51 | zIndex = 52 | toString >> property "z-index" 53 | 54 | 55 | twitchChatNamespace : String 56 | twitchChatNamespace = 57 | "twitchChat_" 58 | 59 | 60 | css : Stylesheet 61 | css = 62 | (stylesheet << namespace twitchChatNamespace) 63 | [ everything 64 | [ padding zero 65 | , margin zero 66 | , boxSizing borderBox 67 | ] 68 | , each [ html, body, (#) Twitch ] 69 | [ width (pct 100) 70 | , height (pct 100) 71 | ] 72 | , (.) Container 73 | [ backgroundColor (hex "#efeef1") 74 | , color (rgb 67 63 74) 75 | , width (pct 100) 76 | , height (pct 100) 77 | , position relative 78 | , minWidth (px 250) 79 | , fontFamilies 80 | [ "Helvetica Neue" 81 | , "Helvetica" 82 | , "sans-serif" 83 | ] 84 | ] 85 | , (.) ChatRoom 86 | [ position absolute 87 | , top (px 50) 88 | , left zero 89 | , right zero 90 | , bottom zero 91 | , display block 92 | , zIndex 4 93 | , property "background-clip" "content-box" 94 | ] 95 | , (.) ChatMessages 96 | [ position absolute 97 | , width auto 98 | , height auto 99 | , top zero 100 | , left zero 101 | , right zero 102 | , bottom (px 111) 103 | , overflow auto 104 | ] 105 | , (.) ChatInterface 106 | [ height (px 111) 107 | , width (pct 100) 108 | , position absolute 109 | , bottom zero 110 | , padding3 zero (px 20) (px 20) 111 | ] 112 | , (.) TextareaContain 113 | [ position relative 114 | , width (pct 100) 115 | , height (px 50) 116 | , marginBottom (px 10) 117 | , descendants 118 | [ selector "textarea" 119 | [ width (pct 100) 120 | , height (px 50) 121 | , property "resize" "none" 122 | , padding (px 7) 123 | , fontSize (px 12) 124 | , lineHeight (px 20) 125 | , color (hex "#706a7c") 126 | , verticalAlign top 127 | , border3 (px 1) solid (hex "#dad8de") 128 | , backgroundColor (hex "#ffffff") 129 | , property "outline" "0" 130 | ] 131 | ] 132 | ] 133 | , (.) ButtonsContainer 134 | [ overflow hidden ] 135 | , (.) Submit 136 | [ property "float" "right" 137 | , backgroundColor (hex "#6441a4") 138 | , border zero 139 | , position relative 140 | , color (hex "#ffffff") 141 | , cursor pointer 142 | , display inlineBlock 143 | , fontSize (px 12) 144 | , lineHeight (px 30) 145 | , padding2 zero (px 10) 146 | , margin zero 147 | ] 148 | , (.) Header 149 | [ position relative 150 | , backgroundColor transparent 151 | , height (px 50) 152 | , padding2 (px 10) (Css.rem 0.7) 153 | , lineHeight (px 30) 154 | , textAlign center 155 | , fontSize (px 14) 156 | , width (pct 100) 157 | , property "box-shadow" "inset 0 -1px 0 0 #dad8de" 158 | ] 159 | , (.) HeaderButton 160 | [ position absolute 161 | , top (px 10) 162 | , left (px 20) 163 | , padding zero 164 | , display inlineBlock 165 | ] 166 | , (.) RoomTitle 167 | [ lineHeight (px 20) 168 | , fontSize (px 12) 169 | , marginBottom (px -6) 170 | , paddingTop (px 6) 171 | ] 172 | , (.) Message 173 | [ fontSize (px 12) 174 | , lineHeight (px 20) 175 | , padding2 (px 6) (px 20) 176 | , margin2 (px -3) zero 177 | , property "word-wrap" "break-word" 178 | ] 179 | , (.) PrivateMessage 180 | [] 181 | , (.) ResubMessage 182 | [ backgroundColor (hex "#e5e3e8") 183 | , property "box-shadow" "3px 0 0 #6441a4 inset" 184 | ] 185 | , (.) SystemMessage 186 | [ color (hex "#a49fad") 187 | ] 188 | , (.) Badges 189 | [ property "float" "left" 190 | , after 191 | [ property "content" "''" 192 | , property "display" "table" 193 | , property "clear" "both" 194 | , property "visibility" "hidden" 195 | , fontSize (px 0) 196 | , height zero 197 | ] 198 | ] 199 | , (.) BadgeImg 200 | [ height (px 18) 201 | , minWidth (px 18) 202 | , display inlineBlock 203 | , verticalAlign middle 204 | , property "float" "left" 205 | , margin4 (px 1) (px 3) (px 1) zero 206 | ] 207 | , (.) From 208 | [ fontWeight (int 700) 209 | , margin zero 210 | , padding zero 211 | ] 212 | , (.) Colon 213 | [ margin4 zero (px 2) zero zero 214 | , padding zero 215 | ] 216 | , (.) Content 217 | [ margin zero 218 | , padding zero 219 | ] 220 | , (.) Notice 221 | [ color (hex "#575260") 222 | ] 223 | , (.) Emote 224 | [ property "background-center" "center center" 225 | , property "background-repeat" "no-repeat" 226 | , display inlineBlock 227 | , verticalAlign middle 228 | , margin2 (px -5) zero 229 | , border zero 230 | ] 231 | , (.) TooltipWrapper 232 | [ position relative 233 | , withClass BalloonWrapper 234 | [ property "float" "left" 235 | , hover 236 | [ descendants 237 | [ (.) BalloonTooltip 238 | [ after 239 | [ marginLeft (px -3) ] 240 | ] 241 | ] 242 | ] 243 | ] 244 | , hover 245 | [ descendants 246 | [ (.) BalloonTooltip 247 | [ display block 248 | , before 249 | [ property "content" "''" 250 | , position absolute 251 | , top (px -6) 252 | , left (px -6) 253 | , property "width" "calc(100% + 12px)" 254 | , property "height" "calc(100% + 12px)" 255 | , zIndex -1 256 | ] 257 | , after 258 | [ property "content" "''" 259 | , position absolute 260 | , left (pct 50) 261 | , backgroundColor (hex "#0e0c13") 262 | , top (px -3) 263 | , borderRadius3 (px 1) zero zero 264 | , property "border-width" "1px 0 0 1px" 265 | , width (px 6) 266 | , height (px 6) 267 | , transform (rotate (deg 45)) 268 | , zIndex -1 269 | ] 270 | ] 271 | ] 272 | ] 273 | ] 274 | , (.) BalloonTooltip 275 | [ left (px 10) 276 | , transform (translateX (pct -50)) 277 | , backgroundColor (hex "#0e0c13") 278 | , color (hex "#ffffff") 279 | , padding2 (px 3) (px 6) 280 | , property "white-space" "nowrap" 281 | , property "box-shadow" "none" 282 | , top (pct 100) 283 | , marginTop (px 6) 284 | , borderRadius (px 1) 285 | , display none 286 | , fontSize (px 12) 287 | , lineHeight (px 15) 288 | , textAlign left 289 | , zIndex 99999 290 | , position absolute 291 | ] 292 | , (.) Suggestions 293 | [ zIndex 1000 294 | , padding2 (px 5) zero 295 | , position absolute 296 | , bottom (px 43) 297 | , left (px 6) 298 | , width (px 278) 299 | , backgroundColor (hex "#ffffff") 300 | , border3 (px 1) solid (rgba 0 0 0 0.2) 301 | , backgroundClip paddingBox 302 | , descendants 303 | [ (.) Suggestion 304 | [ padding2 zero (px 10) 305 | , lineHeight (px 24) 306 | , width (px 270) 307 | , fontSize (px 12) 308 | , withClass (Highlighted) 309 | [ color (hex "#ffffff") 310 | , backgroundColor (hex "#6441a4") 311 | ] 312 | ] 313 | ] 314 | ] 315 | ] 316 | 317 | 318 | fromStyle : List Tag -> List ( String, String ) 319 | fromStyle tags = 320 | case tags of 321 | [] -> 322 | [] 323 | 324 | (Color mColor) :: rest -> 325 | ( "color", Maybe.withDefault "#8A2BE2" mColor ) 326 | :: fromStyle rest 327 | 328 | _ :: rest -> 329 | fromStyle rest 330 | -------------------------------------------------------------------------------- /src/Twitch/Chat/Header.elm: -------------------------------------------------------------------------------- 1 | module Twitch.Chat.Header exposing (..) 2 | 3 | import Html exposing (..) 4 | import Svg 5 | import Svg.Attributes as SvgAttr 6 | import Twitch.Chat.Css as Css exposing (id, class) 7 | 8 | 9 | type alias Header = 10 | { channelName : String 11 | } 12 | 13 | 14 | init : String -> Header 15 | init = 16 | Header 17 | 18 | 19 | view : Header -> Html a 20 | view model = 21 | div 22 | [ class [ Css.Header ] 23 | ] 24 | [ div 25 | [ class [ Css.HeaderButton ] 26 | ] 27 | [ Svg.svg 28 | [ SvgAttr.height "16px" 29 | , SvgAttr.width "16px" 30 | , SvgAttr.viewBox "0 0 16 16" 31 | , SvgAttr.fill "#6441a4" 32 | , SvgAttr.style "vertical-align: middle;" 33 | ] 34 | [ Svg.path 35 | [ SvgAttr.clipRule "evenodd" 36 | , SvgAttr.d "M1,13v-2h14v2H1z M1,5h13v2H1V5z M1,2h10v2H1V2z M12,10H1V8h11V10z" 37 | , SvgAttr.fillRule "evenodd" 38 | ] 39 | [] 40 | ] 41 | ] 42 | , p 43 | [ class [ Css.RoomTitle ] 44 | ] 45 | [ text model.channelName ] 46 | ] 47 | -------------------------------------------------------------------------------- /src/Twitch/Chat/MessageLine.elm: -------------------------------------------------------------------------------- 1 | module Twitch.Chat.MessageLine exposing (..) 2 | 3 | import Html exposing (..) 4 | import Twitch.Chat.Badges exposing (BadgeProperties, BadgeSets, Badges) 5 | import Twitch.Chat.MessageLine.View as View 6 | import Twitch.Chat.Parser 7 | import Twitch.Chat.Types exposing (Badge(..), Channel, Emote, Message(..), Tag(..), User) 8 | import WebSocket 9 | 10 | 11 | {-| The messages that `MessageLine` responds to. 12 | 13 | * `RawMessage`: Receives IRC lines from its websocket. It responds to all IRC lines, and not just the PING. 14 | -} 15 | type Msg 16 | = RawMessage String 17 | 18 | 19 | subscriptions : String -> Sub Msg 20 | subscriptions receiveUrl = 21 | WebSocket.listen receiveUrl RawMessage 22 | 23 | 24 | update : Msg -> String -> ( Message, Cmd Msg ) 25 | update msg receiveUrl = 26 | case msg of 27 | RawMessage str -> 28 | let 29 | parsedMessage = 30 | Twitch.Chat.Parser.parse str 31 | |> Result.withDefault Ignored 32 | in 33 | handleWithPing parsedMessage receiveUrl 34 | 35 | 36 | handleWithPing : Message -> String -> ( Message, Cmd Msg ) 37 | handleWithPing message receiveUrl = 38 | case message of 39 | Ping content -> 40 | Ignored 41 | ! [ WebSocket.send receiveUrl <| "PONG " ++ content ] 42 | 43 | rest -> 44 | rest 45 | ! [] 46 | 47 | 48 | {-| We return a keyed HTML node so Virtual DOM can render 49 | these changes efficiently 50 | -} 51 | view : String -> Maybe Badges -> Message -> ( String, Html a ) 52 | view receiveUrl mBadges message = 53 | case message of 54 | PrivateMessage tags user channel content -> 55 | ( takeMessageId tags 56 | , View.viewMessage mBadges tags user content 57 | ) 58 | 59 | Resubscription tags channel mContent -> 60 | ( takeUserId tags 61 | , View.viewResub mBadges tags channel mContent 62 | ) 63 | 64 | Subscription channel content -> 65 | ( content 66 | , View.viewSub content 67 | ) 68 | 69 | ActionMessage tags user channel content -> 70 | ( takeMessageId tags 71 | , View.viewActionMessage mBadges tags user content 72 | ) 73 | 74 | SystemMessage content -> 75 | ( content 76 | , View.viewInfoMessage content 77 | ) 78 | 79 | _ -> 80 | ( "" 81 | , text "" 82 | ) 83 | 84 | 85 | takeMessageId : List Tag -> String 86 | takeMessageId tags = 87 | case tags of 88 | [] -> 89 | Debug.crash "IRCv3 tags are not enabled or message-id tag is missing." 90 | 91 | (Id mId) :: _ -> 92 | mId 93 | 94 | _ :: rest -> 95 | takeMessageId rest 96 | 97 | 98 | takeUserId : List Tag -> String 99 | takeUserId tags = 100 | case tags of 101 | [] -> 102 | Debug.crash "IRCv3 tags are not enabled or user-id tag is missing." 103 | 104 | (UserId mId) :: _ -> 105 | toString mId 106 | 107 | _ :: rest -> 108 | takeUserId rest 109 | -------------------------------------------------------------------------------- /src/Twitch/Chat/MessageLine/View.elm: -------------------------------------------------------------------------------- 1 | module Twitch.Chat.MessageLine.View exposing (..) 2 | 3 | import Dict 4 | import Html exposing (..) 5 | import Html.Attributes exposing (style, src, attribute) 6 | import String 7 | import Twitch.Chat.Badges exposing (Badges, BadgeSets, BadgeProperties) 8 | import Twitch.Chat.Css as Css exposing (id, class) 9 | import Twitch.Chat.Types exposing (Message(..), User, Channel, Tag(..), Badge(..), Emote) 10 | 11 | 12 | viewMessage : Maybe Badges -> List Tag -> User -> String -> Html a 13 | viewMessage badgeResponse tags user content = 14 | div 15 | [ class [ Css.Message, Css.PrivateMessage ] 16 | ] 17 | [ Maybe.map (badges tags) badgeResponse 18 | |> Maybe.withDefault (text "") 19 | , span 20 | [ class [ Css.From ] 21 | , style <| Css.fromStyle tags 22 | ] 23 | [ chatFrom tags user ] 24 | , colon 25 | , span 26 | [ class [ Css.Content ] 27 | ] 28 | <| 29 | chatContent tags content 30 | ] 31 | 32 | 33 | viewActionMessage : Maybe Badges -> List Tag -> User -> String -> Html a 34 | viewActionMessage badgeResponse tags user content = 35 | div 36 | [ class [ Css.Message, Css.PrivateMessage ] 37 | ] 38 | [ Maybe.map (badges tags) badgeResponse 39 | |> Maybe.withDefault (text "") 40 | , span 41 | [ class [ Css.From ] 42 | , style <| Css.fromStyle tags 43 | ] 44 | [ chatFrom tags user ] 45 | , colon 46 | , span 47 | [ class [ Css.Content ] 48 | , style <| Css.fromStyle tags 49 | ] 50 | <| 51 | chatContent tags content 52 | ] 53 | 54 | 55 | viewResub : Maybe Badges -> List Tag -> Channel -> Maybe String -> Html a 56 | viewResub badgeResponse tags channel mContent = 57 | div 58 | [ class [ Css.Message, Css.ResubMessage ] 59 | ] 60 | <| 61 | systemMessage tags 62 | :: case mContent of 63 | Nothing -> 64 | [ text "" ] 65 | 66 | Just content -> 67 | [ Maybe.map (badges tags) badgeResponse 68 | |> Maybe.withDefault (text "") 69 | , span 70 | [ class [ Css.From ] 71 | , style <| Css.fromStyle tags 72 | ] 73 | [ chatFrom tags "" ] 74 | , colon 75 | , span 76 | [ class [ Css.Content ] 77 | ] 78 | <| 79 | chatContent tags content 80 | ] 81 | 82 | 83 | viewSub : String -> Html a 84 | viewSub content = 85 | div 86 | [ class [ Css.Message, Css.ResubMessage ] 87 | ] 88 | [ div 89 | [ class [ Css.SystemMessage ] 90 | ] 91 | [ text content ] 92 | ] 93 | 94 | 95 | systemMessage : List Tag -> Html a 96 | systemMessage tags = 97 | case tags of 98 | [] -> 99 | text "" 100 | 101 | (System message) :: _ -> 102 | div 103 | [ class [ Css.SystemMessage ] 104 | ] 105 | [ text message ] 106 | 107 | _ :: rest -> 108 | systemMessage rest 109 | 110 | 111 | viewInfoMessage : String -> Html a 112 | viewInfoMessage content = 113 | div 114 | [ class [ Css.Notice, Css.Message ] ] 115 | [ text content ] 116 | 117 | 118 | colon : Html a 119 | colon = 120 | span 121 | [ class [ Css.PrivateMessage, Css.Colon ] 122 | ] 123 | [ text ":" 124 | ] 125 | 126 | 127 | badges : List Tag -> Badges -> Html a 128 | badges tags badgeResponse = 129 | case tags of 130 | [] -> 131 | text "" 132 | 133 | (Badges badges) :: _ -> 134 | span [ class [ Css.Badges ] ] 135 | (viewBadges badgeResponse.badgeSets badges) 136 | 137 | _ :: rest -> 138 | badges rest badgeResponse 139 | 140 | 141 | chatFrom : List Tag -> User -> Html a 142 | chatFrom tags user = 143 | case tags of 144 | [] -> 145 | text user 146 | 147 | (DisplayName displayName) :: _ -> 148 | displayName 149 | |> Maybe.withDefault user 150 | |> text 151 | 152 | _ :: rest -> 153 | chatFrom rest user 154 | 155 | 156 | chatContent : List Tag -> String -> List (Html a) 157 | chatContent tags content = 158 | case tags of 159 | [] -> 160 | [ text content ] 161 | 162 | (Emotes emotes) :: _ -> 163 | spanContentWithEmotes 0 emotes content 164 | 165 | _ :: rest -> 166 | chatContent rest content 167 | 168 | 169 | spanContentWithEmotes : Int -> List Emote -> String -> List (Html a) 170 | spanContentWithEmotes len emotes content = 171 | case emotes of 172 | [] -> 173 | [ text content ] 174 | 175 | emote :: rest -> 176 | let 177 | emoteSrc size = 178 | String.join "/" 179 | [ "//static-cdn.jtvnw.net/emoticons/v1" 180 | , toString emote.id 181 | , size 182 | ] 183 | 184 | preEmoteContent = 185 | String.slice 0 (emote.begin - len) content 186 | 187 | emoteContent = 188 | String.slice (emote.begin - len) (emote.end + 1 - len) content 189 | 190 | postEmoteContent = 191 | String.dropLeft (emote.end + 1 - len) content 192 | in 193 | [ text preEmoteContent 194 | , span 195 | [ class [ Css.TooltipWrapper, Css.EmoteWrapper ] ] 196 | [ img 197 | [ src <| emoteSrc "1.0" 198 | , srcset [ emoteSrc "2.0 2x" ] 199 | , class [ Css.Emote ] 200 | ] 201 | [] 202 | , div 203 | [ class [ Css.BalloonTooltip ] ] 204 | [ text emoteContent ] 205 | ] 206 | ] 207 | ++ spanContentWithEmotes (len + (String.length content) - (String.length postEmoteContent)) rest postEmoteContent 208 | 209 | 210 | viewBadges : BadgeSets -> List Badge -> List (Html a) 211 | viewBadges badgeSets badges = 212 | let 213 | badgeHtml : BadgeProperties -> Html a 214 | badgeHtml properties = 215 | span 216 | [ class [ Css.TooltipWrapper, Css.BalloonWrapper ] 217 | ] 218 | [ img 219 | [ class [ Css.BadgeImg ] 220 | , src properties.imageUrl1x 221 | , srcset 222 | [ properties.imageUrl2x ++ " 2x" 223 | , properties.imageUrl4x ++ " 4x" 224 | ] 225 | ] 226 | [] 227 | , div 228 | [ class [ Css.BalloonTooltip ] 229 | ] 230 | [ text properties.title ] 231 | ] 232 | 233 | getBadgeVersion version versions = 234 | Dict.get version versions 235 | |> Maybe.map badgeHtml 236 | |> Maybe.withDefault (text "") 237 | in 238 | case badges of 239 | [] -> 240 | [ text "" ] 241 | 242 | (Subscriber royalty) :: rest -> 243 | badgeSets.subscriber 244 | |> Maybe.map (getBadgeVersion (toString royalty)) 245 | |> Maybe.withDefault (text "") 246 | |> flip (::) (viewBadges badgeSets rest) 247 | 248 | Prime :: rest -> 249 | getBadgeVersion "1" badgeSets.premium 250 | :: viewBadges badgeSets rest 251 | 252 | Turbo :: rest -> 253 | getBadgeVersion "1" badgeSets.turbo 254 | :: viewBadges badgeSets rest 255 | 256 | Moderator :: rest -> 257 | getBadgeVersion "1" badgeSets.mod 258 | :: viewBadges badgeSets rest 259 | 260 | GlobalMod :: rest -> 261 | getBadgeVersion "1" badgeSets.globalMod 262 | :: viewBadges badgeSets rest 263 | 264 | (Bits bits) :: rest -> 265 | let 266 | bitsHtml = 267 | if bits > 99999 then 268 | getBadgeVersion "100000" badgeSets.bits 269 | else if bits > 9999 then 270 | getBadgeVersion "10000" badgeSets.bits 271 | else if bits > 4999 then 272 | getBadgeVersion "5000" badgeSets.bits 273 | else if bits > 999 then 274 | getBadgeVersion "1000" badgeSets.bits 275 | else if bits > 99 then 276 | getBadgeVersion "100" badgeSets.bits 277 | else 278 | getBadgeVersion "1" badgeSets.bits 279 | in 280 | bitsHtml 281 | :: viewBadges badgeSets rest 282 | 283 | Admin :: rest -> 284 | getBadgeVersion "1" badgeSets.admin 285 | :: viewBadges badgeSets rest 286 | 287 | Staff :: rest -> 288 | getBadgeVersion "1" badgeSets.staff 289 | :: viewBadges badgeSets rest 290 | 291 | Broadcaster :: rest -> 292 | getBadgeVersion "1" badgeSets.broadcaster 293 | :: viewBadges badgeSets rest 294 | 295 | 296 | srcset : List String -> Attribute a 297 | srcset = 298 | String.join ", " 299 | >> attribute "srcset" 300 | -------------------------------------------------------------------------------- /src/Twitch/Chat/Parser.elm: -------------------------------------------------------------------------------- 1 | module Twitch.Chat.Parser exposing (..) 2 | 3 | import Combine exposing (..) 4 | import Combine.Char exposing (..) 5 | import Combine.Num exposing (..) 6 | import String 7 | import Twitch.Chat.Types 8 | exposing 9 | ( Message(..) 10 | , Badge(..) 11 | , Tag(..) 12 | , Channel 13 | , User 14 | , Command 15 | , Mode 16 | , Emote 17 | ) 18 | 19 | 20 | {-| Intermediary state of a parsed emote. 21 | 22 | Emotes can have multiple occurences. But having a `List EmoteOccurence` 23 | during rendering makes it unnecessarily difficult when applying the 24 | emotes to the message content. 25 | -} 26 | type alias ParsedEmote = 27 | { id : Int 28 | , indices : List EmoteOccurence 29 | } 30 | 31 | 32 | type alias EmoteOccurence = 33 | { begin : Int 34 | , end : Int 35 | } 36 | 37 | 38 | (<$>) : Parser s a -> (a -> b) -> Parser s b 39 | (<$>) = 40 | flip Combine.map 41 | 42 | 43 | ($>) : Parser s a -> b -> Parser s b 44 | ($>) = 45 | flip (<$) 46 | 47 | 48 | bool : Parser s Bool 49 | bool = 50 | int <$> ((==) 1) 51 | 52 | 53 | rest : Parser s String 54 | rest = 55 | manyTill anyChar end 56 | <$> String.fromList 57 | 58 | 59 | parse : String -> Result String Message 60 | parse ircMessage = 61 | case Combine.parse message ircMessage of 62 | Ok ( _, _, m ) -> 63 | Ok m 64 | 65 | Err ( _, stream, ms ) -> 66 | String.join "" 67 | [ toString ms 68 | , ", " 69 | , toString stream 70 | ] 71 | |> Debug.log "parse error" 72 | |> Err 73 | 74 | 75 | message : Parser s Message 76 | message = 77 | choice 78 | [ actionMessage 79 | , privMessage 80 | , pingMessage 81 | , resubMessage 82 | , subMessage 83 | ] 84 | 85 | 86 | pingMessage : Parser s Message 87 | pingMessage = 88 | succeed Ping 89 | <* string "PING" 90 | <* space 91 | <*> (manyTill anyChar end <$> String.fromList) 92 | 93 | 94 | actionMessage : Parser s Message 95 | actionMessage = 96 | succeed ActionMessage 97 | <*> privMsgTags 98 | <*> commandPrefix 99 | <* string "PRIVMSG " 100 | <*> channel 101 | <* space 102 | <* char ':' 103 | <* string "\x01ACTION" 104 | <* space 105 | <*> rest 106 | 107 | 108 | privMessage : Parser s Message 109 | privMessage = 110 | succeed PrivateMessage 111 | <*> privMsgTags 112 | <*> commandPrefix 113 | <* string "PRIVMSG " 114 | <*> channel 115 | <* space 116 | <* char ':' 117 | <*> rest 118 | 119 | 120 | resubMessage : Parser s Message 121 | resubMessage = 122 | succeed Resubscription 123 | <*> userStateTags 124 | <* string ":tmi.twitch.tv" 125 | <* space 126 | <* string "USERNOTICE " 127 | <*> channel 128 | <*> maybe (space <* char ':' *> rest) 129 | 130 | 131 | 132 | -- ":twitchnotify!twitchnotify@twitchnotify.tmi.twitch.tv PRIVMSG #lirik :LegalizeDragonDildos just subscribed!\r\n" 133 | 134 | 135 | subMessage : Parser s Message 136 | subMessage = 137 | succeed Subscription 138 | <* string ":twitchnotify!twitchnotify@twitchnotify.tmi.twitch.tv" 139 | <* space 140 | <* string "PRIVMSG " 141 | <*> channel 142 | <* space 143 | <* char ':' 144 | <*> rest 145 | 146 | 147 | nickname : Parser s String 148 | nickname = 149 | many (regex "[a-zA-Z0-9]" <|> string "_") <$> String.concat 150 | 151 | 152 | username : Parser s User 153 | username = 154 | nickname 155 | 156 | 157 | channel : Parser s Channel 158 | channel = 159 | char '#' 160 | *> nickname 161 | 162 | 163 | commandPrefix : Parser s User 164 | commandPrefix = 165 | char ':' 166 | *> nickname 167 | *> char '!' 168 | *> username 169 | <* char '@' 170 | <* nickname 171 | <* char '.' 172 | <* string "tmi.twitch.tv" 173 | <* space 174 | 175 | 176 | privMsgTags : Parser s (List Tag) 177 | privMsgTags = 178 | manyTill privMsgTag (char ' ') 179 | 180 | 181 | userStateTags : Parser s (List Tag) 182 | userStateTags = 183 | manyTill userStateTag (char ' ') 184 | 185 | 186 | privMsgTag : Parser s Tag 187 | privMsgTag = 188 | badges 189 | <|> bits 190 | <|> color 191 | <|> displayName 192 | <|> emotes 193 | <|> id 194 | <|> mod 195 | <|> roomId 196 | <|> subscriber 197 | <|> sentTimestamp "tmi-sent-ts" 198 | <|> sentTimestamp "sent-ts" 199 | <|> turbo 200 | <|> userId 201 | <|> userType 202 | 203 | 204 | userStateTag : Parser s Tag 205 | userStateTag = 206 | privMsgTag 207 | <|> msgId 208 | <|> msgParamMonths 209 | <|> systemMsg 210 | <|> login 211 | 212 | 213 | badges : Parser s Tag 214 | badges = 215 | string "@badges" 216 | *> char '=' 217 | *> sepBy (char ',') badge 218 | <* char ';' 219 | <$> Badges 220 | 221 | 222 | badge : Parser s Badge 223 | badge = 224 | let 225 | badgeParser b = 226 | string b *> char '/' *> int 227 | in 228 | choice 229 | [ badgeParser "subscriber" <$> Subscriber 230 | , badgeParser "turbo" $> Turbo 231 | , badgeParser "moderator" $> Moderator 232 | , string "bits" *> char '/' *> int <$> Bits 233 | , badgeParser "admin" $> Admin 234 | , badgeParser "staff" $> Staff 235 | , badgeParser "global_mod" $> GlobalMod 236 | , badgeParser "premium" $> Prime 237 | , badgeParser "broadcaster" $> Broadcaster 238 | ] 239 | 240 | 241 | bits : Parser s Tag 242 | bits = 243 | string "bits" 244 | *> char '=' 245 | *> int 246 | <* char ';' 247 | <$> BitsTag 248 | 249 | 250 | color : Parser s Tag 251 | color = 252 | string "color" 253 | *> char '=' 254 | *> maybe (regex "#[a-fA-F0-9]{6}") 255 | <* char ';' 256 | <$> Color 257 | 258 | 259 | displayName : Parser s Tag 260 | displayName = 261 | string "display-name" 262 | *> char '=' 263 | *> maybe (manyTill anyChar (char ';') <$> String.fromList) 264 | <$> DisplayName 265 | 266 | 267 | emotes : Parser s Tag 268 | emotes = 269 | string "emotes" 270 | *> char '=' 271 | *> sepBy (char '/') emote 272 | <* char ';' 273 | <$> (transformEmotes >> List.concat >> List.sortBy .begin >> Emotes) 274 | 275 | 276 | {-| Convert the intermediary `ParsedEmote`s to actual 277 | `Emote`s that will be used for applying the emotes to the 278 | chat message. 279 | -} 280 | transformEmotes : List ParsedEmote -> List (List Emote) 281 | transformEmotes parsedEmotes = 282 | case parsedEmotes of 283 | [] -> 284 | [] 285 | 286 | pe :: rest -> 287 | List.map (\occurence -> Emote pe.id occurence.begin occurence.end) pe.indices 288 | :: transformEmotes rest 289 | 290 | 291 | emote : Parser s ParsedEmote 292 | emote = 293 | succeed ParsedEmote 294 | <*> int 295 | <* char ':' 296 | <*> sepBy (char ',') emoteOccurence 297 | 298 | 299 | emoteOccurence : Parser s EmoteOccurence 300 | emoteOccurence = 301 | succeed EmoteOccurence 302 | <*> int 303 | <* char '-' 304 | <*> int 305 | 306 | 307 | id : Parser s Tag 308 | id = 309 | string "id" 310 | *> char '=' 311 | *> manyTill anyChar (char ';') 312 | <$> (String.fromList >> Id) 313 | 314 | 315 | mod : Parser s Tag 316 | mod = 317 | string "mod" 318 | *> char '=' 319 | *> bool 320 | <* char ';' 321 | <$> ModTag 322 | 323 | 324 | roomId : Parser s Tag 325 | roomId = 326 | string "room-id" 327 | *> char '=' 328 | *> int 329 | <* char ';' 330 | <$> RoomId 331 | 332 | 333 | subscriber : Parser s Tag 334 | subscriber = 335 | string "subscriber" 336 | *> char '=' 337 | *> bool 338 | <* char ';' 339 | <$> SubTag 340 | 341 | 342 | sentTimestamp : String -> Parser s Tag 343 | sentTimestamp timestampTag = 344 | string timestampTag 345 | *> char '=' 346 | *> int 347 | <* char ';' 348 | <$> always None 349 | 350 | 351 | turbo : Parser s Tag 352 | turbo = 353 | string "turbo" 354 | *> char '=' 355 | *> bool 356 | <* char ';' 357 | <$> TurboTag 358 | 359 | 360 | userId : Parser s Tag 361 | userId = 362 | string "user-id" 363 | *> char '=' 364 | *> int 365 | <* char ';' 366 | <$> UserId 367 | 368 | 369 | userType : Parser s Tag 370 | userType = 371 | let 372 | userTypes = 373 | [ string "mod" 374 | , string "global_mod" 375 | , string "admin" 376 | , string "staff" 377 | ] 378 | in 379 | string "user-type" 380 | *> char '=' 381 | *> maybe (choice userTypes) 382 | <$> UserType 383 | 384 | 385 | systemMsg : Parser s Tag 386 | systemMsg = 387 | string "system-msg" 388 | *> char '=' 389 | *> manyTill anyChar (char ';') 390 | <$> (String.fromList >> String.split "\\s" >> String.join " " >> System) 391 | 392 | 393 | login : Parser s Tag 394 | login = 395 | string "login" 396 | *> char '=' 397 | *> username 398 | <* char ';' 399 | <$> always None 400 | 401 | 402 | msgId : Parser s Tag 403 | msgId = 404 | string "msg-id" 405 | *> char '=' 406 | *> string "resub" 407 | <* char ';' 408 | <$> always None 409 | 410 | 411 | msgParamMonths : Parser s Tag 412 | msgParamMonths = 413 | string "msg-param-months" 414 | *> char '=' 415 | *> int 416 | <* char ';' 417 | <$> always None 418 | -------------------------------------------------------------------------------- /src/Twitch/Chat/Properties.elm: -------------------------------------------------------------------------------- 1 | module Twitch.Chat.Properties exposing (..) 2 | 3 | import Http 4 | import Json.Decode as JD exposing (Decoder, field) 5 | import String 6 | import Task exposing (Task) 7 | import Twitch.Request as Request 8 | 9 | 10 | type Status 11 | = Loading 12 | | Loaded 13 | | Error Http.Error 14 | 15 | 16 | type alias Properties = 17 | { subsonly : Bool 18 | , chatServers : List String 19 | , webSocketServers : List String 20 | , status : Status 21 | } 22 | 23 | 24 | getProperties : String -> Task Http.Error Properties 25 | getProperties channelName = 26 | let 27 | url = 28 | String.join "/" 29 | [ "https://api.twitch.tv/api/channels" 30 | , channelName 31 | , "chat_properties" 32 | ] 33 | in 34 | Request.attempt decodeProperties url 35 | 36 | 37 | decodeProperties : Decoder Properties 38 | decodeProperties = 39 | JD.map4 Properties 40 | (field "subsonly" JD.bool) 41 | (field "chat_servers" <| JD.list JD.string) 42 | (field "web_socket_servers" <| JD.list JD.string) 43 | (JD.succeed Loaded) 44 | -------------------------------------------------------------------------------- /src/Twitch/Chat/SendText.elm: -------------------------------------------------------------------------------- 1 | module Twitch.Chat.SendText exposing (..) 2 | 3 | import Autocomplete 4 | import Char exposing (KeyCode) 5 | import Html exposing (..) 6 | import Html.Attributes exposing (placeholder, tabindex, value) 7 | import Html.Events exposing (keyCode, on, onClick, onInput, onMouseEnter, onWithOptions) 8 | import Json.Decode as JD 9 | import Json.Decode.Extra as JDE 10 | import Regex exposing (Regex) 11 | import Twitch.Chat.Chatters exposing (Chatter) 12 | import Twitch.Chat.Css as Css exposing (class, id) 13 | import Twitch.Chat.Parser 14 | import Twitch.Chat.Types exposing (Channel, Message(..)) 15 | import WebSocket 16 | 17 | 18 | {-| The messages the chat textarea box responds to. 19 | 20 | * `NoOp`: This is the "empty" state change 21 | * `RawMessage`: Receives IRC lines from the websocket and tries to parse ONLY the PING command to keep the connection alive 22 | * 23 | * `UserMessageChanged`: The chat textarea state changes 24 | * `SubmitUserMessage`: Sending the message in the chat textarea and emptying it 25 | -} 26 | type Msg 27 | = NoOp 28 | | RawMessage String 29 | | UserMessageChanged String 30 | | SubmitUserMessage 31 | | SetAutocompleteState Autocomplete.Msg 32 | | SelectPerson Chatter 33 | | Reset Bool 34 | 35 | 36 | type alias UserMessage = 37 | { content : String 38 | , sendUrl : String 39 | , channelName : Channel 40 | , chatters : List Chatter 41 | , autoState : Autocomplete.State 42 | , showSuggestions : Bool 43 | } 44 | 45 | 46 | subscriptions : UserMessage -> Sub Msg 47 | subscriptions model = 48 | Sub.batch 49 | [ WebSocket.listen model.sendUrl RawMessage 50 | , Sub.map SetAutocompleteState Autocomplete.subscription 51 | ] 52 | 53 | 54 | init : String -> Channel -> ( UserMessage, Cmd Msg ) 55 | init sendUrl channelName = 56 | { content = "" 57 | , sendUrl = sendUrl 58 | , channelName = channelName 59 | , chatters = [] 60 | , autoState = Autocomplete.empty 61 | , showSuggestions = False 62 | } 63 | ! [] 64 | 65 | 66 | update : Msg -> UserMessage -> ( UserMessage, Cmd Msg ) 67 | update msg model = 68 | case msg of 69 | NoOp -> 70 | model 71 | ! [] 72 | 73 | RawMessage str -> 74 | case Twitch.Chat.Parser.parse str of 75 | Ok message -> 76 | model 77 | ! [ respondToPing model.sendUrl message ] 78 | 79 | Err err -> 80 | model 81 | ! [] 82 | 83 | UserMessageChanged str -> 84 | let 85 | isTypingMention = 86 | extractMention str 87 | |> String.isEmpty 88 | >> not 89 | in 90 | { model 91 | | content = str 92 | , showSuggestions = isTypingMention 93 | } 94 | ! [] 95 | 96 | SubmitUserMessage -> 97 | let 98 | message = 99 | String.join "" 100 | [ "PRIVMSG #" 101 | , model.channelName 102 | , " :" 103 | , model.content 104 | ] 105 | in 106 | { model 107 | | content = "" 108 | , autoState = Autocomplete.empty 109 | , showSuggestions = False 110 | } 111 | ! [ WebSocket.send model.sendUrl message ] 112 | 113 | SetAutocompleteState autoMsg -> 114 | let 115 | ( newState, maybeMsg ) = 116 | Autocomplete.update updateConfig autoMsg 5 model.autoState (acceptablePeople (extractMention model.content) model.chatters) 117 | 118 | newModel = 119 | { model | autoState = newState } 120 | in 121 | case maybeMsg of 122 | Nothing -> 123 | newModel ! [] 124 | 125 | Just updateMsg -> 126 | update updateMsg newModel 127 | 128 | SelectPerson autocompletedPerson -> 129 | let 130 | typedMention = 131 | extractMention model.content 132 | 133 | dropMention = 134 | String.dropRight (String.length typedMention) model.content 135 | in 136 | { model 137 | | content = dropMention ++ autocompletedPerson 138 | , autoState = Autocomplete.empty 139 | , showSuggestions = False 140 | } 141 | ! [] 142 | 143 | Reset toTop -> 144 | { model 145 | | autoState = 146 | if toTop then 147 | Autocomplete.resetToFirstItem updateConfig (acceptablePeople (extractMention model.content) model.chatters) 5 model.autoState 148 | else 149 | Autocomplete.resetToLastItem updateConfig (acceptablePeople (extractMention model.content) model.chatters) 5 model.autoState 150 | } 151 | ! [] 152 | 153 | 154 | acceptablePeople : String -> List Chatter -> List Chatter 155 | acceptablePeople query chatters = 156 | let 157 | lowerQuery = 158 | String.toLower query 159 | in 160 | List.filter (String.contains lowerQuery << String.toLower) chatters 161 | 162 | 163 | updateConfig : Autocomplete.UpdateConfig Msg Chatter 164 | updateConfig = 165 | Autocomplete.updateConfig 166 | { toId = identity 167 | , onKeyDown = onSubmitAutocomplete 168 | , onTooLow = Nothing 169 | , onTooHigh = Nothing 170 | , onMouseEnter = always Nothing 171 | , onMouseLeave = always Nothing 172 | , onMouseClick = Just << SelectPerson 173 | , separateSelections = False 174 | } 175 | 176 | 177 | viewConfig : Autocomplete.ViewConfig Chatter 178 | viewConfig = 179 | let 180 | customizedLi keySelected mouseSelected chatter = 181 | { attributes = 182 | [ if keySelected || mouseSelected then 183 | class [ Css.Highlighted, Css.Suggestion ] 184 | else 185 | class [ Css.Suggestion ] 186 | ] 187 | , children = 188 | [ text chatter ] 189 | } 190 | in 191 | Autocomplete.viewConfig 192 | { toId = identity 193 | , ul = [ class [ Css.Suggestions ] ] 194 | , li = customizedLi 195 | } 196 | 197 | 198 | onSubmitAutocomplete : KeyCode -> Maybe Chatter -> Maybe Msg 199 | onSubmitAutocomplete code maybeId = 200 | if code == 38 || code == 40 then 201 | -- Up or Down 202 | Nothing 203 | else if code == 13 || code == 9 then 204 | -- Enter or Tab 205 | Maybe.map SelectPerson maybeId 206 | else 207 | Just <| Reset False 208 | 209 | 210 | view : UserMessage -> Html Msg 211 | view model = 212 | let 213 | autocompleter = 214 | if model.showSuggestions then 215 | viewAutocompleter model 216 | else 217 | text "" 218 | in 219 | div 220 | [ class [ Css.ChatInterface ] 221 | ] 222 | [ div 223 | [ class [ Css.TextareaContain ] 224 | , preventTab 225 | ] 226 | [ viewChatbox model.content 227 | , autocompleter 228 | ] 229 | , viewChatButtons 230 | ] 231 | 232 | 233 | extractMention : String -> String 234 | extractMention content = 235 | let 236 | regex = 237 | Regex.regex "@(\\S+)$" 238 | |> Regex.caseInsensitive 239 | in 240 | Regex.find (Regex.AtMost 1) regex content 241 | |> List.head 242 | |> Maybe.map .match 243 | |> Maybe.map (String.dropLeft 1) 244 | |> Maybe.withDefault "" 245 | 246 | 247 | viewAutocompleter : UserMessage -> Html Msg 248 | viewAutocompleter model = 249 | Html.map SetAutocompleteState (Autocomplete.view viewConfig 5 model.autoState (acceptablePeople (extractMention model.content) model.chatters)) 250 | 251 | 252 | viewChatbox : String -> Html Msg 253 | viewChatbox content = 254 | textarea 255 | [ placeholder "Send a message" 256 | , onEnter SubmitUserMessage 257 | , preventDefaultOnEnter 258 | , onInput UserMessageChanged 259 | , value content 260 | ] 261 | [] 262 | 263 | 264 | viewChatButtons : Html Msg 265 | viewChatButtons = 266 | div 267 | [ class [ Css.ButtonsContainer ] 268 | ] 269 | [ button 270 | [ class [ Css.Submit ] 271 | , onClick SubmitUserMessage 272 | , tabindex -1 273 | ] 274 | [ text "Chat" ] 275 | ] 276 | 277 | 278 | respondToPing : String -> Message -> Cmd Msg 279 | respondToPing sendUrl message = 280 | case message of 281 | Ping content -> 282 | WebSocket.send sendUrl <| "PONG " ++ content 283 | 284 | _ -> 285 | Cmd.none 286 | 287 | 288 | onEnter : Msg -> Attribute Msg 289 | onEnter msg = 290 | onKey 13 { stopPropagation = False, preventDefault = False } msg 291 | 292 | 293 | preventTab : Attribute Msg 294 | preventTab = 295 | let 296 | options = 297 | { stopPropagation = False, preventDefault = True } 298 | 299 | filterKey code = 300 | if code == 9 then 301 | Ok code 302 | else 303 | Err "ignored input" 304 | 305 | decoder = 306 | keyCode 307 | |> JD.andThen (filterKey >> JDE.fromResult) 308 | |> JD.map (always NoOp) 309 | in 310 | onWithOptions "keydown" options decoder 311 | 312 | 313 | onKey : KeyCode -> Html.Events.Options -> Msg -> Attribute Msg 314 | onKey key options msg = 315 | let 316 | isKey code = 317 | if code == key then 318 | msg 319 | else 320 | NoOp 321 | in 322 | onWithOptions 323 | "keydown" 324 | options 325 | (JD.map isKey keyCode) 326 | 327 | 328 | {-| A workaround to the bug in `elm-lang/virtual-dom` where capturing 329 | the enter button does not work. So two events are used for this: `keydown` and `keypress`. 330 | -} 331 | preventDefaultOnEnter : Attribute Msg 332 | preventDefaultOnEnter = 333 | keyCode 334 | |> JD.andThen 335 | (\code -> 336 | if code == 13 || code == 9 then 337 | JD.succeed <| code 338 | else 339 | JD.fail "ignore" 340 | ) 341 | |> JD.map (always NoOp) 342 | |> onWithOptions "keypress" 343 | { stopPropagation = False, preventDefault = True } 344 | -------------------------------------------------------------------------------- /src/Twitch/Chat/Types.elm: -------------------------------------------------------------------------------- 1 | module Twitch.Chat.Types exposing (..) 2 | 3 | {-| The `Message`s we process from IRC. Right now, only PRIVMSG and PING are 4 | parsed. JOINs, SERVERMSG and JTV messages are not processed to. 5 | -} 6 | 7 | 8 | type Message 9 | = PrivateMessage (List Tag) User Channel String 10 | | ActionMessage (List Tag) User Channel String 11 | | Resubscription (List Tag) Channel (Maybe String) 12 | | Subscription Channel String 13 | | Ping String 14 | | SystemMessage String 15 | | Ignored 16 | 17 | 18 | type Tag 19 | = Badges (List Badge) 20 | | BitsTag Int 21 | | Color (Maybe String) 22 | | DisplayName (Maybe String) 23 | | Emotes (List Emote) 24 | | Id String 25 | | ModTag Bool 26 | | RoomId Int 27 | | SubTag Bool 28 | | TurboTag Bool 29 | | UserId Int 30 | | UserType (Maybe String) 31 | | System String 32 | | None 33 | 34 | 35 | type Badge 36 | = Broadcaster 37 | | Staff 38 | | Admin 39 | | GlobalMod 40 | | Moderator 41 | | Turbo 42 | | Subscriber Int 43 | | Bits Int 44 | | Prime 45 | 46 | 47 | type alias Channel = 48 | String 49 | 50 | 51 | type alias User = 52 | String 53 | 54 | 55 | type alias Command = 56 | String 57 | 58 | 59 | type alias Mode = 60 | String 61 | 62 | 63 | type alias Emote = 64 | { id : Int 65 | , begin : Int 66 | , end : Int 67 | } 68 | -------------------------------------------------------------------------------- /src/Twitch/Request.elm: -------------------------------------------------------------------------------- 1 | module Twitch.Request exposing (..) 2 | 3 | import Http 4 | import Json.Decode exposing (Decoder) 5 | import Jsonp 6 | import Task exposing (Task) 7 | 8 | 9 | clientId : String 10 | clientId = 11 | "tgvu4jvazvcibhhxrtelm674k8z8tvj" 12 | 13 | 14 | attempt : Decoder value -> String -> Task Http.Error value 15 | attempt decoder url = 16 | makeUrl url [ ( "client_id", clientId ) ] 17 | |> Jsonp.get decoder 18 | 19 | 20 | makeUrl : String -> List ( String, String ) -> String 21 | makeUrl baseUrl args = 22 | case args of 23 | [] -> 24 | baseUrl 25 | 26 | _ -> 27 | baseUrl ++ "?" ++ String.join "&" (List.map queryPair args) 28 | 29 | 30 | queryPair : ( String, String ) -> String 31 | queryPair ( key, value ) = 32 | queryEscape key ++ "=" ++ queryEscape value 33 | 34 | 35 | queryEscape : String -> String 36 | queryEscape string = 37 | String.join "+" (String.split "%20" (Http.encodeUri string)) 38 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 |