├── .gitignore ├── LICENSE.md ├── Makefile ├── README.md ├── elm-package.json ├── icon.png ├── icon.svg ├── index.html ├── site.css ├── src ├── Irc.elm ├── Main.elm ├── Model.elm ├── Ports.elm ├── Update.elm └── View.elm └── wsproxy ├── .gitignore ├── index.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | main.js 2 | elm-stuff/ 3 | elm.js 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Erik Price 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 | 23 | --- 24 | 25 | icon.svg and icon.png are derivative works containing a Font Awesome glyph. 26 | 27 | * Font Awesome 4.0.2 by @davegandy - http://fontawesome.io - @fontawesome 28 | * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) 29 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ELM_FLAGS ?= --warn 2 | 3 | all: build 4 | 5 | debug: ELM_FLAGS += --debug 6 | debug: clean build 7 | 8 | build: 9 | elm-make src/Main.elm --output main.js $(ELM_FLAGS) 10 | 11 | clean: 12 | rm -r elm-stuff/build-artifacts/*/user/ 13 | 14 | watch: 15 | ls src/**.elm | entr make all 16 | 17 | server: 18 | python3 -m http.server 19 | 20 | proxy: 21 | cd wsproxy; npm install; node index.js 22 | 23 | # Spin up everything 24 | dev: 25 | make -j watch server proxy 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rain 2 | 3 | ☔ 4 | 5 | Minimal IRC client in elm. 6 | 7 | ![screenshot](http://i.imgur.com/PwdHbsl.png) 8 | 9 | build dependencies: 10 | 11 | - [elm](https://guide.elm-lang.org/install.html) 12 | - [node](https://nodejs.org/en/download/) - for `wsproxy` 13 | - [entr](https://github.com/clibs/entr) - for `make watch` 14 | 15 | ``` 16 | # Install dependencies, build, and run: 17 | make all dev 18 | 19 | # WebSocket proxy: 20 | ws://localhost:6676 21 | 22 | # Frontend: 23 | http://localhost:8000 24 | ``` 25 | -------------------------------------------------------------------------------- /elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "helpful summary of your project, less than 80 characters", 4 | "repository": "https://github.com/user/project.git", 5 | "license": "MIT", 6 | "source-directories": [ 7 | "src" 8 | ], 9 | "exposed-modules": [], 10 | "dependencies": { 11 | "elm-community/dict-extra": "1.5.0 <= v < 2.0.0", 12 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 13 | "elm-lang/dom": "1.1.1 <= v < 2.0.0", 14 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 15 | "elm-lang/http": "1.0.0 <= v < 2.0.0", 16 | "elm-lang/websocket": "1.0.2 <= v < 2.0.0", 17 | "etaque/elm-form": "2.0.0 <= v < 3.0.0", 18 | "mgold/elm-date-format": "1.3.0 <= v < 2.0.0" 19 | }, 20 | "elm-version": "0.18.0 <= v < 0.19.0" 21 | } 22 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik/rain/dcdbe2cc1be4c10196667447b0ca77cb63e083c8/icon.png -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 74 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Rain 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 136 | -------------------------------------------------------------------------------- /site.css: -------------------------------------------------------------------------------- 1 | * { 2 | line-height: 1.5; 3 | font-family: Avenir, Helvetica Neue, Helvetica, sans-serif; 4 | font-size: 16px; 5 | color: #222; 6 | } 7 | 8 | html, body { 9 | height: 100%; 10 | margin: 0; 11 | } 12 | 13 | a, a:visited { 14 | cursor: pointer; 15 | text-decoration: none; 16 | color: #0000cc; 17 | } 18 | 19 | h1 { 20 | font-weight: normal; 21 | font-size: 1.75em; 22 | margin: 8px 0; 23 | padding: 0; 24 | } 25 | 26 | hr { 27 | opacity: 0; 28 | } 29 | 30 | .message-nick { 31 | font-weight: bold; 32 | } 33 | 34 | .message-nick-self { 35 | font-style: italic; 36 | } 37 | 38 | .highlight { 39 | background-color: #ffffdd; 40 | } 41 | 42 | .action { 43 | font-style: italic; 44 | } 45 | 46 | .buffer-list-item:hover { 47 | color: #fff; 48 | background-color: #222; 49 | } 50 | 51 | .unread { 52 | font-weight: 900; 53 | } 54 | 55 | .current-buffer::after { 56 | content: " •"; 57 | } 58 | 59 | .clickable { 60 | cursor: pointer; 61 | } 62 | 63 | .timestamp { 64 | float: right; 65 | color: #a0a0a0; 66 | font-size: 12px; 67 | } 68 | 69 | .copy-text { 70 | font-size: 0; 71 | color: rgba(0,0,0,0); 72 | } 73 | 74 | .day-group-head { 75 | font-size: 12px; 76 | text-align: center; 77 | margin: 0 auto; 78 | width: 100%; 79 | font-weight: bold; 80 | padding: 1em 0; 81 | } 82 | 83 | .group { 84 | padding-bottom: .5ch; 85 | } 86 | 87 | .group-messages { 88 | border: rgba(0,0,0,.05) solid; 89 | border-width: 0 0 0 .5ch; 90 | padding-left: .5ch; 91 | display: flex; 92 | flex-direction: column; 93 | flex: 1; 94 | word-wrap: break-word; 95 | } 96 | 97 | .command-name { 98 | width: 250px; 99 | font-family: Monaco, monospace; 100 | display: inline-block; 101 | } 102 | 103 | .command-desc { 104 | display: inline-block; 105 | } 106 | 107 | #buffer-header { 108 | position: absolute; 109 | width: 100%; 110 | } 111 | 112 | #buffer-topic { 113 | text-overflow: ellipsis; 114 | overflow: hidden; 115 | white-space: nowrap; 116 | font-style: oblique; 117 | background: white; 118 | } 119 | 120 | #buffer-topic:hover { 121 | white-space: normal; 122 | overflow: auto; 123 | margin-bottom: 0.5em; 124 | } 125 | 126 | #input-line { 127 | outline: none; 128 | border-radius: 5px; 129 | border: 1px solid rgba(0,0,0,.15); 130 | } 131 | 132 | .form-row label { 133 | display: block; 134 | padding: 1ch 0; 135 | } 136 | 137 | .form-row { 138 | display: block; 139 | width: 100%; 140 | } 141 | 142 | ul { 143 | padding: 0 0 0 2ch; 144 | list-style-type: none; 145 | } 146 | 147 | #container { 148 | display: flex; 149 | flex-direction: row; 150 | max-width: 960px; 151 | margin: 0 auto; 152 | } 153 | 154 | #buffer-list { 155 | width: 150px; 156 | text-overflow: ellipsis; 157 | height: 100vh; 158 | overflow-y: auto; 159 | margin: 0; 160 | } 161 | 162 | #buffer-view { 163 | display: flex; 164 | min-width: 25ch; 165 | margin-left: 2ch; 166 | margin-right: 2ch; 167 | flex-direction: column; 168 | height: 100vh; 169 | flex: 1; 170 | position: relative; 171 | } 172 | 173 | #buffer-messages { 174 | flex: 1 1 auto; 175 | overflow-y: scroll; 176 | overflow-x: hidden; 177 | margin: 1ch 0; 178 | display: flex; 179 | flex-direction: column; 180 | margin-top: 100px; 181 | } 182 | 183 | #buffer-footer { 184 | padding-bottom: 1ch; 185 | } 186 | 187 | .flex-fixed { 188 | flex: 0 0 auto; 189 | } 190 | 191 | input { 192 | padding: 1.2ch; 193 | width: 100%; 194 | box-sizing: border-box; 195 | } 196 | -------------------------------------------------------------------------------- /src/Irc.elm: -------------------------------------------------------------------------------- 1 | module Irc exposing (..) 2 | 3 | import Date 4 | import Model 5 | import Regex exposing (HowMany(All)) 6 | import Time 7 | 8 | 9 | type alias ParsedMessage = 10 | { raw : String 11 | , time : Maybe Time.Time 12 | , user : Model.UserInfo 13 | , command : String 14 | , params : List String 15 | } 16 | 17 | 18 | parseTimeTag : String -> Maybe Time.Time 19 | parseTimeTag tags = 20 | tags 21 | |> String.split ";" 22 | |> List.filter (String.startsWith "time=") 23 | |> List.map (String.dropLeft 5) 24 | |> List.head 25 | |> Maybe.andThen (Date.fromString >> Result.toMaybe) 26 | |> Maybe.map Date.toTime 27 | 28 | 29 | parsePrefix : String -> Model.UserInfo 30 | parsePrefix prefix = 31 | let 32 | ( nick, rest ) = 33 | case String.split "!" prefix of 34 | [ nick, rest ] -> 35 | ( nick, rest ) 36 | 37 | _ -> 38 | ( "", prefix ) 39 | 40 | ( real, host ) = 41 | case String.split "@" rest of 42 | [ real, host ] -> 43 | ( real, host ) 44 | 45 | _ -> 46 | ( "", rest ) 47 | in 48 | { isServer = nick == "" 49 | , nick = nick 50 | , host = host 51 | , real = real 52 | } 53 | 54 | 55 | splitMessage : String -> Maybe ParsedMessage 56 | splitMessage line = 57 | let 58 | optional re = 59 | String.concat [ "(?:", re, ")?" ] 60 | 61 | -- FIXME: this doesn't support multiple tags for now. 62 | tag = 63 | optional "@(\\w+=\\S+)\\s+" 64 | 65 | prefix = 66 | optional ":(\\S+)\\s+" 67 | 68 | command = 69 | "(\\w+)\\s+" 70 | 71 | params = 72 | optional "([^:]+)\\s*" 73 | 74 | lastParam = 75 | optional ":(.*?)" 76 | 77 | messageRegex = 78 | [ "^", tag, prefix, command, params, lastParam, "$" ] 79 | |> String.concat 80 | |> Regex.regex 81 | 82 | matches = 83 | Regex.find Regex.All messageRegex line 84 | |> List.map .submatches 85 | in 86 | case matches of 87 | [ [ tags, prefix, Just command, params, lastParam ] ] -> 88 | let 89 | finalParam = 90 | lastParam 91 | |> Maybe.map (\x -> [ x ]) 92 | |> Maybe.withDefault [] 93 | 94 | splitParams = 95 | params 96 | |> Maybe.map String.words 97 | |> Maybe.withDefault [] 98 | in 99 | Just 100 | { raw = line 101 | , time = 102 | tags 103 | |> Maybe.andThen parseTimeTag 104 | , user = 105 | prefix 106 | |> Maybe.withDefault "" 107 | |> parsePrefix 108 | , command = command 109 | , params = List.append splitParams finalParam 110 | } 111 | 112 | _ -> 113 | let 114 | _ = 115 | Debug.log "Failed to parse message" line 116 | in 117 | Nothing 118 | -------------------------------------------------------------------------------- /src/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (..) 2 | 3 | import Dict 4 | import Html exposing (..) 5 | import Model exposing (Model, initialModel, getServer) 6 | import Ports 7 | import Time 8 | import Update exposing (update, Msg(..), ServerMsg(..)) 9 | import View exposing (view) 10 | import WebSocket 11 | 12 | 13 | main : Program Never Model Msg 14 | main = 15 | Html.program 16 | { init = init 17 | , view = view 18 | , update = update 19 | , subscriptions = subscriptions 20 | } 21 | 22 | 23 | init : ( Model, Cmd Msg ) 24 | init = 25 | ( initialModel, Cmd.none ) 26 | 27 | 28 | subscriptions : Model -> Sub Msg 29 | subscriptions model = 30 | let 31 | handleLines serverName lines = 32 | lines 33 | |> String.trim 34 | |> String.lines 35 | |> List.filter (not << String.isEmpty) 36 | |> List.map (\line -> ModifyServer serverName (ReceiveRawLine line)) 37 | |> MultiMsg 38 | 39 | -- Establish all of our open websocket connections 40 | recvWs = 41 | model.servers 42 | |> Dict.values 43 | |> List.map 44 | (\server -> WebSocket.listen server.socket (handleLines server.meta.name)) 45 | 46 | -- Periodically send PINGs to all connected websocket proxies. 47 | pingServers = 48 | model.servers 49 | |> Dict.keys 50 | |> List.map 51 | (\serverName -> 52 | Time.every (60 * Time.second) 53 | (\_ -> ModifyServer serverName (SendRawLine "*PING")) 54 | ) 55 | in 56 | Sub.batch 57 | ([ Ports.addSavedServer AddServer 58 | , Time.every Time.second Tick 59 | , Ports.receiveScrollback 60 | (\( serverName, chan, line ) -> 61 | case getServer model serverName of 62 | Just server -> 63 | ModifyServer serverName (AddLine chan line) 64 | 65 | Nothing -> 66 | Noop 67 | ) 68 | ] 69 | ++ recvWs 70 | ++ pingServers 71 | ) 72 | -------------------------------------------------------------------------------- /src/Model.elm: -------------------------------------------------------------------------------- 1 | module Model exposing (..) 2 | 3 | import Date exposing (Date) 4 | import Dict exposing (Dict) 5 | import Form exposing (Form) 6 | import Form.Validate as Validate exposing (..) 7 | import Time exposing (Time) 8 | 9 | 10 | type alias ServerName = 11 | String 12 | 13 | 14 | type alias BufferName = 15 | String 16 | 17 | 18 | type alias ServerBuffer = 19 | ( ServerName, BufferName ) 20 | 21 | 22 | serverBufferName : BufferName 23 | serverBufferName = 24 | ":server" 25 | 26 | 27 | type alias ServerMetadata = 28 | { proxyHost : String 29 | , proxyPass : Maybe String 30 | , server : String 31 | , port_ : String -- TODO: make this an int 32 | , nick : String 33 | , pass : Maybe String 34 | , name : String 35 | , saveScrollback : Bool 36 | } 37 | 38 | 39 | type alias Server = 40 | { socket : String 41 | , meta : ServerMetadata 42 | , pass : Maybe String 43 | , buffers : Dict BufferName Buffer 44 | } 45 | 46 | 47 | type alias Line = 48 | { ts : Time.Time 49 | , nick : String 50 | , message : String 51 | } 52 | 53 | 54 | type alias DayGroup = 55 | { date : Date.Date 56 | , lineGroups : List LineGroup 57 | } 58 | 59 | 60 | type alias LineGroup = 61 | { ts : Time.Time 62 | , nick : String 63 | , messages : List Line 64 | } 65 | 66 | 67 | type alias LineBuffer = 68 | List DayGroup 69 | 70 | 71 | type alias UserInfo = 72 | { nick : String 73 | , host : String 74 | , real : String 75 | , isServer : Bool 76 | } 77 | 78 | 79 | {-| to avoid choking on large channels, we wait to uniquify the user names 80 | until we receive the "end of names list" message from the server. 81 | 82 | UsersLoaded is nick => last message 83 | 84 | -} 85 | type UserList 86 | = UsersLoading (List String) 87 | | UsersLoaded (Dict String Time.Time) 88 | 89 | 90 | type alias Buffer = 91 | { name : String 92 | , users : UserList 93 | , topic : Maybe String 94 | , buffer : LineBuffer 95 | , lastChecked : Time.Time 96 | , isServer : Bool 97 | } 98 | 99 | 100 | type alias Model = 101 | { servers : Dict ServerName Server 102 | , current : Maybe ServerBuffer 103 | , inputLine : String 104 | , currentTime : Time 105 | , newServerForm : Maybe (Form () ServerMetadata) 106 | } 107 | 108 | 109 | newServerValidation : Validation () ServerMetadata 110 | newServerValidation = 111 | map8 ServerMetadata 112 | (field "proxyHost" string) 113 | (field "proxyPass" <| maybe string) 114 | (field "server" string) 115 | (field "port_" string) 116 | (field "nick" string) 117 | (field "pass" <| maybe string) 118 | (field "name" string) 119 | (field "saveScrollback" bool) 120 | 121 | 122 | initialModel : Model 123 | initialModel = 124 | { servers = Dict.empty 125 | , current = Nothing 126 | , inputLine = "" 127 | , currentTime = 0 128 | , newServerForm = Nothing 129 | } 130 | 131 | 132 | newBuffer : String -> Buffer 133 | newBuffer name = 134 | { name = name 135 | , users = UsersLoading [] 136 | , topic = Nothing 137 | , buffer = [] 138 | , lastChecked = 0 139 | , isServer = name == serverBufferName 140 | } 141 | 142 | 143 | setNickTimestamp : String -> Time.Time -> Buffer -> Buffer 144 | setNickTimestamp nick ts buf = 145 | case buf.users of 146 | UsersLoading list -> 147 | buf 148 | 149 | UsersLoaded set -> 150 | { buf | users = UsersLoaded (Dict.insert nick ts set) } 151 | 152 | 153 | addNicks : List String -> Buffer -> Buffer 154 | addNicks nicks buf = 155 | case buf.users of 156 | UsersLoading list -> 157 | { buf | users = UsersLoading (list ++ nicks) } 158 | 159 | UsersLoaded set -> 160 | let 161 | users = 162 | nicks 163 | |> List.map (\nick -> ( nick, 0 )) 164 | |> Dict.fromList 165 | |> Dict.union set 166 | in 167 | { buf | users = UsersLoaded users } 168 | 169 | 170 | removeNick : String -> Buffer -> Buffer 171 | removeNick nick buf = 172 | case buf.users of 173 | UsersLoading list -> 174 | { buf | users = UsersLoading (List.filter (\x -> not (x == nick)) list) } 175 | 176 | UsersLoaded set -> 177 | { buf | users = UsersLoaded (Dict.remove nick set) } 178 | 179 | 180 | getServer : Model -> ServerName -> Maybe Server 181 | getServer model serverName = 182 | Dict.get serverName model.servers 183 | 184 | 185 | setBuffer : Server -> Buffer -> Model -> Model 186 | setBuffer server buf model = 187 | let 188 | name_ = 189 | String.toLower buf.name 190 | 191 | server_ = 192 | let 193 | buffers = 194 | Dict.insert name_ buf server.buffers 195 | in 196 | { server | buffers = buffers } 197 | in 198 | { model | servers = Dict.insert server.meta.name server_ model.servers } 199 | 200 | 201 | getBuffer : Server -> BufferName -> Maybe Buffer 202 | getBuffer server bufferName = 203 | Dict.get (String.toLower bufferName) server.buffers 204 | 205 | 206 | getServerBuffer : Model -> ServerBuffer -> Maybe ( Server, Buffer ) 207 | getServerBuffer model ( sn, bn ) = 208 | let 209 | server = 210 | getServer model sn 211 | 212 | buffer = 213 | server 214 | |> Maybe.andThen (\server -> getBuffer server bn) 215 | in 216 | Maybe.map2 (,) server buffer 217 | 218 | 219 | getOrCreateBuffer : Server -> BufferName -> Buffer 220 | getOrCreateBuffer server bufferName = 221 | getBuffer server bufferName 222 | |> Maybe.withDefault (newBuffer bufferName) 223 | 224 | 225 | getActive : Model -> Maybe ( Server, Buffer ) 226 | getActive model = 227 | model.current |> Maybe.andThen (getServerBuffer model) 228 | 229 | 230 | getActiveBuffer : Model -> Maybe Buffer 231 | getActiveBuffer model = 232 | getActive model |> Maybe.map Tuple.second 233 | 234 | 235 | getActiveServer : Model -> Maybe Server 236 | getActiveServer model = 237 | getActive model |> Maybe.map Tuple.first 238 | 239 | 240 | appendLine : List DayGroup -> Line -> List DayGroup 241 | appendLine dayGroups line = 242 | let 243 | msgDate = 244 | Date.fromTime line.ts 245 | 246 | dateTuple dt = 247 | ( dt |> Date.year, dt |> Date.month, dt |> Date.day ) 248 | in 249 | case dayGroups of 250 | [] -> 251 | [ { date = msgDate, lineGroups = appendToLineGroup [] line } ] 252 | 253 | hd :: rest -> 254 | if (dateTuple hd.date) == (dateTuple msgDate) then 255 | { hd | lineGroups = appendToLineGroup hd.lineGroups line } :: rest 256 | else 257 | [ { date = msgDate, lineGroups = appendToLineGroup [] line }, hd ] ++ rest 258 | 259 | 260 | appendToLineGroup : List LineGroup -> Line -> List LineGroup 261 | appendToLineGroup groups line = 262 | case groups of 263 | [] -> 264 | [ { ts = line.ts 265 | , nick = line.nick 266 | , messages = [ line ] 267 | } 268 | ] 269 | 270 | hd :: rest -> 271 | if hd.nick == line.nick then 272 | { hd | messages = line :: hd.messages } :: rest 273 | else 274 | List.take 1000 rest 275 | |> List.append 276 | [ { ts = line.ts 277 | , nick = line.nick 278 | , messages = [ line ] 279 | } 280 | , hd 281 | ] 282 | -------------------------------------------------------------------------------- /src/Ports.elm: -------------------------------------------------------------------------------- 1 | port module Ports exposing (..) 2 | 3 | import Model exposing (BufferName, ServerMetadata, ServerName, Line) 4 | 5 | 6 | -- Input ports 7 | 8 | 9 | port addSavedServer : (ServerMetadata -> msg) -> Sub msg 10 | 11 | 12 | port receiveScrollback : (( ServerName, BufferName, Line ) -> msg) -> Sub msg 13 | 14 | 15 | 16 | -- Output ports 17 | 18 | 19 | port modifyServerStore : ( ServerMetadata, String ) -> Cmd msg 20 | 21 | 22 | port clearScrollback : ( ServerName, BufferName ) -> Cmd msg 23 | 24 | 25 | port requestScrollback : ServerName -> Cmd msg 26 | 27 | 28 | port saveScrollback : ( ServerName, BufferName, Line ) -> Cmd msg 29 | 30 | 31 | port sendNotification : ( String, String ) -> Cmd msg 32 | 33 | 34 | port refreshScrollPosition : Bool -> Cmd msg 35 | -------------------------------------------------------------------------------- /src/Update.elm: -------------------------------------------------------------------------------- 1 | module Update exposing (Msg(..), ServerMsg(..), update, commandDescriptions) 2 | 3 | import Debug 4 | import Dict 5 | import Form exposing (Form) 6 | import Http 7 | import Irc 8 | import Model exposing (..) 9 | import Ports 10 | import Regex 11 | import Time exposing (Time) 12 | import WebSocket 13 | 14 | 15 | {-| Messages that require there to be a valid server specified. 16 | -} 17 | type ServerMsg 18 | = AddLine BufferName Line 19 | | AddScrollback BufferName Line 20 | | ClearBuffer BufferName 21 | | CloseBuffer BufferName 22 | | ConnectIrc 23 | | CreateBuffer BufferName 24 | | DisconnectServer 25 | | RemoveServer 26 | | ReceiveRawLine String 27 | | SelectBuffer BufferName 28 | | SendLine Buffer String 29 | | SendRawLine String 30 | | StoreServer 31 | | TabCompleteLine Buffer 32 | 33 | 34 | type Msg 35 | = ModifyServer ServerName ServerMsg 36 | | AddServer ServerMetadata 37 | | FormMsg Form.Msg 38 | | MultiMsg (List Msg) 39 | | RefreshScroll Bool 40 | | SendNotification String String 41 | | ShowAddServerForm 42 | | Tick Time 43 | | TypeLine String 44 | | Noop 45 | 46 | 47 | commandDescriptions : List ( String, String ) 48 | commandDescriptions = 49 | [ ( "/server save", "save the configuration for the current server to localstorage" ) 50 | , ( "/server delete", "remove the current configuration from localstorage" ) 51 | , ( "/server disconnect", "close the IRC connection to the current server" ) 52 | , ( "/join #channel", "join and switch to #channel" ) 53 | , ( "/part", "leave the current channel" ) 54 | , ( "/part #channel", "leave #channel" ) 55 | , ( "/close", "close the current buffer window" ) 56 | , ( "/clear", "clear out the contents of the current buffer window" ) 57 | , ( "/ping nick", "send CTCP PING to nick" ) 58 | , ( "/names", "list the (first 100) users in the current channel" ) 59 | , ( "/nick newNick", "changes current nick to newNick" ) 60 | , ( "/ns", "shorthand to message NickServ" ) 61 | , ( "/cs", "shorthand to message ChanServ" ) 62 | , ( "/query nick", "open a direct message buffer window with nick" ) 63 | , ( "/quote something", "send \"something\" to the server directly" ) 64 | , ( "/help", "list available slash commands." ) 65 | ] 66 | 67 | 68 | modifyServer : Server -> ServerMsg -> Msg 69 | modifyServer server msg = 70 | ModifyServer server.meta.name msg 71 | 72 | 73 | updateServer : Server -> ServerMsg -> Model -> ( Model, Cmd Msg ) 74 | updateServer server msg model = 75 | case msg of 76 | AddLine bufferName line -> 77 | let 78 | buf = 79 | getOrCreateBuffer server bufferName 80 | |> (\b -> { b | buffer = appendLine b.buffer line }) 81 | 82 | model_ = 83 | setBuffer server buf model 84 | 85 | nickRegexp = 86 | Regex.regex ("\\b" ++ server.meta.nick ++ "\\b") 87 | 88 | matchesNick = 89 | Regex.contains nickRegexp line.message 90 | 91 | isDirectMessage = 92 | (server.meta.nick /= line.nick) 93 | && not (targetIsChannel bufferName) 94 | 95 | body = 96 | String.concat [ "<", line.nick, ">: ", line.message ] 97 | 98 | cmd = 99 | if not buf.isServer && (matchesNick || isDirectMessage) then 100 | SendNotification buf.name body 101 | else 102 | Noop 103 | in 104 | update cmd model_ 105 | 106 | AddScrollback bufferName line -> 107 | model ! [ Ports.saveScrollback ( server.meta.name, bufferName, line ) ] 108 | 109 | ClearBuffer bufferName -> 110 | case getBuffer server bufferName of 111 | Just buffer -> 112 | let 113 | buffer_ = 114 | { buffer | buffer = [] } 115 | in 116 | setBuffer server buffer_ model 117 | ! [ Ports.clearScrollback ( server.meta.name, bufferName ) ] 118 | 119 | Nothing -> 120 | Debug.crash "bad buffer name given?" bufferName 121 | 122 | CloseBuffer bufferName -> 123 | let 124 | current = 125 | if model.current == Just ( server.meta.name, bufferName ) then 126 | Nothing 127 | else 128 | model.current 129 | 130 | server_ = 131 | { server | buffers = Dict.remove (String.toLower bufferName) server.buffers } 132 | 133 | model_ = 134 | { model 135 | | current = current 136 | , servers = Dict.insert server.meta.name server_ model.servers 137 | } 138 | in 139 | model_ ! [] 140 | 141 | ConnectIrc -> 142 | let 143 | passMsg = 144 | server.pass 145 | |> Maybe.map (\pass -> "PASS " ++ pass) 146 | |> Maybe.withDefault "" 147 | 148 | lines = 149 | [ passMsg 150 | , "CAP REQ znc.in/server-time-iso" 151 | , "CAP REQ server-time" 152 | , "CAP END" 153 | , "NICK " ++ server.meta.nick 154 | , "USER " ++ server.meta.nick ++ " * * :" ++ server.meta.nick 155 | ] 156 | in 157 | lines 158 | |> List.map (\line -> modifyServer server (SendRawLine line)) 159 | |> flip batchMessage model 160 | 161 | CreateBuffer bufferName -> 162 | let 163 | buffer = 164 | Model.newBuffer bufferName 165 | 166 | model_ = 167 | setBuffer server buffer model 168 | in 169 | model_ ! [] 170 | 171 | DisconnectServer -> 172 | { model | servers = Dict.remove server.meta.name model.servers } ! [] 173 | 174 | RemoveServer -> 175 | ( model, Ports.modifyServerStore ( server.meta, "REMOVE" ) ) 176 | 177 | ReceiveRawLine line -> 178 | let 179 | -- Grab the time out of the message or default to current time. 180 | getTs msg = 181 | msg.time |> Maybe.withDefault model.currentTime 182 | in 183 | -- There are a couple non standard commands the proxy 184 | -- server can send. 185 | case line of 186 | "*CONNECTED" -> 187 | update (modifyServer server ConnectIrc) model 188 | 189 | "*PONG" -> 190 | model ! [] 191 | 192 | _ -> 193 | Irc.splitMessage line 194 | |> Maybe.map (\msg -> handleCommand server (getTs msg) msg model) 195 | |> Maybe.withDefault ( model, Cmd.none ) 196 | 197 | SelectBuffer bufferName -> 198 | let 199 | buffer = 200 | getOrCreateBuffer server bufferName 201 | |> (\chan -> { chan | lastChecked = model.currentTime }) 202 | 203 | model_ = 204 | setBuffer server buffer model 205 | |> (\model -> 206 | { model 207 | | current = Just ( server.meta.name, bufferName ) 208 | , newServerForm = Nothing 209 | } 210 | ) 211 | in 212 | update (RefreshScroll True) model_ 213 | 214 | StoreServer -> 215 | ( model, Ports.modifyServerStore ( server.meta, "STORE" ) ) 216 | 217 | SendLine buf line -> 218 | if model.inputLine == "" then 219 | model ! [] 220 | else 221 | { model | inputLine = "" } 222 | |> batchMessage (sendLine server buf line model) 223 | |> andThen (RefreshScroll True) 224 | 225 | SendRawLine line -> 226 | ( model, WebSocket.send server.socket line ) 227 | 228 | TabCompleteLine buffer -> 229 | let 230 | words = 231 | String.split " " model.inputLine 232 | 233 | lastWord = 234 | -- We want to tab complete "something|", not "something |". 235 | case List.reverse words |> List.head of 236 | Just "" -> 237 | Nothing 238 | 239 | word -> 240 | word 241 | 242 | -- If this gets called while nicks are still 243 | -- loading, just sort alphabetically, 244 | -- otherwise choose the most recent user. 245 | completion = 246 | lastWord 247 | |> Maybe.map 248 | (\word -> 249 | case buffer.users of 250 | UsersLoading list -> 251 | List.filter (String.startsWith word) list 252 | |> List.sort 253 | 254 | UsersLoaded set -> 255 | set 256 | |> Dict.filter (\nick _ -> String.startsWith word nick) 257 | |> Dict.toList 258 | |> List.sortBy (\( nick, lastMessage ) -> -lastMessage) 259 | |> List.map Tuple.first 260 | ) 261 | -- Don't complete our own nick 262 | |> Maybe.map (List.filter (\nick -> not (nick == server.meta.nick))) 263 | -- And just take the first. 264 | |> Maybe.andThen List.head 265 | 266 | inputLine = 267 | case completion of 268 | Just completion -> 269 | case words of 270 | -- if we're tab completing the first word, 271 | -- assume this is a nick highlight. 272 | [ nick ] -> 273 | completion ++ ": " 274 | 275 | words -> 276 | [ completion ] 277 | |> List.append (List.take (List.length words - 1) words) 278 | |> String.join " " 279 | 280 | Nothing -> 281 | model.inputLine 282 | in 283 | { model | inputLine = inputLine } ! [] 284 | 285 | 286 | update : Msg -> Model -> ( Model, Cmd Msg ) 287 | update msg model = 288 | case msg of 289 | ModifyServer serverName msg -> 290 | case getServer model serverName of 291 | Just server -> 292 | updateServer server msg model 293 | 294 | Nothing -> 295 | Debug.crash "unknown server given" ( serverName, msg ) 296 | 297 | AddServer meta -> 298 | let 299 | -- We send meta.name to differentiate the query 300 | -- strings so elm opens up multiple websockets 301 | queryString = 302 | [ ( "host", meta.server ) 303 | , ( "port", meta.port_ ) 304 | , ( "proxyPass", meta.proxyPass |> Maybe.withDefault "" ) 305 | , ( "name", meta.name ) 306 | ] 307 | |> List.map (\( k, v ) -> k ++ "=" ++ Http.encodeUri v) 308 | |> String.join "&" 309 | 310 | socketUrl = 311 | String.concat [ meta.proxyHost, "?", queryString ] 312 | 313 | server = 314 | { socket = socketUrl 315 | , pass = meta.pass 316 | , meta = meta 317 | , buffers = Dict.empty 318 | } 319 | 320 | servers_ = 321 | model.servers 322 | |> Dict.insert meta.name server 323 | in 324 | { model | servers = servers_ } ! [] 325 | 326 | TypeLine str -> 327 | { model | inputLine = str } ! [] 328 | 329 | RefreshScroll force -> 330 | ( model, Ports.refreshScrollPosition force ) 331 | 332 | SendNotification title message -> 333 | ( model, Ports.sendNotification ( title, message ) ) 334 | 335 | Tick time -> 336 | let 337 | model_ = 338 | case getActive model of 339 | Just ( server, buf ) -> 340 | setBuffer server 341 | { buf | lastChecked = time } 342 | model 343 | 344 | Nothing -> 345 | model 346 | in 347 | ( { model_ | currentTime = time }, Cmd.none ) 348 | 349 | FormMsg formMsg -> 350 | let 351 | form_ = 352 | model.newServerForm 353 | 354 | serverMeta = 355 | form_ 356 | |> Maybe.andThen Form.getOutput 357 | in 358 | case ( formMsg, serverMeta ) of 359 | ( Form.Submit, Just serverMeta ) -> 360 | update (AddServer serverMeta) { model | newServerForm = Nothing } 361 | 362 | _ -> 363 | -- FIXME: gnarly. 364 | { model 365 | | newServerForm = 366 | form_ 367 | |> Maybe.map (Form.update newServerValidation formMsg) 368 | } 369 | ! [] 370 | 371 | ShowAddServerForm -> 372 | let 373 | form = 374 | Form.initial [] newServerValidation 375 | in 376 | { model | newServerForm = Just form } ! [] 377 | 378 | MultiMsg msgs -> 379 | batchMessage msgs model 380 | 381 | Noop -> 382 | ( model, Cmd.none ) 383 | 384 | 385 | {-| Nabbed from 386 | -} 387 | andThen : Msg -> ( Model, Cmd Msg ) -> ( Model, Cmd Msg ) 388 | andThen msg ( model, cmd ) = 389 | let 390 | ( newModel, newCmd ) = 391 | update msg model 392 | in 393 | newModel ! [ cmd, newCmd ] 394 | 395 | 396 | batchMessage : List Msg -> Model -> ( Model, Cmd Msg ) 397 | batchMessage msgs model = 398 | List.foldr andThen ( model, Cmd.none ) msgs 399 | 400 | 401 | targetIsChannel : String -> Bool 402 | targetIsChannel target = 403 | (String.startsWith "#" target) 404 | || (String.startsWith "&" target) 405 | 406 | 407 | handleMessage : Server -> UserInfo -> String -> String -> Time.Time -> Model -> ( Model, Cmd Msg ) 408 | handleMessage server user target message ts model = 409 | let 410 | target_ = 411 | if targetIsChannel target then 412 | target 413 | else 414 | user.nick 415 | 416 | nick = 417 | if user.isServer then 418 | server.meta.name 419 | else 420 | user.nick 421 | 422 | colorCodeRegex = 423 | Regex.regex "\x03\\d{2}(?:,\\d{2})?" 424 | 425 | -- FIXME: for now we just strip out color codes, but this 426 | -- FIXME: should probably be supported. 427 | stripped = 428 | message 429 | |> Regex.replace Regex.All colorCodeRegex (\_ -> "") 430 | 431 | newLine = 432 | { ts = ts, nick = nick, message = stripped } 433 | 434 | newMsg = 435 | AddLine target_ newLine |> modifyServer server 436 | 437 | refreshMsg = 438 | if Just ( server.meta.name, target_ ) == model.current then 439 | RefreshScroll False 440 | else 441 | Noop 442 | 443 | scrollbackMsg = 444 | if not user.isServer && server.meta.saveScrollback then 445 | AddScrollback target_ newLine |> modifyServer server 446 | else 447 | Noop 448 | 449 | model_ = 450 | if not user.isServer then 451 | getBuffer server target 452 | |> Maybe.map (setNickTimestamp nick ts) 453 | |> Maybe.map (\buf -> setBuffer server buf model) 454 | |> Maybe.withDefault model 455 | else 456 | model 457 | in 458 | update (MultiMsg [ newMsg, refreshMsg, scrollbackMsg ]) model_ 459 | 460 | 461 | handleCommand : Server -> Time.Time -> Irc.ParsedMessage -> Model -> ( Model, Cmd Msg ) 462 | handleCommand server ts msg model = 463 | case ( msg.command, msg.params ) of 464 | -- Clean out the buffers when we rejoin. 465 | ( "001", _ ) -> 466 | let 467 | buffers_ = 468 | server.buffers 469 | |> Dict.map (\_ v -> { v | buffer = [] }) 470 | 471 | server_ = 472 | { server | buffers = buffers_ } 473 | 474 | model_ = 475 | model.servers 476 | |> Dict.insert server.meta.name server_ 477 | |> (\servers -> { model | servers = servers }) 478 | in 479 | model_ ! [ Ports.requestScrollback server.meta.name ] 480 | 481 | ( "PING", params ) -> 482 | let 483 | pong = 484 | "PONG " ++ String.concat params 485 | in 486 | updateServer server (SendRawLine pong) model 487 | 488 | ( "JOIN", [ channel ] ) -> 489 | let 490 | weJoined = 491 | server.meta.nick == msg.user.nick 492 | 493 | buffer = 494 | getBuffer server channel 495 | |> Maybe.withDefault (newBuffer channel) 496 | |> addNicks [ msg.user.nick ] 497 | 498 | -- don't report unread messages in the past. 499 | lastChecked = 500 | if weJoined then 501 | model.currentTime 502 | else 503 | buffer.lastChecked 504 | 505 | model_ = 506 | setBuffer server { buffer | lastChecked = lastChecked } model 507 | 508 | current_ = 509 | -- We want to switch to the channel if we haven't 510 | -- joined anything else yet. 511 | if weJoined && model.current == Nothing then 512 | Just ( server.meta.name, buffer.name ) 513 | else 514 | model.current 515 | in 516 | { model_ | current = current_ } ! [] 517 | 518 | ( "PART", channel :: reason ) -> 519 | case getBuffer server channel of 520 | Just buf -> 521 | let 522 | buf_ = 523 | removeNick msg.user.nick buf 524 | 525 | model_ = 526 | setBuffer server buf_ model 527 | 528 | ( current, cmd ) = 529 | if server.meta.nick == msg.user.nick then 530 | ( Nothing, CloseBuffer channel |> modifyServer server ) 531 | else 532 | ( model.current, Noop ) 533 | in 534 | update cmd { model_ | current = current } 535 | 536 | Nothing -> 537 | let 538 | _ = 539 | Debug.log "odd: PART for channel we aren't in." channel 540 | in 541 | ( model, Cmd.none ) 542 | 543 | ( "QUIT", _ ) -> 544 | let 545 | server_ = 546 | server.buffers 547 | |> Dict.map (\_ buf -> removeNick msg.user.nick buf) 548 | |> (\buffers -> { server | buffers = buffers }) 549 | 550 | model_ = 551 | { model | servers = Dict.insert server.meta.name server model.servers } 552 | in 553 | ( model_, Cmd.none ) 554 | 555 | ( "PRIVMSG", [ target, message ] ) -> 556 | handleMessage server msg.user target message ts model 557 | 558 | ( "NOTICE", [ target, message ] ) -> 559 | let 560 | formatCtcp msg = 561 | case String.words msg of 562 | [ "PING", timeString ] -> 563 | let 564 | time = 565 | timeString 566 | |> String.toFloat 567 | |> Result.withDefault 0 568 | 569 | pingTime = 570 | (model.currentTime - time) 571 | |> Time.inSeconds 572 | |> toString 573 | in 574 | "PONG: " ++ pingTime ++ " secs" 575 | 576 | _ -> 577 | "CTCP:" ++ msg 578 | 579 | notice = 580 | if String.startsWith "\x01" message then 581 | formatCtcp (String.split "\x01" message |> String.concat) 582 | else 583 | "NOTICE: " ++ message 584 | in 585 | handleMessage server msg.user target notice ts model 586 | 587 | -- You have been marked as being away 588 | ( "306", _ ) -> 589 | model ! [] 590 | 591 | -- Channel topic 592 | ( "332", [ _, target, topic ] ) -> 593 | let 594 | buf = 595 | getOrCreateBuffer server target 596 | 597 | buf_ = 598 | { buf | topic = Just topic } 599 | in 600 | ( setBuffer server buf_ model, Cmd.none ) 601 | 602 | ( "333", _ ) -> 603 | model ! [] 604 | 605 | -- NAMES list 606 | ( "353", [ _, _, channel, usersString ] ) -> 607 | let 608 | specialChars = 609 | Regex.regex "[%@~\\+]" 610 | 611 | stripSpecial = 612 | Regex.replace Regex.All specialChars (\_ -> "") 613 | 614 | userList = 615 | stripSpecial usersString 616 | |> String.words 617 | 618 | buf = 619 | getOrCreateBuffer server channel 620 | |> addNicks userList 621 | 622 | model_ = 623 | setBuffer server buf model 624 | in 625 | model_ ! [] 626 | 627 | -- END of /NAMES 628 | ( "366", [ _, channel, _ ] ) -> 629 | case getBuffer server channel of 630 | Just buf -> 631 | let 632 | users = 633 | case buf.users of 634 | UsersLoading list -> 635 | list 636 | |> List.map (\nick -> ( nick, 0 )) 637 | |> Dict.fromList 638 | |> UsersLoaded 639 | 640 | set -> 641 | set 642 | 643 | model_ = 644 | setBuffer server { buf | users = users } model 645 | in 646 | model_ ! [] 647 | 648 | _ -> 649 | let 650 | _ = 651 | Debug.log "weird: 366 for unknown channel" channel 652 | in 653 | model ! [] 654 | 655 | ( "NICK", [ nick ] ) -> 656 | let 657 | myNick = 658 | if msg.user.nick == server.meta.nick then 659 | nick 660 | else 661 | server.meta.nick 662 | 663 | serverMeta_ = 664 | server.meta |> (\meta -> { meta | nick = myNick }) 665 | 666 | server_ = 667 | server.buffers 668 | |> Dict.map 669 | (\_ buf -> 670 | removeNick msg.user.nick buf 671 | |> addNicks [ nick ] 672 | ) 673 | |> (\buffers -> 674 | { server 675 | | buffers = buffers 676 | , meta = serverMeta_ 677 | } 678 | ) 679 | 680 | model_ = 681 | { model | servers = Dict.insert server.meta.name server_ model.servers } 682 | in 683 | model_ ! [] 684 | 685 | _ -> 686 | let 687 | msgText = 688 | String.join " " msg.params 689 | 690 | newLine = 691 | { ts = ts 692 | , nick = msg.user.nick 693 | , message = String.join ": " [ msg.command, msgText ] 694 | } 695 | 696 | _ = 697 | Debug.log "unknown msg" msg 698 | in 699 | update 700 | (MultiMsg 701 | [ AddLine serverBufferName newLine |> modifyServer server 702 | , RefreshScroll False 703 | ] 704 | ) 705 | model 706 | 707 | 708 | {-| Handle sending messages to the server (and all the slash commands 709 | and such that could be used) 710 | -} 711 | sendLine : Server -> Buffer -> String -> Model -> List Msg 712 | sendLine server buf line model = 713 | let 714 | privmsg target msg = 715 | let 716 | line = 717 | { ts = model.currentTime 718 | , nick = server.meta.nick 719 | , message = msg 720 | } 721 | 722 | rawLine = 723 | String.join " " [ "PRIVMSG", target, ":" ++ msg ] 724 | in 725 | if buf.isServer then 726 | addErrorMessage "use /quote to send messages directly to the server" 727 | else 728 | [ SendRawLine rawLine |> modifyServer server 729 | , AddLine target line |> modifyServer server 730 | , if server.meta.saveScrollback then 731 | AddScrollback target line |> modifyServer server 732 | else 733 | Noop 734 | ] 735 | 736 | ctcp target command msg = 737 | let 738 | msg_ = 739 | String.concat [ "\x01", command, " ", msg, "\x01" ] 740 | in 741 | privmsg target msg_ 742 | 743 | -- shortened versions of common commands 744 | commandAliases cmd = 745 | Dict.fromList 746 | [ ( "/j", "/join" ) 747 | , ( "/msg", "/privmsg" ) 748 | , ( "/pm", "/privmsg" ) 749 | , ( "/q", "/query" ) 750 | ] 751 | |> Dict.get cmd 752 | |> Maybe.withDefault cmd 753 | 754 | addErrorMessage msg = 755 | let 756 | line = 757 | { ts = model.currentTime 758 | , nick = "*error" 759 | , message = msg 760 | } 761 | in 762 | [ AddLine buf.name line |> modifyServer server ] 763 | 764 | slashCommand cmd params = 765 | case ( String.toLower cmd, params ) of 766 | ( "/join", [ channel ] ) -> 767 | if targetIsChannel channel then 768 | [ SendRawLine ("JOIN " ++ channel) 769 | , SelectBuffer channel 770 | ] 771 | |> List.map (modifyServer server) 772 | else 773 | addErrorMessage "channel names must begin with #" 774 | 775 | ( "/query", [ nick ] ) -> 776 | if targetIsChannel nick then 777 | addErrorMessage "can only initiate queries with users" 778 | else 779 | [ modifyServer server (SelectBuffer nick) ] 780 | 781 | ( "/part", [] ) -> 782 | slashCommand "/part" [ buf.name ] 783 | 784 | ( "/part", [ channel ] ) -> 785 | [ SendRawLine ("PART " ++ channel) |> modifyServer server 786 | , CloseBuffer channel |> modifyServer server 787 | ] 788 | 789 | ( "/close", [] ) -> 790 | if buf.isServer then 791 | addErrorMessage "use `/server disconnect` instead" 792 | else 793 | [ CloseBuffer buf.name |> modifyServer server 794 | , ClearBuffer buf.name |> modifyServer server 795 | ] 796 | 797 | ( "/clear", [] ) -> 798 | [ ClearBuffer buf.name |> modifyServer server ] 799 | 800 | ( "/me", rest ) -> 801 | let 802 | msg = 803 | String.join " " rest 804 | in 805 | ctcp buf.name "ACTION" msg 806 | 807 | ( "/privmsg", target :: rest ) -> 808 | privmsg target (String.join " " rest) 809 | 810 | ( "/ping", [ target ] ) -> 811 | ctcp target "PING" (toString model.currentTime) 812 | 813 | ( "/ns", rest ) -> 814 | privmsg "NickServ" (String.join " " rest) 815 | 816 | ( "/cs", rest ) -> 817 | privmsg "ChanServ" (String.join " " rest) 818 | 819 | ( "/names", [] ) -> 820 | let 821 | nickList = 822 | case buf.users of 823 | UsersLoading list -> 824 | list 825 | 826 | UsersLoaded set -> 827 | Dict.toList set 828 | |> List.map Tuple.first 829 | 830 | nicks = 831 | List.take 100 nickList 832 | 833 | message = 834 | [ List.length nickList |> toString, "users:" ] 835 | ++ nicks 836 | 837 | line = 838 | { ts = model.currentTime 839 | , message = String.join " " message 840 | , nick = buf.name 841 | } 842 | in 843 | [ AddLine buf.name line |> modifyServer server ] 844 | 845 | ( "/server", [ "save" ] ) -> 846 | [ modifyServer server StoreServer ] 847 | 848 | ( "/server", [ "delete" ] ) -> 849 | [ modifyServer server DisconnectServer 850 | , modifyServer server RemoveServer 851 | ] 852 | 853 | ( "/server", [ "disconnect" ] ) -> 854 | [ modifyServer server DisconnectServer ] 855 | 856 | ( "/help", _ ) -> 857 | let 858 | lines = 859 | commandDescriptions 860 | |> List.map (\( cmd, desc ) -> cmd ++ "\t--\t" ++ desc) 861 | |> List.map 862 | (\line -> 863 | { ts = model.currentTime 864 | , message = line 865 | , nick = buf.name 866 | } 867 | ) 868 | 869 | cmds = 870 | List.map (AddLine buf.name) lines 871 | |> List.map (modifyServer server) 872 | in 873 | cmds 874 | 875 | ( "/nick", [ nick ] ) -> 876 | [ SendRawLine ("NICK " ++ nick) |> modifyServer server ] 877 | 878 | ( "/quote", rest ) -> 879 | [ SendRawLine (String.join " " rest) |> modifyServer server ] 880 | 881 | _ -> 882 | addErrorMessage "unknown command, did you forget to /quote?" 883 | in 884 | case ( String.left 1 line, String.words line ) of 885 | ( "/", cmd :: params ) -> 886 | slashCommand (commandAliases cmd) params 887 | 888 | _ -> 889 | privmsg buf.name line 890 | -------------------------------------------------------------------------------- /src/View.elm: -------------------------------------------------------------------------------- 1 | module View exposing (view) 2 | 3 | import Date 4 | import Date.Format as Date 5 | import Dict 6 | import Form exposing (Form) 7 | import Form.Input as Input 8 | import Html exposing (..) 9 | import Html.Attributes exposing (id, href, class, title, target, value, classList, placeholder, autofocus, spellcheck) 10 | import Html.Events exposing (onInput, onSubmit, onWithOptions, keyCode, onClick) 11 | import Html.Lazy exposing (lazy, lazy2, lazy3) 12 | import Json.Decode as Json 13 | import Model exposing (..) 14 | import Regex exposing (HowMany(All, AtMost), regex) 15 | import Update exposing (Msg(..), ServerMsg(..)) 16 | 17 | 18 | view : Model -> Html Msg 19 | view model = 20 | let 21 | chatView = 22 | case ( model.newServerForm, getActive model ) of 23 | ( Just form, _ ) -> 24 | Html.map FormMsg (viewForm form) 25 | 26 | ( Nothing, Just ( server, buffer ) ) -> 27 | viewBuffer model server buffer 28 | 29 | _ -> 30 | viewHelpText 31 | in 32 | div [ id "container" ] [ viewBufferList model, chatView ] 33 | 34 | 35 | viewHelpText : Html msg 36 | viewHelpText = 37 | let 38 | commands = 39 | Update.commandDescriptions 40 | |> List.map 41 | (\( cmd, desc ) -> 42 | li [] 43 | [ div [ class "command-name" ] [ text cmd ] 44 | , div [ class "command-desc" ] [ text desc ] 45 | ] 46 | ) 47 | |> ul [] 48 | 49 | setupSteps = 50 | [ "start websocket proxy" 51 | , "configure irc connection through 'add server' dialog" 52 | , """if everything connects correctly, '/server save' to 53 | persist the server config to localStorage""" 54 | , "☔ " 55 | ] 56 | |> List.map (linkifyLine >> (li [])) 57 | |> ol [] 58 | in 59 | div [ id "buffer-view" ] 60 | [ h1 [] [ text "rain" ] 61 | , p [] [ text """Minimal browser based IRC client connecting 62 | over a websocket proxy backend.""" ] 63 | , p [] (linkifyLine "source: https://github.com/erik/rain") 64 | , h2 [] [ text "01 setup" ] 65 | , setupSteps 66 | , h2 [] [ text "02 supported commands" ] 67 | , commands 68 | ] 69 | 70 | 71 | viewForm : Form () ServerMetadata -> Html Form.Msg 72 | viewForm form = 73 | let 74 | inputs = 75 | [ ( Input.textInput, "WebSocket proxy:", "proxyHost", "wss://rain-proxy.example.com/" ) 76 | , ( Input.passwordInput, "WebSocket proxy password:", "proxyPass", "" ) 77 | , ( Input.textInput, "IRC server name:", "name", "freenode" ) 78 | , ( Input.textInput, "IRC server host:", "server", "irc.freenode.net" ) 79 | , ( Input.textInput, "IRC server port:", "port_", "6697, (TLS: +6697)" ) 80 | , ( Input.passwordInput, "IRC server password", "pass", "" ) 81 | , ( Input.textInput, "Nick", "nick", "rain`" ) 82 | ] 83 | 84 | inputsHtml = 85 | inputs 86 | |> List.map 87 | (\( input, lbl, fieldName, ex ) -> 88 | div [ class "form-row" ] 89 | [ label [] [ text lbl ] 90 | , input (Form.getFieldAsString fieldName form) 91 | [ placeholder ex ] 92 | ] 93 | ) 94 | 95 | scrollbackInput = 96 | div [ class "form-row" ] 97 | [ label [] [ text "Save scrollback" ] 98 | , Input.checkboxInput (Form.getFieldAsBool "saveScrollback" form) [] 99 | ] 100 | in 101 | div [ id "buffer-view" ] 102 | [ h1 [] [ text "Add IRC Connection" ] 103 | , div [] (inputsHtml ++ [ scrollbackInput ]) 104 | , div [ class "form-row" ] 105 | [ button 106 | [ onClick Form.Submit ] 107 | [ text "Add server" ] 108 | ] 109 | ] 110 | 111 | 112 | hasUnread : Buffer -> Bool 113 | hasUnread buf = 114 | let 115 | lastMessageTs = 116 | buf.buffer 117 | |> List.head 118 | |> Maybe.andThen (\date -> List.head date.lineGroups) 119 | |> Maybe.andThen (\grp -> List.head grp.messages) 120 | |> Maybe.map .ts 121 | |> Maybe.withDefault buf.lastChecked 122 | in 123 | buf.lastChecked < lastMessageTs 124 | 125 | 126 | viewBufferList : Model -> Html Msg 127 | viewBufferList model = 128 | let 129 | isCurrent serverName bufferName = 130 | model.current == Just ( serverName, bufferName ) 131 | 132 | viewBuf serverName buf = 133 | li 134 | [ onClick (ModifyServer serverName (SelectBuffer buf.name)) 135 | , classList 136 | [ ( "clickable", True ) 137 | , ( "unread", hasUnread buf ) 138 | , ( "buffer-list-item", True ) 139 | , ( "current-buffer", isCurrent serverName buf.name ) 140 | ] 141 | ] 142 | [ text buf.name ] 143 | 144 | bufferList server = 145 | server.buffers 146 | |> Dict.values 147 | |> List.filter (\buf -> not buf.isServer) 148 | |> List.sortBy .name 149 | |> List.map (viewBuf server.meta.name) 150 | 151 | serverList = 152 | model.servers 153 | |> Dict.toList 154 | |> List.map 155 | (\( serverName, server ) -> 156 | div [] 157 | [ hr [] [] 158 | , li [ class "clickable" ] 159 | [ span 160 | [ onClick 161 | (ModifyServer serverName 162 | (SelectBuffer serverBufferName) 163 | ) 164 | ] 165 | [ text serverName ] 166 | , ul [] (bufferList server) 167 | ] 168 | ] 169 | ) 170 | 171 | addServer = 172 | li [ class "clickable", onClick ShowAddServerForm ] [ text "add server" ] 173 | in 174 | div [ id "buffer-list" ] [ ul [] (addServer :: serverList) ] 175 | 176 | 177 | viewBuffer : Model -> Server -> Buffer -> Html Msg 178 | viewBuffer model server buffer = 179 | let 180 | bufferName = 181 | if buffer.isServer then 182 | server.meta.name 183 | else 184 | buffer.name 185 | in 186 | div [ id "buffer-view" ] 187 | [ div [ id "buffer-header", class "flex-fixed" ] 188 | [ h1 [] [ text bufferName ] 189 | , viewTopic buffer 190 | ] 191 | , lazy2 viewBufferMessages server.meta buffer.buffer 192 | , div [ id "buffer-footer", class "flex-fixed" ] 193 | [ input 194 | [ id "input-line" 195 | , placeholder server.meta.nick 196 | , onInput TypeLine 197 | , onInputKey model server buffer 198 | , value model.inputLine 199 | , autofocus True 200 | , spellcheck True 201 | ] 202 | [] 203 | ] 204 | ] 205 | 206 | 207 | enterKey : number 208 | enterKey = 209 | 13 210 | 211 | 212 | tabKey : number 213 | tabKey = 214 | 9 215 | 216 | 217 | {-| Handle enter / tab key presses. 218 | Cribbed from elm-todo 219 | -} 220 | onInputKey : Model -> Server -> Buffer -> Attribute Msg 221 | onInputKey model server buffer = 222 | let 223 | isKey code = 224 | if code == enterKey then 225 | SendLine buffer model.inputLine 226 | |> ModifyServer server.meta.name 227 | |> Json.succeed 228 | else if code == tabKey then 229 | TabCompleteLine buffer 230 | |> ModifyServer server.meta.name 231 | |> Json.succeed 232 | else 233 | Json.fail "nope" 234 | 235 | options = 236 | { stopPropagation = False 237 | , preventDefault = True 238 | } 239 | in 240 | onWithOptions "keydown" options (Json.andThen isKey keyCode) 241 | 242 | 243 | viewTopic : Buffer -> Html Msg 244 | viewTopic buffer = 245 | let 246 | topic = 247 | Maybe.withDefault "" buffer.topic 248 | in 249 | div [ id "buffer-topic" ] (linkifyLine topic) 250 | 251 | 252 | viewBufferMessages : ServerMetadata -> LineBuffer -> Html Msg 253 | viewBufferMessages serverMeta buffer = 254 | buffer 255 | |> List.map (viewDayGroup serverMeta) 256 | |> List.reverse 257 | |> div [ id "buffer-messages" ] 258 | 259 | 260 | viewDayGroup : ServerMetadata -> DayGroup -> Html Msg 261 | viewDayGroup serverMeta group = 262 | let 263 | dateStr = 264 | group.date |> Date.format "%Y/%m/%d" 265 | 266 | groupHead = 267 | div [ class "day-group-head" ] [ text dateStr ] 268 | 269 | lineGroups = 270 | group.lineGroups 271 | |> List.map (\grp -> viewLineGroup serverMeta grp) 272 | |> List.reverse 273 | in 274 | div [ class "day-group" ] 275 | (groupHead :: lineGroups) 276 | 277 | 278 | viewLineGroup : ServerMetadata -> LineGroup -> Html Msg 279 | viewLineGroup serverMeta group = 280 | let 281 | timeStr = 282 | group.ts 283 | |> Date.fromTime 284 | |> Date.format "%H:%M:%S" 285 | 286 | groupHead = 287 | div [ class "group-head" ] 288 | [ small [ class "timestamp" ] [ text timeStr ] 289 | , span [] [ text " " ] 290 | , div 291 | [ classList 292 | [ ( "message-nick", True ) 293 | , ( "message-nick-self", group.nick == serverMeta.nick ) 294 | ] 295 | ] 296 | [ span 297 | [ class "clickable" 298 | , onClick (ModifyServer serverMeta.name (SelectBuffer group.nick)) 299 | ] 300 | [ text group.nick ] 301 | ] 302 | ] 303 | 304 | formatMessages msgs = 305 | List.map (formatLine serverMeta.nick) msgs 306 | |> List.reverse 307 | |> div [ class "group-messages" ] 308 | in 309 | div [ class "group" ] 310 | [ groupHead 311 | , lazy formatMessages group.messages 312 | ] 313 | 314 | 315 | linkifyLine : String -> List (Html msg) 316 | linkifyLine line = 317 | let 318 | -- Simple approximation of one at least. 319 | linkRegex = 320 | regex "\\b(\\w+://[!,&-\\w@:%._\\+~#=/\\?]+)\\b" 321 | 322 | linkify word url = 323 | a [ href url, target "_blank" ] [ text word ] 324 | 325 | applyMarkup word = 326 | case Regex.find (AtMost 1) linkRegex word of 327 | [] -> 328 | text word 329 | 330 | url :: [] -> 331 | linkify word url.match 332 | 333 | _ -> 334 | Debug.crash ("Linkify failed on" ++ word) 335 | in 336 | line 337 | |> String.split " " 338 | |> List.map applyMarkup 339 | |> List.intersperse (span [] [ text " " ]) 340 | 341 | 342 | formatLine : String -> Line -> Html Msg 343 | formatLine nick line = 344 | let 345 | timeStr = 346 | line.ts 347 | |> Date.fromTime 348 | |> Date.format "%Y-%m-%d %H:%M:%S" 349 | 350 | ( message, isAction ) = 351 | case String.split ("\x01" ++ "ACTION") line.message of 352 | "" :: rest -> 353 | let 354 | message = 355 | rest 356 | |> String.concat 357 | |> String.dropRight 1 358 | in 359 | ( String.join " " [ line.nick, message ], True ) 360 | 361 | _ -> 362 | ( line.message, False ) 363 | 364 | matchesNick = 365 | line.message 366 | |> Regex.contains (regex ("\\b" ++ nick ++ "\\b")) 367 | 368 | copyText = 369 | span [ class "copy-text" ] [ text ("<" ++ line.nick ++ "> ") ] 370 | 371 | linkified = 372 | linkifyLine message 373 | in 374 | div 375 | [ title timeStr 376 | , classList 377 | [ ( "highlight", matchesNick ) 378 | , ( "action", isAction ) 379 | ] 380 | ] 381 | (copyText :: linkified) 382 | -------------------------------------------------------------------------------- /wsproxy/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | -------------------------------------------------------------------------------- /wsproxy/index.js: -------------------------------------------------------------------------------- 1 | /*jshint esversion: 6 */ 2 | 3 | const tls = require('tls'); 4 | const net = require('net'); 5 | const url = require('url'); 6 | 7 | const WebSocket = require('ws'); 8 | 9 | const PROXY_PASS = process.env.PROXY_PASS || ''; 10 | const PROXY_PORT = +process.env.PROXY_PORT || 6676; 11 | 12 | const wss = new WebSocket.Server({ port: PROXY_PORT }); 13 | console.log(`WebSocket proxy listening on ${PROXY_PORT}`); 14 | 15 | wss.on('connection', function connection(ws) { 16 | let query = url.parse(ws.upgradeReq.url, true).query; 17 | const required = ['host', 'port']; 18 | 19 | for (let i in required) { 20 | if (!query[required[i]]) 21 | return ws.send(`missing required param ${required[i]}`); 22 | } 23 | 24 | if (PROXY_PASS && query.proxyPass != PROXY_PASS) { 25 | return ws.send(`Bad password`); 26 | } 27 | 28 | let socket; 29 | 30 | // This is a TLS connection 31 | if (query.port.startsWith('+')) { 32 | socket = tls.connect({ 33 | host: query.host, 34 | port: +query.port, 35 | rejectUnauthorized: false 36 | }, () => { 37 | console.log('[TLS] connected to', query.host, query.port); 38 | }); 39 | } else { 40 | socket = new net.Socket(); 41 | socket.connect(+query.port, query.host, () => { 42 | console.log('connected to', query.host, query.port); 43 | }); 44 | } 45 | 46 | socket.setEncoding('utf8') 47 | .on('data', (data) => { 48 | data.trim().split(/[\r\n]+/g).forEach(line => { 49 | console.log('<--', line); 50 | ws.send(line + '\n'); 51 | }); 52 | }) 53 | .on('end', () => { ws.close(); }); 54 | 55 | // TODO: Maybe this can be replaced by 001? 56 | ws.send('*CONNECTED\n'); 57 | 58 | ws.isAlive = true; 59 | ws.on('pong', () => { ws.isAlive = true; }); 60 | ws.on('close', () => socket.destroy()); 61 | 62 | ws.on('message', function incoming(message) { 63 | message.split(/[\r\n]+/).forEach(line => { 64 | console.log('-->', line); 65 | 66 | if (line === '*PING') { 67 | ws.send('*PONG\n'); 68 | return; 69 | } 70 | 71 | socket.write(line + '\n'); 72 | }); 73 | }); 74 | }); 75 | 76 | 77 | // Clear out dead connections 78 | setInterval(() => { 79 | wss.clients.forEach((ws) => { 80 | if (!ws.isAlive) { 81 | ws.terminate(); 82 | } else { 83 | ws.isAlive = false; 84 | ws.ping('', false, true); 85 | } 86 | }); 87 | }, 15 * 1000); 88 | -------------------------------------------------------------------------------- /wsproxy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wsproxy", 3 | "version": "1.0.0", 4 | "description": "IRC <-> WS", 5 | "main": "index.js", 6 | "license": "MIT", 7 | "dependencies": { 8 | "ws": "^3.3.1" 9 | } 10 | } 11 | --------------------------------------------------------------------------------