├── 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 | 
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 |
--------------------------------------------------------------------------------