├── .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 | 
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 |
--------------------------------------------------------------------------------