├── .gitignore ├── MIT-LICENSE ├── README.textile ├── Rakefile ├── bin ├── jschat-client ├── jschat-server └── jschat-web ├── jschat.gemspec ├── lib └── jschat │ ├── client.rb │ ├── errors.rb │ ├── flood_protection.rb │ ├── http │ ├── config.ru │ ├── helpers │ │ └── url_for.rb │ ├── jschat.rb │ ├── public │ │ ├── favicon.ico │ │ ├── iOS-114.png │ │ ├── iOS-57.png │ │ ├── iOS-72.png │ │ ├── images │ │ │ ├── emoticons │ │ │ │ ├── angry.gif │ │ │ │ ├── arr.gif │ │ │ │ ├── blink.gif │ │ │ │ ├── blush.gif │ │ │ │ ├── brucelee.gif │ │ │ │ ├── btw.gif │ │ │ │ ├── chuckle.gif │ │ │ │ ├── clap.gif │ │ │ │ ├── cool.gif │ │ │ │ ├── drool.gif │ │ │ │ ├── drunk.gif │ │ │ │ ├── dry.gif │ │ │ │ ├── eek.gif │ │ │ │ ├── flex.gif │ │ │ │ ├── happy.gif │ │ │ │ ├── holmes.gif │ │ │ │ ├── huh.gif │ │ │ │ ├── laugh.gif │ │ │ │ ├── lol.gif │ │ │ │ ├── mad.gif │ │ │ │ ├── mellow.gif │ │ │ │ ├── noclue.gif │ │ │ │ ├── oh.gif │ │ │ │ ├── ohmy.gif │ │ │ │ ├── panic.gif │ │ │ │ ├── ph34r.gif │ │ │ │ ├── pimp.gif │ │ │ │ ├── punch.gif │ │ │ │ ├── realmad.gif │ │ │ │ ├── rock.gif │ │ │ │ ├── rofl.gif │ │ │ │ ├── rolleyes.gif │ │ │ │ ├── sad.gif │ │ │ │ ├── scratch.gif │ │ │ │ ├── shifty.gif │ │ │ │ ├── shock.gif │ │ │ │ ├── shrug.gif │ │ │ │ ├── sleep.gif │ │ │ │ ├── sleeping.gif │ │ │ │ ├── smile.gif │ │ │ │ ├── suicide.gif │ │ │ │ ├── sweat.gif │ │ │ │ ├── thumbs.gif │ │ │ │ ├── tongue.gif │ │ │ │ ├── unsure.gif │ │ │ │ ├── w00t.gif │ │ │ │ ├── wacko.gif │ │ │ │ ├── whistling.gif │ │ │ │ ├── wink.gif │ │ │ │ ├── worship.gif │ │ │ │ └── yucky.gif │ │ │ ├── jschat.gif │ │ │ └── shadow.png │ │ ├── javascripts │ │ │ ├── all.js │ │ │ ├── app │ │ │ │ ├── controllers │ │ │ │ │ ├── chat_controller.js │ │ │ │ │ └── signon_controller.js │ │ │ │ ├── helpers │ │ │ │ │ ├── emote_helper.js │ │ │ │ │ ├── form_helpers.js │ │ │ │ │ ├── link_helper.js │ │ │ │ │ ├── page_helper.js │ │ │ │ │ └── text_helper.js │ │ │ │ ├── lib │ │ │ │ │ └── split.js │ │ │ │ ├── models │ │ │ │ │ ├── cookie.js │ │ │ │ │ └── user.js │ │ │ │ ├── protocol │ │ │ │ │ ├── change.js │ │ │ │ │ ├── chat_request.js │ │ │ │ │ └── display.js │ │ │ │ └── ui │ │ │ │ │ ├── commands.js │ │ │ │ │ └── tab_completion.js │ │ │ └── init.js │ │ └── stylesheets │ │ │ ├── ipad.css │ │ │ ├── iphone.css │ │ │ └── screen.css │ ├── tmp │ │ └── restart.txt │ └── views │ │ ├── form.erb │ │ ├── index.erb │ │ ├── ipad.erb │ │ ├── iphone.erb │ │ ├── iphone_message_form.erb │ │ ├── layout.erb │ │ ├── message_form.erb │ │ └── twitter.erb │ ├── init.rb │ ├── server.rb │ ├── server_options.rb │ └── storage │ ├── init.rb │ ├── mongo.rb │ └── null.rb └── test ├── server_test.rb ├── stateless_test.rb └── test_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.swo 3 | .DS_Store 4 | http/tmp 5 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Alex R. Young 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /README.textile: -------------------------------------------------------------------------------- 1 | !http://github.com/alexyoung/jschat/raw/8c11578439770962d86d6444d0c159cfb2affbcd/http/public/images/jschat.gif! 2 | 3 | JsChat is a chat system. It has an easy to learn JSON protocol, an ncurses client, a web app, and a server. You can try it right now on "jschat.org":http://jschat.org. 4 | 5 | JsChat is similar to IRC, but it's a fundamentally simpler system. 6 | 7 | The *web app* has lots of interesting features: 8 | 9 | * IRC-like commands: /names, /name new_name (/nick works too), /clear, /lastlog 10 | * It's pretty tiny; it's built with "Sinatra":http://www.sinatrarb.com and "Prototype":http://prototypejs.org 11 | * Auto-linking: pasting an image displays it inline, youtube and vimeo videos will appear as well 12 | * Last messages are displayed on join: the last 100 messages are displayed, so you don't feel lost when you join a room 13 | * Tab completion! 14 | 15 | !http://dl.getdropbox.com/u/221414/blogs/jschat.png! 16 | 17 | h3. Installation 18 | 19 | You can install with rubygems: 20 | 21 |
gem install jschat
22 | 23 | Then run jschat-server and jschat-client to try out the console client locally. 24 | 25 | To try out the web client, run jschat-web and visit "http://localhost:4567":http://localhost:4567. 26 | 27 | h3. Ruby Library Requirements 28 | 29 | These gems are required by JsChat: 30 | 31 | * eventmachine 32 | * ncurses (for the client) 33 | * json 34 | 35 | h3. Usage 36 | 37 | * Run the server with ./server.rb 38 | * Connect a client with ./client.rb 39 | 40 | The web app must be run alongside the server. The web app must be started in production mode: 41 | 42 | http/jschat.rb -e production 43 | 44 | The web app currently has no database dependencies, it's a wrapper that links cookies to JsChat server proxies. You can run it on port 80 by configuring Rack or an Apache proxy. I have Apache set up this way on "jschat.org":http://jschat.org. 45 | 46 | h3. Configuration Files 47 | 48 | These are the default locations of the configuration files. You can override them with --config=PATH: 49 | 50 | * Client: ~/.jschat/config.json 51 | * Server: /etc/jschat/config.json 52 | 53 | The web app will use the same configuration file as the server so it can find out where the server is. 54 | 55 | The file format is JSON, like this: 56 | 57 |
58 | { "port": 3001 }
59 | 
60 | 61 | h3. Server Configuration Options 62 | 63 |
64 | {
65 |   "port": integer,
66 |   "ip": "string: IP address to bind to",
67 |   "tmp_files": "string: path to tmp files (including PID file)"
68 | }
69 | 
70 | 71 | h3. Client Commands 72 | 73 | * Change name or identify: /nick name 74 | * Join a room: /join #room 75 | * Join a room (alias): /j #room 76 | 77 | h3. Protocol Design 78 | 79 | The protocol is designed to be as close to executable JSON as possible, so clients and servers are simple to implement. 80 | 81 | Look at client.rb JsChat::Protocol to see what I mean. 82 | 83 | h3. Hey, this is like Campfire! 84 | 85 | I love Campfire and I didn't intend for JsChat to compete with it. JsChat is just a fun project, it doesn't offer Campfire's business-friendly interface, file hosting, transcripts and Basecamp integration. 86 | 87 | h3. Credits 88 | 89 | JsChat was created by "Alex Young":http://alexyoung.org for "Helicoid":http://helicoid.net. A growing group of friends are helping out: 90 | 91 | * "nickmartini":http://github.com/nickmartini 92 | * "gabrielg":http://github.com/gabrielg 93 | * "Simon Starr":http://github.com/sstarr 94 | * Kevin Ford 95 | * "sekrett":http://github.com/sekrett 96 | 97 | If you'd like to contribute, send "alexyoung":http://github.com/alexyoung a message on GitHub. 98 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # This deploys by using gem and symlinking jschat to a path suitable for a web server 2 | 3 | deploy_to = 'web2' 4 | app_path = '/var/www/jschat' 5 | remote_gem_command = 'gem' 6 | restart_web_server = '/etc/init.d/apache2 restart' 7 | 8 | task :test do 9 | require 'rake/testtask' 10 | Rake::TestTask.new do |t| 11 | t.libs << 'test' 12 | t.test_files = FileList['test/*_test.rb'] 13 | t.verbose = true 14 | end 15 | end 16 | 17 | task :assets do 18 | js = '' 19 | File.unlink('lib/jschat/http/public/javascripts/all.js') 20 | 21 | Dir['lib/jschat/http/public/javascripts/**/*.js'].reverse.each do |file| 22 | js << File.read(file) 23 | end 24 | 25 | File.open('lib/jschat/http/public/javascripts/all.js', 'w+') do |file| 26 | file.write js 27 | end 28 | end 29 | 30 | task :deploy do 31 | `#{remote_gem_command} build jschat.gemspec` 32 | jschat_gem = `ls jschat-*.gem` 33 | `#{remote_gem_command} push jschat-*.gem` 34 | puts "Waiting until gem has updated on rubyforge" 35 | sleep 30 36 | `ssh #{deploy_to} sudo #{remote_gem_command} update jschat` 37 | gem_path = `ssh #{deploy_to} #{remote_gem_command} environment | grep " - INSTALLATION DIRECTORY: " | sed 's/ - INSTALLATION DIRECTORY: //'` 38 | `ssh #{deploy_to} rm #{app_path}` 39 | jschat_gem_path = jschat_gem.sub(/\.gem/, '').strip 40 | `ssh #{deploy_to} ln -s #{gem_path.strip}/#{jschat_gem_path}/lib/jschat/http/ #{app_path}` 41 | `ssh #{deploy_to} sudo #{restart_web_server}` 42 | `rm jschat-*.gem` 43 | end 44 | -------------------------------------------------------------------------------- /bin/jschat-client: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | $:.unshift(File.expand_path(File.dirname(__FILE__) + '/../lib')) 4 | 5 | require 'jschat/client' 6 | -------------------------------------------------------------------------------- /bin/jschat-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | $:.unshift(File.expand_path(File.dirname(__FILE__) + '/../lib')) 4 | 5 | require 'logger' 6 | require 'jschat/server' 7 | require 'jschat/server_options' 8 | 9 | def reload! 10 | puts 'Reloading Files' 11 | Gem.clear_paths 12 | spec = Gem.searcher.find('jschat/server') 13 | load File.join(spec.full_gem_path, 'lib', 'jschat', 'server.rb') 14 | load File.join(spec.full_gem_path, 'lib', 'jschat', 'server_options.rb') 15 | end 16 | 17 | trap 'SIGHUP', lambda { reload! } 18 | 19 | JsChat::Server.run! 20 | -------------------------------------------------------------------------------- /bin/jschat-web: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | $:.unshift(File.expand_path(File.dirname(__FILE__) + '/../lib')) 4 | 5 | require 'rubygems' 6 | require 'sinatra' 7 | require 'getoptlong' 8 | 9 | set :environment, :production 10 | 11 | def printusage(error_code) 12 | print "Usage: jschat-web [options]\n\n" 13 | print " -b, --bind=ADDRESS IP address\n" 14 | print " -p, --port=PORT Port number\n" 15 | print " -H, --help This text\n" 16 | 17 | exit(error_code) 18 | end 19 | 20 | opts = GetoptLong.new( 21 | [ "--bind", "-b", GetoptLong::REQUIRED_ARGUMENT ], 22 | [ "--port", "-p", GetoptLong::REQUIRED_ARGUMENT ], 23 | [ "--help", "-H", GetoptLong::NO_ARGUMENT ] 24 | ) 25 | 26 | begin 27 | opts.each do |opt, arg| 28 | case opt 29 | when "--bind" 30 | set :bind, arg 31 | when "--port" 32 | set :port, arg 33 | when "--help" 34 | printusage(0) 35 | end 36 | end 37 | end 38 | 39 | require 'jschat/http/jschat' 40 | Sinatra::Application.run! 41 | 42 | -------------------------------------------------------------------------------- /jschat.gemspec: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | 3 | Gem::Specification.new do |s| 4 | s.name = %q{jschat} 5 | s.version = '0.3.7' 6 | 7 | s.required_rubygems_version = Gem::Requirement.new('>= 0') if s.respond_to? :required_rubygems_version= 8 | s.authors = ['Alex R. Young'] 9 | s.date = %q{2012-04-13} 10 | s.description = %q{JsChat is a JSON-based web and console chat app.} 11 | s.email = %q{alex@alexyoung.org} 12 | s.files = FileList['{bin,http,lib,test}/**/*', 'README.textile', 'MIT-LICENSE'].to_a 13 | s.has_rdoc = false 14 | s.bindir = 'bin' 15 | s.executables = %w{jschat-server jschat-client jschat-web} 16 | s.default_executable = 'bin/jschat-server' 17 | s.homepage = %q{http://github.com/alexyoung/jschat} 18 | s.summary = %q{JsChat features a chat server, client and web app.} 19 | 20 | s.add_dependency('sinatra', '>= 0.9.4') 21 | s.add_dependency('json', '>= 1.1.9') 22 | s.add_dependency('eventmachine', '>= 0.12.8') 23 | s.add_dependency('ncurses', '>= 0.9.1') 24 | end 25 | -------------------------------------------------------------------------------- /lib/jschat/client.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'eventmachine' 3 | gem 'json', '>= 1.1.9' 4 | require 'json' 5 | require 'ncurses' 6 | require 'optparse' 7 | require 'time' 8 | 9 | options = {} 10 | default_config_file = '~/.jschat/config.json' 11 | ARGV.clone.options do |opts| 12 | script_name = File.basename($0) 13 | opts.banner = "Usage: #{$0} [options]" 14 | 15 | opts.separator "" 16 | 17 | opts.on("-c", "--config=PATH", String, "Configuration file location (#{default_config_file})") { |o| options['config'] = o } 18 | opts.on("-h", "--hostname=host", String, "JsChat server hostname") { |o| options['hostname'] = o } 19 | opts.on("-p", "--port=port", String, "JsChat server port number") { |o| options['port'] = o } 20 | opts.on("-r", "--room=#room", String, "Channel to auto-join: remember to escape the hash") { |o| options['room'] = o } 21 | opts.on("-n", "--nick=name", String, "Your name") { |o| options['nick'] = o } 22 | opts.on("--help", "-H", "This text") { puts opts; exit 0 } 23 | 24 | opts.parse! 25 | end 26 | 27 | # Command line options will overrides these 28 | def load_options(path) 29 | path = File.expand_path path 30 | if File.exists? path 31 | JSON.parse(File.read path) 32 | else 33 | {} 34 | end 35 | end 36 | 37 | options = load_options(options['config'] || default_config_file).merge options 38 | 39 | ClientConfig = { 40 | :port => options['port'] || '6789', 41 | :ip => options['hostname'] || '0.0.0.0', 42 | :name => options['nick'] || ENV['LOGNAME'], 43 | :auto_join => options['room'] || '#jschat' 44 | } 45 | 46 | module Ncurses 47 | KEY_DELETE = ?\C-h 48 | KEY_TAB = 9 49 | end 50 | 51 | module JsChat 52 | class Protocol 53 | class Response 54 | attr_reader :message, :time 55 | def initialize(message, time) 56 | @message, @time = message, time 57 | end 58 | end 59 | 60 | def initialize(connection) 61 | @connection = connection 62 | end 63 | 64 | def legal_commands 65 | %w(messages message joined quit error join names part identified part_notice quit_notice join_notice) 66 | end 67 | 68 | def legal_change_commands 69 | %w(user) 70 | end 71 | 72 | def legal?(command) 73 | legal_commands.include? command 74 | end 75 | 76 | def legal_change?(command) 77 | legal_change_commands.include? command 78 | end 79 | 80 | def identified? ; @identified ; end 81 | 82 | def change_user(json) 83 | original_name = json['name'].keys().first 84 | new_name = json['name'].values.first 85 | @connection.names.delete original_name 86 | @connection.names << new_name 87 | "* User #{original_name} is now known as #{new_name}" 88 | end 89 | 90 | def message(json) 91 | if json['room'] 92 | "[#{json['room']}] <#{json['user']}> #{json['message']}" 93 | else 94 | "PRIVATE <#{json['user']}> #{json['message']}" 95 | end 96 | end 97 | 98 | def messages(messages) 99 | results = [] 100 | messages.each do |json| 101 | results << process_message(json) 102 | end 103 | results 104 | end 105 | 106 | def get_command(json) 107 | if json.has_key? 'display' and legal? json['display'] 108 | 'display' 109 | elsif json.has_key? 'change' and legal_change? json['change'] 110 | 'change' 111 | end 112 | end 113 | 114 | def get_time(json) 115 | if json.kind_of? Hash and json.has_key? 'time' 116 | Time.parse(json['time']).getlocal 117 | else 118 | Time.now.localtime 119 | end 120 | end 121 | 122 | def protocol_method_name(command, method_name) 123 | if command == 'display' 124 | method_name 125 | elsif command == 'change' 126 | "change_#{method_name}" 127 | end 128 | end 129 | 130 | def process_message(json) 131 | command = get_command json 132 | 133 | if command 134 | time = get_time json 135 | response = send(protocol_method_name(command, json[command]), json[json[command]]) 136 | case response 137 | when Array 138 | response 139 | when String 140 | Response.new(response, time) 141 | end 142 | end 143 | end 144 | 145 | def join(json) 146 | @connection.send_names json['room'] 147 | "* User #{json['user']} joined #{json['room']}" 148 | end 149 | 150 | def join_notice(json) 151 | @connection.names << json['user'] 152 | "* User #{json['user']} joined #{json['room']}" 153 | end 154 | 155 | def part(json) 156 | "* You left #{json['room']}" 157 | end 158 | 159 | def part_notice(json) 160 | @connection.names.delete json['user'] 161 | "* #{json['user']} left #{json['room']}" 162 | end 163 | 164 | def quit(json) 165 | @connection.names.delete json['user'] 166 | "* User #{json['user']} left #{json['room']}" 167 | end 168 | 169 | def names(json) 170 | @connection.names = json.collect { |u| u['name'] } 171 | "* In this room: #{@connection.names.join(', ')}" 172 | end 173 | 174 | def identified(json) 175 | if ClientConfig[:auto_join] and !@auto_joined 176 | @connection.send_join ClientConfig[:auto_join] 177 | @auto_joined = true 178 | end 179 | 180 | @identified = true 181 | 182 | "* You are now known as #{json['name']}" 183 | end 184 | 185 | def error(json) 186 | "* [ERROR] #{json['message']}" 187 | end 188 | 189 | alias_method :quit_notice, :quit 190 | end 191 | end 192 | 193 | module JsClient 194 | class TabComplete 195 | attr_accessor :matched, :index 196 | 197 | def initialize 198 | end 199 | 200 | def run(names, field, form, cursor_x) 201 | form.form_driver Ncurses::Form::REQ_BEG_LINE 202 | text = field.field_buffer(0).dup.strip 203 | if text.size > 0 and cursor_x > 0 204 | if @matched.nil? 205 | source = text.slice(0, cursor_x) 206 | source = text.split(' ').last if text.match(' ') 207 | @index = 0 208 | else 209 | source = @matched 210 | @index += 1 211 | end 212 | names = names.sort.find_all { |name| name.match /^#{source}/i } 213 | @index = 0 if @index >= names.size 214 | name = names[@index] 215 | if name and (@matched or source.size == text.size) 216 | @matched = source 217 | field.set_field_buffer(0, "#{name}: ") 218 | form.form_driver Ncurses::Form::REQ_END_LINE 219 | form.form_driver Ncurses::Form::REQ_NEXT_CHAR 220 | else 221 | @matched = nil 222 | @index = 0 223 | (0..cursor_x - 1).each do 224 | form.form_driver Ncurses::Form::REQ_NEXT_CHAR 225 | end 226 | end 227 | end 228 | end 229 | 230 | def match(input) 231 | end 232 | 233 | def reset 234 | @matched = nil 235 | @index = 0 236 | end 237 | end 238 | 239 | def keyboard=(keyboard) 240 | @keyboard = keyboard 241 | end 242 | 243 | # This should take room into account 244 | def names=(names) 245 | @names = names 246 | end 247 | 248 | def names 249 | @names 250 | end 251 | 252 | module KeyboardInput 253 | def setup_screen 254 | Ncurses.initscr 255 | @windows = {} 256 | 257 | Ncurses.raw 258 | Ncurses.start_color 259 | Ncurses.noecho 260 | Ncurses.use_default_colors 261 | Ncurses.init_pair 2, Ncurses::COLOR_WHITE, Ncurses::COLOR_BLUE 262 | Ncurses.init_pair 3, Ncurses::COLOR_CYAN, -1 263 | Ncurses.init_pair 4, Ncurses::COLOR_YELLOW, -1 264 | Ncurses.init_pair 5, Ncurses::COLOR_BLACK, -1 265 | Ncurses.init_pair 6, Ncurses::COLOR_RED, -1 266 | 267 | @history_position = 0 268 | @history = [] 269 | @lastlog = [] 270 | @room_name = '' 271 | 272 | setup_windows 273 | end 274 | 275 | def room_name=(room_name) 276 | @room_name = room_name 277 | end 278 | 279 | def setup_windows 280 | Ncurses.refresh 281 | 282 | display_windows 283 | 284 | Thread.new do 285 | loop do 286 | display_time 287 | sleep 60 - Time.now.sec 288 | end 289 | end 290 | 291 | Signal.trap('SIGWINCH') do 292 | resize 293 | end 294 | end 295 | 296 | def remove_windows_and_forms 297 | remove_windows 298 | remove_forms 299 | end 300 | 301 | def remove_windows 302 | @windows.each do |name, window| 303 | window.delete 304 | end 305 | end 306 | 307 | def remove_forms 308 | @input_field.free_field 309 | @input_form.free_form 310 | end 311 | 312 | def update_windows 313 | rows, cols = get_window_size 314 | remove_windows_and_forms 315 | display_windows 316 | display_room_name 317 | end 318 | 319 | def display_windows 320 | rows, cols = get_window_size 321 | @windows[:text] = Ncurses.newwin(rows - 1, cols, 0, 0) 322 | @windows[:info] = Ncurses.newwin(rows - 1, cols, rows - 2, 0) 323 | @windows[:input] = Ncurses.newwin(rows, cols, rows - 1, 0) 324 | @windows[:text].scrollok(true) 325 | @windows[:info].bkgd Ncurses.COLOR_PAIR(2) 326 | @windows[:input].keypad(true) 327 | @windows[:input].nodelay(true) 328 | 329 | @windows[:text].refresh 330 | @windows[:info].refresh 331 | @windows[:input].refresh 332 | display_input 333 | end 334 | 335 | def display_input 336 | offset = @room_name.size > 0 ? @room_name.size + 3 : 0 337 | @input_field = Ncurses::Form::FIELD.new(1, Ncurses.COLS - offset, 0, offset, 0, 0) 338 | Ncurses::Form.field_opts_off(@input_field, Ncurses::Form::O_AUTOSKIP) 339 | Ncurses::Form.field_opts_off(@input_field, Ncurses::Form::O_STATIC) 340 | @input_form = Ncurses::Form::FORM.new([@input_field]) 341 | @input_form.set_form_win @windows[:input] 342 | @input_form.post_form 343 | @input_field.set_field_buffer 0, '' 344 | end 345 | 346 | def display_room_name 347 | if @room_name 348 | display_input 349 | @windows[:input].mvprintw(0, 0, "[#{@room_name}] ") 350 | @windows[:input].refresh 351 | end 352 | end 353 | 354 | def display_time 355 | @windows[:info].move 0, 0 356 | @windows[:info].addstr "[#{Time.now.strftime('%H:%M')}]\n" 357 | @windows[:info].refresh 358 | @windows[:input].refresh 359 | end 360 | 361 | def resize 362 | # Save the user input 363 | @input_form.form_driver Ncurses::Form::REQ_BEG_LINE 364 | input = @input_field.field_buffer(0) 365 | input.rstrip! 366 | 367 | Ncurses.def_prog_mode 368 | Ncurses.endwin 369 | Ncurses.reset_prog_mode 370 | 371 | update_windows 372 | 373 | cols = get_window_size[0] 374 | if @lastlog.size > 0 375 | lastlog_start = cols > @lastlog.size ? @lastlog.size : cols 376 | @lastlog[-lastlog_start..-1].each do |message| 377 | display_text message[0], message[1] 378 | end 379 | end 380 | 381 | @input_field.set_field_buffer(0, input) 382 | @windows[:input].addstr input 383 | @input_form.form_driver Ncurses::Form::REQ_END_LINE 384 | 385 | Ncurses.refresh 386 | rescue Exception => exception 387 | puts exception 388 | end 389 | 390 | # FIXME: This doesn't work after resize 391 | # I've tried other ruby ncurses programs and they don't either 392 | def get_window_size 393 | Ncurses.refresh 394 | cols, rows = [], [] 395 | Ncurses.stdscr.getmaxyx rows, cols 396 | [rows.first, cols.first] 397 | end 398 | 399 | def get_history_text(offset = 1) 400 | offset_position = @history_position + offset 401 | if offset_position >= 0 and offset_position < @history.size 402 | @history_position = offset_position 403 | @history[@history_position] 404 | else 405 | if @history_position > -1 and @history_position < @history.size 406 | @history_position = offset_position 407 | end 408 | '' 409 | end 410 | end 411 | 412 | def arrow_keys(data) 413 | c = data[0] 414 | if @sequence 415 | @sequence << c 416 | history_text = '' 417 | 418 | if data == 'A' 419 | @sequence = nil 420 | history_text = get_history_text(-1) 421 | elsif data == 'B' 422 | @sequence = nil 423 | history_text = get_history_text 424 | elsif data == 'O' 425 | return true 426 | elsif data == 'D' 427 | # left 428 | @sequence = nil 429 | @input_form.form_driver Ncurses::Form::REQ_PREV_CHAR 430 | @windows[:input].refresh 431 | return true 432 | elsif data == 'C' 433 | # right 434 | @input_form.form_driver Ncurses::Form::REQ_NEXT_CHAR 435 | @windows[:input].refresh 436 | @sequence = nil 437 | return true 438 | else 439 | @sequence = nil 440 | return true 441 | end 442 | 443 | begin 444 | @input_form.form_driver Ncurses::Form::REQ_CLR_FIELD 445 | @input_field.set_field_buffer(0, history_text) 446 | @windows[:input].addstr history_text 447 | @windows[:input].refresh 448 | @input_form.form_driver Ncurses::Form::REQ_END_LINE 449 | rescue Exception => exception 450 | end 451 | 452 | return true 453 | elsif c == 27 454 | @sequence = c 455 | return true 456 | end 457 | end 458 | 459 | def cursor_position 460 | cursor_position = Ncurses.getcurx(@windows[:input]) - 10 461 | end 462 | 463 | def receive_data(data) 464 | @clipboard ||= '' 465 | c = data[0] 466 | 467 | if arrow_keys data 468 | return 469 | end 470 | 471 | if c != Ncurses::KEY_TAB 472 | @tab_completion.reset 473 | end 474 | 475 | case c 476 | when -1 477 | # Return 478 | when Ncurses::KEY_TAB 479 | @tab_completion.run @connection.names, @input_field, @input_form, cursor_position 480 | when Ncurses::KEY_ENTER, ?\n, ?\r 481 | @input_form.form_driver Ncurses::Form::REQ_BEG_LINE 482 | line = @input_field.field_buffer(0) 483 | line.rstrip! 484 | 485 | if !line.empty? and line.length > 0 486 | @history << line.dup 487 | @history_position = @history.size 488 | manage_commands line 489 | end 490 | @input_form.form_driver Ncurses::Form::REQ_CLR_FIELD 491 | when ?\C-l 492 | # Refresh 493 | resize 494 | when Ncurses::KEY_BACKSPACE, ?\C-h, 127 495 | # Backspace 496 | @input_form.form_driver Ncurses::Form::REQ_DEL_PREV 497 | @input_form.form_driver Ncurses::Form::REQ_CLR_EOL 498 | when ?\C-d 499 | @input_form.form_driver Ncurses::Form::REQ_DEL_CHAR 500 | when Ncurses::KEY_LEFT, ?\C-b 501 | @input_form.form_driver Ncurses::Form::REQ_PREV_CHAR 502 | when Ncurses::KEY_RIGHT, ?\C-f 503 | @input_form.form_driver Ncurses::Form::REQ_NEXT_CHAR 504 | when ?\C-a 505 | @input_form.form_driver Ncurses::Form::REQ_BEG_LINE 506 | when ?\C-e 507 | @input_form.form_driver Ncurses::Form::REQ_END_LINE 508 | when ?\C-k 509 | @input_form.form_driver Ncurses::Form::REQ_CLR_EOL 510 | when ?\C-u 511 | @input_form.form_driver Ncurses::Form::REQ_BEG_LINE 512 | @clipboard = @input_field.field_buffer(0) 513 | @input_form.form_driver Ncurses::Form::REQ_CLR_FIELD 514 | when ?\C-y 515 | unless @clipboard.empty? 516 | cursor_position = Ncurses.getcurx(@windows[:input]) 517 | 518 | text = @input_field.field_buffer(0).insert(cursor_position - 9, @clipboard) 519 | @input_field.set_field_buffer(0, text) 520 | 521 | @windows[:text].addstr "#{cursor_position}\n #{text}" 522 | @windows[:text].refresh 523 | end 524 | when ?\C-c 525 | quit 526 | when ?\C-w 527 | @input_form.form_driver Ncurses::Form::REQ_PREV_CHAR 528 | @input_form.form_driver Ncurses::Form::REQ_DEL_WORD 529 | else 530 | @input_form.form_driver c 531 | end 532 | @windows[:input].refresh 533 | end 534 | 535 | def show_message(messages, time = Time.now.localtime) 536 | messages.split("\n").each do |message| 537 | @lastlog << [message.dup, time] 538 | @lastlog.shift if @lastlog.size > 250 539 | display_text message, time 540 | end 541 | end 542 | 543 | def display_text(message, time = Time.now.localtime) 544 | message = message.dup 545 | @windows[:text].addstr "#{time.strftime('%H:%M')} " 546 | 547 | if message.match /^\*/ 548 | @windows[:text].attrset(Ncurses.COLOR_PAIR(3)) 549 | end 550 | 551 | if message.match /^\* \[ERROR\]/ 552 | @windows[:text].attrset(Ncurses.COLOR_PAIR(6)) 553 | elsif message.match /^\[/ 554 | channel_info = message.split(']').first.sub /\[/, '' 555 | message.sub! "[#{channel_info}]", '' 556 | 557 | @windows[:text].attrset(Ncurses.COLOR_PAIR(5)) 558 | @windows[:text].addstr "[" 559 | @windows[:text].attrset(Ncurses.COLOR_PAIR(4)) 560 | @windows[:text].addstr "#{channel_info}" 561 | @windows[:text].attrset(Ncurses.COLOR_PAIR(5)) 562 | @windows[:text].addstr "] " 563 | 564 | name = message.split('>').first.sub(/ ", '') 566 | @windows[:text].attrset(Ncurses.COLOR_PAIR(5)) 567 | @windows[:text].addstr '<' 568 | @windows[:text].attrset(Ncurses.COLOR_PAIR(0)) 569 | @windows[:text].addstr "#{name}" 570 | @windows[:text].attrset(Ncurses.COLOR_PAIR(5)) 571 | @windows[:text].addstr '>' 572 | @windows[:text].attrset(Ncurses.COLOR_PAIR(0)) 573 | end 574 | 575 | @windows[:text].addstr "#{message}\n" 576 | @windows[:text].refresh 577 | @windows[:input].refresh 578 | 579 | display_time 580 | 581 | if message.match /^\*/ 582 | @windows[:text].attrset(Ncurses.COLOR_PAIR(0)) 583 | end 584 | end 585 | 586 | def quit 587 | Ncurses.endwin 588 | exit 589 | end 590 | 591 | def help_text 592 | <<-TEXT 593 | *** JsChat Help *** 594 | Commands start with a forward slash. Parameters in square brackets are optional. 595 | /name new_name - Change your name. Alias: /nick 596 | /names [room name] - List the people in a room. 597 | /join #room - Join a room. Alias: /j 598 | /switch #room - Speak in a different room. Alias: /s 599 | /part #room - Leave a room. Alias: /p 600 | /message person - Send a private message. Alias: /m 601 | /lastlog - Display the last 100 messages for a room. 602 | /quit - Quit JsChat 603 | *** End Help *** 604 | 605 | TEXT 606 | end 607 | 608 | def manage_commands(line) 609 | operand = strip_command line 610 | case line 611 | when %r{^/switch}, %r{^/s} 612 | @connection.switch_room operand 613 | when %r{^/names} 614 | @connection.send_names operand 615 | when %r{^/name}, %r{^/nick} 616 | @connection.send_change_name operand 617 | when %r{^/quit} 618 | quit 619 | when %r{^/join}, %r{^/j} 620 | @connection.send_join operand 621 | when %r{^/part}, %r{^/p} 622 | @connection.send_part operand 623 | when %r{^/lastlog} 624 | @connection.send_lastlog 625 | when %r{^/clear} 626 | @windows[:text].clear 627 | @windows[:text].refresh 628 | display_time 629 | when %r{^/message}, %r{^/m} 630 | if operand and operand.size > 0 631 | message = operand.match(/([^ ]*)\s+(.*)/) 632 | if message 633 | @connection.send_private_message message[1], message[2] 634 | end 635 | end 636 | when %r{^/help} 637 | help_text.split("\n").each do |message| 638 | display_text message 639 | end 640 | when %r{^/} 641 | display_text '* Command not found. Try using /help' 642 | else 643 | @connection.send_message(line) 644 | end 645 | end 646 | 647 | def strip_command(line) 648 | matches = line.match(%r{/[a-zA-Z][^ ]*(.*)}) 649 | if matches 650 | matches[1].strip 651 | else 652 | line 653 | end 654 | end 655 | 656 | def connection=(connection) 657 | @connection = connection 658 | @tab_completion = JsClient::TabComplete.new 659 | end 660 | end 661 | 662 | include EM::Protocols::LineText2 663 | 664 | def receive_line(data) 665 | data.split("\n").each do |line| 666 | json = JSON.parse(line.strip) 667 | # Execute the json 668 | protocol_response = @protocol.process_message json 669 | if protocol_response 670 | if protocol_response.kind_of? Array 671 | protocol_response.each do |response| 672 | @keyboard.show_message response.message, response.time 673 | end 674 | else 675 | @keyboard.show_message protocol_response.message, protocol_response.time 676 | end 677 | elsif json.has_key? 'notice' 678 | @keyboard.show_message "* #{json['notice']}" 679 | else 680 | @keyboard.show_message "* [SERVER] #{line}" 681 | end 682 | end 683 | rescue Exception => exception 684 | @keyboard.show_message "* [CLIENT ERROR] #{exception}" 685 | end 686 | 687 | def send_join(room) 688 | @current_room = room 689 | @keyboard.room_name = room 690 | @keyboard.display_room_name 691 | send_data({ 'join' => room }.to_json + "\n") 692 | end 693 | 694 | def switch_room(room) 695 | @current_room = room 696 | @keyboard.room_name = room 697 | @keyboard.display_room_name 698 | @keyboard.show_message "* Switched room to: #{room}" 699 | end 700 | 701 | def send_part(room = nil) 702 | room = @current_room if room.nil? 703 | send_data({ 'part' => room }.to_json + "\n") 704 | end 705 | 706 | def send_names(room = nil) 707 | room = @current_room if room.nil? or room.strip.empty? 708 | send_data({ 'names' => room }.to_json + "\n") 709 | end 710 | 711 | def send_lastlog(room = nil) 712 | room = @current_room if room.nil? or room.strip.empty? 713 | send_data({ 'lastlog' => room }.to_json + "\n") 714 | end 715 | 716 | def send_message(line) 717 | send_data({ 'to' => @current_room, 'send' => line }.to_json + "\n") 718 | end 719 | 720 | def send_private_message(user, message) 721 | send_data({ 'to' => user, 'send' => message }.to_json + "\n") 722 | end 723 | 724 | def send_identify(username) 725 | send_data({ 'identify' => username }.to_json + "\n") 726 | end 727 | 728 | def send_change_name(username) 729 | if @protocol.identified? 730 | send_data({ 'change' => 'user', 'user' => { 'name' => username } }.to_json + "\n") 731 | else 732 | send_identify username 733 | end 734 | end 735 | 736 | def unbind 737 | Ncurses.endwin 738 | Ncurses.clear 739 | puts "Disconnected from server" 740 | exit 741 | end 742 | 743 | def post_init 744 | # When connected 745 | @protocol = JsChat::Protocol.new self 746 | send_identify ClientConfig[:name] 747 | end 748 | end 749 | 750 | EM.run do 751 | puts "Connecting to: #{ClientConfig[:ip]}" 752 | connection = EM.connect ClientConfig[:ip], ClientConfig[:port], JsClient 753 | 754 | EM.open_keyboard(JsClient::KeyboardInput) do |keyboard| 755 | keyboard.connection = connection 756 | keyboard.setup_screen 757 | connection.keyboard = keyboard 758 | end 759 | end 760 | -------------------------------------------------------------------------------- /lib/jschat/errors.rb: -------------------------------------------------------------------------------- 1 | module JsChat 2 | class Error < RuntimeError 3 | def initialize(code_key, message) 4 | @message = message 5 | @code = JsChat::Errors::Codes.invert[code_key] 6 | end 7 | 8 | # Note: This shouldn't really include 'display' directives 9 | def to_json(*a) 10 | { 'display' => 'error', 'error' => { 'message' => @message, 'code' => @code } }.to_json(*a) 11 | end 12 | end 13 | 14 | module Errors 15 | class InvalidName < JsChat::Error ; end 16 | class MessageTooLong < JsChat::Error ; end 17 | class InvalidCookie < JsChat::Error ; end 18 | 19 | Codes = { 20 | # 1xx: User errors 21 | 100 => :name_taken, 22 | 101 => :invalid_name, 23 | 104 => :not_online, 24 | 105 => :identity_required, 25 | 106 => :already_identified, 26 | 107 => :invalid_cookie, 27 | # 2xx: Room errors 28 | 200 => :already_joined, 29 | 201 => :invalid_room, 30 | 202 => :not_in_room, 31 | 204 => :room_not_available, 32 | # 3xx: Message errors 33 | 300 => :to_required, 34 | 301 => :message_too_long, 35 | # 5xx: Other errors 36 | 500 => :invalid_request, 37 | 501 => :flooding, 38 | 502 => :ping_out 39 | } 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/jschat/flood_protection.rb: -------------------------------------------------------------------------------- 1 | module JsChat 2 | module Errors 3 | class Flooding < JsChat::Error ; end 4 | class StillFlooding < Exception ; end 5 | end 6 | 7 | module FloodProtection 8 | def seen! 9 | @activity_log ||= [] 10 | @activity_log << Time.now.utc 11 | @activity_log.shift if @activity_log.size > 50 12 | remove_old_activity_logs 13 | detect_flooding 14 | 15 | if flooding? 16 | if @still_flooding 17 | raise JsChat::Errors::StillFlooding 18 | else 19 | @still_flooding = true 20 | raise JsChat::Errors::Flooding.new(:flooding, 'Please wait a few seconds before responding') 21 | end 22 | elsif @still_flooding 23 | @still_flooding = false 24 | end 25 | end 26 | 27 | def detect_flooding 28 | @flooding = @activity_log.size > 10 and @activity_log.sort.inject { |i, sum| sum.to_i - i.to_i } - @activity_log.first.to_i < 0.5 29 | end 30 | 31 | def flooding? 32 | @flooding 33 | end 34 | 35 | def remove_old_activity_logs 36 | @activity_log.delete_if { |l| l + 5 < Time.now.utc } 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/jschat/http/config.ru: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'sinatra' 3 | 4 | set :environment, :production 5 | 6 | # You could log like this: 7 | # log = File.new(File.join(File.dirname(__FILE__), 'sinatra.log'), 'a') 8 | # $stdout.reopen(log) 9 | # $stderr.reopen(log) 10 | 11 | require 'jschat.rb' 12 | run Sinatra::Application 13 | -------------------------------------------------------------------------------- /lib/jschat/http/helpers/url_for.rb: -------------------------------------------------------------------------------- 1 | # From http://github.com/emk/sinatra-url-for/blob/master/lib/sinatra/url_for.rb 2 | module Sinatra 3 | module UrlForHelper 4 | # Construct a link to +url_fragment+, which should be given relative to 5 | # the base of this Sinatra app. The mode should be either 6 | # :path_only, which will generate an absolute path within 7 | # the current domain (the default), or :full, which will 8 | # include the site name and port number. (The latter is typically 9 | # necessary for links in RSS feeds.) Example usage: 10 | # 11 | # url_for "/" # Returns "/myapp/" 12 | # url_for "/foo" # Returns "/myapp/foo" 13 | # url_for "/foo", :full # Returns "http://example.com/myapp/foo" 14 | #-- 15 | # See README.rdoc for a list of some of the people who helped me clean 16 | # up earlier versions of this code. 17 | def url_for url_fragment, mode=:path_only 18 | case mode 19 | when :path_only 20 | base = request.script_name 21 | when :full 22 | scheme = request.scheme 23 | if (scheme == 'http' && request.port == 80 || 24 | scheme == 'https' && request.port == 443) 25 | port = "" 26 | else 27 | port = ":#{request.port}" 28 | end 29 | base = "#{scheme}://#{request.host}#{port}#{request.script_name}" 30 | else 31 | raise TypeError, "Unknown url_for mode #{mode}" 32 | end 33 | "#{base}#{url_fragment}" 34 | end 35 | end 36 | 37 | helpers UrlForHelper 38 | end 39 | -------------------------------------------------------------------------------- /lib/jschat/http/jschat.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'sinatra' 3 | require 'sha1' 4 | gem 'json', '>= 1.1.9' 5 | require 'json' 6 | require 'jschat/init' 7 | require 'jschat/http/helpers/url_for' 8 | 9 | set :public, File.join(File.dirname(__FILE__), 'public') 10 | set :views, File.join(File.dirname(__FILE__), 'views') 11 | set :sessions, true 12 | 13 | module JsChat::Auth 14 | end 15 | 16 | module JsChat::Auth::Twitter 17 | def self.template 18 | :twitter 19 | end 20 | 21 | def self.load 22 | require 'twitter_oauth' 23 | @loaded = true 24 | rescue LoadError 25 | puts 'Error: twitter_oauth gem not found' 26 | @loaded = false 27 | end 28 | 29 | def self.loaded? 30 | @loaded 31 | end 32 | end 33 | 34 | module JsChat 35 | class ConnectionError < Exception ; end 36 | 37 | def self.configure_authenticators 38 | if ServerConfig['twitter'] 39 | JsChat::Auth::Twitter.load 40 | end 41 | end 42 | 43 | def self.init 44 | configure_authenticators 45 | JsChat.init_storage 46 | end 47 | end 48 | 49 | JsChat.init 50 | 51 | before do 52 | if JsChat::Auth::Twitter.loaded? 53 | @twitter = TwitterOAuth::Client.new( 54 | :consumer_key => ServerConfig['twitter']['key'], 55 | :consumer_secret => ServerConfig['twitter']['secret'], 56 | :token => session[:access_token], 57 | :secret => session[:secret_token] 58 | ) 59 | 60 | if twitter_user? 61 | load_twitter_user_and_set_bridge_id 62 | 63 | unless valid_twitter_client_id? 64 | clear_cookies 65 | end 66 | end 67 | end 68 | end 69 | 70 | # todo: can this be async and allow the server to have multiple threads? 71 | class JsChat::Bridge 72 | attr_reader :cookie, :identification_error, :last_error 73 | 74 | def initialize(cookie = nil) 75 | @cookie = cookie 76 | end 77 | 78 | def cookie_set? 79 | !(@cookie.nil? or @cookie.empty?) 80 | end 81 | 82 | def connect 83 | response = send_json({ :protocol => 'stateless' }) 84 | @cookie = response['cookie'] 85 | end 86 | 87 | def identify(name, ip, session_length = nil) 88 | response = send_json({ :identify => name, :ip => ip, :session_length => session_length }) 89 | if response['display'] == 'error' 90 | @identification_error = response 91 | false 92 | else 93 | true 94 | end 95 | end 96 | 97 | def rooms 98 | send_json({ :list => 'rooms' }) 99 | end 100 | 101 | def lastlog(room) 102 | response = send_json({ :lastlog => room }) 103 | response['messages'] 104 | end 105 | 106 | def search(phrase, room) 107 | response = send_json({ :search => phrase, :room => room }) 108 | response['messages'] 109 | end 110 | 111 | def recent_messages(room) 112 | send_json({ 'since' => room })['messages'] 113 | end 114 | 115 | def room_update_times 116 | send_json({ 'times' => 'all' }) 117 | end 118 | 119 | def join(room) 120 | send_json({ :join => room }, false) 121 | end 122 | 123 | def part(room) 124 | send_json({ :part => room }) 125 | end 126 | 127 | def send_message(message, to) 128 | send_json({ :send => message, :to => to }, false) 129 | end 130 | 131 | def active? 132 | return false unless cookie_set? 133 | response = ping 134 | if response.nil? or response['display'] == 'error' 135 | @last_error = response 136 | false 137 | else 138 | true 139 | end 140 | end 141 | 142 | def ping 143 | send_json({ 'ping' => Time.now.utc }) 144 | end 145 | 146 | def change(change_type, data) 147 | send_json({ 'change' => change_type, change_type => data }) 148 | end 149 | 150 | def names(room) 151 | send_json({'names' => room}) 152 | end 153 | 154 | def send_quit(name) 155 | send_json({'quit' => name }) 156 | end 157 | 158 | def send_json(h, get_results = true) 159 | response = nil 160 | h[:cookie] = @cookie if cookie_set? 161 | c = TCPSocket.open(ServerConfig['ip'], ServerConfig['port']) 162 | c.send(h.to_json + "\n", 0) 163 | if get_results 164 | response = c.gets 165 | response = JSON.parse(response) 166 | end 167 | ensure 168 | c.close 169 | response 170 | end 171 | end 172 | 173 | helpers do 174 | include Rack::Utils 175 | alias_method :h, :escape_html 176 | 177 | def escape_json(string) 178 | string.to_s.gsub("&", "&"). 179 | gsub("<", "<"). 180 | gsub(">", ">") 181 | end 182 | 183 | def detected_layout 184 | if iphone_user_agent? 185 | :iphone 186 | elsif ipad_user_agent? 187 | :ipad 188 | else 189 | :layout 190 | end 191 | end 192 | 193 | def detected_message_form 194 | if iphone_user_agent? 195 | :iphone_message_form 196 | elsif ipad_user_agent? 197 | :iphone_message_form 198 | else 199 | :message_form 200 | end 201 | end 202 | 203 | def iphone_user_agent? 204 | request.env["HTTP_USER_AGENT"] && request.env["HTTP_USER_AGENT"][/(\(iPhone)/] 205 | end 206 | 207 | def ipad_user_agent? 208 | request.env["HTTP_USER_AGENT"] && request.env["HTTP_USER_AGENT"][/(\(iPad)/] 209 | end 210 | 211 | def load_bridge 212 | @bridge = JsChat::Bridge.new session[:jschat_id] 213 | end 214 | 215 | def load_and_connect 216 | @bridge = JsChat::Bridge.new session[:jschat_id] 217 | @bridge.connect 218 | session[:jschat_id] = @bridge.cookie 219 | end 220 | 221 | def cookie_expiration 222 | Time.now.utc + 94608000 223 | end 224 | 225 | def save_last_room(room) 226 | response.set_cookie 'last-room', { :value => room, :path => '/', :expires => cookie_expiration } 227 | end 228 | 229 | def last_room 230 | request.cookies['last-room'] 231 | end 232 | 233 | def save_nickname(name) 234 | response.set_cookie 'jschat-name', { :value => name, :path => '/', :expires => cookie_expiration } 235 | end 236 | 237 | def messages_js(messages) 238 | messages ||= [] 239 | escape_json messages.to_json 240 | end 241 | 242 | def remove_my_messages(messages) 243 | return if messages.nil? 244 | messages.delete_if { |message| message['message'] and message['message']['user'] == nickname } 245 | end 246 | 247 | def clear_cookies 248 | response.set_cookie 'last-room', { :value => nil, :path => '/' } 249 | session[:jschat_id] = nil 250 | session[:request_token] = nil 251 | session[:request_token_secret] = nil 252 | session[:access_token] = nil 253 | session[:secret_token] = nil 254 | session[:twitter_name] = nil 255 | end 256 | 257 | def twitter_user? 258 | session[:access_token] && session[:secret_token] 259 | end 260 | 261 | def save_twitter_user(options = {}) 262 | options = load_twitter_user.merge(options).merge({ 263 | 'twitter_name' => session[:twitter_name], 264 | 'access_token' => session[:access_token], 265 | 'secret_token' => session[:secret_token], 266 | 'client_id' => session[:client_id] 267 | }) 268 | JsChat::Storage.driver.save_user(options) 269 | end 270 | 271 | def save_twitter_user_rooms 272 | if twitter_user? 273 | rooms = @bridge.rooms 274 | save_twitter_user('rooms' => rooms) 275 | end 276 | end 277 | 278 | def delete_twitter_user 279 | JsChat::Storage.driver.delete_user({ 'twitter_name' => session[:twitter_name] }) 280 | end 281 | 282 | def load_twitter_user 283 | JsChat::Storage.driver.find_user({ 'twitter_name' => session[:twitter_name] }) || {} 284 | end 285 | 286 | def valid_twitter_client_id? 287 | session[:client_id] == load_twitter_user['client_id'] 288 | end 289 | 290 | def load_twitter_user_and_set_bridge_id 291 | user = load_twitter_user 292 | if user['jschat_id'] and user['jschat_id'].size > 0 293 | session[:jschat_id] = user['jschat_id'] 294 | end 295 | end 296 | 297 | def nickname 298 | request.cookies['jschat-name'] 299 | end 300 | 301 | def unique_token 302 | chars = ("a".."z").to_a + ("1".."9").to_a 303 | Array.new(8, '').collect { chars[rand(chars.size)] }.join 304 | end 305 | end 306 | 307 | # Identify 308 | get '/' do 309 | load_bridge 310 | 311 | if @bridge.active? and last_room 312 | redirect "/chat/#{last_room}" 313 | else 314 | clear_cookies 315 | erb :index, :layout => detected_layout 316 | end 317 | end 318 | 319 | post '/identify' do 320 | load_and_connect 321 | save_last_room params['room'] 322 | save_nickname params['name'] 323 | if @bridge.identify params['name'], request.ip 324 | { 'action' => 'redirect', 'to' => "/chat/#{params['room']}" }.to_json 325 | else 326 | @bridge.identification_error.to_json 327 | end 328 | end 329 | 330 | post '/change-name' do 331 | load_bridge 332 | result = @bridge.change('user', { 'name' => params['name'] }) 333 | if result['notice'] 334 | save_nickname params['name'] 335 | save_twitter_user({ :name => params['name'] }) if twitter_user? 336 | end 337 | [result].to_json 338 | end 339 | 340 | get '/messages' do 341 | load_bridge 342 | if @bridge.active? 343 | save_last_room params['room'] 344 | messages_js remove_my_messages(@bridge.recent_messages(params['room'])) 345 | else 346 | if @bridge.last_error and @bridge.last_error['error']['code'] == 107 347 | error 500, [@bridge.last_error].to_json 348 | elsif @bridge.last_error 349 | [@bridge.last_error].to_json 350 | else 351 | error 500, 'Unknown error' 352 | end 353 | end 354 | end 355 | 356 | get '/room_update_times' do 357 | load_bridge 358 | if @bridge.active? 359 | messages_js @bridge.room_update_times 360 | end 361 | end 362 | 363 | get '/names' do 364 | load_bridge 365 | save_last_room params['room'] 366 | [@bridge.names(params['room'])].to_json 367 | end 368 | 369 | get '/lastlog' do 370 | load_bridge 371 | if @bridge.active? 372 | save_last_room params['room'] 373 | messages_js @bridge.lastlog(params['room']) 374 | end 375 | end 376 | 377 | get '/search' do 378 | load_bridge 379 | if @bridge.active? 380 | messages_js @bridge.search(params['q'], params['room']) 381 | end 382 | end 383 | 384 | post '/join' do 385 | load_bridge 386 | @bridge.join params['room'] 387 | save_last_room params['room'] 388 | save_twitter_user_rooms 389 | 'OK' 390 | end 391 | 392 | get '/part' do 393 | load_bridge 394 | @bridge.part params['room'] 395 | save_twitter_user_rooms 396 | if @bridge.last_error 397 | error 500, [@bridge.last_error].to_json 398 | else 399 | 'OK' 400 | end 401 | end 402 | 403 | get '/chat/' do 404 | load_bridge 405 | if @bridge and @bridge.active? 406 | erb detected_message_form, :layout => detected_layout 407 | else 408 | erb :index, :layout => detected_layout 409 | end 410 | end 411 | 412 | post '/message' do 413 | load_bridge 414 | save_last_room params['to'] 415 | @bridge.send_message params['message'], params['to'] 416 | 'OK' 417 | end 418 | 419 | get '/user/name' do 420 | load_bridge 421 | nickname 422 | end 423 | 424 | get '/ping' do 425 | load_bridge 426 | @bridge.ping.to_json 427 | end 428 | 429 | get '/quit' do 430 | load_bridge 431 | @bridge.send_quit nickname 432 | delete_twitter_user if twitter_user? 433 | clear_cookies 434 | redirect '/' 435 | end 436 | 437 | get '/rooms' do 438 | load_bridge 439 | rooms = @bridge.rooms 440 | save_twitter_user('rooms' => rooms) if twitter_user? 441 | rooms.to_json 442 | end 443 | 444 | get '/twitter' do 445 | request_token = @twitter.request_token( 446 | :oauth_callback => url_for('/twitter_auth', :full) 447 | ) 448 | session[:request_token] = request_token.token 449 | session[:request_token_secret] = request_token.secret 450 | redirect request_token.authorize_url.gsub('authorize', 'authenticate') 451 | end 452 | 453 | get '/twitter_auth' do 454 | # Exchange the request token for an access token. 455 | begin 456 | @access_token = @twitter.authorize( 457 | session[:request_token], 458 | session[:request_token_secret], 459 | :oauth_verifier => params[:oauth_verifier] 460 | ) 461 | rescue OAuth::Unauthorized => exception 462 | puts exception 463 | halt "Unable to login with Twitter: #{exception.class}" 464 | end 465 | 466 | if @twitter.authorized? 467 | session[:access_token] = @access_token.token 468 | session[:secret_token] = @access_token.secret 469 | session[:twitter_name] = @twitter.info['screen_name'] 470 | 471 | # TODO: Make this cope if someone has the same name 472 | room = '#jschat' 473 | user = load_twitter_user 474 | name = @twitter.info['screen_name'] 475 | 476 | if user['name'] and user['name'].length > 0 477 | name = user['name'] 478 | end 479 | 480 | session[:jschat_id] = user['jschat_id'] if user['jschat_id'] and !user['jschat_id'].empty? 481 | session[:client_id] = unique_token 482 | save_nickname name 483 | save_twitter_user('twitter_name' => @twitter.info['screen_name'], 484 | 'jschat_id' => session[:jschat_id], 485 | 'name' => name) 486 | user = load_twitter_user 487 | load_bridge 488 | 489 | if @bridge.active? 490 | if user['rooms'] and user['rooms'].any? 491 | room = user['rooms'].first 492 | end 493 | else 494 | # Reconnect 495 | session[:jschat_id] = nil 496 | load_and_connect 497 | save_twitter_user('jschat_id' => session[:jschat_id]) 498 | @bridge.identify(@twitter.info['screen_name'], request.ip, (((60 * 60) * 24) * 7)) 499 | if user['rooms'] 500 | user['rooms'].each do |room| 501 | @bridge.join room 502 | end 503 | room = user['rooms'].first 504 | else 505 | save_last_room '#jschat' 506 | @bridge.join '#jschat' 507 | end 508 | end 509 | 510 | redirect "/chat/#{room}" 511 | else 512 | redirect '/' 513 | end 514 | end 515 | 516 | # TODO: This doesn't seem to work with twitter oauth right now 517 | post '/tweet' do 518 | if twitter_user? and @twitter.authorized? 519 | @twitter.update(params['tweet']) 520 | else 521 | error 500, 'You are not signed in with Twitter' 522 | end 523 | end 524 | -------------------------------------------------------------------------------- /lib/jschat/http/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/favicon.ico -------------------------------------------------------------------------------- /lib/jschat/http/public/iOS-114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/iOS-114.png -------------------------------------------------------------------------------- /lib/jschat/http/public/iOS-57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/iOS-57.png -------------------------------------------------------------------------------- /lib/jschat/http/public/iOS-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/iOS-72.png -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/angry.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/angry.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/arr.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/arr.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/blink.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/blink.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/blush.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/blush.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/brucelee.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/brucelee.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/btw.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/btw.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/chuckle.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/chuckle.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/clap.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/clap.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/cool.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/cool.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/drool.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/drool.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/drunk.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/drunk.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/dry.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/dry.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/eek.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/eek.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/flex.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/flex.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/happy.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/happy.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/holmes.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/holmes.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/huh.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/huh.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/laugh.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/laugh.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/lol.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/lol.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/mad.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/mad.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/mellow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/mellow.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/noclue.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/noclue.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/oh.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/oh.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/ohmy.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/ohmy.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/panic.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/panic.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/ph34r.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/ph34r.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/pimp.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/pimp.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/punch.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/punch.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/realmad.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/realmad.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/rock.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/rock.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/rofl.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/rofl.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/rolleyes.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/rolleyes.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/sad.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/sad.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/scratch.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/scratch.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/shifty.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/shifty.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/shock.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/shock.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/shrug.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/shrug.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/sleep.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/sleep.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/sleeping.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/sleeping.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/smile.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/smile.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/suicide.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/suicide.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/sweat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/sweat.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/thumbs.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/thumbs.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/tongue.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/tongue.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/unsure.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/unsure.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/w00t.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/w00t.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/wacko.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/wacko.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/whistling.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/whistling.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/wink.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/wink.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/worship.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/worship.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/emoticons/yucky.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/emoticons/yucky.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/jschat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/jschat.gif -------------------------------------------------------------------------------- /lib/jschat/http/public/images/shadow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/public/images/shadow.png -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/all.js: -------------------------------------------------------------------------------- 1 | var JsChat = {}; 2 | 3 | document.observe('dom:loaded', function() { 4 | JsChat.user = new User(); 5 | 6 | if ($('post_message')) { 7 | var chatController = new JsChat.ChatController(); 8 | } 9 | 10 | if ($('sign-on')) { 11 | if (JsChat.user.name) { 12 | $('name').value = JsChat.user.name; 13 | } 14 | 15 | if ($('room') && window.location.hash) { 16 | $('room').value = window.location.hash; 17 | } 18 | 19 | var signOnController = new JsChat.SignOnController(); 20 | } 21 | }); 22 | var History = Class.create({ 23 | initialize: function() { 24 | this.messages = []; 25 | this.index = 0; 26 | this.limit = 100; 27 | }, 28 | 29 | prev: function() { 30 | this.index = this.index <= 0 ? this.messages.length - 1 : this.index - 1; 31 | }, 32 | 33 | next: function() { 34 | this.index = this.index >= this.messages.length - 1 ? 0 : this.index + 1; 35 | }, 36 | 37 | reset: function() { 38 | this.index = this.messages.length; 39 | }, 40 | 41 | value: function() { 42 | if (this.messages.length == 0) return ''; 43 | return this.messages[this.index]; 44 | }, 45 | 46 | add: function(value) { 47 | if (!value || value.length == 0) return; 48 | 49 | this.messages.push(value); 50 | if (this.messages.length > this.limit) { 51 | this.messages = this.messages.slice(-this.limit); 52 | } 53 | this.index = this.messages.length; 54 | }, 55 | 56 | atTop: function() { 57 | return this.index === this.messages.length; 58 | } 59 | }); 60 | 61 | var TabCompletion = Class.create({ 62 | initialize: function(element) { 63 | this.element = $(element); 64 | this.matches = []; 65 | this.match_offset = 0; 66 | this.cycling = false; 67 | this.has_focus = true; 68 | this.history = new History(); 69 | 70 | document.observe('keydown', this.keyboardEvents.bindAsEventListener(this)); 71 | this.element.observe('focus', this.onFocus.bindAsEventListener(this)); 72 | this.element.observe('blur', this.onBlur.bindAsEventListener(this)); 73 | this.element.observe('click', this.onFocus.bindAsEventListener(this)); 74 | }, 75 | 76 | onBlur: function() { 77 | this.has_focus = false; 78 | this.reset(); 79 | }, 80 | 81 | onFocus: function() { 82 | this.has_focus = true; 83 | this.reset(); 84 | }, 85 | 86 | tabSearch: function(input) { 87 | var names = $$('#names li').collect(function(element) { return element.innerHTML }).sort(); 88 | return names.findAll(function(name) { return name.toLowerCase().match(input.toLowerCase()) }); 89 | }, 90 | 91 | textToLeft: function() { 92 | var text = this.element.value; 93 | var caret_position = FormHelpers.getCaretPosition(this.element); 94 | if (caret_position < text.length) { 95 | text = text.slice(0, caret_position); 96 | } 97 | 98 | text = text.split(' ').last(); 99 | return text; 100 | }, 101 | 102 | elementFocused: function(e) { 103 | if (typeof document.activeElement == 'undefined') { 104 | return this.has_focus; 105 | } else { 106 | return document.activeElement == this.element; 107 | } 108 | }, 109 | 110 | keyboardEvents: function(e) { 111 | if (this.elementFocused()) { 112 | switch (e.keyCode) { 113 | case Event.KEY_TAB: 114 | var caret_position = FormHelpers.getCaretPosition(this.element); 115 | 116 | if (this.element.value.length > 0) { 117 | var search_text = ''; 118 | var search_result = ''; 119 | var replace_inline = false; 120 | var editedText = this.element.value.match(/[^a-z0-9]/i); 121 | 122 | if (this.cycling) { 123 | if (this.element.value == '#{last_result}: '.interpolate({ last_result: this.last_result })) { 124 | editedText = false; 125 | } else { 126 | replace_inline = true; 127 | } 128 | search_text = this.last_result; 129 | } else if (editedText && this.matches.length == 0) { 130 | search_text = this.textToLeft(); 131 | replace_inline = true; 132 | } else { 133 | search_text = this.element.value; 134 | } 135 | 136 | if (this.matches.length == 0) { 137 | this.matches = this.tabSearch(search_text); 138 | search_result = this.matches.first(); 139 | this.cycling = true; 140 | } else { 141 | this.match_offset++; 142 | if (this.match_offset >= this.matches.length) { 143 | this.match_offset = 0; 144 | } 145 | search_result = this.matches[this.match_offset]; 146 | } 147 | 148 | if (search_result && search_result.length > 0) { 149 | if (this.cycling && this.last_result) { 150 | search_text = this.last_result; 151 | } 152 | this.last_result = search_result; 153 | 154 | if (replace_inline) { 155 | var slice_start = caret_position - search_text.length; 156 | if (slice_start > 0) { 157 | this.element.value = this.element.value.substr(0, slice_start) + search_result + this.element.value.substr(caret_position, this.element.value.length); 158 | FormHelpers.setCaretPosition(this.element, slice_start + search_result.length); 159 | } 160 | } else if (!editedText) { 161 | this.element.value = '#{search_result}: '.interpolate({ search_result: search_result }); 162 | } 163 | } 164 | } 165 | 166 | Event.stop(e); 167 | return false; 168 | break; 169 | 170 | case Event.KEY_UP: 171 | if (this.history.atTop()) { 172 | this.history.add(this.element.value); 173 | } 174 | 175 | this.history.prev(); 176 | this.element.value = this.history.value(); 177 | FormHelpers.setCaretPosition(this.element, this.element.value.length + 1); 178 | Event.stop(e); 179 | return false; 180 | break; 181 | 182 | case Event.KEY_DOWN: 183 | this.history.next(); 184 | this.element.value = this.history.value(); 185 | FormHelpers.setCaretPosition(this.element, this.element.value.length + 1); 186 | Event.stop(e); 187 | return false; 188 | break; 189 | 190 | default: 191 | this.reset(); 192 | break; 193 | } 194 | } 195 | }, 196 | 197 | reset: function() { 198 | this.matches = []; 199 | this.match_offset = 0; 200 | this.last_result = null; 201 | this.cycling = false; 202 | } 203 | }); 204 | var UserCommands = { 205 | '/emotes': function() { 206 | var text = ''; 207 | Display.add_message('Available Emotes — Prefix with a : to use', 'help'); 208 | Display.add_message(EmoteHelper.legalEmotes.join(', '), 'help'); 209 | }, 210 | 211 | '/help': function() { 212 | var help = []; 213 | Display.add_message('JsChat Help — Type the following commands into the message field:', 'help') 214 | help.push(['/clear', 'Clears messages']); 215 | help.push(['/join #room_name', 'Joins a room']); 216 | help.push(['/part #room_name', 'Leaves a room. Leave room_name blank for the current room']); 217 | help.push(['/lastlog', 'Shows recent activity']); 218 | help.push(['/search query', 'Searches the logs for this room']); 219 | help.push(['/names', 'Refreshes the names list']); 220 | help.push(['/name new_name', 'Changes your name']); 221 | help.push(['/toggle images', 'Toggles showing of images and videos']); 222 | help.push(['/quit', 'Quit']); 223 | help.push(['/emotes', 'Shows available emotes']); 224 | $A(help).each(function(options) { 225 | var help_text = '#{command}#{text}'.interpolate({ command: options[0], text: options[1]}); 226 | Display.add_message(help_text, 'help'); 227 | }); 228 | }, 229 | 230 | '/clear': function() { 231 | $('messages').innerHTML = ''; 232 | }, 233 | 234 | '/lastlog': function() { 235 | this.pausePollers = true; 236 | $('messages').innerHTML = ''; 237 | JsChat.Request.get('/lastlog', function(transport) { 238 | this.displayMessages(transport.responseText); 239 | $('names').innerHTML = ''; 240 | this.updateNames(); 241 | this.pausePollers = false; 242 | }.bind(this)); 243 | }, 244 | 245 | '/search\\s+(.*)': function(query) { 246 | query = query[1]; 247 | this.pausePollers = true; 248 | $('messages').innerHTML = ''; 249 | JsChat.Request.get('/search?q=' + query, function(transport) { 250 | Display.add_message('Search results:', 'server'); 251 | this.displayMessages(transport.responseText); 252 | this.pausePollers = false; 253 | }.bind(this)); 254 | }, 255 | 256 | '/(name|nick)\\s+(.*)': function(name) { 257 | name = name[2]; 258 | new Ajax.Request('/change-name', { 259 | method: 'post', 260 | parameters: { name: name }, 261 | onSuccess: function(response) { 262 | this.displayMessages(response.responseText); 263 | JsChat.user.setName(name); 264 | this.updateNames(); 265 | }.bind(this), 266 | onFailure: function() { 267 | Display.add_message("Server error: couldn't access: #{url}".interpolate({ url: url }), 'server'); 268 | } 269 | }); 270 | }, 271 | 272 | '/names': function() { 273 | this.updateNames(); 274 | }, 275 | 276 | '/toggle images': function() { 277 | JsChat.user.setHideImages(!JsChat.user.hideImages); 278 | Display.add_message("Hide images set to #{hide}".interpolate({ hide: JsChat.user.hideImages }), 'server'); 279 | }, 280 | 281 | '/(join)\\s+(.*)': function() { 282 | var room = arguments[0][2]; 283 | this.validateAndJoinRoom(room); 284 | }, 285 | 286 | '/(part|leave)': function() { 287 | this.partRoom(PageHelper.currentRoom()); 288 | }, 289 | 290 | '/(part|leave)\\s+(.*)': function() { 291 | var room = arguments[0][2]; 292 | this.partRoom(room); 293 | }, 294 | 295 | '/tweet\\s+(.*)': function() { 296 | var message = arguments[0][1]; 297 | this.sendTweet(message); 298 | }, 299 | 300 | '/quit': function() { 301 | window.location = '/quit'; 302 | } 303 | }; 304 | var Display = { 305 | scrolled: false, 306 | 307 | add_message: function(text, className, time) { 308 | var time_html = '\#{time}'.interpolate({ time: TextHelper.dateText(time) }); 309 | $('messages').insert({ bottom: '
  • ' + time_html + ' ' + text + '
  • ' }); 310 | this.scrollMessagesToTop(); 311 | }, 312 | 313 | addImageOnLoads: function() { 314 | $$('#messages li').last().select('img').each(function(element) { 315 | element.observe('load', this.scrollMessagesToTop); 316 | }.bind(this)); 317 | }, 318 | 319 | message: function(message, time) { 320 | var name = JsChat.user.name; 321 | var user_class = name == message['user'] ? 'user active' : 'user'; 322 | var text = '\#{user} \#{message}'; 323 | var blurred_mention = ''; 324 | 325 | if (message['message'].match(new RegExp(name, 'i')) && name != message['user']) { 326 | user_class = 'user mentioned'; 327 | blurred_mention = '*'; 328 | } 329 | 330 | Display.clearIdleState(message['user']); 331 | 332 | text = text.interpolate({ 333 | user_class: user_class, 334 | room: message['room'], 335 | user: TextHelper.truncateName(message['user']), 336 | message: TextHelper.decorateMessage(message['message']), 337 | message_class: 'message' 338 | }); 339 | 340 | this.add_message(text, 'message', time); 341 | this.addImageOnLoads(); 342 | 343 | if (this.show_unread) { 344 | this.unread++; 345 | document.title = 'JsChat: (' + this.unread + blurred_mention + ') new messages'; 346 | } 347 | }, 348 | 349 | messages: function(messages) { 350 | $('messages').innerHTML = ''; 351 | this.ignore_notices = true; 352 | 353 | $A(messages).each(function(json) { 354 | try { 355 | if (json['change']) { 356 | Change[json['change']](json[json['change']]); 357 | } else { 358 | this[json['display']](json[json['display']]); 359 | } 360 | } catch (exception) { 361 | } 362 | }.bind(this)); 363 | 364 | this.ignore_notices = false; 365 | this.scrollMessagesToTop(); 366 | /* This is assumed to be the point at which displaying /lastlog completes */ 367 | $('loading').hide(); 368 | }, 369 | 370 | scrollMessagesToTop: function() { 371 | if (!this.scrolled) { 372 | $('messages').scrollTop = $('messages').scrollHeight; 373 | } 374 | }, 375 | 376 | clearIdleState: function(user_name) { 377 | $$('#names li').each(function(element) { 378 | if (element.innerHTML == user_name && element.hasClassName('idle')) { 379 | element.lastIdle = (new Date()); 380 | element.removeClassName('idle'); 381 | } 382 | }); 383 | }, 384 | 385 | isIdle: function(dateValue) { 386 | try { 387 | var d = typeof dateValue == 'string' ? new Date(Date.parse(dateValue)) : dateValue, 388 | now = new Date(); 389 | if (((now - d) / 1000) > (60 * 5)) { 390 | return true; 391 | } 392 | } catch (exception) { 393 | console.log(exception); 394 | } 395 | return false; 396 | }, 397 | 398 | names: function(users) { 399 | $('names').innerHTML = ''; 400 | users.each(function(user) { 401 | var name = user['name'], 402 | list_class = this.isIdle(user['last_activity']) ? 'idle' : '', 403 | element = $(document.createElement('li')); 404 | 405 | element.addClassName(list_class); 406 | element.innerHTML = TextHelper.truncateName(name); 407 | $('names').insert({ bottom: element }); 408 | 409 | try { 410 | // Record the last idle time so the idle state can be dynamically updated 411 | element.lastIdle = new Date(Date.parse(user['last_activity'])); 412 | } catch (exception) { 413 | element.lastIdle = null; 414 | } 415 | }.bind(this)); 416 | }, 417 | 418 | join: function(join) { 419 | $('room-name').innerHTML = TextHelper.truncateRoomName(join['room']); 420 | $('room-name').title = PageHelper.currentRoom(); 421 | }, 422 | 423 | join_notice: function(join, time) { 424 | this.add_user(join['user']); 425 | this.add_message(join['user'] + ' has joined the room', 'server', time); 426 | }, 427 | 428 | add_user: function(name) { 429 | if (!this.ignore_notices) { 430 | $('names').insert({ bottom: '
  • ' + TextHelper.truncateName(name) + '
  • ' }); 431 | } 432 | }, 433 | 434 | remove_user: function(name) { 435 | if (!this.ignore_notices) { 436 | $$('#names li').each(function(element) { if (element.innerHTML == name) element.remove(); }); 437 | } 438 | }, 439 | 440 | part_notice: function(part, time) { 441 | this.remove_user(part['user']); 442 | this.add_message(part['user'] + ' has left the room', 'server', time); 443 | }, 444 | 445 | quit_notice: function(quit, time) { 446 | this.remove_user(quit['user']); 447 | this.add_message(quit['user'] + ' has quit', 'server', time); 448 | }, 449 | 450 | notice: function(notice) { 451 | this.add_message(notice, 'server'); 452 | }, 453 | 454 | error: function(error) { 455 | this.add_message(error['message'], 'error'); 456 | } 457 | }; 458 | /* FIXME: Later on this should be a class */ 459 | JsChat.Request = { 460 | get: function(url, callback) { 461 | new Ajax.Request(url, { 462 | method: 'get', 463 | parameters: { time: new Date().getTime(), room: PageHelper.currentRoom() }, 464 | onFailure: function() { 465 | Display.add_message("Server error: couldn't access: #{url}".interpolate({ url: url }), 'server'); 466 | }, 467 | onComplete: function(transport) { return callback(transport); } 468 | }); 469 | } 470 | }; 471 | var Change = { 472 | user: function(user, time) { 473 | if (user['name']) { 474 | change = $H(user['name']).toArray()[0]; 475 | var old = change[0], 476 | new_value = change[1]; 477 | if (new_value !== PageHelper.nickname()) { 478 | Display.add_message("#{old} is now known as #{new_value}".interpolate({ old: old, new_value: new_value }), 'server', time); 479 | } 480 | $$('#names li').each(function(element) { 481 | if (element.innerHTML == old) element.innerHTML = new_value; 482 | }); 483 | } 484 | } 485 | }; 486 | User = function() { 487 | this.name = Cookie.find('jschat-name'); 488 | this.hideImages = Cookie.find('jschat-hideImages') === '1' ? true : false; 489 | }; 490 | 491 | User.prototype.setName = function(name) { 492 | Cookie.create('jschat-name', name, 28, '/'); 493 | this.name = name; 494 | }; 495 | 496 | User.prototype.setHideImages = function(hideImages) { 497 | this.hideImages = hideImages; 498 | Cookie.create('jschat-hideImages', (hideImages ? '1' : '0'), 28, '/'); 499 | }; 500 | Cookie = { 501 | create: function(name, value, days, path) { 502 | var expires = ''; 503 | path = typeof path == 'undefined' ? '/' : path; 504 | 505 | if (days) { 506 | var date = new Date(); 507 | date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); 508 | expires = "; expires=" + date.toGMTString(); 509 | } 510 | 511 | if (name && value) { 512 | document.cookie = name + '=' + escape(value) + expires + ';path=' + path; 513 | } 514 | }, 515 | 516 | find: function(name) { 517 | var matches = document.cookie.match(name + '=([^;]*)'); 518 | if (matches && matches.length == 2) { 519 | return unescape(matches[1]); 520 | } 521 | }, 522 | 523 | destroy: function(name) { 524 | this.create(name, ' ', -1); 525 | } 526 | }; 527 | /* 528 | Cross-Browser Split 0.3 529 | By Steven Levithan 530 | MIT license 531 | Provides a consistent cross-browser, ECMA-262 v3 compliant split method 532 | */ 533 | 534 | String.prototype._$$split = String.prototype._$$split || String.prototype.split; 535 | 536 | String.prototype.split = function (s /* separator */, limit) { 537 | // if separator is not a regex, use the native split method 538 | if (!(s instanceof RegExp)) 539 | return String.prototype._$$split.apply(this, arguments); 540 | 541 | var flags = (s.global ? "g" : "") + (s.ignoreCase ? "i" : "") + (s.multiline ? "m" : ""), 542 | s2 = new RegExp("^" + s.source + "$", flags), 543 | output = [], 544 | origLastIndex = s.lastIndex, 545 | lastLastIndex = 0, 546 | i = 0, match, lastLength; 547 | 548 | /* behavior for limit: if it's... 549 | - undefined: no limit 550 | - NaN or zero: return an empty array 551 | - a positive number: use limit after dropping any decimal 552 | - a negative number: no limit 553 | - other: type-convert, then use the above rules 554 | */ 555 | if (limit === undefined || +limit < 0) { 556 | limit = false; 557 | } else { 558 | limit = Math.floor(+limit); 559 | if (!limit) 560 | return []; 561 | } 562 | 563 | if (s.global) 564 | s.lastIndex = 0; 565 | else 566 | s = new RegExp(s.source, "g" + flags); 567 | 568 | while ((!limit || i++ <= limit) && (match = s.exec(this))) { 569 | var emptyMatch = !match[0].length; 570 | 571 | // Fix IE's infinite-loop-resistant but incorrect lastIndex 572 | if (emptyMatch && s.lastIndex > match.index) 573 | s.lastIndex--; 574 | 575 | if (s.lastIndex > lastLastIndex) { 576 | // Fix browsers whose exec methods don't consistently return undefined for non-participating capturing groups 577 | if (match.length > 1) { 578 | match[0].replace(s2, function () { 579 | for (var j = 1; j < arguments.length - 2; j++) { 580 | if (arguments[j] === undefined) 581 | match[j] = undefined; 582 | } 583 | }); 584 | } 585 | 586 | output = output.concat(this.slice(lastLastIndex, match.index)); 587 | if (1 < match.length && match.index < this.length) 588 | output = output.concat(match.slice(1)); 589 | lastLength = match[0].length; // only needed if s.lastIndex === this.length 590 | lastLastIndex = s.lastIndex; 591 | } 592 | 593 | if (emptyMatch) 594 | s.lastIndex++; // avoid an infinite loop 595 | } 596 | 597 | // since this uses test(), output must be generated before restoring lastIndex 598 | output = lastLastIndex === this.length ? 599 | (s.test("") && !lastLength ? output : output.concat("")) : 600 | (limit ? output : output.concat(this.slice(lastLastIndex))); 601 | s.lastIndex = origLastIndex; // only needed if s.global, else we're working with a copy of the regex 602 | return output; 603 | }; 604 | 605 | var TextHelper = { 606 | zeroPad: function(value, length) { 607 | value = value.toString(); 608 | if (value.length >= length) { 609 | return value; 610 | } else { 611 | return this.zeroPad('0' + value, length); 612 | } 613 | }, 614 | 615 | dateText: function(time) { 616 | var d = new Date(); 617 | if (typeof time != 'undefined') { 618 | d = new Date(Date.parse(time)); 619 | } 620 | return this.zeroPad(d.getHours(), 2) + ':' + this.zeroPad(d.getMinutes(), 2); 621 | }, 622 | 623 | truncateName: function(text) { 624 | return text.truncate(15); 625 | }, 626 | 627 | truncateRoomName: function(text) { 628 | return text.truncate(15); 629 | }, 630 | 631 | decorateMessage: function(text) { 632 | return EmoteHelper.insertEmotes(this.autoLink(this.textilize(text))); 633 | }, 634 | 635 | textilize: function(text) { 636 | function escape_regex(text) { return text.replace(/([\*\?\+\^\?])/g, "\\$1"); } 637 | function openTag(text) { return '<' + text + '>'; } 638 | function closeTag(text) { return ''; } 639 | 640 | var map = { '_': 'em', '*': 'strong' }; 641 | 642 | $H(map).each(function(mapping) { 643 | var result = ''; 644 | var m = escape_regex(mapping[0]); 645 | var mr = new RegExp('(' + m + ')'); 646 | var matcher = new RegExp('(^|\\s+)(' + m + ')([^\\s][^' + mapping[0] + ']*[^\\s])(' + m + ')', 'g'); 647 | 648 | if (text.match(matcher)) { 649 | var open = false; 650 | text.split(matcher).each(function(segment) { 651 | if (segment == mapping[0]) { 652 | var tag = open ? closeTag(mapping[1]) : openTag(mapping[1]); 653 | result += segment.replace(mr, tag); 654 | open = !open; 655 | } else { 656 | result += segment; 657 | } 658 | }); 659 | 660 | if (open) result += closeTag(mapping[1]); 661 | text = result; 662 | } 663 | }); 664 | 665 | return text; 666 | }, 667 | 668 | autoLink: function(text) { 669 | var result = ''; 670 | try { 671 | if (!LinkHelper.url(text)) { 672 | return text; 673 | } 674 | 675 | $A(text.split(/(https?:\/\/[^\s]*)/gi)).each(function(link) { 676 | if (link.match(/href="/)) { 677 | result += link; 678 | } else { 679 | if (LinkHelper.youtube_url(link) && !JsChat.user.hideImages) { 680 | result += link.replace(link, LinkHelper.youtube(link)); 681 | } else if (LinkHelper.vimeo_url(link) && !JsChat.user.hideImages) { 682 | result += link.replace(link, LinkHelper.vimeo(link)); 683 | } else if (LinkHelper.image_url(link) && !JsChat.user.hideImages) { 684 | result += link.replace(link, LinkHelper.image(link)); 685 | } else if (LinkHelper.twitpic_url(link) && !JsChat.user.hideImages) { 686 | result += link.replace(link, LinkHelper.twitpic(link)); 687 | } else if (LinkHelper.url(link)) { 688 | result += link.replace(link, LinkHelper.link(link)); 689 | } else { 690 | result += link; 691 | } 692 | } 693 | }); 694 | } catch (exception) { 695 | } 696 | return result; 697 | } 698 | }; 699 | var PageHelper = { 700 | currentRoom: function() { 701 | return window.location.hash; 702 | }, 703 | 704 | setCurrentRoomName: function(roomName) { 705 | window.location.hash = roomName; 706 | $('room-name').innerHTML = TextHelper.truncateRoomName(PageHelper.currentRoom()); 707 | $('room-name').title = PageHelper.currentRoom(); 708 | document.title = PageHelper.title(); 709 | }, 710 | 711 | allRoomNames: function() { 712 | return $$('#rooms li a').collect(function(link) { 713 | return link.innerHTML; 714 | }); 715 | }, 716 | 717 | nickname: function() { 718 | return JsChat.user.name; 719 | }, 720 | 721 | title: function() { 722 | if (PageHelper.currentRoom()) { 723 | return 'JsChat: ' + PageHelper.currentRoom(); 724 | } else { 725 | return 'JsChat'; 726 | } 727 | }, 728 | 729 | device: function() { 730 | if ($$('body.iphone').length > 0) { 731 | return 'iphone'; 732 | } else if ($$('body.ipad').length > 0) { 733 | return 'ipad'; 734 | } 735 | }, 736 | 737 | isDevice: function(device) { 738 | return PageHelper.device() == device; 739 | } 740 | }; 741 | var LinkHelper = { 742 | url: function(url) { 743 | return url.match(/(https?:\/\/[^\s]*)/gi); 744 | }, 745 | 746 | link: function(url) { 747 | return '\#{link_name}'.interpolate({ url: url, link_name: url}); 748 | }, 749 | 750 | image_url: function(url) { 751 | return url.match(/\.(jpe?g|png|gif)/i); 752 | }, 753 | 754 | image: function(url) { 755 | return ''.interpolate({ url: url, image: url }) 756 | }, 757 | 758 | twitpic_url: function(url) { 759 | return url.match(/\bhttp:\/\/twitpic.com\/(show|[^\s]*)\b/i); 760 | }, 761 | 762 | twitpic: function(url) { 763 | var twitpic_id = url.split('/').last(); 764 | return ''.interpolate({ twitpic_id: twitpic_id, url: url }) 765 | }, 766 | 767 | youtube_url: function(url) { 768 | return url.match(/youtube\.com/) && url.match(/watch\?v/); 769 | }, 770 | 771 | youtube: function(url) { 772 | var youtube_url_id = url.match(/\?v=([^&\s]*)/); 773 | if (youtube_url_id && youtube_url_id[1]) { 774 | var youtube_url = 'http://www.youtube.com/v/' + youtube_url_id[1]; 775 | var youtube_html = ''; 776 | return youtube_html.interpolate({ movie_url: youtube_url, url: youtube_url }); 777 | } else { 778 | return this.link(url); 779 | } 780 | }, 781 | 782 | vimeo_url: function(url) { 783 | return url.match(/vimeo\.com/) && url.match(/\/\d+/); 784 | }, 785 | 786 | vimeo: function(url) { 787 | var vimeo_url_id = url.match(/\d+/); 788 | if (vimeo_url_id) { 789 | var vimeo_url = 'http://vimeo.com/' + vimeo_url_id; 790 | var vimeo_html = ''; 791 | return vimeo_html.interpolate({ movie_url: vimeo_url, url: vimeo_url }); 792 | } else { 793 | return this.link(url); 794 | } 795 | } 796 | }; 797 | var FormHelpers = { 798 | getCaretPosition: function(element) { 799 | if (element.setSelectionRange) { 800 | return element.selectionStart; 801 | } else if (element.createTextRange) { 802 | try { 803 | // The current selection 804 | var range = document.selection.createRange(); 805 | // We'll use this as a 'dummy' 806 | var stored_range = range.duplicate(); 807 | // Select all text 808 | stored_range.moveToElementText(element); 809 | // Now move 'dummy' end point to end point of original range 810 | stored_range.setEndPoint('EndToEnd', range); 811 | 812 | return stored_range.text.length - range.text.length; 813 | } catch (exception) { 814 | // IE is being mental. TODO: Figure out what IE's issue is 815 | return 0; 816 | } 817 | } 818 | }, 819 | 820 | setCaretPosition: function(element, pos) { 821 | if (element.setSelectionRange) { 822 | element.focus() 823 | element.setSelectionRange(pos, pos) 824 | } else if (element.createTextRange) { 825 | var range = element.createTextRange() 826 | 827 | range.collapse(true) 828 | range.moveEnd('character', pos) 829 | range.moveStart('character', pos) 830 | range.select() 831 | } 832 | } 833 | }; 834 | var EmoteHelper = { 835 | legalEmotes: ['angry', 'arr', 'blink', 'blush', 'brucelee', 'btw', 'chuckle', 'clap', 'cool', 'drool', 'drunk', 'dry', 'eek', 'flex', 'happy', 'holmes', 'huh', 'laugh', 'lol', 'mad', 'mellow', 'noclue', 'oh', 'ohmy', 'panic', 'ph34r', 'pimp', 'punch', 'realmad', 'rock', 'rofl', 'rolleyes', 'sad', 'scratch', 'shifty', 'shock', 'shrug', 'sleep', 'sleeping', 'smile', 'suicide', 'sweat', 'thumbs', 'tongue', 'unsure', 'w00t', 'wacko', 'whistling', 'wink', 'worship', 'yucky'], 836 | 837 | emoteToImage: function(emote) { 838 | var result = emote; 839 | emote = emote.replace(/^:/, '').toLowerCase(); 840 | if (EmoteHelper.legalEmotes.find(function(v) { return v == emote })) { 841 | result = '#{description}'.interpolate({ emote: emote, description: emote }); 842 | } 843 | return result; 844 | }, 845 | 846 | insertEmotes: function(text) { 847 | var result = ''; 848 | $A(text.split(/(:[^ ]*)/)).each(function(segment) { 849 | if (segment && segment.match(/^:/)) { 850 | segment = EmoteHelper.emoteToImage(segment); 851 | } 852 | result += segment; 853 | }); 854 | return result; 855 | } 856 | }; 857 | JsChat.SignOnController = Class.create({ 858 | initialize: function() { 859 | this.retries = 0; 860 | setTimeout(function() { $('name').activate(); }, 500); 861 | $('sign-on').observe('submit', this.submitEvent.bindAsEventListener(this)); 862 | }, 863 | 864 | submitEvent: function(e) { 865 | this.signOn(); 866 | Event.stop(e); 867 | return false; 868 | }, 869 | 870 | showError: function(message) { 871 | $('feedback').innerHTML = '
    #{message}
    '.interpolate({ message: message }); 872 | $('feedback').show(); 873 | $('sign-on-submit').enable(); 874 | }, 875 | 876 | signOn: function() { 877 | $('loading').show(); 878 | $('sign-on-submit').disable(); 879 | this.retries += 1; 880 | 881 | new Ajax.Request('/identify', { 882 | parameters: $('sign-on').serialize(true), 883 | onSuccess: function(transport) { 884 | try { 885 | var json = transport.responseText.evalJSON(true); 886 | if (json['action'] == 'reload' && this.retries < 4) { 887 | setTimeout(function() { this.signOn() }.bind(this), 50); 888 | } else if (json['action'] == 'redirect') { 889 | if (window.location.toString().match(new RegExp(json['to'] + '$'))) { 890 | window.location.reload(); 891 | } else { 892 | window.location = json['to']; 893 | } 894 | } else if (json['error']) { 895 | this.showError(json['error']['message']); 896 | $('loading').hide(); 897 | } else { 898 | this.showError('Connection error'); 899 | } 900 | } catch (exception) { 901 | this.showError('Connection error: #{error}'.interpolate({ error: exception })); 902 | } 903 | }.bind(this), 904 | onFailure: function() { 905 | this.showError('Connection error'); 906 | }.bind(this), 907 | onComplete: function() { 908 | $('loading').hide(); 909 | } 910 | }); 911 | } 912 | }); 913 | JsChat.ChatController = Class.create({ 914 | initialize: function() { 915 | $('loading').show(); 916 | 917 | this.resizeEvent(); 918 | setTimeout(this.initDisplay.bind(this), 50); 919 | this.tabCompletion = new TabCompletion('message'); 920 | 921 | Event.observe(window, 'focus', this.focusEvent.bindAsEventListener(this)); 922 | Event.observe(window, 'blur', this.blurEvent.bindAsEventListener(this)); 923 | Event.observe(window, 'resize', this.resizeEvent.bindAsEventListener(this)); 924 | 925 | $('post_message').observe('submit', this.postMessageFormEvent.bindAsEventListener(this)); 926 | $('messages').observe('scroll', this.messagesScrolled.bindAsEventListener(this)); 927 | $$('#rooms li.join a').first().observe('click', this.joinRoomClicked.bindAsEventListener(this)); 928 | Event.observe(document, 'click', this.roomTabClick.bindAsEventListener(this)); 929 | this.allRecentMessages(); 930 | }, 931 | 932 | allRecentMessages: function() { 933 | new Ajax.Request('/room_update_times', { 934 | method: 'get', 935 | onComplete: function(request) { 936 | var times = request.responseText.evalJSON(); 937 | $H(this.lastUpdateTimes).each(function(data) { 938 | var room = data[0], 939 | time = data[1]; 940 | if (Date.parse(time) < Date.parse(times[room])) { 941 | this.roomTabAlert(room); 942 | } 943 | }.bind(this)); 944 | this.lastUpdateTimes = times; 945 | }.bind(this) 946 | }); 947 | }, 948 | 949 | roomTabAlert: function(room) { 950 | if (room === PageHelper.currentRoom()) return; 951 | 952 | $$('ul#rooms li a').each(function(roomLink) { 953 | if (roomLink.innerHTML === room) { 954 | roomLink.addClassName('new'); 955 | } 956 | }); 957 | }, 958 | 959 | clearRoomTabAlert: function(room) { 960 | $$('ul#rooms li a').each(function(roomLink) { 961 | if (roomLink.innerHTML === room) { 962 | roomLink.removeClassName('new'); 963 | } 964 | }); 965 | }, 966 | 967 | joinRoomClicked: function(e) { 968 | this.addRoomPrompt(e); 969 | Event.stop(e); 970 | return false; 971 | }, 972 | 973 | roomTabClick: function(e) { 974 | var element = Event.element(e); 975 | 976 | if (element.tagName == 'A' && element.up('#rooms') && !element.up('li').hasClassName('join')) { 977 | this.switchRoom(element.innerHTML); 978 | Event.stop(e); 979 | return false; 980 | } 981 | }, 982 | 983 | messagesScrolled: function() { 984 | Display.scrolled = (($('messages').scrollHeight - $('messages').scrollTop) > $('messages').getHeight()); 985 | }, 986 | 987 | focusEvent: function() { 988 | Display.unread = 0; 989 | Display.show_unread = false; 990 | document.title = PageHelper.title(); 991 | }, 992 | 993 | blurEvent: function() { 994 | Display.show_unread = true; 995 | }, 996 | 997 | resizeEvent: function() { 998 | var messageInset = PageHelper.isDevice('iphone') ? 390 : 290, 999 | heightInset = PageHelper.isDevice('iphone') ? 200 : 100, 1000 | windowSize = document.viewport.getDimensions(); 1001 | 1002 | if (PageHelper.isDevice('ipad')) { 1003 | messageInset = 330; 1004 | heightInset = 130; 1005 | } 1006 | 1007 | $('messages').setStyle({ width: windowSize.width - 220 + 'px' }); 1008 | $('messages').setStyle({ height: windowSize.height - heightInset + 'px' }); 1009 | $('message').setStyle({ width: windowSize.width - messageInset + 'px' }); 1010 | $('names').setStyle({ height: windowSize.height - 200 + 'px' }); 1011 | Display.scrollMessagesToTop(); 1012 | }, 1013 | 1014 | postMessageFormEvent: function(e) { 1015 | try { 1016 | var element = Event.element(e); 1017 | var message = $('message').value; 1018 | $('message').value = ''; 1019 | 1020 | this.tabCompletion.history.add(message); 1021 | 1022 | if (message.length > 0) { 1023 | var command_posted = $H(UserCommands).find(function(command) { 1024 | var name = command[0]; 1025 | var matches = message.match(new RegExp('^' + name + '$')); 1026 | if (matches) { 1027 | command[1].bind(this)(matches); 1028 | return true; 1029 | } 1030 | }.bind(this)); 1031 | 1032 | if (!command_posted) { 1033 | if (message.match(/^\/\s?\//)) { 1034 | this.postMessage(message.replace(/\//, '').strip()); 1035 | } else if (message.match(/^\//)) { 1036 | Display.add_message('Error: Command not found. Use /help display commands.', 'error'); 1037 | } else { 1038 | this.postMessage(message); 1039 | } 1040 | } 1041 | } 1042 | } catch (exception) { 1043 | console.log(exception); 1044 | } 1045 | 1046 | Event.stop(e); 1047 | return false; 1048 | }, 1049 | 1050 | postMessage: function(message) { 1051 | Display.message({ 'message': message.escapeHTML(), 'user': JsChat.user.name }, new Date()); 1052 | new Ajax.Request('/message', { 1053 | method: 'post', 1054 | parameters: { 'message': message, 'to': PageHelper.currentRoom() } 1055 | }); 1056 | }, 1057 | 1058 | sendTweet: function(message) { 1059 | new Ajax.Request('/tweet', { 1060 | method: 'post', 1061 | parameters: { 'tweet': message } 1062 | }); 1063 | }, 1064 | 1065 | initDisplay: function() { 1066 | Display.unread = 0; 1067 | Display.show_unread = false; 1068 | Display.ignore_notices = false; 1069 | 1070 | PageHelper.setCurrentRoomName(window.location.hash); 1071 | $('message').activate(); 1072 | $$('.header .navigation li').invoke('hide'); 1073 | $('quit-nav').show(); 1074 | $('help-nav').show(); 1075 | 1076 | $('help-link').observe('click', function(e) { 1077 | UserCommands['/help'](); 1078 | $('message').activate(); 1079 | Event.stop(e); 1080 | return false; 1081 | }); 1082 | 1083 | this.createPollers(); 1084 | this.getRoomList(this.addRoomAndCheckSelected); 1085 | this.joinRoom(PageHelper.currentRoom()); 1086 | }, 1087 | 1088 | getRoomList: function(callback) { 1089 | new Ajax.Request('/rooms', { 1090 | method: 'get', 1091 | parameters: { time: new Date().getTime() }, 1092 | onComplete: function(response) { 1093 | response.responseText.evalJSON().sort().each(function(roomName) { 1094 | try { 1095 | callback.apply(this, [roomName]); 1096 | } catch (exception) { 1097 | console.log(exception); 1098 | } 1099 | }.bind(this)); 1100 | }.bind(this) 1101 | }); 1102 | }, 1103 | 1104 | joinRoom: function(roomName) { 1105 | new Ajax.Request('/join', { 1106 | method: 'post', 1107 | parameters: { time: new Date().getTime(), room: roomName }, 1108 | onFailure: function() { 1109 | Display.add_message("Error: Couldn't join channel", 'server'); 1110 | $('loading').hide(); 1111 | }, 1112 | onComplete: function() { 1113 | // Make the server update the last polled time 1114 | JsChat.Request.get('/messages', function() {}); 1115 | document.title = PageHelper.title(); 1116 | UserCommands['/lastlog'].apply(this); 1117 | $('loading').hide(); 1118 | $('rooms').show(); 1119 | this.addRoomToNav(roomName, true); 1120 | }.bind(this) 1121 | }); 1122 | }, 1123 | 1124 | isValidRoom: function(roomName) { 1125 | if (PageHelper.allRoomNames().include(roomName)) { 1126 | return false; 1127 | } 1128 | return true; 1129 | }, 1130 | 1131 | validateAndJoinRoom: function(roomName) { 1132 | if (roomName === null || roomName.length == 0) { 1133 | return; 1134 | } 1135 | 1136 | if (!roomName.match(/^#/)) { 1137 | roomName = '#' + roomName; 1138 | } 1139 | 1140 | if (this.isValidRoom(roomName)) { 1141 | this.joinRoomInTab(roomName); 1142 | } 1143 | }, 1144 | 1145 | addRoomPrompt: function() { 1146 | var roomName = prompt('Enter a room name:'); 1147 | this.validateAndJoinRoom(roomName); 1148 | }, 1149 | 1150 | addRoomToNav: function(roomName, selected) { 1151 | if (PageHelper.allRoomNames().include(roomName)) return; 1152 | 1153 | var classAttribute = selected ? ' class="selected"' : ''; 1154 | $('rooms').insert({ bottom: '#{roomName}'.interpolate({ classAttribute: classAttribute, roomName: roomName }) }); 1155 | }, 1156 | 1157 | addRoomAndCheckSelected: function(roomName) { 1158 | this.addRoomToNav(roomName, PageHelper.currentRoom() == roomName); 1159 | }, 1160 | 1161 | removeSelectedTab: function() { 1162 | $$('#rooms .selected').invoke('removeClassName', 'selected'); 1163 | }, 1164 | 1165 | selectRoomTab: function(roomName) { 1166 | $$('#rooms a').each(function(a) { 1167 | if (a.innerHTML == roomName) { 1168 | a.up('li').addClassName('selected'); 1169 | } 1170 | }); 1171 | }, 1172 | 1173 | joinRoomInTab: function(roomName) { 1174 | this.removeSelectedTab(); 1175 | PageHelper.setCurrentRoomName(roomName); 1176 | this.joinRoom(roomName); 1177 | $('message').focus(); 1178 | }, 1179 | 1180 | switchRoom: function(roomName) { 1181 | if (PageHelper.currentRoom() == roomName) { 1182 | return; 1183 | } 1184 | 1185 | this.removeSelectedTab(); 1186 | this.selectRoomTab(roomName); 1187 | PageHelper.setCurrentRoomName(roomName); 1188 | UserCommands['/lastlog'].apply(this); 1189 | this.clearRoomTabAlert(roomName); 1190 | $('message').focus(); 1191 | }, 1192 | 1193 | rooms: function() { 1194 | return $$('#rooms li a').slice(1).collect(function(element) { 1195 | return element.innerHTML; 1196 | }); 1197 | }, 1198 | 1199 | partRoom: function(roomName) { 1200 | if (this.rooms().length == 1) { 1201 | return UserCommands['/quit'](); 1202 | } 1203 | 1204 | new Ajax.Request('/part', { 1205 | method: 'get', 1206 | parameters: { room: roomName }, 1207 | onSuccess: function(request) { 1208 | this.removeTab(roomName); 1209 | }.bind(this), 1210 | onFailure: function(request) { 1211 | Display.add_message('Error: ' + request.responseText, 'server'); 1212 | } 1213 | }); 1214 | }, 1215 | 1216 | removeTab: function(roomName) { 1217 | $$('#rooms li').each(function(element) { 1218 | if (element.down('a').innerHTML == roomName) { 1219 | element.remove(); 1220 | 1221 | if (roomName == PageHelper.currentRoom()) { 1222 | this.switchRoom($$('#rooms li a')[1].innerHTML); 1223 | } 1224 | } 1225 | }.bind(this)); 1226 | }, 1227 | 1228 | updateNames: function() { 1229 | JsChat.Request.get('/names', function(t) { this.displayMessages(t.responseText); }.bind(this)); 1230 | }, 1231 | 1232 | showMessagesResponse: function(transport) { 1233 | try { 1234 | this.displayMessages(transport.responseText); 1235 | 1236 | if ($$('#messages li').length > 1000) { 1237 | $$('#messages li').slice(0, 500).invoke('remove'); 1238 | } 1239 | } catch (exception) { 1240 | console.log(transport.responseText); 1241 | console.log(exception); 1242 | } 1243 | }, 1244 | 1245 | updateMessages: function() { 1246 | if (this.pausePollers) { 1247 | return; 1248 | } 1249 | 1250 | new Ajax.Request('/messages', { 1251 | method: 'get', 1252 | parameters: { time: new Date().getTime(), room: PageHelper.currentRoom() }, 1253 | onSuccess: function(transport) { 1254 | this.showMessagesResponse(transport); 1255 | }.bind(this), 1256 | onFailure: function(request) { 1257 | this.stopPolling(); 1258 | Display.add_message('Server error: please reconnect'.interpolate({ room: PageHelper.currentRoom() }), 'server'); 1259 | }.bind(this) 1260 | }); 1261 | }, 1262 | 1263 | displayMessages: function(text, successCallback) { 1264 | var json_set = text.evalJSON(true); 1265 | if (json_set.length == 0) { 1266 | return; 1267 | } 1268 | json_set.each(function(json) { 1269 | try { 1270 | if (json['change']) { 1271 | Change[json['change']](json[json['change']], json['time']); 1272 | } else { 1273 | Display[json['display']](json[json['display']], json['time']); 1274 | if (json['display'] !== 'error' && typeof successCallback !== 'undefined') { 1275 | successCallback(); 1276 | } 1277 | } 1278 | } catch (exception) { 1279 | } 1280 | }); 1281 | }, 1282 | 1283 | checkIdleNames: function() { 1284 | $$('#names li').each(function(element) { 1285 | if (Display.isIdle(element.lastIdle)) { 1286 | element.addClassName('idle'); 1287 | } 1288 | }); 1289 | }, 1290 | 1291 | stopPolling: function() { 1292 | this.pollers.invoke('stop'); 1293 | }, 1294 | 1295 | firePollers: function() { 1296 | this.pollers.invoke('execute'); 1297 | }, 1298 | 1299 | createPollers: function() { 1300 | this.pollers = $A(); 1301 | this.pollers.push(new PeriodicalExecuter(this.updateMessages.bind(this), 3)); 1302 | this.pollers.push(new PeriodicalExecuter(this.checkIdleNames.bind(this), 5)); 1303 | this.pollers.push(new PeriodicalExecuter(this.allRecentMessages.bind(this), 10)); 1304 | } 1305 | }); 1306 | -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/app/controllers/chat_controller.js: -------------------------------------------------------------------------------- 1 | JsChat.ChatController = Class.create({ 2 | initialize: function() { 3 | $('loading').show(); 4 | 5 | this.resizeEvent(); 6 | setTimeout(this.initDisplay.bind(this), 50); 7 | this.tabCompletion = new TabCompletion('message'); 8 | 9 | Event.observe(window, 'focus', this.focusEvent.bindAsEventListener(this)); 10 | Event.observe(window, 'blur', this.blurEvent.bindAsEventListener(this)); 11 | Event.observe(window, 'resize', this.resizeEvent.bindAsEventListener(this)); 12 | 13 | $('post_message').observe('submit', this.postMessageFormEvent.bindAsEventListener(this)); 14 | $('messages').observe('scroll', this.messagesScrolled.bindAsEventListener(this)); 15 | $$('#rooms li.join a').first().observe('click', this.joinRoomClicked.bindAsEventListener(this)); 16 | Event.observe(document, 'click', this.roomTabClick.bindAsEventListener(this)); 17 | this.allRecentMessages(); 18 | }, 19 | 20 | allRecentMessages: function() { 21 | new Ajax.Request('/room_update_times', { 22 | method: 'get', 23 | onComplete: function(request) { 24 | var times = request.responseText.evalJSON(); 25 | $H(this.lastUpdateTimes).each(function(data) { 26 | var room = data[0], 27 | time = data[1]; 28 | if (Date.parse(time) < Date.parse(times[room])) { 29 | this.roomTabAlert(room); 30 | } 31 | }.bind(this)); 32 | this.lastUpdateTimes = times; 33 | }.bind(this) 34 | }); 35 | }, 36 | 37 | roomTabAlert: function(room) { 38 | if (room === PageHelper.currentRoom()) return; 39 | 40 | $$('ul#rooms li a').each(function(roomLink) { 41 | if (roomLink.innerHTML === room) { 42 | roomLink.addClassName('new'); 43 | } 44 | }); 45 | }, 46 | 47 | clearRoomTabAlert: function(room) { 48 | $$('ul#rooms li a').each(function(roomLink) { 49 | if (roomLink.innerHTML === room) { 50 | roomLink.removeClassName('new'); 51 | } 52 | }); 53 | }, 54 | 55 | joinRoomClicked: function(e) { 56 | this.addRoomPrompt(e); 57 | Event.stop(e); 58 | return false; 59 | }, 60 | 61 | roomTabClick: function(e) { 62 | var element = Event.element(e); 63 | 64 | if (element.tagName == 'A' && element.up('#rooms') && !element.up('li').hasClassName('join')) { 65 | this.switchRoom(element.innerHTML); 66 | Event.stop(e); 67 | return false; 68 | } 69 | }, 70 | 71 | messagesScrolled: function() { 72 | Display.scrolled = (($('messages').scrollHeight - $('messages').scrollTop) > $('messages').getHeight()); 73 | }, 74 | 75 | focusEvent: function() { 76 | Display.unread = 0; 77 | Display.show_unread = false; 78 | document.title = PageHelper.title(); 79 | }, 80 | 81 | blurEvent: function() { 82 | Display.show_unread = true; 83 | }, 84 | 85 | resizeEvent: function() { 86 | var messageInset = PageHelper.isDevice('iphone') ? 390 : 290, 87 | heightInset = PageHelper.isDevice('iphone') ? 200 : 100, 88 | windowSize = document.viewport.getDimensions(); 89 | 90 | if (PageHelper.isDevice('ipad')) { 91 | messageInset = 330; 92 | heightInset = 130; 93 | } 94 | 95 | $('messages').setStyle({ width: windowSize.width - 220 + 'px' }); 96 | $('messages').setStyle({ height: windowSize.height - heightInset + 'px' }); 97 | $('message').setStyle({ width: windowSize.width - messageInset + 'px' }); 98 | $('names').setStyle({ height: windowSize.height - 200 + 'px' }); 99 | Display.scrollMessagesToTop(); 100 | }, 101 | 102 | postMessageFormEvent: function(e) { 103 | try { 104 | var element = Event.element(e); 105 | var message = $('message').value; 106 | $('message').value = ''; 107 | 108 | this.tabCompletion.history.add(message); 109 | 110 | if (message.length > 0) { 111 | var command_posted = $H(UserCommands).find(function(command) { 112 | var name = command[0]; 113 | var matches = message.match(new RegExp('^' + name + '$')); 114 | if (matches) { 115 | command[1].bind(this)(matches); 116 | return true; 117 | } 118 | }.bind(this)); 119 | 120 | if (!command_posted) { 121 | if (message.match(/^\/\s?\//)) { 122 | this.postMessage(message.replace(/\//, '').strip()); 123 | } else if (message.match(/^\//)) { 124 | Display.add_message('Error: Command not found. Use /help display commands.', 'error'); 125 | } else { 126 | this.postMessage(message); 127 | } 128 | } 129 | } 130 | } catch (exception) { 131 | console.log(exception); 132 | } 133 | 134 | Event.stop(e); 135 | return false; 136 | }, 137 | 138 | postMessage: function(message) { 139 | Display.message({ 'message': message.escapeHTML(), 'user': JsChat.user.name }, new Date()); 140 | new Ajax.Request('/message', { 141 | method: 'post', 142 | parameters: { 'message': message, 'to': PageHelper.currentRoom() } 143 | }); 144 | }, 145 | 146 | sendTweet: function(message) { 147 | new Ajax.Request('/tweet', { 148 | method: 'post', 149 | parameters: { 'tweet': message } 150 | }); 151 | }, 152 | 153 | initDisplay: function() { 154 | Display.unread = 0; 155 | Display.show_unread = false; 156 | Display.ignore_notices = false; 157 | 158 | PageHelper.setCurrentRoomName(window.location.hash); 159 | $('message').activate(); 160 | $$('.header .navigation li').invoke('hide'); 161 | $('quit-nav').show(); 162 | $('help-nav').show(); 163 | 164 | $('help-link').observe('click', function(e) { 165 | UserCommands['/help'](); 166 | $('message').activate(); 167 | Event.stop(e); 168 | return false; 169 | }); 170 | 171 | this.createPollers(); 172 | this.getRoomList(this.addRoomAndCheckSelected); 173 | this.joinRoom(PageHelper.currentRoom()); 174 | }, 175 | 176 | getRoomList: function(callback) { 177 | new Ajax.Request('/rooms', { 178 | method: 'get', 179 | parameters: { time: new Date().getTime() }, 180 | onComplete: function(response) { 181 | response.responseText.evalJSON().sort().each(function(roomName) { 182 | try { 183 | callback.apply(this, [roomName]); 184 | } catch (exception) { 185 | console.log(exception); 186 | } 187 | }.bind(this)); 188 | }.bind(this) 189 | }); 190 | }, 191 | 192 | joinRoom: function(roomName) { 193 | new Ajax.Request('/join', { 194 | method: 'post', 195 | parameters: { time: new Date().getTime(), room: roomName }, 196 | onFailure: function() { 197 | Display.add_message("Error: Couldn't join channel", 'server'); 198 | $('loading').hide(); 199 | }, 200 | onComplete: function() { 201 | // Make the server update the last polled time 202 | JsChat.Request.get('/messages', function() {}); 203 | document.title = PageHelper.title(); 204 | UserCommands['/lastlog'].apply(this); 205 | $('loading').hide(); 206 | $('rooms').show(); 207 | this.addRoomToNav(roomName, true); 208 | }.bind(this) 209 | }); 210 | }, 211 | 212 | isValidRoom: function(roomName) { 213 | if (PageHelper.allRoomNames().include(roomName)) { 214 | return false; 215 | } 216 | return true; 217 | }, 218 | 219 | validateAndJoinRoom: function(roomName) { 220 | if (roomName === null || roomName.length == 0) { 221 | return; 222 | } 223 | 224 | if (!roomName.match(/^#/)) { 225 | roomName = '#' + roomName; 226 | } 227 | 228 | if (this.isValidRoom(roomName)) { 229 | this.joinRoomInTab(roomName); 230 | } 231 | }, 232 | 233 | addRoomPrompt: function() { 234 | var roomName = prompt('Enter a room name:'); 235 | this.validateAndJoinRoom(roomName); 236 | }, 237 | 238 | addRoomToNav: function(roomName, selected) { 239 | if (PageHelper.allRoomNames().include(roomName)) return; 240 | 241 | var classAttribute = selected ? ' class="selected"' : ''; 242 | $('rooms').insert({ bottom: '#{roomName}'.interpolate({ classAttribute: classAttribute, roomName: roomName }) }); 243 | }, 244 | 245 | addRoomAndCheckSelected: function(roomName) { 246 | this.addRoomToNav(roomName, PageHelper.currentRoom() == roomName); 247 | }, 248 | 249 | removeSelectedTab: function() { 250 | $$('#rooms .selected').invoke('removeClassName', 'selected'); 251 | }, 252 | 253 | selectRoomTab: function(roomName) { 254 | $$('#rooms a').each(function(a) { 255 | if (a.innerHTML == roomName) { 256 | a.up('li').addClassName('selected'); 257 | } 258 | }); 259 | }, 260 | 261 | joinRoomInTab: function(roomName) { 262 | this.removeSelectedTab(); 263 | PageHelper.setCurrentRoomName(roomName); 264 | this.joinRoom(roomName); 265 | $('message').focus(); 266 | }, 267 | 268 | switchRoom: function(roomName) { 269 | if (PageHelper.currentRoom() == roomName) { 270 | return; 271 | } 272 | 273 | this.removeSelectedTab(); 274 | this.selectRoomTab(roomName); 275 | PageHelper.setCurrentRoomName(roomName); 276 | UserCommands['/lastlog'].apply(this); 277 | this.clearRoomTabAlert(roomName); 278 | $('message').focus(); 279 | }, 280 | 281 | rooms: function() { 282 | return $$('#rooms li a').slice(1).collect(function(element) { 283 | return element.innerHTML; 284 | }); 285 | }, 286 | 287 | partRoom: function(roomName) { 288 | if (this.rooms().length == 1) { 289 | return UserCommands['/quit'](); 290 | } 291 | 292 | new Ajax.Request('/part', { 293 | method: 'get', 294 | parameters: { room: roomName }, 295 | onSuccess: function(request) { 296 | this.removeTab(roomName); 297 | }.bind(this), 298 | onFailure: function(request) { 299 | Display.add_message('Error: ' + request.responseText, 'server'); 300 | } 301 | }); 302 | }, 303 | 304 | removeTab: function(roomName) { 305 | $$('#rooms li').each(function(element) { 306 | if (element.down('a').innerHTML == roomName) { 307 | element.remove(); 308 | 309 | if (roomName == PageHelper.currentRoom()) { 310 | this.switchRoom($$('#rooms li a')[1].innerHTML); 311 | } 312 | } 313 | }.bind(this)); 314 | }, 315 | 316 | updateNames: function() { 317 | JsChat.Request.get('/names', function(t) { this.displayMessages(t.responseText); }.bind(this)); 318 | }, 319 | 320 | showMessagesResponse: function(transport) { 321 | try { 322 | this.displayMessages(transport.responseText); 323 | 324 | if ($$('#messages li').length > 1000) { 325 | $$('#messages li').slice(0, 500).invoke('remove'); 326 | } 327 | } catch (exception) { 328 | console.log(transport.responseText); 329 | console.log(exception); 330 | } 331 | }, 332 | 333 | updateMessages: function() { 334 | if (this.pausePollers) { 335 | return; 336 | } 337 | 338 | new Ajax.Request('/messages', { 339 | method: 'get', 340 | parameters: { time: new Date().getTime(), room: PageHelper.currentRoom() }, 341 | onSuccess: function(transport) { 342 | this.showMessagesResponse(transport); 343 | }.bind(this), 344 | onFailure: function(request) { 345 | this.stopPolling(); 346 | Display.add_message('Server error: please reconnect'.interpolate({ room: PageHelper.currentRoom() }), 'server'); 347 | }.bind(this) 348 | }); 349 | }, 350 | 351 | displayMessages: function(text, successCallback) { 352 | var json_set = text.evalJSON(true); 353 | if (json_set.length == 0) { 354 | return; 355 | } 356 | json_set.each(function(json) { 357 | try { 358 | if (json['change']) { 359 | Change[json['change']](json[json['change']], json['time']); 360 | } else { 361 | Display[json['display']](json[json['display']], json['time']); 362 | if (json['display'] !== 'error' && typeof successCallback !== 'undefined') { 363 | successCallback(); 364 | } 365 | } 366 | } catch (exception) { 367 | } 368 | }); 369 | }, 370 | 371 | checkIdleNames: function() { 372 | $$('#names li').each(function(element) { 373 | if (Display.isIdle(element.lastIdle)) { 374 | element.addClassName('idle'); 375 | } 376 | }); 377 | }, 378 | 379 | stopPolling: function() { 380 | this.pollers.invoke('stop'); 381 | }, 382 | 383 | firePollers: function() { 384 | this.pollers.invoke('execute'); 385 | }, 386 | 387 | createPollers: function() { 388 | this.pollers = $A(); 389 | this.pollers.push(new PeriodicalExecuter(this.updateMessages.bind(this), 3)); 390 | this.pollers.push(new PeriodicalExecuter(this.checkIdleNames.bind(this), 5)); 391 | this.pollers.push(new PeriodicalExecuter(this.allRecentMessages.bind(this), 10)); 392 | } 393 | }); 394 | -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/app/controllers/signon_controller.js: -------------------------------------------------------------------------------- 1 | JsChat.SignOnController = Class.create({ 2 | initialize: function() { 3 | this.retries = 0; 4 | setTimeout(function() { $('name').activate(); }, 500); 5 | $('sign-on').observe('submit', this.submitEvent.bindAsEventListener(this)); 6 | }, 7 | 8 | submitEvent: function(e) { 9 | this.signOn(); 10 | Event.stop(e); 11 | return false; 12 | }, 13 | 14 | showError: function(message) { 15 | $('feedback').innerHTML = '
    #{message}
    '.interpolate({ message: message }); 16 | $('feedback').show(); 17 | $('sign-on-submit').enable(); 18 | }, 19 | 20 | signOn: function() { 21 | $('loading').show(); 22 | $('sign-on-submit').disable(); 23 | this.retries += 1; 24 | 25 | new Ajax.Request('/identify', { 26 | parameters: $('sign-on').serialize(true), 27 | onSuccess: function(transport) { 28 | try { 29 | var json = transport.responseText.evalJSON(true); 30 | if (json['action'] == 'reload' && this.retries < 4) { 31 | setTimeout(function() { this.signOn() }.bind(this), 50); 32 | } else if (json['action'] == 'redirect') { 33 | if (window.location.toString().match(new RegExp(json['to'] + '$'))) { 34 | window.location.reload(); 35 | } else { 36 | window.location = json['to']; 37 | } 38 | } else if (json['error']) { 39 | this.showError(json['error']['message']); 40 | $('loading').hide(); 41 | } else { 42 | this.showError('Connection error'); 43 | } 44 | } catch (exception) { 45 | this.showError('Connection error: #{error}'.interpolate({ error: exception })); 46 | } 47 | }.bind(this), 48 | onFailure: function() { 49 | this.showError('Connection error'); 50 | }.bind(this), 51 | onComplete: function() { 52 | $('loading').hide(); 53 | } 54 | }); 55 | } 56 | }); 57 | -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/app/helpers/emote_helper.js: -------------------------------------------------------------------------------- 1 | var EmoteHelper = { 2 | legalEmotes: ['angry', 'arr', 'blink', 'blush', 'brucelee', 'btw', 'chuckle', 'clap', 'cool', 'drool', 'drunk', 'dry', 'eek', 'flex', 'happy', 'holmes', 'huh', 'laugh', 'lol', 'mad', 'mellow', 'noclue', 'oh', 'ohmy', 'panic', 'ph34r', 'pimp', 'punch', 'realmad', 'rock', 'rofl', 'rolleyes', 'sad', 'scratch', 'shifty', 'shock', 'shrug', 'sleep', 'sleeping', 'smile', 'suicide', 'sweat', 'thumbs', 'tongue', 'unsure', 'w00t', 'wacko', 'whistling', 'wink', 'worship', 'yucky'], 3 | 4 | emoteToImage: function(emote) { 5 | var result = emote; 6 | emote = emote.replace(/^:/, '').toLowerCase(); 7 | if (EmoteHelper.legalEmotes.find(function(v) { return v == emote })) { 8 | result = '#{description}'.interpolate({ emote: emote, description: emote }); 9 | } 10 | return result; 11 | }, 12 | 13 | insertEmotes: function(text) { 14 | var result = ''; 15 | $A(text.split(/(:[^ ]*)/)).each(function(segment) { 16 | if (segment && segment.match(/^:/)) { 17 | segment = EmoteHelper.emoteToImage(segment); 18 | } 19 | result += segment; 20 | }); 21 | return result; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/app/helpers/form_helpers.js: -------------------------------------------------------------------------------- 1 | var FormHelpers = { 2 | getCaretPosition: function(element) { 3 | if (element.setSelectionRange) { 4 | return element.selectionStart; 5 | } else if (element.createTextRange) { 6 | try { 7 | // The current selection 8 | var range = document.selection.createRange(); 9 | // We'll use this as a 'dummy' 10 | var stored_range = range.duplicate(); 11 | // Select all text 12 | stored_range.moveToElementText(element); 13 | // Now move 'dummy' end point to end point of original range 14 | stored_range.setEndPoint('EndToEnd', range); 15 | 16 | return stored_range.text.length - range.text.length; 17 | } catch (exception) { 18 | // IE is being mental. TODO: Figure out what IE's issue is 19 | return 0; 20 | } 21 | } 22 | }, 23 | 24 | setCaretPosition: function(element, pos) { 25 | if (element.setSelectionRange) { 26 | element.focus() 27 | element.setSelectionRange(pos, pos) 28 | } else if (element.createTextRange) { 29 | var range = element.createTextRange() 30 | 31 | range.collapse(true) 32 | range.moveEnd('character', pos) 33 | range.moveStart('character', pos) 34 | range.select() 35 | } 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/app/helpers/link_helper.js: -------------------------------------------------------------------------------- 1 | var LinkHelper = { 2 | url: function(url) { 3 | return url.match(/(https?:\/\/[^\s]*)/gi); 4 | }, 5 | 6 | link: function(url) { 7 | return '\#{link_name}'.interpolate({ url: url, link_name: url}); 8 | }, 9 | 10 | image_url: function(url) { 11 | return url.match(/\.(jpe?g|png|gif)/i); 12 | }, 13 | 14 | image: function(url) { 15 | return ''.interpolate({ url: url, image: url }) 16 | }, 17 | 18 | twitpic_url: function(url) { 19 | return url.match(/\bhttp:\/\/twitpic.com\/(show|[^\s]*)\b/i); 20 | }, 21 | 22 | twitpic: function(url) { 23 | var twitpic_id = url.split('/').last(); 24 | return ''.interpolate({ twitpic_id: twitpic_id, url: url }) 25 | }, 26 | 27 | youtube_url: function(url) { 28 | return url.match(/youtube\.com/) && url.match(/watch\?v/); 29 | }, 30 | 31 | youtube: function(url) { 32 | var youtube_url_id = url.match(/\?v=([^&\s]*)/); 33 | if (youtube_url_id && youtube_url_id[1]) { 34 | var youtube_url = 'http://www.youtube.com/v/' + youtube_url_id[1]; 35 | var youtube_html = ''; 36 | return youtube_html.interpolate({ movie_url: youtube_url, url: youtube_url }); 37 | } else { 38 | return this.link(url); 39 | } 40 | }, 41 | 42 | vimeo_url: function(url) { 43 | return url.match(/vimeo\.com/) && url.match(/\/\d+/); 44 | }, 45 | 46 | vimeo: function(url) { 47 | var vimeo_url_id = url.match(/\d+/); 48 | if (vimeo_url_id) { 49 | var vimeo_url = 'http://vimeo.com/' + vimeo_url_id; 50 | var vimeo_html = ''; 51 | return vimeo_html.interpolate({ movie_url: vimeo_url, url: vimeo_url }); 52 | } else { 53 | return this.link(url); 54 | } 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/app/helpers/page_helper.js: -------------------------------------------------------------------------------- 1 | var PageHelper = { 2 | currentRoom: function() { 3 | return window.location.hash; 4 | }, 5 | 6 | setCurrentRoomName: function(roomName) { 7 | window.location.hash = roomName; 8 | $('room-name').innerHTML = TextHelper.truncateRoomName(PageHelper.currentRoom()); 9 | $('room-name').title = PageHelper.currentRoom(); 10 | document.title = PageHelper.title(); 11 | }, 12 | 13 | allRoomNames: function() { 14 | return $$('#rooms li a').collect(function(link) { 15 | return link.innerHTML; 16 | }); 17 | }, 18 | 19 | nickname: function() { 20 | return JsChat.user.name; 21 | }, 22 | 23 | title: function() { 24 | if (PageHelper.currentRoom()) { 25 | return 'JsChat: ' + PageHelper.currentRoom(); 26 | } else { 27 | return 'JsChat'; 28 | } 29 | }, 30 | 31 | device: function() { 32 | if ($$('body.iphone').length > 0) { 33 | return 'iphone'; 34 | } else if ($$('body.ipad').length > 0) { 35 | return 'ipad'; 36 | } 37 | }, 38 | 39 | isDevice: function(device) { 40 | return PageHelper.device() == device; 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/app/helpers/text_helper.js: -------------------------------------------------------------------------------- 1 | var TextHelper = { 2 | zeroPad: function(value, length) { 3 | value = value.toString(); 4 | if (value.length >= length) { 5 | return value; 6 | } else { 7 | return this.zeroPad('0' + value, length); 8 | } 9 | }, 10 | 11 | dateText: function(time) { 12 | var d = new Date(); 13 | if (typeof time != 'undefined') { 14 | d = new Date(Date.parse(time)); 15 | } 16 | return this.zeroPad(d.getHours(), 2) + ':' + this.zeroPad(d.getMinutes(), 2); 17 | }, 18 | 19 | truncateName: function(text) { 20 | return text.truncate(15); 21 | }, 22 | 23 | truncateRoomName: function(text) { 24 | return text.truncate(15); 25 | }, 26 | 27 | decorateMessage: function(text) { 28 | return EmoteHelper.insertEmotes(this.autoLink(this.textilize(text))); 29 | }, 30 | 31 | textilize: function(text) { 32 | function escape_regex(text) { return text.replace(/([\*\?\+\^\?])/g, "\\$1"); } 33 | function openTag(text) { return '<' + text + '>'; } 34 | function closeTag(text) { return ''; } 35 | 36 | var map = { '_': 'em', '*': 'strong' }; 37 | 38 | $H(map).each(function(mapping) { 39 | var result = ''; 40 | var m = escape_regex(mapping[0]); 41 | var mr = new RegExp('(' + m + ')'); 42 | var matcher = new RegExp('(^|\\s+)(' + m + ')([^\\s][^' + mapping[0] + ']*[^\\s])(' + m + ')', 'g'); 43 | 44 | if (text.match(matcher)) { 45 | var open = false; 46 | text.split(matcher).each(function(segment) { 47 | if (segment == mapping[0]) { 48 | var tag = open ? closeTag(mapping[1]) : openTag(mapping[1]); 49 | result += segment.replace(mr, tag); 50 | open = !open; 51 | } else { 52 | result += segment; 53 | } 54 | }); 55 | 56 | if (open) result += closeTag(mapping[1]); 57 | text = result; 58 | } 59 | }); 60 | 61 | return text; 62 | }, 63 | 64 | autoLink: function(text) { 65 | var result = ''; 66 | try { 67 | if (!LinkHelper.url(text)) { 68 | return text; 69 | } 70 | 71 | $A(text.split(/(https?:\/\/[^\s]*)/gi)).each(function(link) { 72 | if (link.match(/href="/)) { 73 | result += link; 74 | } else { 75 | if (LinkHelper.youtube_url(link) && !JsChat.user.hideImages) { 76 | result += link.replace(link, LinkHelper.youtube(link)); 77 | } else if (LinkHelper.vimeo_url(link) && !JsChat.user.hideImages) { 78 | result += link.replace(link, LinkHelper.vimeo(link)); 79 | } else if (LinkHelper.image_url(link) && !JsChat.user.hideImages) { 80 | result += link.replace(link, LinkHelper.image(link)); 81 | } else if (LinkHelper.twitpic_url(link) && !JsChat.user.hideImages) { 82 | result += link.replace(link, LinkHelper.twitpic(link)); 83 | } else if (LinkHelper.url(link)) { 84 | result += link.replace(link, LinkHelper.link(link)); 85 | } else { 86 | result += link; 87 | } 88 | } 89 | }); 90 | } catch (exception) { 91 | } 92 | return result; 93 | } 94 | }; 95 | -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/app/lib/split.js: -------------------------------------------------------------------------------- 1 | /* 2 | Cross-Browser Split 0.3 3 | By Steven Levithan 4 | MIT license 5 | Provides a consistent cross-browser, ECMA-262 v3 compliant split method 6 | */ 7 | 8 | String.prototype._$$split = String.prototype._$$split || String.prototype.split; 9 | 10 | String.prototype.split = function (s /* separator */, limit) { 11 | // if separator is not a regex, use the native split method 12 | if (!(s instanceof RegExp)) 13 | return String.prototype._$$split.apply(this, arguments); 14 | 15 | var flags = (s.global ? "g" : "") + (s.ignoreCase ? "i" : "") + (s.multiline ? "m" : ""), 16 | s2 = new RegExp("^" + s.source + "$", flags), 17 | output = [], 18 | origLastIndex = s.lastIndex, 19 | lastLastIndex = 0, 20 | i = 0, match, lastLength; 21 | 22 | /* behavior for limit: if it's... 23 | - undefined: no limit 24 | - NaN or zero: return an empty array 25 | - a positive number: use limit after dropping any decimal 26 | - a negative number: no limit 27 | - other: type-convert, then use the above rules 28 | */ 29 | if (limit === undefined || +limit < 0) { 30 | limit = false; 31 | } else { 32 | limit = Math.floor(+limit); 33 | if (!limit) 34 | return []; 35 | } 36 | 37 | if (s.global) 38 | s.lastIndex = 0; 39 | else 40 | s = new RegExp(s.source, "g" + flags); 41 | 42 | while ((!limit || i++ <= limit) && (match = s.exec(this))) { 43 | var emptyMatch = !match[0].length; 44 | 45 | // Fix IE's infinite-loop-resistant but incorrect lastIndex 46 | if (emptyMatch && s.lastIndex > match.index) 47 | s.lastIndex--; 48 | 49 | if (s.lastIndex > lastLastIndex) { 50 | // Fix browsers whose exec methods don't consistently return undefined for non-participating capturing groups 51 | if (match.length > 1) { 52 | match[0].replace(s2, function () { 53 | for (var j = 1; j < arguments.length - 2; j++) { 54 | if (arguments[j] === undefined) 55 | match[j] = undefined; 56 | } 57 | }); 58 | } 59 | 60 | output = output.concat(this.slice(lastLastIndex, match.index)); 61 | if (1 < match.length && match.index < this.length) 62 | output = output.concat(match.slice(1)); 63 | lastLength = match[0].length; // only needed if s.lastIndex === this.length 64 | lastLastIndex = s.lastIndex; 65 | } 66 | 67 | if (emptyMatch) 68 | s.lastIndex++; // avoid an infinite loop 69 | } 70 | 71 | // since this uses test(), output must be generated before restoring lastIndex 72 | output = lastLastIndex === this.length ? 73 | (s.test("") && !lastLength ? output : output.concat("")) : 74 | (limit ? output : output.concat(this.slice(lastLastIndex))); 75 | s.lastIndex = origLastIndex; // only needed if s.global, else we're working with a copy of the regex 76 | return output; 77 | }; 78 | 79 | -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/app/models/cookie.js: -------------------------------------------------------------------------------- 1 | Cookie = { 2 | create: function(name, value, days, path) { 3 | var expires = ''; 4 | path = typeof path == 'undefined' ? '/' : path; 5 | 6 | if (days) { 7 | var date = new Date(); 8 | date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); 9 | expires = "; expires=" + date.toGMTString(); 10 | } 11 | 12 | if (name && value) { 13 | document.cookie = name + '=' + escape(value) + expires + ';path=' + path; 14 | } 15 | }, 16 | 17 | find: function(name) { 18 | var matches = document.cookie.match(name + '=([^;]*)'); 19 | if (matches && matches.length == 2) { 20 | return unescape(matches[1]); 21 | } 22 | }, 23 | 24 | destroy: function(name) { 25 | this.create(name, ' ', -1); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/app/models/user.js: -------------------------------------------------------------------------------- 1 | User = function() { 2 | this.name = Cookie.find('jschat-name'); 3 | this.hideImages = Cookie.find('jschat-hideImages') === '1' ? true : false; 4 | }; 5 | 6 | User.prototype.setName = function(name) { 7 | Cookie.create('jschat-name', name, 28, '/'); 8 | this.name = name; 9 | }; 10 | 11 | User.prototype.setHideImages = function(hideImages) { 12 | this.hideImages = hideImages; 13 | Cookie.create('jschat-hideImages', (hideImages ? '1' : '0'), 28, '/'); 14 | }; 15 | -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/app/protocol/change.js: -------------------------------------------------------------------------------- 1 | var Change = { 2 | user: function(user, time) { 3 | if (user['name']) { 4 | change = $H(user['name']).toArray()[0]; 5 | var old = change[0], 6 | new_value = change[1]; 7 | if (new_value !== PageHelper.nickname()) { 8 | Display.add_message("#{old} is now known as #{new_value}".interpolate({ old: old, new_value: new_value }), 'server', time); 9 | } 10 | $$('#names li').each(function(element) { 11 | if (element.innerHTML == old) element.innerHTML = new_value; 12 | }); 13 | } 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/app/protocol/chat_request.js: -------------------------------------------------------------------------------- 1 | /* FIXME: Later on this should be a class */ 2 | JsChat.Request = { 3 | get: function(url, callback) { 4 | new Ajax.Request(url, { 5 | method: 'get', 6 | parameters: { time: new Date().getTime(), room: PageHelper.currentRoom() }, 7 | onFailure: function() { 8 | Display.add_message("Server error: couldn't access: #{url}".interpolate({ url: url }), 'server'); 9 | }, 10 | onComplete: function(transport) { return callback(transport); } 11 | }); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/app/protocol/display.js: -------------------------------------------------------------------------------- 1 | var Display = { 2 | scrolled: false, 3 | 4 | add_message: function(text, className, time) { 5 | var time_html = '\#{time}'.interpolate({ time: TextHelper.dateText(time) }); 6 | $('messages').insert({ bottom: '
  • ' + time_html + ' ' + text + '
  • ' }); 7 | this.scrollMessagesToTop(); 8 | }, 9 | 10 | addImageOnLoads: function() { 11 | $$('#messages li').last().select('img').each(function(element) { 12 | element.observe('load', this.scrollMessagesToTop); 13 | }.bind(this)); 14 | }, 15 | 16 | message: function(message, time) { 17 | var name = JsChat.user.name; 18 | var user_class = name == message['user'] ? 'user active' : 'user'; 19 | var text = '\#{user} \#{message}'; 20 | var blurred_mention = ''; 21 | 22 | if (message['message'].match(new RegExp(name, 'i')) && name != message['user']) { 23 | user_class = 'user mentioned'; 24 | blurred_mention = '*'; 25 | } 26 | 27 | Display.clearIdleState(message['user']); 28 | 29 | text = text.interpolate({ 30 | user_class: user_class, 31 | room: message['room'], 32 | user: TextHelper.truncateName(message['user']), 33 | message: TextHelper.decorateMessage(message['message']), 34 | message_class: 'message' 35 | }); 36 | 37 | this.add_message(text, 'message', time); 38 | this.addImageOnLoads(); 39 | 40 | if (this.show_unread) { 41 | this.unread++; 42 | document.title = 'JsChat: (' + this.unread + blurred_mention + ') new messages'; 43 | } 44 | }, 45 | 46 | messages: function(messages) { 47 | $('messages').innerHTML = ''; 48 | this.ignore_notices = true; 49 | 50 | $A(messages).each(function(json) { 51 | try { 52 | if (json['change']) { 53 | Change[json['change']](json[json['change']]); 54 | } else { 55 | this[json['display']](json[json['display']]); 56 | } 57 | } catch (exception) { 58 | } 59 | }.bind(this)); 60 | 61 | this.ignore_notices = false; 62 | this.scrollMessagesToTop(); 63 | /* This is assumed to be the point at which displaying /lastlog completes */ 64 | $('loading').hide(); 65 | }, 66 | 67 | scrollMessagesToTop: function() { 68 | if (!this.scrolled) { 69 | $('messages').scrollTop = $('messages').scrollHeight; 70 | } 71 | }, 72 | 73 | clearIdleState: function(user_name) { 74 | $$('#names li').each(function(element) { 75 | if (element.innerHTML == user_name && element.hasClassName('idle')) { 76 | element.lastIdle = (new Date()); 77 | element.removeClassName('idle'); 78 | } 79 | }); 80 | }, 81 | 82 | isIdle: function(dateValue) { 83 | try { 84 | var d = typeof dateValue == 'string' ? new Date(Date.parse(dateValue)) : dateValue, 85 | now = new Date(); 86 | if (((now - d) / 1000) > (60 * 5)) { 87 | return true; 88 | } 89 | } catch (exception) { 90 | console.log(exception); 91 | } 92 | return false; 93 | }, 94 | 95 | names: function(users) { 96 | $('names').innerHTML = ''; 97 | users.each(function(user) { 98 | var name = user['name'], 99 | list_class = this.isIdle(user['last_activity']) ? 'idle' : '', 100 | element = $(document.createElement('li')); 101 | 102 | element.addClassName(list_class); 103 | element.innerHTML = TextHelper.truncateName(name); 104 | $('names').insert({ bottom: element }); 105 | 106 | try { 107 | // Record the last idle time so the idle state can be dynamically updated 108 | element.lastIdle = new Date(Date.parse(user['last_activity'])); 109 | } catch (exception) { 110 | element.lastIdle = null; 111 | } 112 | }.bind(this)); 113 | }, 114 | 115 | join: function(join) { 116 | $('room-name').innerHTML = TextHelper.truncateRoomName(join['room']); 117 | $('room-name').title = PageHelper.currentRoom(); 118 | }, 119 | 120 | join_notice: function(join, time) { 121 | this.add_user(join['user']); 122 | this.add_message(join['user'] + ' has joined the room', 'server', time); 123 | }, 124 | 125 | add_user: function(name) { 126 | if (!this.ignore_notices) { 127 | $('names').insert({ bottom: '
  • ' + TextHelper.truncateName(name) + '
  • ' }); 128 | } 129 | }, 130 | 131 | remove_user: function(name) { 132 | if (!this.ignore_notices) { 133 | $$('#names li').each(function(element) { if (element.innerHTML == name) element.remove(); }); 134 | } 135 | }, 136 | 137 | part_notice: function(part, time) { 138 | this.remove_user(part['user']); 139 | this.add_message(part['user'] + ' has left the room', 'server', time); 140 | }, 141 | 142 | quit_notice: function(quit, time) { 143 | this.remove_user(quit['user']); 144 | this.add_message(quit['user'] + ' has quit', 'server', time); 145 | }, 146 | 147 | notice: function(notice) { 148 | this.add_message(notice, 'server'); 149 | }, 150 | 151 | error: function(error) { 152 | this.add_message(error['message'], 'error'); 153 | } 154 | }; 155 | -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/app/ui/commands.js: -------------------------------------------------------------------------------- 1 | var UserCommands = { 2 | '/emotes': function() { 3 | var text = ''; 4 | Display.add_message('Available Emotes — Prefix with a : to use', 'help'); 5 | Display.add_message(EmoteHelper.legalEmotes.join(', '), 'help'); 6 | }, 7 | 8 | '/help': function() { 9 | var help = []; 10 | Display.add_message('JsChat Help — Type the following commands into the message field:', 'help') 11 | help.push(['/clear', 'Clears messages']); 12 | help.push(['/join #room_name', 'Joins a room']); 13 | help.push(['/part #room_name', 'Leaves a room. Leave room_name blank for the current room']); 14 | help.push(['/lastlog', 'Shows recent activity']); 15 | help.push(['/search query', 'Searches the logs for this room']); 16 | help.push(['/names', 'Refreshes the names list']); 17 | help.push(['/name new_name', 'Changes your name']); 18 | help.push(['/toggle images', 'Toggles showing of images and videos']); 19 | help.push(['/quit', 'Quit']); 20 | help.push(['/emotes', 'Shows available emotes']); 21 | $A(help).each(function(options) { 22 | var help_text = '#{command}#{text}'.interpolate({ command: options[0], text: options[1]}); 23 | Display.add_message(help_text, 'help'); 24 | }); 25 | }, 26 | 27 | '/clear': function() { 28 | $('messages').innerHTML = ''; 29 | }, 30 | 31 | '/lastlog': function() { 32 | this.pausePollers = true; 33 | $('messages').innerHTML = ''; 34 | JsChat.Request.get('/lastlog', function(transport) { 35 | this.displayMessages(transport.responseText); 36 | $('names').innerHTML = ''; 37 | this.updateNames(); 38 | this.pausePollers = false; 39 | }.bind(this)); 40 | }, 41 | 42 | '/search\\s+(.*)': function(query) { 43 | query = query[1]; 44 | this.pausePollers = true; 45 | $('messages').innerHTML = ''; 46 | JsChat.Request.get('/search?q=' + query, function(transport) { 47 | Display.add_message('Search results:', 'server'); 48 | this.displayMessages(transport.responseText); 49 | this.pausePollers = false; 50 | }.bind(this)); 51 | }, 52 | 53 | '/(name|nick)\\s+(.*)': function(name) { 54 | name = name[2]; 55 | new Ajax.Request('/change-name', { 56 | method: 'post', 57 | parameters: { name: name }, 58 | onSuccess: function(response) { 59 | this.displayMessages(response.responseText); 60 | JsChat.user.setName(name); 61 | this.updateNames(); 62 | }.bind(this), 63 | onFailure: function() { 64 | Display.add_message("Server error: couldn't access: #{url}".interpolate({ url: url }), 'server'); 65 | } 66 | }); 67 | }, 68 | 69 | '/names': function() { 70 | this.updateNames(); 71 | }, 72 | 73 | '/toggle images': function() { 74 | JsChat.user.setHideImages(!JsChat.user.hideImages); 75 | Display.add_message("Hide images set to #{hide}".interpolate({ hide: JsChat.user.hideImages }), 'server'); 76 | }, 77 | 78 | '/(join)\\s+(.*)': function() { 79 | var room = arguments[0][2]; 80 | this.validateAndJoinRoom(room); 81 | }, 82 | 83 | '/(part|leave)': function() { 84 | this.partRoom(PageHelper.currentRoom()); 85 | }, 86 | 87 | '/(part|leave)\\s+(.*)': function() { 88 | var room = arguments[0][2]; 89 | this.partRoom(room); 90 | }, 91 | 92 | '/tweet\\s+(.*)': function() { 93 | var message = arguments[0][1]; 94 | this.sendTweet(message); 95 | }, 96 | 97 | '/quit': function() { 98 | window.location = '/quit'; 99 | } 100 | }; 101 | -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/app/ui/tab_completion.js: -------------------------------------------------------------------------------- 1 | var History = Class.create({ 2 | initialize: function() { 3 | this.messages = []; 4 | this.index = 0; 5 | this.limit = 100; 6 | }, 7 | 8 | prev: function() { 9 | this.index = this.index <= 0 ? this.messages.length - 1 : this.index - 1; 10 | }, 11 | 12 | next: function() { 13 | this.index = this.index >= this.messages.length - 1 ? 0 : this.index + 1; 14 | }, 15 | 16 | reset: function() { 17 | this.index = this.messages.length; 18 | }, 19 | 20 | value: function() { 21 | if (this.messages.length == 0) return ''; 22 | return this.messages[this.index]; 23 | }, 24 | 25 | add: function(value) { 26 | if (!value || value.length == 0) return; 27 | 28 | this.messages.push(value); 29 | if (this.messages.length > this.limit) { 30 | this.messages = this.messages.slice(-this.limit); 31 | } 32 | this.index = this.messages.length; 33 | }, 34 | 35 | atTop: function() { 36 | return this.index === this.messages.length; 37 | } 38 | }); 39 | 40 | var TabCompletion = Class.create({ 41 | initialize: function(element) { 42 | this.element = $(element); 43 | this.matches = []; 44 | this.match_offset = 0; 45 | this.cycling = false; 46 | this.has_focus = true; 47 | this.history = new History(); 48 | 49 | document.observe('keydown', this.keyboardEvents.bindAsEventListener(this)); 50 | this.element.observe('focus', this.onFocus.bindAsEventListener(this)); 51 | this.element.observe('blur', this.onBlur.bindAsEventListener(this)); 52 | this.element.observe('click', this.onFocus.bindAsEventListener(this)); 53 | }, 54 | 55 | onBlur: function() { 56 | this.has_focus = false; 57 | this.reset(); 58 | }, 59 | 60 | onFocus: function() { 61 | this.has_focus = true; 62 | this.reset(); 63 | }, 64 | 65 | tabSearch: function(input) { 66 | var names = $$('#names li').collect(function(element) { return element.innerHTML }).sort(); 67 | return names.findAll(function(name) { return name.toLowerCase().match(input.toLowerCase()) }); 68 | }, 69 | 70 | textToLeft: function() { 71 | var text = this.element.value; 72 | var caret_position = FormHelpers.getCaretPosition(this.element); 73 | if (caret_position < text.length) { 74 | text = text.slice(0, caret_position); 75 | } 76 | 77 | text = text.split(' ').last(); 78 | return text; 79 | }, 80 | 81 | elementFocused: function(e) { 82 | if (typeof document.activeElement == 'undefined') { 83 | return this.has_focus; 84 | } else { 85 | return document.activeElement == this.element; 86 | } 87 | }, 88 | 89 | keyboardEvents: function(e) { 90 | if (this.elementFocused()) { 91 | switch (e.keyCode) { 92 | case Event.KEY_TAB: 93 | var caret_position = FormHelpers.getCaretPosition(this.element); 94 | 95 | if (this.element.value.length > 0) { 96 | var search_text = ''; 97 | var search_result = ''; 98 | var replace_inline = false; 99 | var editedText = this.element.value.match(/[^a-z0-9]/i); 100 | 101 | if (this.cycling) { 102 | if (this.element.value == '#{last_result}: '.interpolate({ last_result: this.last_result })) { 103 | editedText = false; 104 | } else { 105 | replace_inline = true; 106 | } 107 | search_text = this.last_result; 108 | } else if (editedText && this.matches.length == 0) { 109 | search_text = this.textToLeft(); 110 | replace_inline = true; 111 | } else { 112 | search_text = this.element.value; 113 | } 114 | 115 | if (this.matches.length == 0) { 116 | this.matches = this.tabSearch(search_text); 117 | search_result = this.matches.first(); 118 | this.cycling = true; 119 | } else { 120 | this.match_offset++; 121 | if (this.match_offset >= this.matches.length) { 122 | this.match_offset = 0; 123 | } 124 | search_result = this.matches[this.match_offset]; 125 | } 126 | 127 | if (search_result && search_result.length > 0) { 128 | if (this.cycling && this.last_result) { 129 | search_text = this.last_result; 130 | } 131 | this.last_result = search_result; 132 | 133 | if (replace_inline) { 134 | var slice_start = caret_position - search_text.length; 135 | if (slice_start > 0) { 136 | this.element.value = this.element.value.substr(0, slice_start) + search_result + this.element.value.substr(caret_position, this.element.value.length); 137 | FormHelpers.setCaretPosition(this.element, slice_start + search_result.length); 138 | } 139 | } else if (!editedText) { 140 | this.element.value = '#{search_result}: '.interpolate({ search_result: search_result }); 141 | } 142 | } 143 | } 144 | 145 | Event.stop(e); 146 | return false; 147 | break; 148 | 149 | case Event.KEY_UP: 150 | if (this.history.atTop()) { 151 | this.history.add(this.element.value); 152 | } 153 | 154 | this.history.prev(); 155 | this.element.value = this.history.value(); 156 | FormHelpers.setCaretPosition(this.element, this.element.value.length + 1); 157 | Event.stop(e); 158 | return false; 159 | break; 160 | 161 | case Event.KEY_DOWN: 162 | this.history.next(); 163 | this.element.value = this.history.value(); 164 | FormHelpers.setCaretPosition(this.element, this.element.value.length + 1); 165 | Event.stop(e); 166 | return false; 167 | break; 168 | 169 | default: 170 | this.reset(); 171 | break; 172 | } 173 | } 174 | }, 175 | 176 | reset: function() { 177 | this.matches = []; 178 | this.match_offset = 0; 179 | this.last_result = null; 180 | this.cycling = false; 181 | } 182 | }); 183 | -------------------------------------------------------------------------------- /lib/jschat/http/public/javascripts/init.js: -------------------------------------------------------------------------------- 1 | var JsChat = {}; 2 | 3 | document.observe('dom:loaded', function() { 4 | JsChat.user = new User(); 5 | 6 | if ($('post_message')) { 7 | var chatController = new JsChat.ChatController(); 8 | } 9 | 10 | if ($('sign-on')) { 11 | if (JsChat.user.name) { 12 | $('name').value = JsChat.user.name; 13 | } 14 | 15 | if ($('room') && window.location.hash) { 16 | $('room').value = window.location.hash; 17 | } 18 | 19 | var signOnController = new JsChat.SignOnController(); 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /lib/jschat/http/public/stylesheets/ipad.css: -------------------------------------------------------------------------------- 1 | body { font-size: 120% } 2 | #message, #send_button { font-size: 130% } 3 | -------------------------------------------------------------------------------- /lib/jschat/http/public/stylesheets/iphone.css: -------------------------------------------------------------------------------- 1 | body { font-size: 200% } 2 | input { font-size: 150% } 3 | #info { display: none } 4 | .header .rooms { top: 6px; } 5 | .header .rooms li { padding-bottom: 4px; height: 1.25em; font-size: 120% } 6 | -------------------------------------------------------------------------------- /lib/jschat/http/public/stylesheets/screen.css: -------------------------------------------------------------------------------- 1 | body { margin: 0 auto; padding: 0; font-family: 'Lucida Grande', arial, helvetica, sans-serif; color: #111; text-align: center; } 2 | html, body { background: #f0f0f0; } 3 | 4 | h1, h2, h3, h4 { margin: 0; padding: 0; } 5 | 6 | h1 { text-align: left; margin-left: 20px } 7 | 8 | .header { position: absolute; top: 0; height: 59px; left: 0; width: 100%; z-index: 10; background-color: #ffc; clear: both; } 9 | .header img { border: none; margin: 5px 0 0 0 } 10 | .header h1 { width: 200px; float: left } 11 | .header .navigation { float: right; list-style-type: none; margin: 18px 0 0 0; padding: 0 } 12 | .header .navigation li { float: left; margin: 0 20px 0 0; padding: 0 } 13 | .header .navigation li a { text-decoration: none; color: #fff; background-color: #777; padding: 1px 3px } 14 | .header .navigation li a:hover { background-color: #fff; color: #555 } 15 | .header .navigation li#quit-nav a { background-color: #990000 } 16 | .header .navigation li#quit-nav a:hover { color: #990000; background-color: #fff } 17 | .header-shadow { width: 100%; height: 6px; position: absolute; top: 59px; left: 0; background-image: url('/images/shadow.png'); background-repeat: repeat-x } 18 | 19 | .header .rooms { position: absolute; left: 200px; top: 35px; list-style-type: none; margin: 0; padding: 0 } 20 | .header .rooms li { float: left; margin: 0 1px 0 0; padding: 2px 6px 3px 6px; background-color: #f0f0f0; border: 1px solid #aaa; border-bottom: #fff; font-size: 90%; height: 18px } 21 | .header .rooms li.selected { background-color: #fff; border: 1px solid #ccc; border-bottom: #fff } 22 | .header .rooms li a { color: #777; text-decoration: none } 23 | .header .rooms li a.new { color: #990000; font-weight: bold; } 24 | .header .rooms li a:hover { color: #000 } 25 | .header .rooms li.selected a { color: #444 } 26 | 27 | .page { margin-top: 60px } 28 | 29 | #messages { width: 500px; height: 300px; margin: 0 20px 10px 20px; padding: 0; overflow: auto; background-color: #fff; float: left; text-align: left; display: inline; } 30 | #messages { list-style-type: none; overflow: auto } 31 | #messages li { padding: 0.25em 0; border-bottom: 1px solid #f0f0f0; float: left; width: 100%; line-height: 1.5em } 32 | #messages span.user { margin: 0; text-align: left; font-weight: bold; display: inline; float: left } 33 | #messages .active { color: #000099 } 34 | #messages .mentioned { color: #cccc00 } 35 | #messages span.time { text-align: left; margin: 0 10px; display: block; float: left; color: #ccc !important; font-style: normal !important } 36 | #messages span.message { display: inline; padding: 0 0 0 10px } 37 | #messages .help { color: #990000 } 38 | #messages .help span.command { width: 12em; display: block; float: left; font-weight: bold } 39 | #messages li.server { color: #999; font-style: italic } 40 | #messages li.error { color: #990000; font-style: italic; font-weight: bold } 41 | #messages li.server span.time, 42 | #messages li.error span.time { color: #999 } 43 | img.inline-image { border: 1px solid #ccc; padding: 2px } 44 | img.inline-image { 45 | max-width: 200px; 46 | width: expression(this.width > 200 ? 200: true); 47 | } 48 | 49 | #input { clear: both; text-align: left; margin: 0 0 0 20px; padding: 0; } 50 | form { margin: 0; padding: 0; } 51 | #message { width: 100% } 52 | #message:focus { background-color: #ffc } 53 | 54 | #info { text-align: left; margin: 0 0 0 20px } 55 | #room-name { padding: 10px 0; font-size: 125% } 56 | 57 | ul#names { margin: 0; padding: 0; list-style-type: none; height: 200px; overflow: auto; } 58 | ul#names li { margin: 0; padding: 0.25em 0; } 59 | ul#names li.idle { color: #777 } 60 | 61 | .footer { border-top: 1px solid #ccc; padding: 8px 0 0 0; font-size: 80%; font-style: italic; color: #444; margin: 40px 0 0 0 } 62 | 63 | /* Front page */ 64 | #sign-on { padding: 10px 0; margin: 10px auto; background-color: #fff; border: 1px solid #ccc; clear: both; text-align: center } 65 | #sign-on input[type="text"] { width: 12em } 66 | #sign-on input:focus { background-color: #ffc } 67 | .content { text-align: left; margin: 0 20px; padding: 5px 0 } 68 | .content em { padding: 1px 2px; font-style: normal; background-color: #ffc; color: #880000 } 69 | .content h2 { margin-top: 20px } 70 | 71 | #feedback .error { padding: 10px; border: 2px solid #990000; background-color: #fff; margin: 10px 0 } 72 | #loading { background-color: #990000; color: #fff; font-weight: bold; padding: 3px; margin: 0 auto; position: absolute; z-index: 1020; top: 0; right: 10px; border-top: none } 73 | .angry { color: #990000 } 74 | .big_message { background-color: #fff; border: 1px solid #ccc; padding: 10px; } 75 | .big_message h2, .big_message p { margin: 0; padding 0 } 76 | .big_message h2 { margin-bottom: 10px } 77 | -------------------------------------------------------------------------------- /lib/jschat/http/tmp/restart.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexyoung/jschat/28bf4a2d636d4b2f664d1f7d8f36424ca7f5eed1/lib/jschat/http/tmp/restart.txt -------------------------------------------------------------------------------- /lib/jschat/http/views/form.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | Enter name: 4 | and room: 5 | 6 |
    7 |
    8 | -------------------------------------------------------------------------------- /lib/jschat/http/views/index.erb: -------------------------------------------------------------------------------- 1 |
    2 |

    About

    3 |

    JsChat is an open source chat system that uses a simple protocol based on JSON.

    4 |

    To download the code and an irssi-like console client, visit GitHub.

    5 |

    Read more on the JsChat Blog.

    6 |

    Try JsChat Now

    7 | 8 | <%= erb :form, :layout => false %> 9 | <% if JsChat::Auth::Twitter.loaded? %> 10 | <%= erb JsChat::Auth::Twitter.template, :layout => false %> 11 | <% end %> 12 |

    Features

    13 |
      14 |
    • Simple protocol that makes it easy to implement clients and bots
    • 15 |
    • Console client designed to look and feel like IRC clients
    • 16 |
    • Web client auto links and displays images/YouTube videos inline
    • 17 |
    • Protocol designed to be close to executable code, so creating clients and bots is easy
    • 18 |
    • Optional mongodb logging
    • 19 |
    • Optional Twitter authentication
    • 20 |
    21 |
    22 | 25 | -------------------------------------------------------------------------------- /lib/jschat/http/views/ipad.erb: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | JsChat iPad 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
    18 |

    JsChat

    19 | 22 | 28 |
    29 |
    30 |
    31 | <%= yield %> 32 |
    33 | 34 | 35 | -------------------------------------------------------------------------------- /lib/jschat/http/views/iphone.erb: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | JsChat iPhone 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
    19 |

    JsChat

    20 | 23 | 29 |
    30 |
    31 |
    32 | <%= yield %> 33 |
    34 | 35 | 36 | -------------------------------------------------------------------------------- /lib/jschat/http/views/iphone_message_form.erb: -------------------------------------------------------------------------------- 1 |
      2 |
    3 |
    4 |

    Loading...

    5 | 6 |
      7 |
    8 |
    9 |
    10 |
    11 | 12 | 13 |
    14 |
    15 | 16 | -------------------------------------------------------------------------------- /lib/jschat/http/views/layout.erb: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | JsChat 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
    15 |

    JsChat

    16 | 19 | 25 |
    26 |
    27 |
    28 | <%= yield %> 29 |
    30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /lib/jschat/http/views/message_form.erb: -------------------------------------------------------------------------------- 1 |
      2 |
    3 |
    4 |

    Loading...

    5 |
      6 |
    7 |
    8 |
    9 |
    10 | 11 | 12 |
    13 |
    14 | 15 | -------------------------------------------------------------------------------- /lib/jschat/http/views/twitter.erb: -------------------------------------------------------------------------------- 1 |

    Login with Twitter

    2 | 3 |

    JsChat will save your rooms and keep your presence online until you click Quit.

    4 |

    This will persist even if you login on another computer.

    5 | 6 |
    7 | -------------------------------------------------------------------------------- /lib/jschat/init.rb: -------------------------------------------------------------------------------- 1 | module JsChat ; end 2 | 3 | require 'jschat/server_options' 4 | require 'jschat/storage/init' 5 | 6 | module JsChat 7 | STATELESS_TIMEOUT = 60 8 | LASTLOG_DEFAULT = 100 9 | 10 | def self.init_storage 11 | if JsChat::Storage::MongoDriver.available? 12 | JsChat::Storage.enabled = true 13 | JsChat::Storage.driver = JsChat::Storage::MongoDriver 14 | else 15 | JsChat::Storage.enabled = false 16 | JsChat::Storage.driver = JsChat::Storage::NullDriver 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/jschat/server.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'eventmachine' 3 | gem 'json', '>= 1.1.9' 4 | require 'json' 5 | require 'time' 6 | require 'socket' 7 | 8 | # JsChat libraries 9 | require 'jschat/init' 10 | require 'jschat/errors' 11 | require 'jschat/flood_protection' 12 | 13 | module JsChat 14 | module Server 15 | def self.pid_file_name 16 | File.join(ServerConfig['tmp_files'], 'jschat.pid') 17 | end 18 | 19 | def self.write_pid_file 20 | return unless ServerConfig['use_tmp_files'] 21 | File.open(pid_file_name, 'w') { |f| f << Process.pid } 22 | end 23 | 24 | def self.rm_pid_file 25 | FileUtils.rm pid_file_name 26 | end 27 | 28 | def self.stop 29 | rm_pid_file 30 | end 31 | 32 | def self.run! 33 | write_pid_file 34 | JsChat.init_storage 35 | 36 | at_exit do 37 | stop 38 | end 39 | 40 | EM.run do 41 | EM.start_server ServerConfig['ip'], ServerConfig['port'], JsChat 42 | end 43 | end 44 | end 45 | 46 | class User 47 | include JsChat::FloodProtection 48 | 49 | attr_accessor :name, :connection, :rooms, :last_activity, 50 | :identified, :ip, :last_poll, :session_length 51 | 52 | def initialize(connection) 53 | @name = nil 54 | @connection = connection 55 | @rooms = [] 56 | @last_activity = Time.now.utc 57 | @last_poll = Time.now.utc 58 | @identified = false 59 | @ip = '' 60 | @expires = nil 61 | @session_length = nil 62 | end 63 | 64 | def session_expired? 65 | return true if @expires.nil? 66 | Time.now.utc >= @expires 67 | end 68 | 69 | def update_session_expiration 70 | return if @session_length.nil? 71 | @expires = Time.now.utc + @session_length 72 | end 73 | 74 | def to_json(*a) 75 | { 'name' => @name, 'last_activity' => @last_activity }.to_json(*a) 76 | end 77 | 78 | def name=(name) 79 | if @connection and @connection.name_taken? name 80 | raise JsChat::Errors::InvalidName.new(:name_taken, 'Name taken') 81 | elsif User.valid_name?(name) 82 | @identified = true 83 | @name = name 84 | else 85 | raise JsChat::Errors::InvalidName.new(:invalid_name, 'Invalid name') 86 | end 87 | end 88 | 89 | def self.valid_name?(name) 90 | not name.match /[^[:alnum:]._\-\[\]^C]/ and name.size > 0 91 | end 92 | 93 | def private_message(message) 94 | response = { 'display' => 'message', 'message' => message } 95 | @connection.send_response response 96 | end 97 | 98 | def change(params) 99 | # Valid options for change 100 | ['name'].each do |field| 101 | if params[field] 102 | old_value = send(field) 103 | send "#{field}=", params[field] 104 | @rooms.each do |room| 105 | response = { 'change' => 'user', 106 | 'room' => room.name, 107 | 'user' => { field => { old_value => params[field] } } } 108 | room.change_notice self, response 109 | return [field, params[field]] 110 | end 111 | end 112 | end 113 | end 114 | end 115 | 116 | class Room 117 | attr_accessor :name, :users 118 | 119 | def initialize(name) 120 | @name = name 121 | @users = [] 122 | end 123 | 124 | def self.valid_name?(name) 125 | User.valid_name?(name[1..-1]) and name[0].chr == '#' 126 | end 127 | 128 | def self.find(item) 129 | @@rooms ||= [] 130 | 131 | if item.kind_of? String 132 | @@rooms.find { |room| room.name.downcase == item.downcase if room.name } 133 | elsif item.kind_of? User 134 | @@rooms.find_all { |room| room.users.include? item } 135 | end 136 | end 137 | 138 | def self.find_or_create(room_name) 139 | room = find room_name 140 | if room.nil? 141 | room = new(room_name) 142 | @@rooms << room 143 | end 144 | room 145 | end 146 | 147 | def self.rooms 148 | @@rooms 149 | end 150 | 151 | def lastlog(since = nil) 152 | { 'display' => 'messages', 'messages' => messages_since(since) } 153 | end 154 | 155 | def search(query, limit = 100) 156 | { 'display' => 'messages', 'messages' => message_search(query, limit) } 157 | end 158 | 159 | def last_update_time 160 | message = JsChat::Storage.driver.lastlog(LASTLOG_DEFAULT, name).last 161 | message['time'] if message 162 | end 163 | 164 | def messages_since(since) 165 | messages = JsChat::Storage.driver.lastlog(LASTLOG_DEFAULT, name) 166 | if since.nil? 167 | messages 168 | else 169 | messages.select { |m| m['time'] && m['time'] > since } 170 | end 171 | end 172 | 173 | def message_search(query, limit) 174 | JsChat::Storage.driver.search(query, name, limit) 175 | end 176 | 177 | def add_to_lastlog(message) 178 | if message 179 | message['time'] = Time.now.utc 180 | JsChat::Storage.driver.log message, name 181 | end 182 | end 183 | 184 | def join(user) 185 | if @users.include? user 186 | Error.new(:already_joined, 'Already in that room') 187 | else 188 | @users << user 189 | user.rooms << self 190 | join_notice user 191 | { 'display' => 'join', 'join' => { 'user' => user.name, 'room' => @name } } 192 | end 193 | end 194 | 195 | def part(user) 196 | if not @users.include?(user) 197 | Error.new(:not_in_room, 'Not in that room') 198 | else 199 | user.rooms.delete_if { |r| r == self } 200 | @users.delete_if { |u| u == user } 201 | part_notice user 202 | { 'display' => 'part', 'part' => { 'user' => user.name, 'room' => @name } } 203 | end 204 | end 205 | 206 | def send_message(message) 207 | message['room'] = name 208 | response = { 'display' => 'message', 'message' => message } 209 | 210 | add_to_lastlog response 211 | 212 | @users.each do |user| 213 | user.connection.send_response response 214 | end 215 | end 216 | 217 | def member_names 218 | @users.collect { |user| user.name } 219 | end 220 | 221 | def to_json(*a) 222 | { 'name' => @name, 'members' => member_names }.to_json(*a) 223 | end 224 | 225 | def notice(user, message, all = false) 226 | add_to_lastlog message 227 | 228 | @users.each do |u| 229 | if (u != user and !all) or all 230 | u.connection.send_response(message) 231 | end 232 | end 233 | end 234 | 235 | def change_notice(user, response) 236 | notice(user, response, true) 237 | end 238 | 239 | def join_notice(user) 240 | notice(user, { 'display' => 'join_notice', 'join_notice' => { 'user' => user.name, 'room' => @name } }) 241 | end 242 | 243 | def part_notice(user) 244 | notice(user, { 'display' => 'part_notice', 'part_notice' => { 'user' => user.name, 'room' => @name } }) 245 | end 246 | 247 | def quit_notice(user) 248 | notice(user, { 'display' => 'quit_notice', 'quit_notice' => { 'user' => user.name, 'room' => @name } }) 249 | @users.delete_if { |u| u == user } 250 | end 251 | end 252 | 253 | # User initially has a nil name 254 | def users_with_names 255 | @@users.find_all { |u| u.name } 256 | end 257 | 258 | def name_taken?(name) 259 | users_with_names.find { |user| user.name.downcase == name.downcase } 260 | end 261 | 262 | # {"identify":"alex"} 263 | def identify(name, ip, session_length, options = {}) 264 | if @user and @user.identified 265 | Error.new :already_identified, 'You have already identified' 266 | elsif name_taken? name 267 | Error.new :name_taken, 'Name already taken' 268 | else 269 | @user.name = name 270 | @user.ip = ip 271 | @user.session_length = session_length 272 | @user.update_session_expiration 273 | register_stateless_user if @stateless 274 | { 'display' => 'identified', 'identified' => @user } 275 | end 276 | rescue JsChat::Errors::InvalidName => exception 277 | exception 278 | end 279 | 280 | def lastlog(room, options = {}) 281 | room = Room.find room 282 | if room and room.users.include? @user 283 | room.lastlog 284 | else 285 | Error.new(:not_in_room, "Please join this room first") 286 | end 287 | end 288 | 289 | def search(query, options = {}) 290 | room = Room.find options['room'] 291 | if room and room.users.include? @user 292 | room.search query 293 | else 294 | Error.new(:not_in_room, "Please join this room first") 295 | end 296 | end 297 | 298 | def since(room, options = {}) 299 | room = Room.find room 300 | if room and room.users.include? @user 301 | response = room.lastlog(@user.last_poll) 302 | @user.last_poll = Time.now.utc 303 | response 304 | else 305 | Error.new(:not_in_room, "Please join this room first") 306 | end 307 | end 308 | 309 | def times(message, options = {}) 310 | times = {} 311 | @user.rooms.each do |room| 312 | times[room.name] = room.last_update_time 313 | end 314 | times 315 | end 316 | 317 | def ping(message, options = {}) 318 | if @user and @user.last_poll and Time.now.utc > @user.last_poll 319 | time = Time.now.utc 320 | @user.update_session_expiration 321 | { 'pong' => time } 322 | else 323 | # TODO: HANDLE PING OUTS 324 | Error.new(:ping_out, 'Your connection has been lost') 325 | end 326 | end 327 | 328 | def quit(message, options = {}) 329 | if @user 330 | disconnect_user @user 331 | end 332 | end 333 | 334 | def room_message(message, options) 335 | room = Room.find options['to'] 336 | if room and room.users.include? @user 337 | room.send_message({ 'message' => message, 'user' => @user.name }) 338 | else 339 | send_response Error.new(:not_in_room, "Please join this room first") 340 | end 341 | end 342 | 343 | def private_message(message, options) 344 | user = users_with_names.find { |u| u.name.downcase == options['to'].downcase } 345 | if user 346 | # Return the message to the user, and send it to the other person too 347 | now = Time.now.utc 348 | user.private_message({ 'message' => message, 'user' => @user.name }) 349 | @user.private_message({ 'message' => message, 'user' => @user.name }) 350 | else 351 | Error.new(:not_online, 'User not online') 352 | end 353 | end 354 | 355 | def send_message(message, options) 356 | if options['to'].nil? 357 | send_response Error.new(:to_required, 'Please specify who to send the message to or join a channel') 358 | elsif options['to'][0].chr == '#' 359 | room_message message, options 360 | else 361 | private_message message, options 362 | end 363 | end 364 | 365 | def join(room_name, options = {}) 366 | if Room.valid_name? room_name 367 | room = Room.find_or_create(room_name) 368 | room.join @user 369 | else 370 | Error.new(:invalid_room, 'Invalid room name') 371 | end 372 | end 373 | 374 | def part(room_name, options = {}) 375 | room = @user.rooms.find { |r| r.name == room_name } 376 | if room 377 | room.part @user 378 | else 379 | Error.new(:not_in_room, "You are not in that room") 380 | end 381 | end 382 | 383 | def names(room_name, options = {}) 384 | room = Room.find(room_name) 385 | if room 386 | { 'display' => 'names', 'names' => room.users, 'room' => room.name } 387 | else 388 | Error.new(:room_not_available, 'No such room') 389 | end 390 | end 391 | 392 | def new_cookie 393 | chars = ("a".."z").to_a + ("1".."9").to_a 394 | Array.new(8, '').collect { chars[rand(chars.size)] }.join 395 | end 396 | 397 | def register_stateless_client 398 | @stateless_cookie = new_cookie 399 | user = User.new(self) 400 | @@stateless_cookies << { :cookie => @stateless_cookie, :user => user } 401 | @@users << user 402 | { 'cookie' => @stateless_cookie } 403 | end 404 | 405 | def current_stateless_client 406 | @@stateless_cookies.find { |c| c[:cookie] == @stateless_cookie } 407 | end 408 | 409 | def register_stateless_user 410 | current_stateless_client[:user] = @user 411 | end 412 | 413 | def valid_stateless_user? 414 | current_stateless_client 415 | end 416 | 417 | def load_stateless_user 418 | if client = current_stateless_client 419 | @user = client[:user] 420 | @stateless = true 421 | else 422 | raise JsChat::Errors::InvalidCookie.new(:invalid_cookie, 'Invalid cookie') 423 | end 424 | end 425 | 426 | def disconnect_lagged_users 427 | @@stateless_cookies.delete_if do |cookie| 428 | if cookie[:user].session_expired? 429 | lagged?(cookie[:user].last_poll) ? disconnect_user(cookie[:user]) && true : false 430 | end 431 | end 432 | end 433 | 434 | def lagged?(time) 435 | Time.now.utc - time > STATELESS_TIMEOUT 436 | end 437 | 438 | def unbind 439 | return if @stateless 440 | disconnect_user(@user) 441 | @user = nil 442 | end 443 | 444 | def disconnect_user(user) 445 | log :info, "Removing a connection" 446 | Room.find(user).each do |room| 447 | room.quit_notice user 448 | end 449 | 450 | @@users.delete_if { |u| u == user } 451 | end 452 | 453 | def post_init 454 | @@users ||= [] 455 | @@stateless_cookies ||= [] 456 | @user = User.new(self) 457 | end 458 | 459 | def log(level, message) 460 | if Object.const_defined? :ServerConfig and ServerConfig['logger'] 461 | if @user 462 | message = "#{@user.name} (#{@user.ip}): #{message}" 463 | end 464 | ServerConfig['logger'].send level, message 465 | end 466 | end 467 | 468 | def change(change, options = {}) 469 | if change == 'user' 470 | field, value = @user.send :change, options[change] 471 | { 'display' => 'notice', 'notice' => "Your #{field} has been changed to: #{value}" } 472 | else 473 | Error.new(:invalid_request, 'Invalid change request') 474 | end 475 | rescue JsChat::Errors::InvalidName => exception 476 | exception 477 | end 478 | 479 | def list(list, options = {}) 480 | case list 481 | when 'rooms' 482 | @user.rooms.collect { |room| room.name } 483 | else 484 | Error.new(:invalid_request, 'Invalid list command') 485 | end 486 | end 487 | 488 | def send_response(data) 489 | response = '' 490 | case data 491 | when String 492 | response = data 493 | when Error 494 | response = data.to_json + "\n" 495 | log :error, data.message 496 | else 497 | # Other objects should be safe for to_json 498 | response = data.to_json + "\n" 499 | log :info, response.strip 500 | end 501 | 502 | send_data response 503 | end 504 | 505 | include EM::Protocols::LineText2 506 | 507 | def get_remote_ip 508 | Socket.unpack_sockaddr_in(get_peername)[1] 509 | end 510 | 511 | def receive_line(data) 512 | response = '' 513 | disconnect_lagged_users 514 | 515 | if data and data.size > ServerConfig['max_message_length'] 516 | raise JsChat::Errors::MessageTooLong.new(:message_too_long, 'Message too long') 517 | end 518 | 519 | data.chomp.split("\n").each do |line| 520 | # Receive the identify request 521 | input = JSON.parse line 522 | 523 | @user.seen! 524 | 525 | # Unbind when a stateless connection doesn't match the cookie 526 | if input.has_key?('cookie') 527 | @stateless_cookie = input['cookie'] 528 | load_stateless_user 529 | end 530 | 531 | if input.has_key? 'protocol' 532 | if input['protocol'] == 'stateless' 533 | @stateless = true 534 | response << send_response(register_stateless_client) 535 | end 536 | elsif input.has_key? 'identify' 537 | input['ip'] ||= get_remote_ip 538 | response << send_response(identify(input['identify'], input['ip'], input['session_length'])) 539 | else 540 | %w{search lastlog change send join names part since ping list quit times}.each do |command| 541 | if @user.name.nil? 542 | response << send_response(Error.new(:identity_required, 'Identify first')) 543 | return response 544 | end 545 | 546 | if input.has_key? command 547 | if command == 'send' 548 | @user.last_activity = Time.now.utc 549 | message_result = send('send_message', input[command], input) 550 | response << message_result if message_result.kind_of? String 551 | else 552 | result = send_response(send(command, input[command], input)) 553 | response << result if result.kind_of? String 554 | end 555 | end 556 | end 557 | end 558 | end 559 | 560 | response 561 | rescue JsChat::Errors::StillFlooding 562 | "" 563 | rescue JsChat::Errors::Flooding => exception 564 | send_response exception 565 | rescue JsChat::Errors::MessageTooLong => exception 566 | send_response exception 567 | rescue JsChat::Errors::InvalidCookie => exception 568 | send_response exception 569 | rescue Exception => exception 570 | puts "Data that raised exception: #{exception}" 571 | p data 572 | print_call_stack 573 | end 574 | 575 | def print_call_stack(from = 0, to = 10) 576 | puts "Stack:" 577 | (from..to).each do |index| 578 | puts "\t#{caller[index]}" 579 | end 580 | end 581 | end 582 | -------------------------------------------------------------------------------- /lib/jschat/server_options.rb: -------------------------------------------------------------------------------- 1 | require 'optparse' 2 | require 'tmpdir' 3 | 4 | logger = nil 5 | 6 | if Object.const_defined? :Logger 7 | logger = Logger.new(STDERR) 8 | logger = Logger.new(STDOUT) 9 | end 10 | 11 | ServerConfigDefaults = { 12 | 'port' => 6789, 13 | 'ip' => '0.0.0.0', 14 | 'logger' => logger, 15 | 'max_message_length' => 500, 16 | 'tmp_files' => File.join(Dir::tmpdir, 'jschat'), 17 | 'db_name' => 'jschat', 18 | 'db_host' => 'localhost', 19 | 'db_port' => 27017, 20 | #'db_username' => '', 21 | #'db_password' => '', 22 | # Register your instance of JsChat here: http://twitter.com/apps/create 23 | # 'twitter' => { 'key' => '', 'secret' => '' } 24 | } 25 | 26 | # Command line options will overrides these 27 | def load_options(path) 28 | path = File.expand_path path 29 | if File.exists? path 30 | JSON.parse(File.read path) 31 | else 32 | {} 33 | end 34 | end 35 | 36 | def make_tmp_files 37 | ServerConfig['use_tmp_files'] = false 38 | if File.exists? ServerConfig['tmp_files'] 39 | ServerConfig['use_tmp_files'] = true 40 | else 41 | if Dir.mkdir ServerConfig['tmp_files'] 42 | ServerConfig['use_tmp_files'] = true 43 | end 44 | end 45 | end 46 | 47 | options = {} 48 | default_config_file = '/etc/jschat/config.json' 49 | 50 | ARGV.clone.options do |opts| 51 | script_name = File.basename($0) 52 | opts.banner = "Usage: #{$0} [options]" 53 | 54 | opts.separator "" 55 | 56 | opts.on("-c", "--config=PATH", String, "Configuration file location (#{default_config_file}") { |o| options['config'] = o } 57 | opts.on("-p", "--port=PORT", String, "Port number") { |o| options['port'] = o } 58 | opts.on("-t", "--tmp_files=PATH", String, "Temporary files location (including pid file)") { |o| options['tmp_files'] = o } 59 | opts.on("--help", "-H", "This text") { puts opts; exit 0 } 60 | 61 | opts.parse! 62 | end 63 | 64 | options = load_options(options['config'] || default_config_file).merge options 65 | 66 | ServerConfig = ServerConfigDefaults.merge options 67 | make_tmp_files 68 | -------------------------------------------------------------------------------- /lib/jschat/storage/init.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'mongo') 2 | require File.join(File.dirname(__FILE__), 'null') 3 | 4 | module JsChat::Storage 5 | def self.driver=(driver) 6 | @driver = driver 7 | end 8 | 9 | def self.driver ; @driver ; end 10 | 11 | def self.enabled=(enabled) 12 | @enabled = enabled 13 | end 14 | 15 | def self.enabled? 16 | @enabled 17 | end 18 | end 19 | 20 | -------------------------------------------------------------------------------- /lib/jschat/storage/mongo.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'mongo' 3 | rescue LoadError 4 | end 5 | 6 | module JsChat::Storage 7 | module MongoDriver 8 | def self.connect! 9 | @db = Mongo::Connection.new(ServerConfig['db_host'], ServerConfig['db_port'], :slave_ok => true).db(ServerConfig['db_name']) 10 | if ServerConfig['db_username'] and ServerConfig['db_password'] 11 | if @db.authenticate(ServerConfig['db_username'], ServerConfig['db_password']) 12 | true 13 | else 14 | raise 'Bad Mongo username or password' 15 | end 16 | else 17 | true 18 | end 19 | end 20 | 21 | def self.log(message, room) 22 | message['room'] = room 23 | @db['events'].insert(message) 24 | end 25 | 26 | def self.lastlog(number, room) 27 | @db['events'].find({ :room => room }, { :limit => number, :sort => ['time', Mongo::DESCENDING] }).to_a.reverse 28 | end 29 | 30 | def self.search(query, room, limit) 31 | query = /\b#{query}\b/i 32 | @db['events'].find({ 'message.message' => query, 'room' => room }, 33 | { :limit => limit, :sort => ['time', Mongo::DESCENDING] } 34 | ).to_a.reverse 35 | end 36 | 37 | # TODO: use twitter oauth for the key 38 | def self.find_user(options) 39 | @db['users'].find_one(options) 40 | end 41 | 42 | def self.save_user(user) 43 | @db['users'].save user 44 | end 45 | 46 | def self.delete_user(user) 47 | @db['users'].remove user 48 | end 49 | 50 | def self.available? 51 | return unless Object.const_defined?(:Mongo) 52 | connect! 53 | rescue 54 | p $! 55 | puts 'Failed to connect to mongo' 56 | false 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/jschat/storage/null.rb: -------------------------------------------------------------------------------- 1 | module JsChat::Storage 2 | MEMORY_MESSAGE_LIMIT = 100 3 | 4 | module NullDriver 5 | def self.log(message, room) 6 | @messages ||= [] 7 | message['room'] = room 8 | @messages.push message 9 | @messages = @messages[-MEMORY_MESSAGE_LIMIT..-1] if @messages.size > MEMORY_MESSAGE_LIMIT 10 | end 11 | 12 | def self.lastlog(number, room) 13 | @messages ||= [] 14 | @messages.select { |m| m['room'] == room }.reverse[0..number].reverse 15 | end 16 | 17 | def self.search(query, room, limit) 18 | @messages ||= [] 19 | @messages.select do |m| 20 | m['message'] and m['message']['message'].match(query) and m['room'] == room 21 | end.reverse[0..limit].reverse 22 | end 23 | 24 | def self.find_user(options) 25 | end 26 | 27 | def self.save_user(user) 28 | end 29 | 30 | def self.delete_user(user) 31 | end 32 | end 33 | end 34 | 35 | -------------------------------------------------------------------------------- /test/server_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ServerTest < Test::Unit::TestCase 4 | include JsChatHelpers 5 | 6 | def setup 7 | @jschat = JsChatMock.new 8 | @jschat.post_init 9 | end 10 | 11 | def teardown 12 | @jschat.reset 13 | end 14 | 15 | def test_identify 16 | response = JSON.parse @jschat.receive_line({ 'identify' => 'alex' }.to_json) 17 | assert_equal 'identified', response['display'] 18 | end 19 | 20 | def test_invalid_identify 21 | response = JSON.parse @jschat.receive_line({ 'identify' => '@lex' }.to_json) 22 | assert_equal JsChat::Errors::Codes.invert[:invalid_name], response['error']['code'] 23 | end 24 | 25 | def test_identify_twice_fails 26 | identify_as 'alex' 27 | result = JSON.parse identify_as('alex') 28 | assert_equal 106, result['error']['code'] 29 | end 30 | 31 | def test_ensure_nicks_are_longer_than_0 32 | result = JSON.parse identify_as('') 33 | assert result['error'] 34 | end 35 | 36 | def test_ensure_nicks_are_unique 37 | identify_as 'alex' 38 | 39 | # Obvious duplicate 40 | result = identify_as 'alex' 41 | assert result['error'] 42 | 43 | # Case 44 | result = identify_as 'Alex' 45 | assert result['error'] 46 | end 47 | 48 | def test_invalid_room_name 49 | identify_as 'bob' 50 | response = JSON.parse @jschat.receive_line({ 'join' => 'oublinet' }.to_json) 51 | assert_equal 'Invalid room name', response['error']['message'] 52 | end 53 | 54 | def test_join 55 | identify_as 'bob' 56 | expected = { 'display' => 'join', 'join' => { 'user' => 'bob', 'room' => '#oublinet' } }.to_json + "\n" 57 | assert_equal expected, @jschat.receive_line({ 'join' => '#oublinet' }.to_json) 58 | end 59 | 60 | def test_join_without_identifying 61 | response = JSON.parse @jschat.receive_line({ 'join' => '#oublinet' }.to_json) 62 | assert response['error'] 63 | end 64 | 65 | def test_join_more_than_once 66 | identify_as 'bob' 67 | 68 | expected = { 'display' => 'error', 'error' => { 'message' => 'Already in that room' } }.to_json + "\n" 69 | @jschat.receive_line({ 'join' => '#oublinet' }.to_json) 70 | response = JSON.parse @jschat.receive_line({ 'join' => '#oublinet' }.to_json) 71 | assert_equal JsChat::Errors::Codes.invert[:already_joined], response['error']['code'] 72 | end 73 | 74 | def test_names 75 | identify_as 'nick', '#oublinet' 76 | 77 | # Add a user 78 | @jschat.add_user 'alex', '#oublinet' 79 | 80 | response = JSON.parse(@jschat.receive_line({ 'names' => '#oublinet' }.to_json)) 81 | assert response['names'] 82 | end 83 | 84 | def test_valid_names 85 | user = JsChat::User.new nil 86 | ['alex*', "alex\n"].each do |name| 87 | assert_raises JsChat::Errors::InvalidName do 88 | user.name = name 89 | end 90 | end 91 | end 92 | 93 | def test_message_not_in_room 94 | identify_as 'nick', '#oublinet' 95 | @jschat.add_user 'alex', '#oublinet' 96 | response = JSON.parse @jschat.receive_line({ 'send' => 'hello', 'to' => '#merk' }.to_json) 97 | assert_equal 'Please join this room first', response['error']['message'] 98 | end 99 | 100 | def test_message 101 | identify_as 'nick', '#oublinet' 102 | @jschat.add_user 'alex', '#oublinet' 103 | assert @jschat.receive_line({ 'send' => 'hello', 'to' => '#oublinet' }.to_json) 104 | end 105 | 106 | def test_message_ignores_case 107 | identify_as 'nick', '#oublinet' 108 | @jschat.add_user 'alex', '#oublinet' 109 | response = @jschat.receive_line({ 'send' => 'hello', 'to' => '#Oublinet' }.to_json) 110 | assert response 111 | end 112 | 113 | def test_part 114 | identify_as 'nick', '#oublinet' 115 | @jschat.add_user 'alex', '#oublinet' 116 | response = JSON.parse @jschat.receive_line({ 'part' => '#oublinet'}.to_json) 117 | assert_equal '#oublinet', response['part']['room'] 118 | end 119 | 120 | def test_private_message 121 | identify_as 'nick' 122 | @jschat.add_user 'alex', '#oublinet' 123 | response = JSON.parse @jschat.receive_line({ 'send' => 'hello', 'to' => 'alex' }.to_json) 124 | assert_equal 'hello', response['message']['message'] 125 | end 126 | 127 | def test_private_message_ignores_case 128 | identify_as 'nick' 129 | @jschat.add_user 'alex', '#oublinet' 130 | response = JSON.parse @jschat.receive_line({ 'send' => 'hello', 'to' => 'Alex' }.to_json) 131 | assert_equal 'hello', response['message']['message'] 132 | end 133 | 134 | def test_log_request 135 | identify_as 'nick', '#oublinet' 136 | @jschat.receive_line({ 'send' => 'hello', 'to' => '#oublinet' }.to_json) 137 | response = JSON.parse @jschat.receive_line({ 'lastlog' => '#oublinet' }.to_json) 138 | assert_equal 'hello', response['messages'].last['message']['message'] 139 | end 140 | 141 | def test_name_change 142 | identify_as 'nick', '#oublinet' 143 | @jschat.add_user 'alex', '#oublinet' 144 | response = JSON.parse @jschat.receive_line({ 'change' => 'user', 'user' => { 'name' => 'bob' }}.to_json) 145 | assert_equal 'notice', response['display'] 146 | end 147 | 148 | def test_name_change_duplicate 149 | identify_as 'nick', '#oublinet' 150 | @jschat.add_user 'alex', '#oublinet' 151 | response = JSON.parse @jschat.receive_line({ 'change' => 'user', 'user' => { 'name' => 'alex' }}.to_json) 152 | assert_equal 'error', response['display'] 153 | end 154 | 155 | def test_max_message_length 156 | identify_as 'nick', '#oublinet' 157 | response = JSON.parse @jschat.receive_line({ 'send' => 'a' * 1000, 'to' => '#oublinet' }.to_json) 158 | assert response['error'] 159 | end 160 | 161 | def test_flood_protection 162 | identify_as 'nick', '#oublinet' 163 | response = '' 164 | # simulate a flood and extract the error response 165 | (1..50).detect do 166 | response = @jschat.receive_line({ 'send' => 'a' * 10, 'to' => '#oublinet' }.to_json) 167 | response.match /error/ 168 | end 169 | response = JSON.parse response 170 | assert response['error'] 171 | assert_match /wait a few seconds/i, response['error']['message'] 172 | end 173 | 174 | end 175 | 176 | -------------------------------------------------------------------------------- /test/stateless_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class StatelessTest < Test::Unit::TestCase 4 | include JsChatHelpers 5 | 6 | def setup 7 | @jschat = JsChatMock.new 8 | @jschat.post_init 9 | @cookie = get_cookie 10 | end 11 | 12 | def test_identify 13 | response = send_to_jschat({ 'identify' => 'alex', 'cookie' => @cookie }) 14 | assert_equal 'identified', response['display'] 15 | end 16 | 17 | def test_join 18 | response = identify_as 'alex2', '#jschat' 19 | assert JSON.parse(response)['join'] 20 | end 21 | 22 | def test_message 23 | response = identify_as 'nick', '#jschat' 24 | assert send_to_jschat({ 'cookie' => @cookie, 'send' => 'hello', 'to' => '#jschat' }, false) 25 | end 26 | 27 | private 28 | 29 | def get_cookie 30 | JSON.parse(@jschat.receive_line({ 'protocol' => 'stateless' }.to_json))['cookie'] 31 | end 32 | 33 | end 34 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | require 'rubygems' 3 | require 'eventmachine' 4 | gem 'json', '>= 1.1.9' 5 | require 'json' 6 | $:.unshift File.join(File.dirname(__FILE__), '..', 'lib') 7 | require File.join(File.dirname(__FILE__), '..', 'lib', 'jschat', 'server.rb') 8 | 9 | ServerConfig['max_message_length'] = 500 10 | 11 | class JsChat::Room 12 | def self.reset 13 | @@rooms = nil 14 | end 15 | end 16 | 17 | module JsChatHelpers 18 | def identify_as(name, channel = nil) 19 | if @cookie 20 | result = @jschat.receive_line({ 'identify' => name, :cookie => @cookie }.to_json) 21 | result = @jschat.receive_line({ 'join' => channel, :cookie => @cookie }.to_json) if channel 22 | else 23 | result = @jschat.receive_line({ 'identify' => name }.to_json) 24 | result = @jschat.receive_line({ 'join' => channel }.to_json) if channel 25 | end 26 | result 27 | end 28 | 29 | def send_to_jschat(h, parse = true) 30 | response = @jschat.receive_line(h.to_json) 31 | parse ? JSON.parse(response) : response 32 | end 33 | end 34 | 35 | class JsChatMock 36 | include JsChat 37 | 38 | def get_remote_ip 39 | '' 40 | end 41 | 42 | def send_data(data) 43 | data 44 | end 45 | 46 | def reset 47 | @@users = nil 48 | @user = nil 49 | Room.reset 50 | end 51 | 52 | # Helper for testing 53 | def add_user(name, room_name) 54 | room = Room.find_or_create room_name 55 | user = User.new self 56 | user.name = name 57 | user.rooms << room 58 | @@users << user 59 | room.users << user 60 | end 61 | end 62 | 63 | JsChat::Storage.enabled = false 64 | JsChat::Storage.driver = JsChat::Storage::NullDriver 65 | 66 | --------------------------------------------------------------------------------