├── LICENSE ├── Makefile ├── README.md ├── api ├── .gitignore ├── README.md ├── config │ ├── config.exs │ ├── dev.exs │ ├── prod.exs │ └── test.exs ├── lib │ ├── sling.ex │ └── sling │ │ ├── endpoint.ex │ │ ├── guardian_serializer.ex │ │ └── repo.ex ├── mix.exs ├── mix.lock ├── priv │ ├── gettext │ │ ├── en │ │ │ └── LC_MESSAGES │ │ │ │ └── errors.po │ │ └── errors.pot │ ├── repo │ │ ├── migrations │ │ │ ├── 20161219012204_create_user.exs │ │ │ ├── 20161219111725_create_room.exs │ │ │ ├── 20161219111907_create_user_room.exs │ │ │ └── 20161219121546_create_message.exs │ │ └── seeds.exs │ └── static │ │ ├── css │ │ └── app.css │ │ ├── favicon.ico │ │ ├── images │ │ └── phoenix.png │ │ ├── js │ │ ├── app.js │ │ └── phoenix.js │ │ └── robots.txt ├── test │ ├── controllers │ │ ├── room_controller_test.exs │ │ └── user_controller_test.exs │ ├── models │ │ ├── message_test.exs │ │ ├── room_test.exs │ │ ├── user_room_test.exs │ │ └── user_test.exs │ ├── support │ │ ├── channel_case.ex │ │ ├── conn_case.ex │ │ └── model_case.ex │ ├── test_helper.exs │ └── views │ │ └── error_view_test.exs └── web │ ├── channels │ ├── presence.ex │ ├── room_channel.ex │ └── user_socket.ex │ ├── controllers │ └── api │ │ ├── message_controller.ex │ │ ├── room_controller.ex │ │ ├── session_controller.ex │ │ └── user_controller.ex │ ├── gettext.ex │ ├── models │ ├── message.ex │ ├── room.ex │ ├── user.ex │ └── user_room.ex │ ├── router.ex │ ├── views │ ├── changeset_view.ex │ ├── error_helpers.ex │ ├── error_view.ex │ ├── message_view.ex │ ├── pagination_helpers.ex │ ├── room_view.ex │ ├── session_view.ex │ └── user_view.ex │ └── web.ex ├── docker-compose.yml ├── preview.png └── web ├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── README.md ├── build ├── build.js ├── check-versions.js ├── dev-client.js ├── dev-server.js ├── utils.js ├── webpack.base.conf.js ├── webpack.dev.conf.js └── webpack.prod.conf.js ├── config ├── dev.env.js ├── index.js ├── prod.env.js └── test.env.js ├── index.html ├── package.json ├── src ├── assets │ ├── app.scss │ ├── bootstrap.css │ ├── font-awesome.css │ ├── fonts │ │ ├── FontAwesome.otf │ │ ├── fontawesome-webfont.eot │ │ ├── fontawesome-webfont.svg │ │ ├── fontawesome-webfont.ttf │ │ ├── fontawesome-webfont.woff │ │ └── fontawesome-webfont.woff2 │ └── logo.png ├── components │ ├── ActiveRooms.vue │ ├── ActiveUsers.vue │ ├── Avatar.vue │ ├── Message.vue │ ├── MessageForm.vue │ ├── MessageInput.vue │ ├── MessageList.vue │ ├── Navbar.vue │ ├── NewRoomForm.vue │ ├── NotFound.vue │ ├── RoomListItem.vue │ ├── RoomNavbar.vue │ ├── SigninForm.vue │ └── SignupForm.vue ├── containers │ ├── App.vue │ ├── Home.vue │ ├── Room.vue │ ├── Sidebar.vue │ ├── Signin.vue │ └── Signup.vue ├── main.js ├── plugins │ ├── http │ │ ├── index.js │ │ └── interceptors.js │ └── socket │ │ └── index.js ├── router │ ├── beforeEach.js │ └── index.js ├── store │ ├── actions.js │ ├── getters.js │ ├── index.js │ ├── mutations.js │ ├── plugins.js │ ├── room │ │ ├── actions.js │ │ ├── index.js │ │ ├── mutations.js │ │ └── state.js │ ├── rooms │ │ ├── actions.js │ │ ├── index.js │ │ ├── mutations.js │ │ └── state.js │ ├── session │ │ ├── actions.js │ │ ├── index.js │ │ ├── mutations.js │ │ └── state.js │ ├── state.js │ └── types.js └── utils │ └── get.js ├── static └── .gitkeep ├── test ├── e2e │ ├── custom-assertions │ │ └── elementCount.js │ ├── nightwatch.conf.js │ ├── runner.js │ └── specs │ │ └── test.js └── unit │ ├── .eslintrc │ ├── index.js │ ├── karma.conf.js │ └── specs │ └── Hello.spec.js └── yarn.lock /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Daniel Docki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL=/bin/bash 2 | 3 | .PHONY: all 4 | all: up_backend up_frontend 5 | 6 | .PHONY: up_backend 7 | up_backend: 8 | cd api && \ 9 | mix deps.get && \ 10 | mix deps.update postgrex && \ 11 | mix deps.compile postgrex && \ 12 | docker-compose up -d && \ 13 | mix ecto.create && \ 14 | mix ecto.migrate && \ 15 | mix phoenix.server 16 | 17 | .PHONY: up_frontend 18 | up_frontend: 19 | cd web && \ 20 | yarn && \ 21 | yarn run dev 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## About 2 | 3 | This Project was inspired by [Slack clone built with Phoenix and React](https://medium.com/@benhansen/lets-build-a-slack-clone-with-elixir-phoenix-and-react-part-1-project-setup-3252ae780a1) 4 | 5 | This Project is a Slack clone built with Phoenix and VueJS. 6 | 7 |  8 | 9 | ## Getting started 10 | 11 | To run the project locally: 12 | 13 | ### quick start 14 | 15 | bring up the backend then the frontend: 16 | 17 | ```sh 18 | make up_backend 19 | ``` 20 | 21 | ```sh 22 | make up_frontend 23 | ``` 24 | 25 | #### Running the Phoenix app 26 | 27 | ##### detailed 28 | 29 | Download dependencies 30 | 31 | ``` 32 | cd api 33 | mix deps.get 34 | ``` 35 | 36 | Edit the database connection config in `/config/dev.exs` or `config/dev.secret.exs` 37 | with your postgres user info if needed 38 | 39 | Create and migrate the database 40 | 41 | ``` 42 | mix ecto.create && mix ecto.migrate 43 | ``` 44 | 45 | Start the server 46 | 47 | ``` 48 | mix phoenix.server 49 | ``` 50 | 51 | #### Running the VueJS app 52 | 53 | Install [Yarn](https://github.com/yarnpkg/yarn) 54 | 55 | Install dependencies 56 | 57 | ``` 58 | cd web 59 | yarn 60 | ``` 61 | 62 | Start the dev server 63 | 64 | ``` 65 | yarn run dev 66 | ``` 67 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | 7 | # Generated on crash by the VM 8 | erl_crash.dump 9 | 10 | # The config/prod.secret.exs file by default contains sensitive 11 | # data and you should not commit it into version control. 12 | # 13 | # Alternatively, you may comment the line below and commit the 14 | # secrets file as long as you replace its contents by environment 15 | # variables. 16 | /config/prod.secret.exs 17 | /config/dev.secret.exs 18 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # Sling 2 | 3 | To start your Phoenix app: 4 | 5 | * Install dependencies with `mix deps.get` 6 | * Create and migrate your database with `mix ecto.create && mix ecto.migrate` 7 | * Start Phoenix endpoint with `mix phoenix.server` 8 | 9 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 10 | 11 | Ready to run in production? Please [check our deployment guides](http://www.phoenixframework.org/docs/deployment). 12 | 13 | ## Learn more 14 | 15 | * Official website: http://www.phoenixframework.org/ 16 | * Guides: http://phoenixframework.org/docs/overview 17 | * Docs: https://hexdocs.pm/phoenix 18 | * Mailing list: http://groups.google.com/group/phoenix-talk 19 | * Source: https://github.com/phoenixframework/phoenix 20 | -------------------------------------------------------------------------------- /api/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | use Mix.Config 7 | 8 | # General application configuration 9 | config :sling, 10 | ecto_repos: [Sling.Repo] 11 | 12 | # Configures the endpoint 13 | config :sling, Sling.Endpoint, 14 | url: [host: "localhost"], 15 | secret_key_base: "BImfahldwSKtw/9FMkv1OXBtpe7gVfWonS5J861oImyyTYwfE6Nm0G3Ifunowo/S", 16 | render_errors: [view: Sling.ErrorView, accepts: ~w(json)], 17 | pubsub: [name: Sling.PubSub, 18 | adapter: Phoenix.PubSub.PG2] 19 | 20 | # Configures Elixir's Logger 21 | config :logger, :console, 22 | format: "$time $metadata[$level] $message\n", 23 | metadata: [:request_id] 24 | 25 | config :guardian, Guardian, 26 | issuer: "Sling", 27 | ttl: {30, :days}, 28 | verify_issuer: true, 29 | serializer: Sling.GuardianSerializer 30 | 31 | # Import environment specific config. This must remain at the bottom 32 | # of this file so it overrides the configuration defined above. 33 | import_config "#{Mix.env}.exs" 34 | -------------------------------------------------------------------------------- /api/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with brunch.io to recompile .js and .css sources. 9 | config :sling, Sling.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false, 14 | watchers: [] 15 | 16 | 17 | # Do not include metadata nor timestamps in development logs 18 | config :logger, :console, format: "[$level] $message\n" 19 | 20 | # Set a higher stacktrace during development. Avoid configuring such 21 | # in production as building large stacktraces may be expensive. 22 | config :phoenix, :stacktrace_depth, 20 23 | 24 | # Configure your database 25 | config :sling, Sling.Repo, 26 | adapter: Ecto.Adapters.Postgres, 27 | username: "postgres", 28 | password: "postgres", 29 | database: "sling_dev", 30 | hostname: "localhost", 31 | pool_size: 10 32 | 33 | config :guardian, Guardian, 34 | secret_key: "coxJt6/svYoLYg+KltCBazU8yH4SqXZbyaCdx5O/K0H3ybIfENJsEB1lH+ACPgU4" 35 | 36 | import_config "dev.secret.exs" 37 | -------------------------------------------------------------------------------- /api/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, we configure the host to read the PORT 4 | # from the system environment. Therefore, you will need 5 | # to set PORT=80 before running your server. 6 | # 7 | # You should also configure the url host to something 8 | # meaningful, we use this information when generating URLs. 9 | # 10 | # Finally, we also include the path to a manifest 11 | # containing the digested version of static files. This 12 | # manifest is generated by the mix phoenix.digest task 13 | # which you typically run after static files are built. 14 | config :sling, Sling.Endpoint, 15 | http: [port: {:system, "PORT"}], 16 | url: [host: "example.com", port: 80], 17 | cache_static_manifest: "priv/static/manifest.json" 18 | 19 | # Do not print debug messages in production 20 | config :logger, level: :info 21 | 22 | # ## SSL Support 23 | # 24 | # To get SSL working, you will need to add the `https` key 25 | # to the previous section and set your `:url` port to 443: 26 | # 27 | # config :sling, Sling.Endpoint, 28 | # ... 29 | # url: [host: "example.com", port: 443], 30 | # https: [port: 443, 31 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 32 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")] 33 | # 34 | # Where those two env variables return an absolute path to 35 | # the key and cert in disk or a relative path inside priv, 36 | # for example "priv/ssl/server.key". 37 | # 38 | # We also recommend setting `force_ssl`, ensuring no data is 39 | # ever sent via http, always redirecting to https: 40 | # 41 | # config :sling, Sling.Endpoint, 42 | # force_ssl: [hsts: true] 43 | # 44 | # Check `Plug.SSL` for all available options in `force_ssl`. 45 | 46 | # ## Using releases 47 | # 48 | # If you are doing OTP releases, you need to instruct Phoenix 49 | # to start the server for all endpoints: 50 | # 51 | # config :phoenix, :serve_endpoints, true 52 | # 53 | # Alternatively, you can configure exactly which server to 54 | # start per endpoint: 55 | # 56 | # config :sling, Sling.Endpoint, server: true 57 | # 58 | 59 | config :guardian, Guardian, 60 | secret_key: System.get_env("GUARDIAN_SECRET_KEY") 61 | 62 | # Finally import the config/prod.secret.exs 63 | # which should be versioned separately. 64 | import_config "prod.secret.exs" 65 | -------------------------------------------------------------------------------- /api/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :sling, Sling.Endpoint, 6 | http: [port: 4001], 7 | server: false 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | 12 | # Configure your database 13 | config :sling, Sling.Repo, 14 | adapter: Ecto.Adapters.Postgres, 15 | username: "postgres", 16 | password: "postgres", 17 | database: "sling_test", 18 | hostname: "localhost", 19 | pool: Ecto.Adapters.SQL.Sandbox 20 | -------------------------------------------------------------------------------- /api/lib/sling.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling do 2 | use Application 3 | 4 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | import Supervisor.Spec 8 | 9 | # Define workers and child supervisors to be supervised 10 | children = [ 11 | # Start the Ecto repository 12 | supervisor(Sling.Repo, []), 13 | # Start the endpoint when the application starts 14 | supervisor(Sling.Endpoint, []), 15 | # Start your own worker by calling: Sling.Worker.start_link(arg1, arg2, arg3) 16 | # worker(Sling.Worker, [arg1, arg2, arg3]), 17 | supervisor(Sling.Presence, []), 18 | ] 19 | 20 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 21 | # for other strategies and supported options 22 | opts = [strategy: :one_for_one, name: Sling.Supervisor] 23 | Supervisor.start_link(children, opts) 24 | end 25 | 26 | # Tell Phoenix to update the endpoint configuration 27 | # whenever the application is updated. 28 | def config_change(changed, _new, removed) do 29 | Sling.Endpoint.config_change(changed, removed) 30 | :ok 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /api/lib/sling/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :sling 3 | 4 | socket "/socket", Sling.UserSocket 5 | 6 | # Serve at "/" the static files from "priv/static" directory. 7 | # 8 | # You should set gzip to true if you are running phoenix.digest 9 | # when deploying your static files in production. 10 | plug Plug.Static, 11 | at: "/", from: :sling, gzip: false, 12 | only: ~w(css fonts images js favicon.ico robots.txt) 13 | 14 | # Code reloading can be explicitly enabled under the 15 | # :code_reloader configuration of your endpoint. 16 | if code_reloading? do 17 | plug Phoenix.CodeReloader 18 | end 19 | 20 | plug Plug.RequestId 21 | plug Plug.Logger 22 | 23 | plug Plug.Parsers, 24 | parsers: [:urlencoded, :multipart, :json], 25 | pass: ["*/*"], 26 | json_decoder: Poison 27 | 28 | plug Plug.MethodOverride 29 | plug Plug.Head 30 | 31 | # The session will be stored in the cookie and signed, 32 | # this means its contents can be read but not tampered with. 33 | # Set :encryption_salt if you would also like to encrypt it. 34 | plug Plug.Session, 35 | store: :cookie, 36 | key: "_sling_key", 37 | signing_salt: "GzFYZNuB" 38 | 39 | plug CORSPlug 40 | 41 | plug Sling.Router 42 | end 43 | -------------------------------------------------------------------------------- /api/lib/sling/guardian_serializer.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.GuardianSerializer do 2 | @behaviour Guardian.Serializer 3 | 4 | alias Sling.Repo 5 | alias Sling.User 6 | 7 | def for_token(user = %User{}), do: {:ok, "User:#{user.id}"} 8 | def for_token(_), do: {:error, "Unknown resource type"} 9 | 10 | def from_token("User:" <> id), do: {:ok, Repo.get(User, String.to_integer(id))} 11 | def from_token(_), do: {:error, "Unknown resource type"} 12 | end 13 | -------------------------------------------------------------------------------- /api/lib/sling/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.Repo do 2 | use Ecto.Repo, otp_app: :sling 3 | use Scrivener, page_size: 25 4 | end 5 | -------------------------------------------------------------------------------- /api/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Sling.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :sling, 6 | version: "0.0.1", 7 | elixir: "~> 1.2", 8 | elixirc_paths: elixirc_paths(Mix.env), 9 | compilers: [:phoenix, :gettext] ++ Mix.compilers, 10 | build_embedded: Mix.env == :prod, 11 | start_permanent: Mix.env == :prod, 12 | aliases: aliases(), 13 | deps: deps()] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [mod: {Sling, []}, 21 | applications: [:phoenix, :phoenix_pubsub, :cowboy, :logger, :gettext, 22 | :phoenix_ecto, :postgrex, :comeonin, :scrivener_ecto]] 23 | end 24 | 25 | # Specifies which paths to compile per environment. 26 | defp elixirc_paths(:test), do: ["lib", "web", "test/support"] 27 | defp elixirc_paths(_), do: ["lib", "web"] 28 | 29 | # Specifies your project dependencies. 30 | # 31 | # Type `mix help deps` for examples and options. 32 | defp deps do 33 | [{:phoenix, "~> 1.2.1"}, 34 | {:phoenix_pubsub, "~> 1.0"}, 35 | {:phoenix_ecto, "~> 3.0"}, 36 | {:postgrex, ">= 0.0.0"}, 37 | {:gettext, "~> 0.11"}, 38 | {:cowboy, "~> 1.0"}, 39 | {:comeonin, "~> 2.5"}, 40 | {:guardian, "~> 0.13.0"}, 41 | {:cors_plug, "~> 1.1"}, 42 | {:scrivener_ecto, "~> 1.0"}] 43 | end 44 | 45 | # Aliases are shortcuts or tasks specific to the current project. 46 | # For example, to create, migrate and run the seeds file at once: 47 | # 48 | # $ mix ecto.setup 49 | # 50 | # See the documentation for `Mix` for more info on aliases. 51 | defp aliases do 52 | ["ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 53 | "ecto.reset": ["ecto.drop", "ecto.setup"], 54 | "test": ["ecto.create --quiet", "ecto.migrate", "test"]] 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /api/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], []}, 3 | "comeonin": {:hex, :comeonin, "2.6.0", "74c288338b33205f9ce97e2117bb9a2aaab103a1811d243443d76fdb62f904ac", [:make, :mix], []}, 4 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], []}, 5 | "cors_plug": {:hex, :cors_plug, "1.1.2", "3e7451286996f745c7b629c39d24a6493e59b0c8191f27e67f6ab097f96ffd23", [:mix], [{:cowboy, "~> 1.0.0", [hex: :cowboy, optional: false]}, {:plug, "> 0.8.0", [hex: :plug, optional: false]}]}, 6 | "cowboy": {:hex, :cowboy, "1.0.4", "a324a8df9f2316c833a470d918aaf73ae894278b8aa6226ce7a9bf699388f878", [:make, :rebar], [{:cowlib, "~> 1.0.0", [hex: :cowlib, optional: false]}, {:ranch, "~> 1.0", [hex: :ranch, optional: false]}]}, 7 | "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], []}, 8 | "db_connection": {:hex, :db_connection, "1.1.3", "89b30ca1ef0a3b469b1c779579590688561d586694a3ce8792985d4d7e575a61", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, 9 | "decimal": {:hex, :decimal, "1.5.0", "b0433a36d0e2430e3d50291b1c65f53c37d56f83665b43d79963684865beab68", [:mix], [], "hexpm"}, 10 | "ecto": {:hex, :ecto, "2.0.6", "9dcbf819c2a77f67a66b83739b7fcc00b71aaf6c100016db4f798930fa4cfd47", [:mix], [{:db_connection, "~> 1.0", [hex: :db_connection, optional: true]}, {:decimal, "~> 1.1.2 or ~> 1.2", [hex: :decimal, optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, optional: true]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: false]}, {:postgrex, "~> 0.12.0", [hex: :postgrex, optional: true]}, {:sbroker, "~> 1.0-beta", [hex: :sbroker, optional: true]}]}, 11 | "gettext": {:hex, :gettext, "0.13.0", "daafbddc5cda12738bb93b01d84105fe75b916a302f1c50ab9fb066b95ec9db4", [:mix], []}, 12 | "guardian": {:hex, :guardian, "0.13.0", "37c5b5302617276093570ee938baca146f53e1d5de1f5c2b8effb1d2fea596d2", [:mix], [{:jose, "~> 1.8", [hex: :jose, optional: false]}, {:phoenix, "~> 1.2.0", [hex: :phoenix, optional: true]}, {:plug, "~> 1.0", [hex: :plug, optional: false]}, {:poison, ">= 1.3.0", [hex: :poison, optional: false]}, {:uuid, ">=1.1.1", [hex: :uuid, optional: false]}]}, 13 | "jose": {:hex, :jose, "1.8.0", "1ee027c5c0ff3922e3bfe58f7891509e8f87f771ba609ee859e623cc60237574", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, optional: false]}]}, 14 | "mime": {:hex, :mime, "1.0.1", "05c393850524767d13a53627df71beeebb016205eb43bfbd92d14d24ec7a1b51", [:mix], []}, 15 | "phoenix": {:hex, :phoenix, "1.2.1", "6dc592249ab73c67575769765b66ad164ad25d83defa3492dc6ae269bd2a68ab", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, optional: false]}, {:plug, "~> 1.1", [hex: :plug, optional: false]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: false]}]}, 16 | "phoenix_ecto": {:hex, :phoenix_ecto, "3.0.1", "42eb486ef732cf209d0a353e791806721f33ff40beab0a86f02070a5649ed00a", [:mix], [{:ecto, "~> 2.0", [hex: :ecto, optional: false]}, {:phoenix_html, "~> 2.6", [hex: :phoenix_html, optional: true]}, {:plug, "~> 1.0", [hex: :plug, optional: false]}]}, 17 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.0.1", "c10ddf6237007c804bf2b8f3c4d5b99009b42eca3a0dfac04ea2d8001186056a", [:mix], []}, 18 | "plug": {:hex, :plug, "1.3.0", "6e2b01afc5db3fd011ca4a16efd9cb424528c157c30a44a0186bcc92c7b2e8f3", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, optional: true]}, {:mime, "~> 1.0", [hex: :mime, optional: false]}]}, 19 | "poison": {:hex, :poison, "2.2.0", "4763b69a8a77bd77d26f477d196428b741261a761257ff1cf92753a0d4d24a63", [:mix], []}, 20 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []}, 21 | "postgrex": {:hex, :postgrex, "0.12.2", "13cfd784a148da78f0edddd433bcef5c98388fdb2963ba25ed1fa7265885e6bf", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"}, 22 | "ranch": {:hex, :ranch, "1.2.1", "a6fb992c10f2187b46ffd17ce398ddf8a54f691b81768f9ef5f461ea7e28c762", [:make], []}, 23 | "scrivener": {:hex, :scrivener, "2.1.1", "eb52c8b7d283e8999edd6fd50d872ab870669d1f4504134841d0845af11b5ef3", [:mix], []}, 24 | "scrivener_ecto": {:hex, :scrivener_ecto, "1.0.3", "35144d4b4f89a664eb291844e0f3954fe13d92c237f440d9a6562515cd6d44d2", [:mix], [{:ecto, "~> 2.0", [hex: :ecto, optional: false]}, {:postgrex, "~> 0.11.0 or ~> 0.12.0", [hex: :postgrex, optional: true]}, {:scrivener, "~> 2.0", [hex: :scrivener, optional: false]}]}, 25 | "uuid": {:hex, :uuid, "1.1.5", "96cb36d86ee82f912efea4d50464a5df606bf3f1163d6bdbb302d98474969369", [:mix], []}, 26 | } 27 | -------------------------------------------------------------------------------- /api/priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_format/3 26 | msgid "has invalid format" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_subset/3 30 | msgid "has an invalid entry" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_exclusion/3 34 | msgid "is reserved" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_confirmation/3 38 | msgid "does not match confirmation" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.no_assoc_constraint/3 42 | msgid "is still associated to this entry" 43 | msgstr "" 44 | 45 | msgid "are still associated to this entry" 46 | msgstr "" 47 | 48 | ## From Ecto.Changeset.validate_length/3 49 | msgid "should be %{count} character(s)" 50 | msgid_plural "should be %{count} character(s)" 51 | msgstr[0] "" 52 | msgstr[1] "" 53 | 54 | msgid "should have %{count} item(s)" 55 | msgid_plural "should have %{count} item(s)" 56 | msgstr[0] "" 57 | msgstr[1] "" 58 | 59 | msgid "should be at least %{count} character(s)" 60 | msgid_plural "should be at least %{count} character(s)" 61 | msgstr[0] "" 62 | msgstr[1] "" 63 | 64 | msgid "should have at least %{count} item(s)" 65 | msgid_plural "should have at least %{count} item(s)" 66 | msgstr[0] "" 67 | msgstr[1] "" 68 | 69 | msgid "should be at most %{count} character(s)" 70 | msgid_plural "should be at most %{count} character(s)" 71 | msgstr[0] "" 72 | msgstr[1] "" 73 | 74 | msgid "should have at most %{count} item(s)" 75 | msgid_plural "should have at most %{count} item(s)" 76 | msgstr[0] "" 77 | msgstr[1] "" 78 | 79 | ## From Ecto.Changeset.validate_number/3 80 | msgid "must be less than %{number}" 81 | msgstr "" 82 | 83 | msgid "must be greater than %{number}" 84 | msgstr "" 85 | 86 | msgid "must be less than or equal to %{number}" 87 | msgstr "" 88 | 89 | msgid "must be greater than or equal to %{number}" 90 | msgstr "" 91 | 92 | msgid "must be equal to %{number}" 93 | msgstr "" 94 | -------------------------------------------------------------------------------- /api/priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This file is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here as no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_format/3 24 | msgid "has invalid format" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_subset/3 28 | msgid "has an invalid entry" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_exclusion/3 32 | msgid "is reserved" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_confirmation/3 36 | msgid "does not match confirmation" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.no_assoc_constraint/3 40 | msgid "is still associated to this entry" 41 | msgstr "" 42 | 43 | msgid "are still associated to this entry" 44 | msgstr "" 45 | 46 | ## From Ecto.Changeset.validate_length/3 47 | msgid "should be %{count} character(s)" 48 | msgid_plural "should be %{count} character(s)" 49 | msgstr[0] "" 50 | msgstr[1] "" 51 | 52 | msgid "should have %{count} item(s)" 53 | msgid_plural "should have %{count} item(s)" 54 | msgstr[0] "" 55 | msgstr[1] "" 56 | 57 | msgid "should be at least %{count} character(s)" 58 | msgid_plural "should be at least %{count} character(s)" 59 | msgstr[0] "" 60 | msgstr[1] "" 61 | 62 | msgid "should have at least %{count} item(s)" 63 | msgid_plural "should have at least %{count} item(s)" 64 | msgstr[0] "" 65 | msgstr[1] "" 66 | 67 | msgid "should be at most %{count} character(s)" 68 | msgid_plural "should be at most %{count} character(s)" 69 | msgstr[0] "" 70 | msgstr[1] "" 71 | 72 | msgid "should have at most %{count} item(s)" 73 | msgid_plural "should have at most %{count} item(s)" 74 | msgstr[0] "" 75 | msgstr[1] "" 76 | 77 | ## From Ecto.Changeset.validate_number/3 78 | msgid "must be less than %{number}" 79 | msgstr "" 80 | 81 | msgid "must be greater than %{number}" 82 | msgstr "" 83 | 84 | msgid "must be less than or equal to %{number}" 85 | msgstr "" 86 | 87 | msgid "must be greater than or equal to %{number}" 88 | msgstr "" 89 | 90 | msgid "must be equal to %{number}" 91 | msgstr "" 92 | -------------------------------------------------------------------------------- /api/priv/repo/migrations/20161219012204_create_user.exs: -------------------------------------------------------------------------------- 1 | defmodule Sling.Repo.Migrations.CreateUser do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add :username, :string, null: false 7 | add :email, :string, null: false 8 | add :password_hash, :string, null: false 9 | 10 | timestamps() 11 | end 12 | 13 | create unique_index(:users, [:username]) 14 | create unique_index(:users, [:email]) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /api/priv/repo/migrations/20161219111725_create_room.exs: -------------------------------------------------------------------------------- 1 | defmodule Sling.Repo.Migrations.CreateRoom do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:rooms) do 6 | add :name, :string, null: false 7 | add :topic, :string, default: "" 8 | 9 | timestamps() 10 | end 11 | 12 | create unique_index(:rooms, [:name]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /api/priv/repo/migrations/20161219111907_create_user_room.exs: -------------------------------------------------------------------------------- 1 | defmodule Sling.Repo.Migrations.CreateUserRoom do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:user_rooms) do 6 | add :user_id, references(:users, on_delete: :nothing), null: false 7 | add :room_id, references(:rooms, on_delete: :nothing), null: false 8 | 9 | timestamps() 10 | end 11 | create index(:user_rooms, [:user_id]) 12 | create index(:user_rooms, [:room_id]) 13 | create index(:user_rooms, [:user_id, :room_id], unique: true) 14 | 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /api/priv/repo/migrations/20161219121546_create_message.exs: -------------------------------------------------------------------------------- 1 | defmodule Sling.Repo.Migrations.CreateMessage do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:messages) do 6 | add :text, :text, null: false 7 | add :room_id, references(:rooms, on_delete: :nothing), null: false 8 | add :user_id, references(:users, on_delete: :nothing), null: false 9 | 10 | timestamps() 11 | end 12 | create index(:messages, [:room_id]) 13 | create index(:messages, [:user_id]) 14 | 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /api/priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Sling.Repo.insert!(%Sling.SomeModel{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /api/priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldocki/slack-clone-vuejs-elixir-phoenix/cd558cfc337fa9b410e00372ae19284585ce196d/api/priv/static/favicon.ico -------------------------------------------------------------------------------- /api/priv/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldocki/slack-clone-vuejs-elixir-phoenix/cd558cfc337fa9b410e00372ae19284585ce196d/api/priv/static/images/phoenix.png -------------------------------------------------------------------------------- /api/priv/static/js/app.js: -------------------------------------------------------------------------------- 1 | // for phoenix_html support, including form and button helpers 2 | // copy the following scripts into your javascript bundle: 3 | // * https://raw.githubusercontent.com/phoenixframework/phoenix_html/v2.3.0/priv/static/phoenix_html.js -------------------------------------------------------------------------------- /api/priv/static/js/phoenix.js: -------------------------------------------------------------------------------- 1 | (function(exports){ 2 | "use strict"; 3 | 4 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol ? "symbol" : typeof obj; }; 5 | 6 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 7 | 8 | Object.defineProperty(exports, "__esModule", { 9 | value: true 10 | }); 11 | 12 | function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } 13 | 14 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 15 | 16 | // Phoenix Channels JavaScript client 17 | // 18 | // ## Socket Connection 19 | // 20 | // A single connection is established to the server and 21 | // channels are multiplexed over the connection. 22 | // Connect to the server using the `Socket` class: 23 | // 24 | // let socket = new Socket("/ws", {params: {userToken: "123"}}) 25 | // socket.connect() 26 | // 27 | // The `Socket` constructor takes the mount point of the socket, 28 | // the authentication params, as well as options that can be found in 29 | // the Socket docs, such as configuring the `LongPoll` transport, and 30 | // heartbeat. 31 | // 32 | // ## Channels 33 | // 34 | // Channels are isolated, concurrent processes on the server that 35 | // subscribe to topics and broker events between the client and server. 36 | // To join a channel, you must provide the topic, and channel params for 37 | // authorization. Here's an example chat room example where `"new_msg"` 38 | // events are listened for, messages are pushed to the server, and 39 | // the channel is joined with ok/error/timeout matches: 40 | // 41 | // let channel = socket.channel("room:123", {token: roomToken}) 42 | // channel.on("new_msg", msg => console.log("Got message", msg) ) 43 | // $input.onEnter( e => { 44 | // channel.push("new_msg", {body: e.target.val}, 10000) 45 | // .receive("ok", (msg) => console.log("created message", msg) ) 46 | // .receive("error", (reasons) => console.log("create failed", reasons) ) 47 | // .receive("timeout", () => console.log("Networking issue...") ) 48 | // }) 49 | // channel.join() 50 | // .receive("ok", ({messages}) => console.log("catching up", messages) ) 51 | // .receive("error", ({reason}) => console.log("failed join", reason) ) 52 | // .receive("timeout", () => console.log("Networking issue. Still waiting...") ) 53 | // 54 | // 55 | // ## Joining 56 | // 57 | // Creating a channel with `socket.channel(topic, params)`, binds the params to 58 | // `channel.params`, which are sent up on `channel.join()`. 59 | // Subsequent rejoins will send up the modified params for 60 | // updating authorization params, or passing up last_message_id information. 61 | // Successful joins receive an "ok" status, while unsuccessful joins 62 | // receive "error". 63 | // 64 | // ## Duplicate Join Subscriptions 65 | // 66 | // While the client may join any number of topics on any number of channels, 67 | // the client may only hold a single subscription for each unique topic at any 68 | // given time. When attempting to create a duplicate subscription, 69 | // the server will close the existing channel, log a warning, and 70 | // spawn a new channel for the topic. The client will have their 71 | // `channel.onClose` callbacks fired for the existing channel, and the new 72 | // channel join will have its receive hooks processed as normal. 73 | // 74 | // ## Pushing Messages 75 | // 76 | // From the previous example, we can see that pushing messages to the server 77 | // can be done with `channel.push(eventName, payload)` and we can optionally 78 | // receive responses from the push. Additionally, we can use 79 | // `receive("timeout", callback)` to abort waiting for our other `receive` hooks 80 | // and take action after some period of waiting. The default timeout is 5000ms. 81 | // 82 | // 83 | // ## Socket Hooks 84 | // 85 | // Lifecycle events of the multiplexed connection can be hooked into via 86 | // `socket.onError()` and `socket.onClose()` events, ie: 87 | // 88 | // socket.onError( () => console.log("there was an error with the connection!") ) 89 | // socket.onClose( () => console.log("the connection dropped") ) 90 | // 91 | // 92 | // ## Channel Hooks 93 | // 94 | // For each joined channel, you can bind to `onError` and `onClose` events 95 | // to monitor the channel lifecycle, ie: 96 | // 97 | // channel.onError( () => console.log("there was an error!") ) 98 | // channel.onClose( () => console.log("the channel has gone away gracefully") ) 99 | // 100 | // ### onError hooks 101 | // 102 | // `onError` hooks are invoked if the socket connection drops, or the channel 103 | // crashes on the server. In either case, a channel rejoin is attempted 104 | // automatically in an exponential backoff manner. 105 | // 106 | // ### onClose hooks 107 | // 108 | // `onClose` hooks are invoked only in two cases. 1) the channel explicitly 109 | // closed on the server, or 2). The client explicitly closed, by calling 110 | // `channel.leave()` 111 | // 112 | // 113 | // ## Presence 114 | // 115 | // The `Presence` object provides features for syncing presence information 116 | // from the server with the client and handling presences joining and leaving. 117 | // 118 | // ### Syncing initial state from the server 119 | // 120 | // `Presence.syncState` is used to sync the list of presences on the server 121 | // with the client's state. An optional `onJoin` and `onLeave` callback can 122 | // be provided to react to changes in the client's local presences across 123 | // disconnects and reconnects with the server. 124 | // 125 | // `Presence.syncDiff` is used to sync a diff of presence join and leave 126 | // events from the server, as they happen. Like `syncState`, `syncDiff` 127 | // accepts optional `onJoin` and `onLeave` callbacks to react to a user 128 | // joining or leaving from a device. 129 | // 130 | // ### Listing Presences 131 | // 132 | // `Presence.list` is used to return a list of presence information 133 | // based on the local state of metadata. By default, all presence 134 | // metadata is returned, but a `listBy` function can be supplied to 135 | // allow the client to select which metadata to use for a given presence. 136 | // For example, you may have a user online from different devices with a 137 | // a metadata status of "online", but they have set themselves to "away" 138 | // on another device. In this case, they app may choose to use the "away" 139 | // status for what appears on the UI. The example below defines a `listBy` 140 | // function which prioritizes the first metadata which was registered for 141 | // each user. This could be the first tab they opened, or the first device 142 | // they came online from: 143 | // 144 | // let state = {} 145 | // state = Presence.syncState(state, stateFromServer) 146 | // let listBy = (id, {metas: [first, ...rest]}) => { 147 | // first.count = rest.length + 1 // count of this user's presences 148 | // first.id = id 149 | // return first 150 | // } 151 | // let onlineUsers = Presence.list(state, listBy) 152 | // 153 | // 154 | // ### Example Usage 155 | // 156 | // // detect if user has joined for the 1st time or from another tab/device 157 | // let onJoin = (id, current, newPres) => { 158 | // if(!current){ 159 | // console.log("user has entered for the first time", newPres) 160 | // } else { 161 | // console.log("user additional presence", newPres) 162 | // } 163 | // } 164 | // // detect if user has left from all tabs/devices, or is still present 165 | // let onLeave = (id, current, leftPres) => { 166 | // if(current.metas.length === 0){ 167 | // console.log("user has left from all devices", leftPres) 168 | // } else { 169 | // console.log("user left from a device", leftPres) 170 | // } 171 | // } 172 | // let presences = {} // client's initial empty presence state 173 | // // receive initial presence data from server, sent after join 174 | // myChannel.on("presences", state => { 175 | // presences = Presence.syncState(presences, state, onJoin, onLeave) 176 | // displayUsers(Presence.list(presences)) 177 | // }) 178 | // // receive "presence_diff" from server, containing join/leave events 179 | // myChannel.on("presence_diff", diff => { 180 | // presences = Presence.syncDiff(presences, diff, onJoin, onLeave) 181 | // this.setState({users: Presence.list(room.presences, listBy)}) 182 | // }) 183 | // 184 | var VSN = "1.0.0"; 185 | var SOCKET_STATES = { connecting: 0, open: 1, closing: 2, closed: 3 }; 186 | var DEFAULT_TIMEOUT = 10000; 187 | var CHANNEL_STATES = { 188 | closed: "closed", 189 | errored: "errored", 190 | joined: "joined", 191 | joining: "joining", 192 | leaving: "leaving" 193 | }; 194 | var CHANNEL_EVENTS = { 195 | close: "phx_close", 196 | error: "phx_error", 197 | join: "phx_join", 198 | reply: "phx_reply", 199 | leave: "phx_leave" 200 | }; 201 | var TRANSPORTS = { 202 | longpoll: "longpoll", 203 | websocket: "websocket" 204 | }; 205 | 206 | var Push = function () { 207 | 208 | // Initializes the Push 209 | // 210 | // channel - The Channel 211 | // event - The event, for example `"phx_join"` 212 | // payload - The payload, for example `{user_id: 123}` 213 | // timeout - The push timeout in milliseconds 214 | // 215 | 216 | function Push(channel, event, payload, timeout) { 217 | _classCallCheck(this, Push); 218 | 219 | this.channel = channel; 220 | this.event = event; 221 | this.payload = payload || {}; 222 | this.receivedResp = null; 223 | this.timeout = timeout; 224 | this.timeoutTimer = null; 225 | this.recHooks = []; 226 | this.sent = false; 227 | } 228 | 229 | _createClass(Push, [{ 230 | key: "resend", 231 | value: function resend(timeout) { 232 | this.timeout = timeout; 233 | this.cancelRefEvent(); 234 | this.ref = null; 235 | this.refEvent = null; 236 | this.receivedResp = null; 237 | this.sent = false; 238 | this.send(); 239 | } 240 | }, { 241 | key: "send", 242 | value: function send() { 243 | if (this.hasReceived("timeout")) { 244 | return; 245 | } 246 | this.startTimeout(); 247 | this.sent = true; 248 | this.channel.socket.push({ 249 | topic: this.channel.topic, 250 | event: this.event, 251 | payload: this.payload, 252 | ref: this.ref 253 | }); 254 | } 255 | }, { 256 | key: "receive", 257 | value: function receive(status, callback) { 258 | if (this.hasReceived(status)) { 259 | callback(this.receivedResp.response); 260 | } 261 | 262 | this.recHooks.push({ status: status, callback: callback }); 263 | return this; 264 | } 265 | 266 | // private 267 | 268 | }, { 269 | key: "matchReceive", 270 | value: function matchReceive(_ref) { 271 | var status = _ref.status; 272 | var response = _ref.response; 273 | var ref = _ref.ref; 274 | 275 | this.recHooks.filter(function (h) { 276 | return h.status === status; 277 | }).forEach(function (h) { 278 | return h.callback(response); 279 | }); 280 | } 281 | }, { 282 | key: "cancelRefEvent", 283 | value: function cancelRefEvent() { 284 | if (!this.refEvent) { 285 | return; 286 | } 287 | this.channel.off(this.refEvent); 288 | } 289 | }, { 290 | key: "cancelTimeout", 291 | value: function cancelTimeout() { 292 | clearTimeout(this.timeoutTimer); 293 | this.timeoutTimer = null; 294 | } 295 | }, { 296 | key: "startTimeout", 297 | value: function startTimeout() { 298 | var _this = this; 299 | 300 | if (this.timeoutTimer) { 301 | return; 302 | } 303 | this.ref = this.channel.socket.makeRef(); 304 | this.refEvent = this.channel.replyEventName(this.ref); 305 | 306 | this.channel.on(this.refEvent, function (payload) { 307 | _this.cancelRefEvent(); 308 | _this.cancelTimeout(); 309 | _this.receivedResp = payload; 310 | _this.matchReceive(payload); 311 | }); 312 | 313 | this.timeoutTimer = setTimeout(function () { 314 | _this.trigger("timeout", {}); 315 | }, this.timeout); 316 | } 317 | }, { 318 | key: "hasReceived", 319 | value: function hasReceived(status) { 320 | return this.receivedResp && this.receivedResp.status === status; 321 | } 322 | }, { 323 | key: "trigger", 324 | value: function trigger(status, response) { 325 | this.channel.trigger(this.refEvent, { status: status, response: response }); 326 | } 327 | }]); 328 | 329 | return Push; 330 | }(); 331 | 332 | var Channel = exports.Channel = function () { 333 | function Channel(topic, params, socket) { 334 | var _this2 = this; 335 | 336 | _classCallCheck(this, Channel); 337 | 338 | this.state = CHANNEL_STATES.closed; 339 | this.topic = topic; 340 | this.params = params || {}; 341 | this.socket = socket; 342 | this.bindings = []; 343 | this.timeout = this.socket.timeout; 344 | this.joinedOnce = false; 345 | this.joinPush = new Push(this, CHANNEL_EVENTS.join, this.params, this.timeout); 346 | this.pushBuffer = []; 347 | this.rejoinTimer = new Timer(function () { 348 | return _this2.rejoinUntilConnected(); 349 | }, this.socket.reconnectAfterMs); 350 | this.joinPush.receive("ok", function () { 351 | _this2.state = CHANNEL_STATES.joined; 352 | _this2.rejoinTimer.reset(); 353 | _this2.pushBuffer.forEach(function (pushEvent) { 354 | return pushEvent.send(); 355 | }); 356 | _this2.pushBuffer = []; 357 | }); 358 | this.onClose(function () { 359 | _this2.rejoinTimer.reset(); 360 | _this2.socket.log("channel", "close " + _this2.topic + " " + _this2.joinRef()); 361 | _this2.state = CHANNEL_STATES.closed; 362 | _this2.socket.remove(_this2); 363 | }); 364 | this.onError(function (reason) { 365 | if (_this2.isLeaving() || _this2.isClosed()) { 366 | return; 367 | } 368 | _this2.socket.log("channel", "error " + _this2.topic, reason); 369 | _this2.state = CHANNEL_STATES.errored; 370 | _this2.rejoinTimer.scheduleTimeout(); 371 | }); 372 | this.joinPush.receive("timeout", function () { 373 | if (!_this2.isJoining()) { 374 | return; 375 | } 376 | _this2.socket.log("channel", "timeout " + _this2.topic, _this2.joinPush.timeout); 377 | _this2.state = CHANNEL_STATES.errored; 378 | _this2.rejoinTimer.scheduleTimeout(); 379 | }); 380 | this.on(CHANNEL_EVENTS.reply, function (payload, ref) { 381 | _this2.trigger(_this2.replyEventName(ref), payload); 382 | }); 383 | } 384 | 385 | _createClass(Channel, [{ 386 | key: "rejoinUntilConnected", 387 | value: function rejoinUntilConnected() { 388 | this.rejoinTimer.scheduleTimeout(); 389 | if (this.socket.isConnected()) { 390 | this.rejoin(); 391 | } 392 | } 393 | }, { 394 | key: "join", 395 | value: function join() { 396 | var timeout = arguments.length <= 0 || arguments[0] === undefined ? this.timeout : arguments[0]; 397 | 398 | if (this.joinedOnce) { 399 | throw "tried to join multiple times. 'join' can only be called a single time per channel instance"; 400 | } else { 401 | this.joinedOnce = true; 402 | this.rejoin(timeout); 403 | return this.joinPush; 404 | } 405 | } 406 | }, { 407 | key: "onClose", 408 | value: function onClose(callback) { 409 | this.on(CHANNEL_EVENTS.close, callback); 410 | } 411 | }, { 412 | key: "onError", 413 | value: function onError(callback) { 414 | this.on(CHANNEL_EVENTS.error, function (reason) { 415 | return callback(reason); 416 | }); 417 | } 418 | }, { 419 | key: "on", 420 | value: function on(event, callback) { 421 | this.bindings.push({ event: event, callback: callback }); 422 | } 423 | }, { 424 | key: "off", 425 | value: function off(event) { 426 | this.bindings = this.bindings.filter(function (bind) { 427 | return bind.event !== event; 428 | }); 429 | } 430 | }, { 431 | key: "canPush", 432 | value: function canPush() { 433 | return this.socket.isConnected() && this.isJoined(); 434 | } 435 | }, { 436 | key: "push", 437 | value: function push(event, payload) { 438 | var timeout = arguments.length <= 2 || arguments[2] === undefined ? this.timeout : arguments[2]; 439 | 440 | if (!this.joinedOnce) { 441 | throw "tried to push '" + event + "' to '" + this.topic + "' before joining. Use channel.join() before pushing events"; 442 | } 443 | var pushEvent = new Push(this, event, payload, timeout); 444 | if (this.canPush()) { 445 | pushEvent.send(); 446 | } else { 447 | pushEvent.startTimeout(); 448 | this.pushBuffer.push(pushEvent); 449 | } 450 | 451 | return pushEvent; 452 | } 453 | 454 | // Leaves the channel 455 | // 456 | // Unsubscribes from server events, and 457 | // instructs channel to terminate on server 458 | // 459 | // Triggers onClose() hooks 460 | // 461 | // To receive leave acknowledgements, use the a `receive` 462 | // hook to bind to the server ack, ie: 463 | // 464 | // channel.leave().receive("ok", () => alert("left!") ) 465 | // 466 | 467 | }, { 468 | key: "leave", 469 | value: function leave() { 470 | var _this3 = this; 471 | 472 | var timeout = arguments.length <= 0 || arguments[0] === undefined ? this.timeout : arguments[0]; 473 | 474 | this.state = CHANNEL_STATES.leaving; 475 | var onClose = function onClose() { 476 | _this3.socket.log("channel", "leave " + _this3.topic); 477 | _this3.trigger(CHANNEL_EVENTS.close, "leave", _this3.joinRef()); 478 | }; 479 | var leavePush = new Push(this, CHANNEL_EVENTS.leave, {}, timeout); 480 | leavePush.receive("ok", function () { 481 | return onClose(); 482 | }).receive("timeout", function () { 483 | return onClose(); 484 | }); 485 | leavePush.send(); 486 | if (!this.canPush()) { 487 | leavePush.trigger("ok", {}); 488 | } 489 | 490 | return leavePush; 491 | } 492 | 493 | // Overridable message hook 494 | // 495 | // Receives all events for specialized message handling 496 | // before dispatching to the channel callbacks. 497 | // 498 | // Must return the payload, modified or unmodified 499 | 500 | }, { 501 | key: "onMessage", 502 | value: function onMessage(event, payload, ref) { 503 | return payload; 504 | } 505 | 506 | // private 507 | 508 | }, { 509 | key: "isMember", 510 | value: function isMember(topic) { 511 | return this.topic === topic; 512 | } 513 | }, { 514 | key: "joinRef", 515 | value: function joinRef() { 516 | return this.joinPush.ref; 517 | } 518 | }, { 519 | key: "sendJoin", 520 | value: function sendJoin(timeout) { 521 | this.state = CHANNEL_STATES.joining; 522 | this.joinPush.resend(timeout); 523 | } 524 | }, { 525 | key: "rejoin", 526 | value: function rejoin() { 527 | var timeout = arguments.length <= 0 || arguments[0] === undefined ? this.timeout : arguments[0]; 528 | if (this.isLeaving()) { 529 | return; 530 | } 531 | this.sendJoin(timeout); 532 | } 533 | }, { 534 | key: "trigger", 535 | value: function trigger(event, payload, ref) { 536 | var close = CHANNEL_EVENTS.close; 537 | var error = CHANNEL_EVENTS.error; 538 | var leave = CHANNEL_EVENTS.leave; 539 | var join = CHANNEL_EVENTS.join; 540 | 541 | if (ref && [close, error, leave, join].indexOf(event) >= 0 && ref !== this.joinRef()) { 542 | return; 543 | } 544 | var handledPayload = this.onMessage(event, payload, ref); 545 | if (payload && !handledPayload) { 546 | throw "channel onMessage callbacks must return the payload, modified or unmodified"; 547 | } 548 | 549 | this.bindings.filter(function (bind) { 550 | return bind.event === event; 551 | }).map(function (bind) { 552 | return bind.callback(handledPayload, ref); 553 | }); 554 | } 555 | }, { 556 | key: "replyEventName", 557 | value: function replyEventName(ref) { 558 | return "chan_reply_" + ref; 559 | } 560 | }, { 561 | key: "isClosed", 562 | value: function isClosed() { 563 | return this.state === CHANNEL_STATES.closed; 564 | } 565 | }, { 566 | key: "isErrored", 567 | value: function isErrored() { 568 | return this.state === CHANNEL_STATES.errored; 569 | } 570 | }, { 571 | key: "isJoined", 572 | value: function isJoined() { 573 | return this.state === CHANNEL_STATES.joined; 574 | } 575 | }, { 576 | key: "isJoining", 577 | value: function isJoining() { 578 | return this.state === CHANNEL_STATES.joining; 579 | } 580 | }, { 581 | key: "isLeaving", 582 | value: function isLeaving() { 583 | return this.state === CHANNEL_STATES.leaving; 584 | } 585 | }]); 586 | 587 | return Channel; 588 | }(); 589 | 590 | var Socket = exports.Socket = function () { 591 | 592 | // Initializes the Socket 593 | // 594 | // endPoint - The string WebSocket endpoint, ie, "ws://example.com/ws", 595 | // "wss://example.com" 596 | // "/ws" (inherited host & protocol) 597 | // opts - Optional configuration 598 | // transport - The Websocket Transport, for example WebSocket or Phoenix.LongPoll. 599 | // Defaults to WebSocket with automatic LongPoll fallback. 600 | // timeout - The default timeout in milliseconds to trigger push timeouts. 601 | // Defaults `DEFAULT_TIMEOUT` 602 | // heartbeatIntervalMs - The millisec interval to send a heartbeat message 603 | // reconnectAfterMs - The optional function that returns the millsec 604 | // reconnect interval. Defaults to stepped backoff of: 605 | // 606 | // function(tries){ 607 | // return [1000, 5000, 10000][tries - 1] || 10000 608 | // } 609 | // 610 | // logger - The optional function for specialized logging, ie: 611 | // `logger: (kind, msg, data) => { console.log(`${kind}: ${msg}`, data) } 612 | // 613 | // longpollerTimeout - The maximum timeout of a long poll AJAX request. 614 | // Defaults to 20s (double the server long poll timer). 615 | // 616 | // params - The optional params to pass when connecting 617 | // 618 | // For IE8 support use an ES5-shim (https://github.com/es-shims/es5-shim) 619 | // 620 | 621 | function Socket(endPoint) { 622 | var _this4 = this; 623 | 624 | var opts = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; 625 | 626 | _classCallCheck(this, Socket); 627 | 628 | this.stateChangeCallbacks = { open: [], close: [], error: [], message: [] }; 629 | this.channels = []; 630 | this.sendBuffer = []; 631 | this.ref = 0; 632 | this.timeout = opts.timeout || DEFAULT_TIMEOUT; 633 | this.transport = opts.transport || window.WebSocket || LongPoll; 634 | this.heartbeatIntervalMs = opts.heartbeatIntervalMs || 30000; 635 | this.reconnectAfterMs = opts.reconnectAfterMs || function (tries) { 636 | return [1000, 2000, 5000, 10000][tries - 1] || 10000; 637 | }; 638 | this.logger = opts.logger || function () {}; // noop 639 | this.longpollerTimeout = opts.longpollerTimeout || 20000; 640 | this.params = opts.params || {}; 641 | this.endPoint = endPoint + "/" + TRANSPORTS.websocket; 642 | this.reconnectTimer = new Timer(function () { 643 | _this4.disconnect(function () { 644 | return _this4.connect(); 645 | }); 646 | }, this.reconnectAfterMs); 647 | } 648 | 649 | _createClass(Socket, [{ 650 | key: "protocol", 651 | value: function protocol() { 652 | return location.protocol.match(/^https/) ? "wss" : "ws"; 653 | } 654 | }, { 655 | key: "endPointURL", 656 | value: function endPointURL() { 657 | var uri = Ajax.appendParams(Ajax.appendParams(this.endPoint, this.params), { vsn: VSN }); 658 | if (uri.charAt(0) !== "/") { 659 | return uri; 660 | } 661 | if (uri.charAt(1) === "/") { 662 | return this.protocol() + ":" + uri; 663 | } 664 | 665 | return this.protocol() + "://" + location.host + uri; 666 | } 667 | }, { 668 | key: "disconnect", 669 | value: function disconnect(callback, code, reason) { 670 | if (this.conn) { 671 | this.conn.onclose = function () {}; // noop 672 | if (code) { 673 | this.conn.close(code, reason || ""); 674 | } else { 675 | this.conn.close(); 676 | } 677 | this.conn = null; 678 | } 679 | callback && callback(); 680 | } 681 | 682 | // params - The params to send when connecting, for example `{user_id: userToken}` 683 | 684 | }, { 685 | key: "connect", 686 | value: function connect(params) { 687 | var _this5 = this; 688 | 689 | if (params) { 690 | console && console.log("passing params to connect is deprecated. Instead pass :params to the Socket constructor"); 691 | this.params = params; 692 | } 693 | if (this.conn) { 694 | return; 695 | } 696 | 697 | this.conn = new this.transport(this.endPointURL()); 698 | this.conn.timeout = this.longpollerTimeout; 699 | this.conn.onopen = function () { 700 | return _this5.onConnOpen(); 701 | }; 702 | this.conn.onerror = function (error) { 703 | return _this5.onConnError(error); 704 | }; 705 | this.conn.onmessage = function (event) { 706 | return _this5.onConnMessage(event); 707 | }; 708 | this.conn.onclose = function (event) { 709 | return _this5.onConnClose(event); 710 | }; 711 | } 712 | 713 | // Logs the message. Override `this.logger` for specialized logging. noops by default 714 | 715 | }, { 716 | key: "log", 717 | value: function log(kind, msg, data) { 718 | this.logger(kind, msg, data); 719 | } 720 | 721 | // Registers callbacks for connection state change events 722 | // 723 | // Examples 724 | // 725 | // socket.onError(function(error){ alert("An error occurred") }) 726 | // 727 | 728 | }, { 729 | key: "onOpen", 730 | value: function onOpen(callback) { 731 | this.stateChangeCallbacks.open.push(callback); 732 | } 733 | }, { 734 | key: "onClose", 735 | value: function onClose(callback) { 736 | this.stateChangeCallbacks.close.push(callback); 737 | } 738 | }, { 739 | key: "onError", 740 | value: function onError(callback) { 741 | this.stateChangeCallbacks.error.push(callback); 742 | } 743 | }, { 744 | key: "onMessage", 745 | value: function onMessage(callback) { 746 | this.stateChangeCallbacks.message.push(callback); 747 | } 748 | }, { 749 | key: "onConnOpen", 750 | value: function onConnOpen() { 751 | var _this6 = this; 752 | 753 | this.log("transport", "connected to " + this.endPointURL(), this.transport.prototype); 754 | this.flushSendBuffer(); 755 | this.reconnectTimer.reset(); 756 | if (!this.conn.skipHeartbeat) { 757 | clearInterval(this.heartbeatTimer); 758 | this.heartbeatTimer = setInterval(function () { 759 | return _this6.sendHeartbeat(); 760 | }, this.heartbeatIntervalMs); 761 | } 762 | this.stateChangeCallbacks.open.forEach(function (callback) { 763 | return callback(); 764 | }); 765 | } 766 | }, { 767 | key: "onConnClose", 768 | value: function onConnClose(event) { 769 | this.log("transport", "close", event); 770 | this.triggerChanError(); 771 | clearInterval(this.heartbeatTimer); 772 | this.reconnectTimer.scheduleTimeout(); 773 | this.stateChangeCallbacks.close.forEach(function (callback) { 774 | return callback(event); 775 | }); 776 | } 777 | }, { 778 | key: "onConnError", 779 | value: function onConnError(error) { 780 | this.log("transport", error); 781 | this.triggerChanError(); 782 | this.stateChangeCallbacks.error.forEach(function (callback) { 783 | return callback(error); 784 | }); 785 | } 786 | }, { 787 | key: "triggerChanError", 788 | value: function triggerChanError() { 789 | this.channels.forEach(function (channel) { 790 | return channel.trigger(CHANNEL_EVENTS.error); 791 | }); 792 | } 793 | }, { 794 | key: "connectionState", 795 | value: function connectionState() { 796 | switch (this.conn && this.conn.readyState) { 797 | case SOCKET_STATES.connecting: 798 | return "connecting"; 799 | case SOCKET_STATES.open: 800 | return "open"; 801 | case SOCKET_STATES.closing: 802 | return "closing"; 803 | default: 804 | return "closed"; 805 | } 806 | } 807 | }, { 808 | key: "isConnected", 809 | value: function isConnected() { 810 | return this.connectionState() === "open"; 811 | } 812 | }, { 813 | key: "remove", 814 | value: function remove(channel) { 815 | this.channels = this.channels.filter(function (c) { 816 | return c.joinRef() !== channel.joinRef(); 817 | }); 818 | } 819 | }, { 820 | key: "channel", 821 | value: function channel(topic) { 822 | var chanParams = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; 823 | 824 | var chan = new Channel(topic, chanParams, this); 825 | this.channels.push(chan); 826 | return chan; 827 | } 828 | }, { 829 | key: "push", 830 | value: function push(data) { 831 | var _this7 = this; 832 | 833 | var topic = data.topic; 834 | var event = data.event; 835 | var payload = data.payload; 836 | var ref = data.ref; 837 | 838 | var callback = function callback() { 839 | return _this7.conn.send(JSON.stringify(data)); 840 | }; 841 | this.log("push", topic + " " + event + " (" + ref + ")", payload); 842 | if (this.isConnected()) { 843 | callback(); 844 | } else { 845 | this.sendBuffer.push(callback); 846 | } 847 | } 848 | 849 | // Return the next message ref, accounting for overflows 850 | 851 | }, { 852 | key: "makeRef", 853 | value: function makeRef() { 854 | var newRef = this.ref + 1; 855 | if (newRef === this.ref) { 856 | this.ref = 0; 857 | } else { 858 | this.ref = newRef; 859 | } 860 | 861 | return this.ref.toString(); 862 | } 863 | }, { 864 | key: "sendHeartbeat", 865 | value: function sendHeartbeat() { 866 | if (!this.isConnected()) { 867 | return; 868 | } 869 | this.push({ topic: "phoenix", event: "heartbeat", payload: {}, ref: this.makeRef() }); 870 | } 871 | }, { 872 | key: "flushSendBuffer", 873 | value: function flushSendBuffer() { 874 | if (this.isConnected() && this.sendBuffer.length > 0) { 875 | this.sendBuffer.forEach(function (callback) { 876 | return callback(); 877 | }); 878 | this.sendBuffer = []; 879 | } 880 | } 881 | }, { 882 | key: "onConnMessage", 883 | value: function onConnMessage(rawMessage) { 884 | var msg = JSON.parse(rawMessage.data); 885 | var topic = msg.topic; 886 | var event = msg.event; 887 | var payload = msg.payload; 888 | var ref = msg.ref; 889 | 890 | this.log("receive", (payload.status || "") + " " + topic + " " + event + " " + (ref && "(" + ref + ")" || ""), payload); 891 | this.channels.filter(function (channel) { 892 | return channel.isMember(topic); 893 | }).forEach(function (channel) { 894 | return channel.trigger(event, payload, ref); 895 | }); 896 | this.stateChangeCallbacks.message.forEach(function (callback) { 897 | return callback(msg); 898 | }); 899 | } 900 | }]); 901 | 902 | return Socket; 903 | }(); 904 | 905 | var LongPoll = exports.LongPoll = function () { 906 | function LongPoll(endPoint) { 907 | _classCallCheck(this, LongPoll); 908 | 909 | this.endPoint = null; 910 | this.token = null; 911 | this.skipHeartbeat = true; 912 | this.onopen = function () {}; // noop 913 | this.onerror = function () {}; // noop 914 | this.onmessage = function () {}; // noop 915 | this.onclose = function () {}; // noop 916 | this.pollEndpoint = this.normalizeEndpoint(endPoint); 917 | this.readyState = SOCKET_STATES.connecting; 918 | 919 | this.poll(); 920 | } 921 | 922 | _createClass(LongPoll, [{ 923 | key: "normalizeEndpoint", 924 | value: function normalizeEndpoint(endPoint) { 925 | return endPoint.replace("ws://", "http://").replace("wss://", "https://").replace(new RegExp("(.*)\/" + TRANSPORTS.websocket), "$1/" + TRANSPORTS.longpoll); 926 | } 927 | }, { 928 | key: "endpointURL", 929 | value: function endpointURL() { 930 | return Ajax.appendParams(this.pollEndpoint, { token: this.token }); 931 | } 932 | }, { 933 | key: "closeAndRetry", 934 | value: function closeAndRetry() { 935 | this.close(); 936 | this.readyState = SOCKET_STATES.connecting; 937 | } 938 | }, { 939 | key: "ontimeout", 940 | value: function ontimeout() { 941 | this.onerror("timeout"); 942 | this.closeAndRetry(); 943 | } 944 | }, { 945 | key: "poll", 946 | value: function poll() { 947 | var _this8 = this; 948 | 949 | if (!(this.readyState === SOCKET_STATES.open || this.readyState === SOCKET_STATES.connecting)) { 950 | return; 951 | } 952 | 953 | Ajax.request("GET", this.endpointURL(), "application/json", null, this.timeout, this.ontimeout.bind(this), function (resp) { 954 | if (resp) { 955 | var status = resp.status; 956 | var token = resp.token; 957 | var messages = resp.messages; 958 | 959 | _this8.token = token; 960 | } else { 961 | var status = 0; 962 | } 963 | 964 | switch (status) { 965 | case 200: 966 | messages.forEach(function (msg) { 967 | return _this8.onmessage({ data: JSON.stringify(msg) }); 968 | }); 969 | _this8.poll(); 970 | break; 971 | case 204: 972 | _this8.poll(); 973 | break; 974 | case 410: 975 | _this8.readyState = SOCKET_STATES.open; 976 | _this8.onopen(); 977 | _this8.poll(); 978 | break; 979 | case 0: 980 | case 500: 981 | _this8.onerror(); 982 | _this8.closeAndRetry(); 983 | break; 984 | default: 985 | throw "unhandled poll status " + status; 986 | } 987 | }); 988 | } 989 | }, { 990 | key: "send", 991 | value: function send(body) { 992 | var _this9 = this; 993 | 994 | Ajax.request("POST", this.endpointURL(), "application/json", body, this.timeout, this.onerror.bind(this, "timeout"), function (resp) { 995 | if (!resp || resp.status !== 200) { 996 | _this9.onerror(status); 997 | _this9.closeAndRetry(); 998 | } 999 | }); 1000 | } 1001 | }, { 1002 | key: "close", 1003 | value: function close(code, reason) { 1004 | this.readyState = SOCKET_STATES.closed; 1005 | this.onclose(); 1006 | } 1007 | }]); 1008 | 1009 | return LongPoll; 1010 | }(); 1011 | 1012 | var Ajax = exports.Ajax = function () { 1013 | function Ajax() { 1014 | _classCallCheck(this, Ajax); 1015 | } 1016 | 1017 | _createClass(Ajax, null, [{ 1018 | key: "request", 1019 | value: function request(method, endPoint, accept, body, timeout, ontimeout, callback) { 1020 | if (window.XDomainRequest) { 1021 | var req = new XDomainRequest(); // IE8, IE9 1022 | this.xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback); 1023 | } else { 1024 | var req = window.XMLHttpRequest ? new XMLHttpRequest() : // IE7+, Firefox, Chrome, Opera, Safari 1025 | new ActiveXObject("Microsoft.XMLHTTP"); // IE6, IE5 1026 | this.xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback); 1027 | } 1028 | } 1029 | }, { 1030 | key: "xdomainRequest", 1031 | value: function xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback) { 1032 | var _this10 = this; 1033 | 1034 | req.timeout = timeout; 1035 | req.open(method, endPoint); 1036 | req.onload = function () { 1037 | var response = _this10.parseJSON(req.responseText); 1038 | callback && callback(response); 1039 | }; 1040 | if (ontimeout) { 1041 | req.ontimeout = ontimeout; 1042 | } 1043 | 1044 | // Work around bug in IE9 that requires an attached onprogress handler 1045 | req.onprogress = function () {}; 1046 | 1047 | req.send(body); 1048 | } 1049 | }, { 1050 | key: "xhrRequest", 1051 | value: function xhrRequest(req, method, endPoint, accept, body, timeout, ontimeout, callback) { 1052 | var _this11 = this; 1053 | 1054 | req.timeout = timeout; 1055 | req.open(method, endPoint, true); 1056 | req.setRequestHeader("Content-Type", accept); 1057 | req.onerror = function () { 1058 | callback && callback(null); 1059 | }; 1060 | req.onreadystatechange = function () { 1061 | if (req.readyState === _this11.states.complete && callback) { 1062 | var response = _this11.parseJSON(req.responseText); 1063 | callback(response); 1064 | } 1065 | }; 1066 | if (ontimeout) { 1067 | req.ontimeout = ontimeout; 1068 | } 1069 | 1070 | req.send(body); 1071 | } 1072 | }, { 1073 | key: "parseJSON", 1074 | value: function parseJSON(resp) { 1075 | return resp && resp !== "" ? JSON.parse(resp) : null; 1076 | } 1077 | }, { 1078 | key: "serialize", 1079 | value: function serialize(obj, parentKey) { 1080 | var queryStr = []; 1081 | for (var key in obj) { 1082 | if (!obj.hasOwnProperty(key)) { 1083 | continue; 1084 | } 1085 | var paramKey = parentKey ? parentKey + "[" + key + "]" : key; 1086 | var paramVal = obj[key]; 1087 | if ((typeof paramVal === "undefined" ? "undefined" : _typeof(paramVal)) === "object") { 1088 | queryStr.push(this.serialize(paramVal, paramKey)); 1089 | } else { 1090 | queryStr.push(encodeURIComponent(paramKey) + "=" + encodeURIComponent(paramVal)); 1091 | } 1092 | } 1093 | return queryStr.join("&"); 1094 | } 1095 | }, { 1096 | key: "appendParams", 1097 | value: function appendParams(url, params) { 1098 | if (Object.keys(params).length === 0) { 1099 | return url; 1100 | } 1101 | 1102 | var prefix = url.match(/\?/) ? "&" : "?"; 1103 | return "" + url + prefix + this.serialize(params); 1104 | } 1105 | }]); 1106 | 1107 | return Ajax; 1108 | }(); 1109 | 1110 | Ajax.states = { complete: 4 }; 1111 | 1112 | var Presence = exports.Presence = { 1113 | syncState: function syncState(currentState, newState, onJoin, onLeave) { 1114 | var _this12 = this; 1115 | 1116 | var state = this.clone(currentState); 1117 | var joins = {}; 1118 | var leaves = {}; 1119 | 1120 | this.map(state, function (key, presence) { 1121 | if (!newState[key]) { 1122 | leaves[key] = presence; 1123 | } 1124 | }); 1125 | this.map(newState, function (key, newPresence) { 1126 | var currentPresence = state[key]; 1127 | if (currentPresence) { 1128 | (function () { 1129 | var newRefs = newPresence.metas.map(function (m) { 1130 | return m.phx_ref; 1131 | }); 1132 | var curRefs = currentPresence.metas.map(function (m) { 1133 | return m.phx_ref; 1134 | }); 1135 | var joinedMetas = newPresence.metas.filter(function (m) { 1136 | return curRefs.indexOf(m.phx_ref) < 0; 1137 | }); 1138 | var leftMetas = currentPresence.metas.filter(function (m) { 1139 | return newRefs.indexOf(m.phx_ref) < 0; 1140 | }); 1141 | if (joinedMetas.length > 0) { 1142 | joins[key] = newPresence; 1143 | joins[key].metas = joinedMetas; 1144 | } 1145 | if (leftMetas.length > 0) { 1146 | leaves[key] = _this12.clone(currentPresence); 1147 | leaves[key].metas = leftMetas; 1148 | } 1149 | })(); 1150 | } else { 1151 | joins[key] = newPresence; 1152 | } 1153 | }); 1154 | return this.syncDiff(state, { joins: joins, leaves: leaves }, onJoin, onLeave); 1155 | }, 1156 | syncDiff: function syncDiff(currentState, _ref2, onJoin, onLeave) { 1157 | var joins = _ref2.joins; 1158 | var leaves = _ref2.leaves; 1159 | 1160 | var state = this.clone(currentState); 1161 | if (!onJoin) { 1162 | onJoin = function onJoin() {}; 1163 | } 1164 | if (!onLeave) { 1165 | onLeave = function onLeave() {}; 1166 | } 1167 | 1168 | this.map(joins, function (key, newPresence) { 1169 | var currentPresence = state[key]; 1170 | state[key] = newPresence; 1171 | if (currentPresence) { 1172 | var _state$key$metas; 1173 | 1174 | (_state$key$metas = state[key].metas).unshift.apply(_state$key$metas, _toConsumableArray(currentPresence.metas)); 1175 | } 1176 | onJoin(key, currentPresence, newPresence); 1177 | }); 1178 | this.map(leaves, function (key, leftPresence) { 1179 | var currentPresence = state[key]; 1180 | if (!currentPresence) { 1181 | return; 1182 | } 1183 | var refsToRemove = leftPresence.metas.map(function (m) { 1184 | return m.phx_ref; 1185 | }); 1186 | currentPresence.metas = currentPresence.metas.filter(function (p) { 1187 | return refsToRemove.indexOf(p.phx_ref) < 0; 1188 | }); 1189 | onLeave(key, currentPresence, leftPresence); 1190 | if (currentPresence.metas.length === 0) { 1191 | delete state[key]; 1192 | } 1193 | }); 1194 | return state; 1195 | }, 1196 | list: function list(presences, chooser) { 1197 | if (!chooser) { 1198 | chooser = function chooser(key, pres) { 1199 | return pres; 1200 | }; 1201 | } 1202 | 1203 | return this.map(presences, function (key, presence) { 1204 | return chooser(key, presence); 1205 | }); 1206 | }, 1207 | 1208 | // private 1209 | 1210 | map: function map(obj, func) { 1211 | return Object.getOwnPropertyNames(obj).map(function (key) { 1212 | return func(key, obj[key]); 1213 | }); 1214 | }, 1215 | clone: function clone(obj) { 1216 | return JSON.parse(JSON.stringify(obj)); 1217 | } 1218 | }; 1219 | 1220 | // Creates a timer that accepts a `timerCalc` function to perform 1221 | // calculated timeout retries, such as exponential backoff. 1222 | // 1223 | // ## Examples 1224 | // 1225 | // let reconnectTimer = new Timer(() => this.connect(), function(tries){ 1226 | // return [1000, 5000, 10000][tries - 1] || 10000 1227 | // }) 1228 | // reconnectTimer.scheduleTimeout() // fires after 1000 1229 | // reconnectTimer.scheduleTimeout() // fires after 5000 1230 | // reconnectTimer.reset() 1231 | // reconnectTimer.scheduleTimeout() // fires after 1000 1232 | // 1233 | 1234 | var Timer = function () { 1235 | function Timer(callback, timerCalc) { 1236 | _classCallCheck(this, Timer); 1237 | 1238 | this.callback = callback; 1239 | this.timerCalc = timerCalc; 1240 | this.timer = null; 1241 | this.tries = 0; 1242 | } 1243 | 1244 | _createClass(Timer, [{ 1245 | key: "reset", 1246 | value: function reset() { 1247 | this.tries = 0; 1248 | clearTimeout(this.timer); 1249 | } 1250 | 1251 | // Cancels any previous scheduleTimeout and schedules callback 1252 | 1253 | }, { 1254 | key: "scheduleTimeout", 1255 | value: function scheduleTimeout() { 1256 | var _this13 = this; 1257 | 1258 | clearTimeout(this.timer); 1259 | 1260 | this.timer = setTimeout(function () { 1261 | _this13.tries = _this13.tries + 1; 1262 | _this13.callback(); 1263 | }, this.timerCalc(this.tries + 1)); 1264 | } 1265 | }]); 1266 | 1267 | return Timer; 1268 | }(); 1269 | 1270 | })(typeof(exports) === "undefined" ? window.Phoenix = window.Phoenix || {} : exports); 1271 | 1272 | -------------------------------------------------------------------------------- /api/priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /api/test/controllers/room_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sling.RoomControllerTest do 2 | use Sling.ConnCase 3 | 4 | alias Sling.Room 5 | @valid_attrs %{name: "some content", topic: "some content"} 6 | @invalid_attrs %{} 7 | 8 | setup %{conn: conn} do 9 | {:ok, conn: put_req_header(conn, "accept", "application/json")} 10 | end 11 | 12 | test "lists all entries on index", %{conn: conn} do 13 | conn = get conn, room_path(conn, :index) 14 | assert json_response(conn, 200)["data"] == [] 15 | end 16 | 17 | test "shows chosen resource", %{conn: conn} do 18 | room = Repo.insert! %Room{} 19 | conn = get conn, room_path(conn, :show, room) 20 | assert json_response(conn, 200)["data"] == %{"id" => room.id, 21 | "name" => room.name, 22 | "topic" => room.topic} 23 | end 24 | 25 | test "renders page not found when id is nonexistent", %{conn: conn} do 26 | assert_error_sent 404, fn -> 27 | get conn, room_path(conn, :show, -1) 28 | end 29 | end 30 | 31 | test "creates and renders resource when data is valid", %{conn: conn} do 32 | conn = post conn, room_path(conn, :create), room: @valid_attrs 33 | assert json_response(conn, 201)["data"]["id"] 34 | assert Repo.get_by(Room, @valid_attrs) 35 | end 36 | 37 | test "does not create resource and renders errors when data is invalid", %{conn: conn} do 38 | conn = post conn, room_path(conn, :create), room: @invalid_attrs 39 | assert json_response(conn, 422)["errors"] != %{} 40 | end 41 | 42 | test "updates and renders chosen resource when data is valid", %{conn: conn} do 43 | room = Repo.insert! %Room{} 44 | conn = put conn, room_path(conn, :update, room), room: @valid_attrs 45 | assert json_response(conn, 200)["data"]["id"] 46 | assert Repo.get_by(Room, @valid_attrs) 47 | end 48 | 49 | test "does not update chosen resource and renders errors when data is invalid", %{conn: conn} do 50 | room = Repo.insert! %Room{} 51 | conn = put conn, room_path(conn, :update, room), room: @invalid_attrs 52 | assert json_response(conn, 422)["errors"] != %{} 53 | end 54 | 55 | test "deletes chosen resource", %{conn: conn} do 56 | room = Repo.insert! %Room{} 57 | conn = delete conn, room_path(conn, :delete, room) 58 | assert response(conn, 204) 59 | refute Repo.get(Room, room.id) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /api/test/controllers/user_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sling.UserControllerTest do 2 | use Sling.ConnCase 3 | 4 | alias Sling.User 5 | @valid_attrs %{email: "some content", password_hash: "some content", username: "some content"} 6 | @invalid_attrs %{} 7 | 8 | setup %{conn: conn} do 9 | {:ok, conn: put_req_header(conn, "accept", "application/json")} 10 | end 11 | 12 | test "lists all entries on index", %{conn: conn} do 13 | conn = get conn, user_path(conn, :index) 14 | assert json_response(conn, 200)["data"] == [] 15 | end 16 | 17 | test "shows chosen resource", %{conn: conn} do 18 | user = Repo.insert! %User{} 19 | conn = get conn, user_path(conn, :show, user) 20 | assert json_response(conn, 200)["data"] == %{"id" => user.id, 21 | "username" => user.username, 22 | "email" => user.email, 23 | "password_hash" => user.password_hash} 24 | end 25 | 26 | test "renders page not found when id is nonexistent", %{conn: conn} do 27 | assert_error_sent 404, fn -> 28 | get conn, user_path(conn, :show, -1) 29 | end 30 | end 31 | 32 | test "creates and renders resource when data is valid", %{conn: conn} do 33 | conn = post conn, user_path(conn, :create), user: @valid_attrs 34 | assert json_response(conn, 201)["data"]["id"] 35 | assert Repo.get_by(User, @valid_attrs) 36 | end 37 | 38 | test "does not create resource and renders errors when data is invalid", %{conn: conn} do 39 | conn = post conn, user_path(conn, :create), user: @invalid_attrs 40 | assert json_response(conn, 422)["errors"] != %{} 41 | end 42 | 43 | test "updates and renders chosen resource when data is valid", %{conn: conn} do 44 | user = Repo.insert! %User{} 45 | conn = put conn, user_path(conn, :update, user), user: @valid_attrs 46 | assert json_response(conn, 200)["data"]["id"] 47 | assert Repo.get_by(User, @valid_attrs) 48 | end 49 | 50 | test "does not update chosen resource and renders errors when data is invalid", %{conn: conn} do 51 | user = Repo.insert! %User{} 52 | conn = put conn, user_path(conn, :update, user), user: @invalid_attrs 53 | assert json_response(conn, 422)["errors"] != %{} 54 | end 55 | 56 | test "deletes chosen resource", %{conn: conn} do 57 | user = Repo.insert! %User{} 58 | conn = delete conn, user_path(conn, :delete, user) 59 | assert response(conn, 204) 60 | refute Repo.get(User, user.id) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /api/test/models/message_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sling.MessageTest do 2 | use Sling.ModelCase 3 | 4 | alias Sling.Message 5 | 6 | @valid_attrs %{text: "some content"} 7 | @invalid_attrs %{} 8 | 9 | test "changeset with valid attributes" do 10 | changeset = Message.changeset(%Message{}, @valid_attrs) 11 | assert changeset.valid? 12 | end 13 | 14 | test "changeset with invalid attributes" do 15 | changeset = Message.changeset(%Message{}, @invalid_attrs) 16 | refute changeset.valid? 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /api/test/models/room_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sling.RoomTest do 2 | use Sling.ModelCase 3 | 4 | alias Sling.Room 5 | 6 | @valid_attrs %{name: "some content", topic: "some content"} 7 | @invalid_attrs %{} 8 | 9 | test "changeset with valid attributes" do 10 | changeset = Room.changeset(%Room{}, @valid_attrs) 11 | assert changeset.valid? 12 | end 13 | 14 | test "changeset with invalid attributes" do 15 | changeset = Room.changeset(%Room{}, @invalid_attrs) 16 | refute changeset.valid? 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /api/test/models/user_room_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sling.UserRoomTest do 2 | use Sling.ModelCase 3 | 4 | alias Sling.UserRoom 5 | 6 | @valid_attrs %{} 7 | @invalid_attrs %{} 8 | 9 | test "changeset with valid attributes" do 10 | changeset = UserRoom.changeset(%UserRoom{}, @valid_attrs) 11 | assert changeset.valid? 12 | end 13 | 14 | test "changeset with invalid attributes" do 15 | changeset = UserRoom.changeset(%UserRoom{}, @invalid_attrs) 16 | refute changeset.valid? 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /api/test/models/user_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sling.UserTest do 2 | use Sling.ModelCase 3 | 4 | alias Sling.User 5 | 6 | @valid_attrs %{email: "some content", password_hash: "some content", username: "some content"} 7 | @invalid_attrs %{} 8 | 9 | test "changeset with valid attributes" do 10 | changeset = User.changeset(%User{}, @valid_attrs) 11 | assert changeset.valid? 12 | end 13 | 14 | test "changeset with invalid attributes" do 15 | changeset = User.changeset(%User{}, @invalid_attrs) 16 | refute changeset.valid? 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /api/test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | use Phoenix.ChannelTest 22 | 23 | alias Sling.Repo 24 | import Ecto 25 | import Ecto.Changeset 26 | import Ecto.Query 27 | 28 | 29 | # The default endpoint for testing 30 | @endpoint Sling.Endpoint 31 | end 32 | end 33 | 34 | setup tags do 35 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Sling.Repo) 36 | 37 | unless tags[:async] do 38 | Ecto.Adapters.SQL.Sandbox.mode(Sling.Repo, {:shared, self()}) 39 | end 40 | 41 | :ok 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /api/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | use Phoenix.ConnTest 22 | 23 | alias Sling.Repo 24 | import Ecto 25 | import Ecto.Changeset 26 | import Ecto.Query 27 | 28 | import Sling.Router.Helpers 29 | 30 | # The default endpoint for testing 31 | @endpoint Sling.Endpoint 32 | end 33 | end 34 | 35 | setup tags do 36 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Sling.Repo) 37 | 38 | unless tags[:async] do 39 | Ecto.Adapters.SQL.Sandbox.mode(Sling.Repo, {:shared, self()}) 40 | end 41 | 42 | {:ok, conn: Phoenix.ConnTest.build_conn()} 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /api/test/support/model_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.ModelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | model tests. 5 | 6 | You may define functions here to be used as helpers in 7 | your model tests. See `errors_on/2`'s definition as reference. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | alias Sling.Repo 20 | 21 | import Ecto 22 | import Ecto.Changeset 23 | import Ecto.Query 24 | import Sling.ModelCase 25 | end 26 | end 27 | 28 | setup tags do 29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Sling.Repo) 30 | 31 | unless tags[:async] do 32 | Ecto.Adapters.SQL.Sandbox.mode(Sling.Repo, {:shared, self()}) 33 | end 34 | 35 | :ok 36 | end 37 | 38 | @doc """ 39 | Helper for returning list of errors in a struct when given certain data. 40 | 41 | ## Examples 42 | 43 | Given a User schema that lists `:name` as a required field and validates 44 | `:password` to be safe, it would return: 45 | 46 | iex> errors_on(%User{}, %{password: "password"}) 47 | [password: "is unsafe", name: "is blank"] 48 | 49 | You could then write your assertion like: 50 | 51 | assert {:password, "is unsafe"} in errors_on(%User{}, %{password: "password"}) 52 | 53 | You can also create the changeset manually and retrieve the errors 54 | field directly: 55 | 56 | iex> changeset = User.changeset(%User{}, password: "password") 57 | iex> {:password, "is unsafe"} in changeset.errors 58 | true 59 | """ 60 | def errors_on(struct, data) do 61 | struct.__struct__.changeset(struct, data) 62 | |> Ecto.Changeset.traverse_errors(&Sling.ErrorHelpers.translate_error/1) 63 | |> Enum.flat_map(fn {key, errors} -> for msg <- errors, do: {key, msg} end) 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /api/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | Ecto.Adapters.SQL.Sandbox.mode(Sling.Repo, :manual) 4 | 5 | -------------------------------------------------------------------------------- /api/test/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Sling.ErrorViewTest do 2 | use Sling.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.json" do 8 | assert render(Sling.ErrorView, "404.json", []) == 9 | %{errors: %{detail: "Page not found"}} 10 | end 11 | 12 | test "render 500.json" do 13 | assert render(Sling.ErrorView, "500.json", []) == 14 | %{errors: %{detail: "Internal server error"}} 15 | end 16 | 17 | test "render any other" do 18 | assert render(Sling.ErrorView, "505.json", []) == 19 | %{errors: %{detail: "Internal server error"}} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /api/web/channels/presence.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.Presence do 2 | @moduledoc """ 3 | Provides presence tracking to channels and processes. 4 | 5 | See the [`Phoenix.Presence`](http://hexdocs.pm/phoenix/Phoenix.Presence.html) 6 | docs for more details. 7 | 8 | ## Usage 9 | 10 | Presences can be tracked in your channel after joining: 11 | 12 | defmodule Sling.MyChannel do 13 | use Sling.Web, :channel 14 | alias Sling.Presence 15 | 16 | def join("some:topic", _params, socket) do 17 | send(self, :after_join) 18 | {:ok, assign(socket, :user_id, ...)} 19 | end 20 | 21 | def handle_info(:after_join, socket) do 22 | {:ok, _} = Presence.track(socket, socket.assigns.user_id, %{ 23 | online_at: inspect(System.system_time(:seconds)) 24 | }) 25 | push socket, "presence_state", Presence.list(socket) 26 | {:noreply, socket} 27 | end 28 | end 29 | 30 | In the example above, `Presence.track` is used to register this 31 | channel's process as a presence for the socket's user ID, with 32 | a map of metadata. Next, the current presence list for 33 | the socket's topic is pushed to the client as a `"presence_state"` event. 34 | 35 | Finally, a diff of presence join and leave events will be sent to the 36 | client as they happen in real-time with the "presence_diff" event. 37 | See `Phoenix.Presence.list/2` for details on the presence datastructure. 38 | 39 | ## Fetching Presence Information 40 | 41 | The `fetch/2` callback is triggered when using `list/1` 42 | and serves as a mechanism to fetch presence information a single time, 43 | before broadcasting the information to all channel subscribers. 44 | This prevents N query problems and gives you a single place to group 45 | isolated data fetching to extend presence metadata. 46 | 47 | The function receives a topic and map of presences and must return a 48 | map of data matching the Presence datastructure: 49 | 50 | %{"123" => %{metas: [%{status: "away", phx_ref: ...}], 51 | "456" => %{metas: [%{status: "online", phx_ref: ...}]} 52 | 53 | The `:metas` key must be kept, but you can extend the map of information 54 | to include any additional information. For example: 55 | 56 | def fetch(_topic, entries) do 57 | query = 58 | from u in User, 59 | where: u.id in ^Map.keys(entries), 60 | select: {u.id, u} 61 | 62 | users = query |> Repo.all |> Enum.into(%{}) 63 | 64 | for {key, %{metas: metas}} <- entries, into: %{} do 65 | {key, %{metas: metas, user: users[key]}} 66 | end 67 | end 68 | 69 | The function above fetches all users from the database who 70 | have registered presences for the given topic. The fetched 71 | information is then extended with a `:user` key of the user's 72 | information, while maintaining the required `:metas` field from the 73 | original presence data. 74 | """ 75 | use Phoenix.Presence, otp_app: :sling, 76 | pubsub_server: Sling.PubSub 77 | end 78 | -------------------------------------------------------------------------------- /api/web/channels/room_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.RoomChannel do 2 | use Sling.Web, :channel 3 | 4 | def join("rooms:" <> room_id, _params, socket) do 5 | room = Repo.get!(Sling.Room, room_id) 6 | 7 | page = 8 | Sling.Message 9 | |> where([m], m.room_id == ^room.id) 10 | |> order_by([desc: :inserted_at, desc: :id]) 11 | |> preload(:user) 12 | |> Sling.Repo.paginate() 13 | 14 | response = %{ 15 | room: Phoenix.View.render_one(room, Sling.RoomView, "room.json"), 16 | messages: Phoenix.View.render_many(page.entries, Sling.MessageView, "message.json"), 17 | pagination: Sling.PaginationHelpers.pagination(page) 18 | } 19 | 20 | send(self, :after_join) 21 | {:ok, response, assign(socket, :room, room)} 22 | end 23 | 24 | def handle_info(:after_join, socket) do 25 | Sling.Presence.track(socket, socket.assigns.current_user.id, %{ 26 | user: Phoenix.View.render_one(socket.assigns.current_user, Sling.UserView, "user.json") 27 | }) 28 | push(socket, "presence_state", Sling.Presence.list(socket)) 29 | {:noreply, socket} 30 | end 31 | 32 | def handle_in("new_message", params, socket) do 33 | changeset = 34 | socket.assigns.room 35 | |> build_assoc(:messages, user_id: socket.assigns.current_user.id) 36 | |> Sling.Message.changeset(params) 37 | 38 | case Repo.insert(changeset) do 39 | {:ok, message} -> 40 | broadcast_message(socket, message) 41 | {:reply, :ok, socket} 42 | {:error, changeset} -> 43 | {:reply, {:error, Phoenix.View.render(Sling.ChangesetView, "error.json", changeset: changeset)}, socket} 44 | end 45 | end 46 | 47 | def terminate(_reason, socket) do 48 | {:ok, socket} 49 | end 50 | 51 | defp broadcast_message(socket, message) do 52 | message = Repo.preload(message, :user) 53 | rendered_message = Phoenix.View.render_one(message, Sling.MessageView, "message.json") 54 | broadcast!(socket, "message_created", rendered_message) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /api/web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | channel "rooms:*", Sling.RoomChannel 6 | 7 | ## Transports 8 | transport :websocket, Phoenix.Transports.WebSocket 9 | # transport :longpoll, Phoenix.Transports.LongPoll 10 | 11 | # Socket params are passed from the client and can 12 | # be used to verify and authenticate a user. After 13 | # verification, you can put default assigns into 14 | # the socket that will be set for all channels, ie 15 | # 16 | # {:ok, assign(socket, :user_id, verified_user_id)} 17 | # 18 | # To deny connection, return `:error`. 19 | # 20 | # See `Phoenix.Token` documentation for examples in 21 | # performing token verification on connect. 22 | def connect(%{"token" => token}, socket) do 23 | case Guardian.decode_and_verify(token) do 24 | {:ok, claims} -> 25 | case Sling.GuardianSerializer.from_token(claims["sub"]) do 26 | {:ok, user} -> 27 | {:ok, assign(socket, :current_user, user)} 28 | {:error, _reason} -> 29 | :error 30 | end 31 | {:error, _reason} -> 32 | :error 33 | end 34 | end 35 | 36 | def connect(_params, _socket), do: :error 37 | 38 | # Socket id's are topics that allow you to identify all sockets for a given user: 39 | # 40 | # def id(socket), do: "users_socket:#{socket.assigns.user_id}" 41 | # 42 | # Would allow you to broadcast a "disconnect" event and terminate 43 | # all active sockets and channels for a given user: 44 | # 45 | # Sling.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{}) 46 | # 47 | # Returning `nil` makes this socket anonymous. 48 | def id(socket), do: "users_socket:#{socket.assigns.current_user.id}" 49 | end 50 | -------------------------------------------------------------------------------- /api/web/controllers/api/message_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.MessageController do 2 | use Sling.Web, :controller 3 | 4 | plug Guardian.Plug.EnsureAuthenticated, handler: Sling.SessionController 5 | 6 | def index(conn, params) do 7 | last_seen_id = params["last_seen_id"] || 0 8 | room = Repo.get!(Sling.Room, params["room_id"]) 9 | 10 | page = 11 | Sling.Message 12 | |> where([m], m.room_id == ^room.id) 13 | |> where([m], m.id < ^last_seen_id) 14 | |> order_by([desc: :inserted_at, desc: :id]) 15 | |> preload(:user) 16 | |> Sling.Repo.paginate() 17 | 18 | render(conn, "index.json", %{messages: page.entries, pagination: Sling.PaginationHelpers.pagination(page)}) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /api/web/controllers/api/room_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.RoomController do 2 | use Sling.Web, :controller 3 | 4 | alias Sling.Room 5 | 6 | plug Guardian.Plug.EnsureAuthenticated, handler: Sling.SessionController 7 | 8 | def index(conn, _params) do 9 | rooms = Repo.all(Room) 10 | render(conn, "index.json", rooms: rooms) 11 | end 12 | 13 | def create(conn, %{"room" => room_params}) do 14 | current_user = Guardian.Plug.current_resource(conn) 15 | changeset = Room.changeset(%Room{}, room_params) 16 | 17 | case Repo.insert(changeset) do 18 | {:ok, room} -> 19 | assoc_changeset = Sling.UserRoom.changeset( 20 | %Sling.UserRoom{}, 21 | %{user_id: current_user.id, room_id: room.id} 22 | ) 23 | Repo.insert(assoc_changeset) 24 | 25 | conn 26 | |> put_status(:created) 27 | |> render("show.json", room: room) 28 | {:error, changeset} -> 29 | conn 30 | |> put_status(:unprocessable_entity) 31 | |> render(Sling.ChangesetView, "error.json", changeset: changeset) 32 | end 33 | end 34 | 35 | def join(conn, %{"id" => id}) do 36 | current_user = Guardian.Plug.current_resource(conn) 37 | room = Repo.get!(Room, id) 38 | 39 | changeset = Sling.UserRoom.changeset( 40 | %Sling.UserRoom{}, 41 | %{room_id: room.id, user_id: current_user.id} 42 | ) 43 | 44 | case Repo.insert(changeset) do 45 | {:ok, _user_room} -> 46 | conn 47 | |> put_status(:created) 48 | |> render("show.json", %{room: room}) 49 | {:error, changeset} -> 50 | conn 51 | |> put_status(:unprocessable_entity) 52 | |> render(Sling.ChangesetView, "error.json", changeset: changeset) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /api/web/controllers/api/session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.SessionController do 2 | use Sling.Web, :controller 3 | 4 | def create(conn, params) do 5 | case authenticate(params) do 6 | {:ok, user} -> 7 | new_conn = Guardian.Plug.api_sign_in(conn, user, :access) 8 | jwt = Guardian.Plug.current_token(new_conn) 9 | 10 | new_conn 11 | |> put_status(:created) 12 | |> render("show.json", user: user, jwt: jwt) 13 | :error -> 14 | conn 15 | |> put_status(:unauthorized) 16 | |> render("error.json") 17 | end 18 | end 19 | 20 | def delete(conn, _) do 21 | jwt = Guardian.Plug.current_token(conn) 22 | Guardian.revoke!(jwt) 23 | 24 | conn 25 | |> put_status(:ok) 26 | |> render("delete.json") 27 | end 28 | 29 | def refresh(conn, _params) do 30 | user = Guardian.Plug.current_resource(conn) 31 | jwt = Guardian.Plug.current_token(conn) 32 | {:ok, claims} = Guardian.Plug.claims(conn) 33 | 34 | case Guardian.refresh!(jwt, claims, %{ttl: {30, :days}}) do 35 | {:ok, new_jwt, _new_claims} -> 36 | conn 37 | |> put_status(:ok) 38 | |> render("show.json", user: user, jwt: new_jwt) 39 | {:error, _reason} -> 40 | conn 41 | |> put_status(:unauthorized) 42 | |> render("forbidden.json", error: "Not authenticated") 43 | end 44 | end 45 | 46 | def unauthenticated(conn, _params) do 47 | conn 48 | |> put_status(:forbidden) 49 | |> render(Sling.SessionView, "forbidden.json", error: "Not Authenticated") 50 | end 51 | 52 | defp authenticate(%{"email" => email, "password" => password}) do 53 | user = Repo.get_by(Sling.User, email: String.downcase(email)) 54 | 55 | case check_password(user, password) do 56 | true -> {:ok, user} 57 | _ -> :error 58 | end 59 | end 60 | 61 | defp check_password(user, password) do 62 | case user do 63 | nil -> Comeonin.Bcrypt.dummy_checkpw() 64 | _ -> Comeonin.Bcrypt.checkpw(password, user.password_hash) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /api/web/controllers/api/user_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.UserController do 2 | use Sling.Web, :controller 3 | 4 | alias Sling.User 5 | 6 | plug Guardian.Plug.EnsureAuthenticated, [handler: Sling.SessionController] when action in [:rooms] 7 | 8 | def create(conn, %{"user" => user_params}) do 9 | changeset = User.registration_changeset(%User{}, user_params) 10 | 11 | case Repo.insert(changeset) do 12 | {:ok, user} -> 13 | new_conn = Guardian.Plug.api_sign_in(conn, user, :access) 14 | jwt = Guardian.Plug.current_token(new_conn) 15 | 16 | new_conn 17 | |> put_status(:created) 18 | |> render(Sling.SessionView, "show.json", user: user, jwt: jwt) 19 | {:error, changeset} -> 20 | conn 21 | |> put_status(:unprocessable_entity) 22 | |> render(Sling.ChangesetView, "error.json", changeset: changeset) 23 | end 24 | end 25 | 26 | def rooms(conn, _params) do 27 | current_user = Guardian.Plug.current_resource(conn) 28 | rooms = Repo.all(assoc(current_user, :rooms)) 29 | render(conn, Sling.RoomView, "index.json", %{rooms: rooms}) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /api/web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import Sling.Gettext 9 | 10 | # Simple translation 11 | gettext "Here is the string to translate" 12 | 13 | # Plural translation 14 | ngettext "Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3 17 | 18 | # Domain-based translation 19 | dgettext "errors", "Here is the error message to translate" 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :sling 24 | end 25 | -------------------------------------------------------------------------------- /api/web/models/message.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.Message do 2 | use Sling.Web, :model 3 | 4 | schema "messages" do 5 | field :text, :string 6 | belongs_to :room, Sling.Room 7 | belongs_to :user, Sling.User 8 | 9 | timestamps() 10 | end 11 | 12 | @doc """ 13 | Builds a changeset based on the `struct` and `params`. 14 | """ 15 | def changeset(struct, params \\ %{}) do 16 | struct 17 | |> cast(params, [:text, :user_id, :room_id]) 18 | |> validate_required([:text, :user_id, :room_id]) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /api/web/models/room.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.Room do 2 | use Sling.Web, :model 3 | 4 | schema "rooms" do 5 | field :name, :string 6 | field :topic, :string 7 | many_to_many :users, Sling.User, join_through: "user_rooms", on_delete: :delete_all 8 | has_many :messages, Sling.Message, on_delete: :delete_all 9 | 10 | timestamps() 11 | end 12 | 13 | @doc """ 14 | Builds a changeset based on the `struct` and `params`. 15 | """ 16 | def changeset(struct, params \\ %{}) do 17 | struct 18 | |> cast(params, [:name, :topic]) 19 | |> validate_required([:name]) 20 | |> unique_constraint(:name) 21 | |> downcase(:name) 22 | end 23 | 24 | defp downcase(changeset, field) do 25 | update_change(changeset, field, &String.downcase/1) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /api/web/models/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.User do 2 | use Sling.Web, :model 3 | 4 | schema "users" do 5 | field :username, :string 6 | field :email, :string 7 | field :password_hash, :string 8 | field :password, :string, virtual: true 9 | many_to_many :rooms, Sling.Room, join_through: "user_rooms" 10 | has_many :messages, Sling.Message 11 | 12 | timestamps() 13 | end 14 | 15 | @doc """ 16 | Builds a changeset based on the `struct` and `params`. 17 | """ 18 | def changeset(struct, params \\ %{}) do 19 | struct 20 | |> cast(params, [:username, :email]) 21 | |> validate_required([:username, :email]) 22 | |> unique_constraint(:username) 23 | |> unique_constraint(:email) 24 | end 25 | 26 | def registration_changeset(struct, params) do 27 | struct 28 | |> changeset(params) 29 | |> cast(params, [:password]) 30 | |> validate_length(:password, min: 6, max: 100) 31 | |> put_password_hash() 32 | end 33 | 34 | defp put_password_hash(changeset) do 35 | case changeset do 36 | %Ecto.Changeset{valid?: true, changes: %{password: password}} -> 37 | put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(password)) 38 | _ -> 39 | changeset 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /api/web/models/user_room.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.UserRoom do 2 | use Sling.Web, :model 3 | 4 | schema "user_rooms" do 5 | belongs_to :user, Sling.User 6 | belongs_to :room, Sling.Room 7 | 8 | timestamps() 9 | end 10 | 11 | @doc """ 12 | Builds a changeset based on the `struct` and `params`. 13 | """ 14 | def changeset(struct, params \\ %{}) do 15 | struct 16 | |> cast(params, [:user_id, :room_id]) 17 | |> validate_required([:user_id, :room_id]) 18 | |> unique_constraint(:user_id_room_id) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /api/web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.Router do 2 | use Sling.Web, :router 3 | 4 | pipeline :api do 5 | plug :accepts, ["json"] 6 | plug Guardian.Plug.VerifyHeader, realm: "Bearer" 7 | plug Guardian.Plug.LoadResource 8 | end 9 | 10 | scope "/api", Sling do 11 | pipe_through :api 12 | 13 | post "/sessions", SessionController, :create 14 | delete "/sessions", SessionController, :delete 15 | post "/sessions/refresh", SessionController, :refresh 16 | resources "/users", UserController, only: [:create] 17 | 18 | get "/users/:id/rooms", UserController, :rooms 19 | resources "/rooms", RoomController, only: [:index, :create] do 20 | resources "/messages", MessageController, only: [:index] 21 | end 22 | post "/rooms/:id/join", RoomController, :join 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /api/web/views/changeset_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.ChangesetView do 2 | use Sling.Web, :view 3 | 4 | @doc """ 5 | Traverses and translates changeset errors. 6 | 7 | See `Ecto.Changeset.traverse_errors/2` and 8 | `Sling.ErrorHelpers.translate_error/1` for more details. 9 | """ 10 | def translate_errors(changeset) do 11 | Ecto.Changeset.traverse_errors(changeset, &translate_error/1) 12 | end 13 | 14 | def render("error.json", %{changeset: changeset}) do 15 | # When encoded, the changeset returns its errors 16 | # as a JSON object. So we just pass it forward. 17 | %{errors: translate_errors(changeset)} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /api/web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | @doc """ 7 | Translates an error message using gettext. 8 | """ 9 | def translate_error({msg, opts}) do 10 | # Because error messages were defined within Ecto, we must 11 | # call the Gettext module passing our Gettext backend. We 12 | # also use the "errors" domain as translations are placed 13 | # in the errors.po file. 14 | # Ecto will pass the :count keyword if the error message is 15 | # meant to be pluralized. 16 | # On your own code and templates, depending on whether you 17 | # need the message to be pluralized or not, this could be 18 | # written simply as: 19 | # 20 | # dngettext "errors", "1 file", "%{count} files", count 21 | # dgettext "errors", "is invalid" 22 | # 23 | if count = opts[:count] do 24 | Gettext.dngettext(Sling.Gettext, "errors", msg, msg, count, opts) 25 | else 26 | Gettext.dgettext(Sling.Gettext, "errors", msg, opts) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /api/web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.ErrorView do 2 | use Sling.Web, :view 3 | 4 | def render("404.json", _assigns) do 5 | %{errors: %{detail: "Page not found"}} 6 | end 7 | 8 | def render("500.json", _assigns) do 9 | %{errors: %{detail: "Internal server error"}} 10 | end 11 | 12 | # In case no render clause matches or no 13 | # template is found, let's render it as 500 14 | def template_not_found(_template, assigns) do 15 | render "500.json", assigns 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /api/web/views/message_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.MessageView do 2 | use Sling.Web, :view 3 | 4 | def render("index.json", %{messages: messages, pagination: pagination}) do 5 | %{ 6 | data: render_many(messages, Sling.MessageView, "message.json"), 7 | pagination: pagination 8 | } 9 | end 10 | 11 | def render("message.json", %{message: message}) do 12 | %{ 13 | id: message.id, 14 | inserted_at: message.inserted_at, 15 | text: message.text, 16 | user: %{ 17 | email: message.user.email, 18 | username: message.user.username 19 | } 20 | } 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /api/web/views/pagination_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.PaginationHelpers do 2 | def pagination(page) do 3 | %{ 4 | page_number: page.page_number, 5 | page_size: page.page_size, 6 | total_pages: page.total_pages, 7 | total_entries: page.total_entries 8 | } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /api/web/views/room_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.RoomView do 2 | use Sling.Web, :view 3 | 4 | def render("index.json", %{rooms: rooms}) do 5 | %{data: render_many(rooms, Sling.RoomView, "room.json")} 6 | end 7 | 8 | def render("show.json", %{room: room}) do 9 | %{data: render_one(room, Sling.RoomView, "room.json")} 10 | end 11 | 12 | def render("room.json", %{room: room}) do 13 | %{id: room.id, 14 | name: room.name, 15 | topic: room.topic} 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /api/web/views/session_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.SessionView do 2 | use Sling.Web, :view 3 | 4 | def render("show.json", %{user: user, jwt: jwt}) do 5 | %{ 6 | data: render_one(user, Sling.UserView, "user.json"), 7 | meta: %{token: jwt} 8 | } 9 | end 10 | 11 | def render("error.json", _) do 12 | %{error: "Invalid email or password"} 13 | end 14 | 15 | def render("delete.json", _) do 16 | %{ok: true} 17 | end 18 | 19 | def render("forbidden.json", %{error: error}) do 20 | %{error: error} 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /api/web/views/user_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.UserView do 2 | use Sling.Web, :view 3 | 4 | def render("user.json", %{user: user}) do 5 | %{ 6 | id: user.id, 7 | username: user.username, 8 | email: user.email, 9 | } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /api/web/web.ex: -------------------------------------------------------------------------------- 1 | defmodule Sling.Web do 2 | @moduledoc """ 3 | A module that keeps using definitions for controllers, 4 | views and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use Sling.Web, :controller 9 | use Sling.Web, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. 17 | """ 18 | 19 | def model do 20 | quote do 21 | use Ecto.Schema 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | end 27 | end 28 | 29 | def controller do 30 | quote do 31 | use Phoenix.Controller 32 | 33 | alias Sling.Repo 34 | import Ecto 35 | import Ecto.Query 36 | 37 | import Sling.Router.Helpers 38 | import Sling.Gettext 39 | end 40 | end 41 | 42 | def view do 43 | quote do 44 | use Phoenix.View, root: "web/templates" 45 | 46 | # Import convenience functions from controllers 47 | import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1] 48 | 49 | import Sling.Router.Helpers 50 | import Sling.ErrorHelpers 51 | import Sling.Gettext 52 | end 53 | end 54 | 55 | def router do 56 | quote do 57 | use Phoenix.Router 58 | end 59 | end 60 | 61 | def channel do 62 | quote do 63 | use Phoenix.Channel 64 | 65 | alias Sling.Repo 66 | import Ecto 67 | import Ecto.Query 68 | import Sling.Gettext 69 | end 70 | end 71 | 72 | @doc """ 73 | When used, dispatch to the appropriate controller/view/etc. 74 | """ 75 | defmacro __using__(which) when is_atom(which) do 76 | apply(__MODULE__, which, []) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | services: 3 | db: 4 | image: postgres:10.3-alpine 5 | environment: 6 | POSTGRES_USER: postgres 7 | POSTGRES_PASSWORD: postgres 8 | ports: 9 | - 5432:5432 10 | -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danieldocki/slack-clone-vuejs-elixir-phoenix/cd558cfc337fa9b410e00372ae19284585ce196d/preview.png -------------------------------------------------------------------------------- /web/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2"], 3 | "plugins": ["transform-runtime"], 4 | "comments": false 5 | } 6 | -------------------------------------------------------------------------------- /web/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /web/.eslintignore: -------------------------------------------------------------------------------- 1 | build/*.js 2 | config/*.js 3 | -------------------------------------------------------------------------------- /web/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: 'babel-eslint', 4 | parserOptions: { 5 | sourceType: 'module' 6 | }, 7 | extends: 'airbnb-base', 8 | // required to lint *.vue files 9 | plugins: [ 10 | 'html' 11 | ], 12 | // check if imports actually resolve 13 | 'settings': { 14 | 'import/resolver': { 15 | 'webpack': { 16 | 'config': 'build/webpack.base.conf.js' 17 | } 18 | } 19 | }, 20 | // add your custom rules here 21 | 'rules': { 22 | // don't require .vue extension when importing 23 | 'import/extensions': ['error', 'always', { 24 | 'js': 'never', 25 | 'vue': 'never' 26 | }], 27 | // allow debugger during development 28 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, 29 | 'semi': 0 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | npm-debug.log 5 | test/unit/coverage 6 | test/e2e/reports 7 | selenium-debug.log 8 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # sling 2 | 3 | > Sling chat 4 | 5 | ## Build Setup 6 | 7 | ``` bash 8 | # install dependencies 9 | npm install 10 | 11 | # serve with hot reload at localhost:8080 12 | npm run dev 13 | 14 | # build for production with minification 15 | npm run build 16 | 17 | # run unit tests 18 | npm run unit 19 | 20 | # run e2e tests 21 | npm run e2e 22 | 23 | # run all tests 24 | npm test 25 | ``` 26 | 27 | For detailed explanation on how things work, checkout the [guide](http://vuejs-templates.github.io/webpack/) and [docs for vue-loader](http://vuejs.github.io/vue-loader). 28 | -------------------------------------------------------------------------------- /web/build/build.js: -------------------------------------------------------------------------------- 1 | // https://github.com/shelljs/shelljs 2 | require('./check-versions')() 3 | require('shelljs/global') 4 | env.NODE_ENV = 'production' 5 | 6 | var path = require('path') 7 | var config = require('../config') 8 | var ora = require('ora') 9 | var webpack = require('webpack') 10 | var webpackConfig = require('./webpack.prod.conf') 11 | 12 | console.log( 13 | ' Tip:\n' + 14 | ' Built files are meant to be served over an HTTP server.\n' + 15 | ' Opening index.html over file:// won\'t work.\n' 16 | ) 17 | 18 | var spinner = ora('building for production...') 19 | spinner.start() 20 | 21 | var assetsPath = path.join(config.build.assetsRoot, config.build.assetsSubDirectory) 22 | rm('-rf', assetsPath) 23 | mkdir('-p', assetsPath) 24 | cp('-R', 'static/*', assetsPath) 25 | 26 | webpack(webpackConfig, function (err, stats) { 27 | spinner.stop() 28 | if (err) throw err 29 | process.stdout.write(stats.toString({ 30 | colors: true, 31 | modules: false, 32 | children: false, 33 | chunks: false, 34 | chunkModules: false 35 | }) + '\n') 36 | }) 37 | -------------------------------------------------------------------------------- /web/build/check-versions.js: -------------------------------------------------------------------------------- 1 | var semver = require('semver') 2 | var chalk = require('chalk') 3 | var packageConfig = require('../package.json') 4 | var exec = function (cmd) { 5 | return require('child_process') 6 | .execSync(cmd).toString().trim() 7 | } 8 | 9 | var versionRequirements = [ 10 | { 11 | name: 'node', 12 | currentVersion: semver.clean(process.version), 13 | versionRequirement: packageConfig.engines.node 14 | }, 15 | { 16 | name: 'npm', 17 | currentVersion: exec('npm --version'), 18 | versionRequirement: packageConfig.engines.npm 19 | } 20 | ] 21 | 22 | module.exports = function () { 23 | var warnings = [] 24 | for (var i = 0; i < versionRequirements.length; i++) { 25 | var mod = versionRequirements[i] 26 | if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) { 27 | warnings.push(mod.name + ': ' + 28 | chalk.red(mod.currentVersion) + ' should be ' + 29 | chalk.green(mod.versionRequirement) 30 | ) 31 | } 32 | } 33 | 34 | if (warnings.length) { 35 | console.log('') 36 | console.log(chalk.yellow('To use this template, you must update following to modules:')) 37 | console.log() 38 | for (var i = 0; i < warnings.length; i++) { 39 | var warning = warnings[i] 40 | console.log(' ' + warning) 41 | } 42 | console.log() 43 | process.exit(1) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /web/build/dev-client.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | require('eventsource-polyfill') 3 | var hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true') 4 | 5 | hotClient.subscribe(function (event) { 6 | if (event.action === 'reload') { 7 | window.location.reload() 8 | } 9 | }) 10 | -------------------------------------------------------------------------------- /web/build/dev-server.js: -------------------------------------------------------------------------------- 1 | require('./check-versions')() 2 | var config = require('../config') 3 | if (!process.env.NODE_ENV) process.env.NODE_ENV = JSON.parse(config.dev.env.NODE_ENV) 4 | var path = require('path') 5 | var express = require('express') 6 | var webpack = require('webpack') 7 | var opn = require('opn') 8 | var proxyMiddleware = require('http-proxy-middleware') 9 | var webpackConfig = process.env.NODE_ENV === 'testing' 10 | ? require('./webpack.prod.conf') 11 | : require('./webpack.dev.conf') 12 | 13 | // default port where dev server listens for incoming traffic 14 | var port = process.env.PORT || config.dev.port 15 | // Define HTTP proxies to your custom API backend 16 | // https://github.com/chimurai/http-proxy-middleware 17 | var proxyTable = config.dev.proxyTable 18 | 19 | var app = express() 20 | var compiler = webpack(webpackConfig) 21 | 22 | var devMiddleware = require('webpack-dev-middleware')(compiler, { 23 | publicPath: webpackConfig.output.publicPath, 24 | stats: { 25 | colors: true, 26 | chunks: false 27 | } 28 | }) 29 | 30 | var hotMiddleware = require('webpack-hot-middleware')(compiler) 31 | // force page reload when html-webpack-plugin template changes 32 | compiler.plugin('compilation', function (compilation) { 33 | compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) { 34 | hotMiddleware.publish({ action: 'reload' }) 35 | cb() 36 | }) 37 | }) 38 | 39 | // proxy api requests 40 | Object.keys(proxyTable).forEach(function (context) { 41 | var options = proxyTable[context] 42 | if (typeof options === 'string') { 43 | options = { target: options } 44 | } 45 | app.use(proxyMiddleware(context, options)) 46 | }) 47 | 48 | // handle fallback for HTML5 history API 49 | app.use(require('connect-history-api-fallback')()) 50 | 51 | // serve webpack bundle output 52 | app.use(devMiddleware) 53 | 54 | // enable hot-reload and state-preserving 55 | // compilation error display 56 | app.use(hotMiddleware) 57 | 58 | // serve pure static assets 59 | var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory) 60 | app.use(staticPath, express.static('./static')) 61 | 62 | module.exports = app.listen(port, function (err) { 63 | if (err) { 64 | console.log(err) 65 | return 66 | } 67 | var uri = 'http://localhost:' + port 68 | console.log('Listening at ' + uri + '\n') 69 | 70 | // when env is testing, don't need open it 71 | if (process.env.NODE_ENV !== 'testing') { 72 | opn(uri) 73 | } 74 | }) 75 | -------------------------------------------------------------------------------- /web/build/utils.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var config = require('../config') 3 | var ExtractTextPlugin = require('extract-text-webpack-plugin') 4 | 5 | exports.assetsPath = function (_path) { 6 | var assetsSubDirectory = process.env.NODE_ENV === 'production' 7 | ? config.build.assetsSubDirectory 8 | : config.dev.assetsSubDirectory 9 | return path.posix.join(assetsSubDirectory, _path) 10 | } 11 | 12 | exports.cssLoaders = function (options) { 13 | options = options || {} 14 | // generate loader string to be used with extract text plugin 15 | function generateLoaders (loaders) { 16 | var sourceLoader = loaders.map(function (loader) { 17 | var extraParamChar 18 | if (/\?/.test(loader)) { 19 | loader = loader.replace(/\?/, '-loader?') 20 | extraParamChar = '&' 21 | } else { 22 | loader = loader + '-loader' 23 | extraParamChar = '?' 24 | } 25 | return loader + (options.sourceMap ? extraParamChar + 'sourceMap' : '') 26 | }).join('!') 27 | 28 | // Extract CSS when that option is specified 29 | // (which is the case during production build) 30 | if (options.extract) { 31 | return ExtractTextPlugin.extract('vue-style-loader', sourceLoader) 32 | } else { 33 | return ['vue-style-loader', sourceLoader].join('!') 34 | } 35 | } 36 | 37 | // http://vuejs.github.io/vue-loader/en/configurations/extract-css.html 38 | return { 39 | css: generateLoaders(['css']), 40 | postcss: generateLoaders(['css']), 41 | less: generateLoaders(['css', 'less']), 42 | sass: generateLoaders(['css', 'sass?indentedSyntax']), 43 | scss: generateLoaders(['css', 'sass']), 44 | stylus: generateLoaders(['css', 'stylus']), 45 | styl: generateLoaders(['css', 'stylus']) 46 | } 47 | } 48 | 49 | // Generate loaders for standalone style files (outside of .vue) 50 | exports.styleLoaders = function (options) { 51 | var output = [] 52 | var loaders = exports.cssLoaders(options) 53 | for (var extension in loaders) { 54 | var loader = loaders[extension] 55 | output.push({ 56 | test: new RegExp('\\.' + extension + '$'), 57 | loader: loader 58 | }) 59 | } 60 | return output 61 | } 62 | -------------------------------------------------------------------------------- /web/build/webpack.base.conf.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var config = require('../config') 3 | var utils = require('./utils') 4 | var projectRoot = path.resolve(__dirname, '../') 5 | 6 | var env = process.env.NODE_ENV 7 | // check env & config/index.js to decide whether to enable CSS source maps for the 8 | // various preprocessor loaders added to vue-loader at the end of this file 9 | var cssSourceMapDev = (env === 'development' && config.dev.cssSourceMap) 10 | var cssSourceMapProd = (env === 'production' && config.build.productionSourceMap) 11 | var useCssSourceMap = cssSourceMapDev || cssSourceMapProd 12 | 13 | module.exports = { 14 | entry: { 15 | app: './src/main.js' 16 | }, 17 | output: { 18 | path: config.build.assetsRoot, 19 | publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath, 20 | filename: '[name].js' 21 | }, 22 | resolve: { 23 | extensions: ['', '.js', '.vue', '.json'], 24 | fallback: [path.join(__dirname, '../node_modules')], 25 | alias: { 26 | 'vue$': 'vue/dist/vue.common.js', 27 | 'src': path.resolve(__dirname, '../src'), 28 | 'plugins': path.resolve(__dirname, '../src/plugins'), 29 | 'utils': path.resolve(__dirname, '../src/utils'), 30 | 'assets': path.resolve(__dirname, '../src/assets'), 31 | 'components': path.resolve(__dirname, '../src/components'), 32 | 'containers': path.resolve(__dirname, '../src/containers') 33 | } 34 | }, 35 | resolveLoader: { 36 | fallback: [path.join(__dirname, '../node_modules')] 37 | }, 38 | module: { 39 | noParse: [new RegExp('node_modules/localforage/dist/localforage.js')], 40 | preLoaders: [ 41 | { 42 | test: /\.vue$/, 43 | loader: 'eslint', 44 | include: projectRoot, 45 | exclude: /node_modules/ 46 | }, 47 | { 48 | test: /\.js$/, 49 | loader: 'eslint', 50 | include: projectRoot, 51 | exclude: /node_modules/ 52 | } 53 | ], 54 | loaders: [ 55 | { 56 | test: /\.vue$/, 57 | loader: 'vue' 58 | }, 59 | { 60 | test: /\.js$/, 61 | loader: 'babel', 62 | include: projectRoot, 63 | exclude: /node_modules/ 64 | }, 65 | { 66 | test: /\.json$/, 67 | loader: 'json' 68 | }, 69 | { 70 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 71 | loader: 'url', 72 | query: { 73 | limit: 10000, 74 | name: utils.assetsPath('img/[name].[hash:7].[ext]') 75 | } 76 | }, 77 | { 78 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 79 | loader: 'url', 80 | query: { 81 | limit: 10000, 82 | name: utils.assetsPath('fonts/[name].[hash:7].[ext]') 83 | } 84 | } 85 | ] 86 | }, 87 | eslint: { 88 | formatter: require('eslint-friendly-formatter') 89 | }, 90 | vue: { 91 | loaders: utils.cssLoaders({ sourceMap: useCssSourceMap }), 92 | postcss: [ 93 | require('autoprefixer')({ 94 | browsers: ['last 2 versions'] 95 | }) 96 | ] 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /web/build/webpack.dev.conf.js: -------------------------------------------------------------------------------- 1 | var config = require('../config') 2 | var webpack = require('webpack') 3 | var merge = require('webpack-merge') 4 | var utils = require('./utils') 5 | var baseWebpackConfig = require('./webpack.base.conf') 6 | var HtmlWebpackPlugin = require('html-webpack-plugin') 7 | 8 | // add hot-reload related code to entry chunks 9 | Object.keys(baseWebpackConfig.entry).forEach(function (name) { 10 | baseWebpackConfig.entry[name] = ['./build/dev-client'].concat(baseWebpackConfig.entry[name]) 11 | }) 12 | 13 | module.exports = merge(baseWebpackConfig, { 14 | module: { 15 | loaders: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap }) 16 | }, 17 | // eval-source-map is faster for development 18 | devtool: '#eval-source-map', 19 | plugins: [ 20 | new webpack.DefinePlugin({ 21 | 'process.env': config.dev.env 22 | }), 23 | // https://github.com/glenjamin/webpack-hot-middleware#installation--usage 24 | new webpack.optimize.OccurrenceOrderPlugin(), 25 | new webpack.HotModuleReplacementPlugin(), 26 | new webpack.NoErrorsPlugin(), 27 | // https://github.com/ampedandwired/html-webpack-plugin 28 | new HtmlWebpackPlugin({ 29 | filename: 'index.html', 30 | template: 'index.html', 31 | inject: true 32 | }) 33 | ] 34 | }) 35 | -------------------------------------------------------------------------------- /web/build/webpack.prod.conf.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var config = require('../config') 3 | var utils = require('./utils') 4 | var webpack = require('webpack') 5 | var merge = require('webpack-merge') 6 | var baseWebpackConfig = require('./webpack.base.conf') 7 | var ExtractTextPlugin = require('extract-text-webpack-plugin') 8 | var HtmlWebpackPlugin = require('html-webpack-plugin') 9 | var env = process.env.NODE_ENV === 'testing' 10 | ? require('../config/test.env') 11 | : config.build.env 12 | 13 | var webpackConfig = merge(baseWebpackConfig, { 14 | module: { 15 | loaders: utils.styleLoaders({ sourceMap: config.build.productionSourceMap, extract: true }) 16 | }, 17 | devtool: config.build.productionSourceMap ? '#source-map' : false, 18 | output: { 19 | path: config.build.assetsRoot, 20 | filename: utils.assetsPath('js/[name].[chunkhash].js'), 21 | chunkFilename: utils.assetsPath('js/[id].[chunkhash].js') 22 | }, 23 | vue: { 24 | loaders: utils.cssLoaders({ 25 | sourceMap: config.build.productionSourceMap, 26 | extract: true 27 | }) 28 | }, 29 | plugins: [ 30 | // http://vuejs.github.io/vue-loader/en/workflow/production.html 31 | new webpack.DefinePlugin({ 32 | 'process.env': env 33 | }), 34 | new webpack.optimize.UglifyJsPlugin({ 35 | compress: { 36 | warnings: false 37 | } 38 | }), 39 | new webpack.optimize.OccurrenceOrderPlugin(), 40 | // extract css into its own file 41 | new ExtractTextPlugin(utils.assetsPath('css/[name].[contenthash].css')), 42 | // generate dist index.html with correct asset hash for caching. 43 | // you can customize output by editing /index.html 44 | // see https://github.com/ampedandwired/html-webpack-plugin 45 | new HtmlWebpackPlugin({ 46 | filename: process.env.NODE_ENV === 'testing' 47 | ? 'index.html' 48 | : config.build.index, 49 | template: 'index.html', 50 | inject: true, 51 | minify: { 52 | removeComments: true, 53 | collapseWhitespace: true, 54 | removeAttributeQuotes: true 55 | // more options: 56 | // https://github.com/kangax/html-minifier#options-quick-reference 57 | }, 58 | // necessary to consistently work with multiple chunks via CommonsChunkPlugin 59 | chunksSortMode: 'dependency' 60 | }), 61 | // split vendor js into its own file 62 | new webpack.optimize.CommonsChunkPlugin({ 63 | name: 'vendor', 64 | minChunks: function (module, count) { 65 | // any required modules inside node_modules are extracted to vendor 66 | return ( 67 | module.resource && 68 | /\.js$/.test(module.resource) && 69 | module.resource.indexOf( 70 | path.join(__dirname, '../node_modules') 71 | ) === 0 72 | ) 73 | } 74 | }), 75 | // extract webpack runtime and module manifest to its own file in order to 76 | // prevent vendor hash from being updated whenever app bundle is updated 77 | new webpack.optimize.CommonsChunkPlugin({ 78 | name: 'manifest', 79 | chunks: ['vendor'] 80 | }) 81 | ] 82 | }) 83 | 84 | if (config.build.productionGzip) { 85 | var CompressionWebpackPlugin = require('compression-webpack-plugin') 86 | 87 | webpackConfig.plugins.push( 88 | new CompressionWebpackPlugin({ 89 | asset: '[path].gz[query]', 90 | algorithm: 'gzip', 91 | test: new RegExp( 92 | '\\.(' + 93 | config.build.productionGzipExtensions.join('|') + 94 | ')$' 95 | ), 96 | threshold: 10240, 97 | minRatio: 0.8 98 | }) 99 | ) 100 | } 101 | 102 | module.exports = webpackConfig 103 | -------------------------------------------------------------------------------- /web/config/dev.env.js: -------------------------------------------------------------------------------- 1 | var merge = require('webpack-merge') 2 | var prodEnv = require('./prod.env') 3 | 4 | module.exports = merge(prodEnv, { 5 | NODE_ENV: '"development"', 6 | APP_API_URL: "'http://localhost:4000/api'", 7 | APP_WEBSOCKET_URL: "'ws://localhost:4000/socket'", 8 | }) 9 | -------------------------------------------------------------------------------- /web/config/index.js: -------------------------------------------------------------------------------- 1 | // see http://vuejs-templates.github.io/webpack for documentation. 2 | var path = require('path') 3 | 4 | module.exports = { 5 | build: { 6 | env: require('./prod.env'), 7 | index: path.resolve(__dirname, '../dist/index.html'), 8 | assetsRoot: path.resolve(__dirname, '../dist'), 9 | assetsSubDirectory: 'static', 10 | assetsPublicPath: '/', 11 | productionSourceMap: true, 12 | // Gzip off by default as many popular static hosts such as 13 | // Surge or Netlify already gzip all static assets for you. 14 | // Before setting to `true`, make sure to: 15 | // npm install --save-dev compression-webpack-plugin 16 | productionGzip: false, 17 | productionGzipExtensions: ['js', 'css'] 18 | }, 19 | dev: { 20 | env: require('./dev.env'), 21 | port: 8080, 22 | assetsSubDirectory: 'static', 23 | assetsPublicPath: '/', 24 | proxyTable: {}, 25 | // CSS Sourcemaps off by default because relative paths are "buggy" 26 | // with this option, according to the CSS-Loader README 27 | // (https://github.com/webpack/css-loader#sourcemaps) 28 | // In our experience, they generally work as expected, 29 | // just be aware of this issue when enabling this option. 30 | cssSourceMap: false 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /web/config/prod.env.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | NODE_ENV: '"production"' 3 | } 4 | -------------------------------------------------------------------------------- /web/config/test.env.js: -------------------------------------------------------------------------------- 1 | var merge = require('webpack-merge') 2 | var devEnv = require('./dev.env') 3 | 4 | module.exports = merge(devEnv, { 5 | NODE_ENV: '"testing"' 6 | }) 7 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |Page not found
4 |