vines 0.1.1 → 0.2.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.
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