├── TODO ├── assets ├── stylesheets │ ├── rooms.scss │ ├── users.scss │ ├── application.css │ ├── mogo_chat.scss │ ├── login.scss │ ├── common.scss │ ├── page.scss │ ├── left-panel.scss │ ├── mobile.scss │ ├── active-room.scss │ └── lib │ │ └── normalize.css ├── images │ ├── logout.png │ ├── users.png │ └── navicon.png └── javascripts │ ├── routes │ ├── rooms_index_route.js.coffee │ ├── users_index_route.js.coffee │ ├── rooms_new_route.js.coffee │ ├── room_edit_route.js.coffee │ ├── room_route.js.coffee │ ├── user_edit_route.js.coffee │ ├── user_route.js.coffee │ ├── users_new_route.js.coffee │ ├── logout_route.js.coffee │ ├── application_route.js.coffee │ ├── index_route.js.coffee │ └── authenticated_route.js.coffee │ ├── controllers │ ├── application_controller.js.coffee │ ├── rooms_index_controller.js.coffee │ ├── users_index_controller.js.coffee │ ├── room_item_controller.js.coffee │ ├── room_edit_controller.js.coffee │ ├── user_item_controller.js.coffee │ ├── user_edit_controller.js.coffee │ ├── users_new_controller.js.coffee │ ├── rooms_new_controller.js.coffee │ ├── login_controller.js.coffee │ ├── room_user_state_item_controller.js.coffee │ └── index_controller.js.coffee │ ├── transforms.js.coffee │ ├── models │ ├── room.js.coffee │ ├── user.js.coffee │ ├── message.js.coffee │ └── room_user_state.js.coffee │ ├── helpers.js.coffee │ ├── serializers.js.coffee │ ├── pollers │ ├── users_poller.js.coffee │ └── message_poller.js.coffee │ ├── plugins.js.coffee │ ├── mogo_chat.js.coffee │ ├── application.js │ ├── notifications.js │ ├── views.js.coffee │ └── lib │ ├── moment.min.js │ └── fastclick.js ├── templates ├── test.eex ├── message.html.eex └── index.html.eex ├── screenshot.png ├── Procfile ├── priv ├── static │ ├── favicon.ico │ ├── assets │ │ ├── logout.png │ │ ├── users.png │ │ ├── navicon.png │ │ └── application.css │ └── sounds │ │ └── notify.wav └── repo │ └── migrations │ ├── 20140115124723_create_rooms.exs │ ├── 20140118160925_create_messages.exs │ ├── 20140115123327_create_users.exs │ └── 20140122043504_create_room_user_states.exs ├── scripts ├── run_tests ├── setup ├── start ├── migrate └── start_with_shell ├── mogo_chat.config ├── elixir_buildpack.config ├── .gitignore ├── Gemfile ├── lib ├── mogo_chat │ ├── repo.ex │ ├── errors.ex │ ├── controllers │ │ ├── main.ex │ │ ├── room_messages.ex │ │ ├── sessions_api.ex │ │ ├── room_user_states_api.ex │ │ ├── messages_api.ex │ │ ├── rooms_api.ex │ │ └── users_api.ex │ ├── templates.ex │ ├── supervisor.ex │ ├── models │ │ ├── room.ex │ │ ├── room_user_state.ex │ │ ├── message.ex │ │ └── user.ex │ ├── config.ex │ ├── auth_error_handler.ex │ ├── app_config.ex │ ├── plugs │ │ ├── session │ │ │ ├── adapter.ex │ │ │ └── adapters │ │ │ │ └── ets.ex │ │ └── session.ex │ ├── util.ex │ ├── model_utils.ex │ ├── json_parser.ex │ ├── html_engine.ex │ ├── router.ex │ └── controller_utils.ex ├── mix │ └── tasks │ │ ├── server.ex │ │ ├── setup.ex │ │ └── setup_demo.ex └── mogo_chat.ex ├── config └── database.json.sample ├── test ├── routers │ └── application_router_test.exs ├── integration │ ├── room_management_integration_test.exs │ ├── user_management_integration_test.exs │ ├── sessions_integration_test.exs │ └── chat_interface_integration_test.exs └── test_helper.exs ├── docs ├── install-heroku.md ├── install-local.md └── api.md ├── mix.exs ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile └── mix.lock /TODO: -------------------------------------------------------------------------------- 1 | * API docs 2 | -------------------------------------------------------------------------------- /assets/stylesheets/rooms.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/stylesheets/users.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/test.eex: -------------------------------------------------------------------------------- 1 | <%= @greet %>, <%= @username %> -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HashNuke/mogo-chat/HEAD/screenshot.png -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: ELIXIR_ERL_OPTS="-config mogo_chat.config" mix do server, run -no-halt 2 | -------------------------------------------------------------------------------- /assets/images/logout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HashNuke/mogo-chat/HEAD/assets/images/logout.png -------------------------------------------------------------------------------- /assets/images/users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HashNuke/mogo-chat/HEAD/assets/images/users.png -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HashNuke/mogo-chat/HEAD/priv/static/favicon.ico -------------------------------------------------------------------------------- /scripts/run_tests: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | ELIXIR_ERL_OPTS="-config mogo_chat.config" mix test 4 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | ELIXIR_ERL_OPTS="-config mogo_chat.config" mix setup 4 | -------------------------------------------------------------------------------- /assets/images/navicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HashNuke/mogo-chat/HEAD/assets/images/navicon.png -------------------------------------------------------------------------------- /assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | *= require "lib/normalize" 3 | *= require "mogo_chat" 4 | */ 5 | -------------------------------------------------------------------------------- /mogo_chat.config: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | bcrypt, 4 | [{mechanism, port}, {pool_size, 4}] 5 | } 6 | ]. 7 | -------------------------------------------------------------------------------- /priv/static/assets/logout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HashNuke/mogo-chat/HEAD/priv/static/assets/logout.png -------------------------------------------------------------------------------- /priv/static/assets/users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HashNuke/mogo-chat/HEAD/priv/static/assets/users.png -------------------------------------------------------------------------------- /priv/static/sounds/notify.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HashNuke/mogo-chat/HEAD/priv/static/sounds/notify.wav -------------------------------------------------------------------------------- /scripts/start: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | ELIXIR_ERL_OPTS="-config mogo_chat.config" mix do server, run -no-halt -------------------------------------------------------------------------------- /priv/static/assets/navicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HashNuke/mogo-chat/HEAD/priv/static/assets/navicon.png -------------------------------------------------------------------------------- /scripts/migrate: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | ELIXIR_ERL_OPTS="-config mogo_chat.config" mix ecto.migrate Repo 4 | -------------------------------------------------------------------------------- /scripts/start_with_shell: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | ELIXIR_ERL_OPTS="-config mogo_chat.config" iex -S mix server 4 | -------------------------------------------------------------------------------- /elixir_buildpack.config: -------------------------------------------------------------------------------- 1 | erlang_version=R16B03-1 2 | elixir_version=0.12.5 3 | rebar_version=(tag 2.2.0) 4 | always_build_deps=false 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /ebin 2 | /deps 3 | /tmp/dev 4 | /tmp/test 5 | erl_crash.dump 6 | .bundle 7 | _build 8 | .#* 9 | config/database.json 10 | -------------------------------------------------------------------------------- /assets/javascripts/routes/rooms_index_route.js.coffee: -------------------------------------------------------------------------------- 1 | App.RoomsIndexRoute = App.AuthenticatedRoute.extend 2 | model: (params)-> 3 | @store.find("room") 4 | -------------------------------------------------------------------------------- /assets/javascripts/routes/users_index_route.js.coffee: -------------------------------------------------------------------------------- 1 | App.UsersIndexRoute = App.AuthenticatedRoute.extend 2 | model: (params)-> 3 | @store.find("user") 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | gem 'rake' 4 | gem 'sprockets' 5 | gem 'coffee-script' 6 | gem 'sass' 7 | gem 'bourbon' 8 | gem 'uglifier' 9 | 10 | gem 'filewatcher' 11 | -------------------------------------------------------------------------------- /assets/javascripts/controllers/application_controller.js.coffee: -------------------------------------------------------------------------------- 1 | App.ApplicationController = Em.Controller.extend 2 | currentUser: false 3 | isLeftMenuOpen: false 4 | isRightMenuOpen: false 5 | -------------------------------------------------------------------------------- /assets/javascripts/routes/rooms_new_route.js.coffee: -------------------------------------------------------------------------------- 1 | App.RoomsNewRoute = App.AuthenticatedRoute.extend 2 | setupController: (controller)-> 3 | controller.setProperties("roomName": null) 4 | -------------------------------------------------------------------------------- /assets/javascripts/routes/room_edit_route.js.coffee: -------------------------------------------------------------------------------- 1 | App.RoomEditRoute = App.AuthenticatedRoute.extend 2 | model: -> 3 | @modelFor("room") 4 | 5 | deactivate: -> 6 | @controller.get("model").rollback() 7 | -------------------------------------------------------------------------------- /assets/javascripts/routes/room_route.js.coffee: -------------------------------------------------------------------------------- 1 | App.RoomRoute = App.AuthenticatedRoute.extend 2 | model: (params)-> 3 | if params.room_id 4 | @store.find("room", params.room_id) 5 | else 6 | {} 7 | -------------------------------------------------------------------------------- /assets/javascripts/routes/user_edit_route.js.coffee: -------------------------------------------------------------------------------- 1 | App.UserEditRoute = App.AuthenticatedRoute.extend 2 | model: -> 3 | @modelFor("user") 4 | 5 | deactivate: -> 6 | @controller.get("model").rollback() 7 | -------------------------------------------------------------------------------- /assets/javascripts/routes/user_route.js.coffee: -------------------------------------------------------------------------------- 1 | App.UserRoute = App.AuthenticatedRoute.extend 2 | model: (params)-> 3 | if params.user_id 4 | @store.find("user", params.user_id) 5 | else 6 | {} 7 | -------------------------------------------------------------------------------- /assets/javascripts/transforms.js.coffee: -------------------------------------------------------------------------------- 1 | DS.ArrayTransform = DS.Transform.extend 2 | deserialize: (serialized)-> [] 3 | serialize: (deserialized)-> [] 4 | 5 | App.register("transform:array", DS.ArrayTransform) 6 | -------------------------------------------------------------------------------- /lib/mogo_chat/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Repo do 2 | use Ecto.Repo, adapter: Ecto.Adapters.Postgres 3 | import AppConfig 4 | 5 | read_db_config 6 | 7 | def priv do 8 | app_dir(:mogo_chat, "priv/repo") 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /assets/stylesheets/mogo_chat.scss: -------------------------------------------------------------------------------- 1 | @import "bourbon"; 2 | @import "common"; 3 | @import "login"; 4 | @import "active-room"; 5 | @import "left-panel"; 6 | @import "users"; 7 | @import "rooms"; 8 | @import "page"; 9 | @import "mobile"; 10 | 11 | -------------------------------------------------------------------------------- /lib/mogo_chat/errors.ex: -------------------------------------------------------------------------------- 1 | defmodule MogoChat.Errors do 2 | 3 | defexception Unauthorized, [:message] do 4 | defimpl Plug.Exception do 5 | def status(_exception) do 6 | 401 7 | end 8 | end 9 | end 10 | 11 | end -------------------------------------------------------------------------------- /assets/javascripts/models/room.js.coffee: -------------------------------------------------------------------------------- 1 | App.Room = DS.Model.extend 2 | name: DS.attr("string") 3 | roomUserState: DS.belongsTo("room_user_state") 4 | messages: DS.attr("array") 5 | users: DS.attr("array") 6 | isHistoryAvailable: DS.attr("boolean") 7 | -------------------------------------------------------------------------------- /config/database.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "dev": "ecto://postgres_username:password@localhost/mogo_chat_development", 3 | "prod": "ecto://postgres_username:password@localhost/mogo_chat_production", 4 | "test": "ecto://postgres_username:password@localhost/mogo_chat_development" 5 | } 6 | -------------------------------------------------------------------------------- /assets/javascripts/helpers.js.coffee: -------------------------------------------------------------------------------- 1 | Em.Handlebars.helper 'readable-time', (value, options)-> 2 | time = moment(value) 3 | difference = moment().unix() - time.unix() 4 | if difference > 31536000 5 | time.format("h:mma, D MMM YYYY") 6 | else 7 | time.format("h:mma, D MMM") 8 | -------------------------------------------------------------------------------- /lib/mogo_chat/controllers/main.ex: -------------------------------------------------------------------------------- 1 | defmodule MogoChat.Controllers.Main do 2 | use Phoenix.Controller 3 | import MogoChat.ControllerUtils 4 | 5 | def index(conn) do 6 | {:safe, template} = MogoChat.Templates.index() 7 | html conn, template 8 | end 9 | 10 | end 11 | -------------------------------------------------------------------------------- /lib/mogo_chat/templates.ex: -------------------------------------------------------------------------------- 1 | defmodule MogoChat.Templates do 2 | require EEx 3 | 4 | EEx.function_from_file :def, :index, "templates/index.html.eex", [], [engine: HTMLEngine] 5 | EEx.function_from_file :def, :message, "templates/message.html.eex", [:message], [engine: HTMLEngine] 6 | end -------------------------------------------------------------------------------- /lib/mix/tasks/server.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Server do 2 | use Mix.Task 3 | 4 | @shortdoc "Start server" 5 | 6 | @moduledoc """ 7 | Server task. 8 | """ 9 | def run(_) do 10 | :application.ensure_all_started(:mogo_chat) 11 | MogoChat.Router.start 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /assets/javascripts/routes/users_new_route.js.coffee: -------------------------------------------------------------------------------- 1 | App.UsersNewRoute = App.AuthenticatedRoute.extend 2 | setupController: (controller, model)-> 3 | controller.set("model", @store.createRecord("user", {role: "member"})) 4 | 5 | 6 | deactivate: -> 7 | @controller.get("model").rollback() 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20140115124723_create_rooms.exs: -------------------------------------------------------------------------------- 1 | defmodule Repo.Migrations.CreateRooms do 2 | use Ecto.Migration 3 | 4 | def up do 5 | "CREATE TABLE IF NOT EXISTS rooms(id serial primary key, name text)" 6 | end 7 | 8 | def down do 9 | "DROP TABLE rooms" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/mogo_chat/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule MogoChat.Supervisor do 2 | use Supervisor.Behaviour 3 | 4 | def start_link do 5 | :supervisor.start_link(__MODULE__, []) 6 | end 7 | 8 | def init([]) do 9 | children = [ worker(Repo, []) ] 10 | supervise(children, strategy: :one_for_one) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /templates/message.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | <%= message[:user][:name] %>: 5 | 6 | <%= if message[:type] == "paste" do %> 7 |

<%= message[:body] %>
8 | <%= else %> 9 | <%= message[:body] %> 10 | <% end %> 11 |

12 | 13 | 14 | -------------------------------------------------------------------------------- /lib/mogo_chat.ex: -------------------------------------------------------------------------------- 1 | defmodule MogoChat do 2 | use Application.Behaviour 3 | 4 | # See http://elixir-lang.org/docs/stable/Application.Behaviour.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | :ets.new :plug_sessions, [:named_table, :public, {:read_concurrency, true}] 8 | MogoChat.Supervisor.start_link 9 | end 10 | 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20140118160925_create_messages.exs: -------------------------------------------------------------------------------- 1 | defmodule Repo.Migrations.CreateMessages do 2 | use Ecto.Migration 3 | 4 | def up do 5 | "CREATE TABLE IF NOT EXISTS messages(id serial primary key, body text, type text, user_id integer, room_id integer, created_at timestamp)" 6 | end 7 | 8 | def down do 9 | "DROP TABLE messages" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /assets/javascripts/serializers.js.coffee: -------------------------------------------------------------------------------- 1 | App.RoomUserStateSerializer = DS.ActiveModelSerializer.extend(DS.EmbeddedRecordsMixin, { 2 | attrs: { 3 | room: {embedded: "load"}, 4 | user: {embedded: "load"} 5 | } 6 | }) 7 | 8 | 9 | App.MessageSerializer = DS.ActiveModelSerializer.extend(DS.EmbeddedRecordsMixin, { 10 | attrs: { 11 | user: {embedded: "load"} 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20140115123327_create_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Repo.Migrations.CreateUsers do 2 | use Ecto.Migration 3 | 4 | def up do 5 | "CREATE TABLE IF NOT EXISTS users(id serial primary key, name text, email text, role text, encrypted_password text, auth_token text, archived boolean DEFAULT FALSE)" 6 | end 7 | 8 | def down do 9 | "DROP TABLE users" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20140122043504_create_room_user_states.exs: -------------------------------------------------------------------------------- 1 | defmodule Repo.Migrations.CreateRoomUserStates do 2 | use Ecto.Migration 3 | 4 | def up do 5 | "CREATE TABLE IF NOT EXISTS room_user_states(id serial primary key, user_id integer, room_id integer, joined boolean, last_pinged_at timestamp)" 6 | end 7 | 8 | def down do 9 | "DROP TABLE room_user_states" 10 | end 11 | 12 | end 13 | -------------------------------------------------------------------------------- /assets/javascripts/controllers/rooms_index_controller.js.coffee: -------------------------------------------------------------------------------- 1 | App.RoomsIndexController = Em.ArrayController.extend 2 | needs: ["application"] 3 | currentUser: Ember.computed.alias("controllers.application.currentUser") 4 | isLeftMenuOpen: Ember.computed.alias("controllers.application.isLeftMenuOpen") 5 | isRightMenuOpen: Ember.computed.alias("controllers.application.isRightMenuOpen") 6 | 7 | itemController: "RoomItem" 8 | -------------------------------------------------------------------------------- /assets/javascripts/routes/logout_route.js.coffee: -------------------------------------------------------------------------------- 1 | App.LogoutRoute = App.AuthenticatedRoute.extend 2 | setupController: (model, controller)-> 3 | Em.$.ajax(url: "/api/sessions", type: "DELETE") 4 | .then (result)=> 5 | return if !result.ok 6 | @controllerFor("application").get("currentUser").deleteRecord() 7 | @controllerFor("application").set("currentUser", false) 8 | @transitionTo("login") 9 | -------------------------------------------------------------------------------- /assets/javascripts/controllers/users_index_controller.js.coffee: -------------------------------------------------------------------------------- 1 | App.UsersIndexController = Em.ArrayController.extend 2 | needs: ["application"] 3 | currentUser: Ember.computed.alias("controllers.application.currentUser") 4 | isLeftMenuOpen: Ember.computed.alias("controllers.application.isLeftMenuOpen") 5 | isRightMenuOpen: Ember.computed.alias("controllers.application.isRightMenuOpen") 6 | 7 | itemController: "UserItem" 8 | title: "Users" 9 | -------------------------------------------------------------------------------- /lib/mogo_chat/models/room.ex: -------------------------------------------------------------------------------- 1 | defmodule Room do 2 | use Ecto.Model 3 | use MogoChat.ModelUtils 4 | 5 | queryable "rooms" do 6 | field :name, :string 7 | has_many :messages, Message 8 | has_many :room_user_states, RoomUserState 9 | end 10 | 11 | validate room, 12 | name: present(), 13 | name: has_length(1..30) 14 | 15 | def public_attributes(record) do 16 | attributes(record, ["id", "name"]) 17 | end 18 | 19 | end 20 | -------------------------------------------------------------------------------- /test/routers/application_router_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApplicationRouterTest do 2 | use MogoChat.TestCase 3 | use Dynamo.HTTP.Case 4 | 5 | # Sometimes it may be convenient to test a specific 6 | # aspect of a router in isolation. For such, we just 7 | # need to set the @endpoint to the router under test. 8 | @endpoint ApplicationRouter 9 | 10 | # test "returns OK" do 11 | # conn = get("/") 12 | # assert conn.status == 200 13 | # end 14 | end 15 | -------------------------------------------------------------------------------- /assets/javascripts/routes/application_route.js.coffee: -------------------------------------------------------------------------------- 1 | App.ApplicationRoute = Em.Route.extend 2 | actions: 3 | toggleLeftMenu: -> 4 | @controllerFor("application").set( 5 | "isLeftMenuOpen", 6 | !@controllerFor("application").get("isLeftMenuOpen") 7 | ) 8 | 9 | toggleRightMenu: -> 10 | @controllerFor("application").set( 11 | "isRightMenuOpen", 12 | !@controllerFor("application").get("isRightMenuOpen") 13 | ) 14 | -------------------------------------------------------------------------------- /assets/javascripts/models/user.js.coffee: -------------------------------------------------------------------------------- 1 | App.User = DS.Model.extend 2 | name: DS.attr("string") 3 | email: DS.attr("string") 4 | role: DS.attr("string") 5 | password: DS.attr("string") 6 | color: DS.attr("string") 7 | archived: DS.attr("boolean", defaultValue: false) 8 | authToken: DS.attr("string") 9 | 10 | isAdmin: (-> 11 | @get("role") == "admin" 12 | ).property("role") 13 | 14 | 15 | borderStyle: (-> 16 | "border-left: 0.2em solid #{@get("color")};" 17 | ).property("color") 18 | 19 | fontColor: (-> 20 | "color: #{@get("color")};" 21 | ).property("color") 22 | -------------------------------------------------------------------------------- /assets/javascripts/pollers/users_poller.js.coffee: -------------------------------------------------------------------------------- 1 | App.UsersPoller = Em.Object.extend 2 | 3 | start: ()-> 4 | @started = true 5 | @fetchUsers() && @timer = setInterval(@fetchUsers.bind(@), 5000) 6 | 7 | setRoomState: (roomState)-> 8 | @roomState = roomState 9 | 10 | stop: -> 11 | return true if !@started 12 | clearInterval(@timer) 13 | @started = false 14 | 15 | 16 | fetchUsers: -> 17 | $.getJSON "/api/rooms/#{@roomState.get("room.id")}/users", (response)=> 18 | return true if !response.users 19 | #TODO normalizePayload of serializer doesn't seem to do much 20 | @roomState.trigger "addUsers", response.users 21 | -------------------------------------------------------------------------------- /assets/javascripts/controllers/room_item_controller.js.coffee: -------------------------------------------------------------------------------- 1 | App.RoomItemController = Em.ObjectController.extend 2 | needs: ["application"] 3 | currentUser: Ember.computed.alias("controllers.application.currentUser") 4 | isLeftMenuOpen: Ember.computed.alias("controllers.application.isLeftMenuOpen") 5 | isRightMenuOpen: Ember.computed.alias("controllers.application.isRightMenuOpen") 6 | 7 | actions: 8 | remove: -> 9 | room = @get("model") 10 | room.deleteRecord() 11 | successCallback = => 12 | console.log("deleted") 13 | errorCallback = => 14 | console.log("error deleting room...") 15 | room.save().then(successCallback, errorCallback) 16 | -------------------------------------------------------------------------------- /lib/mogo_chat/config.ex: -------------------------------------------------------------------------------- 1 | # Production 2 | defmodule MogoChat.Config do 3 | use Phoenix.Config.App 4 | 5 | config :router, port: System.get_env("PORT") || 4000 6 | config :plugs, code_reload: false 7 | config :logger, level: :error 8 | end 9 | 10 | # Development 11 | defmodule MogoChat.Config.Dev do 12 | use MogoChat.Config 13 | 14 | config :router, port: System.get_env("PORT") || 4000 15 | config :plugs, code_reload: false 16 | config :logger, level: :error 17 | end 18 | 19 | # Test 20 | defmodule MogoChat.Config.Test do 21 | use MogoChat.Config 22 | 23 | config :router, port: System.get_env("PORT") || 8888 24 | config :plugs, code_reload: true 25 | config :logger, level: :debug 26 | end -------------------------------------------------------------------------------- /lib/mogo_chat/auth_error_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule MogoChat.AuthErrorHandler do 2 | @behaviour Plug.Wrapper 3 | 4 | import Plug.Connection 5 | import Phoenix.Controller 6 | import MogoChat.ControllerUtils 7 | 8 | def init(opts), do: opts 9 | 10 | def wrap(conn, _opts, fun) do 11 | try do 12 | fun.(conn) 13 | catch 14 | kind, MogoChat.Errors.Unauthorized[message: reason] -> 15 | stacktrace = System.stacktrace 16 | if xhr?(conn) || hd(conn.path_info) == "api" do 17 | json conn, 401, '{"error": "Unauthorized! Please find some other property to trespass"}' 18 | else 19 | redirect conn, "/#/login" 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/integration/room_management_integration_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RoomManagementIntegrationTest do 2 | use MogoChat.TestCase 3 | use Hound.Helpers 4 | import TestUtils 5 | 6 | hound_session 7 | truncate_db_after_test 8 | 9 | 10 | test "admin should be able to view rooms" do 11 | end 12 | 13 | 14 | test "admin should be able to add rooms" do 15 | end 16 | 17 | 18 | test "admin should be able to remove rooms" do 19 | end 20 | 21 | 22 | test "admin should be able to edit rooms" do 23 | end 24 | 25 | 26 | test "member should *not* be able to manage rooms" do 27 | end 28 | 29 | 30 | test "admin should be able to go back to chat using navigation" do 31 | end 32 | 33 | end 34 | -------------------------------------------------------------------------------- /lib/mogo_chat/models/room_user_state.ex: -------------------------------------------------------------------------------- 1 | defmodule RoomUserState do 2 | use Ecto.Model 3 | use MogoChat.ModelUtils 4 | 5 | queryable "room_user_states" do 6 | belongs_to :user, User 7 | belongs_to :room, Room 8 | field :joined, :boolean 9 | field :last_pinged_at, :datetime 10 | end 11 | 12 | 13 | validate room_user_state, 14 | user_id: present(), 15 | room_id: present(), 16 | joined: member_of([true, false]) 17 | 18 | 19 | def public_attributes(record) do 20 | attrs = attributes(record, ["id", "room_id", "user_id", "joined"]) 21 | if record.last_pinged_at do 22 | attrs ++ [last_pinged_at: timestamp(record.last_pinged_at)] 23 | else 24 | attrs 25 | end 26 | end 27 | 28 | end 29 | -------------------------------------------------------------------------------- /assets/javascripts/models/message.js.coffee: -------------------------------------------------------------------------------- 1 | App.Message = DS.Model.extend 2 | body: DS.attr("string") 3 | formattedBody: DS.attr("string", defaultValue: "*this is empty*") 4 | type: DS.attr("string") 5 | createdAt: DS.attr("string") 6 | errorPosting: DS.attr("boolean", defaultValue: false) 7 | user: DS.belongsTo("user") 8 | room: DS.belongsTo("room") 9 | 10 | condensedBody: (-> 11 | splitMsg = @get("body").split("\n") 12 | if splitMsg.length > 5 13 | newMsg = splitMsg.slice(0, 4) 14 | newMsg.push("...") 15 | newMsg.join("\n") 16 | else 17 | splitMsg.join("\n") 18 | ).property("body") 19 | 20 | link: (-> 21 | "/rooms/#{@get("room.id")}/messages/#{@get("id")}" 22 | ).property(["id", "roomId"]) 23 | -------------------------------------------------------------------------------- /assets/javascripts/controllers/room_edit_controller.js.coffee: -------------------------------------------------------------------------------- 1 | App.RoomEditController = Em.Controller.extend 2 | needs: ["application"] 3 | currentUser: Ember.computed.alias("controllers.application.currentUser") 4 | isLeftMenuOpen: Ember.computed.alias("controllers.application.isLeftMenuOpen") 5 | isRightMenuOpen: Ember.computed.alias("controllers.application.isRightMenuOpen") 6 | 7 | actions: 8 | save: -> 9 | successCallback = => 10 | @transitionToRoute("rooms.index") 11 | 12 | errorCallback = (response) => 13 | if response.errors 14 | @set("errors", response.errors) 15 | else 16 | @set("errorMsg", "Oops ~! something went wrong") 17 | @get("model").save().then(successCallback, errorCallback) 18 | -------------------------------------------------------------------------------- /assets/javascripts/controllers/user_item_controller.js.coffee: -------------------------------------------------------------------------------- 1 | App.UserItemController = Em.ObjectController.extend 2 | needs: ["application"] 3 | currentUser: Ember.computed.alias("controllers.application.currentUser") 4 | isLeftMenuOpen: Ember.computed.alias("controllers.application.isLeftMenuOpen") 5 | isRightMenuOpen: Ember.computed.alias("controllers.application.isRightMenuOpen") 6 | 7 | isCurrentUser: (-> 8 | @get("currentUser").id == @get("model").id 9 | ).property("currentUser") 10 | 11 | actions: 12 | remove: -> 13 | user = @get("model") 14 | user.deleteRecord() 15 | successCallback = => 16 | console.log("deleted") 17 | errorCallback = => 18 | console.log("error whatever...") 19 | user.save().then(successCallback, errorCallback) 20 | -------------------------------------------------------------------------------- /assets/javascripts/controllers/user_edit_controller.js.coffee: -------------------------------------------------------------------------------- 1 | App.UserEditController = Em.Controller.extend 2 | needs: ["application"] 3 | currentUser: Ember.computed.alias("controllers.application.currentUser") 4 | isLeftMenuOpen: Ember.computed.alias("controllers.application.isLeftMenuOpen") 5 | isRightMenuOpen: Ember.computed.alias("controllers.application.isRightMenuOpen") 6 | 7 | validRoles: ["member", "admin"] 8 | 9 | actions: 10 | save: -> 11 | successCallback = => 12 | @transitionToRoute("users.index") 13 | 14 | errorCallback = (response) => 15 | if response.errors 16 | @set("errors", response.errors) 17 | else 18 | @set("errorMsg", "Oops ~! something went wrong") 19 | @get("model").save().then(successCallback, errorCallback) 20 | -------------------------------------------------------------------------------- /lib/mogo_chat/app_config.ex: -------------------------------------------------------------------------------- 1 | defmodule AppConfig do 2 | 3 | defmacro read_db_config do 4 | cond do 5 | System.get_env("STACK") == "cedar" -> 6 | quote do 7 | def conf do 8 | parse_url System.get_env("DATABASE_URL") 9 | end 10 | end 11 | 12 | File.exists?("config/database.json")-> 13 | {:ok, config_json} = File.read "config/database.json" 14 | {:ok, config} = JSEX.decode config_json 15 | database_url = config["#{Mix.env}"] 16 | 17 | quote do 18 | def conf do 19 | parse_url unquote(database_url) 20 | end 21 | end 22 | true -> 23 | raise("Database details not found. Either create a config/database.json or set DATABASE_URL env") 24 | end 25 | 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /docs/install-heroku.md: -------------------------------------------------------------------------------- 1 | # Heroku install instructions 2 | 3 | You'll need a [Heroku](http://heroku.com) account and the [Heroku Toolbelt](https://toolbelt.heroku.com/). 4 | 5 | Once you have them, copy-paste the following commands in your terminal. 6 | 7 | __Copy all the commands at once and paste it. (Don't copy-paste one by one. It's tedious.)__ 8 | 9 | ``` 10 | git clone https://github.com/HashNuke/mogo-chat.git 11 | cd mogo-chat 12 | heroku create --buildpack "https://github.com/HashNuke/heroku-buildpack-elixir.git" 13 | git push heroku master 14 | heroku run bash scripts/migrate && heroku run bash scripts/setup && heroku apps:info 15 | ``` 16 | 17 | The last command will output your Heroku app's URL. Enjoy ~! 18 | 19 | Visit the app and use `admin@example.com` as your login email and the password is `password`. 20 | -------------------------------------------------------------------------------- /lib/mogo_chat/plugs/session/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Plugs.Session.Adapter do 2 | @moduledoc """ 3 | Specification of the session adapter API implemented 4 | by adapters. 5 | """ 6 | use Behaviour 7 | 8 | @type opts :: Keyword.t 9 | @type sid :: binary 10 | @type data :: iodata 11 | @type reason :: String.t 12 | 13 | @doc """ 14 | Defines a handler for starup logic. 15 | """ 16 | defcallback init(opts) :: opts 17 | 18 | @doc """ 19 | Defines ability to get `data` about the session for 20 | a particular session id (`sid`). 21 | """ 22 | defcallback get(sid) :: {:ok, data} | {:error, reason} 23 | 24 | @doc """ 25 | Defines ability to put `data` about the session for 26 | a particular session id (`sid`). 27 | """ 28 | defcallback put(sid, data) :: :ok | {:error, reason} 29 | end -------------------------------------------------------------------------------- /lib/mogo_chat/util.ex: -------------------------------------------------------------------------------- 1 | defmodule MogoChat.Util do 2 | 3 | @doc """ 4 | Borrowed from ericmj's hex_web https://github.com/ericmj/hex_web/blob/master/lib/hex_web/util.ex#L43-L56 5 | Read the body from a Plug connection. 6 | 7 | Should be in Plug proper eventually and can be removed at that point. 8 | """ 9 | def read_body({ :ok, buffer, state }, acc, limit, adapter) when limit >= 0, 10 | do: read_body(adapter.stream_req_body(state, 1_000_000), acc <> buffer, limit - byte_size(buffer), adapter) 11 | def read_body({ :ok, _, state }, _acc, _limit, _adapter), 12 | do: { :too_large, state } 13 | 14 | def read_body({ :done, state }, acc, limit, _adapter) when limit >= 0, 15 | do: { :ok, acc, state } 16 | def read_body({ :done, state }, _acc, _limit, _adapter), 17 | do: { :too_large, state } 18 | 19 | end -------------------------------------------------------------------------------- /lib/mogo_chat/controllers/room_messages.ex: -------------------------------------------------------------------------------- 1 | defmodule MogoChat.Controllers.RoomMessages do 2 | use Phoenix.Controller 3 | import Ecto.Query 4 | import MogoChat.ControllerUtils 5 | 6 | 7 | def show(conn) do 8 | conn = authenticate_user!(conn) 9 | message_id = binary_to_integer(conn.params["message_id"]) 10 | room_id = binary_to_integer(conn.params["room_id"]) 11 | query = from m in Message, 12 | where: m.id == ^message_id and m.room_id == ^room_id, 13 | preload: :user 14 | 15 | [message] = Repo.all query 16 | message_params = message.__entity__(:keywords) 17 | user_params = message.user.get.__entity__(:keywords) 18 | message = message_params ++ [user: user_params] 19 | 20 | {:safe, template} = MogoChat.Templates.message(message) 21 | html conn, template 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /assets/javascripts/controllers/users_new_controller.js.coffee: -------------------------------------------------------------------------------- 1 | App.UsersNewController = Em.Controller.extend 2 | needs: ["application"] 3 | currentUser: Ember.computed.alias("controllers.application.currentUser") 4 | isLeftMenuOpen: Ember.computed.alias("controllers.application.isLeftMenuOpen") 5 | isRightMenuOpen: Ember.computed.alias("controllers.application.isRightMenuOpen") 6 | 7 | validRoles: ["member", "admin"] 8 | 9 | actions: 10 | save: -> 11 | @get("model").set("color", App.paintBox.getColor()) 12 | 13 | successCallback = => 14 | @transitionToRoute("users.index") 15 | 16 | errorCallback = (response) => 17 | if response.errors 18 | @set("errors", response.errors) 19 | else 20 | @set("errorMsg", "Oops ~! something went wrong") 21 | @get("model").save().then(successCallback, errorCallback) 22 | -------------------------------------------------------------------------------- /assets/javascripts/controllers/rooms_new_controller.js.coffee: -------------------------------------------------------------------------------- 1 | App.RoomsNewController = Em.Controller.extend 2 | needs: ["application"] 3 | currentUser: Ember.computed.alias("controllers.application.currentUser") 4 | isLeftMenuOpen: Ember.computed.alias("controllers.application.isLeftMenuOpen") 5 | isRightMenuOpen: Ember.computed.alias("controllers.application.isRightMenuOpen") 6 | 7 | actions: 8 | save: -> 9 | roomAttributes = {name: @get("roomName")} 10 | room = @store.createRecord("room", roomAttributes) 11 | 12 | successCallback = => 13 | @transitionToRoute("rooms.index") 14 | 15 | errorCallback = (response) => 16 | if response.errors 17 | @set("errors", response.errors) 18 | else 19 | @set("errorMsg", "Oops ~! something went wrong") 20 | 21 | room.save().then(successCallback, errorCallback) 22 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule MogoChat.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ app: :mogo_chat, 6 | version: "0.0.1", 7 | build_per_environment: true, 8 | elixir: "~> 0.12.5", 9 | deps: deps ] 10 | end 11 | 12 | # Configuration for the OTP application 13 | def application do 14 | [ 15 | applications: [:phoenix, :bcrypt, :qdate, :jsex, :uuid], 16 | mod: { MogoChat, []} 17 | ] 18 | end 19 | 20 | 21 | defp deps do 22 | [ 23 | {:phoenix, github: "phoenixframework/phoenix"}, 24 | {:ecto, github: "elixir-lang/ecto"}, 25 | {:postgrex, github: "ericmj/postgrex"}, 26 | {:jsex, github: "talentdeficit/jsex"}, 27 | {:qdate, github: "choptastic/qdate" }, 28 | {:bcrypt, github: "irccloud/erlang-bcrypt"}, 29 | {:uuid, github: "okeuday/uuid"} 30 | ] 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/mogo_chat/model_utils.ex: -------------------------------------------------------------------------------- 1 | defmodule MogoChat.ModelUtils do 2 | 3 | defmacro __using__(_) do 4 | quote do 5 | def attributes(record, fields) do 6 | lc field inlist fields do 7 | { "#{field}", apply(record, :"#{field}", []) } 8 | end 9 | end 10 | 11 | def assign_attributes(record, params) do 12 | Enum.reduce params, record, fn({field, value}, accumulated_record) -> 13 | apply(accumulated_record, :"#{field}", [value]) 14 | end 15 | end 16 | 17 | def timestamp(ecto_datetime) do 18 | datetime = { 19 | {ecto_datetime.year, ecto_datetime.month, ecto_datetime.day}, 20 | {ecto_datetime.hour, ecto_datetime.min, ecto_datetime.sec} 21 | } 22 | "#{:qdate.to_string("Y-m-d", datetime)}T#{:qdate.to_string("H:i:s", datetime)}Z" 23 | end 24 | 25 | end 26 | end 27 | 28 | end 29 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: http://rubygems.org/ 3 | specs: 4 | bourbon (3.1.8) 5 | sass (>= 3.2.0) 6 | thor 7 | coffee-script (2.2.0) 8 | coffee-script-source 9 | execjs 10 | coffee-script-source (1.7.0) 11 | execjs (2.0.2) 12 | filewatcher (0.3.2) 13 | trollop (>= 1.16.2) 14 | hike (1.2.3) 15 | json (1.8.1) 16 | multi_json (1.8.4) 17 | rack (1.5.2) 18 | rake (10.1.1) 19 | sass (3.2.13) 20 | sprockets (2.10.1) 21 | hike (~> 1.2) 22 | multi_json (~> 1.0) 23 | rack (~> 1.0) 24 | tilt (~> 1.1, != 1.3.0) 25 | thor (0.18.1) 26 | tilt (1.4.1) 27 | trollop (2.0) 28 | uglifier (2.4.0) 29 | execjs (>= 0.3.0) 30 | json (>= 1.8.0) 31 | 32 | PLATFORMS 33 | ruby 34 | 35 | DEPENDENCIES 36 | bourbon 37 | coffee-script 38 | filewatcher 39 | rake 40 | sass 41 | sprockets 42 | uglifier 43 | -------------------------------------------------------------------------------- /assets/stylesheets/login.scss: -------------------------------------------------------------------------------- 1 | .login { 2 | font-size: 1.1em; 3 | background: #FFF; 4 | padding: 0.5em; 5 | margin: auto; 6 | position: absolute; 7 | top: 0em; 8 | right: 0em; 9 | left: 0em; 10 | bottom: 0em; 11 | 12 | .title { 13 | color: #06c; 14 | font-size: 1.5em; 15 | border-bottom: 0.1em solid #06C; 16 | margin-bottom: 1em; 17 | } 18 | 19 | .field { 20 | margin-bottom: 1em; 21 | 22 | label { 23 | color: #666; 24 | font-size: 0.9em; 25 | } 26 | 27 | input { 28 | border: 0.1em solid #999; 29 | padding: 0.2em; 30 | width: 100%; 31 | color: #444; 32 | } 33 | } 34 | 35 | 36 | .actions { 37 | input { 38 | background: #FFF; 39 | color: #06C; 40 | border: 1px solid #06C; 41 | border-radius: 0.15em; 42 | box-shadow: 0em 0em 0.2em 0.05em #0073E6; 43 | } 44 | } 45 | 46 | .error { 47 | width: 100%; 48 | color: #C00; 49 | margin-top: 1em; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/mogo_chat/json_parser.ex: -------------------------------------------------------------------------------- 1 | #NOTE Borrowed from ericmj's hex_web project 2 | defmodule MogoChat.JsonParser do 3 | alias Plug.Conn 4 | 5 | def parse(Conn[] = conn, "application", "json", _headers, opts) do 6 | if conn.method != "GET" do 7 | read_body(conn, Keyword.fetch!(opts, :limit)) 8 | else 9 | { :next, conn } 10 | end 11 | end 12 | 13 | def parse(conn, _type, _subtype, _headers, _opts) do 14 | { :next, conn } 15 | end 16 | 17 | defp read_body(Conn[adapter: { adapter, state }] = conn, limit) do 18 | case MogoChat.Util.read_body({ :ok, "", state }, "", limit, adapter) do 19 | { :too_large, state } -> 20 | { :too_large, conn.adapter({ adapter, state }) } 21 | { :ok, body, state } -> 22 | case JSEX.decode(body) do 23 | { :ok, params } -> 24 | { :ok, params, conn.adapter({ adapter, state }) } 25 | _ -> 26 | raise MogoChat.Util.BadRequest, message: "malformed JSON" 27 | end 28 | end 29 | end 30 | end -------------------------------------------------------------------------------- /lib/mogo_chat/plugs/session/adapters/ets.ex: -------------------------------------------------------------------------------- 1 | defmodule Plugs.Session.Adapters.Ets do 2 | @behaviour Plugs.Session.Adapter 3 | 4 | @table :plug_sessions 5 | @max_tries 5000 6 | 7 | def init(opts), do: opts 8 | 9 | def get(sid) do 10 | case :ets.lookup(@table, sid) do 11 | [{_, data}] -> 12 | data 13 | _ -> 14 | nil 15 | end 16 | end 17 | 18 | 19 | def put(sid, data, tries \\ 0) when tries < @max_tries do 20 | # check_table 21 | if :ets.insert(@table, {sid, data}) do 22 | :ok 23 | else 24 | put sid, data, tries + 1 25 | end 26 | end 27 | def put(sid, _data ,tries) do 28 | {:error, "Unable to save data for '#{sid}' after #{tries} attempts."} 29 | end 30 | 31 | 32 | def delete(sid) do 33 | :ets.delete(@table, sid) 34 | end 35 | 36 | 37 | defp check_table do 38 | case :ets.info @table do 39 | :undefined -> 40 | :ets.new @table, [:named_table, :public, {:read_concurrency, true}] 41 | _ -> :ok 42 | end 43 | end 44 | end -------------------------------------------------------------------------------- /test/integration/user_management_integration_test.exs: -------------------------------------------------------------------------------- 1 | defmodule UserManagementIntegrationTest do 2 | use MogoChat.TestCase 3 | use Hound.Helpers 4 | import TestUtils 5 | 6 | hound_session 7 | truncate_db_after_test 8 | 9 | 10 | test "admin should be able to view users" do 11 | end 12 | 13 | 14 | test "admin should be able to add users" do 15 | end 16 | 17 | 18 | test "admin should be able to remove users" do 19 | end 20 | 21 | 22 | test "admin should be able to edit users" do 23 | end 24 | 25 | 26 | test "admin should be able to update password for user and user must be able to login with new password" do 27 | end 28 | 29 | 30 | test "when admin edits user, password should not change" do 31 | end 32 | 33 | 34 | test "admin should *not* be able to remove self" do 35 | end 36 | 37 | 38 | test "member should be able to edit own account" do 39 | end 40 | 41 | 42 | test "member should be able to change own password" do 43 | end 44 | 45 | 46 | test "member should *not* be able to manage users" do 47 | end 48 | 49 | end 50 | -------------------------------------------------------------------------------- /assets/stylesheets/common.scss: -------------------------------------------------------------------------------- 1 | * { @include box-sizing(border-box); } 2 | 3 | html, body { 4 | width: 100%; 5 | height: 100%; 6 | color: #333; 7 | font-size: 16px; 8 | font-family: 'PT Sans', sans-serif; 9 | } 10 | 11 | .app-name { width: 100%; } 12 | 13 | .app-audio { display: none; } 14 | 15 | .container { 16 | width: 100%; 17 | height: 100%; 18 | float: left; 19 | overflow: hidden; 20 | } 21 | 22 | .left-panel .title, .room-details .title { 23 | font-size: 0.85em; 24 | color: #06c; 25 | text-transform: uppercase; 26 | border-bottom: 0.1em solid #CCC; 27 | margin-bottom: 0.5em; 28 | } 29 | 30 | .settings { 31 | width: 100%; 32 | height: 100%; 33 | float: left; 34 | 35 | .detail { 36 | position: absolute; 37 | top: 0em; 38 | right: 0em; 39 | bottom: 0em; 40 | overflow-y: scroll; 41 | 42 | .header { 43 | width: 100%; 44 | position: fixed; 45 | top: 0em; 46 | color: #06c; 47 | 48 | .title { 49 | float: left; 50 | font-size: 1.5em; 51 | text-transform: capitalize; 52 | } 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Akash Manohar J 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /assets/javascripts/controllers/login_controller.js.coffee: -------------------------------------------------------------------------------- 1 | App.LoginController = Em.Controller.extend 2 | needs: ["application"] 3 | 4 | actions: 5 | login: -> 6 | data = @getProperties("email", "password") 7 | 8 | errorCallback = (response) => 9 | if response.responseJSON 10 | @set("error", response.responseJSON.error) 11 | else 12 | @set("error", "Oops ~! something went wrong") 13 | 14 | successCallback = (response)=> 15 | userAttributes = { 16 | id: response.user.id, 17 | name: response.user.name, 18 | role: response.user.role, 19 | color: App.paintBox.getColor() 20 | } 21 | 22 | if @store.recordIsLoaded("user", userAttributes.id) 23 | @store.find("user", userAttributes.id).then (user)=> 24 | @set("controllers.application.currentUser", user) 25 | @transitionToRoute("index") 26 | else 27 | user = @store.push("user", userAttributes) 28 | @set("controllers.application.currentUser", user) 29 | @transitionToRoute("index") 30 | Em.$.post("/api/sessions", data).then successCallback, errorCallback 31 | -------------------------------------------------------------------------------- /assets/javascripts/routes/index_route.js.coffee: -------------------------------------------------------------------------------- 1 | App.IndexRoute = App.AuthenticatedRoute.extend 2 | setupController: (controller, model)-> 3 | activeState = null 4 | 5 | # Loop thru all room states 6 | # find the first joined room to load 7 | # start pollers for all joined rooms 8 | model.forEach (item)=> 9 | 10 | # This is to make sure the first active room is loaded 11 | if item.get("joined") == true 12 | if !activeState? 13 | activeState = item 14 | activeState.set("active", true) 15 | 16 | item.messagePoller = new App.MessagePoller() 17 | item.messagePoller.setRoomState item 18 | item.messagePoller.start() 19 | 20 | item.usersPoller = new App.UsersPoller() 21 | item.usersPoller.setRoomState item 22 | item.usersPoller.start() 23 | 24 | controller.set("activeState", activeState) 25 | @_super(controller, model) 26 | 27 | 28 | model: -> 29 | @store.find("room_user_state") 30 | 31 | deactivate: -> 32 | @controller.get("model").forEach (item)=> 33 | item.messagePoller.stop() if item.messagePoller 34 | item.usersPoller.stop() if item.usersPoller 35 | -------------------------------------------------------------------------------- /assets/javascripts/plugins.js.coffee: -------------------------------------------------------------------------------- 1 | # Each plugin is registered with 2 | # * a name 3 | # * a regex 4 | # * a callback function 5 | # 6 | # Each message's body will be matched against the regex. 7 | # If it matches, then the callback function is run. 8 | # 9 | # The callback function must return the body of the message. 10 | # The body of the message can be modified if required (HTML allowed). 11 | # 12 | # The callback function is passed the following arguments: 13 | # 14 | # * body of the message 15 | # * type of message ("text", "paste" or "me") 16 | # * a boolean variable which indicates if the message is live or history 17 | # 18 | # Use the third argument incase to check incase your plugin 19 | # should work on live messages only. 20 | # 21 | 22 | 23 | # The following plugin detects links and replaces them with anchor tags 24 | # It does it only for "me" and "text" messages 25 | # and only returns the body for "paste" messages. 26 | 27 | App.plugins.register "link", /(https?\:\S+)/g, (content, messageType, history)-> 28 | if ["me", "text"].indexOf(messageType) != -1 29 | content.replace(/(https?\:\S+)/g, "$1") 30 | else 31 | content 32 | -------------------------------------------------------------------------------- /lib/mix/tasks/setup.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Setup do 2 | use Mix.Task 3 | 4 | @shortdoc "Create admin user and lobby room" 5 | 6 | @moduledoc """ 7 | A test task. 8 | """ 9 | def run(_) do 10 | :application.ensure_all_started(:mogo_chat) 11 | admin_user = User.new( 12 | email: "admin@example.com", 13 | password: "password", 14 | name: "Admin", 15 | role: "admin", 16 | archived: false) 17 | |> User.encrypt_password() 18 | |> User.assign_auth_token() 19 | 20 | case User.validate(admin_user) do 21 | [] -> 22 | _saved_user = Repo.create(admin_user) 23 | IO.puts "Created admin user with email admin@example.com and password \"password\" ~!" 24 | errors -> 25 | IO.puts "Error while creating admin user..." 26 | IO.puts errors 27 | end 28 | 29 | 30 | lobby = Room.new(name: "Lobby") 31 | case Room.validate(lobby) do 32 | [] -> 33 | _saved_room = Repo.create(lobby) 34 | IO.puts "Created your first room called \"lobby\"" 35 | errors -> 36 | IO.puts "Error while creating first room..." 37 | IO.puts errors 38 | end 39 | 40 | IO.puts "" 41 | IO.puts "*** DONE ***" 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /assets/javascripts/pollers/message_poller.js.coffee: -------------------------------------------------------------------------------- 1 | App.MessagePoller = Em.Object.extend 2 | start: ()-> 3 | @started = true 4 | @fetchMessages() && @timer = setInterval(@fetchMessages.bind(@), 2500) 5 | 6 | setRoomState: (roomState)-> 7 | @roomState = roomState 8 | 9 | stop: -> 10 | return true if !@started 11 | clearInterval(@timer) 12 | @started = false 13 | 14 | 15 | fetchMessages: (before = false)-> 16 | url = "/api/messages/#{@roomState.get("room.id")}" 17 | if before == true 18 | true 19 | else if before 20 | url = "#{url}?before=#{before}" 21 | else if @roomState.get("afterMessageId") 22 | url = "#{url}?after=#{@roomState.get("afterMessageId")}" 23 | 24 | getJsonCallback = (response)=> 25 | if response.messages.length >= MogoChat.config.messagesPerLoad && (before != false || !@roomState.get("afterMessageId")) 26 | @roomState.set("room.isHistoryAvailable", true) 27 | else if before != false && response.messages.length < MogoChat.config.messagesPerLoad 28 | @roomState.set("room.isHistoryAvailable", false) 29 | 30 | @roomState.trigger("addMessages", {before: before, messages: response.messages}) 31 | 32 | $.getJSON url, getJsonCallback.bind(@) 33 | -------------------------------------------------------------------------------- /lib/mogo_chat/plugs/session.ex: -------------------------------------------------------------------------------- 1 | defmodule Plugs.Session do 2 | import Plug.Connection 3 | 4 | @behaviour Plug.Wrapper 5 | 6 | def init(opts) do 7 | # Require a name for the session id cookie 8 | unless Keyword.has_key? opts, :name do 9 | raise ArgumentError, message: "Expected session name to be given." 10 | end 11 | 12 | unless Keyword.has_key? opts, :adapter do 13 | raise ArgumentError, message: "Expected session adapter to be given." 14 | else 15 | adapter = opts[:adapter] 16 | end 17 | 18 | adapter.init(opts) 19 | end 20 | 21 | def wrap(conn, opts, fun) do 22 | adapter = opts[:adapter] 23 | sid = conn 24 | |> fetch_cookies 25 | |> get_sid(opts[:name]) 26 | 27 | conn = conn |> put_resp_cookie(opts[:name], sid) 28 | 29 | data = adapter.get(sid) 30 | conn = conn |> 31 | assign(:session, data) 32 | |> assign(:session_adapter, adapter) 33 | |> assign(:session_id, sid) 34 | |> assign(:session_name, opts[:name]) 35 | 36 | conn = fun.(conn) 37 | adapter.put(sid, conn.assigns[:session]) 38 | 39 | conn 40 | end 41 | 42 | defp get_sid(conn, name) do 43 | if sid = conn.cookies[name] do 44 | sid 45 | else 46 | :crypto.strong_rand_bytes(96) |> :base64.encode 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/mogo_chat/models/message.ex: -------------------------------------------------------------------------------- 1 | defmodule Message do 2 | use Ecto.Model 3 | use MogoChat.ModelUtils 4 | 5 | queryable "messages" do 6 | field :body, :string 7 | field :type, :string 8 | field :created_at, :datetime 9 | belongs_to :room, Room 10 | belongs_to :user, User 11 | end 12 | 13 | 14 | def assign_message_type(record) do 15 | cond do 16 | Regex.match?(~r/\n/g, record.body) -> 17 | record.type("paste") 18 | #TODO support sounds 19 | # matches = Regex.named_captures(~r/\/play (?\w+)/g, record.body) -> 20 | # record.type("sound").body(matches[:sound]) 21 | matches = Regex.named_captures(~r/^\/me (?.+)/g, record.body) -> 22 | record.type("me").body(matches[:announcement]) 23 | true -> 24 | record = record.body 25 | |> String.strip() 26 | |> record.body() 27 | record.type("text") 28 | end 29 | end 30 | 31 | 32 | validate message, 33 | user_id: present(), 34 | room_id: present(), 35 | body: present(), 36 | type: member_of(["text", "paste", "me", "sound"]), 37 | created_at: present() 38 | 39 | 40 | def public_attributes(record) do 41 | attrs = attributes(record, ["id", "room_id", "user_id", "body", "type"]) 42 | attrs ++ [created_at: timestamp(record.created_at)] 43 | end 44 | 45 | end 46 | -------------------------------------------------------------------------------- /lib/mogo_chat/html_engine.ex: -------------------------------------------------------------------------------- 1 | defmodule HTMLEngine do 2 | use EEx.TransformerEngine 3 | use EEx.AssignsEngine 4 | 5 | def handle_text(buffer, text) do 6 | quote do 7 | { :safe, unquote(buffer) <> unquote(text) } 8 | end 9 | end 10 | 11 | def handle_expr(buffer, "=", expr) do 12 | expr = transform(expr) 13 | buffer = unsafe(buffer) 14 | 15 | quote location: :keep do 16 | tmp = unquote(buffer) 17 | case unquote(expr) do 18 | { :safe, value } -> 19 | tmp <> to_string(value) 20 | value -> 21 | tmp <> HTMLEngine.escape(to_string(value)) 22 | end 23 | end 24 | end 25 | 26 | def handle_expr(buffer, "", expr) do 27 | quote do 28 | tmp = unquote(buffer) 29 | unquote(expr) 30 | tmp 31 | end 32 | end 33 | 34 | defp unsafe({ :safe, value }), do: value 35 | defp unsafe(value), do: value 36 | 37 | @escapes [{ ?<, "<" }, { ?>, ">" }, { ?&, "&" }, { ?", """ }, { ?', "'" }] 38 | 39 | def escape(buffer) do 40 | iolist_to_binary(do_escape(buffer)) 41 | end 42 | 43 | Enum.each(@escapes, fn { match, insert } -> 44 | defp do_escape(<< unquote(match) :: utf8, rest :: binary >>) do 45 | [ unquote(insert) | do_escape(rest) ] 46 | end 47 | end) 48 | 49 | defp do_escape(<< char :: utf8, rest :: binary >>), 50 | do: [ char | do_escape(rest) ] 51 | defp do_escape(""), 52 | do: [] 53 | end 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MogoChat - team chat app 2 | 3 | #### 3-minute Heroku install. [Just copy-paste a command](https://github.com/HashNuke/mogo-chat/blob/master/docs/install-heroku.md) 4 | 5 | ![screenshot](https://github.com/HashNuke/mogo-chat/raw/master/screenshot.png "Screenshot") 6 | 7 | ### Features 8 | 9 | * **Works on mobile devices ~!** 10 | * Easy to install 11 | * Multiple rooms 12 | * Sound notifications 13 | * Code snippets 14 | * /me status messages 15 | * Comes with an API ([docs](https://github.com/HashNuke/mogo-chat/blob/master/docs/api.md)) 16 | 17 | Written using [Elixir](http://elixir-lang.org), [Phoenix Framework](https://github.com/phoenixframework/phoenix), [Ember.js](http://emberjs.com), [CoffeeScript](http://coffeescript.org) and [PostgreSQL](http://postgresql.org) 18 | 19 | Install instructions for more platforms will come soon. 20 | 21 | ### Credits 22 | 23 | Akash Manohar J © 2014, under the MIT License. See LICENSE file for details. 24 | 25 | ##### Artwork 26 | 27 | * Users icon designed by Mike Finch - 28 | * Logout icon "Power" designed by Michela Tannola - 29 | * Notification sound - 30 | 31 | 32 | ### TODO 33 | 34 | * Asset pipeline to compile assets in Elixir/Erlang (In progress) 35 | * Move Handlebars templates to assets 36 | * grep for `TODO` in the source code and you might stuff to contribute 37 | -------------------------------------------------------------------------------- /assets/javascripts/mogo_chat.js.coffee: -------------------------------------------------------------------------------- 1 | MogoChat = {} 2 | 3 | #TODO This config should go in the db 4 | MogoChat.config = 5 | messagesPerLoad: 20 6 | 7 | class MogoChat.PluginRegistry 8 | plugins: [] 9 | 10 | all: -> @plugins 11 | 12 | register: (name, regex, callback)-> 13 | for plugin in @plugins 14 | if plugin.name == name 15 | throw("Plugin with name \"#{name}\" already registered") 16 | @plugins.push({name, regex, callback}) 17 | 18 | 19 | unregister: (name)-> 20 | registeredIndex = null 21 | for plugin, index in @plugins 22 | if plugin.name == name 23 | registeredIndex = index 24 | break 25 | return false unless registeredIndex 26 | @plugins.splice(registeredIndex, 1)[0] 27 | 28 | 29 | processMessageBody: (body, type, history = false) -> 30 | for plugin, index in @plugins 31 | if body.match(plugin.regex) 32 | body = plugin.callback(body, type, history) 33 | body 34 | 35 | 36 | class MogoChat.PaintBox 37 | nextColor: 0 38 | colors: [ 39 | "F57DBA" 40 | "829DE7" 41 | "77B546" 42 | "FFCC20" 43 | "A79D95" 44 | "809DAA" 45 | "9013FE" 46 | "637AB2" 47 | "BBAD7C" 48 | "C831DD" 49 | "71CCD3" 50 | "417505" 51 | ] 52 | 53 | getColor: -> 54 | color = @colors[@nextColor] 55 | @nextColor = @nextColor + 1 56 | if @nextColor >= @colors.length 57 | @nextColor = 0 58 | "##{color}" 59 | 60 | 61 | window.MogoChat = MogoChat -------------------------------------------------------------------------------- /assets/javascripts/routes/authenticated_route.js.coffee: -------------------------------------------------------------------------------- 1 | App.AuthenticatedRoute = Em.Route.extend 2 | beforeModel: (transition)-> 3 | applicationController = @controllerFor("application") 4 | if applicationController.get("currentUser") 5 | return true 6 | 7 | Em.$.getJSON("/api/sessions").then (response)=> 8 | if response.user 9 | userAttributes = 10 | id: response.user.id, 11 | name: response.user.name, 12 | role: response.user.role 13 | email: response.user.email 14 | authToken: response.user.auth_token 15 | color: App.paintBox.getColor() 16 | 17 | if @store.recordIsLoaded("user", userAttributes.id) 18 | @store.find("user", userAttributes.id).then (user)=> 19 | @controllerFor("application").set("currentUser", user) 20 | else 21 | userAttributes["color"] = App.paintBox.getColor() 22 | user = @store.push("user", userAttributes) 23 | @controllerFor("application").set("currentUser", user) 24 | else 25 | @redirectToLogin(transition) 26 | 27 | # Redirect to the login page and store the current transition so we can 28 | # run it again after login 29 | redirectToLogin: (transition)-> 30 | loginController = @controllerFor("login") 31 | # loginController.set("attemptedTransition", transition) 32 | @transitionTo("login") 33 | 34 | #TODO not sure why this is here 35 | actions: 36 | error: (reason, transition)-> 37 | this.redirectToLogin(transition) 38 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'sprockets' 2 | require 'rake/sprocketstask' 3 | require 'uglifier' 4 | require 'bourbon' 5 | require 'filewatcher' 6 | 7 | sprockets = Sprockets::Environment.new("./") do |env| 8 | env.logger = Logger.new(STDOUT) 9 | env.append_path 'assets/javascripts' 10 | env.append_path 'assets/stylesheets' 11 | env.append_path 'assets/images' 12 | unless ENV["MIX_ENV"] == "dev" 13 | env.js_compressor = Uglifier.new 14 | env.css_compressor = :scss 15 | end 16 | end 17 | 18 | 19 | assets = %w( application.js application.css ) 20 | asset_output = "priv/static/assets" 21 | extra_dirs = ["images"] 22 | 23 | extra_dirs.each do |dir| 24 | Dir.glob("assets/#{dir}/*.*") do |f| 25 | assets.push File.basename(f) 26 | end 27 | end 28 | 29 | 30 | namespace :assets do 31 | 32 | desc "Compile assets" 33 | task :compile do 34 | begin 35 | assets.each do |asset| 36 | puts asset 37 | sprockets[asset].write_to "#{asset_output}/#{asset}" 38 | end 39 | rescue => e 40 | puts "Error #{e.inspect}" 41 | end 42 | end 43 | 44 | 45 | desc "Watch assets for changes and compile" 46 | task :watch do 47 | watch_list = ["assets/javascripts/", "assets/stylesheets/", "assets/images/"] 48 | 49 | FileWatcher.new(watch_list, "Watching assets for compliation...").watch do |filename| 50 | 51 | begin 52 | assets.each do |asset| 53 | puts asset 54 | sprockets[asset].write_to "#{asset_output}/#{asset}" 55 | end 56 | rescue => e 57 | puts "Error #{e.inspect}" 58 | end 59 | 60 | end 61 | end 62 | 63 | end 64 | -------------------------------------------------------------------------------- /assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | //= require "lib/moment.min" 2 | //= require "lib/jquery-2.0.3" 3 | //= require "lib/handlebars-v1.1.2" 4 | //= require "lib/ember" 5 | //= require "lib/ember-data" 6 | //= require "lib/fastclick" 7 | //= require "mogo_chat" 8 | //= require_self 9 | //= require "serializers" 10 | //= require "transforms" 11 | //= require_tree "./models" 12 | //= require "views" 13 | //= require "helpers" 14 | //= require_tree "./controllers" 15 | //= require "./routes/authenticated_route" 16 | //= require_tree "./routes" 17 | //= require_tree "./pollers" 18 | //= require "plugins" 19 | //= require "notifications" 20 | 21 | 22 | window.App = Em.Application.create({LOG_TRANSITIONS: true}); 23 | App.ApplicationSerializer = DS.ActiveModelSerializer.extend({}); 24 | App.ApplicationAdapter = DS.ActiveModelAdapter.reopen({namespace: "api"}); 25 | App.ApplicationView = Em.View.extend({classNames: ["container"]}); 26 | App.paintBox = new MogoChat.PaintBox(); 27 | App.plugins = new MogoChat.PluginRegistry(); 28 | 29 | 30 | App.Router.map(function() { 31 | // login 32 | this.route("login"); 33 | 34 | // logout 35 | this.route("logout"); 36 | 37 | // rooms 38 | this.resource("rooms", function() { 39 | this.route("new"); 40 | this.resource("room", {path: "/:room_id"}, function() { 41 | this.route("edit"); 42 | }); 43 | }); 44 | 45 | // users 46 | // users/new 47 | // users/:user_id 48 | this.resource("users", function() { 49 | this.route("new"); 50 | this.resource("user", {path: "/:user_id"}, function() { 51 | this.route("edit"); 52 | }); 53 | }); 54 | 55 | }); 56 | -------------------------------------------------------------------------------- /lib/mix/tasks/setup_demo.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Setup.Demo do 2 | use Mix.Task 3 | 4 | @shortdoc "Create demo user and 2 example rooms after truncating the tables" 5 | 6 | @moduledoc """ 7 | Truncates db and creates demo user and Lobby room. 8 | """ 9 | def run(_) do 10 | :application.ensure_all_started(:mogo_chat) 11 | 12 | # Truncate everything 13 | table_names = ["users", "messages", "rooms", "room_user_states"] 14 | sql = "TRUNCATE TABLE #{Enum.join(table_names, ", ")} RESTART IDENTITY CASCADE;" 15 | Repo.adapter.query(Repo, sql, []) 16 | 17 | 18 | demo_user = User.new( 19 | email: "demo@example.com", 20 | password: "password", 21 | name: "Demo", 22 | role: "member", 23 | archived: false) 24 | |> User.encrypt_password() 25 | |> User.assign_auth_token() 26 | 27 | case User.validate(demo_user) do 28 | [] -> 29 | _saved_user = Repo.create(demo_user) 30 | errors -> 31 | IO.puts "Error while creating demo user..." 32 | IO.puts errors 33 | end 34 | 35 | lobby = Room.new(name: "Lobby") 36 | case Room.validate(lobby) do 37 | [] -> 38 | _saved_room = Repo.create(lobby) 39 | errors -> 40 | IO.puts "Error while creating first room..." 41 | IO.puts errors 42 | end 43 | 44 | 45 | another_room = Room.new(name: "Project Talk") 46 | case Room.validate(another_room) do 47 | [] -> 48 | _saved_room = Repo.create(another_room) 49 | errors -> 50 | IO.puts "Error while creating second room" 51 | IO.puts errors 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/integration/sessions_integration_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SessionIntegrationTest do 2 | use MogoChat.TestCase 3 | use Hound.Helpers 4 | import TestUtils 5 | 6 | hound_session 7 | truncate_db_after_test 8 | test_helpers 9 | 10 | test "should redirect to login if I visit a page without logging in" do 11 | navigate_to app_path() 12 | wait_until({:name, "login"}) 13 | assert Regex.match?(%r/(login)/, current_url) 14 | end 15 | 16 | 17 | test "should show error when wrong password was entered" do 18 | user = create_member("Test", "test@example.com") 19 | 20 | navigate_to app_path() 21 | 22 | fill_field {:name, "email"}, user.email 23 | fill_field {:name, "password"}, "wrong password" 24 | click({:name, "login"}) 25 | 26 | wait_until({:class, "error"}) 27 | 28 | error_msg = visible_text({:class, "error"}) 29 | assert Regex.match?(%r/(Please check your login credentials)/, error_msg) 30 | end 31 | 32 | 33 | test "should show error when the user does not have an account" do 34 | navigate_to app_path() 35 | 36 | fill_field {:name, "email"}, "nobody@example.com" 37 | fill_field {:name, "password"}, "password" 38 | click({:name, "login"}) 39 | 40 | wait_until({:class, "error"}) 41 | 42 | error_msg = visible_text({:class, "error"}) 43 | assert Regex.match?(%r/(Maybe you don't have an account)/, error_msg) 44 | end 45 | 46 | 47 | test "member should be able to login and logout" do 48 | login_member("Test", "test@example.com") 49 | end 50 | 51 | 52 | test "admin should be able to login and logout" do 53 | login_admin("Test", "test@example.com") 54 | end 55 | 56 | end 57 | -------------------------------------------------------------------------------- /assets/javascripts/controllers/room_user_state_item_controller.js.coffee: -------------------------------------------------------------------------------- 1 | App.RoomUserStateItemController = Em.ObjectController.extend 2 | needs: ["application", "index"] 3 | currentUser: Ember.computed.alias("controllers.application.currentUser") 4 | 5 | actions: 6 | 7 | leaveRoom: -> 8 | roomItemState = @get("model") 9 | roomItemState.messagePoller.stop() 10 | roomItemState.usersPoller.stop() 11 | roomItemState.set("joined", false) 12 | roomItemState.save() 13 | @get("controllers.index").set("activeState", null) 14 | 15 | 16 | joinOrOpen: -> 17 | # set the previous room as not active 18 | previousState = @get("controllers.index.activeState") 19 | if previousState 20 | previousState.set("active", false) 21 | 22 | roomItemState = @get("model") 23 | if roomItemState.get("joined") == false 24 | roomItemState.set("joined", true) 25 | 26 | roomItemState.messagePoller = new App.MessagePoller() 27 | roomItemState.messagePoller.setRoomState roomItemState 28 | roomItemState.messagePoller.start() 29 | 30 | roomItemState.usersPoller = new App.UsersPoller() 31 | roomItemState.usersPoller.setRoomState roomItemState 32 | roomItemState.usersPoller.start() 33 | 34 | successCallback = (=>) 35 | errorCallback = => 36 | console.log("error whatever...") 37 | roomItemState.save().then(successCallback, errorCallback) 38 | 39 | roomItemState.set("notification", false) 40 | roomItemState.set("active", true) 41 | @get("controllers.index").set("activeState", roomItemState) 42 | #TODO load the channel 43 | -------------------------------------------------------------------------------- /lib/mogo_chat/controllers/sessions_api.ex: -------------------------------------------------------------------------------- 1 | defmodule MogoChat.Controllers.SessionsApi do 2 | use Phoenix.Controller 3 | import Ecto.Query 4 | import MogoChat.ControllerUtils 5 | 6 | 7 | def index(conn) do 8 | user_id = conn.assigns[:session] 9 | if user_id do 10 | user = Repo.get(User, user_id) 11 | attributes = User.attributes(user, ["id", "name", "role", "email", "auth_token"]) 12 | json_resp conn, [user: attributes] 13 | else 14 | json_resp conn, [error: "no session"] 15 | end 16 | end 17 | 18 | 19 | def create(conn) do 20 | params = conn.params 21 | login(conn, params["email"], params["password"]) 22 | end 23 | 24 | 25 | def destroy(conn) do 26 | json_resp destroy_session(conn), [ok: "logged out"] 27 | end 28 | 29 | 30 | defp login(conn, email, password) when email == nil or password == nil do 31 | json_resp conn, [error: "Please check your login credentials."], 401 32 | end 33 | 34 | 35 | defp login(conn, email, password) do 36 | query = from u in User, where: u.email == ^email 37 | users = Repo.all query 38 | 39 | case length(users) > 0 do 40 | true -> 41 | user = users |> hd 42 | if User.valid_password?(user, password) do 43 | conn = put_session(conn, user.id) 44 | user_attributes = User.attributes(user, ["id", "name", "role", "email", "auth_token"]) 45 | json_resp conn, [user: user_attributes] 46 | else 47 | json_resp conn, [error: "Please check your login credentials."], 401 48 | end 49 | false -> 50 | json_resp conn, [error: "Maybe you don't have an account?"], 401 51 | end 52 | end 53 | 54 | end 55 | -------------------------------------------------------------------------------- /docs/install-local.md: -------------------------------------------------------------------------------- 1 | # Instructions to install on your computer 2 | 3 | ### Local install for development and other purposes 4 | 5 | You'll need Erlang version R16B02 or higher, Elixir version v0.12.5 (or higher) and Postgresql. 6 | 7 | * Create a postgresql database called `mogo_chat_development` 8 | 9 | * Copy the `config/database.json.sample` as `config/database.json` and edit the database credentials. 10 | 11 | * Then copy-paste the following into your terminal: 12 | 13 | ``` 14 | mix deps.get 15 | bash scripts/migrate 16 | bash scripts/setup 17 | ``` 18 | 19 | Use one of the following commands to start the app: 20 | 21 | ``` 22 | # you can start server with an Elixir console 23 | bash scripts/start_with_shell 24 | 25 | # Or you can start without the console 26 | bash scripts/start 27 | ``` 28 | 29 | #### Building assets 30 | 31 | You'll need Ruby for this. Install a nice version like Ruby 2.1 and install the `bundler` and `rake` rubygems. Then run `bundle install` to install Ruby dependencies. When done, you will be able to use the following commands to compile or watch assets when development is happening. 32 | 33 | * Run `bundle exec rake assets:compile` to compile assets once. 34 | 35 | * Run `bundle exec rake assets:watch` to start asset server. 36 | 37 | To compress javascript when building assets, use the env var `MIX_ENV=prod`. 38 | 39 | #### Tests 40 | 41 | [TODO tests might be broken since switching web frameworks. Needs to be fixed] 42 | 43 | For running test, you'll need a database called `mogo_chat_test`. Make sure you also edit the credentials in `config/database.json` as required. 44 | 45 | * Get dependencies: `MIX_ENV=test mix deps.get` 46 | 47 | * Run tests: `bash scripts/run_tests` 48 | 49 | -------------------------------------------------------------------------------- /lib/mogo_chat/models/user.ex: -------------------------------------------------------------------------------- 1 | defmodule User do 2 | use Ecto.Model 3 | use MogoChat.ModelUtils 4 | 5 | queryable "users" do 6 | field :email, :string 7 | field :encrypted_password, :string 8 | field :role, :string 9 | field :name, :string 10 | field :password, :virtual, default: nil 11 | field :auth_token, :string 12 | field :archived, :boolean 13 | has_many :messages, Message 14 | has_many :room_user_states, RoomUserState 15 | end 16 | 17 | 18 | validate user, 19 | name: present(), 20 | name: has_length(min: 3), 21 | email: present(), 22 | role: member_of(~w(admin member)), 23 | also: validate_password 24 | 25 | 26 | def validate_password(user) do 27 | if !user.encrypted_password || (user.password && size(user.password) < 6) do 28 | [{ :password, "should be 6 characters or more" }] 29 | else 30 | [] 31 | end 32 | end 33 | 34 | 35 | def valid_password?(record, password) do 36 | salt = String.slice(record.encrypted_password, 0, 29) 37 | {:ok, hashed_password} = :bcrypt.hashpw(password, salt) 38 | "#{hashed_password}" == record.encrypted_password 39 | end 40 | 41 | 42 | def public_attributes(record) do 43 | attributes(record, ["id", "name", "role", "archived"]) 44 | end 45 | 46 | def assign_auth_token(record) do 47 | "#{:uuid.get_v4() |> :uuid.uuid_to_string()}" 48 | |> record.auth_token() 49 | end 50 | 51 | def encrypt_password(record) do 52 | if record.password != nil do 53 | {:ok, salt} = :bcrypt.gen_salt() 54 | {:ok, hashed_password} = :bcrypt.hashpw(record.password, salt) 55 | record = record.encrypted_password("#{hashed_password}") 56 | end 57 | record 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /assets/javascripts/notifications.js: -------------------------------------------------------------------------------- 1 | //NOTE from http://stackoverflow.com/a/1060034/477045 2 | (function() { 3 | var hidden = "hidden"; 4 | 5 | // Standards: 6 | if (hidden in document) 7 | document.addEventListener("visibilitychange", onVisibilityChange); 8 | else if ((hidden = "mozHidden") in document) 9 | document.addEventListener("mozvisibilitychange", onVisibilityChange); 10 | else if ((hidden = "webkitHidden") in document) 11 | document.addEventListener("webkitvisibilitychange", onVisibilityChange); 12 | else if ((hidden = "msHidden") in document) 13 | document.addEventListener("msvisibilitychange", onVisibilityChange); 14 | // IE 9 and lower: 15 | else if ('onfocusin' in document) 16 | document.onfocusin = document.onfocusout = onVisibilityChange; 17 | // All others: 18 | else 19 | window.onpageshow = window.onpagehide 20 | = window.onfocus = window.onblur = onVisibilityChange; 21 | 22 | function onVisibilityChange (evt) { 23 | var v = 'visible', h = 'hidden'; 24 | var evtMap = { 25 | focus:v, focusin:v, pageshow:v, blur:h, focusout:h, pagehide:h 26 | }; 27 | 28 | evt = evt || window.event; 29 | if(evt.type == "focusout" || evt.type == "blur" || evt.type == "pagehige" || this[hidden]) { 30 | App.isPageActive = false; 31 | } 32 | else { 33 | App.isPageActive = true; 34 | document.title = "Mogo Chat"; 35 | } 36 | } 37 | })(); 38 | 39 | 40 | App.notifyBySound = function() { 41 | if(App.isPageActive) 42 | return 43 | 44 | if(!document.title.match(/[\+]/g)) 45 | document.title = "[+] " + document.title; 46 | 47 | $audio = $("#app-audio")[0]; 48 | $audio.load(); 49 | $audio.play(); 50 | }; -------------------------------------------------------------------------------- /lib/mogo_chat/router.ex: -------------------------------------------------------------------------------- 1 | defmodule MogoChat.Router do 2 | use Phoenix.Router 3 | 4 | def start_link do 5 | __MODULE__.start 6 | end 7 | 8 | plug MogoChat.AuthErrorHandler 9 | 10 | plug Plug.Parsers, parsers: [:urlencoded, :multipart, MogoChat.JsonParser] 11 | plug Plug.Static, at: "/static", from: :mogo_chat 12 | plug Plugs.Session, name: "mogo_chat_session", adapter: Plugs.Session.Adapters.Ets 13 | 14 | 15 | get "/", MogoChat.Controllers.Main, :index, as: :index 16 | 17 | get "/api/sessions", MogoChat.Controllers.SessionsApi, :index 18 | post "/api/sessions", MogoChat.Controllers.SessionsApi, :create 19 | delete "/api/sessions", MogoChat.Controllers.SessionsApi, :destroy 20 | 21 | 22 | get "/api/users", MogoChat.Controllers.UsersApi, :index 23 | post "/api/users", MogoChat.Controllers.UsersApi, :create 24 | get "/api/users/:user_id", MogoChat.Controllers.UsersApi, :show 25 | put "/api/users/:user_id", MogoChat.Controllers.UsersApi, :update 26 | delete "/api/users/:user_id", MogoChat.Controllers.UsersApi, :destroy 27 | 28 | 29 | get "/api/rooms", MogoChat.Controllers.RoomsApi, :index 30 | post "/api/rooms", MogoChat.Controllers.RoomsApi, :create 31 | get "/api/rooms/:room_id", MogoChat.Controllers.RoomsApi, :show 32 | get "/api/rooms/:room_id/users", MogoChat.Controllers.RoomsApi, :active_users 33 | put "/api/rooms/:room_id", MogoChat.Controllers.RoomsApi, :update 34 | delete "/api/rooms/:room_id", MogoChat.Controllers.RoomsApi, :destroy 35 | 36 | 37 | get "/api/room_user_states", MogoChat.Controllers.RoomUserStatesApi, :show 38 | put "/api/room_user_states/:room_user_state_id", MogoChat.Controllers.RoomUserStatesApi, :update 39 | 40 | 41 | get "/api/messages/:room_id", MogoChat.Controllers.MessagesApi, :index 42 | post "/api/messages", MogoChat.Controllers.MessagesApi, :create 43 | 44 | 45 | get "/rooms/:room_id/messages/:message_id", MogoChat.Controllers.RoomMessages, :show 46 | end 47 | -------------------------------------------------------------------------------- /assets/stylesheets/page.scss: -------------------------------------------------------------------------------- 1 | .page { 2 | float: left; 3 | width: 100%; 4 | height: 100%; 5 | overflow-y: scroll; 6 | 7 | .error, .error-msg { 8 | color: #D00; 9 | } 10 | 11 | .add { 12 | padding: 0.25em 0.5em; 13 | border: 1px solid #68A056; 14 | color: #68A056; 15 | text-decoration: none; 16 | margin-bottom: 1em; 17 | clear: both; 18 | float: left; 19 | border-radius: 0.2em; 20 | cursor: pointer; 21 | } 22 | 23 | .room-form, .user-form { 24 | padding: 1em; 25 | } 26 | 27 | form { 28 | float: left; 29 | width: 100%; 30 | background: #FCFCFC; 31 | padding: 1em; 32 | border: 1px solid #DDD; 33 | 34 | .field { 35 | float: left; 36 | width: 100%; 37 | margin-top: 0.5em; 38 | margin-bottom: 0.5em; 39 | clear: both; 40 | } 41 | 42 | .actions { 43 | float: left; 44 | border-top: 2px solid #EEE; 45 | margin-top: 1em; 46 | padding-top: 1em; 47 | width: 100%; 48 | 49 | .save { 50 | padding: 0.2em 0.5em; 51 | font-size: 1.1em; 52 | background: #FFF; 53 | color: #06C; 54 | border: 1px solid #06C; 55 | border-radius: 0.15em; 56 | box-shadow: 0em 0em 0.2em 0.05em #0073E6; 57 | } 58 | } 59 | } 60 | } 61 | 62 | .list { 63 | width: 100%; 64 | float: left; 65 | 66 | .item { 67 | width: 100%; 68 | float: left; 69 | background: #fafafa; 70 | border-left: 0.15em solid #E08587; 71 | border-right: 0.1em solid #DDD; 72 | border-top: 0.1em solid #DDD; 73 | border-bottom: 0.1em solid #DDD; 74 | padding: 0.5em; 75 | margin-bottom: 1em; 76 | 77 | .name { 78 | font-weight: bold; 79 | } 80 | 81 | .controls { 82 | clear: left; 83 | margin-top: 0.5em; 84 | a, span { 85 | margin-right: 1em; 86 | cursor: pointer; 87 | text-decoration: underline; 88 | color: #06c; 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /test/integration/chat_interface_integration_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ChatInterfaceIntegrationTest do 2 | use MogoChat.TestCase 3 | use Hound.Helpers 4 | import TestUtils 5 | 6 | hound_session 7 | truncate_db_after_test 8 | test_helpers 9 | 10 | 11 | test "user should be able to join rooms and leave rooms" do 12 | create_room("lobby") 13 | 14 | login_member("John", "john@example.com") 15 | join_room("lobby") 16 | end 17 | 18 | 19 | test "users should get each other's chat messages" do 20 | create_room("lobby") 21 | 22 | login_member("John", "john@example.com") 23 | join_room("lobby") 24 | 25 | in_browser_session :jane, fn-> 26 | login_member("Jane", "jane@example.com") 27 | join_room("lobby") 28 | end 29 | 30 | new_msg = "Hi Jane" 31 | fill_field {:tag, "textarea"}, new_msg 32 | send_keys(:return) 33 | 34 | assert size(visible_text {:tag, "textarea"}) == 0 35 | 36 | in_browser_session :jane, fn-> 37 | :timer.sleep(3000) 38 | msg = "Hi Jane" 39 | assert Regex.match?(%r/#{msg}/, page_source) 40 | 41 | fill_field {:tag, "textarea"}, "Hello John" 42 | send_keys(:return) 43 | end 44 | 45 | :timer.sleep(3000) 46 | assert Regex.match?(%r/(Hello John)/, page_source) 47 | 48 | messages = Repo.all Message 49 | assert length(messages) == 2 50 | end 51 | 52 | 53 | # test "user should be able to leave rooms" do 54 | # end 55 | 56 | 57 | # test "user should be able to post message to active room" do 58 | # end 59 | 60 | 61 | # test "user should be able to switch rooms" do 62 | # end 63 | 64 | 65 | # test "user should receive messages when posted" do 66 | # end 67 | 68 | 69 | # test "should be able to load chat history of room" do 70 | # end 71 | 72 | 73 | # test "user should receive notification incase of mentions in inactive room" do 74 | # end 75 | 76 | 77 | # test "user should *not* receive notification if mentioned in active room" do 78 | # end 79 | 80 | 81 | # test "only active users in a room should be listed" do 82 | # end 83 | 84 | 85 | # test "when a user hasn't pinged for 5 seconds, mark as left" do 86 | # end 87 | 88 | end 89 | -------------------------------------------------------------------------------- /assets/javascripts/controllers/index_controller.js.coffee: -------------------------------------------------------------------------------- 1 | App.IndexController = Ember.ArrayController.extend 2 | needs: ["application"] 3 | currentUser: Ember.computed.alias("controllers.application.currentUser") 4 | isLeftMenuOpen: Ember.computed.alias("controllers.application.isLeftMenuOpen") 5 | isRightMenuOpen: Ember.computed.alias("controllers.application.isRightMenuOpen") 6 | 7 | itemController: "RoomUserStateItem" 8 | 9 | 10 | detectTypeAndFormatBody: (body)-> 11 | if body.match("\n") 12 | {type: "paste", body: body} 13 | else if matches = (/^\/me (.*)/g).exec(body) 14 | {type: "me", body: matches[1]} 15 | else 16 | {type: "text", body: body} 17 | 18 | 19 | actions: 20 | loadHistory: -> 21 | activeState = @get("activeState") 22 | room = activeState.get("room") 23 | if room.get("messages")[0] 24 | beforeId = room.get("messages")[0].get("id") 25 | else 26 | beforeId = true 27 | 28 | activeState.messagePoller.fetchMessages(beforeId) 29 | 30 | 31 | postMessage: (msgTxt)-> 32 | escapedBody = $('
').text(msgTxt).html() 33 | msgTxt = msgTxt.replace(/\s*$/g, "") 34 | room = @get("activeState").get("room") 35 | currentUser = @get("currentUser") 36 | formatted = @detectTypeAndFormatBody(msgTxt) 37 | messageParams = 38 | room: room 39 | body: msgTxt 40 | type: formatted.type 41 | createdAt: new Date() 42 | user: currentUser 43 | 44 | msg = @store.createRecord("message", messageParams) 45 | 46 | if room.get("messages.length") == (MogoChat.config.messagesPerLoad + 1) 47 | room.get("messages").shiftObject() 48 | 49 | successCallback = => 50 | room.get("messages").pushObject(msg) 51 | # Note, this can't be set before save(), because createRecord empties this 52 | if formatted.type != "paste" 53 | msg.set "formattedBody", App.plugins.processMessageBody(escapedBody, formatted.type) 54 | errorCallback = => 55 | msg.set("errorPosting", true) 56 | room.get("messages").pushObject(msg) 57 | if formatted.type != "paste" 58 | msg.set "formattedBody", App.plugins.processMessageBody(escapedBody, formatted.type) 59 | msg.save().then(successCallback, errorCallback) 60 | -------------------------------------------------------------------------------- /lib/mogo_chat/controllers/room_user_states_api.ex: -------------------------------------------------------------------------------- 1 | defmodule MogoChat.Controllers.RoomUserStatesApi do 2 | use Phoenix.Controller 3 | import Ecto.Query 4 | import MogoChat.ControllerUtils 5 | 6 | def show(conn) do 7 | conn = authenticate_user!(conn) 8 | user_id = conn.assigns[:current_user].id 9 | rooms = Repo.all(Room) 10 | 11 | room_user_states_attributes = Enum.map rooms, fn(room)-> 12 | query = from r in RoomUserState, 13 | where: r.user_id == ^user_id and r.room_id == ^room.id, 14 | preload: :user 15 | 16 | result = Repo.all query 17 | if length(result) == 0 do 18 | room_user_state = RoomUserState.new(user_id: user_id, room_id: room.id, joined: false) 19 | room_user_state = Repo.create(room_user_state) 20 | get_query = from r in RoomUserState, 21 | where: r.id == ^room_user_state.id, 22 | preload: :user 23 | 24 | [room_user_state] = Repo.all get_query 25 | 26 | RoomUserState.public_attributes(room_user_state) ++ [room: Room.public_attributes(room)] ++ [user: User.public_attributes(room_user_state.user.get)] 27 | else 28 | [room_user_state|_] = result 29 | RoomUserState.public_attributes(room_user_state) ++ [room: Room.public_attributes(room)] ++ [user: User.public_attributes(room_user_state.user.get)] 30 | end 31 | end 32 | 33 | json_resp conn, [room_user_states: room_user_states_attributes] 34 | end 35 | 36 | 37 | def update(conn) do 38 | conn = authenticate_user!(conn) 39 | room_user_state_id = binary_to_integer(conn.params["room_user_state_id"]) 40 | user_id = conn.assigns[:current_user].id 41 | params = conn.params 42 | 43 | room_user_state_params = whitelist_params(params["room_user_state"], ["joined"]) 44 | query = from r in RoomUserState, 45 | where: r.id == ^room_user_state_id and r.user_id == ^user_id 46 | 47 | [room_user_state] = Repo.all query 48 | new_room_user_state = room_user_state.update(room_user_state_params) 49 | 50 | case RoomUserState.validate(new_room_user_state) do 51 | [] -> 52 | :ok = Repo.update(new_room_user_state) 53 | json_resp conn, [user: RoomUserState.public_attributes(new_room_user_state)] 54 | errors -> 55 | json_resp conn, [errors: errors], 422 56 | end 57 | end 58 | 59 | end 60 | -------------------------------------------------------------------------------- /assets/javascripts/views.js.coffee: -------------------------------------------------------------------------------- 1 | App.UsersIndexView = Ember.View.extend 2 | layoutName: "settings" 3 | classNames: ["settings"] 4 | 5 | App.UsersNewView = Ember.View.extend 6 | layoutName: "settings" 7 | classNames: ["settings"] 8 | 9 | App.UserEditView = Ember.View.extend 10 | layoutName: "settings" 11 | classNames: ["settings"] 12 | 13 | App.RoomsIndexView = Ember.View.extend 14 | layoutName: "settings" 15 | classNames: ["settings"] 16 | 17 | App.RoomsNewView = Ember.View.extend 18 | layoutName: "settings" 19 | classNames: ["settings"] 20 | 21 | App.RoomEditView = Ember.View.extend 22 | layoutName: "settings" 23 | classNames: ["settings"] 24 | 25 | 26 | App.NewMessageView = Ember.View.extend 27 | templateName: "new-message" 28 | classNames: ["new-message"] 29 | 30 | keyUp: (event)-> 31 | if event.keyCode == 13 # enter key 32 | return if event.target.value.trim() == "" 33 | @get("controller").send("postMessage", event.target.value); 34 | event.target.value = "" 35 | 36 | 37 | App.RoomMessagesView = Ember.View.extend 38 | templateName: "room-messages" 39 | classNames: ["messages-wrapper"] 40 | 41 | latestMsgId: -1 42 | 43 | scrollIfRequired: -> 44 | messages = @get("controller.activeState.room.messages") 45 | return if !messages || messages.length == 0 46 | 47 | lastMsg = messages[messages.length - 1] 48 | @set("latestMsgId", lastMsg.id) 49 | 50 | viewableHeight = @$().height() 51 | coveredHeight = if @$().scrollTop() < 1 then 1 else @$().scrollTop() 52 | scrollableHeight = @$().find(".messages").height() 53 | 54 | bottomHiddenContentSize = scrollableHeight - (coveredHeight + viewableHeight) 55 | bottomHiddenContentSize = 0 if bottomHiddenContentSize < 0 56 | 57 | # Assume that 16px == 1em. And if the scrolled lines are less than 4, then don't scroll 58 | #TODO use 32 if devicePixelRatio is 2 59 | 60 | if (bottomHiddenContentSize / 16) < 4 61 | Ember.run.scheduleOnce "afterRender", @, => 62 | @$().scrollTop( 63 | @$().find(".messages").prop("scrollHeight") + 100 64 | ) 65 | 66 | 67 | onMessagesChange: (-> 68 | @scrollIfRequired() 69 | ).observes("controller.activeState.room.messages.@each") 70 | 71 | onActiveStateChange: (-> 72 | @scrollIfRequired() 73 | ).observes("controller.activeState") 74 | 75 | -------------------------------------------------------------------------------- /assets/stylesheets/left-panel.scss: -------------------------------------------------------------------------------- 1 | .left-panel-wrapper { 2 | float: left; 3 | width: 20%; // TODO make responsive 4 | height: 100%; 5 | padding-right: 0.3em; 6 | 7 | .left-panel { 8 | float: left; 9 | width: 100%; 10 | height: 100%; 11 | background: #FCFCFC; 12 | box-shadow: 0.1em 0em 0em 0.1em #F1F1F1; 13 | border-right: 0.1em solid #DDD; 14 | overflow-y: scroll; 15 | } 16 | } 17 | 18 | .left-panel { 19 | .app-name { 20 | font-size: 1.2em; 21 | padding: 0.5em 1em 0.5em 1em; 22 | font-weight: bold; 23 | } 24 | 25 | .logout { 26 | float: right; 27 | a { 28 | cursor: pointer; 29 | img { 30 | height: 1em; 31 | } 32 | } 33 | } 34 | 35 | .user-details { 36 | float: left; 37 | width: 100%; 38 | padding: 0.5em 0.5em 0.5em 1.2em; 39 | 40 | a { 41 | color: #777; 42 | font-weight: bold; 43 | } 44 | } 45 | 46 | 47 | .navigation, .rooms { 48 | float: left; 49 | width: 100%; 50 | padding: 1.2em; 51 | 52 | ul { 53 | margin: 0em; 54 | list-style-type: none; 55 | padding: 0em; 56 | 57 | li { 58 | color: #BBB; 59 | padding: 0.25em 0em 0.25em 0em; 60 | 61 | a { cursor: pointer; } 62 | 63 | .room-name { 64 | padding: 0.25em; 65 | font-size: 0.9em; 66 | 67 | &:hover { 68 | background: #f1f1f1; 69 | border-radius: 0.3em; 70 | } 71 | } 72 | 73 | .leave-room { 74 | color: #C00; 75 | padding: 0em 0.35em 0em 0.35em; 76 | cursor: pointer; 77 | } 78 | } 79 | 80 | .notification { 81 | color: #6666CC; 82 | } 83 | 84 | .joined { color: #666; } 85 | .unjoined { padding-left: 1.6em; } 86 | } 87 | } 88 | 89 | .rooms li { font-size: 1.1em; } 90 | .navigation { 91 | li { 92 | a { 93 | text-decoration: none; 94 | color: #666; 95 | } 96 | } 97 | } 98 | 99 | .back-to-chat { 100 | padding: 1.25em; 101 | width: 100%; 102 | float: left; 103 | 104 | a { 105 | text-decoration: none; 106 | background: #444; 107 | color: #FFF; 108 | border-radius: 0.2em; 109 | padding: 0.25em; 110 | float: left; 111 | } 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /lib/mogo_chat/controllers/messages_api.ex: -------------------------------------------------------------------------------- 1 | defmodule MogoChat.Controllers.MessagesApi do 2 | use Phoenix.Controller 3 | import Ecto.Query 4 | import MogoChat.ControllerUtils 5 | 6 | 7 | def index(conn) do 8 | conn = authenticate_user!(conn) 9 | user_id = conn.assigns[:current_user].id 10 | before_message_id = conn.params["before"] 11 | after_message_id = conn.params["after"] 12 | room = Repo.get Room, binary_to_integer(conn.params["room_id"]) 13 | 14 | query = cond do 15 | before_message_id -> 16 | before_message_id = binary_to_integer(before_message_id) 17 | from m in Message, 18 | order_by: [desc: m.created_at], 19 | limit: 20, 20 | preload: :user, 21 | where: m.room_id == ^room.id and m.id < ^before_message_id 22 | 23 | after_message_id -> 24 | after_message_id = binary_to_integer(after_message_id) 25 | from m in Message, 26 | order_by: [desc: m.created_at], 27 | limit: 20, 28 | preload: :user, 29 | where: m.room_id == ^room.id and m.id > ^after_message_id 30 | 31 | true -> 32 | from m in Message, 33 | order_by: [desc: m.created_at], 34 | limit: 20, 35 | preload: :user, 36 | where: m.room_id == ^room.id 37 | end 38 | 39 | 40 | room_state_query = from r in RoomUserState, 41 | where: r.room_id == ^room.id and r.user_id == ^user_id 42 | 43 | [room_state] = Repo.all room_state_query 44 | room_state.update([last_pinged_at: current_timestamp()]) 45 | |> Repo.update() 46 | 47 | messages_attributes = lc message inlist Repo.all(query) do 48 | Dict.merge Message.public_attributes(message), [user: User.public_attributes(message.user.get)] 49 | end 50 | 51 | if !before_message_id do 52 | messages_attributes = Enum.reverse(messages_attributes) 53 | end 54 | 55 | json_resp conn, [messages: messages_attributes] 56 | end 57 | 58 | 59 | def create(conn) do 60 | conn = authenticate_user!(conn) 61 | user_id = conn.assigns[:current_user].id 62 | params = conn.params 63 | 64 | message_params = whitelist_params(params["message"], ["room_id", "body"]) 65 | room = Repo.get Room, message_params["room_id"] 66 | 67 | message = Message.new( 68 | body: message_params["body"], 69 | room_id: room.id, 70 | user_id: user_id, 71 | created_at: current_timestamp() 72 | ) 73 | |> Message.assign_message_type() 74 | 75 | case Message.validate(message) do 76 | [] -> 77 | saved_message = Repo.create(message) 78 | json_resp conn, [message: Message.public_attributes(saved_message)] 79 | errors -> 80 | json_resp conn, [errors: errors] 81 | end 82 | end 83 | 84 | end 85 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | [ "bcrypt": {:git, "git://github.com/irccloud/erlang-bcrypt.git", "3a26a36e4f274bbe30c6a2668922951a9467c99d", []}, 2 | "cowboy": {:git, "git://github.com/extend/cowboy.git", "8993249e421fbfff603cedec70e90be64d3694e6", []}, 3 | "cowlib": {:git, "git://github.com/extend/cowlib.git", "0d4ece08a7cce90a07cf33d4edad29bc324c7d90", [ref: "0.5.1"]}, 4 | "decimal": {:git, "git://github.com/ericmj/decimal.git", "ab1b6637aac1eabce5dc5a57290b5366878c2e84", []}, 5 | "ecto": {:git, "git://github.com/elixir-lang/ecto.git", "09204d16d18a6b1f2b3fcb6d02acd8a11c7ff485", []}, 6 | "erlang_localtime": {:git, "git://github.com/choptastic/erlang_localtime.git", "6e14b6e734eca1f51944a0bba25a4179d068496b", [branch: "master"]}, 7 | "erlware_commons": {:git, "git://github.com/erlware/erlware_commons.git", "b125ce055e8dd7df20b5a30c190f1a4c42a90b92", [branch: "master"]}, 8 | "erlydtl": {:git, "git://github.com/erlydtl/erlydtl.git", "8c876fde4c2b383c241d4d6cbba39d979548cd94", [tag: "0.9.0"]}, 9 | "ex_conf": {:git, "git://github.com/phoenixframework/ex_conf.git", "4ca0cbf65c70d1ce787834a0988338729dcf4098", []}, 10 | "ex_doc": {:git, "git://github.com/elixir-lang/ex_doc.git", "ee01fe3efd7b8af1dbaa00946bad5817d57b5875", []}, 11 | "inflex": {:git, "git://github.com/nurugger07/inflex.git", "f805c97308931bdb4f6e00de1dc893da796ea6c7", []}, 12 | "jsex": {:git, "git://github.com/talentdeficit/jsex.git", "65aa6ae7c1543a0c4e83032c04626af7be7a7f3e", []}, 13 | "jsx": {:git, "git://github.com/talentdeficit/jsx.git", "e50af6e109cb03bd26acf715cbc77de746507d1d", [tag: "v1.4.3"]}, 14 | "merl": {:git, "git://github.com/erlydtl/merl.git", "8cd131f65159309dce77db6fb8e2e8526a083867", [branch: "erlydtl"]}, 15 | "mime": {:git, "git://github.com/dynamo/mime.git", "db84370c53a67a7e58a4d0cfb026f4edc64a9367", []}, 16 | "phoenix": {:git, "git://github.com/phoenixframework/phoenix.git", "8471793cf83b615afa10ccb8b01f70a876c6b288", []}, 17 | "plug": {:git, "git://github.com/elixir-lang/plug.git", "fce879565115427239e650ae690455565b2fafee", []}, 18 | "poolboy": {:git, "git://github.com/devinus/poolboy.git", "64e1eaef0b1e88849c7e7ba9ff75f8cd4dba2a3b", []}, 19 | "postgrex": {:git, "git://github.com/ericmj/postgrex.git", "720b45b5613ef364bca3f5228e285489bb9f01d4", []}, 20 | "qdate": {:git, "git://github.com/choptastic/qdate.git", "6f9ca7d13ce44642a97f53752c2050f6e8bb5cdb", []}, 21 | "quickrand": {:git, "https://github.com/okeuday/quickrand.git", "b4db3b4483b73107682fc593c2bc94be922ddcda", [tag: "v1.3.1"]}, 22 | "ranch": {:git, "git://github.com/extend/ranch.git", "5df1f222f94e08abdcab7084f5e13027143cc222", [ref: "0.9.0"]}, 23 | "rebar_vsn_plugin": {:git, "https://github.com/erlware/rebar_vsn_plugin.git", "fd40c960c7912193631d948fe962e1162a8d1334", [branch: "master"]}, 24 | "uuid": {:git, "git://github.com/okeuday/uuid.git", "841aec1d5833f84b9338032f013fbf4ac1416bfb", []} ] 25 | -------------------------------------------------------------------------------- /assets/stylesheets/mobile.scss: -------------------------------------------------------------------------------- 1 | @mixin desktop-styles { 2 | .login { 3 | width: 20em; 4 | padding: 1em 2em 2em 2em; 5 | } 6 | 7 | .detail { 8 | width: 80%; 9 | padding: 2.3em 1em 1em 1em; 10 | } 11 | 12 | .room-content { 13 | width: 60%; 14 | left: 20%; 15 | 16 | .new-message { 17 | width: 60%; 18 | } 19 | 20 | .room-info { 21 | width: 60%; 22 | } 23 | } 24 | 25 | .left-menu-control, .right-menu-control { 26 | display: none; 27 | } 28 | } 29 | 30 | 31 | @mixin mobile-styles { 32 | 33 | .container { 34 | position: absolute; 35 | overflow-x: hidden !important; 36 | } 37 | 38 | .left-panel-wrapper { 39 | .rooms li { 40 | margin-top: 0.5em; 41 | margin-bottom: 0.5em; 42 | } 43 | } 44 | 45 | .login { width: 100%; } 46 | 47 | .detail { 48 | width: 100%; 49 | padding-top: 2.3em; 50 | } 51 | 52 | .room-content { 53 | .room-info { 54 | width: 100%; 55 | } 56 | .new-message { 57 | width: 100%; 58 | } 59 | } 60 | 61 | .left-menu-control { float: left; } 62 | .right-menu-control { float: right; } 63 | 64 | .left-menu-control, .right-menu-control { 65 | cursor: pointer; 66 | 67 | img { 68 | height: 1.2em; 69 | margin-right: 0.5em; 70 | } 71 | } 72 | 73 | .left-panel-wrapper { 74 | width: 80%; 75 | margin-left: -80%; 76 | } 77 | 78 | .room-content { width: 100%; } 79 | .move-content-right { margin-left: 80%; } 80 | .move-content-left { margin-left: -80%; } 81 | 82 | .room-details-wrapper { 83 | width: 80%; 84 | margin-right: -80%; 85 | } 86 | 87 | .is-left-menu-open { 88 | position: relative !important; 89 | margin-left: 0% !important; 90 | } 91 | 92 | .is-right-menu-open { 93 | position: relative !important; 94 | margin-right: 0% !important; 95 | } 96 | } 97 | 98 | // desktop 99 | @media screen 100 | and (min-device-width : 1024px) { 101 | @include desktop-styles; 102 | } 103 | 104 | // ipad landscape 105 | @media screen 106 | and (min-device-width : 768px) 107 | and (max-device-width : 1024px) 108 | and (orientation : landscape) { 109 | @include mobile-styles; 110 | } 111 | 112 | // ipad portrait 113 | @media screen 114 | and (min-device-width : 768px) 115 | and (max-device-width : 1024px) 116 | and (orientation : portrait) { 117 | @include mobile-styles; 118 | } 119 | 120 | // iphone-5 portrait & landscape 121 | @media screen 122 | and (min-device-width : 320px) 123 | and (max-device-width : 568px) { 124 | @include mobile-styles; 125 | } 126 | 127 | // iphone 2G-4S in portrait & landscape 128 | @media screen 129 | and (min-device-width : 320px) 130 | and (max-device-width : 480px) { 131 | @include mobile-styles; 132 | } 133 | 134 | // Other mobile devices 135 | @media screen and (max-width: 480px) { 136 | @include mobile-styles; 137 | } 138 | 139 | // Desktop 140 | @media screen and (min-width: 1024px) { 141 | @include desktop-styles; 142 | } 143 | -------------------------------------------------------------------------------- /lib/mogo_chat/controllers/rooms_api.ex: -------------------------------------------------------------------------------- 1 | defmodule MogoChat.Controllers.RoomsApi do 2 | use Phoenix.Controller 3 | import Ecto.Query 4 | import MogoChat.ControllerUtils 5 | 6 | 7 | def active_users(conn) do 8 | authenticate_user!(conn) 9 | 10 | room_id = binary_to_integer(conn.params["room_id"]) 11 | room = Repo.get Room, room_id 12 | now = current_timestamp() 13 | seconds_ago = now.sec(now.sec - 7) 14 | query = from s in RoomUserState, 15 | where: s.room_id == ^room.id and s.last_pinged_at > ^seconds_ago, 16 | order_by: s.id, 17 | preload: :user 18 | 19 | users_attributes = lc room_user_state inlist Repo.all(query) do 20 | User.public_attributes(room_user_state.user.get) 21 | end 22 | 23 | json_resp conn, [users: users_attributes] 24 | end 25 | 26 | 27 | def index(conn) do 28 | authenticate_user!(conn) 29 | 30 | rooms = Repo.all Room 31 | rooms_attributes = lc room inlist rooms do 32 | Room.public_attributes(room) 33 | end 34 | json_resp conn, [rooms: rooms_attributes] 35 | end 36 | 37 | 38 | def show(conn) do 39 | conn = authenticate_user!(conn) 40 | 41 | room_id = conn.params["room_id"] 42 | room = Repo.get Room, room_id 43 | 44 | json_resp conn, [room: Room.public_attributes(room)] 45 | end 46 | 47 | 48 | def create(conn) do 49 | conn = authenticate_user!(conn) 50 | authorize_roles!(conn, ["admin"]) 51 | 52 | params = conn.params 53 | 54 | room_params = whitelist_params(params["room"], ["name"]) 55 | room = Room.new room_params 56 | 57 | case Room.validate(room) do 58 | [] -> 59 | room = Repo.create(room) 60 | json_resp conn, [room: Room.public_attributes(room)] 61 | errors -> 62 | json_resp conn, [errors: errors], 422 63 | end 64 | end 65 | 66 | 67 | def update(conn) do 68 | conn = authenticate_user!(conn) 69 | authorize_roles!(conn, ["admin"]) 70 | 71 | room_id = conn.params["room_id"] 72 | params = conn.params 73 | 74 | room_params = whitelist_params(params["room"], ["name"]) 75 | room = Repo.get(Room, room_id).update(room_params) 76 | 77 | case Room.validate(room) do 78 | [] -> 79 | :ok = Repo.update(room) 80 | json_resp conn, [user: Room.public_attributes(room)] 81 | errors -> 82 | json_resp conn, [errors: errors], 422 83 | end 84 | end 85 | 86 | 87 | def destroy(conn) do 88 | conn = authenticate_user!(conn) 89 | authorize_roles!(conn, ["admin"]) 90 | 91 | room_id = binary_to_integer(conn.params["room_id"]) 92 | 93 | # Note instead of fetching it and deleting it 94 | # just create a new record and delete it 95 | room = Room.new(id: room_id) 96 | Repo.delete room 97 | 98 | # Delete all messages 99 | Repo.delete_all(from m in Message, where: m.room_id == ^room_id) 100 | 101 | # Delete the room user states 102 | Repo.delete_all(from rus in RoomUserState, where: rus.room_id == ^room_id) 103 | 104 | json_resp conn, "" 105 | end 106 | 107 | end -------------------------------------------------------------------------------- /lib/mogo_chat/controller_utils.ex: -------------------------------------------------------------------------------- 1 | defmodule MogoChat.ControllerUtils do 2 | import Plug.Connection 3 | import Phoenix.Controller 4 | import Ecto.Query 5 | 6 | def json_decode(json) do 7 | {:ok, data} = JSEX.decode(json) 8 | data 9 | end 10 | 11 | 12 | def json_resp(conn, data, status \\ 200) do 13 | json conn, status, json_encode(data) 14 | end 15 | 16 | 17 | def json_encode(data) do 18 | {:ok, json} = JSEX.encode(data) 19 | json 20 | end 21 | 22 | 23 | def current_timestamp() do 24 | {{year, month, day}, {hour, minute, seconds}} = :calendar.universal_time() 25 | created_at = Ecto.DateTime.new( 26 | year: year, 27 | month: month, 28 | day: day, 29 | hour: hour, 30 | min: minute, 31 | sec: seconds) 32 | end 33 | 34 | 35 | def xhr?(conn) do 36 | headers = conn.req_headers 37 | headers["x-requested-with"] && Regex.match?(~r/xmlhttprequest/i, headers["x-requested-with"]) 38 | end 39 | 40 | 41 | def authenticate_user!(conn) do 42 | user = get_user_for_request(conn) 43 | 44 | if user do 45 | assign(conn, :current_user, user) 46 | else 47 | unauthorized!(conn) 48 | end 49 | end 50 | 51 | 52 | defp get_user_for_request(conn) do 53 | cond do 54 | conn.assigns[:session] -> 55 | user_id = conn.assigns[:session] 56 | Repo.get(User, user_id) 57 | conn.req_headers["authorization"] -> 58 | auth_token = conn.req_headers["authorization"] 59 | query = from u in User, where: u.auth_token == ^auth_token 60 | users = Repo.all query 61 | if length(users) > 0 do 62 | hd(users) 63 | else 64 | nil 65 | end 66 | true -> nil 67 | end 68 | end 69 | 70 | 71 | defp unauthorized!(conn) do 72 | raise MogoChat.Errors.Unauthorized, message: "not authorized" 73 | end 74 | 75 | 76 | def authorize_if!(conn, condition) do 77 | user = conn.assigns[:current_user] 78 | is_authorized = apply(condition, [conn, user]) 79 | 80 | unless is_authorized do 81 | unauthorized!(conn) 82 | end 83 | end 84 | 85 | 86 | def authorize_roles!(conn, allowed_roles) do 87 | user = conn.assigns[:current_user] 88 | 89 | unless :lists.member(user.role, allowed_roles) do 90 | unauthorized!(conn) 91 | end 92 | end 93 | 94 | 95 | def whitelist_params(params, allowed) do 96 | whitelist_params(params, allowed, []) 97 | end 98 | 99 | 100 | def whitelist_params(params, [], collected) do 101 | collected 102 | end 103 | 104 | 105 | def whitelist_params(params, allowed, collected) do 106 | [field | rest] = allowed 107 | if Dict.has_key?(params, field) do 108 | collected = ListDict.merge collected, [{ field, Dict.get(params, field) }] 109 | end 110 | whitelist_params(params, rest, collected) 111 | end 112 | 113 | 114 | def put_session(conn, user_id) do 115 | :ok = conn.assigns[:session_adapter].put(conn.assigns[:session_id], user_id) 116 | assign(conn, :session, user_id) 117 | end 118 | 119 | 120 | def destroy_session(conn) do 121 | apply(conn.assigns[:session_adapter], :delete, [conn.assigns[:session_id]]) 122 | assign(conn, :session, nil) 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /assets/javascripts/models/room_user_state.js.coffee: -------------------------------------------------------------------------------- 1 | App.RoomUserState = DS.Model.extend Em.Evented, 2 | user: DS.belongsTo("user") 3 | joined: DS.attr("boolean") 4 | room: DS.belongsTo("room") 5 | lastPingedAt: DS.attr("date") 6 | beforeMessageId: DS.attr("number") 7 | afterMessageId: DS.attr("number") 8 | active: DS.attr("boolean", {defaultValue: false}) 9 | notification: DS.attr("boolean", {defaultValue: false}) 10 | 11 | addUsers: (users)-> 12 | users = for userAttributes in users 13 | if @store.recordIsLoaded("user", userAttributes.id) 14 | user = @store.getById("user", userAttributes.id) 15 | else 16 | user = @store.push("user", 17 | id: userAttributes.id 18 | name: userAttributes.name 19 | role: userAttributes.role 20 | color: App.paintBox.getColor() 21 | ) 22 | @set("room.users", users) 23 | 24 | 25 | addMessages: (data)-> 26 | messages = data.messages 27 | 28 | if data.before 29 | if data.before == true && messages 30 | messages = messages.reverse() 31 | addAction = "unshiftObject" 32 | else 33 | addAction = "pushObject" 34 | 35 | for messageAttrs in messages 36 | if @store.recordIsLoaded("message", messageAttrs.id) 37 | message = @store.getById("message", messageAttrs.id) 38 | 39 | #TODO this can be optimized I think. Computed property? 40 | existingKeys = [] 41 | for existingMsg in @get("room.messages") 42 | existingKeys.push(existingMsg.get("id")) 43 | 44 | if existingKeys.indexOf(message.get("id")) == -1 45 | @get("room.messages")[addAction](message) 46 | else 47 | escapedBody = $('
').text(messageAttrs.body).html(); 48 | message = @store.push("message", { 49 | id: messageAttrs.id, 50 | type: messageAttrs.type, 51 | body: messageAttrs.body, 52 | formattedBody: App.plugins.processMessageBody( 53 | escapedBody, 54 | messageAttrs.type, 55 | (if data.before then true else false) 56 | ), 57 | createdAt: messageAttrs.created_at 58 | }) 59 | @get("room.messages")[addAction](message) 60 | 61 | message.set("room", @get("room")) 62 | 63 | if @store.recordIsLoaded("user", messageAttrs.user.id) 64 | user = @store.getById("user", messageAttrs.user.id) 65 | else 66 | userParams = 67 | id: messageAttrs.user.id 68 | name: messageAttrs.user.name 69 | role: messageAttrs.user.role 70 | color: App.paintBox.getColor() 71 | archived: messageAttrs.user.archived 72 | user = @store.push("user", userParams) 73 | 74 | message.set("user", user) 75 | 76 | if message.get("body").match(@get("user.name")) && message.get("type") != "paste" && message.get("user.id") != @get("user.id") && addAction == "pushObject" && @get("afterMessageId") 77 | @set("notification", true) if !@get("active") 78 | App.notifyBySound() 79 | 80 | if @get("room.messages.length") == (MogoChat.config.messagesPerLoad + 1) && addAction == "pushObject" 81 | @get("room.messages").shiftObject() 82 | @set("room.isHistoryAvailable", true) 83 | 84 | if messages.length > 0 && !data.before 85 | @set("afterMessageId", messages[messages.length - 1].id) 86 | -------------------------------------------------------------------------------- /lib/mogo_chat/controllers/users_api.ex: -------------------------------------------------------------------------------- 1 | defmodule MogoChat.Controllers.UsersApi do 2 | use Phoenix.Controller 3 | import Ecto.Query 4 | import MogoChat.ControllerUtils 5 | 6 | 7 | def index(conn) do 8 | conn = authenticate_user!(conn) 9 | authorize_roles!(conn, ["admin"]) 10 | 11 | users = Repo.all(from u in User, where: u.archived == ^false) 12 | users_attributes = lc user inlist users do 13 | User.attributes(user, ["id", "name", "role", "email", "auth_token"]) 14 | end 15 | json_resp conn, [users: users_attributes] 16 | end 17 | 18 | 19 | def create(conn) do 20 | conn = authenticate_user!(conn) 21 | authorize_roles!(conn, ["admin"]) 22 | 23 | params = conn.params 24 | user_params = whitelist_params(params["user"], ["name", "email", "password", "role"]) 25 | 26 | user = User.new(user_params) 27 | |> User.encrypt_password() 28 | |> User.assign_auth_token() 29 | 30 | case User.validate(user) do 31 | [] -> 32 | saved_user = Repo.create(user) 33 | json_resp conn, [user: User.public_attributes(saved_user)] 34 | errors -> 35 | json_resp conn, [errors: errors], 422 36 | end 37 | end 38 | 39 | 40 | def show(conn) do 41 | conn = authenticate_user!(conn) 42 | authorize_if! conn, fn(conn, user)-> 43 | user_id = binary_to_integer(conn.params["user_id"]) 44 | 45 | cond do 46 | user.id == user_id || user.role == "admin" -> 47 | true 48 | true -> 49 | false 50 | end 51 | end 52 | 53 | user_id = conn.params["user_id"] 54 | # TODO user query to not return archived users 55 | user = Repo.get User, user_id 56 | user_attributes = User.attributes(user, ["id", "name", "role", "email", "auth_token", "archived"]) 57 | json_resp conn, [user: user_attributes] 58 | end 59 | 60 | 61 | def update(conn) do 62 | conn = authenticate_user!(conn) 63 | authorize_if! conn, fn(conn, user)-> 64 | user_id = binary_to_integer(conn.params["user_id"]) 65 | 66 | cond do 67 | user.id == user_id || user.role == "admin" -> 68 | true 69 | true -> 70 | false 71 | end 72 | end 73 | 74 | user_id = conn.params["user_id"] 75 | params = conn.params 76 | current_user = conn.assigns[:current_user] 77 | whitelist = ["name", "email", "password"] 78 | if current_user.role == "admin" do 79 | whitelist = whitelist ++ ["role"] 80 | end 81 | 82 | user_params = whitelist_params(params["user"], whitelist) 83 | # TODO use query to not fetch archived users 84 | user = Repo.get(User, user_id).update(user_params) 85 | |> User.encrypt_password() 86 | 87 | case User.validate(user) do 88 | [] -> 89 | :ok = Repo.update(user) 90 | json_resp conn, [user: User.public_attributes(user)] 91 | errors -> 92 | json_resp conn, [errors: errors], 422 93 | end 94 | end 95 | 96 | 97 | def destroy(conn) do 98 | conn = authenticate_user!(conn) 99 | authorize_roles!(conn, ["admin"]) 100 | 101 | user_id = binary_to_integer(conn.params["user_id"]) 102 | current_user_id = conn.assigns[:current_user].id 103 | if current_user_id != user_id do 104 | new_attrs = [archived: true, email: nil, auth_token: nil, encrypted_password: nil] 105 | updated_user = Repo.get(User, user_id).update(new_attrs) 106 | :ok = Repo.update(updated_user) 107 | 108 | Repo.delete_all(from rus in RoomUserState, where: rus.user_id == ^user_id) 109 | end 110 | json_resp conn, "" 111 | end 112 | 113 | end -------------------------------------------------------------------------------- /assets/stylesheets/active-room.scss: -------------------------------------------------------------------------------- 1 | .room-content { 2 | position: absolute; 3 | height: 100%; 4 | overflow: hidden; 5 | 6 | .room-info { 7 | position: fixed; 8 | z-index: 100; 9 | background: #fff; 10 | top: 0em; 11 | padding: 0.25em; 12 | font-size: 1.25em; 13 | border-bottom: 0.1em solid #DDD; 14 | color: #06c; 15 | } 16 | 17 | .menu-control { 18 | float: left; 19 | cursor: pointer; 20 | 21 | img { 22 | width: 1.25em; 23 | height: 1.25em; 24 | margin-right: 0.5em; 25 | } 26 | } 27 | 28 | .messages-wrapper { 29 | position: absolute; 30 | width: 100%; 31 | top: 2.7em; 32 | bottom: 3.5em; 33 | overflow-y: scroll; 34 | } 35 | 36 | .load-messages { 37 | background: #FDE893; 38 | padding: 0.25em; 39 | border-radius: 0.3em; 40 | cursor: pointer; 41 | } 42 | 43 | .messages { 44 | float: left; 45 | clear: both; 46 | width: 100%; 47 | padding: 0.5em; 48 | 49 | .message { 50 | clear: both; 51 | float: left; 52 | width: 100%; 53 | margin-top: 1em; 54 | 55 | .content a { color: #06c; } 56 | .me-body { font-style: italic; } 57 | 58 | .paste-wrapper { 59 | float: left; 60 | width: 100%; 61 | box-shadow: 0 0 0.1em 0.05em #CCC; 62 | background: #fff; 63 | margin-top: 0.25em; 64 | padding: 0.25em; 65 | 66 | .paste-actions { 67 | float: left; 68 | a { color: #06C; } 69 | } 70 | 71 | pre.paste-body { 72 | float: left; 73 | width: 100%; 74 | font-family: monospace; 75 | background: #FAFAFA; 76 | padding: 0.5em; 77 | color: #444; 78 | margin: 0.3em 0em 0em 0em; 79 | font-size: 0.9em; 80 | overflow-x: scroll; 81 | } 82 | } 83 | 84 | .message-info { 85 | float: left; 86 | font-size: 0.75em; 87 | color: #888; 88 | width: 100%; 89 | text-align: right; 90 | 91 | .message-meta { 92 | float: right; 93 | 94 | .timestamp, .post-error { 95 | padding: 0.25em; 96 | border-radius: 0.25em; 97 | float: right; 98 | } 99 | 100 | .timestamp { 101 | background: #f1f1f1; 102 | color: #888; 103 | } 104 | 105 | .post-error { 106 | background: #dd0000; 107 | color: #fff; 108 | } 109 | } 110 | } 111 | 112 | .user { font-weight: bold; } 113 | } 114 | } 115 | 116 | .new-message { 117 | position: fixed; 118 | bottom: 0em; 119 | 120 | textarea { 121 | width: 100%; 122 | height: 3.5em; 123 | resize: none; 124 | border-top: 0.1em solid #DDD; 125 | border-right: none; 126 | border-left: none; 127 | border-bottom: none; 128 | &:hover { 129 | outline: none; 130 | } 131 | } 132 | } 133 | } 134 | 135 | 136 | .room-details-wrapper { 137 | width: 20%; // TODO make responsive 138 | height: 100%; 139 | padding-left: 0.3em; 140 | float: right; 141 | 142 | .room-details { 143 | float: left; 144 | width: 100%; 145 | height: 100%; 146 | background: #FCFCFC; 147 | box-shadow: -0.1em 0em 0em 0.1em #F1F1F1; 148 | border-left: 0.1em solid #DDD; 149 | padding: 0.5em; 150 | } 151 | 152 | .active-users { 153 | float: right; 154 | width: 100%; 155 | 156 | ul { 157 | padding: 0em; 158 | margin: 0em; 159 | 160 | li { 161 | list-style-type: none; 162 | font-size: 1em; 163 | color: #777; 164 | padding-left: 0.2em; 165 | margin-bottom: 0.5em; 166 | } 167 | } 168 | } 169 | 170 | } 171 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | Hound.start [driver: "selenium"] 3 | MogoChat.Dynamo.run() 4 | Repo.start_link 5 | 6 | defmodule MogoChat.TestCase do 7 | use ExUnit.CaseTemplate 8 | 9 | # Enable code reloading on test cases 10 | setup do 11 | Dynamo.Loader.enable 12 | :ok 13 | end 14 | end 15 | 16 | 17 | defmodule TestUtils do 18 | def app_path(path \\ "") do 19 | "http://localhost:#{MogoChat.Dynamo.config[:server][:port]}/#{path}" 20 | end 21 | 22 | 23 | def truncate_db_after_test do 24 | table_names = ["users", "messages", "rooms", "room_user_states"] 25 | sql = "TRUNCATE TABLE #{Enum.join(table_names, ", ")} RESTART IDENTITY CASCADE;" 26 | Repo.adapter.query(Repo, sql, []) 27 | end 28 | 29 | 30 | defmacro test_helpers do 31 | quote do 32 | def wait_until(arg, wait_time \\ 10) do 33 | if until_element(arg, wait_time) do 34 | true 35 | else 36 | IO.inspect "The following condition wasn't fullfilled:" 37 | throw arg 38 | end 39 | end 40 | 41 | 42 | defp until_element(arg, wait_time) do 43 | new_wait_time = wait_time - 1 44 | :timer.sleep(1000) 45 | 46 | if new_wait_time > 0 do 47 | try do 48 | if is_tuple(arg) do 49 | {strategy, identifier} = arg 50 | find_element(strategy, identifier) 51 | else 52 | if !apply(arg, []) do 53 | until_element(arg, new_wait_time) 54 | end 55 | end 56 | catch 57 | _ -> 58 | until_element(arg, new_wait_time) 59 | else 60 | _ -> 61 | true 62 | end 63 | else 64 | if is_tuple(arg) do 65 | {strategy, identifier} = arg 66 | find_element(strategy, identifier) 67 | else 68 | apply(arg, []) 69 | end 70 | end 71 | end 72 | 73 | 74 | def join_room(name) do 75 | elements = find_all_elements(:class, "room-name") 76 | 77 | lc element inlist elements do 78 | room_name = visible_text(element) 79 | if Regex.match?(%r(#{name}), room_name) do 80 | click(element) 81 | wait_until fn-> 82 | active_room_name = visible_text({:class, "active-room-name"}) 83 | Regex.match?(%r(#{active_room_name}), room_name) 84 | end 85 | end 86 | end 87 | 88 | end 89 | 90 | 91 | def login_user(name, email, role) do 92 | user = create_user("Test", "test@example.com", role) 93 | 94 | navigate_to app_path() 95 | wait_until({:name, "email"}) 96 | url_at_login = current_url() 97 | 98 | fill_field {:name, "email"}, user.email 99 | fill_field {:name, "password"}, "password" 100 | click({:name, "login"}) 101 | 102 | wait_until({:class, "left-panel-wrapper"}) 103 | assert url_at_login != current_url() 104 | end 105 | 106 | 107 | def login_admin(name, email) do 108 | login_user(name, email, "admin") 109 | end 110 | 111 | def login_member(name, email) do 112 | login_user(name, email, "member") 113 | end 114 | 115 | end 116 | end 117 | 118 | 119 | def create_room(name) do 120 | Room.new(name: name) 121 | |> Repo.create 122 | end 123 | 124 | 125 | def create_user(name, email, role) do 126 | User.new(name: name, email: email, password: "password", role: role) 127 | |> User.encrypt_password() 128 | |> Repo.create 129 | end 130 | 131 | 132 | def create_admin(name, email) do 133 | create_user(name, email, "admin") 134 | end 135 | 136 | 137 | def create_member(name, email) do 138 | create_user(name, email, "member") 139 | end 140 | 141 | 142 | defmacro test_dynamo(dynamo) do 143 | quote do 144 | setup_all do 145 | Dynamo.under_test(unquote(dynamo)) 146 | :ok 147 | end 148 | 149 | teardown_all do 150 | Dynamo.under_test(nil) 151 | :ok 152 | end 153 | end 154 | end 155 | 156 | end -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | [TODO: Too tired to complete the docs. If you feel like contributing, please take a look at the routers and send a pull-request.] 4 | 5 | #### Roles 6 | 7 | Valid roles are "admin" and "member". 8 | 9 | #### Authentication 10 | 11 | Find your auth token on your account settings page. 12 | If your token is `1n2jjvsns`, pass the auth token as a header `Authorization: 1n2jjvsns` for the API request. 13 | 14 | Using curl for example, to get a list of rooms, 15 | 16 | ``` 17 | curl -XGET http://example.com/api/rooms -H 'Authorization: 1n2jjvsns' 18 | ``` 19 | 20 | Your access to certain API calls depends on the role of your account. 21 | 22 | #### Request body 23 | 24 | Request body must be encoded in JSON for all POST and PUT requests. 25 | 26 | 27 | ### Available APIs 28 | 29 | * [Users](#users) 30 | * [Create a user](#create-a-user) 31 | * [Modify a user](#modify-a-user) 32 | * [Delete a user](#delete-a-user) 33 | * [Rooms](#rooms) 34 | * [Create a room](#create-a-room) 35 | * [Modify a room](#modify-a-room) 36 | * [Delete a room](#delete-a-room) 37 | * [Active users in a room](#active-users-in-a-room) 38 | * [Messages](#messages) 39 | * [Post a message to a room](#post-a-message-to-a-room) 40 | * [Get messages in a room](#get-messages-in-a-room) 41 | * [Room User States](#room-user-states) 42 | * [Get room states](#get-room-states) 43 | * [Join or leave rooms](#join-or-leave-rooms) 44 | * [Sessions](#sessions) 45 | * [Sign in](#sign-in) 46 | 47 | 48 | ## Users 49 | 50 | #### Create a user 51 | 52 | > POST /api/users 53 | 54 | Request body example: 55 | 56 | ``` 57 | { 58 | "user": { 59 | "name": "John Doe", 60 | "email": "user@example.com", 61 | "password": "password", 62 | "role": "member" 63 | } 64 | } 65 | ``` 66 | 67 | #### Modify a user 68 | 69 | > PUT /api/users/:user_id 70 | 71 | ``` 72 | { 73 | "user": { 74 | "name": "John Doe", 75 | "email": "user@example.com", 76 | "password": "password", 77 | "role": "member" 78 | } 79 | } 80 | ``` 81 | 82 | #### Delete a user 83 | 84 | > DELETE /api/users/:user_id 85 | 86 | 87 | ## Rooms 88 | 89 | #### Create a room 90 | 91 | > POST /api/rooms 92 | 93 | ``` 94 | { 95 | "room": { 96 | name: "Example" 97 | } 98 | } 99 | ``` 100 | 101 | #### Modify a room 102 | 103 | > PUT /api/rooms/:room_id 104 | 105 | ``` 106 | { 107 | "room": { 108 | name: "New room name" 109 | } 110 | } 111 | ``` 112 | 113 | #### Delete a room 114 | 115 | > DELETE /api/rooms/:room_id 116 | 117 | 118 | #### Active users in a room 119 | 120 | > GET /api/rooms/:room_id/users 121 | 122 | You must be active in a room ("joined") to access it's user list. 123 | 124 | ``` 125 | TODO example body 126 | ``` 127 | 128 | ## Messages 129 | 130 | #### Post a message to a room 131 | 132 | > POST /messages 133 | 134 | Request body: 135 | 136 | ``` 137 | { 138 | "message": { 139 | "room_id": 1, 140 | "user_id": 2, 141 | "body": "Hey guys, what's up?", 142 | "type": "text" 143 | } 144 | } 145 | ``` 146 | 147 | Response body: 148 | ``` 149 | { 150 | "message": { 151 | "id": 80, 152 | "room_id": 1, 153 | "user_id": 2, 154 | "body": "Hey guys, what's up?", 155 | "type": "text", 156 | "created_at": "2014-04-07T08:28:48Z" 157 | } 158 | } 159 | ``` 160 | 161 | #### Get messages in a room 162 | 163 | > GET /messages/:room_id 164 | 165 | By default lists the most recent 20 messages in a room. 166 | 167 | In order to get a room's history or get future messages, you can pass any one of the following params: 168 | 169 | * `before` - ID of a message, before which you need the 20 previous (older) messages. Use this to fetch history. 170 | * `after` - ID of a message, after which you need the next (newer) 20 messages. Use this to poll for new messages. 171 | 172 | ``` 173 | { 174 | "messages": [ 175 | { 176 | "id": 8, 177 | "room_id": 1, 178 | "user_id": 2, 179 | "body": "Hey guys!", 180 | "type": "text", 181 | "created_at": "2014-03-30T12:09:11Z", 182 | "user": { 183 | "id": 2, 184 | "name": "Jurre", 185 | "role": "member", 186 | "archived": false 187 | } 188 | }, 189 | { 190 | "id": 9, 191 | "room_id": 1, 192 | "user_id": 2, 193 | "body": "What's up!?", 194 | "type": "text", 195 | "created_at": "2014-03-30T12:17:22Z", 196 | "user": { 197 | "id": 2, 198 | "name": "Jurre", 199 | "role": "member", 200 | "archived": false 201 | } 202 | } 203 | ] 204 | } 205 | ``` 206 | 207 | ## Room User States 208 | 209 | #### Get room states 210 | 211 | > GET /api/room_user_states 212 | 213 | Returns the user's states of rooms (joined/unjoined, last pinged at, etc), along with the room details (only name of the room for now). 214 | 215 | ``` 216 | TODO 217 | ``` 218 | 219 | #### Join or leave rooms 220 | 221 | > PUT /api/room_user_states/:room_user_state_id 222 | 223 | The `room_user_state_id` is the user's state's ID in a room. 224 | It is not the room ID. The user's states for rooms can be fetched as mentioned in the previous request 225 | 226 | Set `joined` to `true` to join a room, or set `false` to leave it. 227 | 228 | ``` 229 | TODO 230 | ``` 231 | 232 | ## Sessions 233 | 234 | #### Sign in 235 | > POST /api/sessions 236 | 237 | Request body: 238 | 239 | ``` 240 | { 241 | "email": "you@example.com", 242 | "password": "correct horse battery staple" 243 | } 244 | ``` 245 | 246 | Response body: 247 | 248 | ``` 249 | { 250 | "user": { 251 | "id": 1, 252 | "name": "You", 253 | "role": "admin", 254 | "email": "you@example.com", 255 | "auth_token": "aa23e8a5-c9d1-4656-bee0-e30807177e0d" 256 | } 257 | } 258 | ``` 259 | -------------------------------------------------------------------------------- /assets/stylesheets/lib/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v2.1.3 | MIT License | git.io/normalize */ 2 | 3 | /* ========================================================================== 4 | HTML5 display definitions 5 | ========================================================================== */ 6 | 7 | /** 8 | * Correct `block` display not defined in IE 8/9. 9 | */ 10 | 11 | article, 12 | aside, 13 | details, 14 | figcaption, 15 | figure, 16 | footer, 17 | header, 18 | hgroup, 19 | main, 20 | nav, 21 | section, 22 | summary { 23 | display: block; 24 | } 25 | 26 | /** 27 | * Correct `inline-block` display not defined in IE 8/9. 28 | */ 29 | 30 | audio, 31 | canvas, 32 | video { 33 | display: inline-block; 34 | } 35 | 36 | /** 37 | * Prevent modern browsers from displaying `audio` without controls. 38 | * Remove excess height in iOS 5 devices. 39 | */ 40 | 41 | audio:not([controls]) { 42 | display: none; 43 | height: 0; 44 | } 45 | 46 | /** 47 | * Address `[hidden]` styling not present in IE 8/9. 48 | * Hide the `template` element in IE, Safari, and Firefox < 22. 49 | */ 50 | 51 | [hidden], 52 | template { 53 | display: none; 54 | } 55 | 56 | /* ========================================================================== 57 | Base 58 | ========================================================================== */ 59 | 60 | /** 61 | * 1. Set default font family to sans-serif. 62 | * 2. Prevent iOS text size adjust after orientation change, without disabling 63 | * user zoom. 64 | */ 65 | 66 | html { 67 | font-family: sans-serif; /* 1 */ 68 | -ms-text-size-adjust: 100%; /* 2 */ 69 | -webkit-text-size-adjust: 100%; /* 2 */ 70 | } 71 | 72 | /** 73 | * Remove default margin. 74 | */ 75 | 76 | body { 77 | margin: 0; 78 | } 79 | 80 | /* ========================================================================== 81 | Links 82 | ========================================================================== */ 83 | 84 | /** 85 | * Remove the gray background color from active links in IE 10. 86 | */ 87 | 88 | a { 89 | background: transparent; 90 | } 91 | 92 | /** 93 | * Address `outline` inconsistency between Chrome and other browsers. 94 | */ 95 | 96 | a:focus { 97 | outline: thin dotted; 98 | } 99 | 100 | /** 101 | * Improve readability when focused and also mouse hovered in all browsers. 102 | */ 103 | 104 | a:active, 105 | a:hover { 106 | outline: 0; 107 | } 108 | 109 | /* ========================================================================== 110 | Typography 111 | ========================================================================== */ 112 | 113 | /** 114 | * Address variable `h1` font-size and margin within `section` and `article` 115 | * contexts in Firefox 4+, Safari 5, and Chrome. 116 | */ 117 | 118 | h1 { 119 | font-size: 2em; 120 | margin: 0.67em 0; 121 | } 122 | 123 | /** 124 | * Address styling not present in IE 8/9, Safari 5, and Chrome. 125 | */ 126 | 127 | abbr[title] { 128 | border-bottom: 1px dotted; 129 | } 130 | 131 | /** 132 | * Address style set to `bolder` in Firefox 4+, Safari 5, and Chrome. 133 | */ 134 | 135 | b, 136 | strong { 137 | font-weight: bold; 138 | } 139 | 140 | /** 141 | * Address styling not present in Safari 5 and Chrome. 142 | */ 143 | 144 | dfn { 145 | font-style: italic; 146 | } 147 | 148 | /** 149 | * Address differences between Firefox and other browsers. 150 | */ 151 | 152 | hr { 153 | -moz-box-sizing: content-box; 154 | box-sizing: content-box; 155 | height: 0; 156 | } 157 | 158 | /** 159 | * Address styling not present in IE 8/9. 160 | */ 161 | 162 | mark { 163 | background: #ff0; 164 | color: #000; 165 | } 166 | 167 | /** 168 | * Correct font family set oddly in Safari 5 and Chrome. 169 | */ 170 | 171 | code, 172 | kbd, 173 | pre, 174 | samp { 175 | font-family: monospace, serif; 176 | font-size: 1em; 177 | } 178 | 179 | /** 180 | * Improve readability of pre-formatted text in all browsers. 181 | */ 182 | 183 | pre { 184 | white-space: pre-wrap; 185 | } 186 | 187 | /** 188 | * Set consistent quote types. 189 | */ 190 | 191 | q { 192 | quotes: "\201C" "\201D" "\2018" "\2019"; 193 | } 194 | 195 | /** 196 | * Address inconsistent and variable font size in all browsers. 197 | */ 198 | 199 | small { 200 | font-size: 80%; 201 | } 202 | 203 | /** 204 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 205 | */ 206 | 207 | sub, 208 | sup { 209 | font-size: 75%; 210 | line-height: 0; 211 | position: relative; 212 | vertical-align: baseline; 213 | } 214 | 215 | sup { 216 | top: -0.5em; 217 | } 218 | 219 | sub { 220 | bottom: -0.25em; 221 | } 222 | 223 | /* ========================================================================== 224 | Embedded content 225 | ========================================================================== */ 226 | 227 | /** 228 | * Remove border when inside `a` element in IE 8/9. 229 | */ 230 | 231 | img { 232 | border: 0; 233 | } 234 | 235 | /** 236 | * Correct overflow displayed oddly in IE 9. 237 | */ 238 | 239 | svg:not(:root) { 240 | overflow: hidden; 241 | } 242 | 243 | /* ========================================================================== 244 | Figures 245 | ========================================================================== */ 246 | 247 | /** 248 | * Address margin not present in IE 8/9 and Safari 5. 249 | */ 250 | 251 | figure { 252 | margin: 0; 253 | } 254 | 255 | /* ========================================================================== 256 | Forms 257 | ========================================================================== */ 258 | 259 | /** 260 | * Define consistent border, margin, and padding. 261 | */ 262 | 263 | fieldset { 264 | border: 1px solid #c0c0c0; 265 | margin: 0 2px; 266 | padding: 0.35em 0.625em 0.75em; 267 | } 268 | 269 | /** 270 | * 1. Correct `color` not being inherited in IE 8/9. 271 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 272 | */ 273 | 274 | legend { 275 | border: 0; /* 1 */ 276 | padding: 0; /* 2 */ 277 | } 278 | 279 | /** 280 | * 1. Correct font family not being inherited in all browsers. 281 | * 2. Correct font size not being inherited in all browsers. 282 | * 3. Address margins set differently in Firefox 4+, Safari 5, and Chrome. 283 | */ 284 | 285 | button, 286 | input, 287 | select, 288 | textarea { 289 | font-family: inherit; /* 1 */ 290 | font-size: 100%; /* 2 */ 291 | margin: 0; /* 3 */ 292 | } 293 | 294 | /** 295 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 296 | * the UA stylesheet. 297 | */ 298 | 299 | button, 300 | input { 301 | line-height: normal; 302 | } 303 | 304 | /** 305 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 306 | * All other form control elements do not inherit `text-transform` values. 307 | * Correct `button` style inheritance in Chrome, Safari 5+, and IE 8+. 308 | * Correct `select` style inheritance in Firefox 4+ and Opera. 309 | */ 310 | 311 | button, 312 | select { 313 | text-transform: none; 314 | } 315 | 316 | /** 317 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 318 | * and `video` controls. 319 | * 2. Correct inability to style clickable `input` types in iOS. 320 | * 3. Improve usability and consistency of cursor style between image-type 321 | * `input` and others. 322 | */ 323 | 324 | button, 325 | html input[type="button"], /* 1 */ 326 | input[type="reset"], 327 | input[type="submit"] { 328 | -webkit-appearance: button; /* 2 */ 329 | cursor: pointer; /* 3 */ 330 | } 331 | 332 | /** 333 | * Re-set default cursor for disabled elements. 334 | */ 335 | 336 | button[disabled], 337 | html input[disabled] { 338 | cursor: default; 339 | } 340 | 341 | /** 342 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 343 | * 2. Remove excess padding in IE 8/9/10. 344 | */ 345 | 346 | input[type="checkbox"], 347 | input[type="radio"] { 348 | box-sizing: border-box; /* 1 */ 349 | padding: 0; /* 2 */ 350 | } 351 | 352 | /** 353 | * 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome. 354 | * 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome 355 | * (include `-moz` to future-proof). 356 | */ 357 | 358 | input[type="search"] { 359 | -webkit-appearance: textfield; /* 1 */ 360 | -moz-box-sizing: content-box; 361 | -webkit-box-sizing: content-box; /* 2 */ 362 | box-sizing: content-box; 363 | } 364 | 365 | /** 366 | * Remove inner padding and search cancel button in Safari 5 and Chrome 367 | * on OS X. 368 | */ 369 | 370 | input[type="search"]::-webkit-search-cancel-button, 371 | input[type="search"]::-webkit-search-decoration { 372 | -webkit-appearance: none; 373 | } 374 | 375 | /** 376 | * Remove inner padding and border in Firefox 4+. 377 | */ 378 | 379 | button::-moz-focus-inner, 380 | input::-moz-focus-inner { 381 | border: 0; 382 | padding: 0; 383 | } 384 | 385 | /** 386 | * 1. Remove default vertical scrollbar in IE 8/9. 387 | * 2. Improve readability and alignment in all browsers. 388 | */ 389 | 390 | textarea { 391 | overflow: auto; /* 1 */ 392 | vertical-align: top; /* 2 */ 393 | } 394 | 395 | /* ========================================================================== 396 | Tables 397 | ========================================================================== */ 398 | 399 | /** 400 | * Remove most spacing between table cells. 401 | */ 402 | 403 | table { 404 | border-collapse: collapse; 405 | border-spacing: 0; 406 | } 407 | -------------------------------------------------------------------------------- /priv/static/assets/application.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v2.1.3 | MIT License | git.io/normalize */article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block}audio,canvas,video{display:inline-block}audio:not([controls]){display:none;height:0}[hidden],template{display:none}html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}a{background:transparent}a:focus{outline:thin dotted}a:active,a:hover{outline:0}h1{font-size:2em;margin:0.67em 0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}mark{background:#ff0;color:#000}code,kbd,pre,samp{font-family:monospace, serif;font-size:1em}pre{white-space:pre-wrap}q{quotes:"\201C" "\201D" "\2018" "\2019"}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:0}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em}legend{border:0;padding:0}button,input,select,textarea{font-family:inherit;font-size:100%;margin:0}button,input{line-height:normal}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0}input[type="search"]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}textarea{overflow:auto;vertical-align:top}table{border-collapse:collapse;border-spacing:0}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html,body{width:100%;height:100%;color:#333;font-size:16px;font-family:'PT Sans', sans-serif}.app-name{width:100%}.app-audio{display:none}.container{width:100%;height:100%;float:left;overflow:hidden}.left-panel .title,.room-details .title{font-size:0.85em;color:#06c;text-transform:uppercase;border-bottom:0.1em solid #CCC;margin-bottom:0.5em}.settings{width:100%;height:100%;float:left}.settings .detail{position:absolute;top:0em;right:0em;bottom:0em;overflow-y:scroll}.settings .detail .header{width:100%;position:fixed;top:0em;color:#06c}.settings .detail .header .title{float:left;font-size:1.5em;text-transform:capitalize}.login{font-size:1.1em;background:#FFF;padding:0.5em;margin:auto;position:absolute;top:0em;right:0em;left:0em;bottom:0em}.login .title{color:#06c;font-size:1.5em;border-bottom:0.1em solid #06C;margin-bottom:1em}.login .field{margin-bottom:1em}.login .field label{color:#666;font-size:0.9em}.login .field input{border:0.1em solid #999;padding:0.2em;width:100%;color:#444}.login .actions input{background:#FFF;color:#06C;border:1px solid #06C;border-radius:0.15em;box-shadow:0em 0em 0.2em 0.05em #0073E6}.login .error{width:100%;color:#C00;margin-top:1em}.room-content{position:absolute;height:100%;overflow:hidden}.room-content .room-info{position:fixed;z-index:100;background:#fff;top:0em;padding:0.25em;font-size:1.25em;border-bottom:0.1em solid #DDD;color:#06c}.room-content .menu-control{float:left;cursor:pointer}.room-content .menu-control img{width:1.25em;height:1.25em;margin-right:0.5em}.room-content .messages-wrapper{position:absolute;width:100%;top:2.7em;bottom:3.5em;overflow-y:scroll}.room-content .load-messages{background:#FDE893;padding:0.25em;border-radius:0.3em;cursor:pointer}.room-content .messages{float:left;clear:both;width:100%;padding:0.5em}.room-content .messages .message{clear:both;float:left;width:100%;margin-top:1em}.room-content .messages .message .content a{color:#06c}.room-content .messages .message .me-body{font-style:italic}.room-content .messages .message .paste-wrapper{float:left;width:100%;box-shadow:0 0 0.1em 0.05em #CCC;background:#fff;margin-top:0.25em;padding:0.25em}.room-content .messages .message .paste-wrapper .paste-actions{float:left}.room-content .messages .message .paste-wrapper .paste-actions a{color:#06C}.room-content .messages .message .paste-wrapper pre.paste-body{float:left;width:100%;font-family:monospace;background:#FAFAFA;padding:0.5em;color:#444;margin:0.3em 0em 0em 0em;font-size:0.9em;overflow-x:scroll}.room-content .messages .message .message-info{float:left;font-size:0.75em;color:#888;width:100%;text-align:right}.room-content .messages .message .message-info .message-meta{float:right}.room-content .messages .message .message-info .message-meta .timestamp,.room-content .messages .message .message-info .message-meta .post-error{padding:0.25em;border-radius:0.25em;float:right}.room-content .messages .message .message-info .message-meta .timestamp{background:#f1f1f1;color:#888}.room-content .messages .message .message-info .message-meta .post-error{background:#dd0000;color:#fff}.room-content .messages .message .user{font-weight:bold}.room-content .new-message{position:fixed;bottom:0em}.room-content .new-message textarea{width:100%;height:3.5em;resize:none;border-top:0.1em solid #DDD;border-right:none;border-left:none;border-bottom:none}.room-content .new-message textarea:hover{outline:none}.room-details-wrapper{width:20%;height:100%;padding-left:0.3em;float:right}.room-details-wrapper .room-details{float:left;width:100%;height:100%;background:#FCFCFC;box-shadow:-0.1em 0em 0em 0.1em #F1F1F1;border-left:0.1em solid #DDD;padding:0.5em}.room-details-wrapper .active-users{float:right;width:100%}.room-details-wrapper .active-users ul{padding:0em;margin:0em}.room-details-wrapper .active-users ul li{list-style-type:none;font-size:1em;color:#777;padding-left:0.2em;margin-bottom:0.5em}.left-panel-wrapper{float:left;width:20%;height:100%;padding-right:0.3em}.left-panel-wrapper .left-panel{float:left;width:100%;height:100%;background:#FCFCFC;box-shadow:0.1em 0em 0em 0.1em #F1F1F1;border-right:0.1em solid #DDD;overflow-y:scroll}.left-panel .app-name{font-size:1.2em;padding:0.5em 1em 0.5em 1em;font-weight:bold}.left-panel .logout{float:right}.left-panel .logout a{cursor:pointer}.left-panel .logout a img{height:1em}.left-panel .user-details{float:left;width:100%;padding:0.5em 0.5em 0.5em 1.2em}.left-panel .user-details a{color:#777;font-weight:bold}.left-panel .navigation,.left-panel .rooms{float:left;width:100%;padding:1.2em}.left-panel .navigation ul,.left-panel .rooms ul{margin:0em;list-style-type:none;padding:0em}.left-panel .navigation ul li,.left-panel .rooms ul li{color:#BBB;padding:0.25em 0em 0.25em 0em}.left-panel .navigation ul li a,.left-panel .rooms ul li a{cursor:pointer}.left-panel .navigation ul li .room-name,.left-panel .rooms ul li .room-name{padding:0.25em;font-size:0.9em}.left-panel .navigation ul li .room-name:hover,.left-panel .rooms ul li .room-name:hover{background:#f1f1f1;border-radius:0.3em}.left-panel .navigation ul li .leave-room,.left-panel .rooms ul li .leave-room{color:#C00;padding:0em 0.35em 0em 0.35em;cursor:pointer}.left-panel .navigation ul .notification,.left-panel .rooms ul .notification{color:#6666CC}.left-panel .navigation ul .joined,.left-panel .rooms ul .joined{color:#666}.left-panel .navigation ul .unjoined,.left-panel .rooms ul .unjoined{padding-left:1.6em}.left-panel .rooms li{font-size:1.1em}.left-panel .navigation li a{text-decoration:none;color:#666}.left-panel .back-to-chat{padding:1.25em;width:100%;float:left}.left-panel .back-to-chat a{text-decoration:none;background:#444;color:#FFF;border-radius:0.2em;padding:0.25em;float:left}.page{float:left;width:100%;height:100%;overflow-y:scroll}.page .error,.page .error-msg{color:#D00}.page .add{padding:0.25em 0.5em;border:1px solid #68A056;color:#68A056;text-decoration:none;margin-bottom:1em;clear:both;float:left;border-radius:0.2em;cursor:pointer}.page .room-form,.page .user-form{padding:1em}.page form{float:left;width:100%;background:#FCFCFC;padding:1em;border:1px solid #DDD}.page form .field{float:left;width:100%;margin-top:0.5em;margin-bottom:0.5em;clear:both}.page form .actions{float:left;border-top:2px solid #EEE;margin-top:1em;padding-top:1em;width:100%}.page form .actions .save{padding:0.2em 0.5em;font-size:1.1em;background:#FFF;color:#06C;border:1px solid #06C;border-radius:0.15em;box-shadow:0em 0em 0.2em 0.05em #0073E6}.list{width:100%;float:left}.list .item{width:100%;float:left;background:#fafafa;border-left:0.15em solid #E08587;border-right:0.1em solid #DDD;border-top:0.1em solid #DDD;border-bottom:0.1em solid #DDD;padding:0.5em;margin-bottom:1em}.list .item .name{font-weight:bold}.list .item .controls{clear:left;margin-top:0.5em}.list .item .controls a,.list .item .controls span{margin-right:1em;cursor:pointer;text-decoration:underline;color:#06c}@media screen and (min-device-width: 1024px){.login{width:20em;padding:1em 2em 2em 2em}.detail{width:80%;padding:2.3em 1em 1em 1em}.room-content{width:60%;left:20%}.room-content .new-message{width:60%}.room-content .room-info{width:60%}.left-menu-control,.right-menu-control{display:none}}@media screen and (min-device-width: 768px) and (max-device-width: 1024px) and (orientation: landscape){.container{position:absolute;overflow-x:hidden !important}.left-panel-wrapper .rooms li{margin-top:0.5em;margin-bottom:0.5em}.login{width:100%}.detail{width:100%;padding-top:2.3em}.room-content .room-info{width:100%}.room-content .new-message{width:100%}.left-menu-control{float:left}.right-menu-control{float:right}.left-menu-control,.right-menu-control{cursor:pointer}.left-menu-control img,.right-menu-control img{height:1.2em;margin-right:0.5em}.left-panel-wrapper{width:80%;margin-left:-80%}.room-content{width:100%}.move-content-right{margin-left:80%}.move-content-left{margin-left:-80%}.room-details-wrapper{width:80%;margin-right:-80%}.is-left-menu-open{position:relative !important;margin-left:0% !important}.is-right-menu-open{position:relative !important;margin-right:0% !important}}@media screen and (min-device-width: 768px) and (max-device-width: 1024px) and (orientation: portrait){.container{position:absolute;overflow-x:hidden !important}.left-panel-wrapper .rooms li{margin-top:0.5em;margin-bottom:0.5em}.login{width:100%}.detail{width:100%;padding-top:2.3em}.room-content .room-info{width:100%}.room-content .new-message{width:100%}.left-menu-control{float:left}.right-menu-control{float:right}.left-menu-control,.right-menu-control{cursor:pointer}.left-menu-control img,.right-menu-control img{height:1.2em;margin-right:0.5em}.left-panel-wrapper{width:80%;margin-left:-80%}.room-content{width:100%}.move-content-right{margin-left:80%}.move-content-left{margin-left:-80%}.room-details-wrapper{width:80%;margin-right:-80%}.is-left-menu-open{position:relative !important;margin-left:0% !important}.is-right-menu-open{position:relative !important;margin-right:0% !important}}@media screen and (min-device-width: 320px) and (max-device-width: 568px){.container{position:absolute;overflow-x:hidden !important}.left-panel-wrapper .rooms li{margin-top:0.5em;margin-bottom:0.5em}.login{width:100%}.detail{width:100%;padding-top:2.3em}.room-content .room-info{width:100%}.room-content .new-message{width:100%}.left-menu-control{float:left}.right-menu-control{float:right}.left-menu-control,.right-menu-control{cursor:pointer}.left-menu-control img,.right-menu-control img{height:1.2em;margin-right:0.5em}.left-panel-wrapper{width:80%;margin-left:-80%}.room-content{width:100%}.move-content-right{margin-left:80%}.move-content-left{margin-left:-80%}.room-details-wrapper{width:80%;margin-right:-80%}.is-left-menu-open{position:relative !important;margin-left:0% !important}.is-right-menu-open{position:relative !important;margin-right:0% !important}}@media screen and (min-device-width: 320px) and (max-device-width: 480px){.container{position:absolute;overflow-x:hidden !important}.left-panel-wrapper .rooms li{margin-top:0.5em;margin-bottom:0.5em}.login{width:100%}.detail{width:100%;padding-top:2.3em}.room-content .room-info{width:100%}.room-content .new-message{width:100%}.left-menu-control{float:left}.right-menu-control{float:right}.left-menu-control,.right-menu-control{cursor:pointer}.left-menu-control img,.right-menu-control img{height:1.2em;margin-right:0.5em}.left-panel-wrapper{width:80%;margin-left:-80%}.room-content{width:100%}.move-content-right{margin-left:80%}.move-content-left{margin-left:-80%}.room-details-wrapper{width:80%;margin-right:-80%}.is-left-menu-open{position:relative !important;margin-left:0% !important}.is-right-menu-open{position:relative !important;margin-right:0% !important}}@media screen and (max-width: 480px){.container{position:absolute;overflow-x:hidden !important}.left-panel-wrapper .rooms li{margin-top:0.5em;margin-bottom:0.5em}.login{width:100%}.detail{width:100%;padding-top:2.3em}.room-content .room-info{width:100%}.room-content .new-message{width:100%}.left-menu-control{float:left}.right-menu-control{float:right}.left-menu-control,.right-menu-control{cursor:pointer}.left-menu-control img,.right-menu-control img{height:1.2em;margin-right:0.5em}.left-panel-wrapper{width:80%;margin-left:-80%}.room-content{width:100%}.move-content-right{margin-left:80%}.move-content-left{margin-left:-80%}.room-details-wrapper{width:80%;margin-right:-80%}.is-left-menu-open{position:relative !important;margin-left:0% !important}.is-right-menu-open{position:relative !important;margin-right:0% !important}}@media screen and (min-width: 1024px){.login{width:20em;padding:1em 2em 2em 2em}.detail{width:80%;padding:2.3em 1em 1em 1em}.room-content{width:60%;left:20%}.room-content .new-message{width:60%}.room-content .room-info{width:60%}.left-menu-control,.right-menu-control{display:none}} 2 | -------------------------------------------------------------------------------- /templates/index.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Mogo Chat 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 111 | 112 | 113 | 123 | 124 | 125 | 144 | 145 | 163 | 164 | 190 | 191 | 194 | 195 | 227 | 228 | 231 | 232 | 261 | 262 | 263 | 273 | 274 | 275 | 285 | 286 | 287 | 348 | 349 | 350 | 379 | 380 | 381 | 410 | 411 | 412 | 441 | 442 | 443 | 467 | 468 | 469 | -------------------------------------------------------------------------------- /assets/javascripts/lib/moment.min.js: -------------------------------------------------------------------------------- 1 | //! moment.js 2 | //! version : 2.4.0 3 | //! authors : Tim Wood, Iskren Chernev, Moment.js contributors 4 | //! license : MIT 5 | //! momentjs.com 6 | (function(a){function b(a,b){return function(c){return i(a.call(this,c),b)}}function c(a,b){return function(c){return this.lang().ordinal(a.call(this,c),b)}}function d(){}function e(a){u(a),g(this,a)}function f(a){var b=o(a),c=b.year||0,d=b.month||0,e=b.week||0,f=b.day||0,g=b.hour||0,h=b.minute||0,i=b.second||0,j=b.millisecond||0;this._input=a,this._milliseconds=+j+1e3*i+6e4*h+36e5*g,this._days=+f+7*e,this._months=+d+12*c,this._data={},this._bubble()}function g(a,b){for(var c in b)b.hasOwnProperty(c)&&(a[c]=b[c]);return b.hasOwnProperty("toString")&&(a.toString=b.toString),b.hasOwnProperty("valueOf")&&(a.valueOf=b.valueOf),a}function h(a){return 0>a?Math.ceil(a):Math.floor(a)}function i(a,b){for(var c=a+"";c.lengthd;d++)(c&&a[d]!==b[d]||!c&&q(a[d])!==q(b[d]))&&g++;return g+f}function n(a){if(a){var b=a.toLowerCase().replace(/(.)s$/,"$1");a=Kb[a]||Lb[b]||b}return a}function o(a){var b,c,d={};for(c in a)a.hasOwnProperty(c)&&(b=n(c),b&&(d[b]=a[c]));return d}function p(b){var c,d;if(0===b.indexOf("week"))c=7,d="day";else{if(0!==b.indexOf("month"))return;c=12,d="month"}bb[b]=function(e,f){var g,h,i=bb.fn._lang[b],j=[];if("number"==typeof e&&(f=e,e=a),h=function(a){var b=bb().utc().set(d,a);return i.call(bb.fn._lang,b,e||"")},null!=f)return h(f);for(g=0;c>g;g++)j.push(h(g));return j}}function q(a){var b=+a,c=0;return 0!==b&&isFinite(b)&&(c=b>=0?Math.floor(b):Math.ceil(b)),c}function r(a,b){return new Date(Date.UTC(a,b+1,0)).getUTCDate()}function s(a){return t(a)?366:365}function t(a){return 0===a%4&&0!==a%100||0===a%400}function u(a){var b;a._a&&-2===a._pf.overflow&&(b=a._a[gb]<0||a._a[gb]>11?gb:a._a[hb]<1||a._a[hb]>r(a._a[fb],a._a[gb])?hb:a._a[ib]<0||a._a[ib]>23?ib:a._a[jb]<0||a._a[jb]>59?jb:a._a[kb]<0||a._a[kb]>59?kb:a._a[lb]<0||a._a[lb]>999?lb:-1,a._pf._overflowDayOfYear&&(fb>b||b>hb)&&(b=hb),a._pf.overflow=b)}function v(a){a._pf={empty:!1,unusedTokens:[],unusedInput:[],overflow:-2,charsLeftOver:0,nullInput:!1,invalidMonth:null,invalidFormat:!1,userInvalidated:!1,iso:!1}}function w(a){return null==a._isValid&&(a._isValid=!isNaN(a._d.getTime())&&a._pf.overflow<0&&!a._pf.empty&&!a._pf.invalidMonth&&!a._pf.nullInput&&!a._pf.invalidFormat&&!a._pf.userInvalidated,a._strict&&(a._isValid=a._isValid&&0===a._pf.charsLeftOver&&0===a._pf.unusedTokens.length)),a._isValid}function x(a){return a?a.toLowerCase().replace("_","-"):a}function y(a,b){return b.abbr=a,mb[a]||(mb[a]=new d),mb[a].set(b),mb[a]}function z(a){delete mb[a]}function A(a){var b,c,d,e,f=0,g=function(a){if(!mb[a]&&nb)try{require("./lang/"+a)}catch(b){}return mb[a]};if(!a)return bb.fn._lang;if(!k(a)){if(c=g(a))return c;a=[a]}for(;f0;){if(c=g(e.slice(0,b).join("-")))return c;if(d&&d.length>=b&&m(e,d,!0)>=b-1)break;b--}f++}return bb.fn._lang}function B(a){return a.match(/\[[\s\S]/)?a.replace(/^\[|\]$/g,""):a.replace(/\\/g,"")}function C(a){var b,c,d=a.match(rb);for(b=0,c=d.length;c>b;b++)d[b]=Pb[d[b]]?Pb[d[b]]:B(d[b]);return function(e){var f="";for(b=0;c>b;b++)f+=d[b]instanceof Function?d[b].call(e,a):d[b];return f}}function D(a,b){return a.isValid()?(b=E(b,a.lang()),Mb[b]||(Mb[b]=C(b)),Mb[b](a)):a.lang().invalidDate()}function E(a,b){function c(a){return b.longDateFormat(a)||a}var d=5;for(sb.lastIndex=0;d>=0&&sb.test(a);)a=a.replace(sb,c),sb.lastIndex=0,d-=1;return a}function F(a,b){var c;switch(a){case"DDDD":return vb;case"YYYY":case"GGGG":case"gggg":return wb;case"YYYYY":case"GGGGG":case"ggggg":return xb;case"S":case"SS":case"SSS":case"DDD":return ub;case"MMM":case"MMMM":case"dd":case"ddd":case"dddd":return zb;case"a":case"A":return A(b._l)._meridiemParse;case"X":return Cb;case"Z":case"ZZ":return Ab;case"T":return Bb;case"SSSS":return yb;case"MM":case"DD":case"YY":case"GG":case"gg":case"HH":case"hh":case"mm":case"ss":case"M":case"D":case"d":case"H":case"h":case"m":case"s":case"w":case"ww":case"W":case"WW":case"e":case"E":return tb;default:return c=new RegExp(N(M(a.replace("\\","")),"i"))}}function G(a){var b=(Ab.exec(a)||[])[0],c=(b+"").match(Hb)||["-",0,0],d=+(60*c[1])+q(c[2]);return"+"===c[0]?-d:d}function H(a,b,c){var d,e=c._a;switch(a){case"M":case"MM":null!=b&&(e[gb]=q(b)-1);break;case"MMM":case"MMMM":d=A(c._l).monthsParse(b),null!=d?e[gb]=d:c._pf.invalidMonth=b;break;case"D":case"DD":null!=b&&(e[hb]=q(b));break;case"DDD":case"DDDD":null!=b&&(c._dayOfYear=q(b));break;case"YY":e[fb]=q(b)+(q(b)>68?1900:2e3);break;case"YYYY":case"YYYYY":e[fb]=q(b);break;case"a":case"A":c._isPm=A(c._l).isPM(b);break;case"H":case"HH":case"h":case"hh":e[ib]=q(b);break;case"m":case"mm":e[jb]=q(b);break;case"s":case"ss":e[kb]=q(b);break;case"S":case"SS":case"SSS":case"SSSS":e[lb]=q(1e3*("0."+b));break;case"X":c._d=new Date(1e3*parseFloat(b));break;case"Z":case"ZZ":c._useUTC=!0,c._tzm=G(b);break;case"w":case"ww":case"W":case"WW":case"d":case"dd":case"ddd":case"dddd":case"e":case"E":a=a.substr(0,1);case"gg":case"gggg":case"GG":case"GGGG":case"GGGGG":a=a.substr(0,2),b&&(c._w=c._w||{},c._w[a]=b)}}function I(a){var b,c,d,e,f,g,h,i,j,k,l=[];if(!a._d){for(d=K(a),a._w&&null==a._a[hb]&&null==a._a[gb]&&(f=function(b){return b?b.length<3?parseInt(b,10)>68?"19"+b:"20"+b:b:null==a._a[fb]?bb().weekYear():a._a[fb]},g=a._w,null!=g.GG||null!=g.W||null!=g.E?h=X(f(g.GG),g.W||1,g.E,4,1):(i=A(a._l),j=null!=g.d?T(g.d,i):null!=g.e?parseInt(g.e,10)+i._week.dow:0,k=parseInt(g.w,10)||1,null!=g.d&&js(e)&&(a._pf._overflowDayOfYear=!0),c=S(e,0,a._dayOfYear),a._a[gb]=c.getUTCMonth(),a._a[hb]=c.getUTCDate()),b=0;3>b&&null==a._a[b];++b)a._a[b]=l[b]=d[b];for(;7>b;b++)a._a[b]=l[b]=null==a._a[b]?2===b?1:0:a._a[b];l[ib]+=q((a._tzm||0)/60),l[jb]+=q((a._tzm||0)%60),a._d=(a._useUTC?S:R).apply(null,l)}}function J(a){var b;a._d||(b=o(a._i),a._a=[b.year,b.month,b.day,b.hour,b.minute,b.second,b.millisecond],I(a))}function K(a){var b=new Date;return a._useUTC?[b.getUTCFullYear(),b.getUTCMonth(),b.getUTCDate()]:[b.getFullYear(),b.getMonth(),b.getDate()]}function L(a){a._a=[],a._pf.empty=!0;var b,c,d,e,f,g=A(a._l),h=""+a._i,i=h.length,j=0;for(d=E(a._f,g).match(rb)||[],b=0;b0&&a._pf.unusedInput.push(f),h=h.slice(h.indexOf(c)+c.length),j+=c.length),Pb[e]?(c?a._pf.empty=!1:a._pf.unusedTokens.push(e),H(e,c,a)):a._strict&&!c&&a._pf.unusedTokens.push(e);a._pf.charsLeftOver=i-j,h.length>0&&a._pf.unusedInput.push(h),a._isPm&&a._a[ib]<12&&(a._a[ib]+=12),a._isPm===!1&&12===a._a[ib]&&(a._a[ib]=0),I(a),u(a)}function M(a){return a.replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g,function(a,b,c,d,e){return b||c||d||e})}function N(a){return a.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&")}function O(a){var b,c,d,e,f;if(0===a._f.length)return a._pf.invalidFormat=!0,a._d=new Date(0/0),void 0;for(e=0;ef)&&(d=f,c=b));g(a,c||b)}function P(a){var b,c=a._i,d=Db.exec(c);if(d){for(a._pf.iso=!0,b=4;b>0;b--)if(d[b]){a._f=Fb[b-1]+(d[6]||" ");break}for(b=0;4>b;b++)if(Gb[b][1].exec(c)){a._f+=Gb[b][0];break}Ab.exec(c)&&(a._f+="Z"),L(a)}else a._d=new Date(c)}function Q(b){var c=b._i,d=ob.exec(c);c===a?b._d=new Date:d?b._d=new Date(+d[1]):"string"==typeof c?P(b):k(c)?(b._a=c.slice(0),I(b)):l(c)?b._d=new Date(+c):"object"==typeof c?J(b):b._d=new Date(c)}function R(a,b,c,d,e,f,g){var h=new Date(a,b,c,d,e,f,g);return 1970>a&&h.setFullYear(a),h}function S(a){var b=new Date(Date.UTC.apply(null,arguments));return 1970>a&&b.setUTCFullYear(a),b}function T(a,b){if("string"==typeof a)if(isNaN(a)){if(a=b.weekdaysParse(a),"number"!=typeof a)return null}else a=parseInt(a,10);return a}function U(a,b,c,d,e){return e.relativeTime(b||1,!!c,a,d)}function V(a,b,c){var d=eb(Math.abs(a)/1e3),e=eb(d/60),f=eb(e/60),g=eb(f/24),h=eb(g/365),i=45>d&&["s",d]||1===e&&["m"]||45>e&&["mm",e]||1===f&&["h"]||22>f&&["hh",f]||1===g&&["d"]||25>=g&&["dd",g]||45>=g&&["M"]||345>g&&["MM",eb(g/30)]||1===h&&["y"]||["yy",h];return i[2]=b,i[3]=a>0,i[4]=c,U.apply({},i)}function W(a,b,c){var d,e=c-b,f=c-a.day();return f>e&&(f-=7),e-7>f&&(f+=7),d=bb(a).add("d",f),{week:Math.ceil(d.dayOfYear()/7),year:d.year()}}function X(a,b,c,d,e){var f,g,h=new Date(Date.UTC(a,0)).getUTCDay();return c=null!=c?c:e,f=e-h+(h>d?7:0),g=7*(b-1)+(c-e)+f+1,{year:g>0?a:a-1,dayOfYear:g>0?g:s(a-1)+g}}function Y(a){var b=a._i,c=a._f;return"undefined"==typeof a._pf&&v(a),null===b?bb.invalid({nullInput:!0}):("string"==typeof b&&(a._i=b=A().preparse(b)),bb.isMoment(b)?(a=g({},b),a._d=new Date(+b._d)):c?k(c)?O(a):L(a):Q(a),new e(a))}function Z(a,b){bb.fn[a]=bb.fn[a+"s"]=function(a){var c=this._isUTC?"UTC":"";return null!=a?(this._d["set"+c+b](a),bb.updateOffset(this),this):this._d["get"+c+b]()}}function $(a){bb.duration.fn[a]=function(){return this._data[a]}}function _(a,b){bb.duration.fn["as"+a]=function(){return+this/b}}function ab(a){var b=!1,c=bb;"undefined"==typeof ender&&(this.moment=a?function(){return!b&&console&&console.warn&&(b=!0,console.warn("Accessing Moment through the global scope is deprecated, and will be removed in an upcoming release.")),c.apply(null,arguments)}:bb)}for(var bb,cb,db="2.4.0",eb=Math.round,fb=0,gb=1,hb=2,ib=3,jb=4,kb=5,lb=6,mb={},nb="undefined"!=typeof module&&module.exports,ob=/^\/?Date\((\-?\d+)/i,pb=/(\-)?(?:(\d*)\.)?(\d+)\:(\d+)(?:\:(\d+)\.?(\d{3})?)?/,qb=/^(-)?P(?:(?:([0-9,.]*)Y)?(?:([0-9,.]*)M)?(?:([0-9,.]*)D)?(?:T(?:([0-9,.]*)H)?(?:([0-9,.]*)M)?(?:([0-9,.]*)S)?)?|([0-9,.]*)W)$/,rb=/(\[[^\[]*\])|(\\)?(Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|mm?|ss?|S{1,4}|X|zz?|ZZ?|.)/g,sb=/(\[[^\[]*\])|(\\)?(LT|LL?L?L?|l{1,4})/g,tb=/\d\d?/,ub=/\d{1,3}/,vb=/\d{3}/,wb=/\d{1,4}/,xb=/[+\-]?\d{1,6}/,yb=/\d+/,zb=/[0-9]*['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+|[\u0600-\u06FF\/]+(\s*?[\u0600-\u06FF]+){1,2}/i,Ab=/Z|[\+\-]\d\d:?\d\d/i,Bb=/T/i,Cb=/[\+\-]?\d+(\.\d{1,3})?/,Db=/^\s*\d{4}-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?([\+\-]\d\d:?\d\d|Z)?)?$/,Eb="YYYY-MM-DDTHH:mm:ssZ",Fb=["YYYY-MM-DD","GGGG-[W]WW","GGGG-[W]WW-E","YYYY-DDD"],Gb=[["HH:mm:ss.SSSS",/(T| )\d\d:\d\d:\d\d\.\d{1,3}/],["HH:mm:ss",/(T| )\d\d:\d\d:\d\d/],["HH:mm",/(T| )\d\d:\d\d/],["HH",/(T| )\d\d/]],Hb=/([\+\-]|\d\d)/gi,Ib="Date|Hours|Minutes|Seconds|Milliseconds".split("|"),Jb={Milliseconds:1,Seconds:1e3,Minutes:6e4,Hours:36e5,Days:864e5,Months:2592e6,Years:31536e6},Kb={ms:"millisecond",s:"second",m:"minute",h:"hour",d:"day",D:"date",w:"week",W:"isoWeek",M:"month",y:"year",DDD:"dayOfYear",e:"weekday",E:"isoWeekday",gg:"weekYear",GG:"isoWeekYear"},Lb={dayofyear:"dayOfYear",isoweekday:"isoWeekday",isoweek:"isoWeek",weekyear:"weekYear",isoweekyear:"isoWeekYear"},Mb={},Nb="DDD w W M D d".split(" "),Ob="M D H h m s w W".split(" "),Pb={M:function(){return this.month()+1},MMM:function(a){return this.lang().monthsShort(this,a)},MMMM:function(a){return this.lang().months(this,a)},D:function(){return this.date()},DDD:function(){return this.dayOfYear()},d:function(){return this.day()},dd:function(a){return this.lang().weekdaysMin(this,a)},ddd:function(a){return this.lang().weekdaysShort(this,a)},dddd:function(a){return this.lang().weekdays(this,a)},w:function(){return this.week()},W:function(){return this.isoWeek()},YY:function(){return i(this.year()%100,2)},YYYY:function(){return i(this.year(),4)},YYYYY:function(){return i(this.year(),5)},gg:function(){return i(this.weekYear()%100,2)},gggg:function(){return this.weekYear()},ggggg:function(){return i(this.weekYear(),5)},GG:function(){return i(this.isoWeekYear()%100,2)},GGGG:function(){return this.isoWeekYear()},GGGGG:function(){return i(this.isoWeekYear(),5)},e:function(){return this.weekday()},E:function(){return this.isoWeekday()},a:function(){return this.lang().meridiem(this.hours(),this.minutes(),!0)},A:function(){return this.lang().meridiem(this.hours(),this.minutes(),!1)},H:function(){return this.hours()},h:function(){return this.hours()%12||12},m:function(){return this.minutes()},s:function(){return this.seconds()},S:function(){return q(this.milliseconds()/100)},SS:function(){return i(q(this.milliseconds()/10),2)},SSS:function(){return i(this.milliseconds(),3)},SSSS:function(){return i(this.milliseconds(),3)},Z:function(){var a=-this.zone(),b="+";return 0>a&&(a=-a,b="-"),b+i(q(a/60),2)+":"+i(q(a)%60,2)},ZZ:function(){var a=-this.zone(),b="+";return 0>a&&(a=-a,b="-"),b+i(q(10*a/6),4)},z:function(){return this.zoneAbbr()},zz:function(){return this.zoneName()},X:function(){return this.unix()}},Qb=["months","monthsShort","weekdays","weekdaysShort","weekdaysMin"];Nb.length;)cb=Nb.pop(),Pb[cb+"o"]=c(Pb[cb],cb);for(;Ob.length;)cb=Ob.pop(),Pb[cb+cb]=b(Pb[cb],2);for(Pb.DDDD=b(Pb.DDD,3),g(d.prototype,{set:function(a){var b,c;for(c in a)b=a[c],"function"==typeof b?this[c]=b:this["_"+c]=b},_months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),months:function(a){return this._months[a.month()]},_monthsShort:"Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec".split("_"),monthsShort:function(a){return this._monthsShort[a.month()]},monthsParse:function(a){var b,c,d;for(this._monthsParse||(this._monthsParse=[]),b=0;12>b;b++)if(this._monthsParse[b]||(c=bb.utc([2e3,b]),d="^"+this.months(c,"")+"|^"+this.monthsShort(c,""),this._monthsParse[b]=new RegExp(d.replace(".",""),"i")),this._monthsParse[b].test(a))return b},_weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),weekdays:function(a){return this._weekdays[a.day()]},_weekdaysShort:"Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"),weekdaysShort:function(a){return this._weekdaysShort[a.day()]},_weekdaysMin:"Su_Mo_Tu_We_Th_Fr_Sa".split("_"),weekdaysMin:function(a){return this._weekdaysMin[a.day()]},weekdaysParse:function(a){var b,c,d;for(this._weekdaysParse||(this._weekdaysParse=[]),b=0;7>b;b++)if(this._weekdaysParse[b]||(c=bb([2e3,1]).day(b),d="^"+this.weekdays(c,"")+"|^"+this.weekdaysShort(c,"")+"|^"+this.weekdaysMin(c,""),this._weekdaysParse[b]=new RegExp(d.replace(".",""),"i")),this._weekdaysParse[b].test(a))return b},_longDateFormat:{LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D YYYY",LLL:"MMMM D YYYY LT",LLLL:"dddd, MMMM D YYYY LT"},longDateFormat:function(a){var b=this._longDateFormat[a];return!b&&this._longDateFormat[a.toUpperCase()]&&(b=this._longDateFormat[a.toUpperCase()].replace(/MMMM|MM|DD|dddd/g,function(a){return a.slice(1)}),this._longDateFormat[a]=b),b},isPM:function(a){return"p"===(a+"").toLowerCase().charAt(0)},_meridiemParse:/[ap]\.?m?\.?/i,meridiem:function(a,b,c){return a>11?c?"pm":"PM":c?"am":"AM"},_calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},calendar:function(a,b){var c=this._calendar[a];return"function"==typeof c?c.apply(b):c},_relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},relativeTime:function(a,b,c,d){var e=this._relativeTime[c];return"function"==typeof e?e(a,b,c,d):e.replace(/%d/i,a)},pastFuture:function(a,b){var c=this._relativeTime[a>0?"future":"past"];return"function"==typeof c?c(b):c.replace(/%s/i,b)},ordinal:function(a){return this._ordinal.replace("%d",a)},_ordinal:"%d",preparse:function(a){return a},postformat:function(a){return a},week:function(a){return W(a,this._week.dow,this._week.doy).week},_week:{dow:0,doy:6},_invalidDate:"Invalid date",invalidDate:function(){return this._invalidDate}}),bb=function(b,c,d,e){return"boolean"==typeof d&&(e=d,d=a),Y({_i:b,_f:c,_l:d,_strict:e,_isUTC:!1})},bb.utc=function(b,c,d,e){var f;return"boolean"==typeof d&&(e=d,d=a),f=Y({_useUTC:!0,_isUTC:!0,_l:d,_i:b,_f:c,_strict:e}).utc()},bb.unix=function(a){return bb(1e3*a)},bb.duration=function(a,b){var c,d,e,g=bb.isDuration(a),h="number"==typeof a,i=g?a._input:h?{}:a,j=null;return h?b?i[b]=a:i.milliseconds=a:(j=pb.exec(a))?(c="-"===j[1]?-1:1,i={y:0,d:q(j[hb])*c,h:q(j[ib])*c,m:q(j[jb])*c,s:q(j[kb])*c,ms:q(j[lb])*c}):(j=qb.exec(a))&&(c="-"===j[1]?-1:1,e=function(a){var b=a&&parseFloat(a.replace(",","."));return(isNaN(b)?0:b)*c},i={y:e(j[2]),M:e(j[3]),d:e(j[4]),h:e(j[5]),m:e(j[6]),s:e(j[7]),w:e(j[8])}),d=new f(i),g&&a.hasOwnProperty("_lang")&&(d._lang=a._lang),d},bb.version=db,bb.defaultFormat=Eb,bb.updateOffset=function(){},bb.lang=function(a,b){var c;return a?(b?y(x(a),b):null===b?(z(a),a="en"):mb[a]||A(a),c=bb.duration.fn._lang=bb.fn._lang=A(a),c._abbr):bb.fn._lang._abbr},bb.langData=function(a){return a&&a._lang&&a._lang._abbr&&(a=a._lang._abbr),A(a)},bb.isMoment=function(a){return a instanceof e},bb.isDuration=function(a){return a instanceof f},cb=Qb.length-1;cb>=0;--cb)p(Qb[cb]);for(bb.normalizeUnits=function(a){return n(a)},bb.invalid=function(a){var b=bb.utc(0/0);return null!=a?g(b._pf,a):b._pf.userInvalidated=!0,b},bb.parseZone=function(a){return bb(a).parseZone()},g(bb.fn=e.prototype,{clone:function(){return bb(this)},valueOf:function(){return+this._d+6e4*(this._offset||0)},unix:function(){return Math.floor(+this/1e3)},toString:function(){return this.clone().lang("en").format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ")},toDate:function(){return this._offset?new Date(+this):this._d},toISOString:function(){return D(bb(this).utc(),"YYYY-MM-DD[T]HH:mm:ss.SSS[Z]")},toArray:function(){var a=this;return[a.year(),a.month(),a.date(),a.hours(),a.minutes(),a.seconds(),a.milliseconds()]},isValid:function(){return w(this)},isDSTShifted:function(){return this._a?this.isValid()&&m(this._a,(this._isUTC?bb.utc(this._a):bb(this._a)).toArray())>0:!1},parsingFlags:function(){return g({},this._pf)},invalidAt:function(){return this._pf.overflow},utc:function(){return this.zone(0)},local:function(){return this.zone(0),this._isUTC=!1,this},format:function(a){var b=D(this,a||bb.defaultFormat);return this.lang().postformat(b)},add:function(a,b){var c;return c="string"==typeof a?bb.duration(+b,a):bb.duration(a,b),j(this,c,1),this},subtract:function(a,b){var c;return c="string"==typeof a?bb.duration(+b,a):bb.duration(a,b),j(this,c,-1),this},diff:function(a,b,c){var d,e,f=this._isUTC?bb(a).zone(this._offset||0):bb(a).local(),g=6e4*(this.zone()-f.zone());return b=n(b),"year"===b||"month"===b?(d=432e5*(this.daysInMonth()+f.daysInMonth()),e=12*(this.year()-f.year())+(this.month()-f.month()),e+=(this-bb(this).startOf("month")-(f-bb(f).startOf("month")))/d,e-=6e4*(this.zone()-bb(this).startOf("month").zone()-(f.zone()-bb(f).startOf("month").zone()))/d,"year"===b&&(e/=12)):(d=this-f,e="second"===b?d/1e3:"minute"===b?d/6e4:"hour"===b?d/36e5:"day"===b?(d-g)/864e5:"week"===b?(d-g)/6048e5:d),c?e:h(e)},from:function(a,b){return bb.duration(this.diff(a)).lang(this.lang()._abbr).humanize(!b)},fromNow:function(a){return this.from(bb(),a)},calendar:function(){var a=this.diff(bb().zone(this.zone()).startOf("day"),"days",!0),b=-6>a?"sameElse":-1>a?"lastWeek":0>a?"lastDay":1>a?"sameDay":2>a?"nextDay":7>a?"nextWeek":"sameElse";return this.format(this.lang().calendar(b,this))},isLeapYear:function(){return t(this.year())},isDST:function(){return this.zone()+bb(a).startOf(b)},isBefore:function(a,b){return b="undefined"!=typeof b?b:"millisecond",+this.clone().startOf(b)<+bb(a).startOf(b)},isSame:function(a,b){return b="undefined"!=typeof b?b:"millisecond",+this.clone().startOf(b)===+bb(a).startOf(b)},min:function(a){return a=bb.apply(null,arguments),this>a?this:a},max:function(a){return a=bb.apply(null,arguments),a>this?this:a},zone:function(a){var b=this._offset||0;return null==a?this._isUTC?b:this._d.getTimezoneOffset():("string"==typeof a&&(a=G(a)),Math.abs(a)<16&&(a=60*a),this._offset=a,this._isUTC=!0,b!==a&&j(this,bb.duration(b-a,"m"),1,!0),this)},zoneAbbr:function(){return this._isUTC?"UTC":""},zoneName:function(){return this._isUTC?"Coordinated Universal Time":""},parseZone:function(){return"string"==typeof this._i&&this.zone(this._i),this},hasAlignedHourOffset:function(a){return a=a?bb(a).zone():0,0===(this.zone()-a)%60},daysInMonth:function(){return r(this.year(),this.month())},dayOfYear:function(a){var b=eb((bb(this).startOf("day")-bb(this).startOf("year"))/864e5)+1;return null==a?b:this.add("d",a-b)},weekYear:function(a){var b=W(this,this.lang()._week.dow,this.lang()._week.doy).year;return null==a?b:this.add("y",a-b)},isoWeekYear:function(a){var b=W(this,1,4).year;return null==a?b:this.add("y",a-b)},week:function(a){var b=this.lang().week(this);return null==a?b:this.add("d",7*(a-b))},isoWeek:function(a){var b=W(this,1,4).week;return null==a?b:this.add("d",7*(a-b))},weekday:function(a){var b=(this.day()+7-this.lang()._week.dow)%7;return null==a?b:this.add("d",a-b)},isoWeekday:function(a){return null==a?this.day()||7:this.day(this.day()%7?a:a-7)},get:function(a){return a=n(a),this[a]()},set:function(a,b){return a=n(a),"function"==typeof this[a]&&this[a](b),this},lang:function(b){return b===a?this._lang:(this._lang=A(b),this)}}),cb=0;cb 0; 176 | 177 | 178 | /** 179 | * iOS requires exceptions. 180 | * 181 | * @type boolean 182 | */ 183 | FastClick.prototype.deviceIsIOS = /iP(ad|hone|od)/.test(navigator.userAgent); 184 | 185 | 186 | /** 187 | * iOS 4 requires an exception for select elements. 188 | * 189 | * @type boolean 190 | */ 191 | FastClick.prototype.deviceIsIOS4 = FastClick.prototype.deviceIsIOS && (/OS 4_\d(_\d)?/).test(navigator.userAgent); 192 | 193 | 194 | /** 195 | * iOS 6.0(+?) requires the target element to be manually derived 196 | * 197 | * @type boolean 198 | */ 199 | FastClick.prototype.deviceIsIOSWithBadTarget = FastClick.prototype.deviceIsIOS && (/OS ([6-9]|\d{2})_\d/).test(navigator.userAgent); 200 | 201 | 202 | /** 203 | * Determine whether a given element requires a native click. 204 | * 205 | * @param {EventTarget|Element} target Target DOM element 206 | * @returns {boolean} Returns true if the element needs a native click 207 | */ 208 | FastClick.prototype.needsClick = function(target) { 209 | 'use strict'; 210 | switch (target.nodeName.toLowerCase()) { 211 | 212 | // Don't send a synthetic click to disabled inputs (issue #62) 213 | case 'button': 214 | case 'select': 215 | case 'textarea': 216 | if (target.disabled) { 217 | return true; 218 | } 219 | 220 | break; 221 | case 'input': 222 | 223 | // File inputs need real clicks on iOS 6 due to a browser bug (issue #68) 224 | if ((this.deviceIsIOS && target.type === 'file') || target.disabled) { 225 | return true; 226 | } 227 | 228 | break; 229 | case 'label': 230 | case 'video': 231 | return true; 232 | } 233 | 234 | return (/\bneedsclick\b/).test(target.className); 235 | }; 236 | 237 | 238 | /** 239 | * Determine whether a given element requires a call to focus to simulate click into element. 240 | * 241 | * @param {EventTarget|Element} target Target DOM element 242 | * @returns {boolean} Returns true if the element requires a call to focus to simulate native click. 243 | */ 244 | FastClick.prototype.needsFocus = function(target) { 245 | 'use strict'; 246 | switch (target.nodeName.toLowerCase()) { 247 | case 'textarea': 248 | return true; 249 | case 'select': 250 | return !this.deviceIsAndroid; 251 | case 'input': 252 | switch (target.type) { 253 | case 'button': 254 | case 'checkbox': 255 | case 'file': 256 | case 'image': 257 | case 'radio': 258 | case 'submit': 259 | return false; 260 | } 261 | 262 | // No point in attempting to focus disabled inputs 263 | return !target.disabled && !target.readOnly; 264 | default: 265 | return (/\bneedsfocus\b/).test(target.className); 266 | } 267 | }; 268 | 269 | 270 | /** 271 | * Send a click event to the specified element. 272 | * 273 | * @param {EventTarget|Element} targetElement 274 | * @param {Event} event 275 | */ 276 | FastClick.prototype.sendClick = function(targetElement, event) { 277 | 'use strict'; 278 | var clickEvent, touch; 279 | 280 | // On some Android devices activeElement needs to be blurred otherwise the synthetic click will have no effect (#24) 281 | if (document.activeElement && document.activeElement !== targetElement) { 282 | document.activeElement.blur(); 283 | } 284 | 285 | touch = event.changedTouches[0]; 286 | 287 | // Synthesise a click event, with an extra attribute so it can be tracked 288 | clickEvent = document.createEvent('MouseEvents'); 289 | clickEvent.initMouseEvent(this.determineEventType(targetElement), true, true, window, 1, touch.screenX, touch.screenY, touch.clientX, touch.clientY, false, false, false, false, 0, null); 290 | clickEvent.forwardedTouchEvent = true; 291 | targetElement.dispatchEvent(clickEvent); 292 | }; 293 | 294 | FastClick.prototype.determineEventType = function(targetElement) { 295 | 'use strict'; 296 | 297 | //Issue #159: Android Chrome Select Box does not open with a synthetic click event 298 | if (this.deviceIsAndroid && targetElement.tagName.toLowerCase() === 'select') { 299 | return 'mousedown'; 300 | } 301 | 302 | return 'click'; 303 | }; 304 | 305 | 306 | /** 307 | * @param {EventTarget|Element} targetElement 308 | */ 309 | FastClick.prototype.focus = function(targetElement) { 310 | 'use strict'; 311 | var length; 312 | 313 | // Issue #160: on iOS 7, some input elements (e.g. date datetime) throw a vague TypeError on setSelectionRange. These elements don't have an integer value for the selectionStart and selectionEnd properties, but unfortunately that can't be used for detection because accessing the properties also throws a TypeError. Just check the type instead. Filed as Apple bug #15122724. 314 | if (this.deviceIsIOS && targetElement.setSelectionRange && targetElement.type.indexOf('date') !== 0 && targetElement.type !== 'time') { 315 | length = targetElement.value.length; 316 | targetElement.setSelectionRange(length, length); 317 | } else { 318 | targetElement.focus(); 319 | } 320 | }; 321 | 322 | 323 | /** 324 | * Check whether the given target element is a child of a scrollable layer and if so, set a flag on it. 325 | * 326 | * @param {EventTarget|Element} targetElement 327 | */ 328 | FastClick.prototype.updateScrollParent = function(targetElement) { 329 | 'use strict'; 330 | var scrollParent, parentElement; 331 | 332 | scrollParent = targetElement.fastClickScrollParent; 333 | 334 | // Attempt to discover whether the target element is contained within a scrollable layer. Re-check if the 335 | // target element was moved to another parent. 336 | if (!scrollParent || !scrollParent.contains(targetElement)) { 337 | parentElement = targetElement; 338 | do { 339 | if (parentElement.scrollHeight > parentElement.offsetHeight) { 340 | scrollParent = parentElement; 341 | targetElement.fastClickScrollParent = parentElement; 342 | break; 343 | } 344 | 345 | parentElement = parentElement.parentElement; 346 | } while (parentElement); 347 | } 348 | 349 | // Always update the scroll top tracker if possible. 350 | if (scrollParent) { 351 | scrollParent.fastClickLastScrollTop = scrollParent.scrollTop; 352 | } 353 | }; 354 | 355 | 356 | /** 357 | * @param {EventTarget} targetElement 358 | * @returns {Element|EventTarget} 359 | */ 360 | FastClick.prototype.getTargetElementFromEventTarget = function(eventTarget) { 361 | 'use strict'; 362 | 363 | // On some older browsers (notably Safari on iOS 4.1 - see issue #56) the event target may be a text node. 364 | if (eventTarget.nodeType === Node.TEXT_NODE) { 365 | return eventTarget.parentNode; 366 | } 367 | 368 | return eventTarget; 369 | }; 370 | 371 | 372 | /** 373 | * On touch start, record the position and scroll offset. 374 | * 375 | * @param {Event} event 376 | * @returns {boolean} 377 | */ 378 | FastClick.prototype.onTouchStart = function(event) { 379 | 'use strict'; 380 | var targetElement, touch, selection; 381 | 382 | // Ignore multiple touches, otherwise pinch-to-zoom is prevented if both fingers are on the FastClick element (issue #111). 383 | if (event.targetTouches.length > 1) { 384 | return true; 385 | } 386 | 387 | targetElement = this.getTargetElementFromEventTarget(event.target); 388 | touch = event.targetTouches[0]; 389 | 390 | if (this.deviceIsIOS) { 391 | 392 | // Only trusted events will deselect text on iOS (issue #49) 393 | selection = window.getSelection(); 394 | if (selection.rangeCount && !selection.isCollapsed) { 395 | return true; 396 | } 397 | 398 | if (!this.deviceIsIOS4) { 399 | 400 | // Weird things happen on iOS when an alert or confirm dialog is opened from a click event callback (issue #23): 401 | // when the user next taps anywhere else on the page, new touchstart and touchend events are dispatched 402 | // with the same identifier as the touch event that previously triggered the click that triggered the alert. 403 | // Sadly, there is an issue on iOS 4 that causes some normal touch events to have the same identifier as an 404 | // immediately preceeding touch event (issue #52), so this fix is unavailable on that platform. 405 | if (touch.identifier === this.lastTouchIdentifier) { 406 | event.preventDefault(); 407 | return false; 408 | } 409 | 410 | this.lastTouchIdentifier = touch.identifier; 411 | 412 | // If the target element is a child of a scrollable layer (using -webkit-overflow-scrolling: touch) and: 413 | // 1) the user does a fling scroll on the scrollable layer 414 | // 2) the user stops the fling scroll with another tap 415 | // then the event.target of the last 'touchend' event will be the element that was under the user's finger 416 | // when the fling scroll was started, causing FastClick to send a click event to that layer - unless a check 417 | // is made to ensure that a parent layer was not scrolled before sending a synthetic click (issue #42). 418 | this.updateScrollParent(targetElement); 419 | } 420 | } 421 | 422 | this.trackingClick = true; 423 | this.trackingClickStart = event.timeStamp; 424 | this.targetElement = targetElement; 425 | 426 | this.touchStartX = touch.pageX; 427 | this.touchStartY = touch.pageY; 428 | 429 | // Prevent phantom clicks on fast double-tap (issue #36) 430 | if ((event.timeStamp - this.lastClickTime) < 200) { 431 | event.preventDefault(); 432 | } 433 | 434 | return true; 435 | }; 436 | 437 | 438 | /** 439 | * Based on a touchmove event object, check whether the touch has moved past a boundary since it started. 440 | * 441 | * @param {Event} event 442 | * @returns {boolean} 443 | */ 444 | FastClick.prototype.touchHasMoved = function(event) { 445 | 'use strict'; 446 | var touch = event.changedTouches[0], boundary = this.touchBoundary; 447 | 448 | if (Math.abs(touch.pageX - this.touchStartX) > boundary || Math.abs(touch.pageY - this.touchStartY) > boundary) { 449 | return true; 450 | } 451 | 452 | return false; 453 | }; 454 | 455 | 456 | /** 457 | * Update the last position. 458 | * 459 | * @param {Event} event 460 | * @returns {boolean} 461 | */ 462 | FastClick.prototype.onTouchMove = function(event) { 463 | 'use strict'; 464 | if (!this.trackingClick) { 465 | return true; 466 | } 467 | 468 | // If the touch has moved, cancel the click tracking 469 | if (this.targetElement !== this.getTargetElementFromEventTarget(event.target) || this.touchHasMoved(event)) { 470 | this.trackingClick = false; 471 | this.targetElement = null; 472 | } 473 | 474 | return true; 475 | }; 476 | 477 | 478 | /** 479 | * Attempt to find the labelled control for the given label element. 480 | * 481 | * @param {EventTarget|HTMLLabelElement} labelElement 482 | * @returns {Element|null} 483 | */ 484 | FastClick.prototype.findControl = function(labelElement) { 485 | 'use strict'; 486 | 487 | // Fast path for newer browsers supporting the HTML5 control attribute 488 | if (labelElement.control !== undefined) { 489 | return labelElement.control; 490 | } 491 | 492 | // All browsers under test that support touch events also support the HTML5 htmlFor attribute 493 | if (labelElement.htmlFor) { 494 | return document.getElementById(labelElement.htmlFor); 495 | } 496 | 497 | // If no for attribute exists, attempt to retrieve the first labellable descendant element 498 | // the list of which is defined here: http://www.w3.org/TR/html5/forms.html#category-label 499 | return labelElement.querySelector('button, input:not([type=hidden]), keygen, meter, output, progress, select, textarea'); 500 | }; 501 | 502 | 503 | /** 504 | * On touch end, determine whether to send a click event at once. 505 | * 506 | * @param {Event} event 507 | * @returns {boolean} 508 | */ 509 | FastClick.prototype.onTouchEnd = function(event) { 510 | 'use strict'; 511 | var forElement, trackingClickStart, targetTagName, scrollParent, touch, targetElement = this.targetElement; 512 | 513 | if (!this.trackingClick) { 514 | return true; 515 | } 516 | 517 | // Prevent phantom clicks on fast double-tap (issue #36) 518 | if ((event.timeStamp - this.lastClickTime) < 200) { 519 | this.cancelNextClick = true; 520 | return true; 521 | } 522 | 523 | // Reset to prevent wrong click cancel on input (issue #156). 524 | this.cancelNextClick = false; 525 | 526 | this.lastClickTime = event.timeStamp; 527 | 528 | trackingClickStart = this.trackingClickStart; 529 | this.trackingClick = false; 530 | this.trackingClickStart = 0; 531 | 532 | // On some iOS devices, the targetElement supplied with the event is invalid if the layer 533 | // is performing a transition or scroll, and has to be re-detected manually. Note that 534 | // for this to function correctly, it must be called *after* the event target is checked! 535 | // See issue #57; also filed as rdar://13048589 . 536 | if (this.deviceIsIOSWithBadTarget) { 537 | touch = event.changedTouches[0]; 538 | 539 | // In certain cases arguments of elementFromPoint can be negative, so prevent setting targetElement to null 540 | targetElement = document.elementFromPoint(touch.pageX - window.pageXOffset, touch.pageY - window.pageYOffset) || targetElement; 541 | targetElement.fastClickScrollParent = this.targetElement.fastClickScrollParent; 542 | } 543 | 544 | targetTagName = targetElement.tagName.toLowerCase(); 545 | if (targetTagName === 'label') { 546 | forElement = this.findControl(targetElement); 547 | if (forElement) { 548 | this.focus(targetElement); 549 | if (this.deviceIsAndroid) { 550 | return false; 551 | } 552 | 553 | targetElement = forElement; 554 | } 555 | } else if (this.needsFocus(targetElement)) { 556 | 557 | // Case 1: If the touch started a while ago (best guess is 100ms based on tests for issue #36) then focus will be triggered anyway. Return early and unset the target element reference so that the subsequent click will be allowed through. 558 | // Case 2: Without this exception for input elements tapped when the document is contained in an iframe, then any inputted text won't be visible even though the value attribute is updated as the user types (issue #37). 559 | if ((event.timeStamp - trackingClickStart) > 100 || (this.deviceIsIOS && window.top !== window && targetTagName === 'input')) { 560 | this.targetElement = null; 561 | return false; 562 | } 563 | 564 | this.focus(targetElement); 565 | 566 | // Select elements need the event to go through on iOS 4, otherwise the selector menu won't open. 567 | if (!this.deviceIsIOS4 || targetTagName !== 'select') { 568 | this.targetElement = null; 569 | event.preventDefault(); 570 | } 571 | 572 | return false; 573 | } 574 | 575 | if (this.deviceIsIOS && !this.deviceIsIOS4) { 576 | 577 | // Don't send a synthetic click event if the target element is contained within a parent layer that was scrolled 578 | // and this tap is being used to stop the scrolling (usually initiated by a fling - issue #42). 579 | scrollParent = targetElement.fastClickScrollParent; 580 | if (scrollParent && scrollParent.fastClickLastScrollTop !== scrollParent.scrollTop) { 581 | return true; 582 | } 583 | } 584 | 585 | // Prevent the actual click from going though - unless the target node is marked as requiring 586 | // real clicks or if it is in the whitelist in which case only non-programmatic clicks are permitted. 587 | if (!this.needsClick(targetElement)) { 588 | event.preventDefault(); 589 | this.sendClick(targetElement, event); 590 | } 591 | 592 | return false; 593 | }; 594 | 595 | 596 | /** 597 | * On touch cancel, stop tracking the click. 598 | * 599 | * @returns {void} 600 | */ 601 | FastClick.prototype.onTouchCancel = function() { 602 | 'use strict'; 603 | this.trackingClick = false; 604 | this.targetElement = null; 605 | }; 606 | 607 | 608 | /** 609 | * Determine mouse events which should be permitted. 610 | * 611 | * @param {Event} event 612 | * @returns {boolean} 613 | */ 614 | FastClick.prototype.onMouse = function(event) { 615 | 'use strict'; 616 | 617 | // If a target element was never set (because a touch event was never fired) allow the event 618 | if (!this.targetElement) { 619 | return true; 620 | } 621 | 622 | if (event.forwardedTouchEvent) { 623 | return true; 624 | } 625 | 626 | // Programmatically generated events targeting a specific element should be permitted 627 | if (!event.cancelable) { 628 | return true; 629 | } 630 | 631 | // Derive and check the target element to see whether the mouse event needs to be permitted; 632 | // unless explicitly enabled, prevent non-touch click events from triggering actions, 633 | // to prevent ghost/doubleclicks. 634 | if (!this.needsClick(this.targetElement) || this.cancelNextClick) { 635 | 636 | // Prevent any user-added listeners declared on FastClick element from being fired. 637 | if (event.stopImmediatePropagation) { 638 | event.stopImmediatePropagation(); 639 | } else { 640 | 641 | // Part of the hack for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2) 642 | event.propagationStopped = true; 643 | } 644 | 645 | // Cancel the event 646 | event.stopPropagation(); 647 | event.preventDefault(); 648 | 649 | return false; 650 | } 651 | 652 | // If the mouse event is permitted, return true for the action to go through. 653 | return true; 654 | }; 655 | 656 | 657 | /** 658 | * On actual clicks, determine whether this is a touch-generated click, a click action occurring 659 | * naturally after a delay after a touch (which needs to be cancelled to avoid duplication), or 660 | * an actual click which should be permitted. 661 | * 662 | * @param {Event} event 663 | * @returns {boolean} 664 | */ 665 | FastClick.prototype.onClick = function(event) { 666 | 'use strict'; 667 | var permitted; 668 | 669 | // It's possible for another FastClick-like library delivered with third-party code to fire a click event before FastClick does (issue #44). In that case, set the click-tracking flag back to false and return early. This will cause onTouchEnd to return early. 670 | if (this.trackingClick) { 671 | this.targetElement = null; 672 | this.trackingClick = false; 673 | return true; 674 | } 675 | 676 | // Very odd behaviour on iOS (issue #18): if a submit element is present inside a form and the user hits enter in the iOS simulator or clicks the Go button on the pop-up OS keyboard the a kind of 'fake' click event will be triggered with the submit-type input element as the target. 677 | if (event.target.type === 'submit' && event.detail === 0) { 678 | return true; 679 | } 680 | 681 | permitted = this.onMouse(event); 682 | 683 | // Only unset targetElement if the click is not permitted. This will ensure that the check for !targetElement in onMouse fails and the browser's click doesn't go through. 684 | if (!permitted) { 685 | this.targetElement = null; 686 | } 687 | 688 | // If clicks are permitted, return true for the action to go through. 689 | return permitted; 690 | }; 691 | 692 | 693 | /** 694 | * Remove all FastClick's event listeners. 695 | * 696 | * @returns {void} 697 | */ 698 | FastClick.prototype.destroy = function() { 699 | 'use strict'; 700 | var layer = this.layer; 701 | 702 | if (this.deviceIsAndroid) { 703 | layer.removeEventListener('mouseover', this.onMouse, true); 704 | layer.removeEventListener('mousedown', this.onMouse, true); 705 | layer.removeEventListener('mouseup', this.onMouse, true); 706 | } 707 | 708 | layer.removeEventListener('click', this.onClick, true); 709 | layer.removeEventListener('touchstart', this.onTouchStart, false); 710 | layer.removeEventListener('touchmove', this.onTouchMove, false); 711 | layer.removeEventListener('touchend', this.onTouchEnd, false); 712 | layer.removeEventListener('touchcancel', this.onTouchCancel, false); 713 | }; 714 | 715 | 716 | /** 717 | * Check whether FastClick is needed. 718 | * 719 | * @param {Element} layer The layer to listen on 720 | */ 721 | FastClick.notNeeded = function(layer) { 722 | 'use strict'; 723 | var metaViewport; 724 | var chromeVersion; 725 | 726 | // Devices that don't support touch don't need FastClick 727 | if (typeof window.ontouchstart === 'undefined') { 728 | return true; 729 | } 730 | 731 | // Chrome version - zero for other browsers 732 | chromeVersion = +(/Chrome\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1]; 733 | 734 | if (chromeVersion) { 735 | 736 | if (FastClick.prototype.deviceIsAndroid) { 737 | metaViewport = document.querySelector('meta[name=viewport]'); 738 | 739 | if (metaViewport) { 740 | // Chrome on Android with user-scalable="no" doesn't need FastClick (issue #89) 741 | if (metaViewport.content.indexOf('user-scalable=no') !== -1) { 742 | return true; 743 | } 744 | // Chrome 32 and above with width=device-width or less don't need FastClick 745 | if (chromeVersion > 31 && window.innerWidth <= window.screen.width) { 746 | return true; 747 | } 748 | } 749 | 750 | // Chrome desktop doesn't need FastClick (issue #15) 751 | } else { 752 | return true; 753 | } 754 | } 755 | 756 | // IE10 with -ms-touch-action: none, which disables double-tap-to-zoom (issue #97) 757 | if (layer.style.msTouchAction === 'none') { 758 | return true; 759 | } 760 | 761 | return false; 762 | }; 763 | 764 | 765 | /** 766 | * Factory method for creating a FastClick object 767 | * 768 | * @param {Element} layer The layer to listen on 769 | */ 770 | FastClick.attach = function(layer) { 771 | 'use strict'; 772 | return new FastClick(layer); 773 | }; 774 | 775 | 776 | if (typeof define !== 'undefined' && define.amd) { 777 | 778 | // AMD. Register as an anonymous module. 779 | define(function() { 780 | 'use strict'; 781 | return FastClick; 782 | }); 783 | } else if (typeof module !== 'undefined' && module.exports) { 784 | module.exports = FastClick.attach; 785 | module.exports.FastClick = FastClick; 786 | } else { 787 | window.FastClick = FastClick; 788 | } 789 | --------------------------------------------------------------------------------