vines 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (83) hide show
  1. data/README +2 -2
  2. data/Rakefile +63 -8
  3. data/bin/vines +0 -1
  4. data/conf/config.rb +16 -7
  5. data/lib/vines.rb +21 -16
  6. data/lib/vines/command/init.rb +5 -3
  7. data/lib/vines/config.rb +34 -0
  8. data/lib/vines/contact.rb +14 -0
  9. data/lib/vines/stanza.rb +26 -0
  10. data/lib/vines/stanza/iq.rb +1 -1
  11. data/lib/vines/stanza/iq/disco_info.rb +3 -0
  12. data/lib/vines/stanza/iq/private_storage.rb +83 -0
  13. data/lib/vines/stanza/iq/roster.rb +26 -30
  14. data/lib/vines/stanza/presence.rb +0 -12
  15. data/lib/vines/stanza/presence/subscribe.rb +3 -20
  16. data/lib/vines/stanza/presence/subscribed.rb +9 -10
  17. data/lib/vines/stanza/presence/unsubscribe.rb +8 -15
  18. data/lib/vines/stanza/presence/unsubscribed.rb +8 -8
  19. data/lib/vines/storage.rb +28 -0
  20. data/lib/vines/storage/couchdb.rb +29 -0
  21. data/lib/vines/storage/local.rb +22 -0
  22. data/lib/vines/storage/redis.rb +26 -0
  23. data/lib/vines/storage/sql.rb +48 -5
  24. data/lib/vines/stream/client.rb +6 -8
  25. data/lib/vines/stream/http.rb +23 -21
  26. data/lib/vines/stream/http/auth.rb +1 -1
  27. data/lib/vines/stream/http/bind.rb +1 -1
  28. data/lib/vines/stream/http/bind_restart.rb +4 -3
  29. data/lib/vines/stream/http/ready.rb +1 -1
  30. data/lib/vines/stream/http/request.rb +94 -5
  31. data/lib/vines/stream/http/session.rb +8 -6
  32. data/lib/vines/version.rb +1 -1
  33. data/test/config_test.rb +12 -0
  34. data/test/contact_test.rb +40 -0
  35. data/test/rake_test_loader.rb +11 -3
  36. data/test/stanza/iq/private_storage_test.rb +177 -0
  37. data/test/stanza/iq/roster_test.rb +1 -1
  38. data/test/stanza/iq_test.rb +63 -0
  39. data/test/storage/couchdb_test.rb +7 -1
  40. data/test/storage/local_test.rb +8 -2
  41. data/test/storage/redis_test.rb +16 -7
  42. data/test/storage/sql_test.rb +8 -1
  43. data/test/storage/storage_tests.rb +50 -0
  44. data/test/stream/http/auth_test.rb +3 -0
  45. data/test/stream/http/ready_test.rb +3 -0
  46. data/test/stream/http/request_test.rb +86 -0
  47. data/test/stream/parser_test.rb +2 -0
  48. data/web/404.html +43 -0
  49. data/web/apple-touch-icon.png +0 -0
  50. data/web/chat/coffeescripts/chat.coffee +385 -0
  51. data/web/chat/coffeescripts/init.coffee +15 -0
  52. data/web/chat/coffeescripts/logout.coffee +5 -0
  53. data/web/chat/index.html +17 -0
  54. data/web/chat/javascripts/app.js +1 -0
  55. data/web/chat/javascripts/chat.js +436 -0
  56. data/web/chat/javascripts/init.js +21 -0
  57. data/web/chat/javascripts/logout.js +11 -0
  58. data/web/chat/stylesheets/chat.css +290 -0
  59. data/web/favicon.png +0 -0
  60. data/web/lib/coffeescripts/contact.coffee +32 -0
  61. data/web/lib/coffeescripts/layout.coffee +30 -0
  62. data/web/lib/coffeescripts/login.coffee +52 -0
  63. data/web/lib/coffeescripts/navbar.coffee +84 -0
  64. data/web/lib/coffeescripts/router.coffee +40 -0
  65. data/web/lib/coffeescripts/session.coffee +211 -0
  66. data/web/lib/images/default-user.png +0 -0
  67. data/web/lib/images/logo-large.png +0 -0
  68. data/web/lib/images/logo-small.png +0 -0
  69. data/web/lib/javascripts/base.js +9 -0
  70. data/web/lib/javascripts/contact.js +94 -0
  71. data/web/lib/javascripts/icons.js +101 -0
  72. data/web/lib/javascripts/jquery.cookie.js +91 -0
  73. data/web/lib/javascripts/jquery.js +18 -0
  74. data/web/lib/javascripts/layout.js +48 -0
  75. data/web/lib/javascripts/login.js +61 -0
  76. data/web/lib/javascripts/navbar.js +69 -0
  77. data/web/lib/javascripts/raphael.js +8 -0
  78. data/web/lib/javascripts/router.js +105 -0
  79. data/web/lib/javascripts/session.js +322 -0
  80. data/web/lib/javascripts/strophe.js +1 -0
  81. data/web/lib/stylesheets/base.css +223 -0
  82. data/web/lib/stylesheets/login.css +63 -0
  83. metadata +51 -9
@@ -177,7 +177,7 @@ class RosterTest < MiniTest::Unit::TestCase
177
177
  assert router.verify
178
178
 
179
179
  expected = node(%q{
180
- <iq type="set" to="alice@wonderland.lit/tea">
180
+ <iq to="alice@wonderland.lit/tea" type="set">
181
181
  <query xmlns="jabber:iq:roster">
182
182
  <item jid="hatter@wonderland.lit" name="Mad Hatter" subscription="none">
183
183
  <group>Friends</group>
@@ -0,0 +1,63 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'vines'
4
+ require 'ext/nokogiri'
5
+ require 'minitest/autorun'
6
+
7
+ class IqTest < MiniTest::Unit::TestCase
8
+ def setup
9
+ @stream = MiniTest::Mock.new
10
+ @stream.expect(:domain, 'wonderland.lit')
11
+ end
12
+
13
+ def test_allow_other_iq_to_route
14
+ alice = Vines::User.new(:jid => 'alice@wonderland.lit/tea')
15
+ hatter = Vines::User.new(:jid => 'hatter@wonderland.lit/crumpets')
16
+ node = node(%q{
17
+ <iq id="42" type="set" to="alice@wonderland.lit/tea" from="hatter@wonderland.lit/crumpets">
18
+ <si xmlns="http://jabber.org/protocol/si" id="42_si" profile="http://jabber.org/protocol/si/profile/file-transfer">
19
+ <file xmlns="http://jabber.org/protocol/si/profile/file-transfer" name="file" size="1"/>
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>
24
+ <value>http://jabber.org/protocol/bytestreams</value>
25
+ </option>
26
+ <option>
27
+ <value>http://jabber.org/protocol/ibb</value>
28
+ </option>
29
+ </field>
30
+ </x>
31
+ </feature>
32
+ </si>
33
+ </iq>}.strip.gsub(/\n|\s{2,}/, ''))
34
+
35
+ recipient = MiniTest::Mock.new
36
+ recipient.expect(:write, nil, [node])
37
+
38
+ router = MiniTest::Mock.new
39
+ router.expect(:available_resources, [recipient], [alice.jid])
40
+ router.expect(:local?, true, [node])
41
+
42
+ @stream.expect(:user, hatter)
43
+ @stream.expect(:router, router)
44
+ @stream.expect(:domain, 'wonderland.lit')
45
+
46
+ stanza = Vines::Stanza::Iq.new(node, @stream)
47
+ stanza.process
48
+ assert @stream.verify
49
+ assert recipient.verify
50
+ end
51
+
52
+ def test_feature_not_implemented
53
+ node = node('<iq type="set" id="42">')
54
+ stanza = Vines::Stanza::Iq.new(node, @stream)
55
+ assert_raises(Vines::StanzaErrors::FeatureNotImplemented) { stanza.process }
56
+ end
57
+
58
+ private
59
+
60
+ def node(xml)
61
+ Nokogiri::XML(xml).root
62
+ end
63
+ end
@@ -49,7 +49,13 @@ class CouchDBTest < MiniTest::Unit::TestCase
49
49
  save_doc({
50
50
  '_id' => 'vcard:full@wonderland.lit',
51
51
  'type' => 'Vcard',
52
- 'card' => StorageTests::VCARD.to_xml
52
+ 'card' => VCARD.to_xml
53
+ })
54
+
55
+ save_doc({
56
+ '_id' => "fragment:full@wonderland.lit:#{FRAGMENT_ID}",
57
+ 'type' => 'Fragment',
58
+ 'xml' => FRAGMENT.to_xml
53
59
  })
54
60
  end
55
61
  end
@@ -14,7 +14,8 @@ class LocalTest < MiniTest::Unit::TestCase
14
14
  :clear_pass => './clear_password@wonderland.lit.user',
15
15
  :bcrypt => './bcrypt_password@wonderland.lit.user',
16
16
  :full => './full@wonderland.lit.user',
17
- :vcard => './full@wonderland.lit.vcard'
17
+ :vcard => './full@wonderland.lit.vcard',
18
+ :fragment => "./full@wonderland.lit-#{FRAGMENT_ID}.fragment"
18
19
  }
19
20
  File.open(@files[:empty], 'w') {|f| f.write('') }
20
21
  File.open(@files[:no_pass], 'w') {|f| f.write('foo: bar') }
@@ -32,11 +33,16 @@ class LocalTest < MiniTest::Unit::TestCase
32
33
  f.puts(" groups: [Group3, Group4]")
33
34
  end
34
35
  File.open(@files[:vcard], 'w') {|f| f.write(StorageTests::VCARD.to_xml) }
36
+ File.open(@files[:fragment], 'w') {|f| f.write(StorageTests::FRAGMENT.to_xml) }
35
37
  end
36
38
 
37
39
  def teardown
38
40
  misc = %w[user vcard].map {|ext| "./save_user@domain.tld.#{ext}" }
39
- [*misc, *@files.values].each do |f|
41
+ [
42
+ "./save_user@domain.tld-#{FRAGMENT_ID}.fragment",
43
+ *misc,
44
+ *@files.values
45
+ ].each do |f|
40
46
  File.delete(f) if File.exist?(f)
41
47
  end
42
48
  end
@@ -11,24 +11,32 @@ class RedisTest < MiniTest::Unit::TestCase
11
11
  def initialize
12
12
  @db = {}
13
13
  end
14
+ def del(key)
15
+ @db.delete(key)
16
+ EM.next_tick { yield if block_given? }
17
+ end
14
18
  def get(key)
15
19
  EM.next_tick { yield @db[key] }
16
20
  end
21
+ def set(key, value)
22
+ @db[key] = value
23
+ EM.next_tick { yield if block_given? }
24
+ end
25
+ def hget(key, field)
26
+ EM.next_tick { yield @db[key][field] rescue nil }
27
+ end
17
28
  def hgetall(key)
18
29
  EM.next_tick { yield @db[key] || {} }
19
30
  end
20
- def set(key, value)
21
- @db[key] = value
31
+ def hset(key, field, value)
32
+ @db[key] ||= {}
33
+ @db[key][field] = value
22
34
  EM.next_tick { yield if block_given? }
23
35
  end
24
36
  def hmset(key, *args)
25
37
  @db[key] = Hash[*args]
26
38
  EM.next_tick { yield if block_given? }
27
39
  end
28
- def del(key)
29
- @db.delete(key)
30
- EM.next_tick { yield if block_given? }
31
- end
32
40
  def flushdb
33
41
  @db.clear
34
42
  EM.next_tick { yield if block_given? }
@@ -51,7 +59,8 @@ class RedisTest < MiniTest::Unit::TestCase
51
59
  {'name' => 'Contact1', 'groups' => %w[Group1 Group2]}.to_json,
52
60
  'contact2@wonderland.lit',
53
61
  {'name' => 'Contact2', 'groups' => %w[Group3 Group4]}.to_json)
54
- db.set('vcard:full@wonderland.lit', {'card' => StorageTests::VCARD.to_xml}.to_json)
62
+ db.set('vcard:full@wonderland.lit', {'card' => VCARD.to_xml}.to_json)
63
+ db.hset('fragments:full@wonderland.lit', FRAGMENT_ID, {'xml' => FRAGMENT.to_xml}.to_json)
55
64
  end
56
65
  end
57
66
 
@@ -25,7 +25,7 @@ class SqlTest < MiniTest::Unit::TestCase
25
25
  :jid => 'full@wonderland.lit',
26
26
  :name => 'Tester',
27
27
  :password => BCrypt::Password.create('secret'),
28
- :vcard => StorageTests::VCARD.to_xml)
28
+ :vcard => VCARD.to_xml)
29
29
  full.contacts << Vines::Storage::Sql::Contact.new(
30
30
  :jid => 'contact1@wonderland.lit',
31
31
  :name => 'Contact1',
@@ -37,6 +37,13 @@ class SqlTest < MiniTest::Unit::TestCase
37
37
  :groups => groups[2, 2],
38
38
  :subscription => 'both')
39
39
  full.save
40
+
41
+ fragment = Vines::Storage::Sql::Fragment.new(
42
+ :user => full,
43
+ :root => 'characters',
44
+ :namespace => 'urn:wonderland',
45
+ :xml => FRAGMENT.to_xml)
46
+ fragment.save
40
47
  end
41
48
 
42
49
  def teardown
@@ -8,6 +8,14 @@ require 'minitest/autorun'
8
8
  # tests are the same regardless of implementation so share those methods
9
9
  # here.
10
10
  module StorageTests
11
+ FRAGMENT_ID = Digest::SHA1.hexdigest("characters:urn:wonderland")
12
+
13
+ FRAGMENT = Nokogiri::XML(%q{
14
+ <characters xmlns="urn:wonderland">
15
+ <character>Alice</character>
16
+ </characters>
17
+ }.strip).root
18
+
11
19
  VCARD = Nokogiri::XML(%q{
12
20
  <vCard xmlns="vcard-temp">
13
21
  <FN>Alice in Wonderland</FN>
@@ -131,4 +139,46 @@ module StorageTests
131
139
  assert_equal VCARD, card
132
140
  end
133
141
  end
142
+
143
+ def test_find_fragment
144
+ EMLoop.new do
145
+ db = storage
146
+ root = Nokogiri::XML(%q{<characters xmlns="urn:wonderland"/>}).root
147
+ bad_name = Nokogiri::XML(%q{<not_characters xmlns="urn:wonderland"/>}).root
148
+ bad_ns = Nokogiri::XML(%q{<characters xmlns="not:wonderland"/>}).root
149
+
150
+ node = db.find_fragment(nil, nil)
151
+ assert_nil node
152
+
153
+ node = db.find_fragment('full@wonderland.lit', bad_name)
154
+ assert_nil node
155
+
156
+ node = db.find_fragment('full@wonderland.lit', bad_ns)
157
+ assert_nil node
158
+
159
+ node = db.find_fragment('full@wonderland.lit', root)
160
+ refute_nil node
161
+ assert_equal FRAGMENT, node
162
+
163
+ node = db.find_fragment(Vines::JID.new('full@wonderland.lit'), root)
164
+ refute_nil node
165
+ assert_equal FRAGMENT, node
166
+
167
+ node = db.find_fragment(Vines::JID.new('full@wonderland.lit/resource'), root)
168
+ refute_nil node
169
+ assert_equal FRAGMENT, node
170
+ end
171
+ end
172
+
173
+ def test_save_fragment
174
+ EMLoop.new do
175
+ db = storage
176
+ root = Nokogiri::XML(%q{<characters xmlns="urn:wonderland"/>}).root
177
+ db.save_user(Vines::User.new(:jid => 'save_user@domain.tld'))
178
+ db.save_fragment('save_user@domain.tld/resource1', FRAGMENT)
179
+ node = db.find_fragment('save_user@domain.tld', root)
180
+ refute_nil node
181
+ assert_equal FRAGMENT, node
182
+ end
183
+ end
134
184
  end
@@ -11,16 +11,19 @@ class HttpAuthTest < MiniTest::Unit::TestCase
11
11
 
12
12
  def test_missing_body_raises_error
13
13
  node = node('<presence type="unavailable"/>')
14
+ @stream.expect(:valid_session?, true, [nil])
14
15
  assert_raises(Vines::StreamErrors::NotAuthorized) { @state.node(node) }
15
16
  end
16
17
 
17
18
  def test_body_with_missing_namespace_raises_error
18
19
  node = node('<body rid="42" sid="12"/>')
20
+ @stream.expect(:valid_session?, true, ['12'])
19
21
  assert_raises(Vines::StreamErrors::NotAuthorized) { @state.node(node) }
20
22
  end
21
23
 
22
24
  def test_missing_rid_raises_error
23
25
  node = node('<body xmlns="http://jabber.org/protocol/httpbind" sid="12"/>')
26
+ @stream.expect(:valid_session?, true, ['12'])
24
27
  assert_raises(Vines::StreamErrors::NotAuthorized) { @state.node(node) }
25
28
  end
26
29
 
@@ -11,16 +11,19 @@ class HttpReadyTest < MiniTest::Unit::TestCase
11
11
 
12
12
  def test_missing_body_raises_error
13
13
  node = node('<presence type="unavailable"/>')
14
+ @stream.expect(:valid_session?, true, [nil])
14
15
  assert_raises(Vines::StreamErrors::NotAuthorized) { @state.node(node) }
15
16
  end
16
17
 
17
18
  def test_body_with_missing_namespace_raises_error
18
19
  node = node('<body rid="42" sid="12"/>')
20
+ @stream.expect(:valid_session?, true, ['12'])
19
21
  assert_raises(Vines::StreamErrors::NotAuthorized) { @state.node(node) }
20
22
  end
21
23
 
22
24
  def test_missing_rid_raises_error
23
25
  node = node('<body xmlns="http://jabber.org/protocol/httpbind" sid="12"/>')
26
+ @stream.expect(:valid_session?, true, ['12'])
24
27
  assert_raises(Vines::StreamErrors::NotAuthorized) { @state.node(node) }
25
28
  end
26
29
 
@@ -0,0 +1,86 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'vines'
4
+ require 'minitest/autorun'
5
+
6
+ class RequestTest < MiniTest::Unit::TestCase
7
+ PASSWORD = File.expand_path('../passwords')
8
+ INDEX = File.expand_path('index.html')
9
+
10
+ def setup
11
+ File.open(PASSWORD, 'w') {|f| f.puts '/etc/passwd contents' }
12
+ File.open(INDEX, 'w') {|f| f.puts 'index.html contents' }
13
+
14
+ @stream = MiniTest::Mock.new
15
+ @parser = MiniTest::Mock.new
16
+ @parser.expect(:headers, {'Content-Type' => 'text/html'})
17
+ @parser.expect(:http_method, 'GET')
18
+ @parser.expect(:request_path, '/blogs/12')
19
+ @parser.expect(:request_url, '/blogs/12?ok=true')
20
+ @parser.expect(:query_string, 'ok=true')
21
+ end
22
+
23
+ def teardown
24
+ File.delete(PASSWORD)
25
+ File.delete(INDEX)
26
+ end
27
+
28
+ def test_copies_request_info_from_parser
29
+ request = Vines::Stream::Http::Request.new(@stream, @parser, '<html></html>')
30
+ assert_equal request.headers, {'Content-Type' => 'text/html'}
31
+ assert_equal request.method, 'GET'
32
+ assert_equal request.path, '/blogs/12'
33
+ assert_equal request.url, '/blogs/12?ok=true'
34
+ assert_equal request.query, 'ok=true'
35
+ assert_equal request.body, '<html></html>'
36
+ assert @stream.verify
37
+ assert @parser.verify
38
+ end
39
+
40
+ def test_reply_with_file_404
41
+ request = Vines::Stream::Http::Request.new(@stream, @parser, '<html></html>')
42
+
43
+ expected = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n"
44
+ @stream.expect(:stream_write, nil, [expected])
45
+ @stream.expect(:close_connection_after_writing, nil)
46
+
47
+ request.reply_with_file(Dir.pwd)
48
+ assert @stream.verify
49
+ assert @parser.verify
50
+ end
51
+
52
+ def test_reply_with_file_directory_traversal
53
+ @parser.expect(:request_path, '../passwords')
54
+ request = Vines::Stream::Http::Request.new(@stream, @parser, '<html></html>')
55
+
56
+ expected = "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n"
57
+ @stream.expect(:stream_write, nil, [expected])
58
+ @stream.expect(:close_connection_after_writing, nil)
59
+
60
+ request.reply_with_file(Dir.pwd)
61
+ assert @stream.verify
62
+ assert @parser.verify
63
+ end
64
+
65
+ def test_reply_with_file_for_directory_serves_index_html
66
+ @parser.expect(:request_path, '/')
67
+ request = Vines::Stream::Http::Request.new(@stream, @parser, '<html></html>')
68
+
69
+ mtime = File.mtime(INDEX).utc.strftime('%a, %d %b %Y %H:%M:%S GMT')
70
+ headers = [
71
+ "HTTP/1.1 200 OK",
72
+ "Connection: close",
73
+ 'Content-Type: text/html; charset="utf-8"',
74
+ "Content-Length: 20",
75
+ "Last-Modified: #{mtime}"
76
+ ].join("\r\n")
77
+
78
+ @stream.expect(:stream_write, nil, ["#{headers}\r\n\r\n"])
79
+ @stream.expect(:stream_write, nil, ["index.html contents\n"])
80
+ @stream.expect(:close_connection_after_writing, nil)
81
+
82
+ request.reply_with_file(Dir.pwd)
83
+ assert @stream.verify
84
+ assert @parser.verify
85
+ end
86
+ end
@@ -31,6 +31,8 @@ class ParserTest < MiniTest::Unit::TestCase
31
31
  ['<iq id="42" type="get"><query xmlns="http://jabber.org/protocol/disco#info"></query></iq>', Vines::Stanza::Iq::Query::DiscoInfo],
32
32
  ['<iq id="42" type="get"><query xmlns="http://jabber.org/protocol/disco#items"></query></iq>', Vines::Stanza::Iq::Query::DiscoItems],
33
33
  ['<iq id="42" type="error"></iq>', Vines::Stanza::Iq::Error],
34
+ ['<iq id="42" type="get"><query xmlns="jabber:iq:private"/></iq>', Vines::Stanza::Iq::PrivateStorage],
35
+ ['<iq id="42" type="set"><query xmlns="jabber:iq:private"/></iq>', Vines::Stanza::Iq::PrivateStorage],
34
36
  ['<iq id="42" type="get"><ping xmlns="urn:xmpp:ping"/></iq>', Vines::Stanza::Iq::Ping],
35
37
  ['<iq id="42" type="result"></iq>', Vines::Stanza::Iq::Result],
36
38
  ['<iq id="42" type="get"><query xmlns="jabber:iq:roster"/></iq>', Vines::Stanza::Iq::Query::Roster],
@@ -0,0 +1,43 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="apple-mobile-web-app-capable" content="yes"/>
6
+ <title>Vines</title>
7
+ <link rel="shortcut icon" type="image/png" href="/favicon.png"/>
8
+ <link rel="stylesheet" href="/lib/stylesheets/base.css"/>
9
+ <style type="text/css">
10
+ body {
11
+ background: -moz-radial-gradient(#1a3762, #0c1a2d);
12
+ background: -webkit-gradient(radial, 50% 50%, 0, 50% 50%, 500, from(#1a3762), to(#0c1a2d));
13
+ display: table;
14
+ text-align: center;
15
+ width: 100%;
16
+ }
17
+ header {
18
+ display: table-cell;
19
+ vertical-align: middle;
20
+ width: 100%;
21
+ }
22
+ h1 {
23
+ background: url(/lib/images/logo-large.png) no-repeat center;
24
+ color: transparent;
25
+ height: 64px;
26
+ text-shadow: none;
27
+ width: 100%;
28
+ }
29
+ p {
30
+ color: #fff;
31
+ font-size: 11pt;
32
+ margin: 20px auto;
33
+ text-shadow: 0 1px 1px #000;
34
+ }
35
+ </style>
36
+ </head>
37
+ <body>
38
+ <header>
39
+ <h1>Page not found</h1>
40
+ <p>This is not the page you're looking for.</p>
41
+ </header>
42
+ </body>
43
+ </html>
Binary file
@@ -0,0 +1,385 @@
1
+ class ChatPage
2
+ constructor: (@session) ->
3
+ @session.onRoster ( ) => this.roster()
4
+ @session.onCard (c) => this.card(c)
5
+ @session.onMessage (m) => this.message(m)
6
+ @session.onPresence (p) => this.presence(p)
7
+ @chats = {}
8
+ @currentContact = null
9
+
10
+ datef: (millis) ->
11
+ d = new Date(millis)
12
+ meridian = if d.getHours() >= 12 then ' pm' else ' am'
13
+ hour = if d.getHours() > 12 then d.getHours() - 12 else d.getHours()
14
+ hour = 12 if hour == 0
15
+ minutes = d.getMinutes() + ''
16
+ minutes = '0' + minutes if minutes.length == 1
17
+ hour + ':' + minutes + meridian
18
+
19
+ card: (card) ->
20
+ this.eachContact card.jid, (node) =>
21
+ $('.vcard-img', node).attr 'src', @session.avatar card.jid
22
+
23
+ roster: ->
24
+ roster = $('#roster')
25
+
26
+ $('li', roster).each (ix, node) =>
27
+ jid = $(node).attr('data-jid')
28
+ $(node).remove() unless @session.roster[jid]
29
+
30
+ setName = (node, contact) ->
31
+ $('.text', node).text contact.name || contact.jid
32
+ node.attr 'data-name', contact.name || ''
33
+
34
+ for jid, contact of @session.roster
35
+ found = $("#roster li[data-jid='#{jid}']")
36
+ setName(found, contact)
37
+ if found.length == 0
38
+ node = $("""
39
+ <li data-jid="#{jid}" data-name="" class="offline">
40
+ <span class="text"></span>
41
+ <span class="status-msg">Offline</span>
42
+ <span class="unread" style="display:none;"></span>
43
+ <img class="vcard-img" alt="#{jid}" src="#{@session.avatar jid}"/>
44
+ </li>
45
+ """).appendTo roster
46
+ setName(node, contact)
47
+ node.click (event) => this.selectContact(event)
48
+
49
+ message: (message) ->
50
+ this.queueMessage message
51
+ me = message.from == @session.jid()
52
+ from = message.from.split('/')[0]
53
+
54
+ if me || from == @currentContact
55
+ bottom = this.atBottom()
56
+ this.appendMessage message
57
+ this.scroll() if bottom
58
+ else
59
+ chat = this.chat message.from
60
+ chat.unread++
61
+ this.eachContact from, (node) ->
62
+ $('.unread', node).text(chat.unread).show()
63
+
64
+ eachContact: (jid, callback) ->
65
+ for node in $("#roster li[data-jid='#{jid}']").get()
66
+ callback $(node)
67
+
68
+ appendMessage: (message) ->
69
+ from = message.from.split('/')[0]
70
+ contact = @session.roster[from]
71
+ name = if contact then (contact.name || from) else from
72
+ name = 'Me' if message.from == @session.jid()
73
+ node = $("""
74
+ <li data-jid="#{from}" style="display:none;">
75
+ <p></p>
76
+ <img alt="#{from}" src="#{@session.avatar from}"/>
77
+ <footer>
78
+ <span class="author"></span>
79
+ <span class="time">#{this.datef message.received}</span>
80
+ </footer>
81
+ </li>
82
+ """).appendTo '#messages'
83
+
84
+ $('p', node).text message.text
85
+ $('.author', node).text name
86
+ node.fadeIn 200
87
+
88
+ queueMessage: (message) ->
89
+ me = message.from == @session.jid()
90
+ full = message[if me then 'to' else 'from']
91
+ chat = this.chat full
92
+ chat.jid = full
93
+ chat.messages.push message
94
+
95
+ chat: (jid) ->
96
+ bare = jid.split('/')[0]
97
+ chat = @chats[bare]
98
+ unless chat
99
+ chat = jid: jid, messages: [], unread: 0
100
+ @chats[bare] = chat
101
+ chat
102
+
103
+ presence: (presence) ->
104
+ from = presence.from.split('/')[0]
105
+ return if from == @session.bareJid()
106
+ if !presence.type || presence.offline
107
+ contact = @session.roster[from]
108
+ this.eachContact from, (node) ->
109
+ $('.status-msg', node).text contact.status()
110
+ if contact.offline()
111
+ node.addClass 'offline'
112
+ else
113
+ node.removeClass 'offline'
114
+
115
+ if presence.offline
116
+ this.chat(from).jid = from
117
+
118
+ if presence.type == 'subscribe'
119
+ node = $("""
120
+ <li data-jid="#{presence.from}" style="display:none;">
121
+ <form class="notify-form">
122
+ <h2>Buddy Approval</h2>
123
+ <p>#{presence.from} wants to add you as a buddy.</p>
124
+ <fieldset class="buttons">
125
+ <input type="button" value="Decline"/>
126
+ <input type="submit" value="Accept"/>
127
+ </fieldset>
128
+ </form>
129
+ </li>
130
+ """).appendTo '#notifications'
131
+ node.fadeIn 200
132
+ $('form', node).submit => this.acceptContact node, presence.from
133
+ $('input[type="button"]', node).click => this.rejectContact node, presence.from
134
+
135
+ acceptContact: (node, jid) ->
136
+ node.fadeOut 200, -> node.remove()
137
+ @session.sendSubscribed jid
138
+ @session.sendSubscribe jid
139
+ false
140
+
141
+ rejectContact: (node, jid) ->
142
+ node.fadeOut 200, -> node.remove()
143
+ @session.sendUnsubscribed jid
144
+
145
+ selectContact: (event) ->
146
+ jid = $(event.currentTarget).attr 'data-jid'
147
+ contact = @session.roster[jid]
148
+ return if @currentContact == jid
149
+ @currentContact = jid
150
+
151
+ $('#roster li').removeClass 'selected'
152
+ $(event.currentTarget).addClass 'selected'
153
+ $('#chat-title').text('Chat with ' + (contact.name || contact.jid))
154
+ $('#messages').empty()
155
+
156
+ chat = @chats[jid]
157
+ messages = []
158
+ if chat
159
+ messages = chat.messages
160
+ chat.unread = 0
161
+ this.eachContact jid, (node) ->
162
+ $('.unread', node).text('').hide()
163
+
164
+ this.appendMessage msg for msg in messages
165
+ this.scroll()
166
+
167
+ $('#remove-contact-msg').html "Are you sure you want to remove " +
168
+ "<strong>#{@currentContact}</strong> from your buddy list?"
169
+ $('#remove-contact-form .buttons').fadeIn 200
170
+
171
+ $('#edit-contact-jid').text @currentContact
172
+ $('#edit-contact-name').val @session.roster[@currentContact].name
173
+ $('#edit-contact-form input').fadeIn 200
174
+ $('#edit-contact-form .buttons').fadeIn 200
175
+
176
+ scroll: ->
177
+ msgs = $ '#messages'
178
+ msgs.animate(scrollTop: msgs.prop('scrollHeight'), 400)
179
+
180
+ atBottom: ->
181
+ msgs = $('#messages')
182
+ bottom = msgs.prop('scrollHeight') - msgs.height()
183
+ msgs.scrollTop() == bottom
184
+
185
+ send: ->
186
+ return false unless @currentContact
187
+ input = $('#message')
188
+ text = input.val().trim()
189
+ if text
190
+ chat = @chats[@currentContact]
191
+ jid = if chat then chat.jid else @currentContact
192
+ this.message
193
+ from: @session.jid()
194
+ text: text
195
+ to: jid
196
+ received: new Date()
197
+ @session.sendMessage jid, text
198
+ input.val ''
199
+ false
200
+
201
+ addContact: ->
202
+ this.toggleForm '#add-contact-form'
203
+ contact =
204
+ jid: $('#add-contact-jid').val()
205
+ name: $('#add-contact-name').val()
206
+ groups: ['Buddies']
207
+ @session.updateContact contact, true if contact.jid
208
+ false
209
+
210
+ removeContact: ->
211
+ this.toggleForm '#remove-contact-form'
212
+ @session.removeContact @currentContact
213
+ @currentContact = null
214
+
215
+ $('#chat-title').text 'Select a buddy to chat'
216
+ $('#messages').empty()
217
+
218
+ $('#remove-contact-msg').html "Select a buddy in the list above to remove."
219
+ $('#remove-contact-form .buttons').hide()
220
+
221
+ $('#edit-contact-jid').text "Select a buddy in the list above to update."
222
+ $('#edit-contact-name').val ''
223
+ $('#edit-contact-form input').hide()
224
+ $('#edit-contact-form .buttons').hide()
225
+ false
226
+
227
+ updateContact: ->
228
+ this.toggleForm '#edit-contact-form'
229
+ contact =
230
+ jid: @currentContact
231
+ name: $('#edit-contact-name').val()
232
+ groups: @session.roster[@currentContact].groups
233
+ @session.updateContact contact
234
+ false
235
+
236
+ toggleForm: (form, fn) ->
237
+ form = $(form)
238
+ $('.contact-form').each ->
239
+ $(this).hide() unless this.id == form.attr 'id'
240
+ if form.is ':hidden'
241
+ fn() if fn
242
+ form.fadeIn 100
243
+ else
244
+ form.fadeOut 100, ->
245
+ form[0].reset()
246
+ fn() if fn
247
+
248
+ filterRoster: ->
249
+ text = $('#search-roster-text').val().toLowerCase()
250
+ if text == ''
251
+ $('#roster li').show()
252
+ return
253
+
254
+ $('#roster li').each ->
255
+ node = $(this)
256
+ jid = (node.attr('data-jid') || '').toLowerCase()
257
+ name = (node.attr('data-name') || '').toLowerCase()
258
+ match = jid.indexOf(text) != -1 || name.indexOf(text) != -1
259
+ if match then node.show() else node.hide()
260
+
261
+ draw: ->
262
+ unless @session.connected()
263
+ window.location.hash = ''
264
+ return
265
+
266
+ $('body').attr 'id', 'chat-page'
267
+ $('#container').hide().empty()
268
+ $("""
269
+ <div id="alpha" class="y-fill">
270
+ <h2>Buddies <div id="search-roster"></div></h2>
271
+ <form id="search-roster-form" style="display:none;">
272
+ <input id="search-roster-text" type="search" placeholder="Filter" results="5"/>
273
+ </form>
274
+ <ul id="roster" class="y-fill"></ul>
275
+ <div id="roster-controls">
276
+ <div id="add-contact"></div>
277
+ <div id="remove-contact"></div>
278
+ <div id="edit-contact"></div>
279
+ </div>
280
+ <form id="add-contact-form" class="contact-form" style="display:none;">
281
+ <h2>Add Buddy</h2>
282
+ <input id="add-contact-jid" type="email" maxlength="1024" placeholder="Account name"/>
283
+ <input id="add-contact-name" type="text" maxlength="1024" placeholder="Real name"/>
284
+ <fieldset class="buttons">
285
+ <input id="add-contact-cancel" type="button" value="Cancel"/>
286
+ <input id="add-contact-ok" type="submit" value="Add"/>
287
+ </fieldset>
288
+ </form>
289
+ <form id="remove-contact-form" class="contact-form" style="display:none;">
290
+ <h2>Remove Buddy</h2>
291
+ <p id="remove-contact-msg">Select a buddy in the list above to remove.</p>
292
+ <fieldset class="buttons" style="display:none;">
293
+ <input id="remove-contact-cancel" type="button" value="Cancel"/>
294
+ <input id="remove-contact-ok" type="submit" value="Remove"/>
295
+ </fieldset>
296
+ </form>
297
+ <form id="edit-contact-form" class="contact-form" style="display:none;">
298
+ <h2>Update Profile</h2>
299
+ <p id="edit-contact-jid">Select a buddy in the list above to update.</p>
300
+ <input id="edit-contact-name" type="text" maxlength="1024" placeholder="Real name" style="display:none;"/>
301
+ <fieldset class="buttons" style="display:none;">
302
+ <input id="edit-contact-cancel" type="button" value="Cancel"/>
303
+ <input id="edit-contact-ok" type="submit" value="Save"/>
304
+ </fieldset>
305
+ </form>
306
+ </div>
307
+ <div id="beta" class="x-fill y-fill">
308
+ <h2 id="chat-title">Select a buddy to chat</h2>
309
+ <ul id="messages" class="y-fill"></ul>
310
+ <form id="message-form">
311
+ <input id="message" name="message" type="text" maxlength="1024" placeholder="Type a message and press enter to send"/>
312
+ </form>
313
+ </div>
314
+ <div id="charlie" class="y-fill">
315
+ <h2>Notifications</h2>
316
+ <ul id="notifications" class="y-fill"></ul>
317
+ <div id="notification-controls">
318
+ <div id="clear-notices"></div>
319
+ </div>
320
+ </div>
321
+ """).appendTo '#container'
322
+
323
+ this.roster()
324
+ this.button 'clear-notices', ICONS.no
325
+ this.button 'add-contact', ICONS.plus
326
+ this.button 'remove-contact', ICONS.minus
327
+ this.button 'edit-contact', ICONS.user
328
+ this.button 'search-roster', ICONS.search, scale: 0.5, translation: '-8 -8'
329
+
330
+ $('#message').focus -> $('.contact-form').fadeOut()
331
+ $('#message-form').submit => this.send()
332
+
333
+ $('#clear-notices').click -> $('#notifications li').fadeOut 200
334
+
335
+ $('#add-contact').click => this.toggleForm '#add-contact-form'
336
+ $('#remove-contact').click => this.toggleForm '#remove-contact-form'
337
+ $('#edit-contact').click => this.toggleForm '#edit-contact-form', =>
338
+ if @currentContact
339
+ $('#edit-contact-jid').text @currentContact
340
+ $('#edit-contact-name').val @session.roster[@currentContact].name
341
+
342
+ $('#add-contact-cancel').click => this.toggleForm '#add-contact-form'
343
+ $('#remove-contact-cancel').click => this.toggleForm '#remove-contact-form'
344
+ $('#edit-contact-cancel').click => this.toggleForm '#edit-contact-form'
345
+
346
+ $('#add-contact-form').submit => this.addContact()
347
+ $('#remove-contact-form').submit => this.removeContact()
348
+ $('#edit-contact-form').submit => this.updateContact()
349
+ $('#search-roster-form').submit -> false
350
+
351
+ $('#search-roster-text').keyup => this.filterRoster()
352
+ $('#search-roster-text').change => this.filterRoster()
353
+ $('#search-roster-text').click => this.filterRoster()
354
+ $('#search-roster').click =>
355
+ this.toggleForm '#search-roster-form', => this.filterRoster()
356
+
357
+ $('#container').fadeIn 200
358
+ this.resize()
359
+
360
+ resize: ->
361
+ a = $ '#alpha'
362
+ b = $ '#beta'
363
+ c = $ '#charlie'
364
+ msg = $ '#message'
365
+ form = $ '#message-form'
366
+ new Layout ->
367
+ c.css 'left', a.width() + b.width()
368
+ msg.width form.width() - 32
369
+
370
+ button: (id, path, options) ->
371
+ options ||= {}
372
+ paper = Raphael(id)
373
+ icon = paper.path(path).attr
374
+ fill: '#000'
375
+ stroke: '#fff'
376
+ 'stroke-width': 0.3
377
+ opacity: 0.6
378
+ scale: options.scale || 0.85
379
+ translation: options.translation || ''
380
+
381
+ node = $('#' + id)
382
+ node.hover(
383
+ -> icon.animate(opacity: 1.0, 200),
384
+ -> icon.animate(opacity: 0.6, 200))
385
+ node.get 0