├── .circleci
└── config.yml
├── .formatter.exs
├── .github
└── dependabot.yml
├── .gitignore
├── .hound.yml
├── .iex.exs
├── .sample.env
├── .tool-versions
├── CONTRIBUTING.md
├── LICENSE
├── Procfile
├── README.md
├── app.json
├── assets
├── .babelrc
├── .eslintrc.json
├── .stylelintignore
├── .stylelintrc.json
├── css
│ ├── _shims.scss
│ ├── app.scss
│ ├── components
│ │ ├── _announcement-create.scss
│ │ ├── _announcement-form.scss
│ │ ├── _announcement.scss
│ │ ├── _announcements.scss
│ │ ├── _app-header.scss
│ │ ├── _app-main.scss
│ │ ├── _at-mention.scss
│ │ ├── _box.scss
│ │ ├── _checkbox-switch.scss
│ │ ├── _comments.scss
│ │ ├── _flash.scss
│ │ ├── _flex-button-container.scss
│ │ ├── _help-block.scss
│ │ ├── _interest.scss
│ │ ├── _page-header.scss
│ │ ├── _pagination.scss
│ │ ├── _tabs.scss
│ │ └── _uploadable-input.scss
│ ├── elements
│ │ ├── _layout.scss
│ │ ├── _lists.scss
│ │ ├── _media.scss
│ │ ├── _tables.scss
│ │ └── _typography.scss
│ ├── generic
│ │ └── _fonts.scss
│ ├── objects
│ │ └── _container.scss
│ ├── scopes
│ │ └── _links-no-underline.scss
│ ├── settings
│ │ └── _variables.scss
│ ├── utilities
│ │ └── _font-size.scss
│ └── vendor
│ │ └── _selectize.scss
├── js
│ ├── app.js
│ ├── checkbox-switch.js
│ ├── components
│ │ ├── announcement-form-mobile.js
│ │ ├── announcement-form.js
│ │ ├── comment-form.js
│ │ ├── recipients-preview.js
│ │ └── user-autocomplete.js
│ ├── lib
│ │ ├── remote-append.js
│ │ ├── syntax-highlighting.js
│ │ └── textarea-image-uploader.js
│ └── socket.js
├── package-lock.json
├── package.json
├── static
│ ├── apple-touch-icon-114x114.png
│ ├── apple-touch-icon-120x120.png
│ ├── apple-touch-icon-144x144.png
│ ├── apple-touch-icon-152x152.png
│ ├── apple-touch-icon-180x180.png
│ ├── apple-touch-icon-57x57.png
│ ├── apple-touch-icon-72x72.png
│ ├── apple-touch-icon-76x76.png
│ ├── apple-touch-icon.png
│ ├── favicon.ico
│ ├── fonts
│ │ ├── Inter-italic.var.woff2
│ │ └── Inter-roman.var.woff2
│ ├── images
│ │ ├── icon-search.svg
│ │ ├── icons.svg
│ │ ├── ralph.png
│ │ └── upload-progress-bar-background.png
│ ├── robots.txt
│ └── vendor
│ │ └── textcomplete.css
└── webpack.config.js
├── bin
├── clone_prod_db_to_dev
├── console
├── deploy
└── setup
├── config
├── config.exs
├── dev.exs
├── locales
│ └── en.exs
├── prod.exs
└── test.exs
├── elixir_buildpack.config
├── lib
├── constable.ex
├── constable
│ ├── announcement.ex
│ ├── announcement_interest.ex
│ ├── application.ex
│ ├── comment.ex
│ ├── env.ex
│ ├── factory.ex
│ ├── interest.ex
│ ├── mailer.ex
│ ├── markdown.ex
│ ├── pact.ex
│ ├── plugs
│ │ ├── deslugifier.ex
│ │ ├── fetch_current_user.ex
│ │ ├── require_api_login.ex
│ │ ├── require_web_login.ex
│ │ └── set_user_id_from_params.ex
│ ├── profiles.ex
│ ├── pub_sub.ex
│ ├── repo.ex
│ ├── serializers
│ │ ├── announcement.ex
│ │ ├── comment.ex
│ │ ├── interest.ex
│ │ ├── serializers.ex
│ │ ├── subscription.ex
│ │ ├── user.ex
│ │ └── user_interest.ex
│ ├── services
│ │ ├── announcement_creator.ex
│ │ ├── announcement_interest_associator.ex
│ │ ├── announcement_updater.ex
│ │ ├── comment_creator.ex
│ │ ├── customer_scrubber.ex
│ │ ├── daily_digest.ex
│ │ ├── email_reply_parser.ex
│ │ ├── fake_google_strategy.ex
│ │ ├── fake_profile_provider.ex
│ │ ├── google_strategy.ex
│ │ ├── hub_profile_provider.ex
│ │ ├── mention_finder.ex
│ │ ├── oauth_redirect_strategy.ex
│ │ ├── profile_provider.ex
│ │ └── slack_hook.ex
│ ├── slug.ex
│ ├── subscription.ex
│ ├── task_supervisor.ex
│ ├── time.ex
│ ├── user.ex
│ ├── user_identifier.ex
│ └── user_interest.ex
├── constable_web.ex
├── constable_web
│ ├── channels
│ │ └── user_socket.ex
│ ├── controller_helper.ex
│ ├── controllers
│ │ ├── announcement_controller.ex
│ │ ├── api
│ │ │ ├── announcement_controller.ex
│ │ │ ├── comment_controller.ex
│ │ │ ├── interest_controller.ex
│ │ │ ├── search_controller.ex
│ │ │ ├── subscription_controller.ex
│ │ │ ├── user_controller.ex
│ │ │ └── user_interest_controller.ex
│ │ ├── auth_controller.ex
│ │ ├── comment_controller.ex
│ │ ├── email_forward_controller.ex
│ │ ├── email_preview_controller.ex
│ │ ├── email_reply_controller.ex
│ │ ├── home_controller.ex
│ │ ├── interest_controller.ex
│ │ ├── recipients_preview_controller.ex
│ │ ├── search_controller.ex
│ │ ├── session_controller.ex
│ │ ├── settings_controller.ex
│ │ ├── slack_channel_controller.ex
│ │ ├── subscriptions_controller.ex
│ │ ├── unsubscribe_controller.ex
│ │ ├── user_activation_controller.ex
│ │ └── user_interest_controller.ex
│ ├── emails.ex
│ ├── endpoint.ex
│ ├── gettext.ex
│ ├── live
│ │ ├── announcement_live
│ │ │ └── show.ex
│ │ └── interest_live.ex
│ ├── router.ex
│ ├── templates
│ │ ├── announcement
│ │ │ ├── _comment.html.eex
│ │ │ ├── _form.html.eex
│ │ │ ├── _unsubscribe_button.html.eex
│ │ │ ├── edit.html.eex
│ │ │ ├── index.html.eex
│ │ │ ├── new.html.eex
│ │ │ └── show.html.leex
│ │ ├── announcement_list
│ │ │ └── index.html.eex
│ │ ├── comment
│ │ │ ├── _form.html.eex
│ │ │ └── edit.html.eex
│ │ ├── email
│ │ │ ├── author_footer.html.eex
│ │ │ ├── daily_digest.html.eex
│ │ │ ├── daily_digest.text.eex
│ │ │ ├── new_announcement.html.eex
│ │ │ ├── new_announcement.text.eex
│ │ │ ├── new_comment.html.eex
│ │ │ ├── new_comment.text.eex
│ │ │ ├── unsubscribe_footer.html.eex
│ │ │ └── unsubscribe_footer.text.eex
│ │ ├── interest
│ │ │ ├── index.html.leex
│ │ │ ├── show.html.eex
│ │ │ └── subscription.html.eex
│ │ ├── layout
│ │ │ ├── _flashes.html.eex
│ │ │ ├── _header.html.eex
│ │ │ ├── _styles.html.eex
│ │ │ ├── app.html.eex
│ │ │ ├── email.html.eex
│ │ │ ├── live.html.leex
│ │ │ ├── login.html.eex
│ │ │ └── root.html.leex
│ │ ├── recipients_preview
│ │ │ └── recipients_preview.html.eex
│ │ ├── search
│ │ │ └── new.html.eex
│ │ ├── session
│ │ │ └── new.html.eex
│ │ ├── settings
│ │ │ └── show.html.eex
│ │ ├── slack_channel
│ │ │ └── edit.html.eex
│ │ └── user_activation
│ │ │ └── index.html.eex
│ └── views
│ │ ├── announcement_list_view.ex
│ │ ├── announcement_view.ex
│ │ ├── api
│ │ ├── announcement_view.ex
│ │ ├── comment_view.ex
│ │ ├── interest_view.ex
│ │ ├── subscription_view.ex
│ │ ├── user_interest_view.ex
│ │ └── user_view.ex
│ │ ├── auth_view.ex
│ │ ├── changeset_view.ex
│ │ ├── comment_view.ex
│ │ ├── email_view.ex
│ │ ├── error_helpers.ex
│ │ ├── error_view.ex
│ │ ├── interest_view.ex
│ │ ├── layout_view.ex
│ │ ├── recipients_preview_view.ex
│ │ ├── search_view.ex
│ │ ├── session_view.ex
│ │ ├── settings_view.ex
│ │ ├── shared_view.ex
│ │ ├── slack_channel_view.ex
│ │ ├── subscription_view.ex
│ │ └── user_activation_view.ex
├── enum_helper.ex
└── mix
│ └── tasks
│ ├── constable.update_profile_image_urls.ex
│ ├── refresh_last_discussed_at.ex
│ └── send_daily_digest.ex
├── mix.exs
├── mix.lock
├── phoenix_static_buildpack.config
├── priv
└── repo
│ ├── migrations
│ ├── .formatter.exs
│ ├── 20150204170307_create_announcements.exs
│ ├── 20150206153434_create_users.exs
│ ├── 20150209232905_create_comments.exs
│ ├── 20150212191157_add_user_to_announcements.exs
│ ├── 20150212203030_add_user_to_comments.exs
│ ├── 20150213213319_add_name_to_users.exs
│ ├── 20150226150429_add_subscriptions_table.exs
│ ├── 20150226184100_create_interests.exs
│ ├── 20150227200438_create_announcements_interests.exs
│ ├── 20150227210639_create_users_interests.exs
│ ├── 20150825202821_add_username_to_users.exs
│ ├── 20150826133822_add_null_constraint_to_username.exs
│ ├── 20150909175723_add_notification_settings_to_users.exs
│ ├── 20150918165253_set_autosubscribe_default_to_true.exs
│ ├── 20150921193646_add_token_to_subscription.exs
│ ├── 20150921194253_generate_subscription_tokens.exs
│ ├── 20151218210106_add_slack_channel_to_interest.exs
│ ├── 20160422175132_add_last_discussed_to_announcements.exs
│ ├── 20160610142434_add_active_to_user.exs
│ ├── 20160624194052_add_not_null_constraint_to_required_foreign_keys.exs
│ ├── 20180413164621_add_slug_to_announcements.exs
│ ├── 20180511201013_make_announcement_slugs_non_nullable.exs
│ └── 20200527201059_add_profile_fields_to_users.exs
│ └── seeds.exs
├── script
└── heroku-compile-assets
└── test
├── constable
├── announcement_test.exs
├── comment_test.exs
├── emails_test.exs
├── interest_test.exs
├── mailers
│ ├── announcement_mailer_test.exs
│ └── comment_mailer_test.exs
├── markdown_test.exs
├── profiles_test.exs
├── pub_sub_test.exs
├── repo_test.exs
├── services
│ ├── announcement_creator_test.exs
│ ├── announcement_updater_test.exs
│ ├── comment_creator_test.exs
│ ├── daily_digest_test.exs
│ ├── email_reply_parser_test.exs
│ ├── google_strategy_test.exs
│ ├── hub_profile_test.exs
│ └── mention_finder_test.exs
├── slug_test.exs
├── user_interest_test.exs
└── user_test.exs
├── constable_web
├── acceptance
│ ├── user_announcement_test.exs
│ ├── user_controls_settings_test.exs
│ ├── user_manages_interests_test.exs
│ ├── user_searches_announcements_test.exs
│ ├── user_sets_slack_channel_for_interest_test.exs
│ └── user_unsubscribes_test.exs
├── controllers
│ ├── announcement_controller_test.exs
│ ├── api
│ │ ├── announcement_controller_test.exs
│ │ ├── comment_controller_test.exs
│ │ ├── interest_controller_test.exs
│ │ ├── search_controller_test.exs
│ │ ├── subscription_controller_test.exs
│ │ ├── user_controller_test.exs
│ │ └── user_interest_controller_test.exs
│ ├── auth_controller_test.exs
│ ├── comment_controller_test.exs
│ ├── email_forward_controller_test.exs
│ ├── email_reply_controller_test.exs
│ ├── home_controller_test.exs
│ ├── interest_controller_test.exs
│ ├── recipients_preview_controller_test.exs
│ ├── search_controller_test.exs
│ ├── session_controller_test.exs
│ ├── settings_controller_test.exs
│ ├── subscription_controller_test.exs
│ ├── unsubscribe_controller_test.exs
│ └── user_activation_controller_test.exs
├── live
│ └── announcement_live
│ │ └── show_test.exs
├── plugs
│ ├── api_auth_test.exs
│ ├── deslugifier_test.exs
│ ├── fetch_current_user_test.exs
│ └── require_web_login_test.exs
└── views
│ ├── api
│ ├── announcement_view_test.exs
│ ├── comment_view_test.exs
│ ├── interest_view_test.exs
│ ├── subscription_view_test.exs
│ ├── user_interest_view_test.exs
│ └── user_view_test.exs
│ ├── auth_view_test.exs
│ ├── email_view_test.exs
│ └── shared_view_test.exs
├── mix
└── send_daily_digest_test.exs
├── support
├── acceptance_case.ex
├── channel_case.ex
├── conn_case.ex
├── conn_case_helper.ex
├── data_case.ex
├── test_with_ecto.ex
├── view_case.ex
├── view_case_helper.ex
└── wallaby_helper.ex
└── test_helper.exs
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto, :phoenix],
3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"],
4 | subdirectories: ["priv/*/migrations"]
5 | ]
6 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: mix
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | time: "08:00"
8 | timezone: America/New_York
9 | open-pull-requests-limit: 10
10 | ignore:
11 | - dependency-name: slugger
12 | versions:
13 | - ">= 0.3.a, < 0.4"
14 | - dependency-name: floki
15 | versions:
16 | - 0.30.0
17 | - dependency-name: earmark
18 | versions:
19 | - 1.4.13
20 | - 1.4.14
21 | - dependency-name: phoenix_live_reload
22 | versions:
23 | - 1.3.0
24 | - dependency-name: postgrex
25 | versions:
26 | - 0.15.8
27 | - dependency-name: phoenix_live_view
28 | versions:
29 | - 0.15.4
30 | - dependency-name: ecto_sql
31 | versions:
32 | - 3.5.4
33 | - dependency-name: phoenix_html
34 | versions:
35 | - 2.14.3
36 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /_build/
2 | /cover/
3 | /deps/
4 | /doc/
5 | /.fetch
6 | erl_crash.dump
7 | *.ez
8 |
9 | assets/node_modules/
10 | priv/static
11 |
12 | sample_app-*.tar
13 |
14 | npm-debug.log
15 |
16 | .env
17 | *.tmlanguage.cache
18 | *.tmPreferences.cache
19 | *.stTheme.cache
20 | *.sublime-workspace
21 | *.sublime-project
22 | .dialyzer.plt
23 | .dialyzer.plt.hash
24 |
25 | screenshots
26 | /config/*.secret.exs
27 |
--------------------------------------------------------------------------------
/.hound.yml:
--------------------------------------------------------------------------------
1 | eslint:
2 | enabled: true
3 | config_file: assets/.eslintrc.json
4 | version: 5.16.0
5 | scss:
6 | enabled: false
7 | stylelint:
8 | config_file: assets/.stylelintrc.json
9 | enabled: true
10 |
--------------------------------------------------------------------------------
/.iex.exs:
--------------------------------------------------------------------------------
1 | use QuickAlias, Constable
2 |
3 | import Ecto.Query
4 |
--------------------------------------------------------------------------------
/.sample.env:
--------------------------------------------------------------------------------
1 | CLIENT_ID=get-value-from-staging
2 | CLIENT_SECRET=get-value-from-staging
3 | INBOUND_EMAIL_DOMAIN=inbound.staging.constable.io
4 | MANDRILL_KEY=GetFromMandrillSettings
5 | OUTBOUND_EMAIL_DOMAIN=staging.constable.io
6 | PERMITTED_EMAIL_DOMAIN=thoughtbot.com
7 | SHUBOX_KEY=get-from-shubox-dashboard
8 | SLACK_WEBHOOK_URL=https://fakeslack/webhook
9 | HUB_URL=https://staging-hub.thoughtbot.com
10 | HUB_API_TOKEN=get-API_TOKEN-value-from-thoughtbot-hub-staging
11 |
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | erlang 24.0
2 | elixir 1.12.1-otp-24
3 | nodejs 10.10.0
4 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: mix phx.server
2 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "constable",
3 | "description": "Constable Heroku review app config",
4 | "scripts": {
5 | "postdeploy": "mix ecto.migrate && mix run priv/repo/seeds.exs"
6 | },
7 | "env": {
8 | "ADMIN_EMAILS": {
9 | "required": true
10 | },
11 | "BUILDPACK_URL": {
12 | "required": true
13 | },
14 | "CLIENT_ID": {
15 | "required": true
16 | },
17 | "CLIENT_SECRET": {
18 | "required": true
19 | },
20 | "HEROKU_APP_NAME": {
21 | "required": true
22 | },
23 | "HONEYBADGER_API_KEY": {
24 | "required": true
25 | },
26 | "HONEYBADGER_ENV": "review",
27 | "INBOUND_EMAIL_DOMAIN": {
28 | "required": true
29 | },
30 | "MANDRILL_KEY": {
31 | "required": true
32 | },
33 | "MIX_ENV": {
34 | "required": true
35 | },
36 | "OAUTH_REDIRECT_OVERRIDE": "https://constable-oauth-redirector.herokuapp.com/auth",
37 | "OUTBOUND_EMAIL_DOMAIN": {
38 | "required": true
39 | },
40 | "PERMITTED_EMAIL_DOMAIN": {
41 | "required": true
42 | },
43 | "SECRET_KEY_BASE": {
44 | "generator": "secret"
45 | },
46 | "SHUBOX_KEY": {
47 | "required": true
48 | },
49 | "SLACK_WEBHOOK_URL": {
50 | "required": true
51 | },
52 | "URL_PORT": {
53 | "required": true
54 | }
55 | },
56 | "formation": {
57 | "web": {
58 | "quantity": 1
59 | }
60 | },
61 | "addons": [
62 | "heroku-postgresql",
63 | "scheduler"
64 | ],
65 | "buildpacks": [
66 | {
67 | "url": "https://github.com/HashNuke/heroku-buildpack-elixir"
68 | },
69 | {
70 | "url": "https://github.com/gjaldon/phoenix-static-buildpack"
71 | }
72 | ]
73 | }
74 |
--------------------------------------------------------------------------------
/assets/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/assets/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@thoughtbot/eslint-config",
3 | "env": {
4 | "browser": true,
5 | "es6": true,
6 | "jquery": true
7 | },
8 | "parserOptions": {
9 | "sourceType": "module",
10 | "ecmaFeatures": {
11 | "impliedStrict": true,
12 | "modules": true
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/assets/.stylelintignore:
--------------------------------------------------------------------------------
1 | css/vendor
2 |
--------------------------------------------------------------------------------
/assets/.stylelintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@thoughtbot/stylelint-config"
3 | }
4 |
--------------------------------------------------------------------------------
/assets/css/_shims.scss:
--------------------------------------------------------------------------------
1 | .tbds-button {
2 | font-family: $font-family-body;
3 | font-weight: $font-weight-medium;
4 |
5 | &:hover {
6 | text-decoration: none;
7 | }
8 | }
9 |
10 | .button-secondary {
11 | background-color: $white;
12 | border: $base-border;
13 | color: $base-font-color;
14 | transition: border-color $base-duration $base-timing;
15 |
16 | &:hover {
17 | background-color: $white;
18 | border-color: shade($base-border-color, 25%);
19 | color: $base-font-color;
20 | }
21 | }
22 |
23 | .app-header__announce-button {
24 | .tbds-button__icon--start {
25 | @media (max-width: $breakpoint-2) {
26 | margin-inline-end: 0;
27 | }
28 | }
29 | }
30 |
31 | .tbds-app-frame__header {
32 | flex-shrink: 0;
33 | }
34 |
35 | .tbds-badge {
36 | font-size: inherit;
37 | font-weight: inherit;
38 | }
39 |
--------------------------------------------------------------------------------
/assets/css/app.scss:
--------------------------------------------------------------------------------
1 | @import "~bourbon/core/bourbon";
2 |
3 | @import "~normalize.css/normalize";
4 |
5 | @import "../node_modules/nprogress/nprogress.css";
6 |
7 | @import "~@thoughtbot/design-system/src/index";
8 |
9 | @import "settings/variables";
10 |
11 | @import "generic/fonts";
12 |
13 | @import "elements/layout";
14 | @import "elements/lists";
15 | @import "elements/tables";
16 | @import "elements/typography";
17 |
18 | @import "objects/container";
19 |
20 | @import "~highlight.js/styles/github.css";
21 | @import "vendor/selectize";
22 |
23 | @import "components/announcement";
24 | @import "components/announcement-create";
25 | @import "components/announcement-form";
26 | @import "components/announcements";
27 | @import "components/app-header";
28 | @import "components/app-main";
29 | @import "components/at-mention";
30 | @import "components/box";
31 | @import "components/checkbox-switch";
32 | @import "components/comments";
33 | @import "components/flex-button-container";
34 | @import "components/flash";
35 | @import "components/help-block";
36 | @import "components/interest";
37 | @import "components/pagination";
38 | @import "components/page-header";
39 | @import "components/tabs";
40 | @import "components/uploadable-input";
41 |
42 | @import "scopes/links-no-underline";
43 |
44 | @import "utilities/font-size";
45 |
46 | @import "shims";
47 |
--------------------------------------------------------------------------------
/assets/css/components/_announcement-create.scss:
--------------------------------------------------------------------------------
1 | .flex-container {
2 | display: flex;
3 | justify-content: flex-start;
4 |
5 | @media (min-width: $breakpoint-1) {
6 | justify-content: center;
7 | }
8 | }
9 |
10 | .announcement-create,
11 | .announcement-preview {
12 | display: none;
13 | flex: 1;
14 | min-height: calc(100vh - 91px);
15 | min-width: calc(100vw / 2);
16 | padding: $large-spacing $base-spacing;
17 |
18 | @media (min-width: $breakpoint-1) {
19 | display: block;
20 | }
21 |
22 | &.active {
23 | display: block;
24 | }
25 | }
26 |
27 | .announcement-preview {
28 | border-left: 1px solid darken($lightest-gray, 4%);
29 |
30 | pre {
31 | overflow-x: auto;
32 | }
33 | }
34 |
35 | .header-tags {
36 | display: flex;
37 | justify-content: center;
38 | position: absolute;
39 | width: 100%;
40 | z-index: $mezzanine-z-index;
41 | }
42 |
43 | .header-tag {
44 | @include padding($tiny-spacing null $tiny-spacing $base-spacing);
45 | background-color: $light-gray;
46 | font-weight: $font-weight-bold;
47 | text-transform: uppercase;
48 | transition: background-color 0.1s ease-out;
49 | width: 100%;
50 |
51 | h2 {
52 | font-size: $tiny-font-size;
53 | }
54 |
55 | a {
56 | color: inherit;
57 | display: block;
58 |
59 | @media (min-width: $breakpoint-2) {
60 | cursor: default;
61 | }
62 | }
63 |
64 | &.active {
65 | background-color: $dark-blue;
66 | color: #fff;
67 |
68 | @media (min-width: $breakpoint-2) {
69 | background-color: $light-gray;
70 | color: $base-font-color;
71 | }
72 | }
73 | }
74 |
75 | .button-container {
76 | @include position(fixed, null 0 0 0);
77 | @include padding($base-spacing null);
78 | background-color: $white;
79 | border-top: $base-border;
80 | display: flex;
81 | justify-content: center;
82 | }
83 |
--------------------------------------------------------------------------------
/assets/css/components/_announcement-form.scss:
--------------------------------------------------------------------------------
1 | .col-markdown {
2 | textarea {
3 | min-height: 400px;
4 | }
5 | }
6 |
7 | .announcement-form__markdown {
8 | h1 {
9 | margin-top: 0;
10 | }
11 | }
12 |
13 | .recipients-preview {
14 | position: relative;
15 |
16 | .interested-user-names {
17 | @include position(absolute, 24px null null null);
18 | background-color: $white;
19 | border: $base-border;
20 | border-radius: $base-border-radius;
21 | box-shadow: 0 0 16px rgba($light-gray, 0.6);
22 | display: none;
23 | font-size: $small-font-size;
24 | padding: $small-spacing;
25 | width: 600px;
26 | z-index: $ceiling-z-index;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/assets/css/components/_announcements.scss:
--------------------------------------------------------------------------------
1 | .search-results-header {
2 | margin-top: $base-spacing;
3 | }
4 |
5 | .announcement-list-item {
6 | .announcement-metadata {
7 | margin-bottom: 0;
8 | }
9 |
10 | span {
11 | color: $medium-gray;
12 | }
13 |
14 | &:last-child {
15 | border-bottom: none;
16 | }
17 | }
18 |
19 | .announcement-list-item-heading {
20 | font-size: 1.75rem;
21 | font-weight: $font-weight-bold;
22 | line-height: $heading-line-height;
23 | margin-bottom: $tiniest-spacing;
24 | margin-top: 0;
25 | }
26 |
27 | .announcement-list-item-metadata {
28 | color: $secondary-font-color;
29 | font-size: $small-font-size;
30 | }
31 |
32 | .announcement-metadata-container {
33 | color: $secondary-font-color;
34 | display: flex;
35 | flex-wrap: wrap;
36 | font-size: $small-font-size;
37 |
38 | .announcement-metadata-item {
39 | flex: 1 0 auto;
40 | }
41 |
42 | .author {
43 | color: $base-font-color;
44 | font-weight: $font-weight-bold;
45 | }
46 |
47 | .subscription {
48 | @include margin($small-spacing 0);
49 | margin-left: 1rem;
50 | }
51 | }
52 |
53 | .commenters {
54 | margin-top: $small-spacing;
55 | }
56 |
57 | .commenters__avatar {
58 | @include size(32px);
59 | border: medium solid $white;
60 | border-radius: 50%;
61 | margin-right: -0.75rem;
62 | vertical-align: middle;
63 |
64 | &:first-of-type {
65 | @include size(40px);
66 | margin-right: 0;
67 | position: relative;
68 | z-index: $mezzanine-z-index;
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/assets/css/components/_app-header.scss:
--------------------------------------------------------------------------------
1 | .app-header {
2 | align-items: center;
3 | background-color: $lightest-gray;
4 | border-bottom: $base-border;
5 | display: flex;
6 | flex-wrap: wrap;
7 | justify-content: space-between;
8 | padding: $small-spacing
9 | calc(#{$base-spacing} + env(safe-area-inset-right))
10 | $small-spacing
11 | calc(#{$base-spacing} + env(safe-area-inset-left));
12 |
13 | @media (min-width: $breakpoint-1) {
14 | padding: $small-spacing $base-spacing;
15 | }
16 | }
17 |
18 | .app-header__logo {
19 | color: $dark-blue;
20 | font-size: $medium-font-size;
21 | font-weight: $font-weight-medium;
22 | order: 2;
23 | text-align: center;
24 |
25 | @media (min-width: $breakpoint-2) {
26 | margin-bottom: 0;
27 | }
28 | }
29 |
30 | .app-header__user-avatar {
31 | margin-right: $small-spacing;
32 | order: 1;
33 |
34 | @media (min-width: $breakpoint-2) {
35 | order: 3;
36 | }
37 | }
38 |
39 | .app-header__search-form {
40 | flex: 1 0 100%;
41 | margin-top: $small-spacing;
42 | order: 4;
43 | position: relative;
44 |
45 | @media (min-width: $breakpoint-2) {
46 | @include margin(0 auto null);
47 | flex: 0 0 18.75rem;
48 | order: 2;
49 | }
50 |
51 | &::before {
52 | @include position(absolute, 28% null null $tiny-spacing);
53 | @include size(1.125rem);
54 | background: url("/images/icon-search.svg") center / cover no-repeat;
55 | content: "";
56 | opacity: 0.75;
57 | z-index: $mezzanine-z-index;
58 | }
59 | }
60 |
61 | .app-header__search-input {
62 | padding-left: $base-spacing;
63 | }
64 |
65 | .app-header__announce-button {
66 | order: 3;
67 | }
68 |
69 | .app-header__announce-button-text {
70 | display: none;
71 |
72 | @media (min-width: $breakpoint-2) {
73 | display: block;
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/assets/css/components/_app-main.scss:
--------------------------------------------------------------------------------
1 | .app-main {
2 | padding: 0
3 | calc(#{$tiniest-spacing} + env(safe-area-inset-right))
4 | calc(#{$tiniest-spacing} + env(safe-area-inset-bottom))
5 | calc(#{$tiniest-spacing} + env(safe-area-inset-left));
6 | }
7 |
--------------------------------------------------------------------------------
/assets/css/components/_at-mention.scss:
--------------------------------------------------------------------------------
1 | @import "../static/vendor/textcomplete.css";
2 |
3 | .dropdown-menu {
4 | background-color: $white;
5 | border: none;
6 | border-radius: $base-border-radius;
7 | box-shadow: $base-box-shadow;
8 |
9 | li {
10 | border-top: $base-border;
11 | cursor: pointer;
12 | padding: $tiny-spacing $small-spacing;
13 |
14 | &.active,
15 | &:hover {
16 | background-color: $dark-gray;
17 | border-color: transparent;
18 |
19 | a {
20 | color: $white;
21 | text-decoration: none;
22 | }
23 | }
24 | }
25 | }
26 |
27 | .textcomplete-header,
28 | .textcomplete-footer {
29 | display: none;
30 | }
31 |
32 | .textcomplete-item {
33 | transition: background-color $base-transition-time;
34 |
35 | &.active {
36 | &:first-of-type {
37 | @include border-top-radius($base-border-radius);
38 | }
39 |
40 | &:last-of-type {
41 | @include border-bottom-radius($base-border-radius);
42 | }
43 | }
44 |
45 | a {
46 | align-items: center;
47 | color: $base-font-color;
48 | display: flex;
49 | font-size: $small-font-size;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/assets/css/components/_box.scss:
--------------------------------------------------------------------------------
1 | .box {
2 | background-color: $white;
3 | border: $base-border;
4 | border-radius: $base-border-radius;
5 | padding: $tbds-space-4;
6 | }
7 |
--------------------------------------------------------------------------------
/assets/css/components/_flash.scss:
--------------------------------------------------------------------------------
1 | .flash {
2 | color: $white;
3 | padding: $small-spacing;
4 | text-align: center;
5 | }
6 |
7 | .flash-error {
8 | background-color: $action-color;
9 | }
10 |
11 | .flash-info {
12 | background-color: $seafoam;
13 | }
14 |
--------------------------------------------------------------------------------
/assets/css/components/_flex-button-container.scss:
--------------------------------------------------------------------------------
1 | .flex-button-container {
2 | display: flex;
3 | flex-direction: row;
4 | flex-wrap: wrap;
5 | justify-content: space-between;
6 |
7 | > .flex-button-container-item {
8 | flex-grow: 1;
9 | margin: 0.4rem 1rem;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/assets/css/components/_help-block.scss:
--------------------------------------------------------------------------------
1 | .help-block {
2 | color: $error-color;
3 | font-style: italic;
4 | }
5 |
--------------------------------------------------------------------------------
/assets/css/components/_interest.scss:
--------------------------------------------------------------------------------
1 | .current-channel {
2 | color: $base-font-color;
3 | padding-top: $small-spacing;
4 | position: relative;
5 |
6 | a {
7 | color: $base-font-color;
8 | }
9 | }
10 |
11 | .interest-list-item {
12 | h3 {
13 | margin: 0;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/assets/css/components/_page-header.scss:
--------------------------------------------------------------------------------
1 | .page-header {
2 | margin-top: $large-spacing;
3 | }
4 |
--------------------------------------------------------------------------------
/assets/css/components/_pagination.scss:
--------------------------------------------------------------------------------
1 | .pagination {
2 | @include margin($base-spacing null);
3 | align-items: center;
4 | display: flex;
5 | justify-content: center;
6 | }
7 |
8 | .pagination-button {
9 | @include margin(null $tiny-spacing);
10 | }
11 |
--------------------------------------------------------------------------------
/assets/css/components/_tabs.scss:
--------------------------------------------------------------------------------
1 | .tabs {
2 | box-shadow: inset 0 -1px $light-gray;
3 | display: flex;
4 | font-size: $small-font-size;
5 | overflow-x: auto;
6 | width: 100%;
7 |
8 | @media (min-width: $breakpoint-1) {
9 | @include padding(null ($base-spacing - $small-spacing));
10 | }
11 |
12 | a {
13 | display: inline-block;
14 | padding: $small-spacing;
15 | white-space: nowrap;
16 |
17 | &.selected {
18 | box-shadow: inset 0 -2px $dark-gray;
19 | font-weight: $font-weight-medium;
20 | }
21 | }
22 | }
23 |
24 | .tabs__link--push {
25 | margin-left: auto;
26 | }
27 |
--------------------------------------------------------------------------------
/assets/css/components/_uploadable-input.scss:
--------------------------------------------------------------------------------
1 | $progress-bar-height: 5px;
2 |
3 | .uploadable-input {
4 | background-image: url("/images/upload-progress-bar-background.png");
5 | background-position: top left;
6 | background-repeat: no-repeat;
7 | background-size: 0 $progress-bar-height;
8 | display: block;
9 |
10 | @for $i from 1 through 99 {
11 | $percentage: $i;
12 |
13 | &[data-shubox-progress="#{$percentage}"] {
14 | background-size: ($percentage * 1%) $progress-bar-height;
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/assets/css/elements/_layout.scss:
--------------------------------------------------------------------------------
1 | html {
2 | box-sizing: border-box;
3 | }
4 |
5 | *,
6 | *::before,
7 | *::after {
8 | box-sizing: inherit;
9 | }
10 |
--------------------------------------------------------------------------------
/assets/css/elements/_lists.scss:
--------------------------------------------------------------------------------
1 | ul,
2 | ol {
3 | list-style-type: none;
4 | margin: 0;
5 | padding: 0;
6 | }
7 |
8 | dl {
9 | margin-bottom: $small-spacing;
10 |
11 | dt {
12 | font-weight: $font-weight-bold;
13 | margin-top: $small-spacing;
14 | }
15 |
16 | dd {
17 | margin: 0;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/assets/css/elements/_media.scss:
--------------------------------------------------------------------------------
1 | img {
2 | height: auto;
3 | max-width: 100%;
4 |
5 | @media (inverted-colors: inverted) {
6 | filter: invert(100%);
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/assets/css/elements/_tables.scss:
--------------------------------------------------------------------------------
1 | table {
2 | border-collapse: collapse;
3 | font-variant-numeric: tabular-nums;
4 | margin: $small-spacing 0;
5 | table-layout: fixed;
6 | width: 100%;
7 | }
8 |
9 | th {
10 | border-bottom: 1px solid shade($base-border-color, 25%);
11 | font-weight: $font-weight-bold;
12 | padding: $small-spacing 0;
13 | text-align: left;
14 | }
15 |
16 | td {
17 | border-bottom: $base-border;
18 | padding: $small-spacing 0;
19 | }
20 |
21 | tr,
22 | td,
23 | th {
24 | vertical-align: middle;
25 | }
26 |
--------------------------------------------------------------------------------
/assets/css/elements/_typography.scss:
--------------------------------------------------------------------------------
1 | body {
2 | color: $base-font-color;
3 | font-family: $font-family-body;
4 | font-size: $base-font-size;
5 | font-variant-ligatures: contextual common-ligatures;
6 | line-height: $base-line-height;
7 | }
8 |
9 | h1,
10 | h2,
11 | h3,
12 | h4,
13 | h5,
14 | h6 {
15 | font-family: $font-family-heading;
16 | font-size: $base-font-size;
17 | font-weight: $font-weight-bold;
18 | line-height: $heading-line-height;
19 | margin: 0 0 $small-spacing;
20 | }
21 |
22 | h1 {
23 | font-size: $large-font-size;
24 | }
25 |
26 | h2 {
27 | font-size: 20px;
28 | margin-bottom: 0;
29 | }
30 |
31 | h4 {
32 | font-size: $base-font-size;
33 | margin-bottom: 0;
34 | }
35 |
36 | b,
37 | strong {
38 | font-weight: $font-weight-bold;
39 | }
40 |
41 | blockquote,
42 | p {
43 | @include margin(0 null ($base-line-height * 0.75) null);
44 | }
45 |
46 | a {
47 | transition-duration: $base-duration;
48 | transition-property: color;
49 | transition-timing-function: $base-timing;
50 | word-wrap: break-word;
51 | }
52 |
53 | hr {
54 | border-bottom: $base-border;
55 | border-left: 0;
56 | border-right: 0;
57 | border-top: 0;
58 | margin: $base-spacing 0;
59 | }
60 |
--------------------------------------------------------------------------------
/assets/css/generic/_fonts.scss:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-display: swap;
3 | font-family: "Inter";
4 | font-style: normal;
5 | font-weight: 100 900;
6 | src: url("/fonts/Inter-roman.var.woff2") format("woff2");
7 | }
8 |
9 | @font-face {
10 | font-display: swap;
11 | font-family: "Inter";
12 | font-style: italic;
13 | font-weight: 100 900;
14 | src: url("/fonts/Inter-italic.var.woff2") format("woff2");
15 | }
16 |
--------------------------------------------------------------------------------
/assets/css/objects/_container.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | @include margin(null auto);
3 | @include padding(null $small-spacing);
4 | max-width: 40rem;
5 | }
6 |
7 | .container-x-small {
8 | max-width: 400px;
9 | }
10 |
11 | .container-pad-top {
12 | padding-top: $base-spacing;
13 | }
14 |
--------------------------------------------------------------------------------
/assets/css/scopes/_links-no-underline.scss:
--------------------------------------------------------------------------------
1 | .links-no-underline {
2 | a {
3 | text-decoration: none;
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/assets/css/utilities/_font-size.scss:
--------------------------------------------------------------------------------
1 | .u-font-size-large {
2 | font-size: $large-font-size;
3 | }
4 |
--------------------------------------------------------------------------------
/assets/js/checkbox-switch.js:
--------------------------------------------------------------------------------
1 | const A11YswitchCheck = function() {
2 | /**
3 | * Author: Scott O'Hara
4 | * Version: 0.1.0
5 | * License: https://github.com/scottaohara/a11y_styled_form_controls/blob/master/LICENSE
6 | */
7 | let el;
8 |
9 | /**
10 | * Initialize the instance, run all setup functions
11 | * and attach the necessary events.
12 | */
13 | this.init = function(elm) {
14 | el = elm;
15 | setRole(el);
16 | attachEvents(el);
17 | };
18 |
19 | /**
20 | * Check default state of element:
21 | * A toggle button is not particularly useful without JavaScript,
22 | * so ideally such a button would be set to hidden or disabled, if JS wasn't
23 | * around to make it function.
24 | */
25 | const setRole = function(el) {
26 | if (el.getAttribute('type') === 'checkbox') {
27 | el.setAttribute('role', 'switch');
28 | } else {
29 | console.error(el.id + ' is not a checkbox...');
30 | }
31 | };
32 |
33 | /**
34 | * Attach keyEvents to toggle buttons
35 | */
36 | let keyEvents = function(e) {
37 | let keyCode = e.keyCode || e.which;
38 |
39 | switch (keyCode) {
40 | case 13:
41 | e.preventDefault();
42 | e.target.click();
43 | break;
44 | }
45 | };
46 |
47 | /**
48 | * Events for toggle buttons
49 | */
50 | const attachEvents = function(el) {
51 | el.addEventListener('keypress', keyEvents, false);
52 | };
53 |
54 | return this;
55 | };
56 |
57 | $(document).ready(function() {
58 | $('.checkbox-switch > input[type=checkbox]').each(function(_index) {
59 | let a11ySwitch = new A11YswitchCheck();
60 | a11ySwitch.init(this);
61 | });
62 | });
63 |
--------------------------------------------------------------------------------
/assets/js/components/announcement-form-mobile.js:
--------------------------------------------------------------------------------
1 | export const setupForm = () => {
2 | const markdownHeader = $('.header-tag-markdown');
3 | const previewHeader = $('.header-tag-preview');
4 | const markdownContainer = $('.announcement-create');
5 | const previewContainer = $('.announcement-preview');
6 |
7 | markdownHeader.on('click', () => {
8 | markdownHeader.addClass('active');
9 | previewHeader.removeClass('active');
10 |
11 | markdownContainer.addClass('active');
12 | previewContainer.removeClass('active');
13 | });
14 |
15 | previewHeader.on('click', () => {
16 | previewHeader.addClass('active');
17 | markdownHeader.removeClass('active');
18 |
19 | previewContainer.addClass('active');
20 | markdownContainer.removeClass('active');
21 | });
22 | };
23 |
--------------------------------------------------------------------------------
/assets/js/components/recipients-preview.js:
--------------------------------------------------------------------------------
1 | export const updateRecipientsPreview = (interests) => {
2 | $.getJSON('/recipients_preview', { interests })
3 | .done((data) => $('.recipients-preview').html(data.recipients_preview_html));
4 | };
5 |
--------------------------------------------------------------------------------
/assets/js/components/user-autocomplete.js:
--------------------------------------------------------------------------------
1 | import { Textcomplete, Textarea } from 'textcomplete';
2 |
3 | const AT_REGEX = /(^|\s)@(\w*)$/;
4 |
5 | export const autocompleteUsers = (selector, users) => {
6 | const usernameStrategy = {
7 | id: 'username',
8 | match: AT_REGEX,
9 | search: (term, callback) => {
10 | term = term.toLowerCase();
11 |
12 | if (term === '') {
13 | callback(users);
14 | } else {
15 | const matches = users.filter((user) => {
16 | const name = user.name;
17 | const username = user.username;
18 | const matchesName = name.toLowerCase().startsWith(term);
19 | const matchesUsername = username.toLowerCase().startsWith(term);
20 |
21 | return matchesName || matchesUsername;
22 | });
23 | callback(matches);
24 | }
25 | },
26 |
27 | template(user, _term) {
28 | return ` ${user.username}`;
29 | },
30 |
31 | replace(user) {
32 | return `$1@${user.username} `;
33 | },
34 | };
35 |
36 | const editor = new Textarea(document.querySelector(selector));
37 | const textcomplete = new Textcomplete(editor);
38 | textcomplete.register([ usernameStrategy ]);
39 | };
40 |
--------------------------------------------------------------------------------
/assets/js/lib/remote-append.js:
--------------------------------------------------------------------------------
1 | $(document).on('click', '.remote-append', (event) => {
2 | event.preventDefault();
3 |
4 | $.ajax({
5 | url: event.currentTarget.href,
6 | method: 'GET',
7 | }).done((data) => $('body').append(data));
8 | });
9 |
--------------------------------------------------------------------------------
/assets/js/lib/syntax-highlighting.js:
--------------------------------------------------------------------------------
1 | import $ from 'jquery';
2 | import marked from 'marked';
3 | import hljs from 'highlight.js';
4 |
5 | marked.setOptions({
6 | highlight: (code) => {
7 | return hljs.highlightAuto(code).value;
8 | },
9 | });
10 |
11 | const observer = new MutationObserver(mutations => {
12 | mutations.forEach((_mutation) => {
13 | highlightCodeBlocks();
14 | });
15 | });
16 |
17 | const initializeSyntaxHighlighting = (_container) => {
18 | highlightCodeBlocks();
19 | };
20 |
21 | const highlightCodeBlocks = () => {
22 | $('pre code').each((_index, block) => {
23 | hljs.highlightBlock(block);
24 | });
25 | };
26 |
27 | export const markedWithSyntax = (value) => {
28 | return marked(value);
29 | };
30 |
31 | export const highlightSyntax = (container) => {
32 | observer.observe(document.querySelector(container), { childList: true });
33 | initializeSyntaxHighlighting(container);
34 | };
35 |
--------------------------------------------------------------------------------
/assets/js/lib/textarea-image-uploader.js:
--------------------------------------------------------------------------------
1 | import Shubox from 'shubox';
2 |
3 | const refreshMarkdownPreview = (inputSelector) => {
4 | $(inputSelector).trigger('input');
5 | };
6 |
7 | export const setupImageUploader = (selector) => {
8 | const shuboxOptions = {
9 | key: window.shuboxKey,
10 | textBehavior: 'append',
11 | clickable: false,
12 | uploadingTemplate: '![Uploading {{name}}...]()',
13 | successTemplate: '',
14 | success() {
15 | refreshMarkdownPreview(selector);
16 | },
17 | };
18 |
19 | if (typeof(Shubox) !== 'undefined') {
20 | new Shubox(selector, shuboxOptions);
21 | }
22 | };
23 |
--------------------------------------------------------------------------------
/assets/js/socket.js:
--------------------------------------------------------------------------------
1 | import { Socket } from 'phoenix';
2 |
3 | let socket = new Socket('/socket', {
4 | params: { token: window.userToken },
5 | });
6 |
7 | socket.connect();
8 |
9 | export default socket;
10 |
--------------------------------------------------------------------------------
/assets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "repository": {},
3 | "scripts": {
4 | "deploy": "webpack --mode production",
5 | "stylelint": "npx stylelint 'css/**/*.scss'",
6 | "watch": "webpack --mode development --watch-stdin",
7 | "eslint": "npx eslint js/**/*.js"
8 | },
9 | "dependencies": {
10 | "@thoughtbot/design-system": "0.7.0",
11 | "bourbon": "^6.0.0",
12 | "highlight.js": "^10.4.1",
13 | "jquery": "^3.5.0",
14 | "local-time": "^2.1.0",
15 | "marked": "^0.7.0",
16 | "mousetrap": "^1.6.3",
17 | "normalize.css": "^8.0.1",
18 | "nprogress": "^0.2.0",
19 | "phoenix": "file:../deps/phoenix",
20 | "phoenix_html": "file:../deps/phoenix_html",
21 | "phoenix_live_view": "file:../deps/phoenix_live_view",
22 | "sass": "^1.23.6",
23 | "selectize": "^0.12.6",
24 | "shubox": "^0.5.0",
25 | "textcomplete": "^0.18.0",
26 | "turbolinks": "^5.2.0"
27 | },
28 | "devDependencies": {
29 | "@babel/core": "^7.7.2",
30 | "@babel/preset-env": "^7.7.1",
31 | "@thoughtbot/eslint-config": "^0.1.0",
32 | "@thoughtbot/stylelint-config": "^1.1.0",
33 | "autoprefixer": "^9.7.2",
34 | "babel-loader": "^8.0.6",
35 | "copy-webpack-plugin": "^5.0.5",
36 | "css-loader": "^3.2.0",
37 | "eslint": "5.16.0",
38 | "mini-css-extract-plugin": "^0.8.0",
39 | "optimize-css-assets-webpack-plugin": "^5.0.3",
40 | "postcss-loader": "^3.0.0",
41 | "sass-loader": "^7.3.1",
42 | "stylelint": "^10.1.0",
43 | "terser-webpack-plugin": "^2.3.5",
44 | "webpack": "^4.41.2",
45 | "webpack-cli": "^3.3.10"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/assets/static/apple-touch-icon-114x114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon-114x114.png
--------------------------------------------------------------------------------
/assets/static/apple-touch-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon-120x120.png
--------------------------------------------------------------------------------
/assets/static/apple-touch-icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon-144x144.png
--------------------------------------------------------------------------------
/assets/static/apple-touch-icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon-152x152.png
--------------------------------------------------------------------------------
/assets/static/apple-touch-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon-180x180.png
--------------------------------------------------------------------------------
/assets/static/apple-touch-icon-57x57.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon-57x57.png
--------------------------------------------------------------------------------
/assets/static/apple-touch-icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon-72x72.png
--------------------------------------------------------------------------------
/assets/static/apple-touch-icon-76x76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon-76x76.png
--------------------------------------------------------------------------------
/assets/static/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/apple-touch-icon.png
--------------------------------------------------------------------------------
/assets/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/favicon.ico
--------------------------------------------------------------------------------
/assets/static/fonts/Inter-italic.var.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/fonts/Inter-italic.var.woff2
--------------------------------------------------------------------------------
/assets/static/fonts/Inter-roman.var.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/fonts/Inter-roman.var.woff2
--------------------------------------------------------------------------------
/assets/static/images/icon-search.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/assets/static/images/ralph.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/images/ralph.png
--------------------------------------------------------------------------------
/assets/static/images/upload-progress-bar-background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thoughtbot/constable/d2d427497ba5fa225378d1c094e54848cf5e753b/assets/static/images/upload-progress-bar-background.png
--------------------------------------------------------------------------------
/assets/static/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /
3 |
--------------------------------------------------------------------------------
/assets/static/vendor/textcomplete.css:
--------------------------------------------------------------------------------
1 | /* Sample */
2 |
3 | .dropdown-menu {
4 | border: 1px solid #ddd;
5 | background-color: white;
6 | }
7 |
8 | .dropdown-menu li {
9 | border-top: 1px solid #ddd;
10 | padding: 2px 5px;
11 | }
12 |
13 | .dropdown-menu li:first-child {
14 | border-top: none;
15 | }
16 |
17 | .dropdown-menu li:hover,
18 | .dropdown-menu .active {
19 | background-color: rgb(110, 183, 219);
20 | }
21 |
22 |
23 | /* SHOULD not modify */
24 |
25 | .dropdown-menu {
26 | list-style: none;
27 | padding: 0;
28 | margin: 0;
29 | }
30 |
31 | .dropdown-menu a:hover {
32 | cursor: pointer;
33 | }
34 |
--------------------------------------------------------------------------------
/assets/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const glob = require('glob');
3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
4 | const TerserPlugin = require('terser-webpack-plugin');
5 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
6 | const CopyWebpackPlugin = require('copy-webpack-plugin');
7 | const autoprefix = require('autoprefixer');
8 | const webpack = require('webpack');
9 |
10 | module.exports = (env, options) => ({
11 | optimization: {
12 | minimizer: [
13 | new TerserPlugin(),
14 | new OptimizeCSSAssetsPlugin({}),
15 | ],
16 | },
17 | entry: {
18 | './js/app.js': [ './js/app.js' ].concat(glob.sync('./vendor/**/*.js')),
19 | },
20 | output: {
21 | filename: 'app.js',
22 | path: path.resolve(__dirname, '../priv/static/js'),
23 | },
24 | module: {
25 | rules: [
26 | {
27 | test: /\.js$/,
28 | exclude: /node_modules/,
29 | use: {
30 | loader: 'babel-loader',
31 | },
32 | },
33 | {
34 | test: /\.(sa|sc|c)ss$/,
35 | use: [
36 | { loader: MiniCssExtractPlugin.loader },
37 | { loader: 'css-loader' },
38 | {
39 | loader: 'postcss-loader',
40 | options: {
41 | plugins: [ autoprefix('last 2 versions') ],
42 | },
43 | },
44 | { loader: 'sass-loader' },
45 | ],
46 | },
47 | ],
48 | },
49 | plugins: [
50 | new webpack.ProvidePlugin({
51 | $: 'jquery',
52 | jQuery: 'jquery',
53 | }),
54 | new MiniCssExtractPlugin({ filename: '../css/app.css' }),
55 | new CopyWebpackPlugin([ { from: 'static/', to: '../' } ]),
56 | ],
57 | });
58 |
--------------------------------------------------------------------------------
/bin/clone_prod_db_to_dev:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | # Exit if any subcommand fails
4 | set -e
5 |
6 | echo "Dropping existing db"
7 | mix ecto.drop
8 |
9 | echo "Importing production data into local development database"
10 | heroku pg:pull DATABASE_URL constable_api_development --remote production
11 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | set -e
4 |
5 | if [ -z "$1" ]; then
6 | iex -S mix
7 | else
8 | heroku run iex -S mix --app constable-api-$1
9 | fi
10 |
--------------------------------------------------------------------------------
/bin/deploy:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | set -e
4 |
5 | if [ -z "$1" ]; then
6 | echo "Specify an environment (staging|production)"
7 | else
8 | heroku config:set BUILDPACK_URL="https://github.com/HashNuke/heroku-buildpack-elixir.git" --app constable-api-$1
9 | git push $1 main; heroku run mix ecto.migrate --app constable-api-$1
10 | fi
11 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | # Exit if any subcommand fails
4 | set -e
5 |
6 | # set up environment variables if not set up yet
7 | if [ ! -f .env ]; then
8 | echo "Copying .env file"
9 | cp .sample.env .env
10 | fi
11 |
12 | # check if chromedriver is installed
13 | if ! command -v chromedriver >/dev/null; then
14 | echo "You must install chromedriver before continuing."
15 | exit 1
16 | fi
17 |
18 | echo "Removing previous build artifacts"
19 | rm -rf deps _build
20 |
21 | # Set up Elixir and Phoenix
22 | if ! command -v mix >/dev/null; then
23 | echo "It looks like you don't have Elixir installed."
24 | echo "See http://elixir-lang.org/install.html for instructions."
25 | exit 1
26 | fi
27 |
28 | echo "Installing dependencies and compiling"
29 | mix local.hex --force
30 | mix deps.get
31 | mix deps.compile
32 | mix compile
33 |
34 | # Set up database
35 | echo "Setting up the database"
36 | mix ecto.create
37 | mix ecto.migrate
38 | mix run priv/repo/seeds.exs
39 |
40 | # Grab JS dependencies from NPM
41 | echo "Installing npm dependencies"
42 | cd assets && npm install && cd ../
43 |
44 | # Only if this isn't CI
45 | if [ -z "$CI" ]; then
46 | # Set up Heroku
47 | echo "Setting up Heroku and git remotes"
48 | heroku join --app constable-api-staging || true
49 | heroku git:remote -r staging -a constable-api-staging || true
50 |
51 | heroku join --app constable-api-production || true
52 | heroku git:remote -r production -a constable-api-production || true
53 |
54 | if ! grep -E -q 'CLIENT_ID |CLIENT_SECRET' .env; then
55 | heroku config --shell --remote staging | grep -E 'CLIENT_ID|CLIENT_SECRET' >> .env
56 | fi
57 | fi
58 |
--------------------------------------------------------------------------------
/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 |
7 | # General application configuration
8 | use Mix.Config
9 |
10 | config :constable,
11 | ecto_repos: [Constable.Repo]
12 |
13 | # Configures the endpoint
14 | config :constable, ConstableWeb.Endpoint,
15 | url: [host: "localhost"],
16 | secret_key_base: "tJ+MdrPlKWMpmz7JyJgSu/11xvwnNZo7Sz8IAacy9MM6di3GqackE9iNjhkHI9p8",
17 | render_errors: [view: ConstableWeb.ErrorView, accepts: ~w(html json)],
18 | pubsub_server: Constable.PubSub,
19 | live_view: [signing_salt: "9CpMX+WbuB/UCRZ3VqJ6Pt0AUKvOhj/T"]
20 |
21 | # Configures Elixir's Logger
22 | config :logger, :console,
23 | format: "$time $metadata[$level] $message\n",
24 | metadata: [:request_id]
25 |
26 | # Use Jason for JSON parsing in Phoenix
27 | config :phoenix, :json_library, Jason
28 |
29 | # To sign in, users must have an email in this domain
30 | config :constable, :permitted_email_domain, System.get_env("PERMITTED_EMAIL_DOMAIN")
31 |
32 | # The shubox key is unique to each app/env
33 | config :constable, :shubox_key, System.get_env("SHUBOX_KEY")
34 |
35 | # Hub
36 | config :constable, :hub_url, System.get_env("HUB_URL")
37 | config :constable, :hub_api_token, System.get_env("HUB_API_TOKEN")
38 |
39 | config :oauth2, serializers: %{"application/json" => Poison}
40 |
41 | # Import environment specific config. This must remain at the bottom
42 | # of this file so it overrides the configuration defined above.
43 | import_config "#{Mix.env()}.exs"
44 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | config :constable, ConstableWeb.Endpoint,
4 | http: [port: System.get_env("PORT") || 4000],
5 | url: [host: "localhost"],
6 | debug_errors: true,
7 | code_reloader: true,
8 | check_origin: false,
9 | watchers: [
10 | node: [
11 | "node_modules/webpack/bin/webpack.js",
12 | "--mode",
13 | "development",
14 | "--watch-stdin",
15 | cd: Path.expand("../assets", __DIR__)
16 | ]
17 | ]
18 |
19 | # Watch static and templates for browser reloading.
20 | config :constable, ConstableWeb.Endpoint,
21 | live_reload: [
22 | patterns: [
23 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
24 | ~r{priv/gettext/.*(po)$},
25 | ~r{lib/constable_web/views/.*(ex)$},
26 | ~r{lib/constable_web/templates/.*(eex)$}
27 | ]
28 | ]
29 |
30 | # Do not include metadata nor timestamps in development logs
31 | config :logger, :console, format: "[$level] $message\n"
32 |
33 | # Set a higher stacktrace during development. Avoid configuring such
34 | # in production as building large stacktraces may be expensive.
35 | config :phoenix, :stacktrace_depth, 20
36 |
37 | # Initialize plugs at runtime for faster development compilation
38 | config :phoenix, :plug_init_mode, :runtime
39 |
40 | # Configure your database
41 | config :constable, Constable.Repo,
42 | adapter: Ecto.Adapters.Postgres,
43 | database: "constable_api_development",
44 | hostname: "localhost"
45 |
46 | config :constable, Constable.Mailer, adapter: Bamboo.LocalAdapter
47 |
48 | config :honeybadger, :environment_name, :dev
49 |
--------------------------------------------------------------------------------
/config/locales/en.exs:
--------------------------------------------------------------------------------
1 | [
2 | hello: "Hello"
3 | ]
4 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | config :constable, ConstableWeb.Endpoint,
4 | http: [port: System.get_env("PORT") || 4002],
5 | server: true
6 |
7 | # Print only warnings and errors during test
8 | config :logger, level: :warn
9 |
10 | # Configure your database
11 | config :constable, Constable.Repo,
12 | adapter: Ecto.Adapters.Postgres,
13 | database: "constable_api_test",
14 | hostname: "localhost",
15 | username: System.get_env("POSTGRES_USER") || "postgres",
16 | pool: Ecto.Adapters.SQL.Sandbox
17 |
18 | config :constable, :sql_sandbox, true
19 |
20 | config :constable, :shubox_key, "111"
21 |
22 | config :constable, Constable.Mailer, adapter: Bamboo.TestAdapter
23 |
24 | config :honeybadger, :environment_name, :test
25 |
26 | # The tests all use thoughtbot.com user emails
27 | config :constable, :permitted_email_domain, "thoughtbot.com"
28 |
29 | config :wallaby,
30 | max_wait_time: 250,
31 | js_logger: false,
32 | driver: Wallaby.Chrome
33 |
34 | # Set a higher stacktrace during test.
35 | config :phoenix, :stacktrace_depth, 35
36 |
--------------------------------------------------------------------------------
/elixir_buildpack.config:
--------------------------------------------------------------------------------
1 | erlang_version=24.0
2 | elixir_version=1.12.1
3 | always_rebuild=true
4 | config_vars_to_export=(DATABASE_URL MANDRILL_KEY URL_PORT SHUBOX_KEY PERMITTED_EMAIL_DOMAIN)
5 |
--------------------------------------------------------------------------------
/lib/constable.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable do
2 | @moduledoc """
3 | Constable keeps the contexts that define your domain
4 | and business logic.
5 | Contexts are also responsible for managing your data, regardless
6 | if it comes from the database, an external API or others.
7 | """
8 | end
9 |
--------------------------------------------------------------------------------
/lib/constable/announcement_interest.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.AnnouncementInterest do
2 | use Ecto.Schema
3 | alias Constable.Announcement
4 | alias Constable.Interest
5 |
6 | schema "announcements_interests" do
7 | timestamps()
8 |
9 | belongs_to :announcement, Announcement
10 | belongs_to :interest, Interest
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/constable/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 |
5 | @moduledoc false
6 |
7 | use Application
8 |
9 | def start(_type, _args) do
10 | unless Mix.env() == "production" do
11 | Envy.auto_load()
12 | Envy.reload_config()
13 | end
14 |
15 | Neuron.Config.set(url: "#{Application.fetch_env!(:constable, :hub_url)}/graphql")
16 |
17 | Neuron.Config.set(
18 | headers: [authorization: "Bearer #{Application.fetch_env!(:constable, :hub_api_token)}"]
19 | )
20 |
21 | setup_dependencies()
22 |
23 | children = [
24 | Constable.Repo,
25 | {Phoenix.PubSub, name: Constable.PubSub},
26 | ConstableWeb.Endpoint,
27 | {Task.Supervisor, name: Constable.TaskSupervisor}
28 | ]
29 |
30 | opts = [strategy: :one_for_one, name: Constable.Supervisor]
31 | Supervisor.start_link(children, opts)
32 | end
33 |
34 | defp setup_dependencies do
35 | Constable.Pact.start_link()
36 | end
37 |
38 | # Tell Phoenix to update the endpoint configuration
39 | # whenever the application is updated.
40 | def config_change(changed, _new, removed) do
41 | ConstableWeb.Endpoint.config_change(changed, removed)
42 | :ok
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/lib/constable/comment.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Comment do
2 | use ConstableWeb, :schema
3 | alias Constable.Announcement
4 | alias Constable.User
5 |
6 | schema "comments" do
7 | field :body
8 |
9 | belongs_to :user, User
10 | belongs_to :announcement, Announcement
11 | timestamps()
12 | end
13 |
14 | def create_changeset(model \\ %__MODULE__{}, params) do
15 | create_changeset(model, params, DateTime.utc_now())
16 | end
17 |
18 | def create_changeset(model, params, last_discussed_at) do
19 | model
20 | |> cast(params, ~w(announcement_id user_id body)a)
21 | |> validate_required(:body)
22 | |> set_last_discussed_at(last_discussed_at)
23 | end
24 |
25 | def update_changeset(model, params) do
26 | model
27 | |> cast(params, ~w(body)a)
28 | |> validate_required(:body)
29 | end
30 |
31 | defp set_last_discussed_at(changeset, last_discussed_at) do
32 | prepare_changes(changeset, fn changeset ->
33 | Announcement
34 | |> where(id: ^get_field(changeset, :announcement_id))
35 | |> changeset.repo.update_all(set: [last_discussed_at: last_discussed_at])
36 |
37 | changeset
38 | end)
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/lib/constable/env.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Env do
2 | @doc """
3 | Gets a variable from the environment and throws an error if it does not exist
4 | """
5 | def get(key) do
6 | System.get_env() |> Map.fetch!(key)
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/lib/constable/interest.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Interest do
2 | defimpl Phoenix.Param do
3 | def to_param(%{name: name}) do
4 | "#{name}"
5 | end
6 | end
7 |
8 | use ConstableWeb, :schema
9 | alias Constable.{Announcement, User}
10 | alias Constable.{AnnouncementInterest, UserInterest}
11 |
12 | schema "interests" do
13 | field :name
14 | field :slack_channel
15 | timestamps()
16 |
17 | many_to_many :announcements, Announcement, join_through: AnnouncementInterest
18 | many_to_many :users, User, join_through: UserInterest
19 | end
20 |
21 | def changeset(interest \\ %__MODULE__{}, params) do
22 | interest
23 | |> cast(params, ~w(name slack_channel)a)
24 | |> validate_required(:name)
25 | |> update_change(:name, &String.trim(&1))
26 | |> update_change(:name, &String.replace(&1, "#", ""))
27 | |> update_change(:name, &String.replace(&1, " ", "-"))
28 | |> update_change(:name, &String.downcase/1)
29 | |> unique_constraint(:name)
30 | end
31 |
32 | def update_channel_changeset(interest, channel_name) do
33 | interest
34 | |> cast(%{slack_channel: channel_name}, ~w(slack_channel)a)
35 | |> update_change(:slack_channel, &Regex.replace(~r/^#*/, &1, "#"))
36 | end
37 |
38 | def ordered_by_name(query \\ __MODULE__) do
39 | from i in query, order_by: [asc: i.name]
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/constable/mailer.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Mailer do
2 | use Bamboo.Mailer, otp_app: :constable
3 | end
4 |
--------------------------------------------------------------------------------
/lib/constable/markdown.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Markdown do
2 | alias Earmark.Options
3 |
4 | def to_html(markdown) do
5 | markdown
6 | |> Earmark.as_html!(%Options{smartypants: false})
7 | |> HtmlSanitizeEx.Scrubber.scrub(Constable.CustomScrubber)
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/constable/pact.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Pact do
2 | use Pact
3 | alias Constable.Services.HubProfileProvider
4 |
5 | register(:daily_digest, Constable.DailyDigest)
6 | register(:google_strategy, GoogleStrategy)
7 | register(:profile_provider, HubProfileProvider)
8 | register(:oauth_redirect_strategy, OAuthRedirectStrategy)
9 | register(:request_with_access_token, OAuth2.Client)
10 | register(:token_retriever, OAuth2.Strategy.AuthCode)
11 | end
12 |
--------------------------------------------------------------------------------
/lib/constable/plugs/deslugifier.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Plugs.Deslugifier do
2 | alias Constable.Slug
3 |
4 | def init(opts) do
5 | case Keyword.get(opts, :slugified_key) do
6 | nil -> raise "Must provide a :slugified_key to #{inspect(__MODULE__)}"
7 | key -> key
8 | end
9 | end
10 |
11 | def call(conn, key) do
12 | with {:ok, slugified_id} <- Map.fetch(conn.params, key),
13 | {:ok, id} <- Slug.deslugify(slugified_id) do
14 | put_in(conn, [Access.key!(:params), key], id)
15 | else
16 | _ -> conn
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/constable/plugs/fetch_current_user.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Plugs.FetchCurrentUser do
2 | import Plug.Conn
3 |
4 | alias Constable.{Repo, User, UserIdentifier}
5 |
6 | def init(opts), do: opts
7 |
8 | def call(conn, _) do
9 | case find_user(conn) do
10 | :not_signed_in ->
11 | conn
12 |
13 | nil ->
14 | conn
15 |
16 | user ->
17 | conn
18 | |> assign(:current_user, user)
19 | |> put_session(:current_user_id, user.id)
20 | end
21 | end
22 |
23 | def find_user(conn) do
24 | case UserIdentifier.verify_signed_user_id(conn) do
25 | {:ok, user_id} -> Repo.get(User.active(), user_id)
26 | {:error, _} -> :not_signed_in
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/constable/plugs/require_api_login.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Plugs.RequireApiLogin do
2 | import Plug.Conn
3 |
4 | alias Constable.Repo
5 | alias Constable.User
6 |
7 | def init(default), do: default
8 |
9 | def call(conn, _) do
10 | case find_user(conn) do
11 | nil ->
12 | unauthorized(conn)
13 |
14 | user ->
15 | conn |> assign(:current_user, user)
16 | end
17 | end
18 |
19 | def find_user(conn) do
20 | fetch_token(conn)
21 | |> find_user_from_token
22 | end
23 |
24 | def fetch_token(conn) do
25 | get_req_header(conn, "authorization") |> List.first()
26 | end
27 |
28 | def find_user_from_token(nil), do: nil
29 |
30 | def find_user_from_token(token) do
31 | Repo.get_by(User.active(), token: token)
32 | end
33 |
34 | def unauthorized(conn) do
35 | conn |> send_resp(401, "") |> halt
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/constable/plugs/require_web_login.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Plugs.RequireWebLogin do
2 | import Plug.Conn
3 |
4 | def init(opts), do: opts
5 |
6 | def call(conn, _) do
7 | if conn.assigns[:current_user] do
8 | conn
9 | else
10 | conn |> redirect_to_login
11 | end
12 | end
13 |
14 | defp redirect_to_login(conn) do
15 | conn
16 | |> maybe_store_request_path
17 | |> Phoenix.Controller.redirect(to: "/session/new")
18 | |> halt
19 | end
20 |
21 | defp maybe_store_request_path(conn) do
22 | if conn.method == "GET" do
23 | conn |> put_session(:original_request_path, conn.request_path)
24 | else
25 | conn
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/constable/plugs/set_user_id_from_params.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Plugs.SetUserIdFromParams do
2 | alias Constable.UserIdentifier
3 |
4 | def init(opts), do: opts
5 |
6 | def call(conn, _opts) do
7 | if user_id = conn.params["as"] do
8 | set_user_id_cookie(conn, user_id)
9 | else
10 | conn
11 | end
12 | end
13 |
14 | defp set_user_id_cookie(conn, user_id) do
15 | signed_user_id = UserIdentifier.sign_user_id(conn, user_id)
16 | conn |> Plug.Conn.put_resp_cookie("user_id", signed_user_id)
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/constable/profiles.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Profiles do
2 | alias Constable.{User, Repo}
3 |
4 | def update_profile_info(user) do
5 | provider = Constable.Pact.get(:profile_provider)
6 | profile_url = provider.fetch_profile_url(user)
7 | image_url = provider.fetch_image_url(user)
8 |
9 | user
10 | |> User.profile_changeset(%{profile_url: profile_url, profile_image_url: image_url})
11 | |> Repo.update()
12 | end
13 |
14 | def update_profile_image_urls(users) do
15 | provider = Constable.Pact.get(:profile_provider)
16 |
17 | Enum.map(users, fn user ->
18 | image_url = provider.fetch_image_url(user)
19 |
20 | user
21 | |> User.profile_changeset(%{profile_image_url: image_url})
22 | |> Repo.update()
23 | end)
24 |
25 | :ok
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/lib/constable/pub_sub.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.PubSub do
2 | def subscribe_to_announcement(announcement_id) do
3 | Phoenix.PubSub.subscribe(__MODULE__, announcement_topic(announcement_id))
4 | end
5 |
6 | def broadcast_new_comment(comment) do
7 | Phoenix.PubSub.broadcast(
8 | __MODULE__,
9 | announcement_topic(comment.announcement_id),
10 | {:new_comment, comment}
11 | )
12 | end
13 |
14 | defp announcement_topic(announcement_id) do
15 | "announcement:#{announcement_id}"
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/constable/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Repo do
2 | use Ecto.Repo,
3 | otp_app: :constable,
4 | adapter: Ecto.Adapters.Postgres
5 |
6 | use Scrivener, page_size: 30
7 | import Ecto.Query
8 |
9 | def count(query) do
10 | one!(from record in query, select: count(record.id))
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/constable/serializers/announcement.ex:
--------------------------------------------------------------------------------
1 | defimpl Poison.Encoder, for: Constable.Announcement do
2 | alias Constable.Repo
3 | @attributes ~W(
4 | id
5 | title
6 | body
7 | user
8 | comments
9 | interests
10 | inserted_at
11 | updated_at
12 | )a
13 |
14 | def encode(announcement, _options) do
15 | announcement
16 | |> Repo.preload([:comments, :user, :interests])
17 | |> Map.take(@attributes)
18 | |> set_interests
19 | |> Poison.encode!()
20 | end
21 |
22 | def set_interests(announcement) do
23 | interest_names = Enum.map(announcement.interests, fn interest -> interest.name end)
24 | Map.put(announcement, "interests", interest_names)
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/constable/serializers/comment.ex:
--------------------------------------------------------------------------------
1 | defimpl Poison.Encoder, for: Constable.Comment do
2 | @attributes ~W(id body user announcement_id inserted_at)a
3 |
4 | def encode(comment, _options) do
5 | comment |> Map.take(@attributes) |> Poison.encode!()
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/lib/constable/serializers/interest.ex:
--------------------------------------------------------------------------------
1 | defimpl Poison.Encoder, for: Constable.Interest do
2 | def encode(interest, _options) do
3 | %{
4 | id: interest.id,
5 | name: interest.name
6 | }
7 | |> Poison.encode!()
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/constable/serializers/serializers.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Serializers do
2 | def ids_as_keys(objects) do
3 | Enum.reduce(objects, %{}, fn object, objects ->
4 | Map.put(objects, to_string(object.id), object)
5 | end)
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/lib/constable/serializers/subscription.ex:
--------------------------------------------------------------------------------
1 | defimpl Poison.Encoder, for: Constable.Subscription do
2 | def encode(subscription, _options) do
3 | %{
4 | id: subscription.id,
5 | user_id: subscription.user_id,
6 | announcement_id: subscription.announcement_id
7 | }
8 | |> Poison.encode!()
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/lib/constable/serializers/user.ex:
--------------------------------------------------------------------------------
1 | defimpl Poison.Encoder, for: Constable.User do
2 | use ConstableWeb, :serializer
3 | alias Constable.Repo
4 |
5 | def encode(user, _options) do
6 | user = user |> Repo.preload([:subscriptions, :user_interests])
7 |
8 | %{
9 | id: user.id,
10 | email: user.email,
11 | name: user.name,
12 | profile_image_url: user.profile_image_url,
13 | user_interests: user.user_interests,
14 | subscriptions: user.subscriptions
15 | }
16 | |> Poison.encode!([])
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/constable/serializers/user_interest.ex:
--------------------------------------------------------------------------------
1 | defimpl Poison.Encoder, for: Constable.UserInterest do
2 | def encode(user_interest, _options) do
3 | %{
4 | id: user_interest.id,
5 | user_id: user_interest.user_id,
6 | interest_id: user_interest.interest_id
7 | }
8 | |> Poison.encode!()
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/lib/constable/services/announcement_interest_associator.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Services.AnnouncementInterestAssociator do
2 | alias Constable.Repo
3 | alias Constable.Interest
4 | alias Constable.AnnouncementInterest
5 |
6 | alias ConstableWeb.Api.InterestView
7 |
8 | def add_interests(announcement, names) do
9 | get_or_create_interests(names)
10 | |> associate_interests_with_announcement(announcement)
11 | end
12 |
13 | defp get_or_create_interests(names) do
14 | List.wrap(names)
15 | |> Enum.reject(&blank_interest?/1)
16 | |> Enum.map(fn name ->
17 | interest = Interest.changeset(%{name: name})
18 |
19 | case Repo.get_by(Interest, interest.changes) do
20 | nil -> create_and_broadcast(interest)
21 | interest -> interest
22 | end
23 | end)
24 | |> Enum.uniq()
25 | end
26 |
27 | defp associate_interests_with_announcement(interests, announcement) do
28 | Enum.each(interests, fn interest ->
29 | %AnnouncementInterest{
30 | interest_id: interest.id,
31 | announcement_id: announcement.id
32 | }
33 | |> Repo.insert!()
34 | end)
35 |
36 | announcement
37 | end
38 |
39 | defp blank_interest?(" " <> rest), do: blank_interest?(rest)
40 | defp blank_interest?(""), do: true
41 | defp blank_interest?(_), do: false
42 |
43 | defp create_and_broadcast(changeset) do
44 | interest = changeset |> Repo.insert!()
45 |
46 | ConstableWeb.Endpoint.broadcast!(
47 | "update",
48 | "interest:add",
49 | InterestView.render("show.json", %{interest: interest})
50 | )
51 |
52 | interest
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/lib/constable/services/announcement_updater.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Services.AnnouncementUpdater do
2 | import Ecto.Query
3 |
4 | alias Constable.Announcement
5 | alias Constable.AnnouncementInterest
6 | alias Constable.Services.AnnouncementInterestAssociator
7 | alias Constable.Repo
8 |
9 | def update(announcement, params, interest_names) do
10 | changeset = Announcement.update_changeset(announcement, params)
11 |
12 | case Repo.update(changeset) do
13 | {:ok, announcement} ->
14 | announcement
15 | |> clear_interests
16 | |> update_interests(interest_names)
17 |
18 | {:ok, announcement}
19 |
20 | error ->
21 | error
22 | end
23 | end
24 |
25 | defp clear_interests(announcement) do
26 | Repo.delete_all(
27 | from ai in AnnouncementInterest,
28 | where: ai.announcement_id == ^announcement.id
29 | )
30 |
31 | announcement
32 | end
33 |
34 | defp update_interests(announcement, names) do
35 | AnnouncementInterestAssociator.add_interests(announcement, names)
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/constable/services/comment_creator.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Services.CommentCreator do
2 | alias Constable.Comment
3 | alias Constable.Repo
4 | alias Constable.Services.MentionFinder
5 | alias Constable.Subscription
6 | alias Constable.Emails
7 | alias Constable.Mailer
8 |
9 | def create(params) do
10 | changeset = Comment.create_changeset(params)
11 |
12 | case Repo.insert(changeset) do
13 | {:ok, comment} ->
14 | comment = comment |> Repo.preload([:user, announcement: :user])
15 | broadcast(comment)
16 | mentioned_users = email_mentioned_users(comment)
17 | email_subscribers(comment, mentioned_users)
18 | subscribe_comment_author(comment)
19 | {:ok, comment}
20 |
21 | {:error, changeset} ->
22 | {:error, changeset}
23 | end
24 | end
25 |
26 | defp subscribe_comment_author(comment) do
27 | comment
28 | |> Map.take([:announcement_id, :user_id])
29 | |> Subscription.changeset()
30 | |> Repo.insert!(on_conflict: :nothing)
31 | end
32 |
33 | defp email_subscribers(comment, mentioned_users) do
34 | users =
35 | (find_subscribed_users(comment.announcement_id) -- mentioned_users)
36 | |> Enum.reject(fn user -> user.id == comment.user_id end)
37 |
38 | Emails.new_comment(comment, users) |> Mailer.deliver_later!()
39 | end
40 |
41 | defp email_mentioned_users(comment) do
42 | users = MentionFinder.find_users(comment.body)
43 |
44 | Emails.new_comment_mention(comment, users) |> Mailer.deliver_later!()
45 | users
46 | end
47 |
48 | defp find_subscribed_users(announcement_id) do
49 | Repo.all(Subscription.for_announcement(announcement_id))
50 | |> Enum.map(fn subscription -> subscription.user end)
51 | end
52 |
53 | defp broadcast(comment) do
54 | Constable.PubSub.broadcast_new_comment(comment)
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/constable/services/daily_digest.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.DailyDigest do
2 | import Ecto.Query
3 | require Logger
4 | alias Constable.Repo
5 | alias Constable.Announcement
6 | alias Constable.Comment
7 | alias Constable.Interest
8 | alias Constable.Mailer
9 | alias Constable.Emails
10 |
11 | def send_email(users, time) do
12 | if new_items_since?(time) do
13 | Logger.info("Sending daily digest")
14 | daily_digest_email(time, users) |> Mailer.deliver_now!()
15 | else
16 | Logger.info("No new items since: #{inspect(time)}")
17 | end
18 | end
19 |
20 | defp daily_digest_email(time, users) do
21 | Emails.daily_digest(
22 | interests_since(time),
23 | announcements_since(time),
24 | comments_since(time),
25 | users
26 | )
27 | end
28 |
29 | defp new_items_since?(time) do
30 | !Enum.empty?(announcements_since(time)) ||
31 | !Enum.empty?(interests_since(time)) ||
32 | !Enum.empty?(comments_since(time))
33 | end
34 |
35 | defp interests_since(time) do
36 | Repo.all(from i in Interest, where: i.inserted_at > ^time)
37 | end
38 |
39 | defp announcements_since(time) do
40 | Repo.all(from i in Announcement, where: i.inserted_at > ^time)
41 | |> Repo.preload([:user, :interests])
42 | end
43 |
44 | defp comments_since(time) do
45 | Repo.all(from i in Comment, where: i.inserted_at > ^time)
46 | |> Repo.preload([:user, announcement: :user])
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/lib/constable/services/email_reply_parser.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.EmailReplyParser do
2 | def remove_original_email(email) do
3 | email
4 | |> remove_quoted_email
5 | |> remove_trailing_newlines
6 | end
7 |
8 | defp remove_quoted_email(body) do
9 | Enum.reduce(reply_header_formats(), body, fn regex, email_body ->
10 | match = Regex.split(regex, email_body)
11 | List.first(match)
12 | end)
13 | end
14 |
15 | defp reply_header_formats do
16 | [
17 | ~r/\n\>?[[:space:]]*On.*\n?.*>?.*\n?wrote:\n?/
18 | ]
19 | end
20 |
21 | defp remove_trailing_newlines(body) do
22 | Regex.replace(~r/\n+$/, body, "")
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/constable/services/fake_google_strategy.ex:
--------------------------------------------------------------------------------
1 | defmodule FakeGoogleStrategy do
2 | def get_token!(_redirect_uri, _params), do: "fake_token"
3 | end
4 |
--------------------------------------------------------------------------------
/lib/constable/services/fake_profile_provider.ex:
--------------------------------------------------------------------------------
1 | defmodule FakeProfileProvider do
2 | alias ConstableWeb.Endpoint
3 | alias Constable.Services.ProfileProvider
4 | @behaviour ProfileProvider
5 |
6 | @impl ProfileProvider
7 | def fetch_profile_url(_user) do
8 | "http://example.com/"
9 | end
10 |
11 | @impl ProfileProvider
12 | def fetch_image_url(_user) do
13 | "#{Endpoint.url()}/images/ralph.png"
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/constable/services/hub_profile_provider.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Services.HubProfileProvider do
2 | alias Constable.Services.ProfileProvider
3 | @behaviour ProfileProvider
4 |
5 | @impl ProfileProvider
6 | def fetch_profile_url(user) do
7 | "#{Application.fetch_env!(:constable, :hub_url)}/people/#{fetch_slug(user)}"
8 | end
9 |
10 | @impl ProfileProvider
11 | def fetch_image_url(user) do
12 | case Neuron.query(person_image_url_by_email_query(user.email)) do
13 | {:ok, %Neuron.Response{body: %{"data" => %{"person" => %{"image_url" => image_url}}}}} ->
14 | image_url
15 |
16 | _ ->
17 | nil
18 | end
19 | end
20 |
21 | defp fetch_slug(user) do
22 | case Neuron.query(person_slug_by_email_query(user.email)) do
23 | {:ok, %Neuron.Response{body: %{"data" => %{"person" => %{"slug" => slug}}}}} ->
24 | slug
25 |
26 | _ ->
27 | nil
28 | end
29 | end
30 |
31 | defp person_slug_by_email_query(email) do
32 | """
33 | {
34 | person(email: "#{email}") {
35 | slug
36 | }
37 | }
38 | """
39 | end
40 |
41 | defp person_image_url_by_email_query(email) do
42 | """
43 | {
44 | person(email: "#{email}") {
45 | image_url
46 | }
47 | }
48 | """
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/lib/constable/services/mention_finder.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Services.MentionFinder do
2 | @mention_regex ~r/@([\w\.]+)/u
3 |
4 | alias Constable.Repo
5 | alias Constable.User
6 |
7 | def find_users(text) do
8 | Regex.scan(@mention_regex, text)
9 | |> Enum.map(fn [_, username] -> username end)
10 | |> Enum.map(&Repo.get_by(User.active(), username: &1))
11 | |> Enum.reject(&is_nil/1)
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/constable/services/oauth_redirect_strategy.ex:
--------------------------------------------------------------------------------
1 | defmodule OAuthRedirectStrategy do
2 | def redirect_uri(original_redirect_uri) do
3 | oauth_redirect_override() || original_redirect_uri
4 | end
5 |
6 | def state_param(original_redirect_uri) do
7 | if oauth_redirect_override() do
8 | original_redirect_uri
9 | else
10 | nil
11 | end
12 | end
13 |
14 | defp oauth_redirect_override do
15 | System.get_env("OAUTH_REDIRECT_OVERRIDE")
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/constable/services/profile_provider.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Services.ProfileProvider do
2 | alias Constable.User
3 |
4 | @callback fetch_profile_url(User.t()) :: String.t()
5 | @callback fetch_image_url(User.t()) :: String.t()
6 | end
7 |
--------------------------------------------------------------------------------
/lib/constable/services/slack_hook.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Services.SlackHook do
2 | import ConstableWeb.Router.Helpers
3 |
4 | alias Constable.Repo
5 |
6 | def new_announcement(announcement) do
7 | announcement = announcement |> Repo.preload([:interests, :user])
8 |
9 | Enum.each(announcement.interests, fn interest ->
10 | if interest.slack_channel do
11 | payload = %{
12 | text:
13 | "#{announcement.user.name} posted <#{announcement_url(ConstableWeb.Endpoint, :show, announcement)}|#{announcement.title}>",
14 | channel: interest.slack_channel
15 | }
16 |
17 | post(payload)
18 | end
19 | end)
20 | end
21 |
22 | defp post(payload) do
23 | if slack_webhook_url() do
24 | spawn(fn ->
25 | HTTPoison.post(slack_webhook_url(), Poison.encode!(payload))
26 | end)
27 | end
28 | end
29 |
30 | defp slack_webhook_url do
31 | System.get_env("SLACK_WEBHOOK_URL")
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/constable/slug.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Slug do
2 | def deslugify!(slugified_id) do
3 | case deslugify(slugified_id) do
4 | {:ok, int} -> int
5 | :error -> raise "Invalid slug"
6 | end
7 | end
8 |
9 | def deslugify(slugified_id) do
10 | case Integer.parse(slugified_id) do
11 | {int, _} when int > 0 -> {:ok, int}
12 | _ -> :error
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/constable/subscription.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Subscription do
2 | use ConstableWeb, :schema
3 | alias Constable.Announcement
4 | alias Constable.User
5 |
6 | schema "subscriptions" do
7 | field :token
8 | belongs_to :user, User
9 | belongs_to :announcement, Announcement
10 | timestamps()
11 | end
12 |
13 | def changeset(subscription \\ %__MODULE__{}, params) do
14 | subscription
15 | |> cast(params, ~w(user_id announcement_id)a)
16 | |> generate_token
17 | end
18 |
19 | def for_announcement(announcement_id) do
20 | from s in __MODULE__,
21 | join: u in assoc(s, :user),
22 | where: u.active,
23 | where: s.announcement_id == ^announcement_id,
24 | preload: [:user]
25 | end
26 |
27 | defp generate_token(changeset) do
28 | token = SecureRandom.urlsafe_base64(32)
29 | put_change(changeset, :token, token)
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/constable/task_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.TaskSupervisor do
2 | def one_off_task(function_to_perform) do
3 | Task.Supervisor.start_child(__MODULE__, function_to_perform)
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/lib/constable/time.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.Time do
2 | def now do
3 | DateTime.utc_now()
4 | end
5 |
6 | def yesterday do
7 | days_ago(1)
8 | end
9 |
10 | def days_ago(count) do
11 | GoodTimes.days_ago(count) |> cast!
12 | end
13 |
14 | def cast!(time) do
15 | time |> NaiveDateTime.from_erl!() |> DateTime.from_naive!("Etc/UTC")
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/constable/user_identifier.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.UserIdentifier do
2 | @one_week 86400 * 7
3 | @salt "user_id"
4 |
5 | def sign_user_id(conn, user_id) do
6 | Phoenix.Token.sign(conn, @salt, user_id)
7 | end
8 |
9 | def verify_signed_user_id(conn) do
10 | Phoenix.Token.verify(conn, @salt, conn.cookies["user_id"], max_age: @one_week)
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/constable/user_interest.ex:
--------------------------------------------------------------------------------
1 | defmodule Constable.UserInterest do
2 | use ConstableWeb, :schema
3 | alias Constable.User
4 | alias Constable.Interest
5 |
6 | schema "users_interests" do
7 | timestamps()
8 |
9 | belongs_to :user, User
10 | belongs_to :interest, Interest
11 | end
12 |
13 | def changeset(user_interest \\ %__MODULE__{}, params) do
14 | user_interest
15 | |> cast(params, ~w(user_id interest_id)a)
16 | |> unique_constraint(:user_id, name: :users_interests_interest_id_user_id_index)
17 | |> unique_constraint(:interest_id, name: :users_interests_interest_id_user_id_index)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/constable_web/channels/user_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.UserSocket do
2 | use Phoenix.Socket
3 | require Logger
4 |
5 | alias Constable.Repo
6 | alias Constable.User
7 |
8 | def connect(%{"token" => token}, socket, _connect_info) do
9 | if user = user_with_token(token) do
10 | socket = assign(socket, :current_user, user)
11 | {:ok, socket}
12 | else
13 | {:error, "Unauthorized"}
14 | end
15 | end
16 |
17 | def connect(params, _socket, _connect_info) do
18 | Logger.debug("Expected socket params to have a 'token', got: #{inspect(params)}")
19 | end
20 |
21 | def id(_socket), do: nil
22 |
23 | defp user_with_token(token) do
24 | Repo.get_by!(User, token: token)
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/constable_web/controller_helper.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.ControllerHelper do
2 | def unauthorized(conn) do
3 | Plug.Conn.send_resp(conn, 401, "")
4 | end
5 |
6 | def current_user(conn) do
7 | conn.assigns[:current_user]
8 | end
9 |
10 | def page_title(conn, title) do
11 | Plug.Conn.assign(conn, :page_title, title)
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/api/comment_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.Api.CommentController do
2 | use ConstableWeb, :controller
3 |
4 | alias Constable.Services.CommentCreator
5 |
6 | plug Constable.Plugs.Deslugifier, slugified_key: "announcement_id"
7 |
8 | def create(conn, %{"comment" => params}) do
9 | current_user = current_user(conn)
10 | params = Map.put(params, "user_id", current_user.id)
11 |
12 | case CommentCreator.create(params) do
13 | {:ok, comment} ->
14 | conn |> put_status(201) |> render("show.json", comment: comment)
15 |
16 | {:error, changeset} ->
17 | conn
18 | |> put_status(422)
19 | |> render(ConstableWeb.ChangesetView, "error.json", changeset: changeset)
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/api/interest_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.Api.InterestController do
2 | use ConstableWeb, :controller
3 |
4 | alias Constable.Interest
5 | alias ConstableWeb.Api.InterestView
6 |
7 | def index(conn, _params) do
8 | interests = Repo.all(Interest)
9 |
10 | render(conn, "index.json", interests: interests)
11 | end
12 |
13 | def show(conn, %{"id" => id}) do
14 | interest = Repo.get!(Interest, id)
15 | render(conn, "show.json", interest: interest)
16 | end
17 |
18 | def update(conn, %{"id" => id, "channel" => channel}) do
19 | interest = Repo.get!(Interest, id)
20 |
21 | case Repo.update(Interest.update_channel_changeset(interest, channel)) do
22 | {:ok, interest} ->
23 | ConstableWeb.Endpoint.broadcast!(
24 | "update",
25 | "interest:update",
26 | InterestView.render("show.json", %{interest: interest})
27 | )
28 |
29 | render(conn, "show.json", interest: interest)
30 |
31 | {:error, changeset} ->
32 | conn
33 | |> put_status(:unprocessable_entity)
34 | |> render(ConstableWeb.ChangesetView, "error.json", changeset: changeset)
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/api/search_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.Api.SearchController do
2 | use ConstableWeb, :controller
3 |
4 | plug :put_view, ConstableWeb.Api.AnnouncementView
5 |
6 | alias Constable.Announcement
7 |
8 | def create(conn, params = %{"query" => search_terms}) do
9 | excludes = params["exclude_interests"] || []
10 |
11 | announcements =
12 | search_terms
13 | |> Announcement.search(exclude_interests: excludes)
14 | |> Announcement.last_discussed_first()
15 | |> Repo.all()
16 |
17 | render(conn, "index.json", announcements: announcements)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/api/subscription_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.Api.SubscriptionController do
2 | use ConstableWeb, :controller
3 |
4 | alias Constable.Subscription
5 |
6 | plug Constable.Plugs.Deslugifier, slugified_key: "announcement_id"
7 |
8 | def index(conn, _params) do
9 | current_user = current_user(conn)
10 | subscriptions = subscriptions_for(current_user)
11 |
12 | render(conn, "index.json", subscriptions: subscriptions)
13 | end
14 |
15 | def create(conn, %{"subscription" => params}) do
16 | current_user = current_user(conn)
17 | params = params |> Map.put("user_id", current_user.id)
18 |
19 | changeset = Subscription.changeset(params)
20 |
21 | case Repo.insert(changeset) do
22 | {:ok, subscription} ->
23 | conn |> put_status(201) |> render("show.json", subscription: subscription)
24 |
25 | {:error, changeset} ->
26 | conn
27 | |> put_status(422)
28 | |> render(ConstableWeb.ChangesetView, "error.json", changeset: changeset)
29 | end
30 | end
31 |
32 | def delete(conn, %{"id" => id}) do
33 | current_user = current_user(conn)
34 | subscription = Repo.get!(Subscription, id)
35 |
36 | if current_user.id == subscription.user_id do
37 | Repo.delete!(subscription)
38 | send_resp(conn, 204, "")
39 | else
40 | unauthorized(conn)
41 | end
42 | end
43 |
44 | defp subscriptions_for(user) do
45 | Repo.all(Ecto.assoc(user, :subscriptions))
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/api/user_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.Api.UserController do
2 | use ConstableWeb, :controller
3 |
4 | alias Constable.{User, Profiles}
5 |
6 | def create(conn, %{"user" => user_params}) do
7 | changeset = User.create_changeset(%User{}, user_params)
8 |
9 | case Repo.insert(changeset) do
10 | {:ok, user} ->
11 | update_profile_info(user)
12 | render(conn, "show.json", user: user)
13 |
14 | {:error, changeset} ->
15 | conn
16 | |> put_status(:unprocessable_entity)
17 | |> put_view(ConstableWeb.ChangesetView)
18 | |> render("error.json", changeset: changeset)
19 | end
20 | end
21 |
22 | defp update_profile_info(user) do
23 | Constable.TaskSupervisor.one_off_task(fn -> Profiles.update_profile_info(user) end)
24 | end
25 |
26 | def index(conn, _params) do
27 | users = all_users_ordered_by_name()
28 |
29 | render(conn, "index.json", users: users)
30 | end
31 |
32 | def show(conn, %{"id" => "me"}) do
33 | current_user = current_user(conn)
34 | render(conn, "show.json", user: current_user)
35 | end
36 |
37 | def show(conn, %{"id" => id}) do
38 | user = Repo.get!(User, id)
39 | render(conn, "show.json", user: user)
40 | end
41 |
42 | def update(conn, %{"user" => params}) do
43 | current_user = current_user(conn)
44 | changeset = User.settings_changeset(current_user, params)
45 |
46 | case Repo.update(changeset) do
47 | {:ok, user} ->
48 | render(conn, "show.json", user: user)
49 |
50 | {:error, changeset} ->
51 | conn
52 | |> put_status(:unprocessable_entity)
53 | |> render(ConstableWeb.ChangesetView, "error.json", changeset: changeset)
54 | end
55 | end
56 |
57 | defp all_users_ordered_by_name do
58 | Repo.all(User.ordered_by_name())
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/api/user_interest_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.Api.UserInterestController do
2 | use ConstableWeb, :controller
3 |
4 | alias Constable.UserInterest
5 |
6 | def index(conn, _params) do
7 | current_user = current_user(conn)
8 | user_interests = user_interests_for(current_user)
9 |
10 | render(conn, "index.json", user_interests: user_interests)
11 | end
12 |
13 | def show(conn, %{"id" => id}) do
14 | user_interest = Repo.get!(UserInterest, id)
15 | render(conn, "show.json", user_interest: user_interest)
16 | end
17 |
18 | def create(conn, %{"user_interest" => params}) do
19 | current_user = current_user(conn)
20 | params = Map.put(params, "user_id", current_user.id)
21 |
22 | changeset = UserInterest.changeset(params)
23 |
24 | case Repo.insert(changeset) do
25 | {:ok, user_interest} ->
26 | conn |> put_status(201) |> render("show.json", user_interest: user_interest)
27 |
28 | {:error, changeset} ->
29 | conn
30 | |> put_status(422)
31 | |> render(ConstableWeb.ChangesetView, "error.json", changeset: changeset)
32 | end
33 | end
34 |
35 | def delete(conn, %{"id" => id}) do
36 | current_user = current_user(conn)
37 | user_interest = Repo.get!(UserInterest, id)
38 |
39 | if current_user.id == user_interest.user_id do
40 | Repo.delete!(user_interest)
41 | send_resp(conn, 204, "")
42 | else
43 | unauthorized(conn)
44 | end
45 | end
46 |
47 | defp user_interests_for(user) do
48 | Repo.all(Ecto.assoc(user, :user_interests))
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/email_forward_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.EmailForwardController do
2 | use ConstableWeb, :controller
3 |
4 | def create(conn, %{"mandrill_events" => messages}) do
5 | messages
6 | |> Poison.decode!()
7 | |> forward_emails_to_admins
8 |
9 | text(conn, nil)
10 | end
11 |
12 | defp forward_emails_to_admins(forwarded_emails) do
13 | for %{"msg" => message} <- forwarded_emails do
14 | message
15 | |> Constable.Emails.forwarded_email()
16 | |> Constable.Mailer.deliver_now!()
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/email_reply_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.EmailReplyController do
2 | use ConstableWeb, :controller
3 | alias Constable.Services.CommentCreator
4 | alias Constable.User
5 | alias Constable.EmailReplyParser
6 |
7 | def create(conn, %{"mandrill_events" => messages}) do
8 | messages
9 | |> Poison.decode!()
10 | |> create_comments
11 |
12 | text(conn, nil)
13 | end
14 |
15 | defp create_comments(messages) do
16 | for message <- messages do
17 | message |> comment_params |> CommentCreator.create()
18 | end
19 | end
20 |
21 | defp comment_params(%{"msg" => %{"text" => email_body, "email" => to, "from_email" => from}}) do
22 | %{
23 | user_id: user_from_email(from).id,
24 | announcement_id: announcement_id_from_email(to),
25 | body: EmailReplyParser.remove_original_email(email_body)
26 | }
27 | end
28 |
29 | defp user_from_email(email_address) do
30 | User.with_email(email_address) |> Repo.one()
31 | end
32 |
33 | defp announcement_id_from_email("announcement-" <> key_and_domain) do
34 | key_and_domain |> String.split("@") |> List.first()
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/home_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.HomeController do
2 | use ConstableWeb, :controller
3 |
4 | def index(conn, _params) do
5 | conn |> redirect_to_original_path_or_announcement_index
6 | end
7 |
8 | defp redirect_to_original_path_or_announcement_index(conn) do
9 | original_request_path = get_session(conn, :original_request_path)
10 |
11 | if original_request_path do
12 | conn
13 | |> delete_session(:original_request_path)
14 | |> redirect(to: original_request_path)
15 | else
16 | conn |> redirect(to: Routes.announcement_path(conn, :index))
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/interest_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.InterestController do
2 | use ConstableWeb, :controller
3 |
4 | alias Constable.{Announcement, Interest}
5 |
6 | def index(conn, _params) do
7 | conn
8 | |> assign(:current_user, preload_interests(conn.assigns.current_user))
9 | |> assign(:interests, all_interests())
10 | |> page_title("Interests")
11 | |> render("index.html")
12 | end
13 |
14 | def show(conn, params) do
15 | interest = get_interest_by_id_or_name(params["id_or_name"])
16 | interest_page = sorted_announcements(interest) |> Repo.paginate(params)
17 |
18 | conn
19 | |> assign(:current_user, preload_interests(conn.assigns.current_user))
20 | |> assign(:announcements, interest_page.entries)
21 | |> assign(:interest_page, interest_page)
22 | |> assign(:interest, interest)
23 | |> page_title("#" <> interest.name)
24 | |> render("show.html")
25 | end
26 |
27 | defp get_interest_by_id_or_name(param) do
28 | case Integer.parse(param) do
29 | :error -> Repo.get_by!(Interest, name: param)
30 | {id, _} -> Repo.get!(Interest, id)
31 | end
32 | end
33 |
34 | defp preload_interests(user) do
35 | Repo.preload(user, :interests)
36 | end
37 |
38 | defp all_interests do
39 | Repo.all(Interest.ordered_by_name())
40 | end
41 |
42 | defp sorted_announcements(interest) do
43 | Ecto.assoc(interest, :announcements)
44 | |> Announcement.last_discussed_first()
45 | |> Announcement.with_announcement_list_assocs()
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/recipients_preview_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.RecipientsPreviewController do
2 | use ConstableWeb, :controller
3 |
4 | alias Constable.User
5 |
6 | def show(conn, params) do
7 | interest_names = params["interests"] |> String.split(",", trim: true)
8 |
9 | conn
10 | |> put_status(200)
11 | |> render("show.json",
12 | interest_names: interest_names,
13 | interested_user_names: interested_user_names(interest_names)
14 | )
15 | end
16 |
17 | defp interested_user_names(interest_names) do
18 | query =
19 | from u in User,
20 | distinct: true,
21 | join: i in assoc(u, :interests),
22 | order_by: u.name,
23 | select: u.name,
24 | where: u.active == true,
25 | where: i.name in ^interest_names
26 |
27 | Repo.all(query)
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/search_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.SearchController do
2 | use ConstableWeb, :controller
3 |
4 | alias Constable.Announcement
5 |
6 | def show(conn, params) do
7 | announcements = matching_announcements(params) |> Repo.paginate(params)
8 |
9 | conn
10 | |> assign(:announcements, announcements.entries)
11 | |> assign(:search_page, announcements)
12 | |> assign(:query, params["query"])
13 | |> page_title("Results for '#{params["query"]}'")
14 | |> render("new.html")
15 | end
16 |
17 | def new(conn, _params) do
18 | render(conn, "new.html", announcements: [])
19 | end
20 |
21 | defp matching_announcements(params = %{"query" => search_terms}) do
22 | excluded_interests = params["exclude_interests"] || []
23 |
24 | Announcement.search(search_terms, exclude_interests: excluded_interests)
25 | |> Announcement.last_discussed_first()
26 | |> Announcement.with_announcement_list_assocs()
27 | end
28 |
29 | defp matching_announcements(_params = %{}) do
30 | Announcement.search("", exclude_interests: [])
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/session_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.SessionController do
2 | use ConstableWeb, :controller
3 |
4 | def new(conn, _params) do
5 | if current_user(conn) do
6 | conn |> redirect(to: Routes.home_path(conn, :index))
7 | else
8 | conn
9 | |> put_layout("login.html")
10 | |> render("new.html")
11 | end
12 | end
13 |
14 | def delete(conn, _params) do
15 | conn
16 | |> logout_user
17 | |> redirect(to: Routes.home_path(conn, :index))
18 | end
19 |
20 | defp logout_user(conn) do
21 | delete_resp_cookie(conn, "user_id")
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/settings_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.SettingsController do
2 | use ConstableWeb, :controller
3 |
4 | alias Constable.User
5 |
6 | def show(conn, _params) do
7 | changeset = User.settings_changeset(conn.assigns.current_user)
8 | render(conn, "show.html", changeset: changeset)
9 | end
10 |
11 | def update(conn, %{"user" => user_params}) do
12 | changeset = User.settings_changeset(conn.assigns.current_user, user_params)
13 |
14 | case Repo.update(changeset) do
15 | {:ok, _user} ->
16 | conn
17 | |> put_flash(:success, "YES!")
18 | |> render("show.html", changeset: changeset)
19 |
20 | {:error, changeset} ->
21 | conn
22 | |> put_status(:unprocessable_entity)
23 | |> put_flash(:error, gettext("Something went wrong!"))
24 | |> render("show.html", changeset: changeset)
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/slack_channel_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.SlackChannelController do
2 | use ConstableWeb, :controller
3 |
4 | alias Constable.Interest
5 |
6 | def edit(conn, %{"interest_id_or_name" => name}) do
7 | interest = find_interest(name)
8 | changeset = Interest.update_channel_changeset(interest, interest.slack_channel)
9 | render(conn, "edit.html", interest: interest, changeset: changeset)
10 | end
11 |
12 | def update(conn, %{"interest_id_or_name" => name, "interest" => %{"slack_channel" => channel}}) do
13 | interest = find_interest(name)
14 |
15 | case Repo.update(Interest.update_channel_changeset(interest, channel)) do
16 | {:ok, interest} ->
17 | redirect(conn, to: Routes.interest_path(conn, :show, interest))
18 |
19 | {:error, changeset} ->
20 | conn
21 | |> put_status(:unprocessable_entity)
22 | |> render(ConstableWeb.ChangesetView, "edit.html", changeset: changeset)
23 | end
24 | end
25 |
26 | def delete(conn, %{"interest_id_or_name" => name}) do
27 | interest = find_interest(name)
28 | Repo.update!(Interest.changeset(interest, %{slack_channel: nil}))
29 | redirect(conn, to: Routes.interest_path(conn, :show, interest))
30 | end
31 |
32 | defp find_interest(interest_name) do
33 | Repo.get_by!(Interest, name: interest_name)
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/subscriptions_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.SubscriptionController do
2 | use ConstableWeb, :controller
3 |
4 | alias Constable.Subscription
5 |
6 | plug Constable.Plugs.Deslugifier, slugified_key: "announcement_id"
7 |
8 | def create(conn, %{"announcement_id" => announcement_id}) do
9 | changeset =
10 | Subscription.changeset(%{
11 | announcement_id: announcement_id,
12 | user_id: conn.assigns.current_user.id
13 | })
14 |
15 | Repo.insert!(changeset)
16 |
17 | redirect(conn, to: Routes.announcement_path(conn, :show, announcement_id))
18 | end
19 |
20 | def delete(conn, %{"announcement_id" => announcement_id}) do
21 | subscription =
22 | Repo.get_by(Subscription,
23 | announcement_id: announcement_id,
24 | user_id: conn.assigns.current_user.id
25 | )
26 |
27 | Repo.delete!(subscription)
28 | redirect(conn, to: Routes.announcement_path(conn, :show, announcement_id))
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/unsubscribe_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.UnsubscribeController do
2 | use ConstableWeb, :controller
3 |
4 | alias Constable.Subscription
5 |
6 | def show(conn, %{"id" => token}) do
7 | subscription = Repo.get_by(subscription_with_announcement(), token: token)
8 |
9 | if subscription do
10 | Repo.delete(subscription)
11 |
12 | conn
13 | |> put_flash(:info, gettext("You've been unsubscribed from this announcement."))
14 | |> redirect(to: Routes.announcement_path(conn, :show, subscription.announcement))
15 | else
16 | conn
17 | |> put_flash(:error, gettext("We could not unsubscribe you from the announcement."))
18 | |> redirect(to: Routes.announcement_path(conn, :index))
19 | end
20 | end
21 |
22 | defp subscription_with_announcement do
23 | Subscription |> preload(:announcement)
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/user_activation_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.UserActivationController do
2 | use ConstableWeb, :controller
3 |
4 | alias Constable.{Repo, User, Profiles}
5 |
6 | def index(conn, _params) do
7 | users = Repo.all(User |> order_by(desc: :active))
8 | render(conn, "index.html", users: users)
9 | end
10 |
11 | def update(conn, %{"id" => id}) do
12 | user = Repo.get!(User, id)
13 | new_activation_status = !user.active
14 |
15 | user
16 | |> Ecto.Changeset.change(%{active: new_activation_status})
17 | |> Repo.update!()
18 | |> update_profile_info()
19 |
20 | redirect(conn, to: Routes.user_activation_path(conn, :index))
21 | end
22 |
23 | defp update_profile_info(user) do
24 | Constable.TaskSupervisor.one_off_task(fn -> Profiles.update_profile_info(user) end)
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/constable_web/controllers/user_interest_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.UserInterestController do
2 | use ConstableWeb, :controller
3 |
4 | alias Constable.{Interest, UserInterest}
5 |
6 | def create(conn, %{"interest_id_or_name" => interest_name}) do
7 | interest = find_interest(interest_name)
8 | current_user = current_user(conn)
9 |
10 | UserInterest.changeset(%{user_id: current_user.id, interest_id: interest.id})
11 | |> Repo.insert!()
12 |
13 | redirect(conn, to: Routes.interest_path(conn, :index))
14 | end
15 |
16 | def delete(conn, %{"interest_id_or_name" => interest_name}) do
17 | interest = find_interest(interest_name)
18 |
19 | UserInterest
20 | |> Repo.get_by!(interest_id: interest.id, user_id: current_user(conn).id)
21 | |> Repo.delete!()
22 |
23 | redirect(conn, to: Routes.interest_path(conn, :index))
24 | end
25 |
26 | defp find_interest(interest_name) do
27 | Interest |> Repo.get_by!(name: interest_name)
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/constable_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.Gettext do
2 | @moduledoc """
3 | A module providing Internationalization with a gettext-based API.
4 |
5 | By using [Gettext](http://hexdocs.pm/gettext),
6 | your module gains a set of macros for translations, for example:
7 |
8 | import Async.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](http://hexdocs.pm/gettext) for detailed usage.
22 | """
23 | use Gettext, otp_app: :constable
24 | end
25 |
--------------------------------------------------------------------------------
/lib/constable_web/live/interest_live.ex:
--------------------------------------------------------------------------------
1 | defmodule ConstableWeb.InterestLive do
2 | use ConstableWeb, :live_view
3 |
4 | alias Constable.{Interest, User, UserInterest}
5 |
6 | def render(assigns) do
7 | render(ConstableWeb.InterestView, "index.html", assigns)
8 | end
9 |
10 | def mount(_, session, socket) do
11 | socket =
12 | socket
13 | |> assign(:current_user, fetch_user(session["current_user_id"]))
14 | |> assign(:interests, fetch_interests())
15 |
16 | {:ok, socket}
17 | end
18 |
19 | def handle_event("subscribe", %{"id" => id}, socket) do
20 | current_user = socket.assigns.current_user
21 | interest = Interest |> Repo.get!(id)
22 |
23 | UserInterest.changeset(%{user_id: current_user.id, interest_id: interest.id})
24 | |> Repo.insert!()
25 |
26 | user = fetch_user(current_user.id)
27 |
28 | {:noreply, assign(socket, current_user: user)}
29 | end
30 |
31 | def handle_event("unsubscribe", %{"id" => id}, socket) do
32 | current_user = socket.assigns.current_user
33 | interest = Interest |> Repo.get!(id)
34 |
35 | UserInterest
36 | |> Repo.get_by!(interest_id: interest.id, user_id: current_user.id)
37 | |> Repo.delete!()
38 |
39 | user = fetch_user(current_user.id)
40 |
41 | {:noreply, assign(socket, current_user: user)}
42 | end
43 |
44 | defp fetch_user(user_id) do
45 | User.active()
46 | |> Repo.get(user_id)
47 | |> Repo.preload(:interests)
48 | end
49 |
50 | defp fetch_interests do
51 | Repo.all(Interest.ordered_by_name())
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/lib/constable_web/templates/announcement/_comment.html.eex:
--------------------------------------------------------------------------------
1 |
6 | |
8 |
9 | <%= @author.name %> 10 | |
11 |
5 | | |
8 | Announced to <%= raw interest_links(@announcement) %> 9 | <%= time_ago_in_words @announcement.inserted_at %>: 10 | | 11 |12 | <%= link to: announcement_url_for_footer(@announcement, nil), 13 | style: "color: #{light_gray()}; font-size: 14px;" do %> 14 | <%= gettext("View on Constable") %> 15 | <% end %> 16 | | 17 |
20 | | |
23 | <%= raw Constable.Markdown.to_html(@announcement.body) %> 24 | | 25 ||
28 | |
35 | |
5 | | ||
8 | <%= if @mentioner do %>
9 | 10 | <%= gettext("You were mentioned by") %> <%= @mentioner.name %>: 11 | 12 | <% end %> 13 | <%= raw Constable.Markdown.to_html(@comment.body) %> 14 | |
15 | 16 | <%= link to: announcement_url_for_footer(@announcement, @comment), 17 | style: "color: #{light_gray()}; font-size: 14px;" do %> 18 | <%= gettext("View on Constable") %> 19 | <% end %> 20 | | 21 ||
24 | |
31 | |
8 | Constable is for posting announcements and 9 | having discussions. 10 |
11 | 12 | <%= link to: Routes.auth_path(@conn, :index, browser: true), class: "tbds-button tbds-button--full-width sign-in-link" do %> 13 | 16 | Sign in with Google 17 | <% end %> 18 |
9 |
10 | Click please!
11 |
12 | """
13 | |> Constable.Markdown.to_html()
14 |
15 | assert html == ~s[\nalert("Gonna steal your data!");\nClick please!\n
]
16 | end
17 |
18 | test "converts markdown" do
19 | html =
20 | """
21 | This is a paragraph
22 | """
23 | |> Constable.Markdown.to_html()
24 |
25 | assert html == "This is a paragraph
\n" 26 | end 27 | 28 | test "allows for including iframes" do 29 | html = 30 | """ 31 | 32 | """ 33 | |> Constable.Markdown.to_html() 34 | 35 | assert html == "\n" 36 | end 37 | 38 | test "allows for alt tags on images" do 39 | html = 40 | """ 41 |  42 | """ 43 | |> Constable.Markdown.to_html() 44 | 45 | assert html == "