├── .gitignore ├── .rspec ├── .rvmrc ├── .simplecov ├── Gemfile ├── Gemfile.lock ├── Guardfile ├── README.md ├── Rakefile ├── cucumber.yml ├── features ├── end_to_end.feature ├── step_definitions │ ├── auction_steps.rb │ └── sniper_steps.rb └── support │ ├── application_runner.rb │ ├── auction_log_driver.rb │ ├── blather.rb │ ├── env.rb │ ├── event_machine.rb │ ├── runs_application.rb │ ├── runs_fake_auction.rb │ └── start_xmpp_server.rb ├── lib ├── announcer.rb ├── auction_sniper.rb ├── column.rb ├── defect.rb ├── item.rb ├── main.rb ├── sniper_launcher.rb ├── sniper_portfolio.rb ├── sniper_snapshot.rb ├── sniper_state.rb ├── ui │ ├── main_window.rb │ └── snipers_table_model.rb ├── ui_thread_sniper_listener.rb └── xmpp │ ├── auction_message_translator.rb │ ├── chat.rb │ ├── logging_xmpp_failure_reporter.rb │ ├── xmpp_auction.rb │ └── xmpp_auction_house.rb ├── log └── .gitkeep ├── spec ├── announcer_spec.rb ├── auction_sniper_spec.rb ├── column_spec.rb ├── item_spec.rb ├── sniper_launcher_spec.rb ├── sniper_portfolio_spec.rb ├── sniper_snapshot_spec.rb ├── sniper_state_spec.rb ├── spec_helper.rb ├── support │ └── roles │ │ ├── auction.rb │ │ ├── auction_event_listener.rb │ │ ├── portfolio_listener.rb │ │ ├── sniper_collector.rb │ │ └── sniper_listener.rb ├── ui │ ├── main_window_spec.rb │ └── snipers_table_model_spec.rb ├── ui_thread_sniper_listener_spec.rb └── xmpp │ ├── auction_message_translator_spec.rb │ ├── chat_spec.rb │ ├── logging_xmpp_failure_reporter_spec.rb │ ├── xmpp_auction_house_spec.rb │ └── xmpp_auction_spec.rb ├── test_support ├── auction_sniper_driver.rb └── fake_auction_server.rb └── vines ├── conf ├── certs │ ├── README │ ├── ca-bundle.crt │ ├── localhost.crt │ └── localhost.key └── config.rb ├── data └── user │ ├── auction-item-54321@localhost │ ├── auction-item-65432@localhost │ └── sniper@localhost └── web ├── 404.html ├── apple-touch-icon.png ├── chat ├── coffeescripts │ ├── chat.coffee │ └── init.coffee ├── index.html ├── javascripts │ └── app.js └── stylesheets │ └── chat.css ├── favicon.png └── lib ├── coffeescripts ├── button.coffee ├── contact.coffee ├── filter.coffee ├── layout.coffee ├── login.coffee ├── logout.coffee ├── navbar.coffee ├── notification.coffee ├── router.coffee ├── session.coffee └── transfer.coffee ├── images ├── dark-gray.png ├── default-user.png ├── light-gray.png ├── logo-large.png ├── logo-small.png └── white.png ├── javascripts ├── base.js ├── icons.js ├── jquery.cookie.js ├── jquery.js ├── raphael.js └── strophe.js └── stylesheets ├── base.css └── login.css /.gitignore: -------------------------------------------------------------------------------- 1 | log 2 | vcard 3 | pid 4 | .bundle 5 | bin 6 | tmp 7 | coverage 8 | sniper.log 9 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require "spec_helper" 2 | --color 3 | -------------------------------------------------------------------------------- /.rvmrc: -------------------------------------------------------------------------------- 1 | rvm use --create --install ruby-2.0.0-p247@goos 2 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | SimpleCov.start do 2 | add_filter "features" 3 | add_filter "spec" 4 | add_filter "test_support" 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "blather" 4 | gem "gtk2" 5 | gem "rake" 6 | gem "vines" 7 | 8 | group :development do 9 | gem "growl" 10 | gem "guard" 11 | gem "guard-cucumber" 12 | gem "guard-rspec" 13 | gem "cucumber" 14 | gem "debugger" 15 | gem "rspec", "~> 2.14.0.rc1" 16 | gem "simplecov", require: false 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (4.0.0) 5 | i18n (~> 0.6, >= 0.6.4) 6 | minitest (~> 4.2) 7 | multi_json (~> 1.3) 8 | thread_safe (~> 0.1) 9 | tzinfo (~> 0.3.37) 10 | atk (2.0.2) 11 | glib2 (= 2.0.2) 12 | atomic (1.1.12) 13 | bcrypt-ruby (3.0.1) 14 | blather (0.8.5) 15 | activesupport (>= 2.3.11) 16 | eventmachine (>= 1.0.0) 17 | girl_friday 18 | niceogiri (~> 1.0) 19 | nokogiri (~> 1.5, >= 1.5.6) 20 | builder (3.2.2) 21 | cairo (1.12.6) 22 | pkg-config 23 | coderay (1.0.9) 24 | columnize (0.3.6) 25 | connection_pool (1.1.0) 26 | cucumber (1.3.6) 27 | builder (>= 2.1.2) 28 | diff-lcs (>= 1.1.3) 29 | gherkin (~> 2.12.0) 30 | multi_json (~> 1.7.5) 31 | multi_test (>= 0.0.2) 32 | debugger (1.6.1) 33 | columnize (>= 0.3.1) 34 | debugger-linecache (~> 1.2.0) 35 | debugger-ruby_core_source (~> 1.2.3) 36 | debugger-linecache (1.2.0) 37 | debugger-ruby_core_source (1.2.3) 38 | diff-lcs (1.2.4) 39 | em-hiredis (0.1.1) 40 | hiredis (~> 0.4.0) 41 | eventmachine (1.0.3) 42 | ffi (1.9.0) 43 | formatador (0.2.4) 44 | gdk_pixbuf2 (2.0.2) 45 | glib2 (= 2.0.2) 46 | gherkin (2.12.1) 47 | multi_json (~> 1.3) 48 | girl_friday (0.11.2) 49 | connection_pool (~> 1.0) 50 | rubinius-actor 51 | glib2 (2.0.2) 52 | pkg-config 53 | growl (1.0.3) 54 | gtk2 (2.0.2) 55 | atk (= 2.0.2) 56 | gdk_pixbuf2 (= 2.0.2) 57 | pango (= 2.0.2) 58 | guard (1.8.2) 59 | formatador (>= 0.2.4) 60 | listen (>= 1.0.0) 61 | lumberjack (>= 1.0.2) 62 | pry (>= 0.9.10) 63 | thor (>= 0.14.6) 64 | guard-cucumber (1.4.0) 65 | cucumber (>= 1.2.0) 66 | guard (>= 1.1.0) 67 | guard-rspec (3.0.2) 68 | guard (>= 1.8) 69 | rspec (~> 2.13) 70 | hiredis (0.4.5) 71 | http_parser.rb (0.5.3) 72 | i18n (0.6.5) 73 | listen (1.2.3) 74 | rb-fsevent (>= 0.9.3) 75 | rb-inotify (>= 0.9) 76 | rb-kqueue (>= 0.2) 77 | lumberjack (1.0.4) 78 | method_source (0.8.2) 79 | minitest (4.7.5) 80 | multi_json (1.7.9) 81 | multi_test (0.0.2) 82 | net-ldap (0.3.1) 83 | niceogiri (1.1.2) 84 | nokogiri (~> 1.5) 85 | nokogiri (1.5.10) 86 | pango (2.0.2) 87 | cairo (>= 1.12.5) 88 | glib2 (= 2.0.2) 89 | pkg-config (1.1.4) 90 | pry (0.9.12.2) 91 | coderay (~> 1.0.5) 92 | method_source (~> 0.8) 93 | slop (~> 3.4) 94 | rake (10.1.0) 95 | rb-fsevent (0.9.3) 96 | rb-inotify (0.9.1) 97 | ffi (>= 0.5.0) 98 | rb-kqueue (0.2.0) 99 | ffi (>= 0.5.0) 100 | rspec (2.14.1) 101 | rspec-core (~> 2.14.0) 102 | rspec-expectations (~> 2.14.0) 103 | rspec-mocks (~> 2.14.0) 104 | rspec-core (2.14.4) 105 | rspec-expectations (2.14.1) 106 | diff-lcs (>= 1.1.3, < 2.0) 107 | rspec-mocks (2.14.3) 108 | rubinius-actor (0.0.2) 109 | rubinius-core-api 110 | rubinius-core-api (0.0.1) 111 | simplecov (0.7.1) 112 | multi_json (~> 1.0) 113 | simplecov-html (~> 0.7.1) 114 | simplecov-html (0.7.1) 115 | slop (3.4.6) 116 | thor (0.18.1) 117 | thread_safe (0.1.2) 118 | atomic 119 | tzinfo (0.3.37) 120 | vines (0.4.7) 121 | bcrypt-ruby (~> 3.0.1) 122 | em-hiredis (~> 0.1.1) 123 | eventmachine (~> 1.0.3) 124 | http_parser.rb (~> 0.5.3) 125 | net-ldap (~> 0.3.1) 126 | nokogiri (~> 1.5.10) 127 | 128 | PLATFORMS 129 | ruby 130 | 131 | DEPENDENCIES 132 | blather 133 | cucumber 134 | debugger 135 | growl 136 | gtk2 137 | guard 138 | guard-cucumber 139 | guard-rspec 140 | rake 141 | rspec (~> 2.14.0.rc1) 142 | simplecov 143 | vines 144 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # A sample Guardfile 2 | # More info at https://github.com/guard/guard#readme 3 | 4 | guard :rspec do 5 | watch(%r{^spec/.+_spec\.rb$}) 6 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 7 | watch('spec/spec_helper.rb') { "spec" } 8 | end 9 | 10 | guard 'cucumber' do 11 | watch(%r{^features/.+\.feature$}) 12 | watch(%r{^features/support/.+$}) { 'features' } 13 | watch(%r{^features/step_definitions/(.+)_steps\.rb$}) { |m| Dir[File.join("**/#{m[1]}.feature")][0] || 'features' } 14 | end 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GOOS in Ruby 2 | 3 | My attempt at implementing the auction sniper example from part II of 4 | [*Growing Object-Oriented Software, Guided By Tests*](http://www.growing-object-oriented-software.com/). 5 | I've tagged the repository at the end of each chapter: 6 | 7 | * [Chapter 10](https://github.com/kerryb/goos-ruby/tree/chapter-10) 8 | * [Chapter 11](https://github.com/kerryb/goos-ruby/tree/chapter-11) 9 | * [Chapter 12](https://github.com/kerryb/goos-ruby/tree/chapter-12) 10 | * [Chapter 13](https://github.com/kerryb/goos-ruby/tree/chapter-13) 11 | * [Chapter 14](https://github.com/kerryb/goos-ruby/tree/chapter-14) 12 | * [Chapter 15](https://github.com/kerryb/goos-ruby/tree/chapter-15) 13 | * [Chapter 16](https://github.com/kerryb/goos-ruby/tree/chapter-16) 14 | * [Chapter 17](https://github.com/kerryb/goos-ruby/tree/chapter-17) 15 | * [Chapter 18](https://github.com/kerryb/goos-ruby/tree/chapter-18) 16 | * [Chapter 19](https://github.com/kerryb/goos-ruby/tree/chapter-19) 17 | 18 | There are links to a few other people's implementations 19 | [on the GOOS site](http://www.growing-object-oriented-software.com/code.html), 20 | including 21 | [another one in Ruby](https://github.com/marick/growing-oo-software-in-ruby) 22 | from Brian Marick. 23 | 24 | ## Tools 25 | 26 | ### Ruby 27 | 28 | Tested using the version in `.ruby-version`. I started in 1.9.3 then switched 29 | to 2.0.0, but as far as I know anything 1.9 or above ought to work. 30 | 31 | ### XMPP 32 | 33 | The [Vines](http://www.getvines.org/) XMPP server is started and stopped 34 | automatically by the tests. 35 | 36 | For the client I tried [xmpp4r](http://home.gna.org/xmpp4r/) and 37 | [xmp4r-simple](https://github.com/blaine/xmpp4r-simple), but couldn't get 38 | either of them to work properly, so ended up with 39 | [Blather](https://github.com/adhearsion/blather). This runs in 40 | [EventMachine](http://rubyeventmachine.com/) so has to be started in its own 41 | thread to avoid blocking the rest of the application. 42 | 43 | ### GUI 44 | 45 | I considered using Swing to keep close to the book, but that would have 46 | restricted the app to only running on JRuby. I tried Tk and Qt, but couldn't 47 | figure out any easy way of testing them, and they didn't seem to take 48 | particularly well to being run in separate threads. 49 | 50 | In the end, I settled on [GTK+](http://www.gtk.org/). The default Mac version 51 | runs in X Windows and is fairly ugly, but it exposes all its widgets cleanly so 52 | I can poke them and assert things about them from the end-to-end tests. 53 | 54 | ### Testing 55 | 56 | I'm using [Cucumber](http://cukes.info/) for end-to-end/acceptance tests, and 57 | [RSpec](http://rspec.info/) (version 2.14.0rc1, so I can use spies) for unit 58 | tests. 59 | 60 | To test the UI, I'm just calling methods directly on the main Gtk::Window 61 | object (which still runs as normal and can be seen on the screen as the tests 62 | run). 63 | 64 | ## Notes 65 | 66 | ### Interfaces and roles 67 | 68 | I'm trying to stick pretty closely to the design used in the book. However, the 69 | book makes heavy use of Java interfaces, which don't exist in Ruby. As a 70 | compromise between explicit interfaces and pure duck-typing, I've created 71 | [specs for the 72 | interfaces](https://github.com/kerryb/goos-ruby/tree/master/spec/support/roles), 73 | which simply check that anything acting as that role implements the correct 74 | methods. 75 | 76 | ### AuctionMessageTranslator 77 | 78 | Unlike Smack, Blather works at the individual message level, rather than 79 | modelling persistent chats. To ensure messages only get sent to the appropriate 80 | listener for each auction, I've added a guard on the message originator when 81 | registering the listener. 82 | 83 | ### Checking state with doubles in specs 84 | 85 | RSpec doesn't have the "states" feature from JMock, but it's easy enough to 86 | replicate by executing a block when the stubbed methods are called, and setting 87 | or checking an instance variable as appropriate. 88 | 89 | ### Enums 90 | 91 | There are no enums in Ruby, so classes like `Column` and `SniperState` are 92 | implemented as (rather inelegant) collections of constants. 93 | 94 | ### Bugs 95 | 96 | There seems to be a race condition on startup – every so often a feature will 97 | fail because (I think) the auction server never receives a join request from 98 | the sniper. 99 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | $:.unshift "lib" 2 | require "main" 3 | require "cucumber" 4 | require "cucumber/rake/task" 5 | require "rspec/core/rake_task" 6 | require "rake/clean" 7 | 8 | CLEAN.include "log/*log", "vines/log/*log", "coverage" 9 | 10 | task :default => [:clean, :spec, :"cucumber:ok", :"cucumber:wip", :success] 11 | 12 | RSpec::Core::RakeTask.new :spec 13 | 14 | namespace :cucumber do 15 | Cucumber::Rake::Task.new(:ok) do |t| 16 | t.cucumber_opts = "--tags ~@wip" 17 | end 18 | 19 | Cucumber::Rake::Task.new(:wip) do |t| 20 | t.cucumber_opts = "--wip --tags @wip" 21 | end 22 | end 23 | 24 | task :success do 25 | red = "\e[31m" 26 | yellow = "\e[33m" 27 | green = "\e[32m" 28 | blue = "\e[34m" 29 | purple = "\e[35m" 30 | bold = "\e[1m" 31 | normal = "\e[0m" 32 | puts "", "#{bold}#{red}*#{yellow}*#{green}*#{blue}*#{purple}* #{green} ALL TESTS PASSED #{purple}*#{blue}*#{green}*#{yellow}*#{red}*#{normal}" 33 | end 34 | 35 | task :run do 36 | system "cd vines; vines start -d" 37 | Thread.new { EM.run } 38 | sleep 0.01 until EM.reactor_running? 39 | main = Main.main "sniper@localhost", "sniper" 40 | until main.ui.destroyed? 41 | sleep 0.1 42 | end 43 | system "cd vines; vines stop" 44 | end 45 | -------------------------------------------------------------------------------- /cucumber.yml: -------------------------------------------------------------------------------- 1 | default: -r test_support -r features 2 | -------------------------------------------------------------------------------- /features/end_to_end.feature: -------------------------------------------------------------------------------- 1 | Feature: End-to-end test 2 | 3 | Scenario: Lose auction by not bidding 4 | Join auction, don't bid, and lose when auction closes 5 | 6 | Given an auction of an item is in progress 7 | When I start bidding in the auction 8 | And the auction closes 9 | Then I should have lost the auction 10 | 11 | Scenario: Place a bid, but still lose 12 | Given an auction of an item is in progress 13 | When I start bidding in the auction 14 | And I am told the current price, bid increment and high bidder 15 | Then I should place a higher bid 16 | When the auction closes 17 | Then I should have lost the auction 18 | 19 | Scenario: Place a bid and win 20 | Given an auction of an item is in progress 21 | When I start bidding in the auction 22 | And I am told the current price, bid increment and high bidder 23 | Then I should place a higher bid 24 | When I am told that I am the high bidder 25 | And the auction closes 26 | Then I should have won the auction 27 | 28 | Scenario: Bid for multiple items 29 | Given auctions of two items are in progress 30 | When I bid in both auctions 31 | Then I should win both items 32 | 33 | Scenario: Lose an auction because bids go over my stop price 34 | Given an auction of an item is in progress 35 | When I start bidding in the auction, specifying a stop price 36 | And other bidders push the auction over my stop price 37 | And the auction closes 38 | Then I should have lost the auction 39 | 40 | Scenario: Abandon and report failure after invalid auction message 41 | Given auctions of two items are in progress 42 | When I bid in both auctions 43 | And I receive an invalid event from one auction 44 | Then that auction should be shown as failed 45 | When I receive further events from both auctions 46 | Then I should bid on the second item as normal 47 | And the message from the failed auction should be logged 48 | And the first auction should still be shown as failed 49 | -------------------------------------------------------------------------------- /features/step_definitions/auction_steps.rb: -------------------------------------------------------------------------------- 1 | Given "an auction of an item is in progress" do 2 | start_auction_for_item("item-54321") 3 | end 4 | 5 | Given "auctions of two items are in progress" do 6 | start_auction_for_item("item-54321") 7 | start_auction_for_item("item-65432") 8 | end 9 | 10 | When "I am told the current price, bid increment and high bidder" do 11 | auction.report_price 1000, 98, "other bidder" 12 | end 13 | 14 | When "I am told that I am the high bidder" do 15 | auction.report_price 1098, 97, ApplicationRunner::SNIPER_ID 16 | expect(sniper).to be_winning_auction auction 17 | end 18 | 19 | When "other bidders push the auction over my stop price" do 20 | auction.report_price 1000, 98, "other bidder" 21 | expect(sniper).to be_bidding auction, 1000, 1098 22 | auction.wait_for_sniper_to_bid 1098, ApplicationRunner::SNIPER_ID 23 | auction.report_price 1197, 10, "third party" 24 | expect(sniper).to be_losing_auction auction, 1197, 1098 25 | auction.report_price 1207, 10, "fourth party" 26 | expect(sniper).to be_losing_auction auction, 1207, 1098 27 | end 28 | 29 | When "the auction closes" do 30 | auction.close 31 | end 32 | 33 | When "I receive an invalid event from one auction" do 34 | auction_1.send_invalid_message_containing "a broken message" 35 | end 36 | 37 | When "I receive further events from both auctions" do 38 | auction_1.report_price 1000, 98, "other bidder" 39 | auction_2.report_price 500, 21, "other bidder" 40 | end 41 | -------------------------------------------------------------------------------- /features/step_definitions/sniper_steps.rb: -------------------------------------------------------------------------------- 1 | When "I start bidding in the auction" do 2 | sniper.start_bidding_in auction => 999999 3 | auction.wait_for_join_request_from_sniper ApplicationRunner::SNIPER_ID 4 | end 5 | 6 | When "I start bidding in the auction, specifying a stop price" do 7 | sniper.start_bidding_in auction => 1100 8 | auction.wait_for_join_request_from_sniper ApplicationRunner::SNIPER_ID 9 | end 10 | 11 | When "I bid in both auctions" do 12 | sniper.start_bidding_in auction_1 => 999999, auction_2 => 999999 13 | auction_1.wait_for_join_request_from_sniper ApplicationRunner::SNIPER_ID 14 | auction_2.wait_for_join_request_from_sniper ApplicationRunner::SNIPER_ID 15 | end 16 | 17 | Then "I should place a higher bid" do 18 | expect(sniper).to be_bidding auction, 1000, 1098 19 | auction.wait_for_sniper_to_bid 1098, ApplicationRunner::SNIPER_ID 20 | end 21 | 22 | Then "I should have lost the auction" do 23 | expect(sniper).to have_lost_auction auction 24 | end 25 | 26 | Then "I should have won the auction" do 27 | expect(sniper).to have_won_auction auction 28 | end 29 | 30 | Then "I should win both items" do 31 | auction_1.report_price 1000, 98, "other bidder" 32 | expect(sniper).to be_bidding auction_1, 1000, 1098 33 | auction_1.wait_for_sniper_to_bid 1098, ApplicationRunner::SNIPER_ID 34 | 35 | auction_2.report_price 500, 21, "other bidder" 36 | expect(sniper).to be_bidding auction_2, 500, 521 37 | auction_2.wait_for_sniper_to_bid 521, ApplicationRunner::SNIPER_ID 38 | 39 | auction_1.report_price 1098, 97, ApplicationRunner::SNIPER_ID 40 | auction_2.report_price 521, 20, ApplicationRunner::SNIPER_ID 41 | 42 | expect(sniper).to be_winning_auction auction_1 43 | expect(sniper).to be_winning_auction auction_2 44 | 45 | auction_1.close 46 | auction_2.close 47 | 48 | expect(sniper).to have_won_auction auction_1 49 | expect(sniper).to have_won_auction auction_2 50 | end 51 | 52 | Then %r{(?:that auction should|the first auction should still) be shown as failed} do 53 | expect(sniper).to have_marked_auction_as_failed auction_1 54 | end 55 | 56 | Then "I should bid on the second item as normal" do 57 | expect(sniper).to be_bidding auction_2, 500, 521 58 | end 59 | 60 | Then "the message from the failed auction should be logged" do 61 | expect(sniper).to have_logged_invalid_message "a broken message" 62 | end 63 | -------------------------------------------------------------------------------- /features/support/application_runner.rb: -------------------------------------------------------------------------------- 1 | require "main" 2 | 3 | class ApplicationRunner 4 | SNIPER_ID = "sniper@localhost" 5 | SNIPER_PASSWORD = "sniper" 6 | AuctionState = Struct.new :last_price, :last_bid 7 | 8 | def start_bidding_in auctions_with_stop_prices 9 | auctions = auctions_with_stop_prices.keys 10 | @log_driver = AuctionLogDriver.new 11 | @log_driver.clear_log 12 | @auction_states = Hash[*(auctions.flat_map {|a| [a, AuctionState.new(0, 0)] })] 13 | @application = Main.main SNIPER_ID, SNIPER_PASSWORD 14 | @driver = AuctionSniperDriver.new @application.ui 15 | @driver.wait_for_app_to_start 16 | 17 | auctions.each do |auction| 18 | @driver.start_bidding_for auction.item_id, auctions_with_stop_prices[auction] 19 | @driver.wait_for_displayed_sniper_status auction.item_id, 0, 0, SniperState::JOINING.to_s 20 | end 21 | end 22 | 23 | def stop 24 | @application.stop 25 | end 26 | 27 | def bidding? auction, last_price, last_bid 28 | @auction_states[auction] = AuctionState.new last_price, last_bid 29 | @driver.wait_for_displayed_sniper_status(auction.item_id, 30 | last_price, 31 | last_bid, 32 | SniperState::BIDDING.to_s) 33 | end 34 | 35 | def winning_auction? auction 36 | @driver.wait_for_displayed_sniper_status(auction.item_id, 37 | @auction_states[auction].last_bid, 38 | @auction_states[auction].last_bid, 39 | SniperState::WINNING.to_s) 40 | end 41 | 42 | def losing_auction? auction, last_price, last_bid 43 | @auction_states[auction] = AuctionState.new last_price, last_bid 44 | @driver.wait_for_displayed_sniper_status(auction.item_id, 45 | last_price, 46 | last_bid, 47 | SniperState::LOSING.to_s) 48 | end 49 | 50 | def has_lost_auction? auction 51 | @driver.wait_for_displayed_sniper_status(auction.item_id, 52 | @auction_states[auction].last_price, 53 | @auction_states[auction].last_bid, 54 | SniperState::LOST.to_s) 55 | end 56 | 57 | def has_won_auction? auction 58 | @driver.wait_for_displayed_sniper_status(auction.item_id, 59 | @auction_states[auction].last_bid, 60 | @auction_states[auction].last_bid, 61 | SniperState::WON.to_s) 62 | end 63 | 64 | def has_marked_auction_as_failed? auction 65 | @driver.wait_for_displayed_sniper_status(auction.item_id, 0, 0, 66 | SniperState::FAILED.to_s) 67 | end 68 | 69 | def has_logged_invalid_message? message 70 | @log_driver.has_log_entry_containing_string? message 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /features/support/auction_log_driver.rb: -------------------------------------------------------------------------------- 1 | class AuctionLogDriver 2 | def clear_log 3 | File.open(Xmpp::LoggingXmppFailureReporter::LOG_FILE_NAME, "w") {} 4 | end 5 | 6 | def has_log_entry_containing_string? string 7 | File.read(Xmpp::LoggingXmppFailureReporter::LOG_FILE_NAME).lines.any? {|l| l.include? string } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /features/support/blather.rb: -------------------------------------------------------------------------------- 1 | Blather.logger = Logger.new "log/blather.log" 2 | Blather.default_log_level = :debug 3 | -------------------------------------------------------------------------------- /features/support/env.rb: -------------------------------------------------------------------------------- 1 | require "simplecov" 2 | require "debugger" 3 | -------------------------------------------------------------------------------- /features/support/event_machine.rb: -------------------------------------------------------------------------------- 1 | Before do 2 | @em_thread = Thread.new { EM.run } 3 | sleep 0.01 until EM.reactor_running? 4 | end 5 | 6 | After do 7 | EM.stop_event_loop if EM.reactor_running? 8 | @em_thread.join 9 | end 10 | -------------------------------------------------------------------------------- /features/support/runs_application.rb: -------------------------------------------------------------------------------- 1 | module RunsApplication 2 | def sniper 3 | @sniper ||= ApplicationRunner.new 4 | end 5 | end 6 | World RunsApplication 7 | 8 | After { sniper.stop if @sniper } 9 | -------------------------------------------------------------------------------- /features/support/runs_fake_auction.rb: -------------------------------------------------------------------------------- 1 | module RunsFakeAuction 2 | def start_auction_for_item id 3 | @auctions ||= [] 4 | auction = FakeAuctionServer.new id 5 | @auctions << auction 6 | auction.start_selling_item 7 | end 8 | 9 | def auction_1 10 | @auctions[0] 11 | end 12 | alias auction auction_1 13 | 14 | def auction_2 15 | @auctions[1] 16 | end 17 | end 18 | World RunsFakeAuction 19 | 20 | After { @auctions.each(&:stop) } 21 | -------------------------------------------------------------------------------- /features/support/start_xmpp_server.rb: -------------------------------------------------------------------------------- 1 | system "cd vines; vines start -d" 2 | 3 | at_exit do 4 | system "cd vines; vines stop" 5 | end 6 | -------------------------------------------------------------------------------- /lib/announcer.rb: -------------------------------------------------------------------------------- 1 | class Announcer 2 | def initialize 3 | @listeners = [] 4 | end 5 | 6 | def add_listener listener 7 | @listeners << listener 8 | end 9 | 10 | def remove_listener listener 11 | @listeners.delete listener 12 | end 13 | 14 | def method_missing name, *args 15 | @listeners.each {|l| l.send name, *args } 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/auction_sniper.rb: -------------------------------------------------------------------------------- 1 | require "sniper_snapshot" 2 | 3 | class AuctionSniper 4 | attr_reader :snapshot 5 | 6 | def initialize item, auction 7 | @item, @auction, = item, auction 8 | @snapshot = SniperSnapshot.joining item.identifier 9 | end 10 | 11 | def current_price price, increment, price_source 12 | if price_source == :from_sniper 13 | @snapshot = @snapshot.winning price 14 | else 15 | bid = price + increment 16 | if @item.allows_bid? bid 17 | @auction.bid bid 18 | @snapshot = @snapshot.bidding price, bid 19 | else 20 | @snapshot = @snapshot.losing price 21 | end 22 | end 23 | notify_change 24 | end 25 | 26 | def add_sniper_listener sniper_listener 27 | @sniper_listener = sniper_listener 28 | end 29 | 30 | def auction_closed 31 | @snapshot = @snapshot.closed 32 | notify_change 33 | end 34 | 35 | def auction_failed 36 | @snapshot = @snapshot.failed 37 | notify_change 38 | end 39 | 40 | private 41 | 42 | def notify_change 43 | @sniper_listener.sniper_state_changed @snapshot 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/column.rb: -------------------------------------------------------------------------------- 1 | Column = Struct.new :index, :title, :snapshot_extractor 2 | 3 | class Column 4 | ITEM_IDENTIFIER = new 0, "Item", ->(s) { s.item_id } 5 | LAST_PRICE = new 1, "Last price", ->(s) { s.last_price } 6 | LAST_BID = new 2, "Last bid", ->(s) { s.last_bid } 7 | SNIPER_STATE = new 3, "State", ->(s) { s.sniper_state.to_s } 8 | 9 | def self.values 10 | [ITEM_IDENTIFIER, LAST_PRICE, LAST_BID, SNIPER_STATE] 11 | end 12 | 13 | def value_in sniper_snapshot 14 | snapshot_extractor.(sniper_snapshot) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/defect.rb: -------------------------------------------------------------------------------- 1 | class Defect < RuntimeError 2 | end 3 | -------------------------------------------------------------------------------- /lib/item.rb: -------------------------------------------------------------------------------- 1 | Item = Struct.new :identifier, :stop_price do 2 | def allows_bid? bid 3 | bid <= stop_price 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/main.rb: -------------------------------------------------------------------------------- 1 | require "sniper_launcher" 2 | require "sniper_portfolio" 3 | require "ui/snipers_table_model" 4 | require "ui/main_window" 5 | require "xmpp/xmpp_auction_house" 6 | 7 | class Main 8 | attr_reader :ui 9 | 10 | class << self 11 | alias main new 12 | end 13 | 14 | def initialize username, passsword 15 | @portfolio = SniperPortfolio.new 16 | @ui = Ui::MainWindow.new @portfolio 17 | auction_house = Xmpp::XmppAuctionHouse.new username, passsword 18 | start_ui 19 | ui.add_user_request_listener SniperLauncher.new(auction_house, @portfolio) 20 | end 21 | 22 | def stop 23 | ui.destroy 24 | end 25 | 26 | private 27 | 28 | # Blocks main thread 29 | def start_ui 30 | Gtk.init 31 | Thread.new { Gtk.main } 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/sniper_launcher.rb: -------------------------------------------------------------------------------- 1 | require "auction_sniper" 2 | require "item" 3 | 4 | class SniperLauncher 5 | def initialize auction_house, collector 6 | @auction_house, @collector = auction_house, collector 7 | end 8 | 9 | def join_auction item 10 | auction = @auction_house.auction_for item 11 | sniper = AuctionSniper.new item, auction 12 | auction.add_auction_event_listener sniper 13 | @collector.add_sniper sniper 14 | auction.join 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/sniper_portfolio.rb: -------------------------------------------------------------------------------- 1 | class SniperPortfolio 2 | def initialize 3 | @snipers = [] 4 | @listeners = [] 5 | end 6 | 7 | def add_portfolio_listener listener 8 | @listeners << listener 9 | end 10 | 11 | def add_sniper sniper 12 | @snipers << sniper 13 | @listeners.each {|l| l.sniper_added sniper } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/sniper_snapshot.rb: -------------------------------------------------------------------------------- 1 | require "sniper_state" 2 | 3 | SniperSnapshot = Struct.new :item_id, :last_price, :last_bid, :sniper_state do 4 | def self.joining item_id 5 | new item_id, 0, 0, SniperState::JOINING 6 | end 7 | 8 | def bidding new_last_price, new_last_bid 9 | self.class.new item_id, new_last_price, new_last_bid, SniperState::BIDDING 10 | end 11 | 12 | def winning new_last_price 13 | self.class.new item_id, new_last_price, new_last_price, SniperState::WINNING 14 | end 15 | 16 | def losing new_last_price 17 | self.class.new item_id, new_last_price, last_bid, SniperState::LOSING 18 | end 19 | 20 | def closed 21 | self.class.new item_id, last_price, last_bid, sniper_state.when_auction_closed 22 | end 23 | 24 | def failed 25 | self.class.new item_id, 0, 0, SniperState::FAILED 26 | end 27 | 28 | def winning? 29 | sniper_state.winning? 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/sniper_state.rb: -------------------------------------------------------------------------------- 1 | require "defect" 2 | 3 | SniperState = Struct.new :name, :winning, :new_state_when_auction_closed 4 | 5 | class SniperState 6 | JOINING = new "Joining", false, :LOST 7 | BIDDING = new "Bidding", false, :LOST 8 | WINNING = new "Winning", true, :WON 9 | LOSING = new "Losing", false, :LOST 10 | LOST = new "Lost", false 11 | WON = new "Won", true 12 | FAILED = new "Failed", false 13 | 14 | def to_s 15 | name 16 | end 17 | 18 | def inspect 19 | "" 20 | end 21 | 22 | def winning? 23 | winning 24 | end 25 | 26 | def when_auction_closed 27 | if new_state_when_auction_closed 28 | SniperState.const_get new_state_when_auction_closed 29 | else 30 | raise Defect.new("Auction is already closed") 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/ui/main_window.rb: -------------------------------------------------------------------------------- 1 | require "gtk2" 2 | require "ui/snipers_table_model" 3 | require "item" 4 | 5 | module Ui 6 | class MainWindow < Gtk::Window 7 | APPLICATION_TITLE = "Auction sniper" 8 | MAIN_WINDOW_NAME = "main_window" 9 | NEW_ITEM_ID_NAME = "new_item_id" 10 | STOP_PRICE_NAME = "stop_price" 11 | JOIN_BUTTON_NAME = "join" 12 | SNIPERS_TABLE_NAME = "snipers" 13 | 14 | def initialize portfolio 15 | super() 16 | @user_request_listeners = [] 17 | @model = Ui::SnipersTableModel.new 18 | portfolio.add_portfolio_listener @model 19 | 20 | set_name MAIN_WINDOW_NAME 21 | set_title APPLICATION_TITLE 22 | signal_connect "destroy" do 23 | Gtk.main_quit 24 | end 25 | 26 | layout = Gtk::VBox.new false, 5 27 | layout.add make_new_item_form 28 | layout.add make_snipers_table 29 | add layout 30 | show_all 31 | end 32 | 33 | def add_user_request_listener listener 34 | @user_request_listeners << listener 35 | end 36 | 37 | def sniper_state_changed sniper_snapshot 38 | @model.sniper_state_changed sniper_snapshot 39 | end 40 | 41 | private 42 | 43 | def make_new_item_form 44 | layout = Gtk::HBox.new false, 5 45 | 46 | item_id_input = Gtk::Entry.new 47 | item_id_input.name = NEW_ITEM_ID_NAME 48 | 49 | stop_price_input = Gtk::Entry.new 50 | stop_price_input.name = STOP_PRICE_NAME 51 | 52 | button = Gtk::Button.new "Join Auction" 53 | button.name = JOIN_BUTTON_NAME 54 | button.signal_connect("clicked") { 55 | item = Item.new item_id_input.text, stop_price_input.text.to_i 56 | @user_request_listeners.each {|l| l.join_auction item } 57 | } 58 | 59 | layout.add Gtk::Label.new("Item:") 60 | layout.add item_id_input 61 | layout.add Gtk::Label.new("Stop price:") 62 | layout.add stop_price_input 63 | layout.add button 64 | layout 65 | end 66 | 67 | def make_snipers_table 68 | view = Gtk::TreeView.new @model 69 | view.name = SNIPERS_TABLE_NAME 70 | renderer = Gtk::CellRendererText.new 71 | Column.values.each_with_index do |column, index| 72 | view.append_column Gtk::TreeViewColumn.new(column.title, renderer, text: index) 73 | end 74 | view 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/ui/snipers_table_model.rb: -------------------------------------------------------------------------------- 1 | require "gtk2" 2 | require "sniper_snapshot" 3 | require "column" 4 | require "ui_thread_sniper_listener" 5 | 6 | module Ui 7 | class SnipersTableModel < Gtk::ListStore 8 | def initialize 9 | super String, Integer, Integer, String 10 | end 11 | 12 | def sniper_added sniper 13 | row = append 14 | update_row row, sniper.snapshot 15 | sniper.add_sniper_listener UiThreadSniperListener.new(self) 16 | end 17 | 18 | def sniper_state_changed sniper_snapshot 19 | update_row row_for(sniper_snapshot), sniper_snapshot 20 | end 21 | 22 | def row_for sniper_snapshot 23 | each do |_model, _path, iterator| 24 | return iterator if iterator[Column::ITEM_IDENTIFIER.index] == sniper_snapshot.item_id 25 | end 26 | raise Defect.new "Row not found" 27 | end 28 | 29 | def update_row row, sniper_snapshot 30 | Column.values.each_with_index do |column, index| 31 | row[index] = column.value_in sniper_snapshot 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/ui_thread_sniper_listener.rb: -------------------------------------------------------------------------------- 1 | class UiThreadSniperListener 2 | def initialize delegate 3 | @delegate = delegate 4 | end 5 | 6 | def sniper_state_changed state 7 | EM.next_tick { @delegate.sniper_state_changed state } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/xmpp/auction_message_translator.rb: -------------------------------------------------------------------------------- 1 | module Xmpp 2 | class AuctionMessageTranslator 3 | def initialize sniper_id, listener, failure_reporter 4 | @sniper_id, @listener, @failure_reporter = sniper_id, listener, failure_reporter 5 | end 6 | 7 | def handle_message message 8 | event = AuctionEvent.from message 9 | case event.type 10 | when "PRICE" 11 | @listener.current_price event.current_price, event.increment, event.is_from?(@sniper_id) 12 | when "CLOSE" 13 | @listener.auction_closed 14 | end 15 | rescue => e 16 | @failure_reporter.cannot_translate_message @sniper_id, message.body, e 17 | @listener.auction_failed 18 | end 19 | 20 | class AuctionEvent 21 | class MissingValueException < RuntimeError; end 22 | class BadlyFormedMessageException < RuntimeError; end 23 | 24 | def initialize fields 25 | @fields = fields 26 | end 27 | 28 | def self.from message 29 | new fields_in(message.body) 30 | end 31 | 32 | def type 33 | field "Event" 34 | end 35 | 36 | def current_price 37 | Integer(field "CurrentPrice") 38 | end 39 | 40 | def increment 41 | Integer(field "Increment") 42 | end 43 | 44 | def is_from? sniper_id 45 | field("Bidder") == sniper_id ? :from_sniper : :from_other_bidder 46 | end 47 | 48 | private 49 | 50 | def self.fields_in body 51 | fields = body.split ";" 52 | name_value_pairs = fields.flat_map {|a| a.split ":" }.map(&:strip) 53 | Hash[*name_value_pairs] 54 | rescue 55 | raise BadlyFormedMessageException.new body 56 | end 57 | private_class_method :fields_in 58 | 59 | def field key 60 | @fields.fetch(key) { raise MissingValueException.new(key) } 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/xmpp/chat.rb: -------------------------------------------------------------------------------- 1 | module Xmpp 2 | class Chat 3 | def initialize connection, address 4 | @listeners = [] 5 | @connection, @address = connection, address 6 | @connection.register_handler :message, 7 | ->(message) { message.from.node == @address } do |message| 8 | notify_listeners message 9 | end 10 | end 11 | 12 | def add_message_listener listener 13 | @listeners << listener 14 | end 15 | 16 | def remove_message_listener listener 17 | @listeners.delete listener 18 | end 19 | 20 | def send_message message 21 | EM.next_tick do 22 | @connection.write Blather::Stanza::Message.new("#{@address}@localhost", message) 23 | end 24 | end 25 | 26 | private 27 | 28 | def notify_listeners message 29 | @listeners.each {|l| l.handle_message message } 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/xmpp/logging_xmpp_failure_reporter.rb: -------------------------------------------------------------------------------- 1 | module Xmpp 2 | class LoggingXmppFailureReporter 3 | LOG_FILE_NAME = "sniper.log" 4 | 5 | def initialize logger 6 | @logger = logger 7 | end 8 | 9 | def cannot_translate_message auction_id, message, exception 10 | @logger.error %{<#{auction_id}> Could not translate message "#{message}" because #{exception.inspect}} 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/xmpp/xmpp_auction.rb: -------------------------------------------------------------------------------- 1 | require "logger" 2 | require "blather/client/client" 3 | require "announcer" 4 | require "xmpp/chat" 5 | require "xmpp/auction_message_translator" 6 | require "xmpp/logging_xmpp_failure_reporter" 7 | 8 | module Xmpp 9 | class XmppAuction 10 | JOIN_COMMAND_FORMAT = "SOLVersion: 1.1; Command: JOIN;" 11 | BID_COMMAND_FORMAT = "SOLVersion: 1.1; Command: BID; Price: %d;" 12 | 13 | def initialize connection, item 14 | @failure_reporter = Xmpp::LoggingXmppFailureReporter.new( 15 | Logger.new Xmpp::LoggingXmppFailureReporter::LOG_FILE_NAME) 16 | @auction_event_listeners = Announcer.new 17 | translator = translator_for connection 18 | @chat = Xmpp::Chat.new connection, auction_id_for(item) 19 | @chat.add_message_listener translator 20 | add_auction_event_listener chat_disconector_for(translator) 21 | end 22 | 23 | def add_auction_event_listener listener 24 | @auction_event_listeners.add_listener listener 25 | end 26 | 27 | def join 28 | send_message JOIN_COMMAND_FORMAT 29 | end 30 | 31 | def bid amount 32 | send_message(BID_COMMAND_FORMAT % amount) 33 | end 34 | 35 | private 36 | 37 | def translator_for connection 38 | AuctionMessageTranslator.new connection.jid.stripped.to_s, @auction_event_listeners, @failure_reporter 39 | end 40 | 41 | def chat_disconector_for translator 42 | ChatDisconnector.new @chat, translator 43 | end 44 | 45 | def auction_id_for item 46 | "auction-#{item.identifier}" 47 | end 48 | 49 | def send_message message 50 | @chat.send_message message 51 | end 52 | 53 | class ChatDisconnector 54 | def initialize chat, translator 55 | @chat, @translator = chat, translator 56 | end 57 | 58 | def auction_failed 59 | @chat.remove_message_listener @translator 60 | end 61 | 62 | def auction_closed; end 63 | def current_price *; end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/xmpp/xmpp_auction_house.rb: -------------------------------------------------------------------------------- 1 | require "xmpp/xmpp_auction" 2 | require "blather/client/client" 3 | 4 | module Xmpp 5 | class XmppAuctionHouse 6 | def initialize username, password 7 | @connection = setup_xmpp_client username, password 8 | @connection.register_handler(:ready) { @ready = true } 9 | @connection.connect 10 | sleep 0.1 until @ready 11 | end 12 | 13 | def auction_for item 14 | auction = Xmpp::XmppAuction.new @connection, item 15 | auction 16 | end 17 | 18 | private 19 | 20 | def setup_xmpp_client username, passsword 21 | Blather::Client.setup username, passsword 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /log/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kerryb/goos-ruby/d76d17e3d6d53da72bf1cc344c1e9dccf84d6b8b/log/.gitkeep -------------------------------------------------------------------------------- /spec/announcer_spec.rb: -------------------------------------------------------------------------------- 1 | require "announcer" 2 | 3 | describe Announcer do 4 | let(:listener_1) { double :listener_1, event_a: true, event_b: true } 5 | let(:listener_2) { double :listener_2, event_a: true, event_b: true } 6 | before do 7 | subject.add_listener listener_1 8 | subject.add_listener listener_2 9 | end 10 | 11 | it "announces to registered listeners" do 12 | subject.event_a 13 | subject.event_b 14 | 15 | expect(listener_1).to have_received :event_a 16 | expect(listener_2).to have_received :event_a 17 | expect(listener_1).to have_received :event_b 18 | expect(listener_2).to have_received :event_b 19 | end 20 | 21 | it "passes event arguments to listeners" do 22 | subject.event_a "foo", 42 23 | expect(listener_1).to have_received(:event_a).with "foo", 42 24 | end 25 | 26 | it "can remove listeners" do 27 | subject.remove_listener listener_1 28 | subject.event_a 29 | 30 | expect(listener_1).to_not have_received :event_a 31 | expect(listener_2).to have_received :event_a 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/auction_sniper_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/roles/auction" 2 | require "support/roles/sniper_listener" 3 | require "support/roles/auction_event_listener" 4 | require "auction_sniper" 5 | require "item" 6 | 7 | describe AuctionSniper do 8 | subject { AuctionSniper.new item, auction } 9 | let(:auction) { double :auction, join: true, bid: true } 10 | let(:item_id) { "item-123" } 11 | let(:item) { Item.new item_id, 1234 } 12 | let(:price) { 1001 } 13 | let(:increment) { 25 } 14 | let(:sniper_listener) { double :sniper_listener } 15 | 16 | before { subject.add_sniper_listener sniper_listener } 17 | 18 | it_behaves_like "an auction event listener" 19 | 20 | context "when a new price arrives" do 21 | before { allow(sniper_listener).to receive :sniper_state_changed } 22 | 23 | context "from the sniper" do 24 | before { subject.current_price price, increment, :from_sniper } 25 | 26 | it "reports that it's winning" do 27 | expect(sniper_listener).to have_received(:sniper_state_changed).with( 28 | SniperSnapshot.new(item_id, price, price, SniperState::WINNING) 29 | ) 30 | end 31 | end 32 | 33 | context "from another bidder, within the stop price" do 34 | before { subject.current_price price, increment, :from_other_bidder } 35 | 36 | it "bids higher" do 37 | expect(auction).to have_received(:bid).with(price + increment) 38 | end 39 | 40 | it "reports that it's bidding" do 41 | expect(sniper_listener).to have_received(:sniper_state_changed).with( 42 | SniperSnapshot.new(item_id, price, price + increment, SniperState::BIDDING) 43 | ) 44 | end 45 | end 46 | 47 | context "from another bidder, going over the stop price" do 48 | let(:price) { 2345 } 49 | before { subject.current_price price, increment, :from_other_bidder } 50 | 51 | it "does not bid" do 52 | expect(auction).to_not have_received :bid 53 | end 54 | 55 | it "reports that it's losing" do 56 | expect(sniper_listener).to have_received(:sniper_state_changed).with( 57 | SniperSnapshot.new(item_id, price, 0, SniperState::LOSING) 58 | ) 59 | end 60 | end 61 | 62 | end 63 | 64 | it "reports that the sniper has lost when the action closes immediately" do 65 | allow(sniper_listener).to receive :sniper_state_changed 66 | subject.auction_closed 67 | expect(sniper_listener).to have_received(:sniper_state_changed).with( 68 | SniperSnapshot.new(item_id, 0, 0, SniperState::LOST) 69 | ) 70 | end 71 | 72 | it "reports that the sniper has lost when the action closes while bidding" do 73 | allow(sniper_listener).to receive(:sniper_state_changed) do |snapshot| 74 | if snapshot.sniper_state == SniperState::WINNING 75 | expect(@sniper_state).to be SniperState::BIDDING 76 | else 77 | @sniper_state = snapshot.sniper_state 78 | end 79 | end 80 | 81 | subject.current_price price, increment, :from_other_bidder 82 | subject.auction_closed 83 | 84 | expect(sniper_listener).to have_received(:sniper_state_changed).with( 85 | SniperSnapshot.new(item_id, price, price + increment, SniperState::LOST) 86 | ) 87 | end 88 | 89 | it "reports that the sniper has won when the action closes while winning" do 90 | allow(sniper_listener).to receive(:sniper_state_changed) {|snapshot| @sniper_state = snapshot.sniper_state } 91 | allow(sniper_listener).to receive(:sniper_won) { expect(@sniper_state).to be SniperState::WINNING } 92 | 93 | subject.current_price price, increment, :from_sniper 94 | subject.auction_closed 95 | 96 | expect(sniper_listener).to have_received(:sniper_state_changed).with( 97 | SniperSnapshot.new(item_id, price, price, SniperState::WON) 98 | ) 99 | end 100 | 101 | it "reports aution failure" do 102 | allow(sniper_listener).to receive :sniper_state_changed 103 | subject.auction_failed 104 | expect(sniper_listener).to have_received(:sniper_state_changed).with( 105 | SniperSnapshot.new(item_id, 0, 0, SniperState::FAILED) 106 | ) 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/column_spec.rb: -------------------------------------------------------------------------------- 1 | require "column" 2 | require "sniper_snapshot" 3 | 4 | describe Column do 5 | let(:snapshot) { SniperSnapshot.new "item-123", 100, 110, SniperState::JOINING } 6 | 7 | [Column::ITEM_IDENTIFIER, Column::LAST_PRICE, 8 | Column::LAST_BID, Column::SNIPER_STATE].each_with_index do |column, index| 9 | specify "The '#{column.title} column has an index of {index}" do 10 | expect(column.index).to eq index 11 | end 12 | end 13 | 14 | specify "#values returns all values" do 15 | expect(Column.values).to eq [Column::ITEM_IDENTIFIER, 16 | Column::LAST_PRICE, 17 | Column::LAST_BID, 18 | Column::SNIPER_STATE] 19 | end 20 | 21 | [ 22 | ["ITEM_IDENTIFIER", "Item", "item_id"], 23 | ["LAST_PRICE", "Last price", "last_price"], 24 | ["LAST_BID", "Last bid", "last_bid"], 25 | ["SNIPER_STATE", "State", "sniper_state.to_s"], 26 | ].each do |(column, title, field)| 27 | describe column do 28 | subject { Column.const_get column } 29 | 30 | its(:title) { should eq title } 31 | 32 | it "returns the sniper snapshot value in #{field}" do 33 | expect(subject.value_in snapshot).to be snapshot.instance_eval(field) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/item_spec.rb: -------------------------------------------------------------------------------- 1 | require "item" 2 | 3 | describe Item do 4 | subject { Item.new "item-123", 100 } 5 | 6 | it "allows bids below its stop price" do 7 | expect(subject.allows_bid? 99).to be_true 8 | end 9 | 10 | it "allows bids at its stop price" do 11 | expect(subject.allows_bid? 100).to be_true 12 | end 13 | 14 | it "does not allow bids above its stop price" do 15 | expect(subject.allows_bid? 101).to be_false 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/sniper_launcher_spec.rb: -------------------------------------------------------------------------------- 1 | require "sniper_launcher" 2 | 3 | describe SniperLauncher do 4 | subject { SniperLauncher.new auction_house, collector } 5 | let(:auction_house) { double :auction_house } 6 | let(:collector) { double :collector, add_sniper: true } 7 | let(:auction) { double :auction, add_auction_event_listener: true } 8 | let(:item) { Item.new "item-123" } 9 | 10 | before do 11 | allow(auction_house).to receive(:auction_for).with(item) { auction } 12 | end 13 | 14 | it "adds a new sniper to the collector then joins the auction" do 15 | auction_state = :not_joined 16 | expect(auction).to receive :add_auction_event_listener do |listener| 17 | expect(auction_state).to eq :not_joined 18 | expect(listener.snapshot.item_id).to eq "item-123" 19 | end 20 | expect(collector).to receive :add_sniper do |sniper| 21 | expect(auction_state).to eq :not_joined 22 | expect(sniper.snapshot.item_id).to eq "item-123" 23 | end 24 | expect(auction).to receive :join do 25 | auction_state = :joined 26 | end 27 | 28 | subject.join_auction item 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/sniper_portfolio_spec.rb: -------------------------------------------------------------------------------- 1 | require "sniper_portfolio" 2 | require "support/roles/sniper_collector" 3 | 4 | describe SniperPortfolio do 5 | it_behaves_like "a sniper collector" 6 | 7 | describe "when a sniper is added" do 8 | let(:sniper) { double :sniper } 9 | let(:listener) { double :listener, sniper_added: true } 10 | before { subject.add_portfolio_listener listener } 11 | 12 | it "notifies its listeners" do 13 | subject.add_sniper sniper 14 | expect(listener).to have_received(:sniper_added).with sniper 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/sniper_snapshot_spec.rb: -------------------------------------------------------------------------------- 1 | require "sniper_snapshot" 2 | 3 | describe SniperSnapshot do 4 | let(:item_id) { "item-123" } 5 | 6 | describe ".joining" do 7 | it "returns an initial snapshot" do 8 | expect(SniperSnapshot.joining item_id).to eq( 9 | SniperSnapshot.new(item_id, 0, 0, SniperState::JOINING) 10 | ) 11 | end 12 | end 13 | 14 | describe "#bidding" do 15 | subject { SniperSnapshot.new item_id, 0, 0, SniperState::JOINING } 16 | let(:last_price) { 100 } 17 | let(:last_bid) { 110 } 18 | 19 | it "returns a new snapshot with last price and bid" do 20 | expect(subject.bidding last_price, last_bid).to eq( 21 | SniperSnapshot.new(item_id, last_price, last_bid, SniperState::BIDDING) 22 | ) 23 | end 24 | end 25 | 26 | describe "#winning" do 27 | subject { SniperSnapshot.new item_id, last_price, last_bid, SniperState::BIDDING } 28 | let(:last_price) { 100 } 29 | let(:last_bid) { 110 } 30 | 31 | it "returns a new snapshot with last price and bid" do 32 | expect(subject.winning last_bid).to eq( 33 | SniperSnapshot.new(item_id, last_bid, last_bid, SniperState::WINNING) 34 | ) 35 | end 36 | end 37 | 38 | describe "#losing" do 39 | subject { SniperSnapshot.new item_id, last_price, last_bid, SniperState::LOSING } 40 | let(:last_price) { 100 } 41 | let(:last_bid) { 110 } 42 | 43 | it "returns a new snapshot with last price and bid" do 44 | expect(subject.losing last_price).to eq( 45 | SniperSnapshot.new(item_id, last_price, last_bid, SniperState::LOSING) 46 | ) 47 | end 48 | end 49 | 50 | describe "#won" do 51 | subject { SniperSnapshot.new item_id, last_price, last_bid, SniperState::WINNING } 52 | let(:last_price) { 100 } 53 | let(:last_bid) { 110 } 54 | 55 | it "returns a new snapshot with last price and bid" do 56 | expect(subject.winning last_bid).to eq( 57 | SniperSnapshot.new(item_id, last_bid, last_bid, SniperState::WINNING) 58 | ) 59 | end 60 | end 61 | 62 | describe "#closed" do 63 | let(:last_price) { 100 } 64 | let(:last_bid) { 110 } 65 | 66 | context "when winning" do 67 | subject { SniperSnapshot.new item_id, last_price, last_bid, SniperState::WINNING } 68 | 69 | it "returns a new 'won' snapshot with last price and bid" do 70 | expect(subject.closed).to eq( 71 | SniperSnapshot.new(item_id, last_price, last_bid, SniperState::WON) 72 | ) 73 | end 74 | end 75 | 76 | context "when not winning" do 77 | subject { SniperSnapshot.new item_id, last_price, last_bid, SniperState::BIDDING } 78 | 79 | it "returns a new 'lost' snapshot with last price and bid" do 80 | expect(subject.closed).to eq( 81 | SniperSnapshot.new(item_id, last_price, last_bid, SniperState::LOST) 82 | ) 83 | end 84 | end 85 | end 86 | 87 | describe "#failed" do 88 | subject { SniperSnapshot.new item_id, 123, 456, SniperState::JOINING } 89 | 90 | it "returns a new 'failed' snapshot with values zeroed out" do 91 | expect(subject.failed).to eq( 92 | SniperSnapshot.new(item_id, 0, 0, SniperState::FAILED) 93 | ) 94 | end 95 | end 96 | 97 | describe "#winning?" do 98 | it "returns true if the sniper state is WINNING" do 99 | expect(SniperSnapshot.new "", 0, 0, SniperState::WINNING).to be_winning 100 | end 101 | 102 | it "returns false if the sniper state is not WINNING" do 103 | expect(SniperSnapshot.new "", 0, 0, SniperState::BIDDING).to_not be_winning 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /spec/sniper_state_spec.rb: -------------------------------------------------------------------------------- 1 | require "sniper_state" 2 | 3 | { 4 | SniperState::JOINING => [false, "Joining"], 5 | SniperState::BIDDING => [false, "Bidding"], 6 | SniperState::LOSING => [false, "Losing"], 7 | SniperState::WINNING => [true, "Winning"], 8 | SniperState::LOST => [false, "Lost"], 9 | SniperState::WON => [true, "Won"], 10 | SniperState::FAILED => [false, "Failed"], 11 | }.each do |state, (winning, label)| 12 | describe state.name do 13 | specify "is #{winning ? '' : 'not '} winning" do 14 | expect(state.winning?).to eq winning 15 | end 16 | 17 | specify "translates as '#{label}'" do 18 | expect(state.to_s).to eq label 19 | end 20 | end 21 | end 22 | 23 | { 24 | SniperState::JOINING => SniperState::LOST, 25 | SniperState::BIDDING => SniperState::LOST, 26 | SniperState::LOSING => SniperState::LOST, 27 | SniperState::WINNING => SniperState::WON, 28 | }.each do |state, state_after_close| 29 | describe state.name do 30 | specify "transitions to #{state_after_close.inspect} when auction closes" do 31 | expect(state.when_auction_closed).to be state_after_close 32 | end 33 | end 34 | end 35 | 36 | [ 37 | SniperState::LOST, 38 | SniperState::WON, 39 | SniperState::FAILED, 40 | ].each do |state| 41 | describe state.name do 42 | specify "does not have a next state" do 43 | expect { state.when_auction_closed }.to raise_error Defect 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "simplecov" 2 | require "debugger" 3 | 4 | RSpec.configure do |config| 5 | config.expect_with :rspec do |c| 6 | c.syntax = :expect 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/roles/auction.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "an auction" do 2 | it "responds to #add_auction_event_listener" do 3 | expect(subject).to respond_to :add_auction_event_listener 4 | end 5 | 6 | it "responds to #join" do 7 | expect(subject).to respond_to :join 8 | end 9 | 10 | it "responds to #bid" do 11 | expect(subject).to respond_to :bid 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/roles/auction_event_listener.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "an auction event listener" do 2 | it "responds to #auction_closed" do 3 | expect(subject).to respond_to :auction_closed 4 | end 5 | 6 | it "responds to #current_price" do 7 | expect(subject).to respond_to :current_price 8 | end 9 | 10 | it "responds to #auction_failed" do 11 | expect(subject).to respond_to :auction_failed 12 | end 13 | end 14 | 15 | -------------------------------------------------------------------------------- /spec/support/roles/portfolio_listener.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "a portfolio listener" do 2 | it "responds to #sniper_added" do 3 | expect(subject).to respond_to :sniper_added 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/roles/sniper_collector.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "a sniper collector" do 2 | it "responds to #add_sniper" do 3 | expect(subject).to respond_to :add_sniper 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/roles/sniper_listener.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "a sniper listener" do 2 | it "responds to #sniper_state_changed" do 3 | expect(subject).to respond_to :sniper_state_changed 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/ui/main_window_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../test_support/auction_sniper_driver" 2 | require "support/roles/portfolio_listener" 3 | require "ui/main_window" 4 | require "sniper_portfolio" 5 | 6 | describe Ui::MainWindow do 7 | subject(:window) { Ui::MainWindow.new SniperPortfolio.new } 8 | 9 | describe "when join button clicked" do 10 | let(:driver) { AuctionSniperDriver.new window } 11 | let(:listener) { double :listener, join_auction: true } 12 | 13 | it "passes the item details to user request listeners" do 14 | window.add_user_request_listener listener 15 | 16 | driver.start_bidding_for "item-123", 789 17 | expect(listener).to have_received(:join_auction).with Item.new("item-123", 789) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/ui/snipers_table_model_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/roles/sniper_listener" 2 | require "support/roles/sniper_collector" 3 | require "ui/snipers_table_model" 4 | 5 | describe Ui::SnipersTableModel do 6 | let(:auction) { double :auction } 7 | 8 | def rows 9 | rows = [] 10 | subject.each do |_model, _path, iter| 11 | rows << [ 12 | iter[Column::ITEM_IDENTIFIER.index], 13 | iter[Column::LAST_PRICE.index], 14 | iter[Column::LAST_BID.index], 15 | iter[Column::SNIPER_STATE.index], 16 | ] 17 | end 18 | rows 19 | end 20 | 21 | it_behaves_like "a sniper listener" 22 | 23 | it "Has enough columns" do 24 | expect(subject.n_columns).to eq Column.values.size 25 | end 26 | 27 | context "initially" do 28 | it "does not have any rows" do 29 | expect(subject.iter_first).to be_false 30 | end 31 | end 32 | 33 | describe "#sniper_added" do 34 | it "sets the item ID, last price, last bid and sniper status" do 35 | sniper = double(:sniper, snapshot: SniperSnapshot.joining("item-123"), 36 | add_sniper_listener: true) 37 | subject.sniper_added sniper 38 | expect(rows).to eq [["item-123", 0, 0, SniperState::JOINING.to_s]] 39 | end 40 | end 41 | 42 | describe "#sniper_state_changed" do 43 | before do 44 | %w[item-123 item-456 item-789].each do |item_id| 45 | sniper = double("sniper for #{item_id}", snapshot: SniperSnapshot.joining(item_id), 46 | add_sniper_listener: true) 47 | subject.sniper_added sniper 48 | end 49 | end 50 | 51 | it "updates the values in the correct row" do 52 | state = SniperSnapshot.new("item-456", 100, 123, SniperState::BIDDING) 53 | subject.sniper_state_changed state 54 | expect(rows).to eq [ 55 | ["item-123", 0, 0, SniperState::JOINING.to_s], 56 | ["item-456", 100, 123, SniperState::BIDDING.to_s], 57 | ["item-789", 0, 0, SniperState::JOINING.to_s], 58 | ] 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/ui_thread_sniper_listener_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/roles/sniper_listener" 2 | require "ui_thread_sniper_listener" 3 | 4 | describe UiThreadSniperListener do 5 | let(:delegate) { double :delegate, sniper_state_changed: true } 6 | subject { UiThreadSniperListener.new delegate } 7 | 8 | it_behaves_like "a sniper listener" 9 | 10 | describe "when the sniper state changes" do 11 | class FakeEM 12 | def self.next_tick &block 13 | @@block = block 14 | end 15 | 16 | def self.tick 17 | @@block.call 18 | end 19 | end 20 | 21 | before { stub_const "EM", FakeEM } 22 | 23 | it "notifies its delegate on the next EventMachine tick" do 24 | state = double :state 25 | subject.sniper_state_changed state 26 | expect(delegate).to_not have_received :sniper_state_changed 27 | EM.tick 28 | expect(delegate).to have_received(:sniper_state_changed).with state 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/xmpp/auction_message_translator_spec.rb: -------------------------------------------------------------------------------- 1 | require "support/roles/auction_event_listener" 2 | require "xmpp/auction_message_translator" 3 | 4 | describe Xmpp::AuctionMessageTranslator do 5 | subject { Xmpp::AuctionMessageTranslator.new sniper_id, auction_event_listener, failure_reporter } 6 | let(:sniper_id) { "sniper@localhost" } 7 | let(:auction_event_listener) { double :auction_event_listener } 8 | let(:failure_reporter) { double :failure_reporter, cannot_translate_message: true } 9 | 10 | def expect_failure_with_message bad_message 11 | expect(auction_event_listener).to have_received :auction_failed 12 | expect(failure_reporter).to have_received(:cannot_translate_message) 13 | .with sniper_id, bad_message, a_kind_of(Exception) 14 | end 15 | 16 | it "notifies bid details when it receives a current price message from another bidder" do 17 | allow(auction_event_listener).to receive :current_price 18 | message = double :message, 19 | body: "SOLVersion: 1.1; Event: PRICE; CurrentPrice: 192; Increment: 7; Bidder: Someone else;" 20 | subject.handle_message message 21 | expect(auction_event_listener).to have_received(:current_price).with 192, 7, :from_other_bidder 22 | end 23 | 24 | it "notifies bid details when it receives a current price message from the sniper" do 25 | allow(auction_event_listener).to receive :current_price 26 | message = double :message, 27 | body: "SOLVersion: 1.1; Event: PRICE; CurrentPrice: 192; Increment: 7; Bidder: #{sniper_id};" 28 | subject.handle_message message 29 | expect(auction_event_listener).to have_received(:current_price).with 192, 7, :from_sniper 30 | end 31 | 32 | it "notifies that the auction is closed when it receives a close message" do 33 | allow(auction_event_listener).to receive :auction_closed 34 | message = double :message, body: "SOLVersion: 1.1; Event: CLOSE;" 35 | subject.handle_message message 36 | expect(auction_event_listener).to have_received :auction_closed 37 | end 38 | 39 | it "notifies that the auction has failed when it receives an invalid message" do 40 | allow(auction_event_listener).to receive :auction_failed 41 | message = double :message, body: "a bad message" 42 | subject.handle_message message 43 | expect_failure_with_message message.body 44 | end 45 | 46 | it "notifies that the auction has failed when the event type is missing" do 47 | allow(auction_event_listener).to receive :auction_failed 48 | message = double :message, 49 | body: "SOLVersion: 1.1; CurrentPrice: 192; Increment: 7; Bidder: #{sniper_id};" 50 | subject.handle_message message 51 | expect_failure_with_message message.body 52 | end 53 | 54 | it "notifies that the auction has failed when the current price is missing" do 55 | allow(auction_event_listener).to receive :auction_failed 56 | message = double :message, 57 | body: "SOLVersion: 1.1; Event: PRICE; Increment: 7; Bidder: #{sniper_id};" 58 | subject.handle_message message 59 | expect_failure_with_message message.body 60 | end 61 | 62 | it "notifies that the auction has failed when the increment is missing" do 63 | allow(auction_event_listener).to receive :auction_failed 64 | message = double :message, 65 | body: "SOLVersion: 1.1; Event: PRICE; CurrentPrice: 192; Bidder: #{sniper_id};" 66 | subject.handle_message message 67 | expect_failure_with_message message.body 68 | end 69 | 70 | it "notifies that the auction has failed when the bidder is missing" do 71 | allow(auction_event_listener).to receive :auction_failed 72 | message = double :message, 73 | body: "SOLVersion: 1.1; Event: PRICE; CurrentPrice: 192; Increment: 7;" 74 | subject.handle_message message 75 | expect_failure_with_message message.body 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/xmpp/chat_spec.rb: -------------------------------------------------------------------------------- 1 | #require "blather/client/client" 2 | require "xmpp/chat" 3 | 4 | describe Xmpp::Chat do 5 | subject { Xmpp::Chat.new connection, address } 6 | let(:fake_connection_class) { 7 | Class.new do 8 | def register_handler type, guard, &handler 9 | fail "Unexpected registration for #{type} events" unless type == :message 10 | @guard, @handler = guard, handler 11 | end 12 | 13 | def receive_message message 14 | @handler.call message if @guard.(message) 15 | end 16 | end 17 | } 18 | let(:connection) { fake_connection_class.new } 19 | let(:address) { "someone" } 20 | let(:listener) { double :listener, handle_message: true } 21 | let(:our_message) { double :our_message, from: double(node: address) } 22 | let(:another_message) { double :another_message, from: double(node: "someone-else") } 23 | 24 | describe "#add_message_listener" do 25 | it "registers a handler which passes received mesages to the listener" do 26 | subject.add_message_listener listener 27 | connection.receive_message our_message 28 | expect(listener).to have_received(:handle_message).with our_message 29 | end 30 | 31 | it "does not pass on received mesages for a different item" do 32 | subject.add_message_listener listener 33 | connection.receive_message another_message 34 | expect(listener).to_not have_received(:handle_message) 35 | end 36 | end 37 | 38 | describe "#remove_message_listener" do 39 | it "stops passing messages to the listener" do 40 | subject.add_message_listener listener 41 | subject.remove_message_listener listener 42 | connection.receive_message our_message 43 | expect(listener).to_not have_received(:handle_message) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/xmpp/logging_xmpp_failure_reporter_spec.rb: -------------------------------------------------------------------------------- 1 | require "xmpp/logging_xmpp_failure_reporter" 2 | 3 | describe Xmpp::LoggingXmppFailureReporter do 4 | subject { Xmpp::LoggingXmppFailureReporter.new logger } 5 | let(:logger) { double :logger, error: true } 6 | 7 | it "writes message translation failures to a log" do 8 | subject.cannot_translate_message "auction id", "bad message", Exception.new("bad") 9 | expect(logger).to have_received(:error).with( 10 | %{ Could not translate message "bad message" because #}) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/xmpp/xmpp_auction_house_spec.rb: -------------------------------------------------------------------------------- 1 | require "xmpp/xmpp_auction_house" 2 | require "item" 3 | require_relative "../../test_support/fake_auction_server" 4 | 5 | describe Xmpp::XmppAuctionHouse do 6 | let(:auction_house) { Xmpp::XmppAuctionHouse.new "sniper@localhost", "sniper" } 7 | let(:item) { Item.new "item-54321" } 8 | let(:auction) { auction_house.auction_for item } 9 | let(:server) { FakeAuctionServer.new "item-54321" } 10 | let(:listener) { double :listener } 11 | 12 | before do 13 | @em_thread = Thread.new { EM.run } 14 | sleep 0.01 until EM.reactor_running? 15 | system "cd vines; vines start -d &>/dev/null" 16 | server.start_selling_item 17 | allow(listener).to receive(:auction_closed) { @auction_close_event_received = true } 18 | auction.add_auction_event_listener listener 19 | auction.join 20 | end 21 | 22 | after do 23 | system "cd vines; vines stop &>/dev/null" 24 | EM.stop_event_loop if EM.reactor_running? 25 | @em_thread.join 26 | end 27 | 28 | it "receives events from auction server after joining" do 29 | server.wait_for_join_request_from_sniper "sniper@localhost" 30 | server.close 31 | 32 | Timeout.timeout 5 do 33 | sleep 0.01 until @auction_close_event_received 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/xmpp/xmpp_auction_spec.rb: -------------------------------------------------------------------------------- 1 | require "xmpp/xmpp_auction" 2 | require "item" 3 | require "support/roles/auction" 4 | require "support/roles/auction_event_listener" 5 | 6 | describe Xmpp::XmppAuction do 7 | subject { Xmpp::XmppAuction.new connection, item } 8 | let(:connection) { 9 | double :connection, register_handler: true, jid: double(stripped: "sniper") 10 | } 11 | let(:item) { Item.new "item-123" } 12 | 13 | it_behaves_like "an auction" 14 | 15 | end 16 | 17 | describe Xmpp::XmppAuction::ChatDisconnector do 18 | let(:chat) { double(:chat) } 19 | let(:translator) { double(:translator) } 20 | subject { Xmpp::XmppAuction::ChatDisconnector.new chat, translator } 21 | 22 | it_behaves_like "an auction event listener" 23 | 24 | it "removes the translator from the chat when the auction fails" do 25 | allow(chat).to receive :remove_message_listener 26 | subject.auction_failed 27 | expect(chat).to have_received(:remove_message_listener).with translator 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test_support/auction_sniper_driver.rb: -------------------------------------------------------------------------------- 1 | class AuctionSniperDriver 2 | def initialize window 3 | @window = window 4 | end 5 | 6 | def wait_for_app_to_start 7 | wait_for { @window.title == Ui::MainWindow::APPLICATION_TITLE } 8 | wait_for_column_headers "Item", "Last price", "Last bid", "State" 9 | end 10 | 11 | def start_bidding_for item_id, stop_price 12 | new_item_id_input.text = item_id 13 | stop_price_input.text = stop_price.to_s 14 | join_auction_button.clicked 15 | end 16 | 17 | def wait_for_displayed_sniper_status *expected_values 18 | wait_for { displayed_rows.include? expected_values } or fail( 19 | %{Expected a row containing #{expected_values.inspect}, but table contained #{displayed_rows.inspect}}) 20 | end 21 | 22 | private 23 | 24 | def wait_for_window_title title 25 | wait_for { @window.title == title } or fail( 26 | %{Expected window title to be "#{title}", but was "#{@window.title}"}) 27 | end 28 | 29 | def wait_for_column_headers *headers 30 | wait_for { displayed_headers == headers } or fail( 31 | %{Expected displayed headers to be #{headers.inspect}, but were #{displayed_headers.inspect}}) 32 | end 33 | 34 | def displayed_rows 35 | table_model = snipers_table.model 36 | rows = [] 37 | table_model.each do |_model, _path, iterator| 38 | rows << table_model.n_columns.times.map {|n| iterator[n]} 39 | end 40 | rows 41 | end 42 | 43 | def snipers_table 44 | element_with_name Ui::MainWindow::SNIPERS_TABLE_NAME 45 | end 46 | 47 | def new_item_id_input 48 | element_with_name Ui::MainWindow::NEW_ITEM_ID_NAME 49 | end 50 | 51 | def stop_price_input 52 | element_with_name Ui::MainWindow::STOP_PRICE_NAME 53 | end 54 | 55 | def join_auction_button 56 | element_with_name Ui::MainWindow::JOIN_BUTTON_NAME 57 | end 58 | 59 | def wait_for 60 | Timeout.timeout 2 do 61 | sleep 0.01 until yield 62 | return true 63 | end 64 | rescue Timeout::Error 65 | false 66 | end 67 | 68 | def element_with_name name 69 | element_and_children(@window).flatten.find {|elem| elem.name == name } or fail "'No element named '#{name}' found" 70 | end 71 | 72 | def element_and_children element 73 | return [element] unless element.respond_to? :children 74 | [element] + element.children.map {|e| element_and_children e } 75 | end 76 | 77 | def displayed_headers 78 | snipers_table.columns.map(&:title) 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test_support/fake_auction_server.rb: -------------------------------------------------------------------------------- 1 | require "blather/client/client" 2 | 3 | class FakeAuctionServer 4 | PRICE_EVENT_FORMAT = "SOLVersion: 1.1; Event: PRICE; CurrentPrice: %d; Increment: %d; Bidder: %s;" 5 | CLOSE_EVENT_FORMAT = "SOLVersion: 1.1; Event: CLOSE;" 6 | 7 | attr_reader :item_id 8 | 9 | def initialize item_id 10 | @item_id = item_id 11 | end 12 | 13 | def start_selling_item 14 | @client = Blather::Client.setup auction_login, auction_password 15 | @client.register_handler :message do |m| 16 | @received_message = m 17 | end 18 | connect 19 | end 20 | 21 | def report_price price, increment, current_high_bidder 22 | send_message PRICE_EVENT_FORMAT % [price, increment, current_high_bidder] 23 | end 24 | 25 | def close 26 | send_message CLOSE_EVENT_FORMAT 27 | end 28 | 29 | def send_invalid_message_containing message 30 | send_message message 31 | end 32 | 33 | def wait_for_join_request_from_sniper sniper_id 34 | Timeout.timeout 5 do 35 | sleep 0.01 until has_received_message?(Xmpp::XmppAuction::JOIN_COMMAND_FORMAT, sniper_id) 36 | end 37 | end 38 | 39 | def wait_for_sniper_to_bid amount, sniper_id 40 | Timeout.timeout 5 do 41 | sleep 0.01 until has_received_message?(Xmpp::XmppAuction::BID_COMMAND_FORMAT % amount, sniper_id) 42 | end 43 | end 44 | 45 | def stop 46 | EM.next_tick { @client.close } 47 | end 48 | 49 | private 50 | 51 | def auction_password 52 | "auction" 53 | end 54 | 55 | def auction_login 56 | "auction-#{item_id}@localhost" 57 | end 58 | 59 | def sniper_id 60 | "sniper@localhost" 61 | end 62 | 63 | def send_message message 64 | EM.next_tick do 65 | @client.write Blather::Stanza::Message.new(sniper_id, message) 66 | end 67 | end 68 | 69 | def has_received_message? text, sender 70 | @received_message && @received_message.body == text && @received_message.from.stripped == sender 71 | end 72 | 73 | def connect 74 | @client.connect 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /vines/conf/certs/README: -------------------------------------------------------------------------------- 1 | The certs/ directory contains the TLS certificates required for encrypting 2 | client to server and server to server XMPP connections. TLS encryption 3 | is mandatory for these streams so this directory must be configured properly. 4 | 5 | The ca-bundle.crt file contains root Certificate Authority (CA) certificates. 6 | These are used to validate certificates presented during TLS handshake 7 | negotiation. The source for this file is the cacert.pem file available 8 | at http://curl.haxx.se/docs/caextract.html. 9 | 10 | Any self-signed CA certificate placed in this directory will be considered 11 | a trusted certificate. For example, let's say you're running the wonderland.lit 12 | XMPP server and would like to allow verona.lit to connect a server to server 13 | stream. The verona.lit server hasn't purchased a legitimate TLS certificate 14 | from a CA known in ca-bundle.crt. Instead, they've created a self-signed 15 | certificate and sent it to you. Place the certificate in this directory 16 | with a name of verona.lit.crt and it will be trusted. TLS connections from 17 | verona.lit will now work. 18 | 19 | For TLS connections to work for a virtual host, two files are needed in this 20 | directory: .key and .crt. The key file must contain 21 | a PEM encoded private key. Do not give this file to anyone. This is your 22 | private key so keep it private. The crt file must contain a PEM encoded TLS 23 | certificate. This contains your public key so feel free to share it with other 24 | servers. 25 | 26 | For example, when you add a new virtual host named wonderland.lit to 27 | the XMPP server, you need to run the 'vines cert wonderland.lit' command to 28 | generate the private key file and the self-signed certificate file in this 29 | directory. After running that command you will have a certs/wonderland.lit.key 30 | and a certs/wonderland.lit.crt file. 31 | 32 | Alternatively, you can purchase a TLS certificate from a CA (e.g. RapidSSL, 33 | VeriSign, etc.) and place it in this directory. This will avoid the hassles 34 | of managing self-signed certificates. 35 | 36 | Certificates for wildcard domains, like *.wonderland.lit, can be placed in this 37 | directory with a name of wonderland.lit.crt with a matching wonderland.lit.key 38 | file. The wildcard files will be used to secure connections to any subdomain 39 | under wonderland.lit (tea.wonderland.lit, party.wonderland.lit, etc). 40 | -------------------------------------------------------------------------------- /vines/conf/certs/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDnzCCAoegAwIBAgIEUbN5lTANBgkqhkiG9w0BAQUFADBhMQswCQYDVQQGEwJV 3 | UzERMA8GA1UECAwIQ29sb3JhZG8xDzANBgNVBAcMBkRlbnZlcjEaMBgGA1UECgwR 4 | VmluZXMgWE1QUCBTZXJ2ZXIxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0xMzA2MDcx 5 | ODM2MDVaFw0xNDA2MDgxODM2MDVaMGExCzAJBgNVBAYTAlVTMREwDwYDVQQIDAhD 6 | b2xvcmFkbzEPMA0GA1UEBwwGRGVudmVyMRowGAYDVQQKDBFWaW5lcyBYTVBQIFNl 7 | cnZlcjESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A 8 | MIIBCgKCAQEA4v0M29qkuVfqVZ7UGWBIqnrPMt20AhNe+mST2gpj+K0/Byu4lwfu 9 | OriOCz35R5xu9hwpJbPztNYUH010hczV0xXWLCfjC1fjiTS5FHtZTD5Zh85TTPsZ 10 | tYkFpoi3M6pRYshvHgfkHCKqwdY+JcoAhbtlujChl3PSJwiLfk0nUNfEuzkM6yyt 11 | wGKetKAYSyAoai4SKnPt6/zIZtFvbnUC0u+nXD0ugmuipkKtwzsW7/t4GXMyFhxQ 12 | QQANRlb+tT2zPUCgqBc9aHeAGZqUeVj33t5E9hDMYEu1bchoeBt1yRcT/hSYdYTI 13 | sX8jilUDEIY50QcSJ8xAfiVkiydjc6SDfwIDAQABo18wXTAMBgNVHRMEBTADAQH/ 14 | MB0GA1UdDgQWBBTNm6x7W/94uxl/XGBircF+yCXX2TAuBgNVHREEJzAlgglsb2Nh 15 | bGhvc3SCGGtlcnJ5cy1tYWNib29rLXByby5sb2NhbDANBgkqhkiG9w0BAQUFAAOC 16 | AQEAgTnij5znhfjctN+BJGouLeXp92hw0ShUtGfcaVIgpHjtV2D+YBah6XPtSGnL 17 | ggeJRmypW6KWmWXZnzpXOfreLGcDaNLTdUerwKjzxPw0nAcvVKoyTbEf4E24X/kl 18 | RH+2GBqzwkKUsaW2rwV5qWYCEWx7lKYqf1hgZy0iPy5FsArfG85ZzGE0SLM3MYhc 19 | AWznVvC4fEZo4k44eeeLwlWaZNWIIyc5sfUfr+XQ0S99jK7N6LYeS9pj7oQfilGz 20 | m7k+IFib5hLOHQy93We77Ss7ZGLcSuXDTf2FioxXZ507U4FfzYWkERLB+nUc8OY6 21 | 09FSj1W458L0d7DrJUSWsHpt7w== 22 | -----END CERTIFICATE----- 23 | -------------------------------------------------------------------------------- /vines/conf/certs/localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEA4v0M29qkuVfqVZ7UGWBIqnrPMt20AhNe+mST2gpj+K0/Byu4 3 | lwfuOriOCz35R5xu9hwpJbPztNYUH010hczV0xXWLCfjC1fjiTS5FHtZTD5Zh85T 4 | TPsZtYkFpoi3M6pRYshvHgfkHCKqwdY+JcoAhbtlujChl3PSJwiLfk0nUNfEuzkM 5 | 6yytwGKetKAYSyAoai4SKnPt6/zIZtFvbnUC0u+nXD0ugmuipkKtwzsW7/t4GXMy 6 | FhxQQQANRlb+tT2zPUCgqBc9aHeAGZqUeVj33t5E9hDMYEu1bchoeBt1yRcT/hSY 7 | dYTIsX8jilUDEIY50QcSJ8xAfiVkiydjc6SDfwIDAQABAoIBAQCa8L6ZSArO1TsI 8 | zMzcsYOIkyHiB8G69PwNESB3YkZRVfjC7U4oEerEvHuBIwGIpzh62EgiC07cbpD3 9 | uQhD2MlQOASkyWlKseiIHKwFVhljWOAGOolT78bhyrFHtuTO6IB7XHO03RARQHys 10 | qZBsfRGUN5G94To8Rnv51vRY91NR812egD4J8NAfy2aocBGIgkpmC/9CDo+fDW5u 11 | l0XdcSZSxSHdiKygrQ0Hbo+ImurNHjdOuTP5r/FQe97kzuJUZ0rL7psg+mBav4wv 12 | eM0jKovsfrh6Ft+X7uHNKQTuR6t9VG23QV4re2zs4lDv4PIqjnkgjl1f/FjHrDqN 13 | KhmFsZW5AoGBAPSmS8xxeTTx9VFrqeYmYxyasRU8mSKCKyyE5G8bQLQTB+cDSYst 14 | F+8TFZXJV5Gri4bLpKtcJu/rRnIUc7COzHl+z0yytQlOv1UQz117JnVXseaJIhpm 15 | DcHr8Dap1AuxLmGXABueIOXuB7WQ4KfXXlc4QJgViA2UVRlhxAf7X1bzAoGBAO2E 16 | /jXkrhM7jzeTDbox2wtilcm3pUdd6OceBTd2uuYFX/k81WMywL9Af6ABv78QtfBZ 17 | PkdZb6I46SW3uqU/DquE5lZewDB9If2XVnzZSdWqYXaTcAzpGaGmq57sIVldw9w+ 18 | CAtYm8mVFtswXQlaYKNLGBTTR4rOdDLd9e6HspxFAoGAEF6EGdTJ2FoMIPuELasJ 19 | 3KMZECOy11VAUEVAB5MaPDI9yB43MIG+5TcwrYoAOvXwav97MCAFVu42E3H836Ze 20 | Vg6/DhGy/UFwmd97EHUp+JX6iENKrduANiZ0NyQb1QBw6wSwdCibaOcJSwO7lF9b 21 | p5hS8hoWtVnka8NX23TdRzMCgYEAqGugcoioIr0d7bNZjYjioK0UN4gAK53ck12H 22 | J3AKUSbIigvn58JKSJMsrEHxPENWL4qojaFOdkJSmEsyjxAqj9baGa5wKzHf01jn 23 | m/nb0CVTnOgpEQ6M/UIY+cTIVP0W7+oQfDMlrIgKP/yITCSI1+Fcvw+d8EA6xwL6 24 | 61vPe90CgYBfVeK8libqwWzaITkLmW913B+44yLEVieGjGxJjK6C4Mj0tGLo37Lq 25 | Ss8QOEhEaGUnpjaoPFjxXnSdN+GLCS5L+6r4c6wqc0qoZJFQoM52koxTQho3FazW 26 | 3w8ESToDWN5OVEgL0MKIMlNq22agV4JQ4pm9b+yDOSbKVJ86KkcQAw== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /vines/conf/config.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | # This is the Vines XMPP server configuration file. Restart the server with 4 | # 'vines restart' after updating this file. 5 | 6 | Vines::Config.configure do 7 | # Set the logging level to debug, info, warn, error, or fatal. The debug 8 | # level logs all XML sent and received by the server. 9 | log :debug 10 | 11 | # Each host element below is a virtual host domain name that this server will 12 | # service. Hosts can share storage configurations or use separate databases. 13 | # TLS encryption is mandatory so each host must have a .crt and 14 | # .key file in the conf/certs directory. A self-signed certificate can 15 | # be generated for a virtual host domain with the 'vines cert ' 16 | # command. Change the example, 'localhost', domain name to your actual 17 | # domain. 18 | # 19 | # The private_storage attribute allows clients to store XML fragments 20 | # on the server, using the XEP-0049 Private XML Storage feature. 21 | # 22 | # The pubsub attribute defines the XEP-0060 Publish-Subscribe services hosted 23 | # at these virtual host domains. In the example below, pubsub services are 24 | # available at games.localhost and scores.localhost as well as 25 | # games.verona.lit and scores.verona.lit. 26 | # 27 | # Shared storage example: 28 | # host 'verona.lit', 'localhost' do 29 | # private_storage false 30 | # cross_domain_messages false 31 | # storage 'fs' do 32 | # dir 'data' 33 | # end 34 | # components 'tea' => 'secr3t', 35 | # 'cake' => 'passw0rd' 36 | # pubsub 'games', 'scores' 37 | # end 38 | 39 | host 'localhost' do 40 | cross_domain_messages false 41 | private_storage false 42 | storage 'fs' do 43 | dir 'data' 44 | end 45 | # components 'tea' => 'secr3t', 46 | # 'cake' => 'passw0rd' 47 | # pubsub 'games', 'scores' 48 | end 49 | 50 | # Hosts can use LDAP authentication that overrides the authentication 51 | # provided by a storage database. If LDAP is in use, passwords are not 52 | # saved or validated against the storage database. However, all other user 53 | # information, like rosters, is still saved in the storage database. 54 | # 55 | # host 'localhost' do 56 | # cross_domain_messages false 57 | # private_storage false 58 | # storage 'fs' do 59 | # dir 'data' 60 | # end 61 | # ldap 'ldap.localhost', 636 do 62 | # dn 'cn=Directory Manager' 63 | # password 'secr3t' 64 | # basedn 'dc=wonderland,dc=lit' 65 | # groupdn 'cn=chatters,dc=wonderland,dc=lit' # optional 66 | # object_class 'person' 67 | # user_attr 'uid' 68 | # name_attr 'cn' 69 | # tls true 70 | # end 71 | # components 'tea' => 'secr3t', 72 | # 'cake' => 'passw0rd' 73 | # end 74 | 75 | # Configure the client-to-server port. The max_resources_per_account attribute 76 | # limits how many concurrent connections one user can have to the server. 77 | client '0.0.0.0', 5222 do 78 | max_stanza_size 65536 79 | max_resources_per_account 5 80 | end 81 | 82 | # Configure the server-to-server port. The max_stanza_size attribute should be 83 | # much larger than the setting for client-to-server. Add domain names to the 84 | # 'hosts' white-list attribute to allow those servers to connect. Any connection 85 | # attempt from a host not in this list will be denied. 86 | server '0.0.0.0', 5269 do 87 | max_stanza_size 131072 88 | hosts [] 89 | end 90 | 91 | # Configure the built-in HTTP server that serves static files and responds to 92 | # XEP-0124 BOSH requests. This allows HTTP clients to connect to 93 | # the XMPP server. 94 | # 95 | # The root attribute defines the web server's document root (default 'web/'). 96 | # It will only serve files out of this directory. 97 | # 98 | # The bind attribute defines the URL to which BOSH clients must POST their 99 | # XMPP stanza requests (default /xmpp). 100 | # 101 | # The vroute attribute defines the value of the vroute cookie sent in each 102 | # response that uniquely identifies this HTTP server. Reverse proxy servers 103 | # (nginx/apache) can use this cookie to implement sticky sessions. This is 104 | # only needed if the server is clustered behind a reverse proxy. 105 | http '0.0.0.0', 5280 do 106 | bind '/xmpp' 107 | max_stanza_size 65536 108 | max_resources_per_account 5 109 | root 'web' 110 | vroute '' 111 | end 112 | 113 | # Configure the XEP-0114 external component port. Component sub-domains and 114 | # their passwords are defined with their virtual host entries above. 115 | component '0.0.0.0', 5347 do 116 | max_stanza_size 131072 117 | end 118 | 119 | # Configure the redis connection used to form a cluster of server instances, 120 | # serving the same chat domains across many different machines. 121 | #cluster do 122 | # host 'redis.localhost' 123 | # port 6379 124 | # database 0 125 | # password '' 126 | #end 127 | end 128 | 129 | # Available storage implementations: 130 | 131 | #storage 'fs' do 132 | # dir 'data' 133 | #end 134 | 135 | #storage 'couchdb' do 136 | # host 'localhost' 137 | # port 6984 138 | # database 'xmpp' 139 | # tls true 140 | # username '' 141 | # password '' 142 | #end 143 | 144 | #storage 'mongodb' do 145 | # host 'localhost', 27017 146 | # host 'localhost', 27018 # optional, connects to replica set 147 | # database 'xmpp' 148 | # tls true 149 | # username '' 150 | # password '' 151 | # pool 5 152 | #end 153 | 154 | #storage 'redis' do 155 | # host 'localhost' 156 | # port 6379 157 | # database 0 158 | # password '' 159 | #end 160 | 161 | #storage 'sql' do 162 | # adapter 'postgresql' 163 | # host 'localhost' 164 | # port 5432 165 | # database 'xmpp' 166 | # username '' 167 | # password '' 168 | # pool 5 169 | #end 170 | -------------------------------------------------------------------------------- /vines/data/user/auction-item-54321@localhost: -------------------------------------------------------------------------------- 1 | --- 2 | name: 3 | password: !binary |- 4 | JDJhJDEwJEFqLkpBbGhDLkh3dkpRNUVua2NoM3VBQkp2SDNLbWF6eE85cDQ4 5 | bjZFMWJwOE82UWdnRlR1 6 | roster: {} 7 | -------------------------------------------------------------------------------- /vines/data/user/auction-item-65432@localhost: -------------------------------------------------------------------------------- 1 | --- 2 | name: 3 | password: !binary |- 4 | JDJhJDEwJDh2T2xVd3pCcHNrV2c4RTNUVEh2dC5QTTNpS1dZTjZVa0dXWVJw 5 | VGlMZHV2TDhHcUZJLktT 6 | roster: {} 7 | -------------------------------------------------------------------------------- /vines/data/user/sniper@localhost: -------------------------------------------------------------------------------- 1 | --- 2 | name: 3 | password: !binary |- 4 | JDJhJDEwJHRSbkQvWEVyUXpUZUszM3pKN0c4TXVic2dUQ3hOWWpYR2txVk0v 5 | dE5UR3FraGtTMHdteG02 6 | roster: {} 7 | -------------------------------------------------------------------------------- /vines/web/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vines 7 | 8 | 9 | 41 | 42 | 43 |
44 |

Page not found

45 |

46 | This is not the page you're looking for. You probably wanted the 47 | chat application. 48 |

49 |
50 | 51 | 52 | -------------------------------------------------------------------------------- /vines/web/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kerryb/goos-ruby/d76d17e3d6d53da72bf1cc344c1e9dccf84d6b8b/vines/web/apple-touch-icon.png -------------------------------------------------------------------------------- /vines/web/chat/coffeescripts/chat.coffee: -------------------------------------------------------------------------------- 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 | @layout = null 10 | 11 | datef: (millis) -> 12 | d = new Date(millis) 13 | meridian = if d.getHours() >= 12 then ' pm' else ' am' 14 | hour = if d.getHours() > 12 then d.getHours() - 12 else d.getHours() 15 | hour = 12 if hour == 0 16 | minutes = d.getMinutes() + '' 17 | minutes = '0' + minutes if minutes.length == 1 18 | hour + ':' + minutes + meridian 19 | 20 | card: (card) -> 21 | this.eachContact card.jid, (node) => 22 | $('.vcard-img', node).attr 'src', @session.avatar card.jid 23 | 24 | roster: -> 25 | roster = $('#roster') 26 | 27 | $('li', roster).each (ix, node) => 28 | jid = $(node).attr('data-jid') 29 | $(node).remove() unless @session.roster[jid] 30 | 31 | setName = (node, contact) -> 32 | $('.text', node).text contact.name || contact.jid 33 | node.attr 'data-name', contact.name || '' 34 | 35 | for jid, contact of @session.roster 36 | found = $("#roster li[data-jid='#{jid}']") 37 | setName(found, contact) 38 | if found.length == 0 39 | node = $(""" 40 |
  • 41 | 42 | Offline 43 | 44 | #{jid} 45 |
  • 46 | """).appendTo roster 47 | setName(node, contact) 48 | node.click (event) => this.selectContact(event) 49 | 50 | message: (message) -> 51 | return unless message.type == 'chat' && message.text 52 | this.queueMessage message 53 | me = message.from == @session.jid() 54 | from = message.from.split('/')[0] 55 | 56 | if me || from == @currentContact 57 | bottom = this.atBottom() 58 | this.appendMessage message 59 | this.scroll() if bottom 60 | else 61 | chat = this.chat message.from 62 | chat.unread++ 63 | this.eachContact from, (node) -> 64 | $('.unread', node).text(chat.unread).show() 65 | 66 | eachContact: (jid, callback) -> 67 | for node in $("#roster li[data-jid='#{jid}']").get() 68 | callback $(node) 69 | 70 | appendMessage: (message) -> 71 | from = message.from.split('/')[0] 72 | contact = @session.roster[from] 73 | name = if contact then (contact.name || from) else from 74 | name = 'Me' if message.from == @session.jid() 75 | node = $(""" 76 |
  • 77 |

    78 | #{from} 79 |
    80 | 81 | #{this.datef message.received} 82 |
    83 |
  • 84 | """).appendTo '#messages' 85 | 86 | $('p', node).text message.text 87 | $('.author', node).text name 88 | node.fadeIn 200 89 | 90 | queueMessage: (message) -> 91 | me = message.from == @session.jid() 92 | full = message[if me then 'to' else 'from'] 93 | chat = this.chat full 94 | chat.jid = full 95 | chat.messages.push message 96 | 97 | chat: (jid) -> 98 | bare = jid.split('/')[0] 99 | chat = @chats[bare] 100 | unless chat 101 | chat = jid: jid, messages: [], unread: 0 102 | @chats[bare] = chat 103 | chat 104 | 105 | presence: (presence) -> 106 | from = presence.from.split('/')[0] 107 | return if from == @session.bareJid() 108 | if !presence.type || presence.offline 109 | contact = @session.roster[from] 110 | this.eachContact from, (node) -> 111 | $('.status-msg', node).text contact.status() 112 | if contact.offline() 113 | node.addClass 'offline' 114 | else 115 | node.removeClass 'offline' 116 | 117 | if presence.offline 118 | this.chat(from).jid = from 119 | 120 | if presence.type == 'subscribe' 121 | node = $(""" 122 |
  • 123 |
    124 |

    Buddy Approval

    125 |

    #{presence.from} wants to add you as a buddy.

    126 |
    127 | 128 | 129 |
    130 |
    131 |
  • 132 | """).appendTo '#notifications' 133 | node.fadeIn 200 134 | $('form', node).submit => this.acceptContact node, presence.from 135 | $('input[type="button"]', node).click => this.rejectContact node, presence.from 136 | 137 | acceptContact: (node, jid) -> 138 | node.fadeOut 200, -> node.remove() 139 | @session.sendSubscribed jid 140 | @session.sendSubscribe jid 141 | false 142 | 143 | rejectContact: (node, jid) -> 144 | node.fadeOut 200, -> node.remove() 145 | @session.sendUnsubscribed jid 146 | 147 | selectContact: (event) -> 148 | jid = $(event.currentTarget).attr 'data-jid' 149 | contact = @session.roster[jid] 150 | return if @currentContact == jid 151 | @currentContact = jid 152 | 153 | $('#roster li').removeClass 'selected' 154 | $(event.currentTarget).addClass 'selected' 155 | $('#chat-title').text('Chat with ' + (contact.name || contact.jid)) 156 | $('#messages').empty() 157 | 158 | chat = @chats[jid] 159 | messages = [] 160 | if chat 161 | messages = chat.messages 162 | chat.unread = 0 163 | this.eachContact jid, (node) -> 164 | $('.unread', node).text('').hide() 165 | 166 | this.appendMessage msg for msg in messages 167 | this.scroll() 168 | 169 | $('#remove-contact-msg').html "Are you sure you want to remove " + 170 | "#{@currentContact} from your buddy list?" 171 | $('#remove-contact-form .buttons').fadeIn 200 172 | 173 | $('#edit-contact-jid').text @currentContact 174 | $('#edit-contact-name').val @session.roster[@currentContact].name 175 | $('#edit-contact-form input').fadeIn 200 176 | $('#edit-contact-form .buttons').fadeIn 200 177 | 178 | scroll: -> 179 | msgs = $ '#messages' 180 | msgs.animate(scrollTop: msgs.prop('scrollHeight'), 400) 181 | 182 | atBottom: -> 183 | msgs = $('#messages') 184 | bottom = msgs.prop('scrollHeight') - msgs.outerHeight() 185 | msgs.scrollTop() >= bottom 186 | 187 | send: -> 188 | return false unless @currentContact 189 | input = $('#message') 190 | text = input.val().trim() 191 | if text 192 | chat = @chats[@currentContact] 193 | jid = if chat then chat.jid else @currentContact 194 | this.message 195 | from: @session.jid() 196 | text: text 197 | to: jid 198 | type: 'chat' 199 | received: new Date() 200 | @session.sendMessage jid, text 201 | input.val '' 202 | false 203 | 204 | addContact: -> 205 | this.toggleForm '#add-contact-form' 206 | contact = 207 | jid: $('#add-contact-jid').val() 208 | name: $('#add-contact-name').val() 209 | groups: ['Buddies'] 210 | @session.updateContact contact, true if contact.jid 211 | false 212 | 213 | removeContact: -> 214 | this.toggleForm '#remove-contact-form' 215 | @session.removeContact @currentContact 216 | @currentContact = null 217 | 218 | $('#chat-title').text 'Select a buddy to chat' 219 | $('#messages').empty() 220 | 221 | $('#remove-contact-msg').html "Select a buddy in the list above to remove." 222 | $('#remove-contact-form .buttons').hide() 223 | 224 | $('#edit-contact-jid').text "Select a buddy in the list above to update." 225 | $('#edit-contact-name').val '' 226 | $('#edit-contact-form input').hide() 227 | $('#edit-contact-form .buttons').hide() 228 | false 229 | 230 | updateContact: -> 231 | this.toggleForm '#edit-contact-form' 232 | contact = 233 | jid: @currentContact 234 | name: $('#edit-contact-name').val() 235 | groups: @session.roster[@currentContact].groups 236 | @session.updateContact contact 237 | false 238 | 239 | toggleForm: (form, fn) -> 240 | form = $(form) 241 | $('form.overlay').each -> 242 | $(this).hide() unless this.id == form.attr 'id' 243 | if form.is ':hidden' 244 | fn() if fn 245 | form.fadeIn 100 246 | else 247 | form.fadeOut 100, => 248 | form[0].reset() 249 | @layout.resize() 250 | fn() if fn 251 | 252 | draw: -> 253 | unless @session.connected() 254 | window.location.hash = '' 255 | return 256 | 257 | $('body').attr 'id', 'chat-page' 258 | $('#container').hide().empty() 259 | $(""" 260 | 296 |
    297 |

    Select a buddy to chat

    298 |
      299 |
      300 | 301 |
      302 |
      303 | 310 | """).appendTo '#container' 311 | 312 | this.roster() 313 | 314 | new Button '#clear-notices', ICONS.no 315 | new Button '#add-contact', ICONS.plus 316 | new Button '#remove-contact', ICONS.minus 317 | new Button '#edit-contact', ICONS.user 318 | 319 | $('#message').focus -> $('form.overlay').fadeOut() 320 | $('#message-form').submit => this.send() 321 | 322 | $('#clear-notices').click -> $('#notifications li').fadeOut 200 323 | 324 | $('#add-contact').click => this.toggleForm '#add-contact-form' 325 | $('#remove-contact').click => this.toggleForm '#remove-contact-form' 326 | $('#edit-contact').click => this.toggleForm '#edit-contact-form', => 327 | if @currentContact 328 | $('#edit-contact-jid').text @currentContact 329 | $('#edit-contact-name').val @session.roster[@currentContact].name 330 | 331 | $('#add-contact-cancel').click => this.toggleForm '#add-contact-form' 332 | $('#remove-contact-cancel').click => this.toggleForm '#remove-contact-form' 333 | $('#edit-contact-cancel').click => this.toggleForm '#edit-contact-form' 334 | 335 | $('#add-contact-form').submit => this.addContact() 336 | $('#remove-contact-form').submit => this.removeContact() 337 | $('#edit-contact-form').submit => this.updateContact() 338 | 339 | $('#container').fadeIn 200 340 | @layout = this.resize() 341 | 342 | fn = => 343 | @layout.resize() 344 | @layout.resize() # not sure why two are needed 345 | 346 | new Filter 347 | list: '#roster' 348 | icon: '#search-roster-icon' 349 | form: '#search-roster-form' 350 | attrs: ['data-jid', 'data-name'] 351 | open: fn 352 | close: fn 353 | 354 | resize: -> 355 | a = $ '#alpha' 356 | b = $ '#beta' 357 | c = $ '#charlie' 358 | msg = $ '#message' 359 | form = $ '#message-form' 360 | new Layout -> 361 | c.css 'left', a.width() + b.width() 362 | msg.width form.width() - 32 363 | -------------------------------------------------------------------------------- /vines/web/chat/coffeescripts/init.coffee: -------------------------------------------------------------------------------- 1 | $ -> 2 | session = new Session() 3 | nav = new NavBar(session) 4 | nav.draw() 5 | buttons = 6 | Messages: ICONS.chat 7 | Logout: ICONS.power 8 | nav.addButton(label, icon) for label, icon of buttons 9 | 10 | pages = 11 | '/messages': new ChatPage(session) 12 | '/logout': new LogoutPage(session) 13 | 'default': new LoginPage(session, '/messages/') 14 | new Router(pages).draw() 15 | nav.select $('#nav-link-messages').parent() 16 | -------------------------------------------------------------------------------- /vines/web/chat/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vines 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /vines/web/chat/javascripts/app.js: -------------------------------------------------------------------------------- 1 | (function(){this.ChatPage=function(){function a(a){var b=this;this.session=a,this.session.onRoster(function(){return b.roster()}),this.session.onCard(function(a){return b.card(a)}),this.session.onMessage(function(a){return b.message(a)}),this.session.onPresence(function(a){return b.presence(a)}),this.chats={},this.currentContact=null,this.layout=null}return a.prototype.datef=function(a){var b,c,d,e;return b=new Date(a),d=b.getHours()>=12?" pm":" am",c=b.getHours()>12?b.getHours()-12:b.getHours(),c===0&&(c=12),e=b.getMinutes()+"",e.length===1&&(e="0"+e),c+":"+e+d},a.prototype.card=function(a){var b=this;return this.eachContact(a.jid,function(c){return $(".vcard-img",c).attr("src",b.session.avatar(a.jid))})},a.prototype.roster=function(){var a,b,c,d,e,f,g,h,i=this;e=$("#roster"),$("li",e).each(function(a,b){var c;c=$(b).attr("data-jid");if(!i.session.roster[c])return $(b).remove()}),f=function(a,b){return $(".text",a).text(b.name||b.jid),a.attr("data-name",b.name||"")},g=this.session.roster,h=[];for(c in g)a=g[c],b=$("#roster li[data-jid='"+c+"']"),f(b,a),b.length===0?(d=$('
    • \n \n Offline\n \n '+c+'\n
    • ').appendTo(e),f(d,a),h.push(d.click(function(a){return i.selectContact(a)}))):h.push(void 0);return h},a.prototype.message=function(a){var b,c,d,e;if(a.type!=="chat"||!a.text)return;this.queueMessage(a),e=a.from===this.session.jid(),d=a.from.split("/")[0];if(!e&&d!==this.currentContact)return c=this.chat(a.from),c.unread++,this.eachContact(d,function(a){return $(".unread",a).text(c.unread).show()});b=this.atBottom(),this.appendMessage(a);if(b)return this.scroll()},a.prototype.eachContact=function(a,b){var c,d,e,f,g;f=$("#roster li[data-jid='"+a+"']").get(),g=[];for(d=0,e=f.length;d\n

      \n '+c+'\n
      \n \n '+this.datef(a.received)+"\n
      \n").appendTo("#messages"),$("p",e).text(a.text),$(".author",e).text(d),e.fadeIn(200)},a.prototype.queueMessage=function(a){var b,c,d;return d=a.from===this.session.jid(),c=a[d?"to":"from"],b=this.chat(c),b.jid=c,b.messages.push(a)},a.prototype.chat=function(a){var b,c;return b=a.split("/")[0],c=this.chats[b],c||(c={jid:a,messages:[],unread:0},this.chats[b]=c),c},a.prototype.presence=function(a){var b,c,d,e=this;c=a.from.split("/")[0];if(c===this.session.bareJid())return;if(!a.type||a.offline)b=this.session.roster[c],this.eachContact(c,function(a){return $(".status-msg",a).text(b.status()),b.offline()?a.addClass("offline"):a.removeClass("offline")});a.offline&&(this.chat(c).jid=c);if(a.type==="subscribe")return d=$('
    • \n
      \n

      Buddy Approval

      \n

      '+a.from+' wants to add you as a buddy.

      \n
      \n \n \n
      \n
      \n
    • ').appendTo("#notifications"),d.fadeIn(200),$("form",d).submit(function(){return e.acceptContact(d,a.from)}),$('input[type="button"]',d).click(function(){return e.rejectContact(d,a.from)})},a.prototype.acceptContact=function(a,b){return a.fadeOut(200,function(){return a.remove()}),this.session.sendSubscribed(b),this.session.sendSubscribe(b),!1},a.prototype.rejectContact=function(a,b){return a.fadeOut(200,function(){return a.remove()}),this.session.sendUnsubscribed(b)},a.prototype.selectContact=function(a){var b,c,d,e,f,g,h;d=$(a.currentTarget).attr("data-jid"),c=this.session.roster[d];if(this.currentContact===d)return;this.currentContact=d,$("#roster li").removeClass("selected"),$(a.currentTarget).addClass("selected"),$("#chat-title").text("Chat with "+(c.name||c.jid)),$("#messages").empty(),b=this.chats[d],e=[],b&&(e=b.messages,b.unread=0,this.eachContact(d,function(a){return $(".unread",a).text("").hide()}));for(g=0,h=e.length;g"+this.currentContact+" from your buddy list?")),$("#remove-contact-form .buttons").fadeIn(200),$("#edit-contact-jid").text(this.currentContact),$("#edit-contact-name").val(this.session.roster[this.currentContact].name),$("#edit-contact-form input").fadeIn(200),$("#edit-contact-form .buttons").fadeIn(200)},a.prototype.scroll=function(){var a;return a=$("#messages"),a.animate({scrollTop:a.prop("scrollHeight")},400)},a.prototype.atBottom=function(){var a,b;return b=$("#messages"),a=b.prop("scrollHeight")-b.outerHeight(),b.scrollTop()>=a},a.prototype.send=function(){var a,b,c,d;return this.currentContact?(b=$("#message"),d=b.val().trim(),d&&(a=this.chats[this.currentContact],c=a?a.jid:this.currentContact,this.message({from:this.session.jid(),text:d,to:c,type:"chat",received:new Date}),this.session.sendMessage(c,d)),b.val(""),!1):!1},a.prototype.addContact=function(){var a;return this.toggleForm("#add-contact-form"),a={jid:$("#add-contact-jid").val(),name:$("#add-contact-name").val(),groups:["Buddies"]},a.jid&&this.session.updateContact(a,!0),!1},a.prototype.removeContact=function(){return this.toggleForm("#remove-contact-form"),this.session.removeContact(this.currentContact),this.currentContact=null,$("#chat-title").text("Select a buddy to chat"),$("#messages").empty(),$("#remove-contact-msg").html("Select a buddy in the list above to remove."),$("#remove-contact-form .buttons").hide(),$("#edit-contact-jid").text("Select a buddy in the list above to update."),$("#edit-contact-name").val(""),$("#edit-contact-form input").hide(),$("#edit-contact-form .buttons").hide(),!1},a.prototype.updateContact=function(){var a;return this.toggleForm("#edit-contact-form"),a={jid:this.currentContact,name:$("#edit-contact-name").val(),groups:this.session.roster[this.currentContact].groups},this.session.updateContact(a),!1},a.prototype.toggleForm=function(a,b){var c=this;return a=$(a),$("form.overlay").each(function(){if(this.id!==a.attr("id"))return $(this).hide()}),a.is(":hidden")?(b&&b(),a.fadeIn(100)):a.fadeOut(100,function(){a[0].reset(),c.layout.resize();if(b)return b()})},a.prototype.draw=function(){var a,b=this;if(!this.session.connected()){window.location.hash="";return}return $("body").attr("id","chat-page"),$("#container").hide().empty(),$('\n
      \n

      Select a buddy to chat

      \n
        \n
        \n \n
        \n
        \n').appendTo("#container"),this.roster(),new Button("#clear-notices",ICONS.no),new Button("#add-contact",ICONS.plus),new Button("#remove-contact",ICONS.minus),new Button("#edit-contact",ICONS.user),$("#message").focus(function(){return $("form.overlay").fadeOut()}),$("#message-form").submit(function(){return b.send()}),$("#clear-notices").click(function(){return $("#notifications li").fadeOut(200)}),$("#add-contact").click(function(){return b.toggleForm("#add-contact-form")}),$("#remove-contact").click(function(){return b.toggleForm("#remove-contact-form")}),$("#edit-contact").click(function(){return b.toggleForm("#edit-contact-form",function(){if(b.currentContact)return $("#edit-contact-jid").text(b.currentContact),$("#edit-contact-name").val(b.session.roster[b.currentContact].name)})}),$("#add-contact-cancel").click(function(){return b.toggleForm("#add-contact-form")}),$("#remove-contact-cancel").click(function(){return b.toggleForm("#remove-contact-form")}),$("#edit-contact-cancel").click(function(){return b.toggleForm("#edit-contact-form")}),$("#add-contact-form").submit(function(){return b.addContact()}),$("#remove-contact-form").submit(function(){return b.removeContact()}),$("#edit-contact-form").submit(function(){return b.updateContact()}),$("#container").fadeIn(200),this.layout=this.resize(),a=function(){return b.layout.resize(),b.layout.resize()},new Filter({list:"#roster",icon:"#search-roster-icon",form:"#search-roster-form",attrs:["data-jid","data-name"],open:a,close:a})},a.prototype.resize=function(){var a,b,c,d,e;return a=$("#alpha"),b=$("#beta"),c=$("#charlie"),e=$("#message"),d=$("#message-form"),new Layout(function(){return c.css("left",a.width()+b.width()),e.width(d.width()-32)})},a}(),$(function(){var a,b,c,d,e,f;f=new Session,d=new NavBar(f),d.draw(),a={Messages:ICONS.chat,Logout:ICONS.power};for(c in a)b=a[c],d.addButton(c,b);return e={"/messages":new ChatPage(f),"/logout":new LogoutPage(f),"default":new LoginPage(f,"/messages/")},(new Router(e)).draw(),d.select($("#nav-link-messages").parent())})}).call(this); -------------------------------------------------------------------------------- /vines/web/chat/stylesheets/chat.css: -------------------------------------------------------------------------------- 1 | #chat-page #container { 2 | height: 100%; 3 | } 4 | #chat-page #chat-title { 5 | background: #f8f8f8; 6 | } 7 | #chat-page #messages { 8 | background: #fff; 9 | height: 100%; 10 | list-style: none; 11 | text-shadow: 0 1px 1px #ddd; 12 | width: 100%; 13 | } 14 | #chat-page #messages li { 15 | border-bottom: 1px solid #f0f0f0; 16 | min-height: 40px; 17 | padding: 10px; 18 | position: relative; 19 | } 20 | #chat-page #messages li:hover > span .time { 21 | opacity: 0.3; 22 | } 23 | #chat-page #messages li img { 24 | border: 3px solid #fff; 25 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 0 40px rgba(0, 0, 0, 0.06) inset; 26 | position: absolute; 27 | top: 7px; 28 | right: 7px; 29 | height: 40px; 30 | width: 40px; 31 | } 32 | #chat-page #messages li p { 33 | line-height: 1.5; 34 | width: 90%; 35 | } 36 | #chat-page #messages li footer { 37 | font-size: 9pt; 38 | padding-right: 50px; 39 | text-align: right; 40 | } 41 | #chat-page #messages li footer span { 42 | color: #d8d8d8; 43 | margin-right: 0.5em; 44 | text-shadow: none; 45 | } 46 | #chat-page #messages li footer .author::before { 47 | content: '\2014 '; 48 | } 49 | #chat-page #message-form { 50 | background: #f8f8f8 -moz-linear-gradient(rgba(255, 255, 255, 0.1), rgba(0, 0, 0, 0.05)); 51 | background: #f8f8f8 -ms-linear-gradient(rgba(255, 255, 255, 0.1), rgba(0, 0, 0, 0.05)); 52 | background: #f8f8f8 -o-linear-gradient(rgba(255, 255, 255, 0.1), rgba(0, 0, 0, 0.05)); 53 | background: #f8f8f8 -webkit-linear-gradient(rgba(255, 255, 255, 0.1), rgba(0, 0, 0, 0.05)); 54 | border-top: 1px solid #dfdfdf; 55 | height: 50px; 56 | position: absolute; 57 | bottom: 0; 58 | width: 100%; 59 | } 60 | #chat-page #message { 61 | display: block; 62 | position: relative; 63 | left: 10px; 64 | top: 10px; 65 | width: 428px; 66 | } 67 | #chat-page #roster, 68 | #chat-page #notifications { 69 | height: 100%; 70 | list-style: none; 71 | text-shadow: 0 1px 1px #fff; 72 | width: 260px; 73 | } 74 | #chat-page #roster li, 75 | #chat-page #notifications li { 76 | cursor: pointer; 77 | border-bottom: 1px solid #ddd; 78 | font-weight: bold; 79 | min-height: 42px; 80 | padding: 0 10px; 81 | position: relative; 82 | -moz-transition: background 0.3s; 83 | -o-transition: background 0.3s; 84 | -webkit-transition: background 0.3s; 85 | transition: background 0.3s; 86 | } 87 | #chat-page #notifications li { 88 | font-weight: normal; 89 | padding: 10px 0 0 0; 90 | } 91 | #chat-page #roster li:hover:not(.selected), 92 | #chat-page #notifications li:hover { 93 | background: rgba(255, 255, 255, 1.0); 94 | } 95 | #chat-page #roster li.offline > * { 96 | opacity: 0.4; 97 | } 98 | #chat-page #roster li.selected > * { 99 | opacity: 1.0; 100 | } 101 | #chat-page #roster li.selected .status-msg { 102 | color: rgba(255, 255, 255, 0.85); 103 | } 104 | #chat-page #roster .status-msg { 105 | display: block; 106 | font-size: 11px; 107 | font-weight: normal; 108 | line-height: 11px; 109 | } 110 | #chat-page #roster .unread { 111 | background: #319be7; 112 | background: -moz-linear-gradient(#319be7, #1b78d9); 113 | background: -ms-linear-gradient(#319be7, #1b78d9); 114 | background: -o-linear-gradient(#319be7, #1b78d9); 115 | background: -webkit-linear-gradient(#319be7, #1b78d9); 116 | border-radius: 30px; 117 | color: #fff; 118 | display: inline-block; 119 | font-size: 11px; 120 | font-weight: normal; 121 | line-height: 15px; 122 | padding: 0px 6px; 123 | position: absolute; 124 | right: 50px; 125 | top: 14px; 126 | text-shadow: none; 127 | } 128 | #chat-page #roster .vcard-img { 129 | background: #fff; 130 | border: 1px solid #fff; 131 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 0 40px rgba(0, 0, 0, 0.06) inset; 132 | height: 32px; 133 | width: 32px; 134 | position: absolute; 135 | top: 4px; 136 | right: 10px; 137 | } 138 | #chat-page #charlie-controls { 139 | text-align: right; 140 | } 141 | #chat-page #edit-contact-jid { 142 | color: #444; 143 | margin-top: -5px; 144 | } 145 | -------------------------------------------------------------------------------- /vines/web/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kerryb/goos-ruby/d76d17e3d6d53da72bf1cc344c1e9dccf84d6b8b/vines/web/favicon.png -------------------------------------------------------------------------------- /vines/web/lib/coffeescripts/button.coffee: -------------------------------------------------------------------------------- 1 | class @Button 2 | constructor: (node, path, options) -> 3 | @node = $ node 4 | @path = path 5 | @options = options || {} 6 | @options.animate = true unless @options.animate? 7 | this.draw() 8 | 9 | draw: -> 10 | paper = Raphael @node.get(0) 11 | 12 | transform = "s#{@options.scale || 0.85}" 13 | transform += ",t#{@options.translation}" if @options.translation 14 | 15 | icon = paper.path(@path).attr 16 | fill: @options.fill || '#000' 17 | stroke: @options.stroke || '#fff' 18 | 'stroke-width': @options['stroke-width'] || 0.3 19 | opacity: @options.opacity || 0.6 20 | transform: transform 21 | 22 | if @options.animate 23 | @node.hover( 24 | -> icon.animate(opacity: 1.0, 200), 25 | -> icon.animate(opacity: 0.6, 200)) 26 | -------------------------------------------------------------------------------- /vines/web/lib/coffeescripts/contact.coffee: -------------------------------------------------------------------------------- 1 | class @Contact 2 | constructor: (node) -> 3 | node = $(node) 4 | @jid = node.attr 'jid' 5 | @name = node.attr 'name' 6 | @ask = node.attr 'ask' 7 | @subscription = node.attr 'subscription' 8 | @groups = $('group', node).map(-> $(this).text()).get() 9 | @presence = [] 10 | 11 | online: -> 12 | @presence.length > 0 13 | 14 | offline: -> 15 | @presence.length == 0 16 | 17 | available: -> 18 | this.online() && (p for p in @presence when !p.away).length > 0 19 | 20 | away: -> !this.available() 21 | 22 | status: -> 23 | available = (p.status for p in @presence when p.status && !p.away)[0] || 'Available' 24 | away = (p.status for p in @presence when p.status && p.away)[0] || 'Away' 25 | if this.offline() then 'Offline' 26 | else if this.away() then away 27 | else available 28 | 29 | update: (presence) -> 30 | @presence = (p for p in @presence when p.from != presence.from) 31 | @presence.push presence unless presence.type 32 | @presence = [] if presence.type == 'unsubscribed' 33 | -------------------------------------------------------------------------------- /vines/web/lib/coffeescripts/filter.coffee: -------------------------------------------------------------------------------- 1 | class @Filter 2 | constructor: (options) -> 3 | @list = options.list 4 | @icon = options.icon 5 | @form = options.form 6 | @attrs = options.attrs 7 | @open = options.open 8 | @close = options.close 9 | this.draw() 10 | 11 | draw: -> 12 | $(@icon).addClass 'filter-button' 13 | form = $('').appendTo @form 14 | text = $('').appendTo form 15 | 16 | if @icon 17 | new Button @icon, ICONS.search, 18 | scale: 0.5 19 | translation: '-16,-16' 20 | 21 | form.submit -> false 22 | text.keyup => this.filter(text) 23 | text.change => this.filter(text) 24 | text.click => this.filter(text) 25 | $(@icon).click => 26 | if form.is ':hidden' 27 | this.filter(text) 28 | form.show() 29 | this.open() if this.open 30 | else 31 | form.hide() 32 | form[0].reset() 33 | this.filter(text) 34 | this.close() if this.close 35 | 36 | filter: (input) -> 37 | text = input.val().toLowerCase() 38 | if text == '' 39 | $('li', @list).show() 40 | return 41 | 42 | test = (node, attr) -> 43 | val = (node.attr(attr) || '').toLowerCase() 44 | val.indexOf(text) != -1 45 | 46 | $('> li', @list).each (ix, node) => 47 | node = $ node 48 | matches = (true for attr in @attrs when test node, attr) 49 | if matches.length > 0 then node.show() else node.hide() 50 | -------------------------------------------------------------------------------- /vines/web/lib/coffeescripts/layout.coffee: -------------------------------------------------------------------------------- 1 | class @Layout 2 | constructor: (@fn) -> 3 | this.resize() 4 | this.listen() 5 | setTimeout (=> this.resize()), 250 6 | 7 | resize: -> 8 | this.fill '.x-fill', 'outerWidth', 'width' 9 | this.fill '.y-fill', 'outerHeight', 'height' 10 | this.fn() 11 | 12 | fill: (selector, get, set) -> 13 | $(selector).each (ix, node) => 14 | node = $(node) 15 | getter = node[get] 16 | parent = getter.call node.parent(), true 17 | fixed = this.fixed node, selector, (n) -> getter.call(n, true) 18 | node[set].call node, parent - fixed 19 | 20 | fixed: (node, selector, fn) -> 21 | node.siblings().not(selector).not('.float').filter(':visible') 22 | .map(-> fn $ this).get() 23 | .reduce ((sum, num) -> sum + num), 0 24 | 25 | listen: -> 26 | id = null 27 | $(window).resize => 28 | clearTimeout id 29 | id = setTimeout (=> this.resize()), 10 30 | this.resize() 31 | -------------------------------------------------------------------------------- /vines/web/lib/coffeescripts/login.coffee: -------------------------------------------------------------------------------- 1 | class @LoginPage 2 | constructor: (@session, @startPage) -> 3 | 4 | start: -> 5 | $('#error').hide() 6 | 7 | [jid, password] = ($(id).val().trim() for id in ['#jid', '#password']) 8 | if jid.length == 0 || password.length == 0 || jid.indexOf('@') == -1 9 | $('#error').show() 10 | return 11 | 12 | @session.connect jid, password, (success) => 13 | unless success 14 | @session.disconnect() 15 | $('#error').show() 16 | $('#password').val('').focus() 17 | return 18 | 19 | localStorage['jid'] = jid 20 | $('#current-user-name').text @session.bareJid() 21 | $('#current-user-avatar').attr 'src', @session.avatar @session.jid() 22 | $('#current-user-avatar').attr 'alt', @session.bareJid() 23 | $('#container').fadeOut 200, => 24 | $('#navbar').show() 25 | window.location.hash = @startPage 26 | 27 | draw: -> 28 | @session.disconnect() 29 | jid = localStorage['jid'] || '' 30 | $('#navbar').hide() 31 | $('body').attr 'id', 'login-page' 32 | $('#container').hide().empty() 33 | $(""" 34 |
        35 |
        36 |

        vines

        37 |
        38 | 39 | 40 | 41 |
        42 | 43 |
        44 | """).appendTo '#container' 45 | $('#container').fadeIn 1000 46 | $('#login-form').submit => this.start(); false 47 | $('#jid').keydown -> $('#error').fadeOut() 48 | $('#password').keydown -> $('#error').fadeOut() 49 | this.resize() 50 | this.icon() 51 | 52 | icon: -> 53 | opts = 54 | fill: '90-#ccc-#fff' 55 | stroke: '#fff' 56 | 'stroke-width': 1.1 57 | opacity: 0.95 58 | scale: 3.0 59 | translation: '10,8' 60 | animate: false 61 | new Button('#icon', ICONS.chat, opts) 62 | 63 | resize: -> 64 | win = $ window 65 | form = $ '#login-form' 66 | sizer = -> form.css 'top', win.height() / 2 - form.height() / 2 67 | win.resize sizer 68 | sizer() 69 | -------------------------------------------------------------------------------- /vines/web/lib/coffeescripts/logout.coffee: -------------------------------------------------------------------------------- 1 | class @LogoutPage 2 | constructor: (@session) -> 3 | draw: -> 4 | window.location.hash = '' 5 | window.location.reload() 6 | -------------------------------------------------------------------------------- /vines/web/lib/coffeescripts/navbar.coffee: -------------------------------------------------------------------------------- 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 | 39 | """).appendTo 'body' 40 | $('
        ').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 |
      • 51 | 52 | #{label} 53 | 54 |
      • 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 85 | -------------------------------------------------------------------------------- /vines/web/lib/coffeescripts/notification.coffee: -------------------------------------------------------------------------------- 1 | class @Notification 2 | constructor: (@text) -> 3 | this.draw() 4 | 5 | draw: -> 6 | node = $('').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 15 | -------------------------------------------------------------------------------- /vines/web/lib/coffeescripts/router.coffee: -------------------------------------------------------------------------------- 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' 41 | -------------------------------------------------------------------------------- /vines/web/lib/coffeescripts/session.coffee: -------------------------------------------------------------------------------- 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 '' 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 | 80 | 81 | 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 | 101 | 102 | 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 | 112 | 113 | 114 | """ 115 | $('body', node).text message 116 | @xmpp.send node 117 | 118 | sendPresence: (away, status) -> 119 | node = $ this.xml '' 120 | if away 121 | node.append $(this.xml 'xa') 122 | node.append $(this.xml '').text status if status != 'Away' 123 | else 124 | node.append $(this.xml '').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 | 133 | 134 | 135 | 136 | 137 | """ 138 | $('item', node).attr 'name', contact.name 139 | for group in contact.groups 140 | $('item', node).append $(this.xml '').text group 141 | @xmpp.send node 142 | this.sendSubscribe(contact.jid) if add 143 | 144 | removeContact: (jid) -> 145 | node = this.xml """ 146 | 147 | 148 | 149 | 150 | 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 | 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 230 | -------------------------------------------------------------------------------- /vines/web/lib/coffeescripts/transfer.coffee: -------------------------------------------------------------------------------- 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 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 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 | 40 | 41 | 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 | 57 | #{chunk} 58 | 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 | 73 | 74 | 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 107 | -------------------------------------------------------------------------------- /vines/web/lib/images/dark-gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kerryb/goos-ruby/d76d17e3d6d53da72bf1cc344c1e9dccf84d6b8b/vines/web/lib/images/dark-gray.png -------------------------------------------------------------------------------- /vines/web/lib/images/default-user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kerryb/goos-ruby/d76d17e3d6d53da72bf1cc344c1e9dccf84d6b8b/vines/web/lib/images/default-user.png -------------------------------------------------------------------------------- /vines/web/lib/images/light-gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kerryb/goos-ruby/d76d17e3d6d53da72bf1cc344c1e9dccf84d6b8b/vines/web/lib/images/light-gray.png -------------------------------------------------------------------------------- /vines/web/lib/images/logo-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kerryb/goos-ruby/d76d17e3d6d53da72bf1cc344c1e9dccf84d6b8b/vines/web/lib/images/logo-large.png -------------------------------------------------------------------------------- /vines/web/lib/images/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kerryb/goos-ruby/d76d17e3d6d53da72bf1cc344c1e9dccf84d6b8b/vines/web/lib/images/logo-small.png -------------------------------------------------------------------------------- /vines/web/lib/images/white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kerryb/goos-ruby/d76d17e3d6d53da72bf1cc344c1e9dccf84d6b8b/vines/web/lib/images/white.png -------------------------------------------------------------------------------- /vines/web/lib/javascripts/jquery.cookie.js: -------------------------------------------------------------------------------- 1 | /*jslint browser: true */ /*global jQuery: true */ 2 | 3 | /** 4 | * jQuery Cookie plugin 5 | * 6 | * Copyright (c) 2010 Klaus Hartl (stilbuero.de) 7 | * Dual licensed under the MIT and GPL licenses: 8 | * http://www.opensource.org/licenses/mit-license.php 9 | * http://www.gnu.org/licenses/gpl.html 10 | * 11 | */ 12 | 13 | // TODO JsDoc 14 | 15 | /** 16 | * Create a cookie with the given key and value and other optional parameters. 17 | * 18 | * @example $.cookie('the_cookie', 'the_value'); 19 | * @desc Set the value of a cookie. 20 | * @example $.cookie('the_cookie', 'the_value', { expires: 7, path: '/', domain: 'jquery.com', secure: true }); 21 | * @desc Create a cookie with all available options. 22 | * @example $.cookie('the_cookie', 'the_value'); 23 | * @desc Create a session cookie. 24 | * @example $.cookie('the_cookie', null); 25 | * @desc Delete a cookie by passing null as value. Keep in mind that you have to use the same path and domain 26 | * used when the cookie was set. 27 | * 28 | * @param String key The key of the cookie. 29 | * @param String value The value of the cookie. 30 | * @param Object options An object literal containing key/value pairs to provide optional cookie attributes. 31 | * @option Number|Date expires Either an integer specifying the expiration date from now on in days or a Date object. 32 | * If a negative value is specified (e.g. a date in the past), the cookie will be deleted. 33 | * If set to null or omitted, the cookie will be a session cookie and will not be retained 34 | * when the the browser exits. 35 | * @option String path The value of the path atribute of the cookie (default: path of page that created the cookie). 36 | * @option String domain The value of the domain attribute of the cookie (default: domain of page that created the cookie). 37 | * @option Boolean secure If true, the secure attribute of the cookie will be set and the cookie transmission will 38 | * require a secure protocol (like HTTPS). 39 | * @type undefined 40 | * 41 | * @name $.cookie 42 | * @cat Plugins/Cookie 43 | * @author Klaus Hartl/klaus.hartl@stilbuero.de 44 | */ 45 | 46 | /** 47 | * Get the value of a cookie with the given key. 48 | * 49 | * @example $.cookie('the_cookie'); 50 | * @desc Get the value of a cookie. 51 | * 52 | * @param String key The key of the cookie. 53 | * @return The value of the cookie. 54 | * @type String 55 | * 56 | * @name $.cookie 57 | * @cat Plugins/Cookie 58 | * @author Klaus Hartl/klaus.hartl@stilbuero.de 59 | */ 60 | jQuery.cookie = function (key, value, options) { 61 | 62 | // key and at least value given, set cookie... 63 | if (arguments.length > 1 && String(value) !== "[object Object]") { 64 | options = jQuery.extend({}, options); 65 | 66 | if (value === null || value === undefined) { 67 | options.expires = -1; 68 | } 69 | 70 | if (typeof options.expires === 'number') { 71 | var days = options.expires, t = options.expires = new Date(); 72 | t.setDate(t.getDate() + days); 73 | } 74 | 75 | value = String(value); 76 | 77 | return (document.cookie = [ 78 | encodeURIComponent(key), '=', 79 | options.raw ? value : encodeURIComponent(value), 80 | options.expires ? '; expires=' + options.expires.toUTCString() : '', // use expires attribute, max-age is not supported by IE 81 | options.path ? '; path=' + options.path : '', 82 | options.domain ? '; domain=' + options.domain : '', 83 | options.secure ? '; secure' : '' 84 | ].join('')); 85 | } 86 | 87 | // key and possibly options given, get cookie... 88 | options = value || {}; 89 | var result, decode = options.raw ? function (s) { return s; } : decodeURIComponent; 90 | return (result = new RegExp('(?:^|; )' + encodeURIComponent(key) + '=([^;]*)').exec(document.cookie)) ? decode(result[1]) : null; 91 | }; 92 | -------------------------------------------------------------------------------- /vines/web/lib/javascripts/strophe.js: -------------------------------------------------------------------------------- 1 | var Base64=(function(){var keyStr="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";var obj={encode:function(input){var output="";var chr1,chr2,chr3;var enc1,enc2,enc3,enc4;var i=0;do{chr1=input.charCodeAt(i++);chr2=input.charCodeAt(i++);chr3=input.charCodeAt(i++);enc1=chr1>>2;enc2=((chr1&3)<<4)|(chr2>>4);enc3=((chr2&15)<<2)|(chr3>>6);enc4=chr3&63;if(isNaN(chr2)){enc3=enc4=64}else{if(isNaN(chr3)){enc4=64}}output=output+keyStr.charAt(enc1)+keyStr.charAt(enc2)+keyStr.charAt(enc3)+keyStr.charAt(enc4)}while(i>4);chr2=((enc2&15)<<4)|(enc3>>2);chr3=((enc3&3)<<6)|enc4;output=output+String.fromCharCode(chr1);if(enc3!=64){output=output+String.fromCharCode(chr2)}if(enc4!=64){output=output+String.fromCharCode(chr3)}}while(i>16)+(y>>16)+(lsw>>16);return(msw<<16)|(lsw&65535)};var bit_rol=function(num,cnt){return(num<>>(32-cnt))};var str2binl=function(str){var bin=[];var mask=(1<>5]|=(str.charCodeAt(i/chrsz)&mask)<<(i%32)}return bin};var binl2str=function(bin){var str="";var mask=(1<>5]>>>(i%32))&mask)}return str};var binl2hex=function(binarray){var hex_tab=hexcase?"0123456789ABCDEF":"0123456789abcdef";var str="";for(var i=0;i>2]>>((i%4)*8+4))&15)+hex_tab.charAt((binarray[i>>2]>>((i%4)*8))&15)}return str};var binl2b64=function(binarray){var tab="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";var str="";var triplet,j;for(var i=0;i>2]>>8*(i%4))&255)<<16)|(((binarray[i+1>>2]>>8*((i+1)%4))&255)<<8)|((binarray[i+2>>2]>>8*((i+2)%4))&255);for(j=0;j<4;j++){if(i*8+j*6>binarray.length*32){str+=b64pad}else{str+=tab.charAt((triplet>>6*(3-j))&63)}}}return str};var md5_cmn=function(q,a,b,x,s,t){return safe_add(bit_rol(safe_add(safe_add(a,q),safe_add(x,t)),s),b)};var md5_ff=function(a,b,c,d,x,s,t){return md5_cmn((b&c)|((~b)&d),a,b,x,s,t)};var md5_gg=function(a,b,c,d,x,s,t){return md5_cmn((b&d)|(c&(~d)),a,b,x,s,t)};var md5_hh=function(a,b,c,d,x,s,t){return md5_cmn(b^c^d,a,b,x,s,t)};var md5_ii=function(a,b,c,d,x,s,t){return md5_cmn(c^(b|(~d)),a,b,x,s,t)};var core_md5=function(x,len){x[len>>5]|=128<<((len)%32);x[(((len+64)>>>9)<<4)+14]=len;var a=1732584193;var b=-271733879;var c=-1732584194;var d=271733878;var olda,oldb,oldc,oldd;for(var i=0;i16){bkey=core_md5(bkey,key.length*chrsz)}var ipad=new Array(16),opad=new Array(16);for(var i=0;i<16;i++){ipad[i]=bkey[i]^909522486;opad[i]=bkey[i]^1549556828}var hash=core_md5(ipad.concat(str2binl(data)),512+data.length*chrsz);return core_md5(opad.concat(hash),512+128)};var obj={hexdigest:function(s){return binl2hex(core_md5(str2binl(s),s.length*chrsz))},b64digest:function(s){return binl2b64(core_md5(str2binl(s),s.length*chrsz))},hash:function(s){return binl2str(core_md5(str2binl(s),s.length*chrsz))},hmac_hexdigest:function(key,data){return binl2hex(core_hmac_md5(key,data))},hmac_b64digest:function(key,data){return binl2b64(core_hmac_md5(key,data))},hmac_hash:function(key,data){return binl2str(core_hmac_md5(key,data))},test:function(){return MD5.hexdigest("abc")==="900150983cd24fb0d6963f7d28e17f72"}};return obj})();if(!Function.prototype.bind){Function.prototype.bind=function(obj){var func=this;var _slice=Array.prototype.slice;var _concat=Array.prototype.concat;var _args=_slice.call(arguments,1);return function(){return func.apply(obj?obj:this,_concat.call(_args,_slice.call(arguments,0)))}}}if(!Array.prototype.indexOf){Array.prototype.indexOf=function(elt){var len=this.length;var from=Number(arguments[1])||0;from=(from<0)?Math.ceil(from):Math.floor(from);if(from<0){from+=len}for(;from/g,">");text=text.replace(/'/g,"'");text=text.replace(/"/g,""");return text},xmlTextNode:function(text){text=Strophe.xmlescape(text);return Strophe.xmlGenerator().createTextNode(text)},getText:function(elem){if(!elem){return null}var str="";if(elem.childNodes.length===0&&elem.nodeType==Strophe.ElementType.TEXT){str+=elem.nodeValue}for(var i=0;i/g,"\\3e").replace(/@/g,"\\40")},unescapeNode:function(node){return node.replace(/\\20/g," ").replace(/\\22/g,'"').replace(/\\26/g,"&").replace(/\\27/g,"'").replace(/\\2f/g,"/").replace(/\\3a/g,":").replace(/\\3c/g,"<").replace(/\\3e/g,">").replace(/\\40/g,"@").replace(/\\5c/g,"\\")},getNodeFromJid:function(jid){if(jid.indexOf("@")<0){return null}return jid.split("@")[0]},getDomainFromJid:function(jid){var bare=Strophe.getBareJidFromJid(jid);if(bare.indexOf("@")<0){return bare}else{var parts=bare.split("@");parts.splice(0,1);return parts.join("@")}},getResourceFromJid:function(jid){var s=jid.split("/");if(s.length<2){return null}s.splice(0,1);return s.join("/")},getBareJidFromJid:function(jid){return jid?jid.split("/")[0]:null},log:function(level,msg){return},debug:function(msg){this.log(this.LogLevel.DEBUG,msg)},info:function(msg){this.log(this.LogLevel.INFO,msg)},warn:function(msg){this.log(this.LogLevel.WARN,msg)},error:function(msg){this.log(this.LogLevel.ERROR,msg)},fatal:function(msg){this.log(this.LogLevel.FATAL,msg)},serialize:function(elem){var result;if(!elem){return null}if(typeof(elem.tree)==="function"){elem=elem.tree()}var nodeName=elem.nodeName;var i,child;if(elem.getAttribute("_realname")){nodeName=elem.getAttribute("_realname")}result="<"+nodeName;for(i=0;i0){result+=">";for(i=0;i"}}result+=""}else{result+="/>"}return result},_requestId:0,_connectionPlugins:{},addConnectionPlugin:function(name,ptype){Strophe._connectionPlugins[name]=ptype}};Strophe.Builder=function(name,attrs){if(name=="presence"||name=="message"||name=="iq"){if(attrs&&!attrs.xmlns){attrs.xmlns=Strophe.NS.CLIENT}else{if(!attrs){attrs={xmlns:Strophe.NS.CLIENT}}}}this.nodeTree=Strophe.xmlElement(name,attrs);this.node=this.nodeTree};Strophe.Builder.prototype={tree:function(){return this.nodeTree},toString:function(){return Strophe.serialize(this.nodeTree)},up:function(){this.node=this.node.parentNode;return this},attrs:function(moreattrs){for(var k in moreattrs){if(moreattrs.hasOwnProperty(k)){this.node.setAttribute(k,moreattrs[k])}}return this},c:function(name,attrs,text){var child=Strophe.xmlElement(name,attrs,text);this.node.appendChild(child);if(!text){this.node=child}return this},cnode:function(elem){var xmlGen=Strophe.xmlGenerator();try{var impNode=(xmlGen.importNode!==undefined)}catch(e){var impNode=false}var newElem=impNode?xmlGen.importNode(elem,true):Strophe.copyElement(elem);this.node.appendChild(newElem);this.node=newElem;return this},t:function(text){var child=Strophe.xmlTextNode(text);this.node.appendChild(child);return this}};Strophe.Handler=function(handler,ns,name,type,id,from,options){this.handler=handler;this.ns=ns;this.name=name;this.type=type;this.id=id;this.options=options||{matchbare:false};if(!this.options.matchBare){this.options.matchBare=false}if(this.options.matchBare){this.from=from?Strophe.getBareJidFromJid(from):null}else{this.from=from}this.user=true};Strophe.Handler.prototype={isMatch:function(elem){var nsMatch;var from=null;if(this.options.matchBare){from=Strophe.getBareJidFromJid(elem.getAttribute("from"))}else{from=elem.getAttribute("from")}nsMatch=false;if(!this.ns){nsMatch=true}else{var that=this;Strophe.forEachChild(elem,null,function(elem){if(elem.getAttribute("xmlns")==that.ns){nsMatch=true}});nsMatch=nsMatch||elem.getAttribute("xmlns")==this.ns}if(nsMatch&&(!this.name||Strophe.isTagEqual(elem,this.name))&&(!this.type||elem.getAttribute("type")==this.type)&&(!this.id||elem.getAttribute("id")==this.id)&&(!this.from||from==this.from)){return true}return false},run:function(elem){var result=null;try{result=this.handler(elem)}catch(e){if(e.sourceURL){Strophe.fatal("error: "+this.handler+" "+e.sourceURL+":"+e.line+" - "+e.name+": "+e.message)}else{if(e.fileName){if(typeof(console)!="undefined"){console.trace();console.error(this.handler," - error - ",e,e.message)}Strophe.fatal("error: "+this.handler+" "+e.fileName+":"+e.lineNumber+" - "+e.name+": "+e.message)}else{Strophe.fatal("error: "+this.handler)}}throw e}return result},toString:function(){return"{Handler: "+this.handler+"("+this.name+","+this.id+","+this.ns+")}"}};Strophe.TimedHandler=function(period,handler){this.period=period;this.handler=handler;this.lastCalled=new Date().getTime();this.user=true};Strophe.TimedHandler.prototype={run:function(){this.lastCalled=new Date().getTime();return this.handler()},reset:function(){this.lastCalled=new Date().getTime()},toString:function(){return"{TimedHandler: "+this.handler+"("+this.period+")}"}};Strophe.Request=function(elem,func,rid,sends){this.id=++Strophe._requestId;this.xmlData=elem;this.data=Strophe.serialize(elem);this.origFunc=func;this.func=func;this.rid=rid;this.date=NaN;this.sends=sends||0;this.abort=false;this.dead=null;this.age=function(){if(!this.date){return 0}var now=new Date();return(now-this.date)/1000};this.timeDead=function(){if(!this.dead){return 0}var now=new Date();return(now-this.dead)/1000};this.xhr=this._newXHR()};Strophe.Request.prototype={getResponse:function(){var node=null;if(this.xhr.responseXML&&this.xhr.responseXML.documentElement){node=this.xhr.responseXML.documentElement;if(node.tagName=="parsererror"){Strophe.error("invalid response received");Strophe.error("responseText: "+this.xhr.responseText);Strophe.error("responseXML: "+Strophe.serialize(this.xhr.responseXML));throw"parsererror"}}else{if(this.xhr.responseText){Strophe.error("invalid response received");Strophe.error("responseText: "+this.xhr.responseText);Strophe.error("responseXML: "+Strophe.serialize(this.xhr.responseXML))}}return node},_newXHR:function(){var xhr=null;if(window.XMLHttpRequest){xhr=new XMLHttpRequest();if(xhr.overrideMimeType){xhr.overrideMimeType("text/xml")}}else{if(window.ActiveXObject){xhr=new ActiveXObject("Microsoft.XMLHTTP")}}xhr.onreadystatechange=this.func.bind(null,this);return xhr}};Strophe.Connection=function(service){this.service=service;this.jid="";this.rid=Math.floor(Math.random()*4294967295);this.sid=null;this.streamId=null;this.features=null;this.do_session=false;this.do_bind=false;this.timedHandlers=[];this.handlers=[];this.removeTimeds=[];this.removeHandlers=[];this.addTimeds=[];this.addHandlers=[];this._idleTimeout=null;this._disconnectTimeout=null;this.authenticated=false;this.disconnecting=false;this.connected=false;this.errors=0;this.paused=false;this.hold=1;this.wait=60;this.window=5;this._data=[];this._requests=[];this._uniqueId=Math.round(Math.random()*10000);this._sasl_success_handler=null;this._sasl_failure_handler=null;this._sasl_challenge_handler=null;this._idleTimeout=setTimeout(this._onIdle.bind(this),100);for(var k in Strophe._connectionPlugins){if(Strophe._connectionPlugins.hasOwnProperty(k)){var ptype=Strophe._connectionPlugins[k];var F=function(){};F.prototype=ptype;this[k]=new F();this[k].init(this)}}};Strophe.Connection.prototype={reset:function(){this.rid=Math.floor(Math.random()*4294967295);this.sid=null;this.streamId=null;this.do_session=false;this.do_bind=false;this.timedHandlers=[];this.handlers=[];this.removeTimeds=[];this.removeHandlers=[];this.addTimeds=[];this.addHandlers=[];this.authenticated=false;this.disconnecting=false;this.connected=false;this.errors=0;this._requests=[];this._uniqueId=Math.round(Math.random()*10000)},pause:function(){this.paused=true},resume:function(){this.paused=false},getUniqueId:function(suffix){if(typeof(suffix)=="string"||typeof(suffix)=="number"){return ++this._uniqueId+":"+suffix}else{return ++this._uniqueId+""}},connect:function(jid,pass,callback,wait,hold){this.jid=jid;this.pass=pass;this.connect_callback=callback;this.disconnecting=false;this.connected=false;this.authenticated=false;this.errors=0;this.wait=wait||this.wait;this.hold=hold||this.hold;this.domain=Strophe.getDomainFromJid(this.jid);var body=this._buildBody().attrs({to:this.domain,"xml:lang":"en",wait:this.wait,hold:this.hold,content:"text/xml; charset=utf-8",ver:"1.6","xmpp:version":"1.0","xmlns:xmpp":Strophe.NS.BOSH});this._changeConnectStatus(Strophe.Status.CONNECTING,null);this._requests.push(new Strophe.Request(body.tree(),this._onRequestStateChange.bind(this,this._connect_cb.bind(this)),body.tree().getAttribute("rid")));this._throttledRequestHandler()},attach:function(jid,sid,rid,callback,wait,hold,wind){this.jid=jid;this.sid=sid;this.rid=rid;this.connect_callback=callback;this.domain=Strophe.getDomainFromJid(this.jid);this.authenticated=true;this.connected=true;this.wait=wait||this.wait;this.hold=hold||this.hold;this.window=wind||this.window;this._changeConnectStatus(Strophe.Status.ATTACHED,null)},xmlInput:function(elem){return},xmlOutput:function(elem){return},rawInput:function(data){return},rawOutput:function(data){return},send:function(elem){if(elem===null){return}if(typeof(elem.sort)==="function"){for(var i=0;i=0;i--){if(req==this._requests[i]){this._requests.splice(i,1)}}req.xhr.onreadystatechange=function(){};this._throttledRequestHandler()},_restartRequest:function(i){var req=this._requests[i];if(req.dead===null){req.dead=new Date()}this._processRequest(i)},_processRequest:function(i){var req=this._requests[i];var reqStatus=-1;try{if(req.xhr.readyState==4){reqStatus=req.xhr.status}}catch(e){Strophe.error("caught an error in _requests["+i+"], reqStatus: "+reqStatus)}if(typeof(reqStatus)=="undefined"){reqStatus=-1}if(req.sends>5){this._onDisconnectTimeout();return}var time_elapsed=req.age();var primaryTimeout=(!isNaN(time_elapsed)&&time_elapsed>Math.floor(Strophe.TIMEOUT*this.wait));var secondaryTimeout=(req.dead!==null&&req.timeDead()>Math.floor(Strophe.SECONDARY_TIMEOUT*this.wait));var requestCompletedWithServerError=(req.xhr.readyState==4&&(reqStatus<1||reqStatus>=500));if(primaryTimeout||secondaryTimeout||requestCompletedWithServerError){if(secondaryTimeout){Strophe.error("Request "+this._requests[i].id+" timed out (secondary), restarting")}req.abort=true;req.xhr.abort();req.xhr.onreadystatechange=function(){};this._requests[i]=new Strophe.Request(req.xmlData,req.origFunc,req.rid,req.sends);req=this._requests[i]}if(req.xhr.readyState===0){Strophe.debug("request id "+req.id+"."+req.sends+" posting");try{req.xhr.open("POST",this.service,true)}catch(e2){Strophe.error("XHR open failed.");if(!this.connected){this._changeConnectStatus(Strophe.Status.CONNFAIL,"bad-service")}this.disconnect();return}var sendFunc=function(){req.date=new Date();req.xhr.send(req.data)};if(req.sends>1){var backoff=Math.min(Math.floor(Strophe.TIMEOUT*this.wait),Math.pow(req.sends,3))*1000;setTimeout(sendFunc,backoff)}else{sendFunc()}req.sends++;if(this.xmlOutput!==Strophe.Connection.prototype.xmlOutput){this.xmlOutput(req.xmlData)}if(this.rawOutput!==Strophe.Connection.prototype.rawOutput){this.rawOutput(req.data)}}else{Strophe.debug("_processRequest: "+(i===0?"first":"second")+" request has readyState of "+req.xhr.readyState)}},_throttledRequestHandler:function(){if(!this._requests){Strophe.debug("_throttledRequestHandler called with undefined requests")}else{Strophe.debug("_throttledRequestHandler called with "+this._requests.length+" requests")}if(!this._requests||this._requests.length===0){return}if(this._requests.length>0){this._processRequest(0)}if(this._requests.length>1&&Math.abs(this._requests[0].rid-this._requests[1].rid)=400){this._hitError(reqStatus);return}}var reqIs0=(this._requests[0]==req);var reqIs1=(this._requests[1]==req);if((reqStatus>0&&reqStatus<500)||req.sends>5){this._removeRequest(req);Strophe.debug("request id "+req.id+" should now be removed")}if(reqStatus==200){if(reqIs1||(reqIs0&&this._requests.length>0&&this._requests[0].age()>Math.floor(Strophe.SECONDARY_TIMEOUT*this.wait))){this._restartRequest(0)}Strophe.debug("request id "+req.id+"."+req.sends+" got 200");func(req);this.errors=0}else{Strophe.error("request id "+req.id+"."+req.sends+" error "+reqStatus+" happened");if(reqStatus===0||(reqStatus>=400&&reqStatus<600)||reqStatus>=12000){this._hitError(reqStatus);if(reqStatus>=400&&reqStatus<500){this._changeConnectStatus(Strophe.Status.DISCONNECTING,null);this._doDisconnect()}}}if(!((reqStatus>0&&reqStatus<500)||req.sends>5)){this._throttledRequestHandler()}}},_hitError:function(reqStatus){this.errors++;Strophe.warn("request errored, status: "+reqStatus+", number of errors: "+this.errors);if(this.errors>4){this._onDisconnectTimeout()}},_doDisconnect:function(){Strophe.info("_doDisconnect was called");this.authenticated=false;this.disconnecting=false;this.sid=null;this.streamId=null;this.rid=Math.floor(Math.random()*4294967295);if(this.connected){this._changeConnectStatus(Strophe.Status.DISCONNECTED,null);this.connected=false}this.handlers=[];this.timedHandlers=[];this.removeTimeds=[];this.removeHandlers=[];this.addTimeds=[];this.addHandlers=[]},_dataRecv:function(req){try{var elem=req.getResponse()}catch(e){if(e!="parsererror"){throw e}this.disconnect("strophe-parsererror")}if(elem===null){return}if(this.xmlInput!==Strophe.Connection.prototype.xmlInput){this.xmlInput(elem)}if(this.rawInput!==Strophe.Connection.prototype.rawInput){this.rawInput(Strophe.serialize(elem))}var i,hand;while(this.removeHandlers.length>0){hand=this.removeHandlers.pop();i=this.handlers.indexOf(hand);if(i>=0){this.handlers.splice(i,1)}}while(this.addHandlers.length>0){this.handlers.push(this.addHandlers.pop())}if(this.disconnecting&&this._requests.length===0){this.deleteTimedHandler(this._disconnectTimeout);this._disconnectTimeout=null;this._doDisconnect();return}var typ=elem.getAttribute("type");var cond,conflict;if(typ!==null&&typ=="terminate"){if(this.disconnecting){return}cond=elem.getAttribute("condition");conflict=elem.getElementsByTagName("conflict");if(cond!==null){if(cond=="remote-stream-error"&&conflict.length>0){cond="conflict"}this._changeConnectStatus(Strophe.Status.CONNFAIL,cond)}else{this._changeConnectStatus(Strophe.Status.CONNFAIL,"unknown")}this.disconnect();return}var that=this;Strophe.forEachChild(elem,null,function(child){var i,newList;newList=that.handlers;that.handlers=[];for(i=0;i0){cond="conflict"}this._changeConnectStatus(Strophe.Status.CONNFAIL,cond)}else{this._changeConnectStatus(Strophe.Status.CONNFAIL,"unknown")}return}if(!this.sid){this.sid=bodyWrap.getAttribute("sid")}if(!this.stream_id){this.stream_id=bodyWrap.getAttribute("authid")}var wind=bodyWrap.getAttribute("requests");if(wind){this.window=parseInt(wind,10)}var hold=bodyWrap.getAttribute("hold");if(hold){this.hold=parseInt(hold,10)}var wait=bodyWrap.getAttribute("wait");if(wait){this.wait=parseInt(wait,10)}var do_sasl_plain=false;var do_sasl_digest_md5=false;var do_sasl_anonymous=false;var mechanisms=bodyWrap.getElementsByTagName("mechanism");var i,mech,auth_str,hashed_auth_str;if(mechanisms.length>0){for(i=0;i0){jidNode=bind[0].getElementsByTagName("jid");if(jidNode.length>0){this.jid=Strophe.getText(jidNode[0]);if(this.do_session){this._addSysHandler(this._sasl_session_cb.bind(this),null,null,null,"_session_auth_2");this.send($iq({type:"set",id:"_session_auth_2"}).c("session",{xmlns:Strophe.NS.SESSION}).tree())}else{this.authenticated=true;this._changeConnectStatus(Strophe.Status.CONNECTED,null)}}}else{Strophe.info("SASL binding failed.");this._changeConnectStatus(Strophe.Status.AUTHFAIL,null);return false}},_sasl_session_cb:function(elem){if(elem.getAttribute("type")=="result"){this.authenticated=true;this._changeConnectStatus(Strophe.Status.CONNECTED,null)}else{if(elem.getAttribute("type")=="error"){Strophe.info("Session creation failed.");this._changeConnectStatus(Strophe.Status.AUTHFAIL,null);return false}}return false},_sasl_failure_cb:function(elem){if(this._sasl_success_handler){this.deleteHandler(this._sasl_success_handler);this._sasl_success_handler=null}if(this._sasl_challenge_handler){this.deleteHandler(this._sasl_challenge_handler);this._sasl_challenge_handler=null}this._changeConnectStatus(Strophe.Status.AUTHFAIL,null);return false},_auth2_cb:function(elem){if(elem.getAttribute("type")=="result"){this.authenticated=true;this._changeConnectStatus(Strophe.Status.CONNECTED,null)}else{if(elem.getAttribute("type")=="error"){this._changeConnectStatus(Strophe.Status.AUTHFAIL,null);this.disconnect()}}return false},_addSysTimedHandler:function(period,handler){var thand=new Strophe.TimedHandler(period,handler);thand.user=false;this.addTimeds.push(thand);return thand},_addSysHandler:function(handler,ns,name,type,id){var hand=new Strophe.Handler(handler,ns,name,type,id);hand.user=false;this.addHandlers.push(hand);return hand},_onDisconnectTimeout:function(){Strophe.info("_onDisconnectTimeout was called");var req;while(this._requests.length>0){req=this._requests.pop();req.abort=true;req.xhr.abort();req.xhr.onreadystatechange=function(){}}this._doDisconnect();return false},_onIdle:function(){var i,thand,since,newList;while(this.addTimeds.length>0){this.timedHandlers.push(this.addTimeds.pop())}while(this.removeTimeds.length>0){thand=this.removeTimeds.pop();i=this.timedHandlers.indexOf(thand);if(i>=0){this.timedHandlers.splice(i,1)}}var now=new Date().getTime();newList=[];for(i=0;i0&&!this.paused){body=this._buildBody();for(i=0;i0){time_elapsed=this._requests[0].age();if(this._requests[0].dead!==null){if(this._requests[0].timeDead()>Math.floor(Strophe.SECONDARY_TIMEOUT*this.wait)){this._throttledRequestHandler()}}if(time_elapsed>Math.floor(Strophe.TIMEOUT*this.wait)){Strophe.warn("Request "+this._requests[0].id+" timed out, over "+Math.floor(Strophe.TIMEOUT*this.wait)+" seconds since last activity");this._throttledRequestHandler()}}clearTimeout(this._idleTimeout);if(this.connected){this._idleTimeout=setTimeout(this._onIdle.bind(this),100)}}};if(callback){callback(Strophe,$build,$msg,$iq,$pres)}})(function(){window.Strophe=arguments[0];window.$build=arguments[1];window.$msg=arguments[2];window.$iq=arguments[3];window.$pres=arguments[4]}); -------------------------------------------------------------------------------- /vines/web/lib/stylesheets/base.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | html, body { 6 | height: 100%; 7 | overflow: hidden; 8 | } 9 | body { 10 | color: #222; 11 | font-family: "Helvetica Neue", Helvetica, sans-serif; 12 | font-size: 10pt; 13 | line-height: 20pt; 14 | } 15 | input[type="text"], 16 | input[type="email"], 17 | input[type="password"], 18 | textarea { 19 | border-radius: 2px; 20 | border: none; 21 | box-shadow: inset 1px 1px 2px rgba(0, 0, 0, 0.2); 22 | font-family: "Helvetica Neue", Helvetica, sans-serif; 23 | font-size: 10pt; 24 | outline: none; 25 | padding: 6px; 26 | } 27 | /* ipad */ 28 | @media only screen and (min-device-width: 768px) and (max-device-width: 1024px) { 29 | input[type="text"], 30 | input[type="email"], 31 | input[type="password"], 32 | textarea { 33 | border: 1px solid #eee; 34 | } 35 | } 36 | input[type="text"]:focus, 37 | input[type="email"]:focus, 38 | input[type="password"]:focus, 39 | textarea:focus { 40 | box-shadow: inset 1px 1px 2px rgba(0, 0, 0, 0.6); 41 | } 42 | input[type="button"], 43 | input[type="submit"] { 44 | background: -moz-linear-gradient(#ececec, #dcdcdc); 45 | background: -ms-linear-gradient(#ececec, #dcdcdc); 46 | background: -o-linear-gradient(#ececec, #dcdcdc); 47 | background: -webkit-linear-gradient(#ececec, #dcdcdc); 48 | border: 1px solid #ccc; 49 | border-radius: 3px; 50 | box-shadow: 0 1px 1px #fff, inset 0 1px 1px rgba(255, 255, 255, 0.5); 51 | color: #222; 52 | cursor: pointer; 53 | font-family: "Helvetica Neue", Helvetica, sans-serif; 54 | font-size: 13px; 55 | font-weight: bold; 56 | height: 27px; 57 | line-height: 1; 58 | padding: 0 20px; 59 | margin-bottom: 10px; 60 | margin-left: 7px; 61 | text-shadow: 0 1px 1px #fff; 62 | } 63 | input[type="submit"] { 64 | background: #8dd2f7; 65 | background: -moz-linear-gradient(#8dd2f7, #58b8f4); 66 | background: -ms-linear-gradient(#8dd2f7, #58b8f4); 67 | background: -o-linear-gradient(#8dd2f7, #58b8f4); 68 | background: -webkit-linear-gradient(#8dd2f7, #58b8f4); 69 | border: 1px solid #448ccd; 70 | border-top: 1px solid #5da8db; 71 | color: #0d4078; 72 | text-shadow: 0 1px 1px rgba(255, 255, 255, 0.4); 73 | } 74 | input[type="button"]:active, 75 | input[type="submit"]:active { 76 | box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.8); 77 | text-shadow: 0 1px 1px rgba(255, 255, 255, 0.8); 78 | } 79 | input[type="button"].cancel, 80 | input[type="submit"].cancel { 81 | background: #ff3137; 82 | background: -moz-linear-gradient(#ff3137, #dd2828); 83 | background: -ms-linear-gradient(#ff3137, #dd2828); 84 | background: -o-linear-gradient(#ff3137, #dd2828); 85 | background: -webkit-linear-gradient(#ff3137, #dd2828); 86 | border: 1px solid #981a1a; 87 | border-top: 1px solid #ac1f23; 88 | color: #4a0f11; 89 | } 90 | fieldset { 91 | border: none; 92 | } 93 | optgroup { 94 | font-style: normal; 95 | } 96 | optgroup option { 97 | padding-left: 20px; 98 | } 99 | #container { 100 | position: relative; 101 | } 102 | #navbar { 103 | background: #0c1a2d; 104 | background: -moz-linear-gradient(#1e4171, #0c1a2d); 105 | background: -ms-linear-gradient(#1e4171, #0c1a2d); 106 | background: -o-linear-gradient(#1e4171, #0c1a2d); 107 | background: -webkit-linear-gradient(#1e4171, #0c1a2d); 108 | border-bottom: 1px solid rgba(255, 255, 255, 0.3); 109 | box-shadow: 0 0 3px 1px #222; 110 | color: #bdd5ff; 111 | height: 60px; 112 | position: relative; 113 | text-shadow: 0 1px 1px #000, 0 0 1px #fff; 114 | z-index: 10; 115 | } 116 | #navbar #logo { 117 | background: url(../images/logo-small.png) no-repeat; 118 | color: transparent; 119 | height: 40px; 120 | opacity: 0.2; 121 | position: absolute; 122 | top: 10px; 123 | right: 10px; 124 | width: 125px; 125 | text-shadow: none; 126 | } 127 | #navbar #current-user { 128 | height: 50px; 129 | width: 240px; 130 | position: relative; 131 | top: 5px; 132 | left: 10px; 133 | } 134 | #navbar #current-user-avatar { 135 | border: 1px solid rgba(255, 255, 255, 0.1); 136 | box-shadow: 0 0 1px 0px #bdd5ff; 137 | height: 48px; 138 | width: 48px; 139 | } 140 | #navbar #current-user-info { 141 | position: relative; 142 | top: -60px; 143 | left: 58px; 144 | } 145 | #navbar #current-user-name { 146 | font-size: 10pt; 147 | font-weight: normal; 148 | line-height: 1.7; 149 | overflow: hidden; 150 | text-overflow: ellipsis; 151 | width: 180px; 152 | } 153 | #navbar #current-user-presence-form .select { 154 | background: rgba(255, 255, 255, 0.2); 155 | border: 1px solid rgba(255, 255, 255, 0.1); 156 | border-radius: 30px; 157 | color: rgba(255, 255, 255, 0.8); 158 | line-height: 16px; 159 | } 160 | #navbar #current-user-presence { 161 | border: 1px solid; 162 | } 163 | #navbar #app-nav { 164 | height: 100%; 165 | overflow: hidden; 166 | position: absolute; 167 | left: 260px; 168 | top: 0; 169 | width: 600px; 170 | } 171 | #navbar #nav-links { 172 | height: 100%; 173 | } 174 | #navbar #nav-links li { 175 | border-left: 1px solid transparent; 176 | border-right: 1px solid transparent; 177 | color: rgba(255, 255, 255, 0.8); 178 | display: inline-block; 179 | font-family: "Trebuchet MS", "Helvetica Neue", Helvetica, sans-serif; 180 | font-weight: bold; 181 | font-size: 11px; 182 | height: 100%; 183 | min-width: 100px; 184 | line-height: 1; 185 | list-style: none; 186 | text-align: center; 187 | text-transform: uppercase; 188 | } 189 | #navbar #nav-links li:hover, 190 | #navbar #nav-links li.selected { 191 | background: rgba(255, 255, 255, 0.1); 192 | border-left: 1px solid rgba(255, 255, 255, 0.15); 193 | border-right: 1px solid rgba(255, 255, 255, 0.15); 194 | } 195 | #navbar #nav-links li a { 196 | color: inherit; 197 | display: inline-block; 198 | height: 100%; 199 | line-height: 60px; 200 | position: relative; 201 | text-decoration: none; 202 | width: 100%; 203 | } 204 | #navbar #nav-links li a svg { 205 | height: 32px; 206 | width: 32px; 207 | position: absolute !important; 208 | left: 33px; 209 | top: 4px; 210 | } 211 | #navbar #nav-links li a span { 212 | position: relative; 213 | top: 15px; 214 | } 215 | .select { 216 | background: #f0f0f0; 217 | border-radius: 3px; 218 | display: inline-block; 219 | font-size: 11px; 220 | line-height: 18px; 221 | padding: 0 5px; 222 | position: relative; 223 | } 224 | .select span::after { 225 | margin-left: 3px; 226 | content: '\25be'; 227 | } 228 | .select select { 229 | cursor: pointer; 230 | opacity: 0; 231 | position: absolute; 232 | top: 0; 233 | bottom: 0; 234 | left: 0; 235 | right: 0; 236 | } 237 | .filter-button { 238 | cursor: pointer; 239 | display: inline-block; 240 | line-height: 1; 241 | position: absolute; 242 | right: 10px; 243 | top: 6px; 244 | } 245 | .filter-button svg { 246 | height: 16px; 247 | width: 16px; 248 | } 249 | .filter-form { 250 | border-bottom: 1px solid #ddd; 251 | padding: 5px 10px; 252 | } 253 | .filter-text { 254 | width: 100%; 255 | } 256 | .scroll { 257 | overflow-y: auto; 258 | } 259 | .scroll::-webkit-scrollbar { 260 | width: 6px; 261 | } 262 | .scroll::-webkit-scrollbar-thumb { 263 | border-radius: 10px; 264 | } 265 | .scroll::-webkit-scrollbar-thumb:vertical { 266 | background: rgba(0, 0, 0, .2); 267 | } 268 | ul.selectable li.selected { 269 | background: #319be7; 270 | background: -moz-linear-gradient(#319be7, #1b78d9); 271 | background: -ms-linear-gradient(#319be7, #1b78d9); 272 | background: -o-linear-gradient(#319be7, #1b78d9); 273 | background: -webkit-linear-gradient(#319be7, #1b78d9); 274 | border-top: 1px solid #148ddf; 275 | border-bottom: 1px solid #095bba; 276 | color: #fff; 277 | text-shadow: -1px 1px 1px hsl(210, 51%, 45%), 0px -1px 1px hsl(210, 51%, 49%); 278 | } 279 | ul.selectable li.selected.secondary { 280 | background: hsl(220, 29%, 80%); 281 | background: -moz-linear-gradient(hsl(220, 29%, 80%), hsl(217, 25%, 69%)); 282 | background: -ms-linear-gradient(hsl(220, 29%, 80%), hsl(217, 25%, 69%)); 283 | background: -o-linear-gradient(hsl(220, 29%, 80%), hsl(217, 25%, 69%)); 284 | background: -webkit-linear-gradient(hsl(220, 29%, 80%), hsl(217, 25%, 69%)); 285 | border-top: 1px solid hsl(218, 23%, 79%); 286 | border-bottom: 1px solid hsl(218, 20%, 65%); 287 | color: #fff; 288 | text-shadow: -1px 1px 1px #94a1b8; 289 | } 290 | .column { 291 | height: 100%; 292 | position: absolute; 293 | } 294 | .column h2 { 295 | border-bottom: 1px solid #ddd; 296 | font-size: 10pt; 297 | padding-left: 10px; 298 | position: relative; 299 | text-shadow: 0 -1px 1px #fff; 300 | } 301 | .column .controls { 302 | background: rgba(255, 255, 255, 0.05); 303 | border-top: 1px solid #ddd; 304 | height: 50px; 305 | position: absolute; 306 | bottom: 0; 307 | width: 260px; 308 | } 309 | .column .controls > div { 310 | cursor: pointer; 311 | display: inline-block; 312 | height: 27px; 313 | margin: 0 10px; 314 | position: relative; 315 | top: 10px; 316 | width: 27px; 317 | } 318 | .column .controls > div > svg { 319 | height: 27px; 320 | width: 27px; 321 | } 322 | .primary { 323 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.5), 0 0 40px rgba(0, 0, 0, 0.1) inset; 324 | width: 460px; 325 | left: 260px; 326 | z-index: 1; 327 | } 328 | .sidebar { 329 | background: url(../images/light-gray.png); 330 | box-shadow: 0 0 40px rgba(0, 0, 0, 0.1) inset; 331 | width: 260px; 332 | } 333 | form.overlay { 334 | background: rgba(255, 255, 255, 0.95); 335 | border-bottom: 1px solid #ddd; 336 | border-top: 1px solid #fff; 337 | box-shadow: 0px -3px 5px rgba(0, 0, 0, 0.1); 338 | padding-top: 10px; 339 | position: absolute; 340 | bottom: 50px; 341 | left: 0; 342 | width: 260px; 343 | } 344 | form.overlay h2, 345 | form.inset h2 { 346 | border: none !important; 347 | line-height: 1; 348 | margin-bottom: 10px; 349 | } 350 | form.overlay p, 351 | form.inset p { 352 | line-height: 1.5; 353 | margin: 0 10px 10px 10px; 354 | text-shadow: 0 1px 1px #fff; 355 | } 356 | form.inset p { 357 | margin-top: -5px; 358 | } 359 | form.overlay .buttons, 360 | form.inset .buttons { 361 | padding-right: 10px; 362 | text-align: right; 363 | } 364 | form.overlay input[type="text"], 365 | form.overlay input[type="password"], 366 | form.overlay input[type="email"] { 367 | margin-bottom: 10px; 368 | width: 228px; 369 | position: relative; 370 | left: 10px; 371 | } 372 | .notification { 373 | background: rgba(12, 26, 45, 0.75); 374 | border-radius: 30px; 375 | color: #fff; 376 | font-size: 10pt; 377 | font-weight: bold; 378 | padding: 5px 100px; 379 | position: absolute; 380 | top: 50%; 381 | left: 50%; 382 | text-align: center; 383 | text-shadow: 0 1px 1px #000; 384 | z-index: 2; 385 | } 386 | -------------------------------------------------------------------------------- /vines/web/lib/stylesheets/login.css: -------------------------------------------------------------------------------- 1 | #login-page { 2 | background: -moz-radial-gradient(rgba(26, 55, 98, 0.8), rgba(12, 26, 45, 0.8)), url(../images/dark-gray.png); 3 | background: -ms-radial-gradient(center, 500px 500px, rgba(26, 55, 98, 0.8), rgba(12, 26, 45, 0.8)), url(../images/dark-gray.png); 4 | background: -o-radial-gradient(rgba(26, 55, 98, 0.8), rgba(12, 26, 45, 0.8)), url(../images/dark-gray.png); 5 | background: -webkit-radial-gradient(center, 500px 500px, rgba(26, 55, 98, 0.8), rgba(12, 26, 45, 0.8)), url(../images/dark-gray.png); 6 | } 7 | #login-page #container { 8 | height: 100%; 9 | width: 100%; 10 | text-align: center; 11 | } 12 | #login-page #login-form { 13 | margin: 0 auto; 14 | position: relative; 15 | width: 640px; 16 | } 17 | #login-page #login-form h1 { 18 | background: url(../images/logo-large.png) no-repeat; 19 | color: transparent; 20 | height: 82px; 21 | line-height: 1; 22 | margin: 0 auto 40px auto; 23 | text-shadow: none; 24 | width: 245px; 25 | } 26 | #login-page #jid, 27 | #login-page #password { 28 | margin: 0 auto; 29 | margin-bottom: 10px; 30 | width: 240px; 31 | display: block; 32 | } 33 | #login-page #icon { 34 | position: absolute; 35 | left: 90px; 36 | top: 0px; 37 | width: 100px; 38 | height: 80px; 39 | } 40 | #login-page #login-form-controls { 41 | background: rgba(0, 0, 0, 0.2); 42 | border: 1px solid rgba(0, 0, 0, 0.1); 43 | border-bottom: 1px solid rgba(255, 255, 255, 0.06); 44 | border-radius: 5px; 45 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.1) inset; 46 | margin: 0 auto; 47 | padding: 20px; 48 | } 49 | #login-page #start { 50 | width: 100px; 51 | margin-left: 0; 52 | margin-bottom: 0; 53 | } 54 | #login-page #start:active { 55 | box-shadow: inset 0 1px 7px #0c1a2d; 56 | } 57 | #login-page input[type="submit"] { 58 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.8), inset 0 1px 1px rgba(255, 255, 255, 0.5); 59 | } 60 | #login-page #error { 61 | background: rgba(255, 255, 255, 0.05); 62 | border-radius: 3px; 63 | color: #4693FF; 64 | font-size: 10pt; 65 | margin: 20px auto; 66 | text-shadow: 0 1px 1px #000; 67 | width: 250px; 68 | } 69 | --------------------------------------------------------------------------------