├── log
└── .gitkeep
├── .rspec
├── cucumber.yml
├── lib
├── defect.rb
├── item.rb
├── ui_thread_sniper_listener.rb
├── sniper_portfolio.rb
├── announcer.rb
├── xmpp
│ ├── logging_xmpp_failure_reporter.rb
│ ├── xmpp_auction_house.rb
│ ├── chat.rb
│ ├── auction_message_translator.rb
│ └── xmpp_auction.rb
├── sniper_launcher.rb
├── column.rb
├── main.rb
├── sniper_state.rb
├── sniper_snapshot.rb
├── ui
│ ├── snipers_table_model.rb
│ └── main_window.rb
└── auction_sniper.rb
├── .rvmrc
├── features
├── support
│ ├── env.rb
│ ├── blather.rb
│ ├── start_xmpp_server.rb
│ ├── runs_application.rb
│ ├── event_machine.rb
│ ├── auction_log_driver.rb
│ ├── runs_fake_auction.rb
│ └── application_runner.rb
├── step_definitions
│ ├── auction_steps.rb
│ └── sniper_steps.rb
└── end_to_end.feature
├── .gitignore
├── vines
├── web
│ ├── favicon.png
│ ├── apple-touch-icon.png
│ ├── lib
│ │ ├── images
│ │ │ ├── white.png
│ │ │ ├── dark-gray.png
│ │ │ ├── light-gray.png
│ │ │ ├── logo-large.png
│ │ │ ├── logo-small.png
│ │ │ └── default-user.png
│ │ ├── coffeescripts
│ │ │ ├── logout.coffee
│ │ │ ├── notification.coffee
│ │ │ ├── button.coffee
│ │ │ ├── layout.coffee
│ │ │ ├── contact.coffee
│ │ │ ├── router.coffee
│ │ │ ├── filter.coffee
│ │ │ ├── login.coffee
│ │ │ ├── navbar.coffee
│ │ │ ├── transfer.coffee
│ │ │ └── session.coffee
│ │ ├── stylesheets
│ │ │ ├── login.css
│ │ │ └── base.css
│ │ └── javascripts
│ │ │ ├── jquery.cookie.js
│ │ │ └── strophe.js
│ ├── chat
│ │ ├── coffeescripts
│ │ │ ├── init.coffee
│ │ │ └── chat.coffee
│ │ ├── index.html
│ │ ├── stylesheets
│ │ │ └── chat.css
│ │ └── javascripts
│ │ │ └── app.js
│ └── 404.html
├── data
│ └── user
│ │ ├── sniper@localhost
│ │ ├── auction-item-54321@localhost
│ │ └── auction-item-65432@localhost
└── conf
│ ├── certs
│ ├── localhost.crt
│ ├── localhost.key
│ └── README
│ └── config.rb
├── .simplecov
├── spec
├── spec_helper.rb
├── support
│ └── roles
│ │ ├── sniper_collector.rb
│ │ ├── portfolio_listener.rb
│ │ ├── sniper_listener.rb
│ │ ├── auction.rb
│ │ └── auction_event_listener.rb
├── item_spec.rb
├── sniper_portfolio_spec.rb
├── xmpp
│ ├── logging_xmpp_failure_reporter_spec.rb
│ ├── xmpp_auction_spec.rb
│ ├── xmpp_auction_house_spec.rb
│ ├── chat_spec.rb
│ └── auction_message_translator_spec.rb
├── ui
│ ├── main_window_spec.rb
│ └── snipers_table_model_spec.rb
├── ui_thread_sniper_listener_spec.rb
├── announcer_spec.rb
├── sniper_launcher_spec.rb
├── column_spec.rb
├── sniper_state_spec.rb
├── sniper_snapshot_spec.rb
└── auction_sniper_spec.rb
├── Gemfile
├── Guardfile
├── Rakefile
├── test_support
├── fake_auction_server.rb
└── auction_sniper_driver.rb
├── Gemfile.lock
└── README.md
/log/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --require "spec_helper"
2 | --color
3 |
--------------------------------------------------------------------------------
/cucumber.yml:
--------------------------------------------------------------------------------
1 | default: -r test_support -r features
2 |
--------------------------------------------------------------------------------
/lib/defect.rb:
--------------------------------------------------------------------------------
1 | class Defect < RuntimeError
2 | end
3 |
--------------------------------------------------------------------------------
/.rvmrc:
--------------------------------------------------------------------------------
1 | rvm use --create --install ruby-2.0.0-p247@goos
2 |
--------------------------------------------------------------------------------
/features/support/env.rb:
--------------------------------------------------------------------------------
1 | require "simplecov"
2 | require "debugger"
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | log
2 | vcard
3 | pid
4 | .bundle
5 | bin
6 | tmp
7 | coverage
8 | sniper.log
9 |
--------------------------------------------------------------------------------
/vines/web/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kerryb/goos-ruby/HEAD/vines/web/favicon.png
--------------------------------------------------------------------------------
/vines/web/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kerryb/goos-ruby/HEAD/vines/web/apple-touch-icon.png
--------------------------------------------------------------------------------
/vines/web/lib/images/white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kerryb/goos-ruby/HEAD/vines/web/lib/images/white.png
--------------------------------------------------------------------------------
/features/support/blather.rb:
--------------------------------------------------------------------------------
1 | Blather.logger = Logger.new "log/blather.log"
2 | Blather.default_log_level = :debug
3 |
--------------------------------------------------------------------------------
/.simplecov:
--------------------------------------------------------------------------------
1 | SimpleCov.start do
2 | add_filter "features"
3 | add_filter "spec"
4 | add_filter "test_support"
5 | end
6 |
--------------------------------------------------------------------------------
/vines/web/lib/images/dark-gray.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kerryb/goos-ruby/HEAD/vines/web/lib/images/dark-gray.png
--------------------------------------------------------------------------------
/vines/web/lib/images/light-gray.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kerryb/goos-ruby/HEAD/vines/web/lib/images/light-gray.png
--------------------------------------------------------------------------------
/vines/web/lib/images/logo-large.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kerryb/goos-ruby/HEAD/vines/web/lib/images/logo-large.png
--------------------------------------------------------------------------------
/vines/web/lib/images/logo-small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kerryb/goos-ruby/HEAD/vines/web/lib/images/logo-small.png
--------------------------------------------------------------------------------
/vines/web/lib/images/default-user.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kerryb/goos-ruby/HEAD/vines/web/lib/images/default-user.png
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/vines/web/lib/coffeescripts/logout.coffee:
--------------------------------------------------------------------------------
1 | class @LogoutPage
2 | constructor: (@session) ->
3 | draw: ->
4 | window.location.hash = ''
5 | window.location.reload()
6 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/vines/data/user/sniper@localhost:
--------------------------------------------------------------------------------
1 | ---
2 | name:
3 | password: !binary |-
4 | JDJhJDEwJHRSbkQvWEVyUXpUZUszM3pKN0c4TXVic2dUQ3hOWWpYR2txVk0v
5 | dE5UR3FraGtTMHdteG02
6 | roster: {}
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/vines/web/chat/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Vines
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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_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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Vines
7 |
8 |
9 |
41 |
42 |
43 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | http://jabber.org/protocol/ibb
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 \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 \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 ').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').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/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 |
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 |
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 |
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 |
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/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+=""+nodeName+">"}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]});
--------------------------------------------------------------------------------