vines-web 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/Gemfile +3 -0
- data/LICENSE +19 -0
- data/README.md +37 -0
- data/Rakefile +28 -0
- data/app/assets/javascripts/application.coffee +19 -0
- data/app/assets/javascripts/chat.coffee +362 -0
- data/app/assets/javascripts/lib/button.coffee +25 -0
- data/app/assets/javascripts/lib/contact.coffee +32 -0
- data/app/assets/javascripts/lib/filter.coffee +49 -0
- data/app/assets/javascripts/lib/index.coffee +1 -0
- data/app/assets/javascripts/lib/layout.coffee +30 -0
- data/app/assets/javascripts/lib/login.coffee +68 -0
- data/app/assets/javascripts/lib/logout.coffee +5 -0
- data/app/assets/javascripts/lib/navbar.coffee +84 -0
- data/app/assets/javascripts/lib/notification.coffee +14 -0
- data/app/assets/javascripts/lib/router.coffee +40 -0
- data/app/assets/javascripts/lib/session.coffee +229 -0
- data/app/assets/javascripts/lib/transfer.coffee +106 -0
- data/app/assets/javascripts/vendor/icons.js +110 -0
- data/app/assets/javascripts/vendor/index.js +1 -0
- data/app/assets/javascripts/vendor/jquery.js +4 -0
- data/app/assets/javascripts/vendor/raphael.js +6 -0
- data/app/assets/javascripts/vendor/strophe.js +1 -0
- data/app/assets/stylesheets/application.css +5 -0
- data/app/assets/stylesheets/base.scss +385 -0
- data/app/assets/stylesheets/chat.scss +144 -0
- data/app/assets/stylesheets/login.scss +68 -0
- data/bin/vines-web +63 -0
- data/config.ru +10 -0
- data/lib/vines/web/command/init.rb +34 -0
- data/lib/vines/web/command/install.rb +19 -0
- data/lib/vines/web/version.rb +5 -0
- data/lib/vines/web.rb +4 -0
- data/public/assets/application.css +598 -0
- data/public/assets/application.js +9 -0
- data/public/assets/lib.js +1 -0
- data/public/assets/vendor.js +8 -0
- data/public/images/dark-gray.png +0 -0
- data/public/images/default-user.png +0 -0
- data/public/images/light-gray.png +0 -0
- data/public/images/logo-large.png +0 -0
- data/public/images/logo-small.png +0 -0
- data/public/images/white.png +0 -0
- data/public/index.html +13 -0
- data/vines-web.gemspec +27 -0
- metadata +207 -0
@@ -0,0 +1,84 @@
|
|
1
|
+
class @NavBar
|
2
|
+
constructor: (@session) ->
|
3
|
+
@session.onCard (card) =>
|
4
|
+
if card.jid == @session.bareJid()
|
5
|
+
$('#current-user-avatar').attr 'src', @session.avatar card.jid
|
6
|
+
|
7
|
+
draw: ->
|
8
|
+
$("""
|
9
|
+
<header id="navbar" class="x-fill">
|
10
|
+
<h1 id="logo">vines></h1>
|
11
|
+
<div id="current-user">
|
12
|
+
<img id="current-user-avatar" alt="#{@session.bareJid()}" src="#{@session.avatar(@session.jid())}"/>
|
13
|
+
<div id="current-user-info">
|
14
|
+
<h1 id="current-user-name">#{@session.bareJid()}</h1>
|
15
|
+
<form id="current-user-presence-form">
|
16
|
+
<span class="select">
|
17
|
+
<span class="text">Available</span>
|
18
|
+
<select id="current-user-presence">
|
19
|
+
<optgroup label="Available">
|
20
|
+
<option>Available</option>
|
21
|
+
<option>Surfing the web</option>
|
22
|
+
<option>Reading email</option>
|
23
|
+
</optgroup>
|
24
|
+
<optgroup label="Away">
|
25
|
+
<option value="xa">Away</option>
|
26
|
+
<option value="xa">Out to lunch</option>
|
27
|
+
<option value="xa">On the phone</option>
|
28
|
+
<option value="xa">In a meeting</option>
|
29
|
+
</optgroup>
|
30
|
+
</select>
|
31
|
+
</span>
|
32
|
+
</form>
|
33
|
+
</div>
|
34
|
+
</div>
|
35
|
+
<nav id="app-nav" class="x-fill">
|
36
|
+
<ul id="nav-links"></ul>
|
37
|
+
</nav>
|
38
|
+
</header>
|
39
|
+
""").appendTo 'body'
|
40
|
+
$('<div id="container" class="x-fill y-fill"></div>').appendTo 'body'
|
41
|
+
|
42
|
+
$('#current-user-presence').change (event) =>
|
43
|
+
selected = $ 'option:selected', event.currentTarget
|
44
|
+
$('#current-user-presence-form .text').text selected.text()
|
45
|
+
@session.sendPresence selected.val() == 'xa', selected.text()
|
46
|
+
|
47
|
+
addButton: (label, icon) ->
|
48
|
+
id = "nav-link-#{label.toLowerCase()}"
|
49
|
+
node = $("""
|
50
|
+
<li>
|
51
|
+
<a id="#{id}" href="#/#{label.toLowerCase()}">
|
52
|
+
<span>#{label}</span>
|
53
|
+
</a>
|
54
|
+
</li>
|
55
|
+
""").appendTo '#nav-links'
|
56
|
+
this.button(id, icon)
|
57
|
+
node.click (event) => this.select(event.currentTarget)
|
58
|
+
|
59
|
+
select: (button) ->
|
60
|
+
button = $(button)
|
61
|
+
$('#nav-links li').removeClass('selected')
|
62
|
+
$('#nav-links li a').removeClass('selected')
|
63
|
+
button.addClass('selected')
|
64
|
+
$('a', button).addClass('selected')
|
65
|
+
dark = $('#nav-links svg path')
|
66
|
+
dark.attr 'opacity', '0.6'
|
67
|
+
dark.css 'opacity', '0.6'
|
68
|
+
light = $('svg path', button)
|
69
|
+
light.attr 'opacity', '1.0'
|
70
|
+
light.css 'opacity', '1.0'
|
71
|
+
|
72
|
+
button: (id, path) ->
|
73
|
+
paper = Raphael(id)
|
74
|
+
icon = paper.path(path).attr
|
75
|
+
fill: '#fff'
|
76
|
+
stroke: '#000'
|
77
|
+
'stroke-width': 0.3
|
78
|
+
opacity: 0.6
|
79
|
+
|
80
|
+
node = $('#' + id)
|
81
|
+
node.hover(
|
82
|
+
-> icon.animate(opacity: 1.0, 200),
|
83
|
+
-> icon.animate(opacity: 0.6, 200) unless node.hasClass('selected'))
|
84
|
+
node.get 0
|
@@ -0,0 +1,14 @@
|
|
1
|
+
class @Notification
|
2
|
+
constructor: (@text) ->
|
3
|
+
this.draw()
|
4
|
+
|
5
|
+
draw: ->
|
6
|
+
node = $('<div class="notification float" style="display:none;"></div>').appendTo 'body'
|
7
|
+
node.text @text
|
8
|
+
top = node.outerHeight() / 2
|
9
|
+
left = node.outerWidth() / 2
|
10
|
+
node.css {marginTop: "-#{top}px", marginLeft: "-#{left}px"}
|
11
|
+
node.fadeIn 200
|
12
|
+
fn = ->
|
13
|
+
node.fadeOut 200, -> node.remove()
|
14
|
+
setTimeout fn, 1500
|
@@ -0,0 +1,40 @@
|
|
1
|
+
class @Router
|
2
|
+
constructor: (@pages) ->
|
3
|
+
@routes = this.build()
|
4
|
+
$(window).bind 'hashchange', => this.draw()
|
5
|
+
|
6
|
+
build: ->
|
7
|
+
routes = []
|
8
|
+
for pattern, page of @pages
|
9
|
+
routes.push route = args: [], page: page, re: null
|
10
|
+
if pattern == 'default'
|
11
|
+
route.re = pattern
|
12
|
+
continue
|
13
|
+
|
14
|
+
fragments = (f for f in pattern.split '/' when f.length > 0)
|
15
|
+
map = (fragment) ->
|
16
|
+
if fragment[0] == ':'
|
17
|
+
route.args.push fragment.replace ':', ''
|
18
|
+
'(/[^/]+)?'
|
19
|
+
else '/' + fragment
|
20
|
+
route.re = new RegExp '#' + (map f for f in fragments).join ''
|
21
|
+
routes
|
22
|
+
|
23
|
+
draw: ->
|
24
|
+
[route, args] = this.match()
|
25
|
+
route ||= this.defaultRoute()
|
26
|
+
return unless route
|
27
|
+
[opts, ix] = [{}, 0]
|
28
|
+
opts[name] = args[ix++] for name in route.args
|
29
|
+
route.page.draw(opts)
|
30
|
+
|
31
|
+
match: ->
|
32
|
+
for route in @routes
|
33
|
+
if match = window.location.hash.match route.re
|
34
|
+
args = (arg.replace '/', '' for arg in match[1..-1])
|
35
|
+
return [route, args]
|
36
|
+
[]
|
37
|
+
|
38
|
+
defaultRoute: ->
|
39
|
+
for route in @routes
|
40
|
+
return route if route.re == 'default'
|
@@ -0,0 +1,229 @@
|
|
1
|
+
class @Session
|
2
|
+
constructor: ->
|
3
|
+
@xmpp = new Strophe.Connection '/xmpp'
|
4
|
+
@roster = {}
|
5
|
+
@listeners =
|
6
|
+
card: []
|
7
|
+
message: []
|
8
|
+
presence: []
|
9
|
+
roster: []
|
10
|
+
|
11
|
+
connect: (jid, password, callback) ->
|
12
|
+
@xmpp.connect jid, password, (status) =>
|
13
|
+
switch status
|
14
|
+
when Strophe.Status.AUTHFAIL, Strophe.Status.CONNFAIL
|
15
|
+
callback false
|
16
|
+
when Strophe.Status.CONNECTED
|
17
|
+
@xmpp.addHandler ((el) => this.handleIq(el)), null, 'iq'
|
18
|
+
@xmpp.addHandler ((el) => this.handleMessage(el)), null, 'message'
|
19
|
+
@xmpp.addHandler ((el) => this.handlePresence(el)), null, 'presence'
|
20
|
+
callback true
|
21
|
+
this.findRoster =>
|
22
|
+
this.notify('roster')
|
23
|
+
@xmpp.send this.xml '<presence/>'
|
24
|
+
this.findCards()
|
25
|
+
|
26
|
+
disconnect: -> @xmpp.disconnect()
|
27
|
+
|
28
|
+
onCard: (callback) ->
|
29
|
+
@listeners['card'].push callback
|
30
|
+
|
31
|
+
onRoster: (callback) ->
|
32
|
+
@listeners['roster'].push callback
|
33
|
+
|
34
|
+
onMessage: (callback) ->
|
35
|
+
@listeners['message'].push callback
|
36
|
+
|
37
|
+
onPresence: (callback) ->
|
38
|
+
@listeners['presence'].push callback
|
39
|
+
|
40
|
+
connected: ->
|
41
|
+
@xmpp.jid && @xmpp.jid.length > 0
|
42
|
+
|
43
|
+
jid: -> @xmpp.jid
|
44
|
+
|
45
|
+
bareJid: -> @xmpp.jid.split('/')[0]
|
46
|
+
|
47
|
+
uniqueId: -> @xmpp.getUniqueId()
|
48
|
+
|
49
|
+
avatar: (jid) ->
|
50
|
+
card = this.loadCard(jid)
|
51
|
+
if card && card.photo
|
52
|
+
"data:#{card.photo.type};base64,#{card.photo.binval}"
|
53
|
+
else
|
54
|
+
'/lib/images/default-user.png'
|
55
|
+
|
56
|
+
loadCard: (jid) ->
|
57
|
+
jid = jid.split('/')[0]
|
58
|
+
found = localStorage['vcard:' + jid]
|
59
|
+
JSON.parse found if found
|
60
|
+
|
61
|
+
storeCard: (card) ->
|
62
|
+
localStorage['vcard:' + card.jid] = JSON.stringify card
|
63
|
+
|
64
|
+
findCards: ->
|
65
|
+
jids = (jid for jid, contacts of @roster when !this.loadCard jid)
|
66
|
+
jids.push this.bareJid() if !this.loadCard(this.bareJid())
|
67
|
+
|
68
|
+
success = (card) =>
|
69
|
+
this.findCard jids.shift(), success
|
70
|
+
if card
|
71
|
+
this.storeCard card
|
72
|
+
this.notify 'card', card
|
73
|
+
|
74
|
+
this.findCard jids.shift(), success
|
75
|
+
|
76
|
+
findCard: (jid, callback) ->
|
77
|
+
return unless jid
|
78
|
+
node = this.xml """
|
79
|
+
<iq id="#{this.uniqueId()}" to="#{jid}" type="get">
|
80
|
+
<vCard xmlns="vcard-temp"/>
|
81
|
+
</iq>
|
82
|
+
"""
|
83
|
+
this.sendIQ node, (result) ->
|
84
|
+
card = $('vCard', result)
|
85
|
+
photo = $('PHOTO', card)
|
86
|
+
type = $('TYPE', photo).text()
|
87
|
+
bin = $('BINVAL', photo).text()
|
88
|
+
photo =
|
89
|
+
if type && bin
|
90
|
+
type: type, binval: bin.replace(/\n/g, '')
|
91
|
+
else null
|
92
|
+
vcard = jid: jid, photo: photo, retrieved: new Date()
|
93
|
+
callback if card.size() > 0 then vcard else null
|
94
|
+
|
95
|
+
parseRoster: (node) ->
|
96
|
+
$('item', node).map(-> new Contact this ).get()
|
97
|
+
|
98
|
+
findRoster: (callback) ->
|
99
|
+
node = this.xml """
|
100
|
+
<iq id='#{this.uniqueId()}' type="get">
|
101
|
+
<query xmlns="jabber:iq:roster"/>
|
102
|
+
</iq>
|
103
|
+
"""
|
104
|
+
this.sendIQ node, (result) =>
|
105
|
+
contacts = this.parseRoster(result)
|
106
|
+
@roster[contact.jid] = contact for contact in contacts
|
107
|
+
callback()
|
108
|
+
|
109
|
+
sendMessage: (jid, message) ->
|
110
|
+
node = this.xml """
|
111
|
+
<message id="#{this.uniqueId()}" to="#{jid}" type="chat">
|
112
|
+
<body></body>
|
113
|
+
</message>
|
114
|
+
"""
|
115
|
+
$('body', node).text message
|
116
|
+
@xmpp.send node
|
117
|
+
|
118
|
+
sendPresence: (away, status) ->
|
119
|
+
node = $ this.xml '<presence/>'
|
120
|
+
if away
|
121
|
+
node.append $(this.xml '<show>xa</show>')
|
122
|
+
node.append $(this.xml '<status/>').text status if status != 'Away'
|
123
|
+
else
|
124
|
+
node.append $(this.xml '<status/>').text status if status != 'Available'
|
125
|
+
@xmpp.send node
|
126
|
+
|
127
|
+
sendIQ: (node, callback) ->
|
128
|
+
@xmpp.sendIQ node, callback, callback, 5000
|
129
|
+
|
130
|
+
updateContact: (contact, add) ->
|
131
|
+
node = this.xml """
|
132
|
+
<iq id="#{this.uniqueId()}" type="set">
|
133
|
+
<query xmlns="jabber:iq:roster">
|
134
|
+
<item name="" jid="#{contact.jid}"/>
|
135
|
+
</query>
|
136
|
+
</iq>
|
137
|
+
"""
|
138
|
+
$('item', node).attr 'name', contact.name
|
139
|
+
for group in contact.groups
|
140
|
+
$('item', node).append $(this.xml '<group></group>').text group
|
141
|
+
@xmpp.send node
|
142
|
+
this.sendSubscribe(contact.jid) if add
|
143
|
+
|
144
|
+
removeContact: (jid) ->
|
145
|
+
node = this.xml """
|
146
|
+
<iq id="#{this.uniqueId()}" type="set">
|
147
|
+
<query xmlns="jabber:iq:roster">
|
148
|
+
<item jid="#{jid}" subscription="remove"/>
|
149
|
+
</query>
|
150
|
+
</iq>
|
151
|
+
"""
|
152
|
+
@xmpp.send node
|
153
|
+
|
154
|
+
sendSubscribe: (jid) ->
|
155
|
+
@xmpp.send this.presence jid, 'subscribe'
|
156
|
+
|
157
|
+
sendSubscribed: (jid) ->
|
158
|
+
@xmpp.send this.presence jid, 'subscribed'
|
159
|
+
|
160
|
+
sendUnsubscribed: (jid) ->
|
161
|
+
@xmpp.send this.presence jid, 'unsubscribed'
|
162
|
+
|
163
|
+
presence: (to, type) ->
|
164
|
+
this.xml """
|
165
|
+
<presence
|
166
|
+
id="#{this.uniqueId()}"
|
167
|
+
to="#{to}"
|
168
|
+
type="#{type}"/>
|
169
|
+
"""
|
170
|
+
|
171
|
+
handleIq: (node) ->
|
172
|
+
node = $(node)
|
173
|
+
type = node.attr 'type'
|
174
|
+
ns = node.find('query').attr 'xmlns'
|
175
|
+
if type == 'set' && ns == 'jabber:iq:roster'
|
176
|
+
contacts = this.parseRoster(node)
|
177
|
+
for contact in contacts
|
178
|
+
if contact.subscription == 'remove'
|
179
|
+
delete @roster[contact.jid]
|
180
|
+
else
|
181
|
+
old = @roster[contact.jid]
|
182
|
+
contact.presence = old.presence if old
|
183
|
+
@roster[contact.jid] = contact
|
184
|
+
this.notify('roster')
|
185
|
+
true # keep handler alive
|
186
|
+
|
187
|
+
handleMessage: (node) ->
|
188
|
+
node = $(node)
|
189
|
+
to = node.attr 'to'
|
190
|
+
from = node.attr 'from'
|
191
|
+
type = node.attr 'type'
|
192
|
+
thread = node.find('thread').first()
|
193
|
+
body = node.find('body').first()
|
194
|
+
this.notify 'message',
|
195
|
+
to: to
|
196
|
+
from: from
|
197
|
+
type: type
|
198
|
+
thread: thread.text()
|
199
|
+
text: body.text()
|
200
|
+
received: new Date()
|
201
|
+
node: node
|
202
|
+
true # keep handler alive
|
203
|
+
|
204
|
+
handlePresence: (node) ->
|
205
|
+
node = $(node)
|
206
|
+
to = node.attr 'to'
|
207
|
+
from = node.attr 'from'
|
208
|
+
type = node.attr 'type'
|
209
|
+
show = node.find('show').first()
|
210
|
+
status = node.find('status').first()
|
211
|
+
presence =
|
212
|
+
to: to
|
213
|
+
from: from
|
214
|
+
status: status.text()
|
215
|
+
show: show.text()
|
216
|
+
type: type
|
217
|
+
offline: type == 'unavailable' || type == 'error'
|
218
|
+
away: show.text() == 'away' || show.text() == 'xa'
|
219
|
+
dnd: show.text() == 'dnd'
|
220
|
+
node: node
|
221
|
+
contact = @roster[from.split('/')[0]]
|
222
|
+
contact.update presence if contact
|
223
|
+
this.notify 'presence', presence
|
224
|
+
true # keep handler alive
|
225
|
+
|
226
|
+
notify: (type, obj) ->
|
227
|
+
callback(obj) for callback in (@listeners[type] || [])
|
228
|
+
|
229
|
+
xml: (xml) -> $.parseXML(xml).documentElement
|
@@ -0,0 +1,106 @@
|
|
1
|
+
class @Transfer
|
2
|
+
constructor: (options) ->
|
3
|
+
@session = options.session
|
4
|
+
@file = options.file
|
5
|
+
@to = options.to
|
6
|
+
@progress = options.progress
|
7
|
+
@complete = options.complete
|
8
|
+
@chunks = new Chunks @file
|
9
|
+
@opened = false
|
10
|
+
@closed = false
|
11
|
+
@sid = @session.uniqueId()
|
12
|
+
@seq = 0
|
13
|
+
@sent = 0
|
14
|
+
|
15
|
+
start: ->
|
16
|
+
node = $("""
|
17
|
+
<iq id="#{@session.uniqueId()}" to="#{@to}" type="set">
|
18
|
+
<si xmlns="http://jabber.org/protocol/si" id="#{@session.uniqueId()}" profile="http://jabber.org/protocol/si/profile/file-transfer">
|
19
|
+
<file xmlns="http://jabber.org/protocol/si/profile/file-transfer" name="" size="#{@file.size}"/>
|
20
|
+
<feature xmlns="http://jabber.org/protocol/feature-neg">
|
21
|
+
<x xmlns="jabber:x:data" type="form">
|
22
|
+
<field var="stream-method" type="list-single">
|
23
|
+
<option><value>http://jabber.org/protocol/ibb</value></option>
|
24
|
+
</field>
|
25
|
+
</x>
|
26
|
+
</feature>
|
27
|
+
</si>
|
28
|
+
</iq>
|
29
|
+
""")
|
30
|
+
$('file', node).attr 'name', @file.name
|
31
|
+
|
32
|
+
@session.sendIQ node.get(0), (result) =>
|
33
|
+
methods = $('si feature x field[var="stream-method"] value', result)
|
34
|
+
ok = (true for m in methods when $(m).text() == 'http://jabber.org/protocol/ibb').length > 0
|
35
|
+
this.open() if ok
|
36
|
+
|
37
|
+
open: ->
|
38
|
+
node = $("""
|
39
|
+
<iq id="#{@session.uniqueId()}" to="#{@to}" type="set">
|
40
|
+
<open xmlns="http://jabber.org/protocol/ibb" sid="#{@sid}" block-size="4096"/>
|
41
|
+
</iq>
|
42
|
+
""")
|
43
|
+
@session.sendIQ node.get(0), (result) =>
|
44
|
+
if this.ok result
|
45
|
+
@opened = true
|
46
|
+
this.sendChunk()
|
47
|
+
|
48
|
+
sendChunk: ->
|
49
|
+
return if @closed
|
50
|
+
@chunks.chunk (chunk) =>
|
51
|
+
unless chunk
|
52
|
+
this.close()
|
53
|
+
return
|
54
|
+
|
55
|
+
node = $("""
|
56
|
+
<iq id="#{@session.uniqueId()}" to="#{@to}" type="set">
|
57
|
+
<data xmlns="http://jabber.org/protocol/ibb" sid="#{@sid}" seq="#{@seq++}">#{chunk}</data>
|
58
|
+
</iq>
|
59
|
+
""")
|
60
|
+
@seq = 0 if @seq > 65535
|
61
|
+
|
62
|
+
@session.sendIQ node.get(0), (result) =>
|
63
|
+
return unless this.ok result
|
64
|
+
pct = Math.ceil ++@sent / @chunks.total * 100
|
65
|
+
this.progress pct
|
66
|
+
this.sendChunk()
|
67
|
+
|
68
|
+
close: ->
|
69
|
+
return if @closed
|
70
|
+
@closed = true
|
71
|
+
node = $("""
|
72
|
+
<iq id="#{@session.uniqueId()}" to="#{@to}" type="set">
|
73
|
+
<close xmlns="http://jabber.org/protocol/ibb" sid="#{@sid}"/>
|
74
|
+
</iq>
|
75
|
+
""")
|
76
|
+
@session.sendIQ node.get(0), ->
|
77
|
+
this.complete()
|
78
|
+
|
79
|
+
stop: ->
|
80
|
+
if @opened
|
81
|
+
this.close()
|
82
|
+
else
|
83
|
+
this.complete()
|
84
|
+
|
85
|
+
ok: (result) -> $(result).attr('type') == 'result'
|
86
|
+
|
87
|
+
class Chunks
|
88
|
+
CHUNK_SIZE = 3 / 4 * 4096
|
89
|
+
|
90
|
+
constructor: (@file) ->
|
91
|
+
@total = Math.ceil @file.size / CHUNK_SIZE
|
92
|
+
@slice = @file.slice || @file.webkitSlice || @file.mozSlice
|
93
|
+
@pos = 0
|
94
|
+
|
95
|
+
chunk: (callback) ->
|
96
|
+
start = @pos
|
97
|
+
end = @pos + CHUNK_SIZE
|
98
|
+
@pos = end
|
99
|
+
if start > @file.size
|
100
|
+
callback null
|
101
|
+
else
|
102
|
+
chunk = @slice.call @file, start, end
|
103
|
+
reader = new FileReader()
|
104
|
+
reader.onload = (event) ->
|
105
|
+
callback btoa event.target.result
|
106
|
+
reader.readAsBinaryString chunk
|