├── .editorconfig ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── CONTRIBUTORS.edn ├── LICENSE.md ├── README.md ├── deps.edn ├── dev-src └── braid │ └── dev │ ├── core.clj │ └── figwheel.clj ├── dev.cljs.edn ├── docs ├── dev │ ├── code-organization.md │ ├── developing-bots.md │ └── getting-up-and-running-in-development.md ├── drafts │ ├── architecture.md │ ├── background │ │ ├── chatrooms-considered-harmful.md │ │ ├── faq.md │ │ ├── infrastructure-should-be-open.md │ │ ├── similar.md │ │ └── towards-productive-communication.md │ ├── contributing-other.md │ ├── dev-getting-started.md │ ├── developing-modules.md │ ├── guidelines-code.md │ ├── guidelines-design.md │ ├── guidelines-documentation.md │ └── stack.md ├── images │ ├── braid-icon-256.png │ ├── screenshot.png │ └── youtube-player.png ├── on-prem │ └── installing-on-prem.md └── responsible-disclosure-policy.md ├── gateway.cljs.edn ├── prod.cljs.edn ├── profiles.sample.clj ├── project.clj ├── resources ├── CHANGELOG.md ├── public │ ├── desktop.html │ ├── fonts │ │ ├── fa-brands-400.eot │ │ ├── fa-brands-400.svg │ │ ├── fa-brands-400.ttf │ │ ├── fa-brands-400.woff │ │ ├── fa-brands-400.woff2 │ │ ├── fa-solid-900.eot │ │ ├── fa-solid-900.svg │ │ ├── fa-solid-900.ttf │ │ ├── fa-solid-900.woff │ │ └── fa-solid-900.woff2 │ ├── gateway.html │ ├── images │ │ ├── braid-logo-color.svg │ │ ├── braid.svg │ │ ├── favicon.png │ │ └── quests │ │ │ ├── conversation-close.gif │ │ │ ├── conversation-new.gif │ │ │ └── conversation-reply.gif │ └── oauth-post-auth.html └── templates │ ├── email_digest.html.mustache │ ├── email_digest.txt.mustache │ ├── link_signup.html.mustache │ ├── register_page.html.mustache │ └── reset_page.html.mustache ├── scripts └── video-convert.sh ├── src └── braid │ ├── base │ ├── api.cljc │ ├── client │ │ ├── events.cljs │ │ ├── pages.cljs │ │ ├── remote_handlers.cljs │ │ ├── root_view.cljs │ │ ├── router.cljs │ │ ├── socket.cljs │ │ ├── state.cljs │ │ ├── styles.cljs │ │ └── subs.cljs │ ├── conf.clj │ ├── conf_extra.clj │ ├── core.cljc │ └── server │ │ ├── cache.clj │ │ ├── cqrs.clj │ │ ├── cqrs_fx.clj │ │ ├── http_api_routes.clj │ │ ├── http_client_routes.clj │ │ ├── initial_data.clj │ │ ├── jobs.clj │ │ ├── scheduler.clj │ │ ├── schema.clj │ │ ├── seed.clj │ │ ├── socket.clj │ │ ├── spa.clj │ │ └── ws_handler.clj │ ├── bots │ ├── client │ │ ├── autocomplete.cljs │ │ ├── events.cljs │ │ ├── remote_handlers.cljs │ │ ├── subs.cljs │ │ └── views │ │ │ ├── bot_sender_view.cljs │ │ │ ├── bots_page.cljs │ │ │ └── bots_page_styles.cljs │ ├── core.cljc │ ├── schema.cljc │ ├── server.clj │ ├── server │ │ ├── db.clj │ │ ├── routes.clj │ │ └── sync.clj │ └── util.cljc │ ├── chat │ ├── api.cljc │ ├── client │ │ ├── events.cljs │ │ ├── remote_handlers.cljs │ │ └── subs.cljs │ ├── commands.clj │ ├── core.cljc │ ├── db │ │ ├── common.clj │ │ ├── group.clj │ │ ├── invitation.clj │ │ ├── message.clj │ │ ├── tag.clj │ │ ├── thread.clj │ │ └── user.clj │ ├── events.clj │ ├── predicates.clj │ ├── schema.clj │ ├── seed.clj │ ├── server │ │ └── initial_data.clj │ └── socket_message_handlers.clj │ ├── core.clj │ ├── core │ ├── client │ │ ├── desktop │ │ │ ├── core.cljs │ │ │ └── notify.cljs │ │ ├── gateway │ │ │ ├── core.cljs │ │ │ ├── events.cljs │ │ │ ├── forms │ │ │ │ ├── join_group │ │ │ │ │ └── events.cljs │ │ │ │ └── user_auth │ │ │ │ │ ├── events.cljs │ │ │ │ │ ├── styles.cljs │ │ │ │ │ ├── subs.cljs │ │ │ │ │ ├── validations.cljs │ │ │ │ │ └── views.cljs │ │ │ ├── fx.cljs │ │ │ ├── helper_views.cljs │ │ │ ├── helpers.cljs │ │ │ ├── styles.cljs │ │ │ ├── styles_vars.cljs │ │ │ ├── subs.cljs │ │ │ └── views.cljs │ │ ├── group_admin │ │ │ ├── events.cljs │ │ │ ├── subs.cljs │ │ │ └── views │ │ │ │ ├── group_settings_page.cljs │ │ │ │ └── group_settings_page_styles.cljs │ │ ├── helpers.cljs │ │ ├── invites │ │ │ ├── events.cljs │ │ │ ├── schema.cljs │ │ │ ├── subs.cljs │ │ │ └── views │ │ │ │ ├── invite.cljs │ │ │ │ ├── invite_page.cljs │ │ │ │ └── invite_page_styles.cljs │ │ ├── routes.cljs │ │ ├── schema.cljs │ │ ├── state │ │ │ ├── fx │ │ │ │ ├── dispatch_debounce.cljs │ │ │ │ └── redirect.cljs │ │ │ ├── helpers.cljs │ │ │ └── subscription.cljs │ │ ├── store.cljs │ │ └── ui │ │ │ ├── styles │ │ │ ├── animations.cljs │ │ │ ├── body.cljs │ │ │ ├── fontawesome.cljs │ │ │ ├── header.cljs │ │ │ ├── hljs.cljs │ │ │ ├── hover_cards.cljs │ │ │ ├── hover_menu.cljs │ │ │ ├── imports.cljs │ │ │ ├── message.cljs │ │ │ ├── misc.cljs │ │ │ ├── mixins.cljs │ │ │ ├── page.cljs │ │ │ ├── pages │ │ │ │ ├── global_settings.cljs │ │ │ │ └── me.cljs │ │ │ ├── pills.cljs │ │ │ ├── thread.cljs │ │ │ ├── threads.cljs │ │ │ └── vars.cljs │ │ │ └── views │ │ │ ├── app.cljs │ │ │ ├── autocomplete.cljs │ │ │ ├── card_border.cljs │ │ │ ├── header.cljs │ │ │ ├── header_item.cljs │ │ │ ├── hover_menu.cljs │ │ │ ├── main.cljs │ │ │ ├── mentions.cljs │ │ │ ├── message.cljs │ │ │ ├── new_message.cljs │ │ │ ├── new_message_action_button.cljs │ │ │ ├── pages │ │ │ ├── changelog.cljs │ │ │ ├── global_settings.cljs │ │ │ ├── me.cljs │ │ │ └── readonly.cljs │ │ │ ├── pills.cljs │ │ │ ├── styles.cljs │ │ │ ├── subscribe_button.cljs │ │ │ ├── tag_hover_card.cljs │ │ │ ├── thread.cljs │ │ │ ├── thread.cljs.rej │ │ │ ├── thread_header.cljs │ │ │ ├── threads.cljs │ │ │ ├── upload.cljs │ │ │ ├── user_header.cljs │ │ │ └── user_hover_card.cljs │ ├── common │ │ ├── schema.cljc │ │ └── util.cljc │ ├── hooks.cljc │ ├── modules.cljc │ └── server │ │ ├── core.clj │ │ ├── db.clj │ │ ├── email_digest.clj │ │ ├── handler.clj │ │ ├── invite.clj │ │ ├── mail.clj │ │ ├── message_format.clj │ │ ├── middleware.clj │ │ ├── migrate.clj │ │ ├── notify_rules.clj │ │ ├── routes │ │ ├── api │ │ │ ├── private.clj │ │ │ └── public.clj │ │ ├── client.clj │ │ ├── helpers.clj │ │ └── socket.clj │ │ ├── sync_handler.clj │ │ └── sync_helpers.clj │ ├── disconnect_notice │ ├── core.cljc │ ├── styles.cljs │ └── ui.cljs │ ├── embeds │ ├── api.cljc │ ├── core.cljc │ ├── impl.cljs │ └── styles.cljs │ ├── embeds_image │ └── core.cljc │ ├── embeds_map │ └── core.cljc │ ├── embeds_video │ └── core.cljc │ ├── embeds_website │ ├── core.cljc │ ├── link_extract.clj │ ├── styles.cljs │ └── views.cljs │ ├── embeds_youtube │ └── core.cljc │ ├── emoji │ ├── api.cljc │ ├── client │ │ ├── autocomplete.cljs │ │ ├── lookup.cljs │ │ ├── styles.cljs │ │ ├── text_replacements.cljs │ │ └── views.cljs │ └── core.cljc │ ├── emoji_big │ └── core.cljc │ ├── emoji_custom │ ├── client │ │ ├── autocomplete.cljs │ │ ├── state.cljs │ │ ├── styles.cljs │ │ └── views.cljs │ ├── core.cljc │ └── server │ │ ├── core.clj │ │ └── db.clj │ ├── emoji_emojione │ ├── core.cljc │ ├── impl.cljs │ └── ref.cljs │ ├── group_create │ ├── core.cljc │ ├── styles.cljs │ ├── test.clj │ ├── validations.cljs │ └── views.cljs │ ├── group_explore │ ├── api.cljc │ ├── core.cljc │ ├── styles.cljs │ └── views.cljs │ ├── lib │ ├── aws.clj │ ├── color.cljs │ ├── crypto.clj │ ├── date.cljs │ ├── digest.clj │ ├── github.clj │ ├── gravatar.clj │ ├── markdown.clj │ ├── misc.cljc │ ├── noembed.clj │ ├── s3.clj │ ├── s3.cljs │ ├── transit.clj │ ├── upload.cljs │ ├── url.clj │ ├── url.cljs │ ├── uuid.cljc │ └── xhr.cljs │ ├── notices │ └── core.cljc │ ├── page_inbox │ ├── commands.clj │ ├── core.cljc │ ├── styles.cljs │ └── ui.cljs │ ├── page_recent │ └── core.cljc │ ├── page_subscriptions │ ├── core.cljc │ ├── styles.cljs │ └── ui.cljs │ ├── page_uploads │ ├── core.cljc │ └── views │ │ ├── uploads_page.cljs │ │ └── uploads_page_styles.cljs │ ├── permalinks │ └── core.cljc │ ├── popovers │ ├── api.cljs │ ├── core.cljc │ ├── helpers.cljs │ └── impl.cljs │ ├── quests │ ├── client │ │ ├── core.cljs │ │ ├── events.cljs │ │ ├── helpers.cljs │ │ ├── list.cljs │ │ ├── remote_handlers.cljs │ │ ├── styles.cljs │ │ └── views.cljs │ ├── core.cljc │ └── server │ │ ├── core.clj │ │ └── db.clj │ ├── rss │ ├── client │ │ └── views.cljs │ ├── core.cljc │ └── server │ │ ├── db.clj │ │ └── fetching.clj │ ├── search │ ├── api.cljc │ ├── client.cljs │ ├── core.cljc │ ├── lucene.clj │ ├── server.clj │ ├── tags.clj │ ├── threads.clj │ ├── ui │ │ ├── search_bar.cljs │ │ ├── search_button.cljs │ │ ├── search_page.cljs │ │ ├── search_page_styles.cljs │ │ ├── tag_results.cljs │ │ ├── thread_results.cljs │ │ └── user_results.cljs │ └── users.clj │ ├── sidebar │ ├── api.cljs │ ├── core.cljc │ ├── styles.cljs │ └── ui.cljs │ ├── stars │ └── core.cljc │ ├── uploads │ ├── core.cljc │ └── db.clj │ ├── users │ ├── client │ │ └── views │ │ │ ├── users_page.cljs │ │ │ └── users_page_styles.cljs │ └── core.cljc │ └── version │ └── core.cljc └── test ├── braid └── test │ ├── client │ ├── runners │ │ ├── doo.cljs │ │ └── tests.cljs │ └── ui │ │ └── views │ │ └── message_test.cljs │ ├── fixtures │ ├── conf.clj │ └── db.clj │ ├── lib │ └── markdown_test.clj │ └── server │ ├── db │ ├── group_test.clj │ └── user_test.clj │ ├── db_test.clj │ ├── names_test.clj │ ├── new_message_test.clj │ ├── notify_rules_test.clj │ ├── search_test.clj │ ├── tags_test.clj │ └── test_utils.clj ├── helpers └── cookies.clj ├── integration └── braid │ └── system_start_stop_test.clj └── unit └── braid ├── core └── server │ └── middleware_test.clj └── lib └── s3_test.clj /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /resources/public/js/ 2 | /cljs-test-runner-out 3 | /target 4 | .lein-env 5 | .lein-repl-history 6 | .nrepl-port 7 | .rebel_readline_history 8 | *-init.clj 9 | profiles.clj 10 | .lein-failures 11 | .DS_Store 12 | Session.vim 13 | figwheel_server.log 14 | /.idea 15 | *.iml 16 | .cljs_rhino_repl/ 17 | /notes/ 18 | /datomic-pro-0.9.5201 19 | /datomic-pro-0.9.5697 20 | /lucene 21 | .cpcache 22 | /.calva -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Welcome! We're super excited that you are interested in contributing to Braid. 4 | 5 | There are a lot of ways you can be involved - not just coding - and all are valuable. 6 | 7 | 8 | ## Say Hello 9 | 10 | If you'd like to contribute, jump into the [Braid group on Braid](https://braid.chat/braid) and say hello. We can help match you with a good task, point you to resources and help you if you run into any problems. 11 | 12 | @jamesnvc and @rafd have a standing offer to remotely pair with anyone on Braid. Send a message on Braid chat if you'd like to take us up on it. 13 | 14 | We want to make contributing to Braid as painless as possible (and, ideally, gratifying). Please let us know how we can improve. 15 | 16 | 17 | ## Giving Feedback 18 | 19 | We want Braid to be useful to you, so we highly value your feedback. 20 | 21 | Right now, we're especially looking for feedback on: 22 | 23 | - the first-time user experience 24 | - the first-time contributor experience 25 | - the first-time developer experience 26 | 27 | You can give us feedback [Braid](https://braid.chat/braid) or via [Github Issues](https://github.com/braidchat/braid/issues). 28 | 29 | 30 | ### Reporting Issues 31 | 32 | Found a bug or some other issue? Let us know on [Braid](https://braid.chat/braid) or create a [Github Issue](https://github.com/braidchat/braid/issues). 33 | 34 | If it's a security issue, please do not post it publicly, and instead, email us at security@braidchat.com (see our [Responsible Disclosure Policy](./responsible-disclosure-policy.md). 35 | 36 | When reporting an issue, the following information helps us track it down and fix it faster: 37 | 38 | - steps to reproduce the issue 39 | - expected vs observed behaviour 40 | - relevant error messages and/or screenshots 41 | - possible fixes 42 | 43 | 44 | ### Contributing Feature Ideas 45 | 46 | If you have an idea for a new feature or change to Braid, we'd love to hear it. Let us know on [Braid](https://braid.chat/braid) or create a [Github Issue](https://github.com/braidchat/braid/issues). 47 | 48 | -------------------------------------------------------------------------------- /CONTRIBUTORS.edn: -------------------------------------------------------------------------------- 1 | ; Braid Contributors 2 | 3 | ; Thank you to all our contributors! 4 | 5 | ; This is an edn list that loosely follows: 6 | ; https://github.com/kentcdodds/all-contributors 7 | 8 | ; Also worth checking: Github's Network Members: 9 | ; https://github.com/braidchat/braid/network/members 10 | 11 | [ 12 | 13 | {:name "Rafal D." 14 | :github "@rafd" 15 | :contributions ["project inception"] 16 | :tags #{:code 17 | :infrastructure 18 | :documentation 19 | :design 20 | :tests 21 | :answering-questions 22 | :bug-reports 23 | :pr-review 24 | :project-management}} 25 | 26 | {:name "James C." 27 | :github "@jamesnvc" 28 | :tags #{:code 29 | :plugins 30 | :infrastructure 31 | :tests 32 | :documentation 33 | :answering-questions 34 | :bug-reports 35 | :pr-review}} 36 | 37 | {:name "Brandon G." 38 | :github "@10plusY" 39 | :contributions ["port from Om to Reagent" 40 | "webrtc integration"] 41 | :tags #{:code 42 | :plugins}} 43 | 44 | {:name "Sam J." 45 | :github "@sjuraschka" 46 | :contributions ["client UX and UI improvements"] 47 | :tags #{:code 48 | :design}} 49 | 50 | {:github "@CommonCreative" 51 | :tags #{:code}} 52 | 53 | {:name "Dominic M." 54 | :github "@SevereOverfl0w" 55 | :tags #{:documentation}} 56 | 57 | {:github "@sveri" 58 | :contributions [""] 59 | :tags #{:documentation}} 60 | 61 | {:name "Canna W." 62 | :github "@cannawen" 63 | :tags #{:documentation}} 64 | 65 | ] 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /dev-src/braid/dev/figwheel.clj: -------------------------------------------------------------------------------- 1 | (ns braid.dev.figwheel 2 | (:require 3 | [figwheel.main.api :as repl-api] 4 | [mount.core :refer [defstate]])) 5 | 6 | (defstate figwheel 7 | :start (repl-api/start 8 | {:mode :serve 9 | :open-url false 10 | :ring-server-options {:port 3559} 11 | :connect-url "ws://[[client-hostname]]:[[server-port]]/figwheel-connect" 12 | :watch-dirs ["src"]} 13 | "dev") 14 | :stop (repl-api/stop-all)) 15 | -------------------------------------------------------------------------------- /dev.cljs.edn: -------------------------------------------------------------------------------- 1 | {:main braid.core.client.desktop.core 2 | :asset-path "/js/dev/desktop/" 3 | :output-to "resources/public/js/dev/desktop.js" 4 | :output-dir "resources/public/js/dev/desktop/" 5 | :optimizations :none 6 | :verbose false 7 | :closure-defines {"goog.DEBUG" true} 8 | :parallel-build true} 9 | -------------------------------------------------------------------------------- /docs/drafts/architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/drafts/background/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | A list of question or concerns raised by folks and responses. 4 | 5 | ## Why yet-another-chat-app? 6 | 7 | See [Motivation](https://github.com/braidchat/meta/wiki/Motivation) 8 | 9 | First prototype was a one-day hack but we loved it; shared it with some folks, they loved it; so here we are. 10 | 11 | ...different folks have different reasons for supporting Braid, ex. couldn't find an existing app that: 12 | 13 | - was hackable / open-source 14 | - promoted better conversations 15 | - was written in clojure(script) 16 | - ... 17 | 18 | (or a combination of some or all of the above) 19 | 20 | ## Why not build on top of xmpp / irc / matrix / etc.? 21 | 22 | Maybe we will. 23 | 24 | For now, we're focusing on improving the front-end experience, so the backend we already wrote is good enough. 25 | 26 | ## Why clojure + clojurescript? Why not... 27 | 28 | Clojure(script) is a fantastic language that lets us be more productive. [Read More](../dev/stack.md) 29 | -------------------------------------------------------------------------------- /docs/drafts/background/infrastructure-should-be-open.md: -------------------------------------------------------------------------------- 1 | # Communication Infrastructure Should be Open 2 | 3 | (in progress) 4 | 5 | For many years, group chat on the internet was dominated by IRC, mailing lists, forums and XMPP. 6 | 7 | Presently, Slack is the king of group chat, eclipsing the previous technologies. 8 | 9 | 10 | ## Benefits of Open Source 11 | 12 | - not under risk of patron removing your rights 13 | 14 | (ex. Reactiflux community hitting Slack user limit and moving to Discord) 15 | 16 | - freedom to extend: anyone can add features 17 | 18 | - self-hosting, security, privacy 19 | 20 | - standards, interoperability 21 | -------------------------------------------------------------------------------- /docs/drafts/background/similar.md: -------------------------------------------------------------------------------- 1 | # Similar Projects 2 | 3 | Scrollback 4 | https://scrollback.io/scrollback 5 | 6 | Flowdock 7 | https://www.flowdock.com/ 8 | 9 | Euphoria / Heim 10 | https://euphoria.io/ 11 | https://github.com/euphoria-io/heim 12 | 13 | Federated Wiki 14 | http://fed.wiki.org 15 | 16 | Discourse 17 | http://discourse.org 18 | 19 | Loomio 20 | https://www.loomio.org/ 21 | 22 | Apache Wave (nee Google Wave) 23 | https://incubator.apache.org/wave/ 24 | 25 | Zulip 26 | https://zulip.org/ 27 | 28 | ## Discussions 29 | 30 | - https://news.ycombinator.com/item?id=9770322 31 | - https://news.ycombinator.com/item?id=10386847 32 | - https://news.ycombinator.com/item?id=10068943 33 | 34 | ## Direct IRC Alternatives 35 | 36 | - https://blog.okturtles.com/2015/11/five-open-source-slack-alternatives/ 37 | 38 | - http://www.mattermost.org/ 39 | 40 | 41 | 42 | ## Discussions 43 | 44 | - https://github.com/reactiflux/volunteers/issues/25 45 | - https://github.com/reactiflux/volunteers/issues/17 46 | - http://www.jordanhawker.com/posts/131477030371 47 | - https://news.ycombinator.com/item?id=9770322 48 | - https://news.ycombinator.com/item?id=10386847 49 | - https://news.ycombinator.com/item?id=10068943 50 | -------------------------------------------------------------------------------- /docs/drafts/background/towards-productive-communication.md: -------------------------------------------------------------------------------- 1 | # Towards Productive Communication 2 | 3 | - target audience: teams and online communities 4 | - allow for both real-time and async experiences 5 | (ie. don't demand the group's full attention just to keep the conversation on track) 6 | 7 | Forums address some of these issues (but even threads still often have the tangent problem)... need to make threading easier, but this needs balance too (full threading results in fractal conversation, can also be difficult to follow) 8 | 9 | Braid's major changes are: 10 | - thinking with tags instead of rooms (similar to how Gmail thinks with tags vs folders); 11 | - per-topic conversations, instead of per-room chatter (similar to how you have Facebook comments on each post) 12 | 13 | [[images/braid-concept.png]] 14 | 15 | As a result... 16 | 17 | - you can have multiple simultaneous conversations related to a tag (vs. trying to keep track of multiple conversations in one room) 18 | 19 | - users can "mute" conversations (vs. having to deal with notifications for irrelevant messages in a room you want to stay in) 20 | 21 | - conversations can be resumed without context loss (vs. having to search for old messages) 22 | 23 | - no active conversations = your screen is empty (vs. old messages showing) 24 | 25 | - conversations can have multiple tags (vs. settling on the 'best' room) 26 | 27 | - conversations can change tags over time (vs. maintaining a conversation in an inappropriate room) 28 | 29 | 30 | Martin has some great thoughts here: http://www.martinklepsch.org/chaf.html 31 | 32 | More thoughts on threading here: https://hackpad.com/Chatroom-vs-....-bringing-threading-to-chat-8jJD3sxtRAf 33 | -------------------------------------------------------------------------------- /docs/drafts/contributing-other.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | ### Feedback and Voting on Planned Features 10 | 11 | There are a lot of ideas in the [Braid Repo](https://github.com/braidchat/braid/issues), and your feedback can help us prioritize which ones to address. Give the existing ideas a look, and feel free to leave a comment or a :+1:. 12 | 13 | 14 | ### Triaging and Organizing Issues 15 | 16 | The [Braid Repo](https://github.com/braidchat/braid/issues) needs help staying organized (tagging issues, removing duplicates, removing stales issues, etc.) and if that sounds interesting to you, let us know. 17 | 18 | 19 | ## Writing 20 | 21 | ### Improving Documentation for Developers 22 | 23 | ### Improving Guides for Users 24 | 25 | ### Translating Braid to Other Languages 26 | 27 | 28 | ## Sharing 29 | 30 | 31 | ## Sponsoring 32 | 33 | ### Paid Hosting 34 | 35 | ### Gratipay 36 | 37 | ### Bountysource 38 | 39 | 40 | ## Coding 41 | 42 | ### Adressing "Help Wanted" Issues 43 | 44 | We've been tagging issues that are ideal for first-time committers with the [`help wanted`](https://github.com/braidchat/braid/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aopen%20label%3A%22help%20wanted%22) tag on Braid Meta. Most of these issues can be addressed with minor code changes that don't require knowledge of the full system. 45 | 46 | 47 | ### Contributing to Braid Core 48 | 49 | 50 | ### Writing End-to-End Tests 51 | 52 | 53 | ### Developing Integrations 54 | 55 | -------------------------------------------------------------------------------- /docs/drafts/dev-getting-started.md: -------------------------------------------------------------------------------- 1 | # Contributing: Coding 2 | 3 | 4 | 5 | ## Editor 6 | 7 | If you're just starting out with Clojure(script): 8 | - if you use vim or emacs, stick with that 9 | - otherwise, we recommend Cursive: https://cursive-ide.com/ (over Lighttable, SublimeText, Atom, etc.) 10 | 11 | 12 | Fork the Project 13 | 14 | Clone your fork locally 15 | 16 | Install Packages 17 | 18 | 19 | [Getting Started](../dev/getting-up-and-running-in-development.md) 20 | 21 | 22 | 23 | 24 | let us know what features you plan to work on 25 | PR early, to start a discussion (and avoid major refactoring) 26 | 27 | 28 | Issues 29 | 30 | https://github.com/braidchat/braid/issues 31 | 32 | Check for open issues or open a fresh issue to start a discussion around a feature idea or a bug 33 | 34 | There is a [Help Wanted](https://github.com/braidchat/braid/issues?q=is%3Aissue+is%3Aopen+label%3Ahelp-wanted) tag for issues that should be ideal for people who are not very familiar with the codebase yet. 35 | 36 | 37 | ## Support 38 | 39 | If at any point, you run into problems, jump into the [braid group](http://braid.chat/group/braid). 40 | 41 | @jamesnvc and @rafd have a standing offer to remotely pair-code with anyone on Braid (via [Screenhero]() or [Teamviewer]()). Message us if you'd like to take us up on it. 42 | 43 | 44 | 45 | 46 | 47 | Feature Branches 48 | 49 | `git checkout -b new-shiny-thing` 50 | 51 | 52 | 53 | Commit Best Practices 54 | 55 | - messages: chris.beams.io/posts/git-commit/ 56 | - 57 | 58 | 59 | Pull in Upstream Change Regularly 60 | 61 | ``` 62 | git remote add upstream ... 63 | git fetch upstream 64 | git merge upstream/master --ff 65 | ``` 66 | 67 | 68 | Make a Pull Request 69 | 70 | ``` 71 | git push origin new-shiny-thing 72 | ``` 73 | 74 | 75 | 76 | Add yourself to [CONTRIBUTORS.md](../CONTRIBUTORS.edn) 77 | 78 | 79 | -------------------------------------------------------------------------------- /docs/drafts/developing-modules.md: -------------------------------------------------------------------------------- 1 | # Developing Braid Modules 2 | 3 | ## Module Spec 4 | 5 | - a module is self-contained in a folder directly under `braid`, ie. `src/braid/widget` 6 | 7 | `braid.widget.core` 8 | 9 | - a module must have a `braid.widget.core` namespace 10 | - `braid.widget.core` must be a `cljc` file 11 | - `braid.widget.core` must have a namespace docstring explaining the purpose of the module 12 | - `braid.widget.core` must have a non-private `init!` function 13 | - `braid.widget.core` can contain implementation details or require functions or values from other namespaces 14 | - all other functions in `braid.widget.core` (other than `init!`) should be private 15 | 16 | `braid.widget.api` 17 | 18 | - a module can optionally expose functionality to other modules via a `braid.widget.api` namespace 19 | - `braid.widget.api` can be a `clj`, `cljs` or `cljc` file 20 | - `braid.widget.api` should only contain functions (or other values) intended for use by other modules (all "private" values should be in other namespaces under `braid.widget.*`) 21 | - all public functions should have docstrings 22 | - all public functions should validate their inputs (for example using `:pre` conditions and spec) 23 | 24 | 25 | 26 | ## Development Notes 27 | 28 | - new modules need to be added to `braid.core.modules` 29 | - most "new functionality" should be implemented by creating new modules rather than changing exist modules 30 | - you may need to expose functionality in an existing module to achieve what you need 31 | - you probably want to make use of `braid.core.hooks` 32 | 33 | 34 | ## Notes for Future 35 | 36 | - a way for each module to declare library dependencies 37 | - a way for each module to declare tests 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /docs/drafts/guidelines-code.md: -------------------------------------------------------------------------------- 1 | ## Branching Model 2 | 3 | ## Commit Messages 4 | 5 | ## Code Organization 6 | 7 | Tests for server 8 | 9 | 10 | 11 | We value simplicity and elegance. 12 | 13 | 14 | 15 | Three audiences: 16 | - contributors 17 | no-configuration start 18 | 19 | - personal server deploy 20 | 21 | 22 | - production deployment 23 | possible to swap out components, scale horizontally and vertically 24 | 25 | 26 | 27 | 28 | development production 29 | database datomic free datomic pro 30 | session store in-memory redis 31 | search datomic onyx + solr 32 | 33 | 34 | 35 | 36 | ## Nomenclature 37 | 38 | tags 39 | 40 | threads/conversations 41 | -------------------------------------------------------------------------------- /docs/drafts/guidelines-design.md: -------------------------------------------------------------------------------- 1 | # Design Guidelines 2 | 3 | ...when possible, use analogies (ex. to email) 4 | 5 | every action should give immediate visual feedback 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/drafts/guidelines-documentation.md: -------------------------------------------------------------------------------- 1 | # Documentation Guidelines 2 | -------------------------------------------------------------------------------- /docs/images/braid-icon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braidchat/braid/5464e39ce5ce8d0fda3b1a614715ee4c7cbafe06/docs/images/braid-icon-256.png -------------------------------------------------------------------------------- /docs/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braidchat/braid/5464e39ce5ce8d0fda3b1a614715ee4c7cbafe06/docs/images/screenshot.png -------------------------------------------------------------------------------- /docs/images/youtube-player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braidchat/braid/5464e39ce5ce8d0fda3b1a614715ee4c7cbafe06/docs/images/youtube-player.png -------------------------------------------------------------------------------- /docs/responsible-disclosure-policy.md: -------------------------------------------------------------------------------- 1 | # Responsible Disclosure Policy 2 | 3 | Safety and data security is very important to the Braid community. If you have discovered a security vulnerability in our code base, we appreciate your help in disclosing it to us in a responsible manner: 4 | 5 | 1. Please email security@braidchat.com to report any security vulnerabilities found in the [open source code maintained by Braid](https://github.com/braidchat/). 6 | 2. Please refrain from requesting compensation for reporting vulnerabilities. 7 | 3. We will acknowledge receipt of your vulnerability report and send you regular updates about our progress. 8 | 4. If your report results in a change to the code base or documentation of a Braid product, we will – at your option – publicly acknowledge your responsible disclosure. 9 | 10 | 11 | Please do not test directly against our production instances. You can [install a copy of Braid yourself](./dev/getting-up-and-running-in-development.md) to test against. 12 | 13 | If you want to encrypt your disclosure email please email us to ask for our PGP key. 14 | -------------------------------------------------------------------------------- /gateway.cljs.edn: -------------------------------------------------------------------------------- 1 | {:main braid.core.client.gateway.core 2 | :closure-defines {"re_frame.trace.trace_enabled_QMARK_" true} 3 | :asset-path "/js/dev/gateway/" 4 | :output-to "resources/public/js/dev/gateway.js" 5 | :output-dir "resources/public/js/dev/gateway/" 6 | :optimizations :none 7 | :verbose false} 8 | -------------------------------------------------------------------------------- /prod.cljs.edn: -------------------------------------------------------------------------------- 1 | ^{:watch-dirs ["src"]} 2 | {:asset-path "/js/prod/" 3 | :output-dir "resources/public/js/prod/out" 4 | :optimizations :advanced 5 | :pretty-print false 6 | :elide-asserts true 7 | :closure-defines {goog.DEBUG false} 8 | :modules {:cljs-base 9 | {:output-to "resources/public/js/prod/base.js"} 10 | :desktop 11 | {:output-to "resources/public/js/prod/desktop.js" 12 | :entries #{"braid.core.client.desktop.core"}} 13 | :gateway 14 | {:output-to "resources/public/js/prod/gateway.js" 15 | :entries #{"braid.core.client.gateway.core"}}} 16 | :verbose true} 17 | -------------------------------------------------------------------------------- /profiles.sample.clj: -------------------------------------------------------------------------------- 1 | ; Rename this file to profiles.clj 2 | ; Run `lein with-profile +braid repl` 3 | 4 | {:braid 5 | {:env 6 | {;; for invite emails: 7 | :email-host "smtp.mailgun.org" 8 | :email-user "myuser@site.com" 9 | :email-password "my_email_password" 10 | :email-port 587 11 | :email-secure "tls" 12 | :email-from "noreply@braid.chat" 13 | :site-url "http://localhost:5555" 14 | :hmac-secret "foobar" 15 | ;; for avatar and file uploads 16 | :aws-bucket "braid-bucket" 17 | :aws-region "us-east-1" 18 | :aws-access-key "my_aws_key" 19 | :aws-secret-key "my_aws_secret" 20 | ;; for github login 21 | :github-client-id "..." 22 | :github-client-secret "..." 23 | :google-maps-api-key "" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /resources/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ../CHANGELOG.md -------------------------------------------------------------------------------- /resources/public/desktop.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{app_title}} 5 | 6 | 7 | 8 |
9 | {{#extra_scripts}} 10 | {{& script}} 11 | {{/extra_scripts}} 12 | {{#prod}} 13 | 14 | 15 | {{/prod}} 16 | {{#dev}} 17 | 18 | {{/dev}} 19 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /resources/public/fonts/fa-brands-400.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braidchat/braid/5464e39ce5ce8d0fda3b1a614715ee4c7cbafe06/resources/public/fonts/fa-brands-400.eot -------------------------------------------------------------------------------- /resources/public/fonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braidchat/braid/5464e39ce5ce8d0fda3b1a614715ee4c7cbafe06/resources/public/fonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /resources/public/fonts/fa-brands-400.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braidchat/braid/5464e39ce5ce8d0fda3b1a614715ee4c7cbafe06/resources/public/fonts/fa-brands-400.woff -------------------------------------------------------------------------------- /resources/public/fonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braidchat/braid/5464e39ce5ce8d0fda3b1a614715ee4c7cbafe06/resources/public/fonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /resources/public/fonts/fa-solid-900.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braidchat/braid/5464e39ce5ce8d0fda3b1a614715ee4c7cbafe06/resources/public/fonts/fa-solid-900.eot -------------------------------------------------------------------------------- /resources/public/fonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braidchat/braid/5464e39ce5ce8d0fda3b1a614715ee4c7cbafe06/resources/public/fonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /resources/public/fonts/fa-solid-900.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braidchat/braid/5464e39ce5ce8d0fda3b1a614715ee4c7cbafe06/resources/public/fonts/fa-solid-900.woff -------------------------------------------------------------------------------- /resources/public/fonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braidchat/braid/5464e39ce5ce8d0fda3b1a614715ee4c7cbafe06/resources/public/fonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /resources/public/gateway.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{app_title}} 5 | 6 | 7 | 8 |
9 | {{#prod}} 10 | 11 | 12 | {{/prod}} 13 | {{#dev}} 14 | 15 | {{/dev}} 16 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /resources/public/images/braid-logo-color.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/public/images/braid.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/public/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braidchat/braid/5464e39ce5ce8d0fda3b1a614715ee4c7cbafe06/resources/public/images/favicon.png -------------------------------------------------------------------------------- /resources/public/images/quests/conversation-close.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braidchat/braid/5464e39ce5ce8d0fda3b1a614715ee4c7cbafe06/resources/public/images/quests/conversation-close.gif -------------------------------------------------------------------------------- /resources/public/images/quests/conversation-new.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braidchat/braid/5464e39ce5ce8d0fda3b1a614715ee4c7cbafe06/resources/public/images/quests/conversation-new.gif -------------------------------------------------------------------------------- /resources/public/images/quests/conversation-reply.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braidchat/braid/5464e39ce5ce8d0fda3b1a614715ee4c7cbafe06/resources/public/images/quests/conversation-reply.gif -------------------------------------------------------------------------------- /resources/public/oauth-post-auth.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /resources/templates/email_digest.html.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Braid Email Digest 5 | 34 | 35 | 36 |

Braid Updates

37 | 38 | 39 | 40 | 41 | 67 | 68 | 69 |
42 |
43 |

Some things that happened on Braid while you were away

44 |
45 |
46 | {{#threads}} 47 |
48 |
49 |
50 | {{#messages}} 51 |
52 | {{sender}} avatar 53 |
54 | {{sender}} 55 | {{created-at}} 56 |
57 |
{{content}}
58 |
59 | {{/messages}} 60 |
61 |
62 |
63 |
64 | {{/threads}} 65 |
66 |
70 | 71 | 72 | -------------------------------------------------------------------------------- /resources/templates/email_digest.txt.mustache: -------------------------------------------------------------------------------- 1 | Braid Updates 2 | 3 | {{#threads}} 4 | 5 | {{#messages}} 6 | - {{sender}}: {{content}} 7 | {{/messages}} 8 | 9 | {{/threads}} 10 | -------------------------------------------------------------------------------- /resources/templates/reset_page.html.mustache: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Reset Password 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /scripts/video-convert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This script converts movie files into gifs. 4 | # It is used when creating instruction videos for quests. 5 | # 6 | # How to create a quest instruction videos: 7 | # 8 | # 0. Install dependencies: 9 | # 10 | # on Mac: brew install ffmpeg gifsicle 11 | # 12 | # 1. Capture a video 13 | # 14 | # on Mac: you can use Quicktime Player: File > New Screen Recording 15 | # 16 | # 2. Convert the video to a gif using the script: 17 | # 18 | # ./video-convert.sh my-video.mov 19 | # 20 | # (this creates a my-video.gif) 21 | # 22 | # 3. Move the gif to /resource/public/images/quests/ 23 | # 24 | # 4. Add the image path to `braid.quests.list` 25 | # 26 | # For Linux, consider using byzanz: http://linux.die.net/man/1/byzanz-record 27 | # 28 | # Code based on http://blog.pkh.me/p/21-high-quality-gif-with-ffmpeg.html 29 | 30 | INFILE=$1 31 | outfile="${INFILE%%.mov}.gif" 32 | 33 | filters="fps=15,scale=800:-1:flags=lanczos" 34 | palette="/tmp/palette.png" 35 | 36 | ffmpeg -v warning -i $1 -vf "$filters,palettegen" -y $palette 37 | ffmpeg -v warning -i $1 -r 10 -i $palette -lavfi "$filters [x]; [x][1:v] paletteuse" -f gif - | gifsicle --optimize=3 --delay=4 > $outfile 38 | 39 | -------------------------------------------------------------------------------- /src/braid/base/client/pages.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.base.client.pages 2 | (:require 3 | [spec-tools.data-spec :as ds] 4 | [braid.core.hooks :as hooks])) 5 | 6 | (def page-dataspec 7 | {:key keyword? 8 | :view fn? 9 | (ds/opt :on-load) fn? 10 | (ds/opt :on-exit) fn? 11 | (ds/opt :styles) vector?}) 12 | 13 | (defonce pages 14 | (hooks/register! (atom {}) {keyword? page-dataspec})) 15 | 16 | (defn on-load! [page-id page] 17 | (when-let [f (get-in @pages [page-id :on-load])] 18 | (f page))) 19 | 20 | (defn on-exit! [page-id page] 21 | (when-let [f (get-in @pages [page-id :on-exit])] 22 | (f page))) 23 | 24 | (defn get-view [page-id] 25 | (get-in @pages [page-id :view])) 26 | -------------------------------------------------------------------------------- /src/braid/base/client/remote_handlers.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.base.client.remote-handlers 2 | (:require 3 | [braid.core.hooks :as hooks] 4 | [braid.base.client.socket :as socket] 5 | [taoensso.timbre :as timbre :refer-macros [errorf]])) 6 | 7 | (defonce incoming-socket-message-handlers 8 | (hooks/register! (atom {}) {keyword? fn?})) 9 | 10 | (defmethod socket/event-handler :default 11 | [[id data]] 12 | (if-let [handler (@incoming-socket-message-handlers id)] 13 | (handler id data) 14 | (errorf "No socket message handler for id: %s" id))) 15 | -------------------------------------------------------------------------------- /src/braid/base/client/root_view.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.base.client.root-view 2 | (:require 3 | [braid.core.hooks :as hooks])) 4 | 5 | (defonce root-views (hooks/register! (atom []) [fn?])) 6 | 7 | -------------------------------------------------------------------------------- /src/braid/base/client/router.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.base.client.router 2 | (:require 3 | [accountant.core :as accountant] 4 | [secretary.core :as secretary]) 5 | (:import 6 | (goog Uri))) 7 | 8 | (defn init [] 9 | (accountant/configure-navigation! 10 | {:nav-handler (fn [path] 11 | (secretary/dispatch! path)) 12 | :path-exists? (fn [path] 13 | (secretary/locate-route path))})) 14 | 15 | (defn dispatch-current-path! [] 16 | (accountant/dispatch-current!)) 17 | 18 | (defn go-to [path] 19 | (accountant/navigate! path)) 20 | 21 | (defn current-path [] 22 | (.getPath (.parse Uri js/window.location))) 23 | -------------------------------------------------------------------------------- /src/braid/base/client/state.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.base.client.state 2 | (:require 3 | [clojure.spec.alpha :as s] 4 | [spec-tools.data-spec :as ds] 5 | [re-frame.core :as re-frame] 6 | [braid.core.common.util :as util])) 7 | 8 | (defn initialize-state 9 | [db] 10 | (-> (db ::initial-state) 11 | (merge (select-keys db [::state-spec 12 | ::initial-state])))) 13 | 14 | (re-frame/reg-event-fx ::register-state! 15 | (fn [{db :db} [_ state spec]] 16 | {:db (-> db 17 | (update ::initial-state merge state) 18 | (update ::state-spec merge spec))})) 19 | 20 | (defn register-state! 21 | [state spec] 22 | ;; Dispatch sync because we want the module setup calls 23 | ;; to finish before initializing the db 24 | (re-frame/dispatch-sync [::register-state! state spec])) 25 | 26 | (re-frame/reg-sub :braid.state/valid? 27 | (fn [db _] 28 | (util/valid? (db ::state-spec) db))) 29 | 30 | (def validate-schema-interceptor 31 | (re-frame/after 32 | (fn [db [event-id]] 33 | (when-let [errors (s/explain-data 34 | (ds/spec {:name ::app-state 35 | :spec (db ::state-spec)}) 36 | db)] 37 | (js/console.error 38 | (str 39 | "Event " event-id 40 | " caused the state to be invalid:\n") 41 | (pr-str (map (fn [problem] 42 | {:path (problem :path) 43 | :pred (problem :pred)}) 44 | (::s/problems errors)))))))) 45 | 46 | (if ^boolean goog.DEBUG 47 | (defn reg-event-fx 48 | ([id handler-fn] 49 | (reg-event-fx id nil handler-fn)) 50 | ([id interceptors handler-fn] 51 | (re-frame/reg-event-fx 52 | id 53 | [validate-schema-interceptor 54 | interceptors] 55 | handler-fn))) 56 | (def reg-event-fx re-frame/reg-event-fx)) 57 | -------------------------------------------------------------------------------- /src/braid/base/client/styles.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.base.client.styles 2 | (:require 3 | [braid.core.hooks :as hooks] 4 | [garden.core :refer [css]])) 5 | 6 | (def style-dataspec 7 | #(or (vector? %) (map? %))) 8 | 9 | (defonce module-styles 10 | (hooks/register! 11 | (atom [] [style-dataspec]))) 12 | 13 | (defn styles-view [] 14 | [:style 15 | {:type "text/css" 16 | :dangerouslySetInnerHTML 17 | {:__html (css 18 | {:auto-prefix #{:transition 19 | :flex-direction 20 | :flex-shrink 21 | :align-items 22 | :animation 23 | :flex-grow} 24 | :vendors ["webkit"]} 25 | 26 | @module-styles)}}]) 27 | -------------------------------------------------------------------------------- /src/braid/base/client/subs.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.base.client.subs 2 | (:require 3 | [re-frame.core :refer [reg-sub reg-sub-raw]])) 4 | 5 | (defn register-subs! 6 | [sub-map] 7 | (doseq [[k f] sub-map] 8 | (reg-sub k f))) 9 | 10 | (defn register-subs-raw! 11 | [sub-map] 12 | (doseq [[k f] sub-map] 13 | (reg-sub-raw k f))) 14 | -------------------------------------------------------------------------------- /src/braid/base/conf.clj: -------------------------------------------------------------------------------- 1 | (ns braid.base.conf 2 | (:require 3 | [braid.base.conf-extra :refer [ports-config]] 4 | [braid.core.hooks :as hooks] 5 | [malli.core :as malli] 6 | [malli.error :as malli.error] 7 | [malli.transform :as malli.transform] 8 | [clojure.pprint :as pprint] 9 | [mount.core :as mount :refer [defstate]])) 10 | 11 | (defonce config-vars 12 | (hooks/register! (atom []) [any?])) 13 | 14 | (defn ->malli-schema 15 | [config-vars] 16 | (->> config-vars 17 | (map (fn [{:keys [key required? schema]}] 18 | [key {:optional (not required?)} schema])) 19 | (into [:map]))) 20 | 21 | (defn select-and-validate 22 | "Extracts and validates keys from a given env map according to config-vars. 23 | Removes keys that aren't defined in schema. 24 | Throws an error when missing keys or values are invalid." 25 | [env config-vars] 26 | (let [schema (->malli-schema config-vars) 27 | env (malli/decode schema env (malli.transform/transformer 28 | malli.transform/strip-extra-keys-transformer 29 | malli.transform/string-transformer))] 30 | (if (malli/validate schema env) 31 | env 32 | (let [cause (malli/explain schema env)] 33 | (throw 34 | (ex-info (str "Config invalid\n" 35 | (with-out-str 36 | (pprint/pprint (malli.error/humanize cause)))) cause)))))) 37 | 38 | #_(select-and-validate 39 | {:foo "123" 40 | :bar 2} 41 | [{:key :foo 42 | :required? false 43 | :schema [:int]}]) 44 | 45 | 46 | (defstate config 47 | :start 48 | (merge ;; temp defaults 49 | ;; TODO don't special case these here 50 | ;; ports should come from config 51 | {:db-url "datomic:mem://braid" 52 | :site-url (str "http://localhost:" (:port (mount/args))) 53 | :hmac-secret "secret" 54 | :app-title "Braid"} 55 | @ports-config ; overrides site url when port is automatically found 56 | (select-and-validate (mount/args) @config-vars))) 57 | -------------------------------------------------------------------------------- /src/braid/base/conf_extra.clj: -------------------------------------------------------------------------------- 1 | (ns braid.base.conf-extra) 2 | 3 | (defonce ports-config (atom {})) 4 | -------------------------------------------------------------------------------- /src/braid/base/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.base.core 2 | (:require 3 | [braid.base.api :as base] 4 | #?@(:cljs 5 | [[braid.base.client.router :as router] 6 | [braid.base.client.events :as events] 7 | [braid.base.client.socket :as socket] 8 | [re-frame.core :refer [dispatch]]] 9 | :clj 10 | [[braid.base.server.initial-data :as initial-data]]))) 11 | 12 | (defn init! [] 13 | #?(:cljs 14 | (do 15 | (base/register-incoming-socket-message-handlers! 16 | {::init-data 17 | (fn [_ data] 18 | (dispatch [::set-init-data! data]) 19 | (router/dispatch-current-path!) 20 | (dispatch [:notify-if-client-out-of-date! (data :version-checksum)])) 21 | 22 | :socket/connected 23 | (fn [_ _] 24 | (socket/chsk-send! [::server-start nil]))}) 25 | 26 | (base/register-events! 27 | {::set-init-data! 28 | (fn [{state :db} [_ data]] 29 | ;; FIXME :set-login-state! defined in chat 30 | {:dispatch-n (list [:set-login-state! :app]) 31 | :db (-> state 32 | (as-> <> 33 | (reduce (fn [db f] (f db data)) 34 | <> 35 | @events/initial-user-data-handlers)))})})) 36 | 37 | :clj 38 | (do 39 | ;; TODO transfer appropriate vars from braid.chat.core 40 | (base/register-config-var! :app-title :optional [:string]) 41 | (base/register-config-var! :prod-js :optional [:boolean]) 42 | (base/register-config-var! :redis-uri :optional [:re #"^redis://.*$"]) 43 | 44 | 45 | (base/register-server-message-handlers! 46 | {::server-start 47 | (fn [{:keys [user-id]}] 48 | {:chsk-send! [user-id [::init-data 49 | (->> @initial-data/initial-user-data 50 | (into {} (map (fn [f] (f user-id)))))]]})})))) 51 | -------------------------------------------------------------------------------- /src/braid/base/server/cache.clj: -------------------------------------------------------------------------------- 1 | (ns braid.base.server.cache 2 | (:require 3 | [braid.base.conf :as conf] 4 | [taoensso.carmine :as car])) 5 | 6 | ; same as conf in handler, but w/e 7 | (def redis-conn (delay 8 | {:pool {} 9 | :spec {:uri (conf/config :redis-uri)}})) 10 | 11 | (def redis? (delay (conf/config :redis-uri))) 12 | 13 | (def dev-cache 14 | "Cache used in place of redis when running in dev/demo mode" 15 | (atom {})) 16 | 17 | (defn cache-set! [k v] 18 | (if @redis? 19 | (car/wcar @redis-conn (car/set k v)) 20 | (swap! dev-cache assoc k v))) 21 | 22 | (defn cache-get [k] 23 | (if @redis? 24 | (car/wcar @redis-conn (car/get k)) 25 | (@dev-cache k))) 26 | 27 | (defn cache-del! [k] 28 | (if @redis? 29 | (car/wcar @redis-conn (car/del k)) 30 | (swap! dev-cache dissoc k))) 31 | -------------------------------------------------------------------------------- /src/braid/base/server/cqrs.clj: -------------------------------------------------------------------------------- 1 | (ns braid.base.server.cqrs 2 | (:require 3 | [tada.events.core :as tada] 4 | [braid.core.hooks :as hooks] 5 | [taoensso.timbre :as timbre])) 6 | 7 | (defn dispatch [command-id args] 8 | (tada/do! command-id args)) 9 | 10 | (defonce commands (hooks/register! (atom []))) 11 | 12 | (defn ->ws-handler [command] 13 | (fn [{:keys [user-id ?data ?reply-fn]}] 14 | (timbre/debug "Command:" (:id command) ?data) 15 | (try 16 | (dispatch (:id command) 17 | (assoc ?data 18 | :user-id user-id)) 19 | (?reply-fn :braid/ok) 20 | (catch Exception e 21 | (timbre/warn "Command Error:" e) 22 | (?reply-fn {:cqrs/error {:message (.getMessage e) 23 | :data (ex-data e)}}))) 24 | ;; returning {} here, b/c our socket-message-handlers 25 | ;; expect a map of cofx 26 | {})) 27 | 28 | (defn update-registry! [] 29 | (tada/register! @commands)) 30 | -------------------------------------------------------------------------------- /src/braid/base/server/cqrs_fx.clj: -------------------------------------------------------------------------------- 1 | (ns braid.base.server.cqrs-fx 2 | "Namespace that collects common CQRS command fx" 3 | (:require 4 | [braid.base.server.socket :as socket] 5 | [braid.core.server.sync-helpers :as helpers] 6 | [braid.core.server.db :as db] 7 | [braid.base.server.cqrs :as cqrs])) 8 | 9 | (def run-txns! db/run-txns!) 10 | 11 | (def chsk-send! socket/chsk-send!) 12 | 13 | (def group-broadcast! helpers/broadcast-group-change) 14 | 15 | (def dispatch! cqrs/dispatch) 16 | -------------------------------------------------------------------------------- /src/braid/base/server/http_client_routes.clj: -------------------------------------------------------------------------------- 1 | (ns braid.base.server.http-client-routes 2 | (:require 3 | [braid.base.conf :refer [config]] 4 | [compojure.core :refer [GET defroutes]] 5 | [compojure.route :refer [resources]] 6 | [ring.util.response :refer [resource-response]])) 7 | 8 | (defroutes resource-routes 9 | 10 | ; add cache-control headers to js files) 11 | ; (since it uses a cache-busted url anyway) 12 | (GET (str "/js/:build{desktop|gateway|base}.js") [build] 13 | (if (boolean (config :prod-js)) 14 | (if-let [response (resource-response (str "public/js/prod/" build ".js"))] 15 | (assoc-in response [:headers "Cache-Control"] "max-age=365000000, immutable") 16 | {:status 404 17 | :body "File Not Found"}) 18 | (if-let [response (resource-response (str "public/js/dev/" build ".js"))] 19 | response 20 | {:status 200 21 | :headers {"Content-Type" "application/javascript"} 22 | :body (str "alert('The " build " js files are missing. Please compile them with cljsbuild or figwheel.');")}))) 23 | 24 | (resources "/")) 25 | -------------------------------------------------------------------------------- /src/braid/base/server/initial_data.clj: -------------------------------------------------------------------------------- 1 | (ns braid.base.server.initial-data 2 | (:require 3 | [braid.core.hooks :as hooks])) 4 | 5 | (defonce initial-user-data (hooks/register! (atom []))) 6 | -------------------------------------------------------------------------------- /src/braid/base/server/jobs.clj: -------------------------------------------------------------------------------- 1 | (ns braid.base.server.jobs 2 | (:require 3 | [braid.core.hooks :as hooks] 4 | [braid.base.server.scheduler :refer [scheduler]] 5 | [clojurewerkz.quartzite.jobs :as j :refer [defjob]] 6 | [clojurewerkz.quartzite.schedule.cron :as cron] 7 | [clojurewerkz.quartzite.scheduler :as qs] 8 | [clojurewerkz.quartzite.triggers :as t] 9 | [mount.core :refer [defstate]] 10 | [taoensso.timbre :as timbre])) 11 | 12 | (defonce daily-module-jobs (hooks/register! (atom []))) 13 | 14 | (defjob DailyModulesJob 15 | [ctx] 16 | (timbre/debugf "Starting daily modules job") 17 | (doseq [job @daily-module-jobs] 18 | (try 19 | (job) 20 | (catch Exception e 21 | (timbre/errorf "Error running job %s: %s" job e))))) 22 | 23 | (defn daily-modules-job 24 | [] 25 | (j/build 26 | (j/of-type DailyModulesJob) 27 | (j/with-identity (j/key "jobs.daily-modules.1")))) 28 | 29 | (defn daily-modules-trigger 30 | [] 31 | (t/build 32 | (t/with-identity (t/key "triggers.daily-modules")) 33 | (t/start-now) 34 | (t/with-schedule 35 | (cron/schedule 36 | (cron/daily-at-hour-and-minute 2 30))))) 37 | 38 | (defn register-daily-job! 39 | [job-fn] 40 | (swap! daily-module-jobs conj job-fn)) 41 | 42 | (defn add-jobs 43 | [scheduler] 44 | (qs/schedule scheduler (daily-modules-job) (daily-modules-trigger))) 45 | 46 | (defn remove-jobs 47 | [scheduler] 48 | (qs/delete-job scheduler (j/key "jobs.daily-modules.1"))) 49 | 50 | (defstate jobs 51 | :start (add-jobs scheduler) 52 | :stop (remove-jobs scheduler)) 53 | -------------------------------------------------------------------------------- /src/braid/base/server/scheduler.clj: -------------------------------------------------------------------------------- 1 | (ns braid.base.server.scheduler 2 | (:require 3 | [clojurewerkz.quartzite.scheduler :as qs] 4 | [mount.core :as mount :refer [defstate]] 5 | [taoensso.timbre :as timbre])) 6 | 7 | (defn stop-scheduler! 8 | [s] 9 | (qs/shutdown s)) 10 | 11 | (defn start-scheduler! 12 | [] 13 | (timbre/infof "starting quartz scheduler") 14 | (doto (qs/initialize) 15 | (qs/start))) 16 | 17 | (defstate scheduler 18 | :start (start-scheduler!) 19 | :stop (stop-scheduler! scheduler)) 20 | -------------------------------------------------------------------------------- /src/braid/base/server/schema.clj: -------------------------------------------------------------------------------- 1 | (ns braid.base.server.schema 2 | (:require 3 | [clojure.spec.alpha :as s] 4 | [spec-tools.data-spec :as ds] 5 | [braid.core.hooks :as hooks])) 6 | 7 | (def rule-dataspec 8 | {:db/ident keyword? 9 | (ds/opt :db/doc) string? 10 | :db/valueType (s/spec #{:db.type/boolean 11 | :db.type/instant 12 | :db.type/keyword 13 | :db.type/long 14 | :db.type/ref 15 | :db.type/string 16 | :db.type/uuid}) 17 | :db/cardinality (s/spec #{:db.cardinality/one 18 | :db.cardinality/many}) 19 | (ds/opt :db/unique) (s/spec #{:db.unique/identity 20 | :db.unique/value})}) 21 | 22 | (defonce schema 23 | (hooks/register! (atom []) [rule-dataspec])) 24 | -------------------------------------------------------------------------------- /src/braid/base/server/seed.clj: -------------------------------------------------------------------------------- 1 | (ns braid.base.server.seed 2 | (:require 3 | [braid.core.hooks :as hooks])) 4 | 5 | (defonce seed-fns (hooks/register! (atom []))) 6 | 7 | (defn seed! [] 8 | (when (empty? @seed-fns) 9 | "Nothing seeded. You may need to initialize modules first.") 10 | (doseq [f @seed-fns] 11 | (f))) 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/braid/base/server/socket.clj: -------------------------------------------------------------------------------- 1 | (ns braid.base.server.socket 2 | (:require 3 | [mount.core :as mount :refer [defstate]] 4 | [taoensso.sente.server-adapters.http-kit :refer [get-sch-adapter]] 5 | [taoensso.sente.packers.transit :as sente-transit] 6 | [taoensso.sente :as sente])) 7 | 8 | (let [packer (sente-transit/get-transit-packer :json) 9 | {:keys [ch-recv send-fn ajax-post-fn ajax-get-or-ws-handshake-fn 10 | connected-uids]} 11 | (sente/make-channel-socket! (get-sch-adapter) 12 | {:user-id-fn 13 | (fn [ob] (or (get-in ob [:session :user-id]) 14 | (get-in ob [:session :fake-id]))) 15 | :simple-auto-threading? true 16 | :csrf-token-fn nil 17 | :ws-kalive-ms 30000 18 | :packer packer})] 19 | (def ring-ajax-post ajax-post-fn) 20 | (def ring-ajax-get-or-ws-handshake ajax-get-or-ws-handshake-fn) 21 | (def ch-chsk ch-recv) 22 | (def chsk-send! send-fn) 23 | (def connected-uids connected-uids)) 24 | -------------------------------------------------------------------------------- /src/braid/base/server/spa.clj: -------------------------------------------------------------------------------- 1 | (ns braid.base.server.spa 2 | (:require 3 | [cljstache.core :as cljstache] 4 | [braid.lib.s3 :as s3] 5 | [braid.core.hooks :as hooks] 6 | [braid.base.conf :refer [config]] 7 | [braid.lib.digest :as digest])) 8 | 9 | (def additional-script-dataspec 10 | (fn [tag] 11 | (or (fn? tag) 12 | (and (map? tag) 13 | (or (:src tag) (:body tag)))))) 14 | 15 | (defonce additional-scripts 16 | (hooks/register! (atom []) [additional-script-dataspec])) 17 | 18 | (defn init-script [client] 19 | (str "braid.core.client." client ".core.init();")) 20 | 21 | (defn get-html [client vars] 22 | (let [prod-js? (boolean (config :prod-js))] 23 | (cljstache/render-resource 24 | (str "public/" client ".html") 25 | (merge {:prod prod-js? 26 | :dev (not prod-js?) 27 | :algo "sha256" 28 | :init_script (init-script client) 29 | :js (if prod-js? 30 | (str (digest/from-file (str "public/js/prod/" client ".js"))) 31 | (str (digest/from-file (str "public/js/dev/" client ".js")))) 32 | :basejs (when prod-js? 33 | (str (digest/from-file (str "public/js/prod/base.js")))) 34 | :app_title (config :app-title) 35 | :asset_domain (s3/s3-host config) 36 | :extra_scripts 37 | (->> @additional-scripts 38 | (map (fn [s] (if (fn? s) (s) s))) 39 | (map (fn [s] 40 | {:script (if-let [src (:src s)] 41 | (str "") 42 | (str ""))})))} 43 | vars)))) 44 | -------------------------------------------------------------------------------- /src/braid/bots/client/autocomplete.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.bots.client.autocomplete 2 | (:require 3 | [braid.lib.upload :as upload] 4 | [braid.core.client.ui.views.autocomplete :refer [fuzzy-matches?]] 5 | [clojure.string :as string] 6 | [re-frame.core :refer [subscribe dispatch]])) 7 | 8 | ; / -> autocompletes bots 9 | (defn bots-autocomplete-engine [text] 10 | (let [pattern #"^/(\w+)$" 11 | open-group (subscribe [:open-group-id])] 12 | (when-let [bot-name (second (re-find pattern text))] 13 | (into () 14 | (comp (filter (fn [b] (fuzzy-matches? (b :nickname) bot-name))) 15 | (map (fn [b] 16 | {:key (constantly (b :id)) 17 | :action (fn []) 18 | :message-transform 19 | (fn [text] 20 | (string/replace text pattern 21 | (str "/" (b :nickname) " "))) 22 | :html 23 | (constantly 24 | [:div.bot.match 25 | [:img.avatar {:src (upload/->path (b :avatar))}] 26 | [:div.info 27 | [:div.name (b :nickname)] 28 | [:div.extra]]])}))) 29 | @(subscribe [:bots/group-bots] [open-group]))))) 30 | -------------------------------------------------------------------------------- /src/braid/bots/client/remote_handlers.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.bots.client.remote-handlers 2 | (:require 3 | [re-frame.core :refer [dispatch]])) 4 | 5 | (def handlers 6 | {:braid.client.bots/new-bot 7 | (fn [_ [group-id bot]] 8 | (dispatch [:bots/add-group-bot! [group-id bot]])) 9 | 10 | :braid.client.bots/retract-bot 11 | (fn [_ [group-id bot-id]] 12 | (dispatch [:bots/remove-group-bot! [group-id bot-id]])) 13 | 14 | :braid.client.bots/edit-bot 15 | (fn [_ [group-id bot]] 16 | (dispatch [:bots/update-group-bot! [group-id bot]]))}) 17 | -------------------------------------------------------------------------------- /src/braid/bots/client/subs.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.bots.client.subs 2 | (:refer-clojure :exclude [subs]) 3 | (:require 4 | [re-frame.core :refer [reg-sub]])) 5 | 6 | (def subs 7 | {:bots/group-bots 8 | (fn [state _ [group-id]] 9 | (get-in state [:groups group-id :bots])) 10 | 11 | :bots/group-bot 12 | (fn [{:keys [open-group-id] :as state} [_ bot-user-id]] 13 | (let [group-bots (get-in state [:groups open-group-id :bots])] 14 | (some-> group-bots 15 | (->> (filter (fn [b] (= (b :user-id) bot-user-id)))) 16 | first)))}) 17 | -------------------------------------------------------------------------------- /src/braid/bots/client/views/bot_sender_view.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.bots.client.views.bot-sender-view 2 | (:require 3 | [braid.lib.upload :as upload] 4 | [braid.lib.color :as color] 5 | [braid.core.client.routes :as routes] 6 | [re-frame.core :refer [subscribe]])) 7 | 8 | (defn sender-view 9 | [bot-id] 10 | (let [sender @(subscribe [:bots/group-bot bot-id]) 11 | current-group (subscribe [:open-group-id]) 12 | sender-path (routes/group-page-path {:group-id @current-group 13 | :page-id "bots"})] 14 | (when sender 15 | {:avatar [:a.avatar {:href sender-path 16 | :tab-index -1} 17 | [:img {:src (upload/->path (:avatar sender)) 18 | :style {:background-color (color/->color (:id sender))}}]] 19 | :info [:span.bot-notice "BOT"] 20 | :name [:a.nickname {:tab-index -1 21 | :href sender-path} 22 | (:nickname sender)]}))) 23 | -------------------------------------------------------------------------------- /src/braid/bots/client/views/bots_page_styles.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.bots.client.views.bots-page-styles 2 | (:require 3 | [braid.core.client.ui.styles.mixins :as mixins] 4 | [braid.core.client.ui.styles.vars :as vars] 5 | [garden.units :refer [rem em px]])) 6 | 7 | (def bot-notice 8 | [:.thread>.card>.messages>.message>.info>.bot-notice 9 | {:background-color "#c0afc0" 10 | :border-radius (px 5) 11 | :padding (rem 0.25) 12 | :font-weight "bold" 13 | :color "#413f42"}]) 14 | 15 | (def bots-page 16 | [:.app>.main>.page.bots 17 | [:>.title 18 | {:font-size "large"}] 19 | [:>.content 20 | (mixins/settings-container-style) 21 | 22 | [:>.bots 23 | (mixins/settings-item-style) 24 | [:>.bots-list 25 | mixins/flex 26 | {:flex-direction "row" 27 | :flex-wrap "wrap" 28 | :align-content "space-between" 29 | :align-items "baseline"} 30 | 31 | [:>.bot 32 | {:margin (em 1)} 33 | [:img {:margin "0 auto"}] 34 | [:button 35 | (mixins/outline-button {:text-color vars/grey-text 36 | :border-color "darkgray" 37 | :hover-border-color "lightgray" 38 | :hover-text-color "lightgray"}) 39 | [:&.dangerous {:color "red"}] 40 | [:&.delete 41 | (mixins/fontawesome nil)]] 42 | 43 | [:>.avatar 44 | {:width (rem 4) 45 | :height (rem 4) 46 | :display "block" 47 | :border-radius (rem 1) 48 | :margin-bottom vars/pad}]]]] 49 | 50 | [:>.add-bot 51 | (mixins/settings-item-style) 52 | {:max-width "50%" 53 | :margin "0 auto"} 54 | [:form 55 | mixins/flex 56 | {:flex-direction "column"} 57 | [:.new-avatar>img 58 | {:max-width "150px"}]]]]]) 59 | -------------------------------------------------------------------------------- /src/braid/bots/schema.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.bots.schema 2 | (:require 3 | [clojure.spec.alpha :as s] 4 | [spec-tools.data-spec :as ds])) 5 | 6 | (def Bot 7 | {:id uuid? 8 | :group-id uuid? 9 | :user-id uuid? 10 | :name string? 11 | :avatar string? 12 | :webhook-url string? 13 | :event-webhook-url (ds/maybe string?) 14 | :token string? 15 | :notify-all-messages? boolean?}) 16 | 17 | (def BotDisplay 18 | "Like Bot but for publicly-available bot info" 19 | {:id uuid? 20 | :user-id uuid? 21 | :nickname string? 22 | :avatar string?}) 23 | -------------------------------------------------------------------------------- /src/braid/bots/server.clj: -------------------------------------------------------------------------------- 1 | (ns braid.bots.server 2 | "Sending notification of messages and events to bots" 3 | (:require 4 | [braid.lib.crypto :as crypto] 5 | [braid.lib.transit :refer [->transit]] 6 | [org.httpkit.client :as http] 7 | [taoensso.timbre :as timbre]) 8 | (:import 9 | (java.io ByteArrayInputStream))) 10 | 11 | (defn send-message-notification 12 | [bot message] 13 | (let [body (->transit message) 14 | hmac (crypto/hmac-bytes (bot :token) body)] 15 | (timbre/debugf "sending bot notification") 16 | (try 17 | ; TODO: should this be a POST too? 18 | (->> 19 | @(http/put (bot :webhook-url) 20 | {:headers {"Content-Type" "application/transit+msgpack" 21 | "X-Braid-Signature" hmac} 22 | :body (ByteArrayInputStream. body)}) 23 | (timbre/debugf "Bot response: %s")) 24 | (catch Exception ex 25 | (timbre/warnf "Error sending bot notification: %s" ex))))) 26 | 27 | (defn send-event-notification 28 | [bot info] 29 | (when-let [url (:event-webhook-url bot)] 30 | (timbre/debugf "Sending event notification %s to %s" info bot) 31 | (let [body (->transit info) 32 | hmac (crypto/hmac-bytes (bot :token) body)] 33 | (timbre/debugf "sending bot event notification") 34 | (try 35 | (->> 36 | @(http/post url 37 | {:headers {"Content-Type" "application/transit+msgpack" 38 | "X-Braid-Signature" hmac} 39 | :body (ByteArrayInputStream. body)}) 40 | (timbre/debugf "Bot response: %s")) 41 | (catch Exception ex 42 | (timbre/warnf "Error sending bot event notification: %s" ex)))))) 43 | -------------------------------------------------------------------------------- /src/braid/bots/util.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.bots.util) 2 | 3 | (def bot-name-re 4 | #"(?:\w|\d){1,30}") 5 | -------------------------------------------------------------------------------- /src/braid/chat/db/invitation.clj: -------------------------------------------------------------------------------- 1 | (ns braid.chat.db.invitation 2 | (:require 3 | [braid.core.server.db :as db] 4 | [braid.chat.db.common :refer [create-entity-txn db->invitation]] 5 | [datomic.api :as d])) 6 | 7 | ;; Queries 8 | 9 | (defn invite-by-id 10 | [invite-id] 11 | (some-> (d/pull (db/db) 12 | [:invite/id 13 | {:invite/from [:user/id :user/email :user/nickname]} 14 | :invite/to 15 | {:invite/group [:group/id :group/name]}] 16 | [:invite/id invite-id]) 17 | db->invitation)) 18 | 19 | (defn invites-for-user 20 | [user-id] 21 | (->> (d/q '[:find (pull ?i [{:invite/group [:group/id :group/name]} 22 | {:invite/from [:user/id :user/email :user/nickname]} 23 | :invite/to 24 | :invite/id]) 25 | :in $ ?user-id 26 | :where 27 | [?u :user/id ?user-id] 28 | [?u :user/email ?email] 29 | [?i :invite/to ?email]] 30 | (db/db) user-id) 31 | (map (comp db->invitation first)))) 32 | 33 | ;; Transactions 34 | 35 | (defn create-invitation-txn 36 | [{:keys [id inviter-id invitee-email group-id]}] 37 | (create-entity-txn 38 | {:invite/id id 39 | :invite/group [:group/id group-id] 40 | :invite/from [:user/id inviter-id] 41 | :invite/to invitee-email 42 | :invite/created-at (java.util.Date.)} 43 | db->invitation)) 44 | 45 | (defn retract-invitation-txn 46 | [invite-id] 47 | [[:db.fn/retractEntity [:invite/id invite-id]]]) 48 | -------------------------------------------------------------------------------- /src/braid/chat/events.clj: -------------------------------------------------------------------------------- 1 | (ns braid.chat.events 2 | (:require 3 | [braid.core.server.db :as db] 4 | [braid.chat.db.group :as group] 5 | [braid.chat.db.thread :as thread] 6 | [braid.chat.db.user :as user] 7 | [braid.core.server.sync-helpers :as sync-helpers])) 8 | 9 | (defn user-join-group! 10 | [user-id group-id] 11 | (db/run-txns! (group/user-join-group-txn user-id group-id)) 12 | (db/run-txns! (group/user-open-recent-threads user-id group-id)) 13 | (sync-helpers/broadcast-group-change 14 | group-id 15 | [:braid.client/new-user [(-> (user/user-by-id user-id) 16 | (assoc :joined-at (java.util.Date.))) 17 | group-id]])) 18 | 19 | (defn register-user! 20 | [{:keys [email password group-id]}] 21 | (let [[user] (db/run-txns! (user/create-user-txn {:id (db/uuid) 22 | :email email}))] 23 | (db/run-txns! (user/set-user-password-txn (:id user) password)) 24 | (user-join-group! (user :id) group-id) 25 | (user :id))) 26 | -------------------------------------------------------------------------------- /src/braid/chat/server/initial_data.clj: -------------------------------------------------------------------------------- 1 | (ns braid.chat.server.initial-data 2 | (:require 3 | [braid.base.conf :as conf] 4 | [braid.base.server.socket :refer [connected-uids]] 5 | [braid.chat.db.group :as group] 6 | [braid.chat.db.invitation :as invitation] 7 | [braid.chat.db.tag :as tag] 8 | [braid.chat.db.thread :as thread] 9 | [braid.chat.db.user :as user] 10 | [braid.lib.digest :as digest])) 11 | 12 | (defn initial-data-for-user 13 | [user-id] 14 | (let [connected (set (:any @connected-uids)) 15 | user-status (fn [user] (if (connected (user :id)) :online :offline)) 16 | update-user-statuses (fn [users] 17 | (reduce-kv 18 | (fn [m id u] 19 | (assoc m id (assoc u :status (user-status u)))) 20 | {} users))] 21 | {;; TODO could be part of braid.base 22 | :user-id user-id 23 | ;; TODO could be part of braid.base: 24 | :version-checksum (if (boolean (conf/config :prod-js)) 25 | (digest/from-file "public/js/prod/desktop.js") 26 | (digest/from-file "public/js/dev/desktop.js")) 27 | :user-groups 28 | (->> (group/user-groups user-id) 29 | (map (fn [group] (update group :users update-user-statuses))) 30 | (map (fn [group] 31 | (->> (group/group-users-joined-at (:id group)) 32 | (reduce (fn [group [user-id joined-at]] 33 | (assoc-in 34 | group 35 | [:users user-id :joined-at] 36 | joined-at)) 37 | group))))) 38 | :user-threads (thread/open-threads-for-user user-id) 39 | :user-subscribed-tag-ids (tag/subscribed-tag-ids-for-user user-id) 40 | :user-preferences (user/user-get-preferences user-id) 41 | :invitations (invitation/invites-for-user user-id) 42 | :tags (tag/tags-for-user user-id)})) 43 | -------------------------------------------------------------------------------- /src/braid/core.clj: -------------------------------------------------------------------------------- 1 | (ns braid.core 2 | (:gen-class) 3 | (:require 4 | [environ.core :refer [env]] 5 | [mount.core :as mount] 6 | [org.httpkit.client] 7 | [org.httpkit.sni-client] 8 | [taoensso.timbre :as timbre] 9 | [braid.base.conf] 10 | [braid.core.modules :as modules] 11 | ;; all following requires are for mount: 12 | [braid.core.server.core] 13 | [braid.core.server.email-digest])) 14 | 15 | ;; because we often use http-kit as our http-client 16 | ;; including this so that SNI works 17 | (alter-var-root #'org.httpkit.client/*default-client* 18 | (fn [_] org.httpkit.sni-client/default-client)) 19 | 20 | (defn start! 21 | "Entry point for prod" 22 | ([port] 23 | (start! port 24 | (cond-> env 25 | (env :aws-access-key) 26 | (assoc :aws/credentials-provider 27 | (constantly ((juxt :aws-access-key :aws-secret-key) env)))))) 28 | ([port args] 29 | ;; modules must run first 30 | (when (empty? (args :hmac-secret)) 31 | (timbre/warn "No :hmac-secret set, using an insecure default.")) 32 | (modules/init! modules/default) 33 | (-> (mount/with-args (assoc args :port port)) 34 | (mount/start)) 35 | (when (zero? port) 36 | (mount/stop #'braid.base.conf/config) 37 | (mount/start #'braid.base.conf/config)))) 38 | 39 | (defn stop! 40 | "Helper function for stopping all Braid components." 41 | [] 42 | (mount/stop)) 43 | 44 | (defn -main 45 | "Entry point for prod" 46 | [& args] 47 | (let [port (Integer/parseInt (first args))] 48 | (start! port))) 49 | -------------------------------------------------------------------------------- /src/braid/core/client/desktop/core.cljs: -------------------------------------------------------------------------------- 1 | (ns ^:figwheel-hooks 2 | braid.core.client.desktop.core 3 | (:require 4 | [braid.base.client.events] 5 | [braid.base.client.subs] 6 | [braid.chat.client.events] 7 | [braid.chat.client.subs] 8 | [braid.core.client.gateway.events] 9 | [braid.core.client.gateway.subs] 10 | [braid.core.client.group-admin.events] 11 | [braid.core.client.group-admin.subs] 12 | [braid.core.client.invites.events] 13 | [braid.core.client.invites.subs] 14 | [braid.base.client.router :as router] 15 | [braid.base.client.remote-handlers] 16 | [braid.core.client.ui.views.app :refer [app-view]] 17 | [braid.core.modules :as modules] 18 | [re-frame.core :as rf :refer [dispatch-sync dispatch]] 19 | [reagent.dom :as r-dom])) 20 | 21 | (enable-console-print!) 22 | 23 | (defn render [] 24 | (r-dom/render [app-view] (.getElementById js/document "app"))) 25 | 26 | (defn ^:export init 27 | ([] (init modules/default)) 28 | ([modules] 29 | (modules/init! modules) 30 | 31 | (dispatch-sync [:initialize-db!]) 32 | 33 | (.addEventListener js/document "visibilitychange" 34 | (fn [e] 35 | (dispatch [:set-window-visibility! 36 | (= "visible" (.-visibilityState js/document))]))) 37 | 38 | (render) 39 | 40 | (router/init))) 41 | 42 | (defn ^:after-load reload 43 | "Force a re-render. For use with figwheel" 44 | [] 45 | (modules/init! modules/default) 46 | (render)) 47 | -------------------------------------------------------------------------------- /src/braid/core/client/desktop/notify.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.desktop.notify 2 | (:require 3 | [goog.object :as o])) 4 | 5 | (defn has-notify? 6 | [] 7 | (some? (o/get js/window "Notification"))) 8 | 9 | (defn enabled? 10 | [] 11 | (= "granted" (.-permission js/Notification))) 12 | 13 | (defn request-permission 14 | [cb] 15 | (.requestPermission js/Notification (fn [perm] (cb perm)))) 16 | 17 | (defn notify 18 | [{:keys [title body icon] :or {title "Braid" 19 | icon "https://braid.chat/images/braid.svg"}}] 20 | (js/Notification. title #js {:body body :icon icon})) 21 | -------------------------------------------------------------------------------- /src/braid/core/client/gateway/core.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.gateway.core 2 | (:require 3 | [braid.core.client.gateway.events :as events] 4 | [braid.core.client.gateway.subs] 5 | [braid.core.client.gateway.views :refer [gateway-view]] 6 | [braid.core.modules :as modules] 7 | [goog.object :as o] 8 | [re-frame.core :refer [dispatch-sync]] 9 | [reagent.dom :as r-dom])) 10 | 11 | (enable-console-print!) 12 | 13 | (defn render [] 14 | (r-dom/render [gateway-view] (. js/document (getElementById "app")))) 15 | 16 | (defn ^:export init [] 17 | (modules/init! modules/default) 18 | (dispatch-sync [::events/initialize! (keyword (o/get js/window "gateway_action"))]) 19 | (render)) 20 | 21 | (defn ^:export reload [] 22 | (render)) 23 | -------------------------------------------------------------------------------- /src/braid/core/client/gateway/events.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.gateway.events 2 | (:require 3 | [braid.core.client.gateway.forms.join-group.events :as join-group] 4 | [braid.core.client.gateway.forms.user-auth.events :as user-auth] 5 | [braid.core.client.gateway.fx] 6 | [braid.base.client.state :refer [reg-event-fx]] ;; TODO should use base.api/register-events! 7 | [re-frame.core :refer [dispatch]])) 8 | 9 | (reg-event-fx 10 | ::initialize! 11 | (fn [{state :db} [_ action]] 12 | {:db (-> state 13 | (assoc :action action) 14 | (assoc :action-disabled? true)) 15 | :dispatch [::handle-action!]})) 16 | 17 | (reg-event-fx 18 | ::handle-action! 19 | (fn [{state :db} _] 20 | (case (state :action) 21 | :create-group 22 | {:dispatch [:braid.group-create.core/initialize!]} 23 | 24 | :log-in 25 | {:dispatch [::user-auth/initialize! :log-in]} 26 | 27 | :request-password-reset 28 | {:dispatch [::user-auth/initialize! :request-password-reset]} 29 | 30 | :join-group 31 | {:dispatch [::join-group/initialize!]}))) 32 | 33 | (reg-event-fx 34 | ::change-user-status! 35 | (fn [{state :db} [_ logged-in?]] 36 | (merge 37 | {:db (assoc state :action-disabled? (not logged-in?))} 38 | (cond 39 | (and (= :log-in (state :action)) logged-in?) 40 | {:dispatch [:start-socket!]} 41 | 42 | (and (= :join-group (state :action)) logged-in?) 43 | {:dispatch [::join-group/remote-join-group!]} 44 | 45 | :else {})))) 46 | -------------------------------------------------------------------------------- /src/braid/core/client/gateway/forms/join_group/events.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.gateway.forms.join-group.events 2 | (:require 3 | [braid.core.client.gateway.helpers :as helpers :refer [get-url-group-id]] 4 | [braid.base.client.state :refer [reg-event-fx]] ;; TODO should use reg.base.api/register-events! 5 | [clojure.string :as string] 6 | [re-frame.core :refer [dispatch]])) 7 | 8 | (reg-event-fx 9 | ::initialize! 10 | (fn [{state :db}] 11 | {:dispatch-n [[:braid.core.client.gateway.forms.user-auth.events/initialize! :log-in]]})) 12 | 13 | (reg-event-fx 14 | ::remote-join-group! 15 | (fn [{state :db} _] 16 | {:edn-xhr {:method :put 17 | :uri (str "/groups/" (get-url-group-id) "/join") 18 | :headers {"x-csrf-token" (state :csrf-token)} 19 | :on-complete 20 | (fn [response] 21 | (set! js/window.location (str "/groups/" (get-url-group-id) "/inbox"))) 22 | :on-error 23 | (fn [error] 24 | (when-let [k (get-in error [:response :error])] 25 | (dispatch [:braid.notices/display! [(keyword "join-group-error" (get-url-group-id)) k :error]])))}})) 26 | -------------------------------------------------------------------------------- /src/braid/core/client/gateway/forms/user_auth/subs.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.gateway.forms.user-auth.subs 2 | (:require 3 | [braid.core.client.gateway.helpers :as helpers] 4 | [re-frame.core :refer [reg-sub]])) 5 | 6 | (reg-sub 7 | ::user 8 | (fn [state _] 9 | (get-in state [:user-auth :user]))) 10 | 11 | (reg-sub 12 | ::user-auth-mode 13 | (fn [state _] 14 | (cond 15 | (nil? (get-in state [:user-auth])) :checking 16 | (get-in state [:user-auth :checking?]) :checking 17 | (get-in state [:user-auth :user]) :authed 18 | (get-in state [:user-auth :oauth-provider]) :oauth-in-progress 19 | :else (get-in state [:user-auth :mode])))) 20 | 21 | (reg-sub 22 | ::oauth-provider 23 | (fn [state _] 24 | (get-in state [:user-auth :oauth-provider]))) 25 | 26 | (reg-sub 27 | ::error 28 | (fn [state _] 29 | (get-in state [:user-auth :error]))) 30 | 31 | (helpers/reg-form-subs :braid.core.client.gateway.forms.user-auth.subs :user-auth) 32 | -------------------------------------------------------------------------------- /src/braid/core/client/gateway/forms/user_auth/validations.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.gateway.forms.user-auth.validations 2 | (:require 3 | [clojure.string :as string]) 4 | (:import 5 | (goog.format EmailAddress))) 6 | 7 | (def validations 8 | {:email 9 | [(fn [email cb] 10 | (if (string/blank? email) 11 | (cb "You need to enter an email.") 12 | (cb nil))) 13 | (fn [email cb] 14 | (if (not (.isValid (EmailAddress. email))) 15 | (cb "This doesn't look like a valid email.") 16 | (cb nil)))] 17 | 18 | :password 19 | [(fn [password cb] 20 | (if (string/blank? password) 21 | (cb "You need to enter a password.") 22 | (cb nil)))] 23 | 24 | :new-password 25 | [(fn [password cb] 26 | (if (string/blank? password) 27 | (cb "You need to enter a password.") 28 | (cb nil))) 29 | (fn [password cb] 30 | (if (< (count password) 8) 31 | (cb "Your password is too short.") 32 | (cb nil)))]}) 33 | -------------------------------------------------------------------------------- /src/braid/core/client/gateway/fx.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.gateway.fx 2 | (:require 3 | [braid.lib.xhr :as xhr] 4 | [braid.core.client.state.fx.dispatch-debounce :as fx.debounce] 5 | [re-frame.core :refer [dispatch reg-fx]])) 6 | 7 | (reg-fx :dispatch-debounce 8 | fx.debounce/dispatch) 9 | 10 | (reg-fx :stop-debounce 11 | fx.debounce/stop) 12 | 13 | (reg-fx :edn-xhr xhr/edn-xhr) 14 | -------------------------------------------------------------------------------- /src/braid/core/client/gateway/styles_vars.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.gateway.styles-vars 2 | (:require 3 | [braid.core.client.ui.styles.fontawesome :as fontawesome])) 4 | 5 | (def small-spacing "0.5rem") 6 | (def border-radius "3px") 7 | (def font-family "Open Sans") 8 | 9 | (def accent-color "#2bb8ba") 10 | (def page-background-color "#f3f3f3") 11 | (def form-background-color "#ffffff") 12 | (def primary-text-color "#444444") 13 | (def secondary-text-color "#999999") 14 | (def field-border-color "#e0e0e0") 15 | (def invalid-color "#fd4734") 16 | (def invalid-light-color "#ff8477") 17 | (def valid-color "#2bb8ba") 18 | (def disabled-input-color "#f6f6f6") 19 | (def placeholder-color "#eeeeee") 20 | (def disabled-button-color "#cccccc") 21 | 22 | (defn input-field-mixin [] 23 | {:font-size "1.25rem" 24 | :font-family font-family 25 | :padding "0 0.4em" 26 | :width "100%" 27 | :box-sizing "border-box" 28 | :border [["1px" "solid" field-border-color]] 29 | :line-height "2em" 30 | :height "2em" 31 | :border-radius border-radius}) 32 | 33 | (defn small-text-mixin [] 34 | [:& 35 | {:color secondary-text-color 36 | :font-size "0.75em" 37 | :margin [[small-spacing 0 0 0]]} 38 | [:p 39 | {:margin "0"}]]) 40 | 41 | (defn small-button-mixin [] 42 | [:& 43 | {:display "inline-block" 44 | :border-radius border-radius 45 | :padding [[0 "0.5em"]] 46 | :margin-left "0.5em" 47 | :background "none" 48 | :color secondary-text-color 49 | :cursor "pointer" 50 | :font-size "0.8em" 51 | :height "1.25rem" 52 | :line-height "1.25rem" 53 | :letter-spacing "0.02em" 54 | :text-transform "uppercase" 55 | :box-sizing "border-box" 56 | :border [["1px" "solid" field-border-color]] 57 | :transition ["border-color 0.25s ease-in-out" 58 | "color 0.25s ease-in-out"]} 59 | 60 | [:&::after 61 | (fontawesome/mixin :angle-double-right) 62 | {:margin-left "0.25em"}] 63 | 64 | [:&:hover 65 | {:color "#666666" 66 | :border-color "#999999"}] 67 | 68 | [:&:active 69 | {:background secondary-text-color}]]) 70 | 71 | (defn padded-with-border-mixin [] 72 | {:padding "1em" 73 | :border [["1px" "solid" field-border-color]] 74 | :border-radius border-radius}) 75 | -------------------------------------------------------------------------------- /src/braid/core/client/gateway/subs.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.gateway.subs 2 | (:require 3 | [re-frame.core :refer [reg-sub]] 4 | [braid.core.client.gateway.forms.user-auth.subs])) 5 | 6 | (reg-sub 7 | ::action 8 | (fn [state _] 9 | (get-in state [:action]))) 10 | 11 | (reg-sub 12 | ::action-disabled? 13 | (fn [state _] 14 | (get-in state [:action-disabled?]))) 15 | -------------------------------------------------------------------------------- /src/braid/core/client/gateway/views.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.gateway.views 2 | (:require 3 | [braid.core.client.gateway.forms.user-auth.styles :refer [user-auth-styles]] 4 | [braid.core.client.gateway.forms.user-auth.views :refer [user-auth-view]] 5 | [braid.core.client.gateway.styles :as styles] 6 | [braid.core.client.ui.styles.imports :refer [imports]] 7 | [garden.core :refer [css]] 8 | [re-frame.core :refer [subscribe]])) 9 | 10 | (defn style-view [] 11 | [:style 12 | {:type "text/css" 13 | :dangerouslySetInnerHTML 14 | {:__html 15 | (css {:auto-prefix #{:transition 16 | :flex-direction 17 | :flex-shrink 18 | :align-items 19 | :animation 20 | :flex-grow} 21 | :vendors ["webkit"]} 22 | imports 23 | styles/anim-spin 24 | (styles/app-styles) 25 | [:#app 26 | (styles/form-styles) 27 | (user-auth-styles)])}}]) 28 | 29 | (defn header-view [] 30 | [:h1.header "Braid"]) 31 | 32 | (defn gateway-view [] 33 | [:div.gateway 34 | [style-view] 35 | [header-view] 36 | [user-auth-view]]) 37 | -------------------------------------------------------------------------------- /src/braid/core/client/group_admin/events.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.group-admin.events 2 | (:require 3 | [braid.base.client.state :refer [reg-event-fx]] ;; TODO should use base.apie/register-events! 4 | )) 5 | 6 | (reg-event-fx 7 | :set-group-intro! 8 | (fn [{state :db} [_ {:keys [group-id intro local-only?] :as args}]] 9 | {:db (assoc-in state [:groups group-id :intro] intro) 10 | :websocket-send (when-not local-only? 11 | (list [:braid.server/set-group-intro args]))})) 12 | 13 | (reg-event-fx 14 | :set-group-avatar! 15 | (fn [{state :db} [_ {:keys [group-id avatar local-only?] :as args}]] 16 | {:db (assoc-in state [:groups group-id :avatar] avatar) 17 | :websocket-send (when-not local-only? 18 | (list [:braid.server/set-group-avatar args]))})) 19 | 20 | (reg-event-fx 21 | :make-group-public! 22 | (fn [_ [_ group-id]] 23 | {:websocket-send (list [:braid.server/set-group-publicity [group-id true]])})) 24 | 25 | (reg-event-fx 26 | :make-group-private! 27 | (fn [_ [_ group-id]] 28 | {:websocket-send (list [:braid.server/set-group-publicity [group-id false]])})) 29 | 30 | (reg-event-fx 31 | :set-group-publicity! 32 | (fn [{db :db} [_ [group-id publicity]]] 33 | {:db (assoc-in db [:groups group-id :public?] publicity)})) 34 | -------------------------------------------------------------------------------- /src/braid/core/client/group_admin/subs.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.group-admin.subs) 2 | -------------------------------------------------------------------------------- /src/braid/core/client/group_admin/views/group_settings_page_styles.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.group-admin.views.group-settings-page-styles 2 | (:require 3 | [braid.core.client.ui.styles.mixins :as mixins] 4 | [braid.core.client.ui.styles.vars :as vars] 5 | [garden.units :refer [rem em px]])) 6 | 7 | (def group-settings-page 8 | [:>.page.group-settings 9 | (mixins/settings-style)]) 10 | -------------------------------------------------------------------------------- /src/braid/core/client/helpers.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.helpers 2 | (:require-macros 3 | [cljs.core.async.macros :refer [go]]) 4 | (:require 5 | [cljs.core.async :refer [.page.invite 9 | [:>.content 10 | (mixins/settings-container-style) 11 | [:>.invite 12 | (mixins/settings-item-style)]]]) 13 | -------------------------------------------------------------------------------- /src/braid/core/client/routes.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.routes 2 | (:require 3 | [braid.base.client.router :as router] 4 | [braid.base.client.pages :as pages] 5 | [re-frame.core :refer [dispatch subscribe]] 6 | [secretary.core :as secretary :refer-macros [defroute]])) 7 | 8 | (defn go-to! [path] 9 | (router/go-to path)) 10 | 11 | (defroute join-group-path "/groups/:group-id/join" [group-id] 12 | (dispatch [:braid.core.client.gateway.events/initialize! :join-group]) 13 | (dispatch [:set-group-and-page! [nil {:type :login}]])) 14 | 15 | (defroute group-page-path "/groups/:group-id/:page-id" [group-id page-id query-params] 16 | (let [page (merge {:type (keyword page-id) 17 | :page-id (keyword page-id) 18 | :group-id (uuid group-id)} 19 | query-params)] 20 | (pages/on-exit! (:page-id @(subscribe [:page])) @(subscribe [:page])) 21 | (dispatch [:set-group-and-page! [(uuid group-id) page]]) 22 | (pages/on-load! (keyword page-id) page))) 23 | 24 | ;; invites end up here 25 | (defroute group-path "/groups/:group-id" [group-id] 26 | (router/go-to (group-page-path {:group-id group-id 27 | :page-id "inbox"}))) 28 | 29 | (defroute system-page-path "/pages/:page-id" [page-id query-params] 30 | (let [page (merge {:type (keyword page-id) 31 | :page-id (keyword page-id)} 32 | query-params)] 33 | (pages/on-exit! (:page-id @(subscribe [:page])) @(subscribe [:page])) 34 | (dispatch [:set-group-and-page! [nil page]]) 35 | (pages/on-load! (keyword page-id) page))) 36 | 37 | (defroute index-path "/" {} 38 | (dispatch [:set-group-and-page! [nil {:type :login}]]) 39 | (dispatch [:redirect-from-root!])) 40 | -------------------------------------------------------------------------------- /src/braid/core/client/schema.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.schema 2 | (:require 3 | [cljs-uuid-utils.core :as uuid])) 4 | 5 | (defn make-message [data] 6 | {:id (or (data :id) (uuid/make-random-squuid)) 7 | :content (data :content) 8 | :thread-id (or (data :thread-id) (uuid/make-random-squuid)) 9 | :group-id (data :group-id) 10 | :user-id (data :user-id) 11 | :created-at (or (data :created-at) (js/Date.)) 12 | :mentioned-user-ids (or (data :mentioned-user-ids) []) 13 | :mentioned-tag-ids (or (data :mentioned-tag-ids) [])}) 14 | 15 | (defn make-tag [] 16 | {:id (uuid/make-random-squuid) 17 | :name nil 18 | :group-id nil 19 | :description nil 20 | :threads-count 0 21 | :subscribers-count 0}) 22 | 23 | (defn make-group [data] 24 | {:id (or (data :id) (uuid/make-random-squuid)) 25 | :name (data :name) 26 | :slug (data :slug) 27 | :admins #{} 28 | :intro nil 29 | :avatar nil 30 | :public? false 31 | :bots #{}}) 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/braid/core/client/state/fx/dispatch_debounce.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.state.fx.dispatch-debounce 2 | (:require 3 | [re-frame.core :as re-frame])) 4 | 5 | (defonce timeouts 6 | (atom {})) 7 | 8 | (defn dispatch 9 | [[id event-vec n]] 10 | (js/clearTimeout (@timeouts id)) 11 | (swap! timeouts assoc id 12 | (js/setTimeout (fn [] 13 | (re-frame/dispatch event-vec) 14 | (swap! timeouts dissoc id)) 15 | n))) 16 | 17 | (defn stop [id] 18 | (js/clearTimeout (@timeouts id)) 19 | (swap! timeouts dissoc id)) 20 | -------------------------------------------------------------------------------- /src/braid/core/client/state/fx/redirect.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.state.fx.redirect 2 | (:require 3 | [braid.base.client.router :as router])) 4 | 5 | (defn redirect-to [route] 6 | (when route (router/go-to route))) 7 | -------------------------------------------------------------------------------- /src/braid/core/client/state/subscription.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.state.subscription) 2 | 3 | (defmulti subscription 4 | "Create a reaction for the particular type of information. 5 | Do not call directly, should be invoked by `subscribe`" 6 | {:arglists '([state [sub-name args]])} 7 | (fn [_ [sub-name _]] sub-name)) 8 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/styles/animations.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.styles.animations 2 | (:require 3 | [garden.stylesheet :refer [at-keyframes]])) 4 | 5 | (def anim-spin 6 | (at-keyframes :anim-spin 7 | ["0%" {:transform "rotate(0deg)"}] 8 | ["100%" {:transform "rotate(359deg)"}])) 9 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/styles/body.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.styles.body 2 | (:require 3 | [braid.core.client.ui.styles.mixins :as mixins])) 4 | 5 | (def body 6 | [:body 7 | mixins/standard-font 8 | {:margin 0 9 | :padding 0 10 | :background "#F2F2F2"} 11 | ; prevent overscroll: 12 | {:height "100%" 13 | :overflow "hidden"} 14 | 15 | [:textarea :input 16 | {:font-family "inherit" 17 | :font-size "1em"}]]) 18 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/styles/fontawesome.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.styles.fontawesome 2 | (:require 3 | [braid.core.client.ui.styles.mixins :as mixins])) 4 | 5 | (def icons 6 | {:github \uf09b 7 | :facebook \uf09a 8 | :google \uf1a0 9 | :caret-right \uf0da 10 | :chevron-right \uf054 11 | :angle-right \uf105 12 | :angle-double-right \uf101 13 | :warning \uf071 14 | :check-circle \uf00c 15 | :spinner \uf110 16 | :users \uf0c0}) 17 | 18 | (defn mixin [code] 19 | (mixins/fontawesome (or (icons code) code))) 20 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/styles/hljs.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.styles.hljs) 2 | 3 | (def foreground "#eaeaea") 4 | (def background "#000000") 5 | (def selection "#424242") 6 | (def line "#2a2a2a") 7 | (def comment-color "#969896") 8 | (def red "#d54e53") 9 | (def orange "#e78c45") 10 | (def yellow "#e7c547") 11 | (def green "#b9ca4a") 12 | (def aqua "#70c0b1") 13 | (def blue "#7aa6da") 14 | (def purple "#c397d8") 15 | (def window "#4d5057") 16 | 17 | (def hljs-styles 18 | [:>.hljs 19 | 20 | [:.hljs-comment 21 | :.hljs-quote 22 | {:color comment-color}] 23 | 24 | [:.hljs-variable 25 | :.hljs-template-variable 26 | :.hljs-tag 27 | :.hljs-name 28 | :.hljs-selector-id 29 | :.hljs-selector-class 30 | :.hljs-regexp 31 | :.hljs-deletion 32 | {:color red}] 33 | 34 | [:.hljs-number 35 | :.hljs-built_in 36 | :.hljs-builtin-name 37 | :.hljs-literal 38 | :.hljs-type 39 | :.hljs-params 40 | :.hljs-meta 41 | :.hljs-link 42 | {:color orange}] 43 | 44 | [:.hljs-attribute 45 | {:color yellow}] 46 | 47 | [:.hljs-symbol 48 | {:color aqua}] 49 | 50 | [:.hljs-string 51 | :.hljs-bullet 52 | :.hljs-addition 53 | {:color green}] 54 | 55 | [:.hljs-title 56 | :.hljs-section 57 | {:color blue}] 58 | 59 | [:.hljs-keyword 60 | :.hljs-selector-tag 61 | {:color purple}] 62 | 63 | [:.hljs-emphasis 64 | {:font-style "italic"}] 65 | 66 | [:.hljs-strong 67 | {:font-weight "bold"}]]) 68 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/styles/hover_menu.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.styles.hover-menu 2 | (:require 3 | [garden.arithmetic :as m] 4 | [garden.units :refer [px]] 5 | [braid.core.client.ui.styles.mixins :as mixins] 6 | [braid.core.client.ui.styles.vars :as vars])) 7 | 8 | (defn >hover-menu [] 9 | [:>.hover-menu 10 | {:background "white" 11 | :border "0.5px solid #ddd" 12 | :border-radius vars/border-radius 13 | ;; use filter instead of box-shadow 14 | ;; so that it takes the shape of the arrow too 15 | :filter "drop-shadow(0 1px 2px #ccc)"} 16 | 17 | [:&.top 18 | {:position "absolute" 19 | :bottom "100%"}] 20 | 21 | [:&.bottom 22 | {:position "absolute" 23 | :top "100%"}] 24 | 25 | [:&.left 26 | {:position "absolute" 27 | :right "100%"}] 28 | 29 | [:&.right 30 | {:position "absolute" 31 | :left "100%"}] 32 | 33 | ;; arrow 34 | (let [size (px 10)] 35 | [:>.arrow 36 | {:position "absolute" 37 | :width size 38 | :height size 39 | :fill "white" 40 | :stroke "#ddd" 41 | :stroke-width "0.51"} 42 | 43 | [:&.top 44 | {:bottom (m/- size) 45 | :left "11px"}] 46 | 47 | [:&.bottom 48 | {:top (m/- size) 49 | :left "11px"}] 50 | 51 | [:&.left 52 | {:right (m/- size) 53 | :top "11px"}] 54 | 55 | [:&.right 56 | {:left (m/- size) 57 | :top "11px"}]]) 58 | 59 | [:.content 60 | {:overflow-x "auto" 61 | :height "100%" 62 | :box-sizing "border-box" 63 | :padding [[(m/* vars/pad 0.75)]]} 64 | 65 | [:>a 66 | {:display "block" 67 | :color "black" 68 | :text-align "right" 69 | :text-decoration "none" 70 | :line-height "1.85em" 71 | :white-space "nowrap" 72 | :cursor "pointer"} 73 | 74 | [:&:hover 75 | {:color "#666"}] 76 | 77 | [:>.icon 78 | {:display "inline-block" 79 | :margin-left "0.5em" 80 | :width "1.5em"} 81 | (mixins/fontawesome nil)]]]]) 82 | 83 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/styles/imports.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.styles.imports 2 | (:require 3 | [garden.stylesheet :refer [at-import at-font-face]])) 4 | 5 | (def fa-at-font-face 6 | (at-font-face 7 | {:font-family "\"Font Awesome 5 Free\"" 8 | :src ["url('/fonts/fa-solid-900.eot?#iefix') format('embedded-opentype')" 9 | "url('/fonts/fa-solid-900.woff2') format('woff2')" 10 | "url('/fonts/fa-solid-900.woff') format('woff')" 11 | "url('/fonts/fa-solid-900.ttf') format('truetype')" 12 | "url('/fonts/fa-solid-900.svg#fontawesome') format('svg')"] 13 | :font-weight 900 14 | :font-display "block" 15 | :font-style "normal"})) 16 | 17 | (def fab-at-font-face 18 | (at-font-face 19 | {:font-family "\"Font Awesome 5 Brands\"" 20 | :src ["url('/fonts/fa-brands-400.eot?#iefix') format('embedded-opentype')" 21 | "url('/fonts/fa-brands-400.woff2') format('woff2')" 22 | "url('/fonts/fa-brands-400.woff') format('woff')" 23 | "url('/fonts/fa-brands-400.ttf') format('truetype')" 24 | "url('/fonts/fa-brands-400.svg#fontawesome') format('svg')"] 25 | :font-weight 400 26 | :font-display "block" 27 | :font-style "normal"})) 28 | 29 | (def imports 30 | [fa-at-font-face 31 | fab-at-font-face 32 | (at-import "https://fonts.googleapis.com/css?family=Open+Sans:400,300,400italic,700")]) 33 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/styles/misc.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.styles.misc 2 | (:require 3 | [braid.core.client.ui.styles.mixins :as mixins] 4 | [braid.core.client.ui.styles.vars :as vars] 5 | [garden.arithmetic :as m] 6 | [garden.units :refer [rem em px ex]])) 7 | 8 | (def page-headers 9 | [[:.page>.title::before 10 | {:margin-right (em 0.5)}] 11 | 12 | [:.page.users>.title::before 13 | (mixins/fontawesome \uf0c0)] 14 | 15 | [:.page.settings>.title::before 16 | (mixins/fontawesome \uf013)] 17 | 18 | [:.page.search>.title::before 19 | (mixins/fontawesome \uf002)] 20 | 21 | [:.page.me>.title::before 22 | (mixins/fontawesome \uf007)] 23 | 24 | [:.page.userpage>.title::before 25 | (mixins/fontawesome \uf007)]]) 26 | 27 | (def status 28 | [:>.status 29 | mixins/flex 30 | {:position "absolute" 31 | :top 0 32 | :bottom 0 33 | :right 0 34 | :left 0 35 | :background vars/page-background-color 36 | :color vars/primary-text-color 37 | :justify-content "center" 38 | :align-items "center" 39 | :font-size "2em"} 40 | 41 | [:&::before 42 | (mixins/fontawesome \uf110) 43 | mixins/spin 44 | {:display "inline-block" 45 | :margin-right (em 0.5)}]]) 46 | 47 | (defn drag-and-drop [pad] 48 | [:& 49 | 50 | [:&.dragging 51 | {:background-color "lightgray" 52 | :border [[(px 5) "dashed" "darkgray"]]}] 53 | 54 | [:>.uploading-indicator 55 | (mixins/fontawesome \uf110) 56 | mixins/spin 57 | {:font-size (em 1.5) 58 | :text-align "center"}]]) 59 | 60 | (defn avatar-upload [pad] 61 | [:& 62 | (drag-and-drop pad)]) 63 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/styles/page.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.styles.page 2 | (:require 3 | [braid.core.client.ui.styles.vars :as vars] 4 | [garden.units :refer [rem]])) 5 | 6 | (def page 7 | [:>.page 8 | {:position "absolute" 9 | :left vars/sidebar-width 10 | :top vars/pad 11 | :bottom 0 12 | :right 0 13 | :margin-top vars/top-bar-height 14 | :overflow-x "auto"} 15 | 16 | [:>.title 17 | {:height vars/top-bar-height 18 | :line-height vars/top-bar-height 19 | :color vars/grey-text 20 | :margin [[vars/pad 0 0 vars/pad]]}] 21 | 22 | [:>.intro 23 | {:color vars/grey-text 24 | :margin vars/pad 25 | ; make intro above threads, so you can click on clear inbox button 26 | :z-index 100 27 | :position "relative"}] 28 | 29 | [:>.content 30 | {:padding vars/pad 31 | :color vars/darkgrey-text} 32 | 33 | [:>.description 34 | {:width vars/card-width 35 | ; we want description to appear above the new thread, but below other threads 36 | ; TODO: is there a way to make description clickable but not cover 37 | ; threads that are long enough? 38 | :z-index 100 39 | ; opacity & position are hacks to make z-index work 40 | :position "relative" ; z-index only applies if element has a position set 41 | :opacity 0.99 ; need to create a new rendering context b/c threads are absolute postioned 42 | }]]]) 43 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/styles/pages/global_settings.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.styles.pages.global-settings 2 | (:require 3 | [braid.core.client.ui.styles.mixins :as mixins] 4 | [garden.units :refer [rem px]])) 5 | 6 | (def global-settings-page 7 | [:>.page.global-settings 8 | [:>.title {:font-size "large"}] 9 | (mixins/settings-style)]) 10 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/styles/pages/me.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.styles.pages.me 2 | (:require 3 | [braid.core.client.ui.styles.mixins :as mixins] 4 | [braid.core.client.ui.styles.vars :as vars] 5 | [garden.units :refer [rem em px]])) 6 | 7 | (def me-page 8 | [:>.page.me 9 | (mixins/settings-style) 10 | [:>.content 11 | [:>.setting 12 | [:form.password 13 | [:label 14 | {:display "block"}]] 15 | [:>.nickname 16 | [:input.error 17 | {:border-color "red"}]]]]]) 18 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/styles/pills.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.styles.pills 2 | (:require 3 | [braid.core.client.ui.styles.mixins :as mixins])) 4 | 5 | (defn tag [] 6 | [:.tag 7 | [:>.pill 8 | mixins/pill-box]]) 9 | 10 | (defn user [] 11 | [:.user 12 | [:>.pill 13 | mixins/pill-box]]) 14 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/styles/threads.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.styles.threads 2 | (:require 3 | [braid.core.client.ui.styles.thread] 4 | [braid.core.client.ui.styles.message] 5 | [braid.core.client.ui.styles.mixins :as mixins] 6 | [braid.core.client.ui.styles.vars :as vars])) 7 | 8 | (defn >threads [] 9 | [:>.threads 10 | mixins/flex 11 | {:position "absolute" 12 | :top "2.6rem" 13 | :right 0 14 | :bottom 0 15 | :left 0 16 | :align-items "flex-end" 17 | :overflow-x "auto" } 18 | 19 | (braid.core.client.ui.styles.thread/thread vars/pad) 20 | (braid.core.client.ui.styles.thread/notice vars/pad) 21 | 22 | [:>.thread 23 | 24 | [:>.card 25 | 26 | [:>.messages 27 | braid.core.client.ui.styles.message/message] 28 | 29 | (braid.core.client.ui.styles.thread/new-message vars/pad)]]]) 30 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/styles/vars.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.styles.vars 2 | (:require 3 | [garden.arithmetic :as m] 4 | [garden.units :refer [rem px]])) 5 | 6 | (def avatar-size (rem 2)) 7 | 8 | (def pad (rem 1)) 9 | 10 | (def card-width (rem 18)) 11 | 12 | (def top-bar-height (rem 2)) 13 | 14 | (def sidebar-width 15 | (m/+ top-bar-height (m/* pad 2))) 16 | 17 | (def grey-text "#888") 18 | 19 | (def darkgrey-text "#333") 20 | 21 | (def border-radius (px 3)) 22 | 23 | (def private-thread-accent-color "#5f7997") 24 | 25 | (def limbo-thread-accent-color "#CA1414") 26 | 27 | (def archived-thread-accent-color "#AAAAAA") 28 | 29 | (def page-background-color "#f3f3f3") 30 | (def primary-text-color "#444444") 31 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/views/app.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.views.app 2 | (:require 3 | [braid.core.client.ui.views.main :refer [main-view]] 4 | [braid.base.client.styles :as base.styles] 5 | [braid.core.client.ui.views.styles :refer [styles-view]] 6 | [re-frame.core :refer [subscribe]])) 7 | 8 | (defn app-view [] 9 | (case @(subscribe [:login-state]) 10 | 11 | (:ws-connect :anon-ws-connect) 12 | [:div.status 13 | [base.styles/styles-view] 14 | [styles-view] 15 | [:span "Connecting..."]] 16 | 17 | (:gateway :app :anon-connected) 18 | [:div.app 19 | [base.styles/styles-view] 20 | [styles-view] 21 | [main-view]])) 22 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/views/card_border.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.views.card-border 2 | (:require 3 | [re-frame.core :refer [subscribe]] 4 | [braid.lib.color :as color])) 5 | 6 | (defn card-border-view [thread-id] 7 | [:div.border 8 | {:style 9 | {:background (color/->color @(subscribe [:open-group-id]))}}]) 10 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/views/header_item.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.views.header-item 2 | (:require 3 | [re-frame.core :refer [subscribe]] 4 | [spec-tools.data-spec :as ds])) 5 | 6 | (def HeaderItem 7 | {(ds/opt :title) string? 8 | :priority number? 9 | (ds/opt :icon) string? 10 | (ds/opt :on-click) fn? 11 | (ds/opt :route-fn) fn? 12 | (ds/opt :route-args) {keyword? any?} 13 | (ds/opt :body) string?}) 14 | 15 | (defn header-item-view 16 | [conf] 17 | (let [open-group-id (subscribe [:open-group-id]) 18 | current-path (subscribe [:page-path])] 19 | (fn [{:keys [route-fn route-args title icon body on-click context]}] 20 | (let [path (when route-fn 21 | (route-fn (merge route-args {:group-id @open-group-id})))] 22 | [:a {:class (when (= path @current-path) "active") 23 | :href path 24 | :on-click (if context 25 | (on-click context) 26 | on-click) 27 | :title title} 28 | [:span body] 29 | (when icon 30 | [:div.icon icon])])))) 31 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/views/hover_menu.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.views.hover-menu 2 | (:require 3 | [braid.core.client.ui.views.header-item :refer [header-item-view]])) 4 | 5 | (defn hover-menu-view 6 | [context items position] 7 | [:div.hover-menu {:class (name position)} 8 | [:svg.arrow {:class (name position) 9 | :view-box "0 -5 10 10"} 10 | [:polyline {:points "0,-5 5,0 0,5" 11 | :transform (str "rotate(" 12 | (case position 13 | :top 90 14 | :bottom 270 15 | :left 0 16 | :right 180) 17 | ", 5, 0)")}]] 18 | (into 19 | [:div.content] 20 | (doall 21 | (for [item (->> items 22 | (sort-by :priority) 23 | reverse)] 24 | ^{:key (item :class)} 25 | [header-item-view (assoc item :context context)])))]) 26 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/views/main.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.views.main 2 | (:require 3 | [braid.core.client.gateway.views :refer [gateway-view]] 4 | [braid.core.client.gateway.forms.user-auth.views :refer [user-auth-view]] 5 | [braid.base.client.pages :as pages] 6 | [braid.base.client.root-view :as root-view] 7 | [braid.core.client.routes :as routes] 8 | [braid.core.client.ui.views.header :refer [header-view readonly-header-view]] 9 | [braid.core.client.ui.views.pages.readonly :refer [readonly-inbox-page-view]] 10 | [re-frame.core :refer [dispatch subscribe]])) 11 | 12 | (defn page-view [] 13 | (let [page @(subscribe [:page]) 14 | id (:type page)] 15 | (case id 16 | :readonly [readonly-inbox-page-view] 17 | :login [gateway-view] 18 | (when-let [view (pages/get-view id)] 19 | [view page])))) 20 | 21 | (defn readonly-page-view 22 | [] 23 | (let [page @(subscribe [:page]) 24 | id (:type page)] 25 | (case id 26 | (:inbox :readonly) [readonly-inbox-page-view] 27 | :login [gateway-view] 28 | (when-let [view (pages/get-view id)] 29 | [view page])))) 30 | 31 | (defn root-views [] 32 | (into 33 | [:<>] 34 | (for [view @root-view/root-views] 35 | [view]))) 36 | 37 | (defn main-view [] 38 | (case @(subscribe [:login-state]) 39 | :gateway 40 | [:div.main 41 | [:div.gateway 42 | [user-auth-view]]] 43 | 44 | :anon-connected 45 | [:div.main 46 | ;; TODO [sidebar-view] 47 | (when @(subscribe [:active-group]) 48 | [readonly-header-view]) 49 | [readonly-page-view] 50 | [root-views]] 51 | 52 | ;; default 53 | (into 54 | [:div.main 55 | (when @(subscribe [:open-group-id]) 56 | [header-view]) 57 | [page-view] 58 | [root-views]]))) 59 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/views/mentions.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.views.mentions 2 | (:require 3 | [braid.popovers.helpers :as popover] 4 | [braid.core.client.ui.views.pills :refer [user-pill-view tag-pill-view]] 5 | [braid.core.client.ui.views.user-hover-card :refer [user-hover-card-view]] 6 | [braid.core.client.ui.views.tag-hover-card :refer [tag-hover-card-view]])) 7 | 8 | (defn user-mention-view 9 | [user-id] 10 | [:div.user 11 | {:style {:display "inline-block"} 12 | :on-mouse-enter 13 | (popover/on-mouse-enter 14 | (fn [] 15 | [user-hover-card-view user-id]))} 16 | [user-pill-view user-id]]) 17 | 18 | (defn tag-mention-view 19 | [tag-id] 20 | [:div.tag 21 | {:style {:display "inline-block"} 22 | :on-mouse-enter 23 | (popover/on-mouse-enter 24 | (fn [] 25 | [tag-hover-card-view tag-id]))} 26 | [tag-pill-view tag-id]]) 27 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/views/new_message_action_button.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.views.new-message-action-button 2 | (:require 3 | [braid.popovers.helpers :as popovers] 4 | [braid.core.client.ui.views.hover-menu :refer [hover-menu-view]] 5 | [braid.core.client.ui.views.header-item :refer [HeaderItem]] 6 | [braid.core.hooks :as hooks])) 7 | 8 | (defonce menu-items 9 | (hooks/register! (atom []) [HeaderItem])) 10 | 11 | (defn new-message-action-button-view 12 | [{:keys [thread-id group-id]}] 13 | [:div.plus 14 | (let [popup-view (fn [] [hover-menu-view 15 | {:thread-id thread-id 16 | :group-id group-id} 17 | @menu-items :top])] 18 | {:on-mouse-enter 19 | (popovers/on-mouse-enter popup-view) 20 | :on-touch-start 21 | (popovers/on-touch-start popup-view)})]) 22 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/views/pages/changelog.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.views.pages.changelog 2 | (:require 3 | [braid.lib.xhr :as xhr] 4 | [reagent.core :as r])) 5 | 6 | (defn changelog-view [] 7 | (let [change-hiccup (r/atom nil)] 8 | (fn [] 9 | ; FIXME: circumvents re-frame, should use subscribe and dispatch 10 | (xhr/edn-xhr {:method :get 11 | :uri "/changelog" 12 | :on-complete 13 | (fn [resp] 14 | (reset! change-hiccup 15 | (or (:braid/ok resp) 16 | [:div.error "Failed to load changelog"])))}) 17 | [:div.page.changelog 18 | [:div.title "Changelog"] 19 | [:div.content 20 | [:h1 "The Evolution of Braid"] 21 | (if-let [changes @change-hiccup] 22 | changes 23 | [:p "Loading..."])]]))) 24 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/views/pages/readonly.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.views.pages.readonly 2 | (:require 3 | [braid.core.client.ui.views.header :refer [group-header-buttons-view]] 4 | [braid.core.client.ui.views.threads :refer [threads-view]] 5 | [braid.core.client.routes :as routes] 6 | [re-frame.core :refer [dispatch subscribe]] 7 | [reagent.ratom :include-macros true :refer-macros [reaction]])) 8 | 9 | (defn readonly-inbox-page-view 10 | [] 11 | (let [group @(subscribe [:active-group]) 12 | group-id @(subscribe [:open-group-id]) 13 | open-threads @(subscribe [:open-threads])] 14 | [:div.page.readonly-inbox 15 | [:div.intro 16 | (:intro group)] 17 | [threads-view {:threads (map #(assoc % :readonly true) open-threads) 18 | :resort-nonce group-id}]])) 19 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/views/pills.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.views.pills 2 | (:require 3 | [re-frame.core :refer [dispatch subscribe]] 4 | [braid.lib.color :as color])) 5 | 6 | (defn tag-pill-view 7 | [tag-id] 8 | (let [tag (subscribe [:tag tag-id]) 9 | user-subscribed-to-tag? (subscribe [:user-subscribed-to-tag? tag-id]) 10 | color (color/->color tag-id)] 11 | [:span.pill {:class (if @user-subscribed-to-tag? "on" "off") 12 | :tab-index -1 13 | :style {:background-color color 14 | :color color 15 | :border-color color}} 16 | [:div.name "#" (@tag :name)]])) 17 | 18 | (defn user-pill-view 19 | [user-id] 20 | (let [user (or @(subscribe [:user user-id]) 21 | {:nickname "DELETED" 22 | :status :offline}) 23 | color (color/->color user-id)] 24 | [:span.pill {:class (str (case (user :status) :online "on" "off")) 25 | :tab-index -1 26 | :style {:background-color color 27 | :color color 28 | :border-color color}} 29 | [:span.name (str "@" (user :nickname))]])) 30 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/views/subscribe_button.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.views.subscribe-button 2 | (:require 3 | [re-frame.core :refer [dispatch subscribe]])) 4 | 5 | (defn subscribe-button-view 6 | [tag-id] 7 | (let [user-subscribed-to-tag? (subscribe [:user-subscribed-to-tag? tag-id])] 8 | (if @user-subscribed-to-tag? 9 | [:a.button {:on-click 10 | (fn [_] 11 | (dispatch [:unsubscribe-from-tag! tag-id]))} 12 | "Unsubscribe"] 13 | [:a.button {:on-click 14 | (fn [_] 15 | (dispatch [:subscribe-to-tag! {:tag-id tag-id}]))} 16 | "Subscribe"]))) 17 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/views/tag_hover_card.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.views.tag-hover-card 2 | (:require 3 | [re-frame.core :refer [subscribe]] 4 | [braid.lib.color :as color] 5 | [braid.search.ui.search-button :refer [search-button-view]] 6 | [braid.core.client.ui.views.subscribe-button :refer [subscribe-button-view]] 7 | [braid.core.client.ui.views.pills :refer [tag-pill-view]])) 8 | 9 | (defn tag-hover-card-view 10 | [tag-id] 11 | (let [tag (subscribe [:tag tag-id]) 12 | user-subscribed-to-tag? (subscribe [:user-subscribed-to-tag? tag-id])] 13 | [:div.tag.card 14 | [:div.header {:style {:background-color (color/->color tag-id)}} 15 | [tag-pill-view tag-id] 16 | [:div.subscribers.count 17 | {:title (str (@tag :subscribers-count) " Subscribers")} 18 | (@tag :subscribers-count)] 19 | [:div.threads.count 20 | {:title (str (@tag :threads-count) " Conversations")} 21 | (@tag :threads-count)]] 22 | [:div.info 23 | [:div.description 24 | (@tag :description)]] 25 | [:div.actions 26 | [search-button-view (str "#" (@tag :name))] 27 | [subscribe-button-view tag-id]]])) 28 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/views/thread.cljs.rej: -------------------------------------------------------------------------------- 1 | diff a/src/braid/core/client/ui/views/thread.cljs b/src/braid/core/client/ui/views/thread.cljs (rejected hunks) 2 | @@ -162,8 +162,7 @@ 3 | (not (helpers/contains-urls? (prev-message :content)))))))))] 4 | (doall 5 | (for [message sorted-messages] 6 | - ^{:key (message :id)} 7 | - [:<> 8 | + [:<> {:key (message :id)} 9 | (when (message :show-date-divider?) 10 | [:div.divider 11 | [:div.date (helpers/format-date "yyyy-MM-dd" (message :created-at))]]) 12 | -------------------------------------------------------------------------------- /src/braid/core/client/ui/views/upload.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.core.client.ui.views.upload 2 | (:require 3 | [re-frame.core :refer [dispatch]] 4 | [reagent.core :as r])) 5 | 6 | (def max-avatar-size (* 2 1024 1024)) 7 | 8 | (defn avatar-upload-view 9 | [args] 10 | (let [uploading? (r/atom false) 11 | start-upload (fn [on-upload group-id type file-list] 12 | (let [file (aget file-list 0)] 13 | (if (> (.-size file) max-avatar-size) 14 | (dispatch [:braid.notices/display! [:avatar-set-fail "Avatar image too large" :error]]) 15 | (do (reset! uploading? true) 16 | (dispatch [:braid.uploads/upload! 17 | {:file file 18 | :group-id group-id 19 | :type type 20 | :on-complete 21 | (fn [{:keys [url]}] 22 | (reset! uploading? false) 23 | (on-upload url))}])))))] 24 | (fn [{:keys [on-upload dragging-change group-id type] :as args}] 25 | [:div.upload 26 | (if @uploading? 27 | [:div 28 | [:p "Uploading..." [:span.uploading-indicator "\uf110"]]] 29 | [:div 30 | {:on-drag-over (fn [e] 31 | (doto e (.stopPropagation) (.preventDefault)) 32 | (dragging-change true)) 33 | :on-drag-leave (fn [_] (dragging-change false)) 34 | :on-drop (fn [e] 35 | (.preventDefault e) 36 | (dragging-change false) 37 | (reset! uploading? true) 38 | (start-upload on-upload group-id type (.. e -dataTransfer -files)))} 39 | [:label "Choose an avatar image" 40 | [:input {:type "file" :accept "image/*" 41 | :on-change (fn [e] 42 | (start-upload on-upload group-id type (.. e -target -files)))}]]])]))) 43 | -------------------------------------------------------------------------------- /src/braid/core/hooks.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.core.hooks 2 | "Provides a way to register atoms that can later all be reset to their initial values. 3 | This enables changes to Braid modules to be picked up in a reloaded workflow (ie. figwheel). 4 | 5 | On the client-side: 6 | Figwheel is configured to call (braid.core.client.desktop.core/reload!), 7 | which calls (braid.core.modules/init!), which calls (reset-all!) 8 | 9 | On the server-side: 10 | (braid.core.modules/init!) is called once, when the server is started (in braid.core)." 11 | (:require 12 | [braid.core.common.util :as util])) 13 | 14 | ;; Atoms is a map of atoms to their initial values 15 | (defonce atoms (atom {})) 16 | 17 | (defn register! 18 | "Takes an atom and adds it to a central registry so that it can be later reset to its initial value. 19 | Only the first registration of an atom is stored. 20 | Always returns the given atom." 21 | ([a data-spec] 22 | (when-not (contains? @atoms a) 23 | (swap! atoms assoc a @a)) 24 | (when data-spec 25 | (set-validator! a (fn [x] 26 | ;; use assert, to elide the code in prod 27 | (assert (util/valid? data-spec x)) 28 | ;; assert returns nil 29 | ;; but validator expects true 30 | true))) 31 | a) 32 | ([a] 33 | (register! a nil))) 34 | 35 | (defn reset-all! 36 | "Resets all registered atoms to their initial state." 37 | [] 38 | (doseq [[a initial-state] @atoms] 39 | (reset! a initial-state))) 40 | -------------------------------------------------------------------------------- /src/braid/core/server/core.clj: -------------------------------------------------------------------------------- 1 | (ns braid.core.server.core 2 | (:require 3 | [braid.core.server.middleware :refer [wrap-universal-middleware]] 4 | [braid.base.conf-extra :as conf] ; to update ports 5 | [braid.core.server.handler :as handler] 6 | [braid.base.server.ws-handler] ; for mount 7 | [braid.base.server.jobs] ; for mount 8 | [mount.core :as mount :refer [defstate]] 9 | [org.httpkit.server :refer [run-server server-port server-stop!]] 10 | [taoensso.timbre :as timbre])) 11 | 12 | ;; server 13 | 14 | (defn start-server! [{:keys [middleware port]}] 15 | (timbre/debugf "starting desktop server on port %d" port) 16 | (let [server (run-server (wrap-universal-middleware #'handler/app middleware) 17 | {:port port :legacy-return-value? false}) 18 | desktop-port (server-port server)] 19 | (timbre/debugf "Started desktop server on port %d" desktop-port) 20 | (swap! conf/ports-config assoc 21 | :site-url (str "http://localhost:" (server-port server))) 22 | server)) 23 | 24 | (defn stop-server! 25 | [srv] 26 | (timbre/debugf "stopping server") 27 | @(server-stop! srv {:timeout 100})) 28 | 29 | (defstate server 30 | :start (start-server! (mount/args)) 31 | :stop (stop-server! server)) 32 | 33 | ;; exceptions in background thread handler 34 | 35 | (defn set-default-exception-handler 36 | [] 37 | (Thread/setDefaultUncaughtExceptionHandler 38 | (reify Thread$UncaughtExceptionHandler 39 | (uncaughtException [_ thread ex] 40 | (timbre/errorf ex "Uncaught exception on %s" (.getName thread)))))) 41 | 42 | (defstate thread-handler 43 | :start (set-default-exception-handler)) 44 | -------------------------------------------------------------------------------- /src/braid/core/server/mail.clj: -------------------------------------------------------------------------------- 1 | (ns braid.core.server.mail 2 | "Sending email" 3 | (:require 4 | [braid.base.conf :as conf] 5 | [postal.core :as postal])) 6 | 7 | (defn- ->int [s] 8 | (try (Long. s) 9 | (catch java.lang.NumberFormatException _ nil))) 10 | 11 | (defn send! 12 | "Send an email message via SMTP using the server information in config. 13 | 14 | `body` can be a string, which will sent as plain text or a map of 15 | `:text` and `:html`, which will send a multipart/alternative message. 16 | 17 | Uses config variables: 18 | :email-host :email-user :email-password :email-port :email-from 19 | :email-secure can be the string `ssl` or `tls` 20 | " 21 | [{:keys [subject body to from]}] 22 | (postal/send-message 23 | (cond-> {:host (conf/config :email-host) 24 | :user (conf/config :email-user) 25 | :pass (conf/config :email-password)} 26 | (->int (conf/config :email-port)) (assoc :port (->int (conf/config :email-port))) 27 | (= "tls" (conf/config :email-secure)) (assoc :tls true) 28 | (= "ssl" (conf/config :email-secure)) (assoc :ssl true)) 29 | {:from (or from (conf/config :email-from)) 30 | :to to 31 | :subject subject 32 | :body (if (string? body) 33 | body 34 | [:alternative 35 | {:type "text/plain" 36 | :content (:text body)} 37 | {:type "text/html" 38 | :content (:html body)}])})) 39 | -------------------------------------------------------------------------------- /src/braid/core/server/message_format.clj: -------------------------------------------------------------------------------- 1 | (ns braid.core.server.message-format 2 | (:require 3 | [clojure.string :as string] 4 | [braid.chat.db.group :as db.group])) 5 | 6 | (defn str->uuid 7 | [s] 8 | (java.util.UUID/fromString s)) 9 | 10 | (defn make-tags-and-mentions-parser 11 | [group-id] 12 | (let [id->nick (into {} (map (juxt :id :nickname)) (db.group/group-users group-id)) 13 | id->tag (into {} (map (juxt :id :name)) (db.group/group-tags group-id))] 14 | (fn [content] 15 | (let [uuid-re #"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})" 16 | tag-re (re-pattern (str "#" uuid-re)) 17 | mention-re (re-pattern (str "@" uuid-re))] 18 | (-> content 19 | (string/replace tag-re 20 | (comp (partial str "#") id->tag str->uuid second)) 21 | (string/replace 22 | mention-re 23 | (comp (partial str "@") id->nick str->uuid second))))))) 24 | -------------------------------------------------------------------------------- /src/braid/core/server/notify_rules.clj: -------------------------------------------------------------------------------- 1 | (ns braid.core.server.notify-rules 2 | (:require 3 | [clojure.set :as set] 4 | [braid.chat.db.tag :as tag] 5 | [braid.chat.db.thread :as thread] 6 | [braid.core.common.util :as util] 7 | [braid.core.common.schema :as schema])) 8 | 9 | (defn tag->group 10 | [tag-id] 11 | (tag/tag-group-id tag-id)) 12 | 13 | (defn thread->tags 14 | [thread-id] 15 | (:tag-ids (thread/thread-by-id thread-id))) 16 | 17 | (defn thread->groups [thread-id] 18 | (into #{} (map tag->group) (thread->tags thread-id))) 19 | 20 | (defn notify? 21 | [user-id rules new-message] 22 | (assert (util/valid? schema/NotifyRules rules)) 23 | (assert (util/valid? schema/NewMessage new-message)) 24 | (let [{:keys [tag mention any] 25 | :or {tag #{} mention #{} any #{}}} 26 | (->> (group-by first rules) 27 | (into {} 28 | (map (fn [[k v]] 29 | [k (into #{} (map second) v)]))))] 30 | ; notify if... 31 | (or (any :any) ; ...you want to be notified by anything in any group 32 | ; ...or by a tag that this thread has 33 | (seq (set/intersection 34 | (set (new-message :mentioned-tag-ids)) 35 | tag)) 36 | (let [groups (thread->groups (new-message :thread-id))] 37 | (or ; ...or by anything in this group 38 | (seq (set/intersection groups any)) 39 | ; ...or by a mention... 40 | (and (seq mention) ((set (new-message :mentioned-user-ids)) user-id) 41 | (or (mention :any) ; in any group 42 | ; or this group 43 | (seq (set/intersection 44 | groups 45 | mention))))))))) 46 | -------------------------------------------------------------------------------- /src/braid/core/server/routes/helpers.clj: -------------------------------------------------------------------------------- 1 | (ns braid.core.server.routes.helpers 2 | (:require 3 | [braid.chat.db.user :as user] 4 | [ring.middleware.anti-forgery :as anti-forgery])) 5 | 6 | (defn logged-in? [req] 7 | (when-let [user-id (get-in req [:session :user-id])] 8 | (user/user-id-exists? user-id))) 9 | 10 | (defn current-user [req] 11 | (when-let [user-id (get-in req [:session :user-id])] 12 | (when (user/user-id-exists? user-id) 13 | (user/user-by-id user-id)))) 14 | 15 | (defn current-user-id [req] 16 | (when-let [user-id (get-in req [:session :user-id])] 17 | (when (user/user-id-exists? user-id) 18 | user-id))) 19 | 20 | (defn session-token [] 21 | anti-forgery/*anti-forgery-token*) 22 | 23 | (defn error-response [status msg] 24 | {:status status 25 | :headers {"Content-Type" "application/edn; charset=utf-8"} 26 | :body (pr-str {:error msg})}) 27 | 28 | (defn edn-response [clj-body] 29 | {:headers {"Content-Type" "application/edn; charset=utf-8"} 30 | :body (pr-str clj-body)}) 31 | -------------------------------------------------------------------------------- /src/braid/core/server/routes/socket.clj: -------------------------------------------------------------------------------- 1 | (ns braid.core.server.routes.socket 2 | (:require 3 | [braid.base.server.socket :refer [ring-ajax-post ring-ajax-get-or-ws-handshake]] 4 | [compojure.core :refer [GET POST defroutes]] 5 | [ring.middleware.anti-forgery :refer [*anti-forgery-token*]])) 6 | 7 | (defroutes sync-routes 8 | (GET "/chsk" req 9 | (-> req 10 | (assoc-in [:session :ring.middleware.anti-forgery/anti-forgery-token] 11 | *anti-forgery-token*) 12 | (cond-> (and (not (get-in req [:session :user-id])) 13 | (not (get-in req [:session :fake-id]))) 14 | (assoc-in [:session :fake-id] (str "FAKE" (java.util.UUID/randomUUID)))) 15 | ring-ajax-get-or-ws-handshake)) 16 | (POST "/chsk" req (ring-ajax-post req))) 17 | -------------------------------------------------------------------------------- /src/braid/core/server/sync_handler.clj: -------------------------------------------------------------------------------- 1 | (ns braid.core.server.sync-handler 2 | (:require 3 | [braid.core.hooks :as hooks] 4 | [braid.core.server.sync-helpers :as helpers] 5 | [taoensso.timbre :as timbre :refer [debugf]] 6 | [braid.chat.db.group :as group] 7 | [braid.chat.db.thread :as thread] 8 | ;; FIXME: should do these view base.api/something 9 | [braid.base.server.ws-handler :refer [anon-msg-handler 10 | event-msg-handler]])) 11 | 12 | ;; anonymous event handlers 13 | 14 | (defonce anonymous-load-group (hooks/register! (atom []) [fn?])) 15 | 16 | (defmethod anon-msg-handler :braid.server.anon/load-group 17 | [{:as ev-msg :keys [event id ?data ring-req ?reply-fn send-fn]}] 18 | (when-let [group (group/group-by-id ?data)] 19 | (when (:public? group) 20 | (?reply-fn (reduce (fn [m f] (f (group :id) m)) 21 | {:tags (group/group-tags ?data) 22 | :group group 23 | :threads (thread/public-threads ?data)} 24 | @anonymous-load-group)) 25 | (helpers/add-anonymous-reader ?data (get-in ev-msg [:ring-req :session :fake-id]))))) 26 | 27 | (defmethod anon-msg-handler :chsk/uidport-close 28 | [ev-msg] 29 | (debugf "Closing connection for anonymous client %s" (:client-id ev-msg)) 30 | (helpers/remove-anonymous-reader (get-in ev-msg [:ring-req :session :fake-id]))) 31 | 32 | (defmethod anon-msg-handler :braid.client/ping 33 | [{:as ev-msg :keys [?reply-fn]}] 34 | (when-let [reply ?reply-fn] 35 | (reply [:braid.server/pong]))) 36 | 37 | ;; logged in event handlers 38 | 39 | (defmethod event-msg-handler :braid.client/ping 40 | [{:as ev-msg :keys [?reply-fn]}] 41 | (when-let [reply ?reply-fn] 42 | (reply [:braid.server/pong]))) 43 | -------------------------------------------------------------------------------- /src/braid/disconnect_notice/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.disconnect-notice.core 2 | (:require 3 | [braid.base.api :as base] 4 | #?@(:cljs 5 | [[braid.disconnect-notice.ui :as ui] 6 | [braid.disconnect-notice.styles :as styles]]))) 7 | 8 | (defn init! [] 9 | #?(:cljs 10 | (do 11 | (base/register-root-view! ui/disconnect-notice-view) 12 | (base/register-styles! [:#app>.app>.main styles/disconnect-notice])))) 13 | -------------------------------------------------------------------------------- /src/braid/disconnect_notice/styles.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.disconnect-notice.styles 2 | (:require 3 | [braid.core.client.ui.styles.mixins :as mixins] 4 | [braid.core.client.ui.styles.vars :as vars] 5 | [garden.arithmetic :as m])) 6 | 7 | (def disconnect-notice 8 | [:>.disconnect-notice 9 | {:position "absolute" 10 | :bottom 0 11 | :left 0 12 | :right 0 13 | :top 0 14 | :background "rgba(242,242,242,0.75)" 15 | :z-index 10000} 16 | 17 | [:>.info 18 | {:position "absolute" 19 | :bottom vars/pad 20 | :height (m/* vars/pad 4) 21 | :left 0 22 | :right 0 23 | :padding vars/pad 24 | :box-sizing "border-box" 25 | :background "red" 26 | :color "white" 27 | :z-index 5001} 28 | 29 | [:>h1 30 | {:font-size "1em" 31 | :margin 0}] 32 | 33 | [:>.message 34 | 35 | [:>button 36 | (mixins/outline-button {:text-color "#fff" 37 | :border-color "#fff" 38 | :hover-text-color "#fff" 39 | :hover-border-color "fff"})]]]]) 40 | -------------------------------------------------------------------------------- /src/braid/disconnect_notice/ui.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.disconnect-notice.ui 2 | (:require 3 | [reagent.core :as r] 4 | [re-frame.core :refer [subscribe dispatch]])) 5 | 6 | (defn disconnect-notice-view [] 7 | (let [websocket-state (subscribe [:core/websocket-state]) 8 | seconds-to-reconnect (r/atom nil)] 9 | (fn [] 10 | (let [tick! (fn [] 11 | (reset! seconds-to-reconnect 12 | (js/Math.round 13 | (/ (- (@websocket-state :next-reconnect) 14 | (.valueOf (js/Date.))) 15 | 1000))))] 16 | (when-not (@websocket-state :connected?) 17 | [:div.disconnect-notice 18 | {:ref (fn [_] 19 | (tick!) 20 | (js/setTimeout (fn [_] 21 | (tick!)) 22 | 1000))} 23 | [:div.info 24 | [:h1 "Uh oh! Braid has disconnected from the server."] 25 | [:div.message "Attempting to reconnect in " @seconds-to-reconnect " seconds... " 26 | [:button 27 | {:on-click (fn [_] 28 | (dispatch [:core/reconnect!]))} 29 | "Reconnect Now"]]]]))))) 30 | -------------------------------------------------------------------------------- /src/braid/embeds/api.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.embeds.api 2 | (:require 3 | [braid.base.api :as base] 4 | [braid.core.common.util :as util] 5 | #?@(:cljs 6 | [[braid.embeds.impl :as impl]]))) 7 | 8 | #?(:cljs 9 | (do 10 | (defn register-embed! 11 | "Expects a map with: 12 | {:handler ___ - a fn that will return either nil or a vector of [view & args] 13 | :priority ___ - a number, higher means takes precedence before lower priority embed handlers 14 | :styles ___ - garden styles} 15 | 16 | The handler fn is passed a map: 17 | {:urls [\"http://example.org\" 18 | \"http://example.org/2\"]}}" 19 | [{:keys [handler styles priority] :as embed}] 20 | {:pre [(util/valid? impl/embed-engine-dataspec embed)]} 21 | (swap! impl/embed-engines conj embed) 22 | (when styles 23 | (base/register-styles! [:.embed styles]))))) 24 | -------------------------------------------------------------------------------- /src/braid/embeds/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.embeds.core 2 | "Allows for extending Braid with embed handlers, which can display inline content after a message, based on the content of the message" 3 | (:require 4 | [braid.base.api :as base] 5 | [braid.chat.api :as chat] 6 | #?@(:cljs 7 | [[braid.embeds.impl :as impl] 8 | [braid.embeds.styles :as styles]]))) 9 | 10 | (defn init! [] 11 | #?(:cljs 12 | (do 13 | (base/register-styles! styles/embed) 14 | (chat/register-footer-view! impl/embed-view)))) 15 | -------------------------------------------------------------------------------- /src/braid/embeds/impl.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.embeds.impl 2 | (:require 3 | [braid.core.hooks :as hooks] 4 | [spec-tools.data-spec :as ds])) 5 | 6 | (def url-re #"(http(?:s)?://\S+(?:\w|\d|/))") 7 | 8 | (defn- extract-urls 9 | "Given some text, returns a sequence of URLs contained in the text" 10 | [text] 11 | (map first (re-seq url-re text))) 12 | 13 | (def embed-engine-dataspec 14 | {:handler fn? 15 | (ds/opt :styles) vector? 16 | :priority number?}) 17 | 18 | (defonce embed-engines 19 | (hooks/register! (atom []) [embed-engine-dataspec])) 20 | 21 | (defn embed-view [message] 22 | (when-let [handler (->> @embed-engines 23 | (sort-by :priority) 24 | reverse 25 | (some (fn [{:keys [handler]}] 26 | (handler {:urls (extract-urls (message :content))}))))] 27 | [:div.embed 28 | handler])) 29 | -------------------------------------------------------------------------------- /src/braid/embeds/styles.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.embeds.styles 2 | (:require 3 | [garden.arithmetic :as m] 4 | [braid.core.client.ui.styles.vars :as vars])) 5 | 6 | (def embed 7 | [:.embed 8 | {:margin [[0 (m/* -1 vars/pad)]] 9 | :padding [[vars/pad 0 0]] 10 | :overflow "hidden" 11 | :cursor "pointer"}]) 12 | -------------------------------------------------------------------------------- /src/braid/embeds_image/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.embeds-image.core 2 | "If a message contains a link to an image, displays the image as an embed" 3 | (:require 4 | [braid.embeds.api :as embeds])) 5 | 6 | (defn image-embed-view 7 | [url] 8 | [:a.image 9 | {:href url 10 | :target "_blank" 11 | :rel "noopener noreferrer"} 12 | [:img {:src url}]]) 13 | 14 | (defn init! [] 15 | #?(:cljs 16 | (do 17 | (embeds/register-embed! 18 | {:handler 19 | (fn [{:keys [urls]}] 20 | (when-let [url (->> urls 21 | (some (fn [url] 22 | (re-matches #".*(png|jpg|jpeg|gif)$" url))) 23 | first)] 24 | [image-embed-view url])) 25 | 26 | :styles 27 | [:>.image 28 | [:>img 29 | {:width "100%"}]] 30 | 31 | :priority 1})))) 32 | -------------------------------------------------------------------------------- /src/braid/embeds_map/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.embeds-map.core 2 | "Detects google maps links and includes embeds a static image" 3 | (:require 4 | [braid.base.api :as base] 5 | [braid.embeds.api :as embeds] 6 | #?@(:clj 7 | [[org.httpkit.client :as http] 8 | [braid.base.conf :refer [config]]] 9 | :cljs 10 | [[goog.object :as o]]))) 11 | 12 | (def google-regex #"^https://www.google.com/maps/.*/@(\-?\d+\.\d+),(\-?\d+.\.\d+).*") 13 | 14 | #?(:cljs 15 | (defn- map-embed-view 16 | [url] 17 | (let [[_ lat lng] (re-matches google-regex url)] 18 | [:a 19 | {:href (str url) 20 | :target "_blank" 21 | :rel "noopener noreferrer"} 22 | [:img 23 | {:src (str "/api/maps-embeds/static-map?lat=" lat "&lng=" lng)}]]))) 24 | 25 | 26 | (defn init! [] 27 | #?(:clj 28 | (do 29 | (base/register-config-var! :google-maps-api-key :optional [:string]) 30 | 31 | (base/register-private-http-route! 32 | [:get "/maps-embeds/static-map" 33 | (fn [request] 34 | (let [{:keys [lat lng]} (request :params)] 35 | (if-let [key (config :google-maps-api-key)] 36 | (let [resp @(http/request 37 | {:method :get 38 | :url (str "https://maps.googleapis.com/maps/api/staticmap?center=" lat "," lng "&key=" key "&size=300x200&zoom=16")})] 39 | {:status 200 40 | :headers {"Content-Type" (get-in resp [:headers :content-type]) 41 | "Content-Length" (get-in resp [:headers :content-length])} 42 | :body (:body resp)}) 43 | {:status 500 44 | :body nil})))])) 45 | 46 | :cljs 47 | (do 48 | (embeds/register-embed! 49 | {:handler 50 | (fn [{:keys [urls]}] 51 | (when-let [url (->> urls 52 | (some (fn [url] 53 | (first (re-matches google-regex url)))))] 54 | [map-embed-view url])) 55 | 56 | :styles 57 | [:>.image 58 | [:>img 59 | {:width "100%"}]] 60 | 61 | :priority 1})))) 62 | -------------------------------------------------------------------------------- /src/braid/embeds_video/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.embeds-video.core 2 | "If a message contains a link to a video, displays the video as an embed" 3 | (:require 4 | [braid.embeds.api :as embeds])) 5 | 6 | (defn- video-embed-view 7 | [url] 8 | [:video 9 | {:src url 10 | :preload "metadata" 11 | :controls true}]) 12 | 13 | (defn init! [] 14 | #?(:cljs 15 | (do 16 | (embeds/register-embed! 17 | {:handler 18 | (fn [{:keys [urls]}] 19 | (when-let [url (->> urls 20 | (some (fn [url] 21 | (re-matches #".*(mp4|mkv|mov)$" url))) 22 | first)] 23 | [video-embed-view url])) 24 | 25 | :styles 26 | [:>video 27 | {:width "100%"}] 28 | 29 | :priority 1})))) 30 | -------------------------------------------------------------------------------- /src/braid/embeds_website/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.embeds-website.core 2 | "If a message contains a link, displays a generic website embed" 3 | (:require 4 | [braid.base.api :as base] 5 | [braid.embeds.api :as embeds] 6 | #?@(:cljs 7 | [[braid.embeds-website.styles :as styles] 8 | [braid.embeds-website.views :as views]] 9 | :clj 10 | [[braid.embeds-website.link-extract :as link-extract]])) 11 | #?(:clj 12 | (:import 13 | (java.net URLDecoder)))) 14 | 15 | (defn init! [] 16 | #?(:cljs 17 | (do 18 | (embeds/register-embed! 19 | {:handler views/handler 20 | :styles styles/styles 21 | :priority -1})) 22 | :clj 23 | (do 24 | (base/register-public-http-route! 25 | [:get "/extract" 26 | (fn [request] 27 | (let [url (URLDecoder/decode (get-in request [:params :url]))] 28 | {:body (link-extract/extract url)}))])))) 29 | -------------------------------------------------------------------------------- /src/braid/embeds_youtube/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.embeds-youtube.core 2 | "Detects youtube links and includes embedded video player" 3 | (:require 4 | [braid.embeds.api :as embeds])) 5 | 6 | (defn- youtube-embed-view 7 | [video-id] 8 | [:iframe 9 | {:src (str "https://www.youtube-nocookie.com/embed/" video-id "?rel=0") 10 | :frame-border 0 11 | :allow "encrypted-media; autoplay" 12 | :allow-full-screen true}]) 13 | 14 | (defn init! [] 15 | #?(:cljs 16 | (do 17 | (embeds/register-embed! 18 | {:handler 19 | (fn [{:keys [urls]}] 20 | (when-let [video-id (->> urls 21 | (some (fn [url] 22 | (or 23 | (second (re-matches #"^https?://youtu.be/(.*)" url)) 24 | (second (re-matches #"^https?://www\.youtube\.com/watch\?v=(.*)" url))))))] 25 | [youtube-embed-view video-id])) 26 | 27 | :priority 1})))) 28 | -------------------------------------------------------------------------------- /src/braid/emoji/api.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.emoji.api 2 | (:require 3 | [braid.base.api :as base] 4 | #?@(:cljs 5 | [[braid.emoji.client.lookup :as lookup]]))) 6 | 7 | #?(:cljs 8 | (do 9 | (defn register-emoji! 10 | "Adds emoji, which will show up in autocomplete and in messages. 11 | 12 | Takes a map with three optional keys: 13 | 14 | :shortcode-lookup 15 | a 0-arity function that returns a map of shortcode->emoji-meta-map 16 | ex. (fn [] 17 | {\":foobar:\" {:src \"https://example.com/foobar.png\" 18 | :class \"example-emoji\"}}) 19 | the keys must have colons as prefixes and suffixes 20 | these will show up in autocomplete when the user types in a colon or bracket 21 | in messages and autocomplete, the :src and :class will be used in an image element 22 | 23 | :ascii-lookup 24 | a 0-arity function that returns a map of string->emoji-meta-map 25 | ex. (fn [] 26 | {\":)\" {:src \"https://example.com/smiley.png\" 27 | :class \"example-emoji\"}) 28 | these will not be used in autocomplete, but will be used in message 29 | 30 | :styles 31 | garden style rules for custom styling emoji" 32 | [{:keys [shortcode-lookup ascii-lookup styles]}] 33 | (when shortcode-lookup 34 | (swap! lookup/shortcode-fns conj shortcode-lookup)) 35 | (when ascii-lookup 36 | (swap! lookup/ascii-fns conj ascii-lookup)) 37 | (when styles 38 | (base/register-styles! styles))))) 39 | -------------------------------------------------------------------------------- /src/braid/emoji/client/autocomplete.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.emoji.client.autocomplete 2 | (:require 3 | [clojure.string :as string] 4 | [braid.emoji.client.lookup :as lookup] 5 | [braid.emoji.client.views :refer [emoji-view]])) 6 | 7 | (defn- shortcode->brackets 8 | "We allow user to search for emoji either using colons or brackets, ie. :shortcode: or (shortcode). 9 | Internally, the lookups are stored with colon shortcodes, ie. :shortcode: 10 | If the user is searching with brackets, we replace the colons with brackets when displaying results." 11 | [shortcode] 12 | (let [base (apply str (-> shortcode rest butlast))] 13 | (str "(" base ")"))) 14 | 15 | (defn- emoji-result-view 16 | [shortcode emoji-meta show-as-brackets?] 17 | [:div.emoji.match 18 | [emoji-view shortcode emoji-meta] 19 | [:div.info 20 | [:div.name (if show-as-brackets? 21 | (shortcode->brackets shortcode) 22 | shortcode)] 23 | [:div.extra]]]) 24 | 25 | (defn autocomplete-handler [text] 26 | (let [pattern #"\B[:(](\S{2,})$"] 27 | (when-let [query (second (re-find pattern text))] 28 | (let [show-as-brackets? (= "(" (first text))] 29 | (->> (lookup/shortcode) 30 | (filter (fn [[shortcode _]] 31 | (string/includes? shortcode query))) 32 | (map (fn [[shortcode emoji-meta]] 33 | {:key 34 | (fn [] shortcode) 35 | :action 36 | (fn []) 37 | :message-transform 38 | (fn [text] 39 | (string/replace text pattern (str shortcode " "))) 40 | :html 41 | (fn [] 42 | [emoji-result-view shortcode emoji-meta show-as-brackets?])}))))))) 43 | -------------------------------------------------------------------------------- /src/braid/emoji/client/lookup.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.emoji.client.lookup 2 | (:require 3 | [braid.core.hooks :as hooks])) 4 | 5 | (defonce shortcode-fns 6 | (hooks/register! (atom []) [fn?])) 7 | 8 | (defonce ascii-fns 9 | (hooks/register! (atom []) [fn?])) 10 | 11 | (defn shortcode [] 12 | (->> @shortcode-fns 13 | (map (fn [f] (f))) 14 | (apply merge {}))) 15 | 16 | (defn ascii [] 17 | (->> @ascii-fns 18 | (map (fn [f] (f))) 19 | (apply merge {}))) 20 | -------------------------------------------------------------------------------- /src/braid/emoji/client/styles.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.emoji.client.styles) 2 | 3 | (def autocomplete 4 | [:.app 5 | [:.message>.content 6 | [:.emoji.custom-emoji 7 | {:width "3ex" 8 | :height "3.1ex" 9 | :min-height "20px" 10 | :display "inline-block" 11 | :margin [["-0.2ex" "0.15em" "0.2ex"]] 12 | :line-height "normal" 13 | :vertical-align "middle" 14 | :min-width "20px"}]] 15 | [:>.main>.page>.threads>.thread>.card 16 | [:>.message.new 17 | [:>.autocomplete-wrapper 18 | [:>.autocomplete 19 | [:>.result 20 | [:>.emoji.match 21 | [:>img 22 | {:display "block" 23 | :width "2em" 24 | :height "2em" 25 | :float "left" 26 | :margin "0.25em 0.5em 0.25em 0"}]]]]]]]]) 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/braid/emoji/client/text_replacements.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.emoji.client.text-replacements 2 | (:require 3 | [clojure.string :as string] 4 | [braid.emoji.client.lookup :as lookup] 5 | [braid.emoji.client.views :refer [emoji-view]])) 6 | 7 | (defn emoji-shortcodes-replace 8 | [node] 9 | (if (string? node) 10 | (if-let [match (re-matches #":\S*:" node)] 11 | (if-let [emoji-meta ((lookup/shortcode) match)] 12 | [emoji-view match emoji-meta] 13 | node) 14 | node) 15 | node)) 16 | 17 | (defn emoji-ascii-replace [node] 18 | (if (string? node) 19 | (if-let [emoji-meta ((lookup/ascii) node)] 20 | [emoji-view node emoji-meta] 21 | node) 22 | node)) 23 | -------------------------------------------------------------------------------- /src/braid/emoji/client/views.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.emoji.client.views) 2 | 3 | (defn emoji-view [shortcode emoji-meta] 4 | [:img {:class ["emoji" (emoji-meta :class)] 5 | :alt shortcode 6 | :title shortcode 7 | :src (emoji-meta :src)}]) 8 | -------------------------------------------------------------------------------- /src/braid/emoji/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.emoji.core 2 | "Allows other modules to define emoji" 3 | (:require 4 | [braid.base.api :as base] 5 | [braid.chat.api :as chat] 6 | #?@(:cljs 7 | [[braid.emoji.client.autocomplete :refer [autocomplete-handler]] 8 | [braid.emoji.client.text-replacements :refer [emoji-ascii-replace 9 | emoji-shortcodes-replace]] 10 | [braid.emoji.client.styles :refer [autocomplete]]]))) 11 | 12 | (defn init! [] 13 | #?(:cljs 14 | (do 15 | (chat/register-autocomplete-engine! autocomplete-handler) 16 | (chat/register-message-transform! emoji-ascii-replace) 17 | (chat/register-message-transform! emoji-shortcodes-replace) 18 | (base/register-styles! autocomplete)))) 19 | -------------------------------------------------------------------------------- /src/braid/emoji_big/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.emoji-big.core 2 | "If a message consists of a single emoji, displays it larger." 3 | (:require 4 | [braid.chat.api :as chat] 5 | #?@(:cljs 6 | [[braid.emoji.client.views :refer [emoji-view]]]))) 7 | 8 | #?(:cljs 9 | (do 10 | (defn- emoji? [node] 11 | (and 12 | (vector? node) 13 | (= emoji-view (first node)))) 14 | 15 | (defn- single-emoji? [message] 16 | (let [emoji-count (->> message 17 | (filter emoji?) 18 | count) 19 | other-count (- (count message) emoji-count)] 20 | (and 21 | (= emoji-count 1) 22 | (= other-count 0)))) 23 | 24 | (defn- embiggen-emoji [message] 25 | (for [node message] 26 | (if (emoji? node) 27 | (update-in node [2 :class] #(str % " large")) 28 | node))))) 29 | 30 | (defn init! [] 31 | #?(:cljs 32 | (do 33 | (chat/register-message-formatter! 34 | (fn [message] 35 | (if (single-emoji? message) 36 | (embiggen-emoji message) 37 | message)))))) 38 | -------------------------------------------------------------------------------- /src/braid/emoji_custom/client/autocomplete.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.emoji-custom.client.autocomplete 2 | (:require 3 | [clojure.string :as string] 4 | [re-frame.core :refer [subscribe]] 5 | [braid.lib.upload :as upload])) 6 | 7 | (defn lookup [] 8 | (if-let [emoji @(subscribe [:custom-emoji/group-emojis])] 9 | (->> emoji 10 | (reduce (fn [memo {:keys [image shortcode]}] 11 | (assoc memo shortcode {:class "custom-emoji" 12 | :src (upload/->path image)})) {}) 13 | (into {})) 14 | {})) 15 | -------------------------------------------------------------------------------- /src/braid/emoji_custom/client/styles.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.emoji-custom.client.styles 2 | (:require 3 | [braid.core.client.ui.styles.mixins :as mixins])) 4 | 5 | (def settings-page 6 | [:.app>.main>.page.group-settings>.content 7 | [:.settings.custom-emoji 8 | [:button.delete 9 | {:color "red"} 10 | (mixins/fontawesome nil)]]]) 11 | -------------------------------------------------------------------------------- /src/braid/emoji_custom/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.emoji-custom.core 2 | "Allows group admins to create custom emoji" 3 | (:require 4 | [braid.base.api :as base] 5 | [braid.chat.api :as chat] 6 | #?@(:cljs 7 | [[braid.emoji.api :as emoji] 8 | [braid.emoji-custom.client.autocomplete :as autocomplete] 9 | [braid.emoji-custom.client.styles :refer [settings-page]] 10 | [braid.emoji-custom.client.state :as state] 11 | [braid.emoji-custom.client.views :refer [extra-emoji-settings-view]]] 12 | :clj 13 | [[braid.emoji-custom.server.db :refer [db-schema]] 14 | [braid.emoji-custom.server.core :refer [initial-user-data-fn 15 | server-message-handlers]]]))) 16 | 17 | (defn init! [] 18 | #?(:cljs 19 | (do 20 | (emoji/register-emoji! 21 | {:shortcode-lookup autocomplete/lookup}) 22 | (base/register-styles! settings-page) 23 | (base/register-state! state/initial-state state/state-spec) 24 | (base/register-initial-user-data-handler! state/initial-data-handler) 25 | (chat/register-group-setting! extra-emoji-settings-view) 26 | (base/register-events! state/events) 27 | (base/register-subs! state/subscriptions) 28 | (base/register-incoming-socket-message-handlers! 29 | state/socket-message-handlers)) 30 | 31 | :clj 32 | (do 33 | (base/register-db-schema! db-schema) 34 | (base/register-initial-user-data! initial-user-data-fn) 35 | (base/register-server-message-handlers! server-message-handlers)))) 36 | -------------------------------------------------------------------------------- /src/braid/emoji_emojione/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.emoji-emojione.core 2 | "Provides emojione-style emoji" 3 | (:require 4 | [braid.emoji.api :as emoji] 5 | #?@(:cljs 6 | [[braid.emoji-emojione.impl :as impl]]))) 7 | 8 | (defn init! [] 9 | #?(:cljs 10 | (do 11 | (emoji/register-emoji! 12 | {:shortcode-lookup impl/shortcode-lookup 13 | :ascii-lookup impl/ascii-lookup 14 | :styles impl/emojione-styles})))) 15 | -------------------------------------------------------------------------------- /src/braid/emoji_emojione/impl.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.emoji-emojione.impl 2 | (:require 3 | [garden.units :refer [rem em px ex]] 4 | [braid.emoji-emojione.ref :as refs])) 5 | 6 | ;; Taken from https://github.com/emojione/emojione/blob/2.2.7/assets/css/emojione.css#L1 7 | (def emojione-styles 8 | [:.emojione 9 | {:font-size "inherit" 10 | :height (ex 3) 11 | :width (ex 3.1) 12 | :min-height (px 20) 13 | :min-width (px 20) 14 | :display "inline-block" 15 | :margin [[(ex -0.2) (em 0.15) (ex 0.2)]] 16 | :line-height "normal" 17 | :vertical-align "middle"} 18 | ;; Taken from https://github.com/emojione/emojione/blob/2.2.7/assets/css/emojione-awesome.css#L24 19 | [:&.large 20 | {:width (em 3) 21 | :height (em 3) 22 | :margin [[(ex -0.6) (em 0.15) 0 (em 0.3)]] 23 | :background-size [[(em 3) (em 3)]]}]]) 24 | 25 | (defn- emoji-meta [url] 26 | {:class "emojione" 27 | :src url}) 28 | 29 | (defn- emojione-url [id] 30 | (str "//cdn.jsdelivr.net/emojione/assets/png/" id ".png")) 31 | 32 | (defn shortcode-lookup [] 33 | (->> refs/shortcodes 34 | (reduce (fn [memo [shortcode ids]] 35 | (assoc memo shortcode 36 | (emoji-meta (emojione-url (last ids))))) {}))) 37 | 38 | (defn ascii-lookup [] 39 | (->> refs/ascii 40 | (reduce (fn [memo [ascii shortcode]] 41 | (let [id (last (refs/shortcodes shortcode))] 42 | (assoc memo ascii 43 | (emoji-meta (emojione-url id))))) {}))) 44 | -------------------------------------------------------------------------------- /src/braid/group_create/test.clj: -------------------------------------------------------------------------------- 1 | ;; doesn't work 2 | {::foo 3 | (fn [] 4 | {})} 5 | 6 | ;; does work 7 | {::foo 8 | (fn [])} 9 | 10 | {:foo 11 | (fn [] 12 | {})} 13 | 14 | {:foo 15 | (identity 16 | {})} 17 | -------------------------------------------------------------------------------- /src/braid/group_create/validations.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.group-create.validations 2 | (:require 3 | [clojure.string :as string] 4 | [braid.lib.xhr :as xhr])) 5 | 6 | (def validations 7 | {:group-name 8 | [(fn [name cb] 9 | (if (string/blank? name) 10 | (cb "Your group needs a name.") 11 | (cb nil)))] 12 | 13 | :group-url 14 | [(fn [url cb] 15 | (if (string/blank? url) 16 | (cb "Your group needs a URL.") 17 | (cb nil))) 18 | (fn [url cb] 19 | (if (not (re-matches #"[a-z0-9-]*" url)) 20 | (cb "Your URL can only contain lowercase letters, numbers or dashes.") 21 | (cb nil))) 22 | (fn [url cb] 23 | (if (re-matches #"-.*" url) 24 | (cb "Your URL can't start with a dash.") 25 | (cb nil))) 26 | (fn [url cb] 27 | (if (re-matches #".*-" url) 28 | (cb "Your URL can't end with a dash.") 29 | (cb nil))) 30 | (fn [url cb] 31 | (xhr/edn-xhr 32 | {:uri "/registration/check-slug-unique" 33 | :method :get 34 | :params {:slug url} 35 | :on-complete (fn [valid?] 36 | (if valid? 37 | (cb nil) 38 | (cb "Your group URL is already taken; try another."))) 39 | :on-error (fn [_] 40 | (cb "There was an error checking your URL."))}))] 41 | 42 | :group-type 43 | [(fn [type cb] 44 | (if (string/blank? type) 45 | (cb "You need to select a group type.") 46 | (cb nil)))]}) 47 | -------------------------------------------------------------------------------- /src/braid/group_explore/api.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.group-explore.api 2 | #?(:cljs 3 | (:require 4 | [braid.group-explore.views :as views]))) 5 | 6 | #?(:cljs 7 | (defn register-link! 8 | [{:keys [title url] :as link}] 9 | (swap! views/group-explore-links conj link))) 10 | -------------------------------------------------------------------------------- /src/braid/group_explore/styles.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.group-explore.styles 2 | (:require 3 | [braid.core.client.ui.styles.mixins :as mixins] 4 | [braid.core.client.ui.styles.vars :as vars] 5 | [garden.units :refer [rem em px]])) 6 | 7 | (def >group-explore-page 8 | [:>.page.group-explore 9 | 10 | [:>.content 11 | (mixins/settings-container-style) 12 | [:>.invitations 13 | (mixins/settings-item-style)] 14 | [:>.public-groups 15 | (mixins/settings-item-style) 16 | 17 | [:>.active 18 | :>.stale 19 | {:display "flex" 20 | :flex-wrap "wrap"} 21 | 22 | [:>a.group 23 | {:color "white" 24 | :padding "0.5em" 25 | :margin "0.2em" 26 | :border-radius (px 5) 27 | :text-decoration "none"} 28 | [:&:hover 29 | {:color "#ccc" 30 | :opacity 0.8}] 31 | 32 | [:>.name 33 | {:font-size "large"}] 34 | 35 | [:>.info 36 | {:font-size "small"}]]] 37 | 38 | [:>h3 39 | 40 | [:>button.toggle-stale 41 | mixins/standard-font 42 | {:background "transparent" 43 | :background-color "darkgray" 44 | :cursor "pointer" 45 | :border "none" 46 | :color "white" 47 | :margin-left "1rem"}]]]]]) 48 | -------------------------------------------------------------------------------- /src/braid/lib/color.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.lib.color 2 | (:require 3 | [clojure.string :as string] 4 | [cljsjs.husl] 5 | [braid.lib.url :as url])) 6 | 7 | (defn ->color [input] 8 | (js/window.HUSL.toHex (mod (Math/abs (hash input)) 360) 95 50)) 9 | 10 | (defn ->color-darker [input] 11 | (js/window.HUSL.toHex (mod (Math/abs (hash input)) 360) 95 30)) 12 | 13 | (defn url->color [url] 14 | (-> url 15 | string/lower-case 16 | url/url->parts 17 | :domain 18 | ->color)) 19 | -------------------------------------------------------------------------------- /src/braid/lib/date.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.lib.date 2 | (:require 3 | [cljs-time.core :as t] 4 | [cljs-time.format :as f])) 5 | 6 | (defn format-date 7 | [format-string datetime] 8 | (f/unparse (f/formatter format-string) (t/to-default-time-zone datetime))) 9 | 10 | (defn smart-format-date 11 | "Turn a Date object into a nicely formatted string" 12 | [datetime] 13 | (let [datetime (t/to-default-time-zone datetime) 14 | now (t/to-default-time-zone (t/now)) 15 | format (cond 16 | (= (f/unparse (f/formatter "yyyydM") now) 17 | (f/unparse (f/formatter "yyyydM") datetime)) 18 | "h:mm A" 19 | 20 | (= (t/year now) (t/year datetime)) 21 | "h:mm A MMM d" 22 | 23 | :else 24 | "h:mm A MMM d yyyy")] 25 | (f/unparse (f/formatter format) datetime))) 26 | -------------------------------------------------------------------------------- /src/braid/lib/digest.clj: -------------------------------------------------------------------------------- 1 | (ns braid.lib.digest 2 | (:require 3 | [clojure.java.io :as io]) 4 | (:import 5 | [java.security MessageDigest] 6 | [org.apache.commons.codec.binary Base64])) 7 | 8 | (defn sha256-digest 9 | "Byte array to sha256" 10 | [^bytes bs] 11 | (.digest (doto (MessageDigest/getInstance "SHA-256") (.update bs)))) 12 | 13 | (defn from-file 14 | [f] 15 | (when-let [file (io/resource f)] 16 | (-> file 17 | slurp 18 | .getBytes 19 | sha256-digest 20 | Base64/encodeBase64 21 | String.))) 22 | -------------------------------------------------------------------------------- /src/braid/lib/gravatar.clj: -------------------------------------------------------------------------------- 1 | (ns braid.lib.gravatar 2 | (:require 3 | [clojure.string :as string] 4 | [braid.lib.crypto :as crypto])) 5 | 6 | (def ^:private gravatar-base-url ".gravatar.com/avatar/") 7 | 8 | (defn- genhash 9 | "Generate the hash needed for Gravatar. 10 | The hash is generated by trimming, lowercasing & md5-summing the email." 11 | [^String email] 12 | (-> email string/trim string/lower-case crypto/md5 crypto/bytes->hex 13 | string/lower-case)) 14 | 15 | (defn- genparams 16 | "Generate the parameters needed for Gravatar." 17 | [& {size :size default :default rating :rating}] 18 | (format "?s=%s&r=%s&d=%s&" size (name rating) (name default))) 19 | 20 | (defn url 21 | "Generate a gravatar url. 22 | Based on https://github.com/Raynes/clavatar, updated to not use 23 | java.xml.bind, to avoid java 9+ module issues." 24 | [email & {:keys [size https rating default] 25 | :or {size 50 26 | https true 27 | rating :pg 28 | default :retro}}] 29 | (let [base-url (str gravatar-base-url 30 | (genhash (or email "")) 31 | ".jpg" 32 | (genparams :size size :default default :rating rating))] 33 | (str (if https "https://secure" "http://www") base-url))) 34 | -------------------------------------------------------------------------------- /src/braid/lib/markdown.clj: -------------------------------------------------------------------------------- 1 | (ns braid.lib.markdown 2 | (:require 3 | [clojure.string :as string] 4 | [instaparse.core :as insta])) 5 | 6 | ; TODO: refactor this grammar to remove ambiguities - currently issues where 7 | ; PLAIN_TEXT is ambiguous, since you can have [:PLAIN_TEXT "foo"] or 8 | ; [:PLAIN_TEXT "f"] [:PLAIN_TEXT "oo"], etc 9 | (def markdown-parser 10 | "Simple markdown parser. Only parsing enough to handle CHANGELOG.md, so lots 11 | is probably missing" 12 | (insta/parser 13 | "S ::= ( ( HEADER | LIST | ) / PARAGRAPH ) * 14 | 15 | ::= #'(?m)\\A|^' 16 | ::= #'\\n|\\z' 17 | ws ::= #'[ \\t\\x0b]*' 18 | 19 | ::= #'.' 20 | PLAIN_TEXT ::= ( !LINK DOT ) + 21 | URL ::= #'\\S' + 22 | LINK ::= <'['> PLAIN_TEXT <']('> URL <')'> 23 | ::= ( LINK / PLAIN_TEXT ) ( TEXT ?) 24 | 25 | HEADER ::= #'#+' TEXT <'#'*> 26 | 27 | LIST ::= ( LIST_LINE ) + 28 | LIST_LINE ::= <#'\\s+(-|\\*)'> TEXT 29 | 30 | PARAGRAPH ::= PLAIN_LINE+ 31 | BLANK_LINE ::= STARTL ws ENDL 32 | ::= !BLANK_LINE TEXT 33 | ")) 34 | 35 | (defn markdown->hiccup 36 | "Parse markdown into hiccup." 37 | [md-str] 38 | (->> (markdown-parser md-str) 39 | (insta/transform {:HEADER (fn [delim & rst] 40 | (vec (cons (keyword (str "h" (count delim))) 41 | rst))) 42 | :PLAIN_TEXT (fn [& args] (string/join "" args)) 43 | :URL (fn [& args] (string/join "" args)) 44 | :LINK (fn [title url] 45 | [:a {:href url} title]) 46 | :LIST (fn [& args] 47 | (vec (cons :ul args))) 48 | :LIST_LINE (fn [& args] 49 | (vec (cons :li args))) 50 | :PARAGRAPH (fn [& lines] 51 | (vec (cons :p lines))) 52 | :S (fn [& args] 53 | (vec (cons :div args)))}))) 54 | -------------------------------------------------------------------------------- /src/braid/lib/misc.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.lib.misc) 2 | 3 | (defn key-by [k coll] 4 | (into {} (map (juxt k identity)) coll)) 5 | 6 | (defn flip 7 | "Partially apply the function f to the given args, which will come after the 8 | next args. i.e. ((flip vector 3 4) 1 2) => [1 2 3 4]" 9 | [f & a] 10 | (fn [& b] 11 | (apply f (concat b a)))) 12 | -------------------------------------------------------------------------------- /src/braid/lib/noembed.clj: -------------------------------------------------------------------------------- 1 | (ns braid.lib.noembed 2 | (:require [clojure.data.json :as json] 3 | [org.httpkit.client :as http] 4 | [taoensso.timbre :as timbre])) 5 | 6 | (defn get-oembed 7 | [url] 8 | (-> @(http/request {:url "http://noembed.com/embed" 9 | :query-params {:url url}}) 10 | :body 11 | (json/read-str :key-fn keyword))) 12 | -------------------------------------------------------------------------------- /src/braid/lib/s3.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.lib.s3 2 | (:require 3 | [braid.lib.xhr :refer [edn-xhr ajax-xhr]] 4 | [taoensso.timbre :as timbre :refer-macros [errorf]])) 5 | 6 | (defn s3-path [bucket region & paths] 7 | (str "https://" bucket ".s3." region ".amazonaws.com/" (apply str paths))) 8 | 9 | (defn upload 10 | [{:keys [file prefix on-complete]}] 11 | (edn-xhr 12 | {:method :get 13 | :uri "/s3-policy" 14 | :on-error (fn [err] 15 | (errorf "Error getting s3 authorization: %s" err)) 16 | :on-complete 17 | (fn [{:keys [bucket region auth]}] 18 | (let [file-name (.-name file) 19 | file-key (str prefix (js/encodeURIComponent file-name)) 20 | file-url (s3-path bucket region file-key)] 21 | (ajax-xhr {:method :post 22 | :uri (s3-path bucket region) 23 | :body (doto (js/FormData.) 24 | (.append "key" (str prefix "${filename}")) 25 | (.append "acl" "private") 26 | (.append "policy" (:policy auth)) 27 | (.append "x-amz-algorithm" "AWS4-HMAC-SHA256") 28 | (.append "x-amz-credential" (:credential auth)) 29 | (.append "x-amz-signature" (:signature auth)) 30 | (.append "x-amz-date" (:date auth)) 31 | (cond-> 32 | (:security-token auth) 33 | (.append "x-amz-security-token" 34 | (:security-token auth))) 35 | (.append "Content-Type" (.-type file)) 36 | (.append "file" file file-name)) 37 | :on-complete (fn [_] 38 | (on-complete {:key file-key 39 | :url file-url})) 40 | :on-error (fn [e] (errorf "Error uploading: %s" (:error e)))})))})) 41 | -------------------------------------------------------------------------------- /src/braid/lib/transit.clj: -------------------------------------------------------------------------------- 1 | (ns braid.lib.transit 2 | (:require 3 | [cognitect.transit :as transit]) 4 | (:import 5 | (java.io ByteArrayOutputStream ByteArrayInputStream) 6 | (com.cognitect.transit Writer) 7 | (com.cognitect.transit.impl MsgpackEmitter WriteCache WriteHandlerMap) 8 | (org.msgpack MessagePack))) 9 | 10 | (defn fake-cache 11 | [] 12 | (proxy [WriteCache] [] 13 | (isCacheable [_ _] false) 14 | (cacheWrite [s _] s))) 15 | 16 | (defn noncaching-writer 17 | [^java.io.OutputStream out] 18 | (let [handlers (WriteHandlerMap. ^java.util.Map transit/default-write-handlers) 19 | packer (.createPacker (MessagePack.) out) 20 | emitter (MsgpackEmitter. packer handlers) 21 | write-cache (fake-cache) 22 | wrtr (reify Writer 23 | (write [_ o] 24 | (try 25 | (.emit emitter o false (.init write-cache)) 26 | (.flush out) 27 | (catch Throwable e 28 | (throw (RuntimeException. e))))))] 29 | (transit/->Writer wrtr))) 30 | 31 | (defn ->transit ^bytes 32 | [form] 33 | (let [out (ByteArrayOutputStream. 4096) 34 | ;; writer (transit/writer out :msgpack) 35 | writer (noncaching-writer out)] 36 | (transit/write writer form) 37 | (.toByteArray out))) 38 | 39 | (defn transit->form 40 | [^bytes ts] 41 | (let [in (ByteArrayInputStream. ts)] 42 | (transit/read (transit/reader in :msgpack)))) 43 | -------------------------------------------------------------------------------- /src/braid/lib/upload.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.lib.upload 2 | (:require 3 | [clojure.string :as string] 4 | [goog.object :as o])) 5 | 6 | (def regex (delay 7 | (re-pattern (str "https?://" 8 | (-> (o/get js/window "asset_domain") 9 | (string/replace "." "\\.")) 10 | "/")))) 11 | 12 | (defn ->path [url] 13 | (some-> url 14 | (string/replace 15 | @regex 16 | "/api/upload/"))) 17 | 18 | (defn upload-path? [url] 19 | (string/includes? url (o/get js/window "asset_domain"))) 20 | -------------------------------------------------------------------------------- /src/braid/lib/url.clj: -------------------------------------------------------------------------------- 1 | (ns braid.lib.url 2 | (:require 3 | [clojure.string :as string]) 4 | (:import 5 | (java.net URLEncoder) 6 | (org.apache.commons.validator UrlValidator))) 7 | 8 | (defn valid-url? 9 | "Check if the string is a valid http(s) url. Note that this will *not* 10 | accept local urls such as localhost or my-machine.local" 11 | ([s] (valid-url? s ["http" "https"])) 12 | ([s schemes] 13 | (and (string? s) 14 | (or 15 | (string/starts-with? s "http://localhost:") 16 | (.isValid (UrlValidator. (into-array schemes)) s))))) 17 | 18 | (defn map->query-str 19 | [m] 20 | (->> m 21 | (map (fn [[k v]] (str (name k) "=" (URLEncoder/encode (str v))))) 22 | (string/join "&"))) 23 | 24 | -------------------------------------------------------------------------------- /src/braid/lib/url.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.lib.url 2 | (:import 3 | (goog Uri))) 4 | 5 | (def url-re #"(http(?:s)?://\S+(?:\w|\d|/))") 6 | 7 | (defn extract-urls 8 | "Given some text, returns a sequence of URLs contained in the text" 9 | [text] 10 | (map first (re-seq url-re text))) 11 | 12 | (defn contains-urls? [text] 13 | (boolean (seq (extract-urls text)))) 14 | 15 | (defn url->parts [url] 16 | (let [url-info (.parse Uri url)] 17 | {:domain (.getDomain url-info) 18 | :path (.getPath url-info) 19 | :scheme (.getScheme url-info) 20 | :port (.getPort url-info)})) 21 | 22 | (defn site-url 23 | [] 24 | (let [{:keys [domain scheme port]} (url->parts (.-location js/window))] 25 | (str scheme "://" domain (when (or (and (= scheme "http") 26 | (not= port 80)) 27 | (and (= scheme "https") 28 | (not= port 443))) 29 | (str ":" port))))) 30 | -------------------------------------------------------------------------------- /src/braid/lib/uuid.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.lib.uuid 2 | (:require 3 | #?@(:clj 4 | [[datomic.api :as d]] 5 | :cljs 6 | [[cljs-uuid-utils.core :as uuid]]))) 7 | 8 | (defn squuid [] 9 | #?(:clj 10 | (d/squuid) 11 | :cljs 12 | (uuid/make-random-squuid))) 13 | -------------------------------------------------------------------------------- /src/braid/lib/xhr.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.lib.xhr 2 | (:require 3 | [ajax.core :refer [ajax-request json-request-format json-response-format]] 4 | [ajax.edn :refer [edn-request-format edn-response-format]] 5 | [goog.object :as o])) 6 | 7 | (defn edn-xhr 8 | [args] 9 | (ajax-request (assoc args 10 | :uri (str "/api" (args :uri)) 11 | :with-credentials true 12 | :format (edn-request-format) 13 | :response-format (edn-response-format) 14 | :handler (let [on-error-fn (or (args :on-error) identity) 15 | on-complete-fn (or (args :on-complete) identity)] 16 | (fn [[ok? data]] 17 | (if ok? 18 | (on-complete-fn data) 19 | (on-error-fn data))))))) 20 | 21 | (defn ajax-xhr 22 | [args] 23 | (ajax-request (assoc args 24 | :format (json-request-format) 25 | :response-format (json-response-format {:keywords? true}) 26 | :handler (let [on-error-fn (or (args :on-error) identity) 27 | on-complete-fn (or (args :on-complete) identity)] 28 | (fn [[ok? data]] 29 | (if ok? 30 | (on-complete-fn data) 31 | (on-error-fn data))))))) 32 | -------------------------------------------------------------------------------- /src/braid/page_inbox/commands.clj: -------------------------------------------------------------------------------- 1 | (ns braid.page-inbox.commands 2 | (:require 3 | [braid.core.server.db :as db] 4 | [braid.base.server.socket :as socket] 5 | [braid.chat.predicates :as p] 6 | [braid.chat.db.thread :as db.thread])) 7 | 8 | (def commands 9 | [{:id :braid.inbox/hide-thread! 10 | :params {:thread-id uuid? 11 | :user-id uuid?} 12 | :conditions 13 | (fn [{:keys [user-id thread-id]}] 14 | [[#(p/user-exists? (db/db) user-id) 15 | :not-found "User does not exist"] 16 | [#(p/thread-exists? (db/db) thread-id) 17 | :not-found "Thread does not exist"] 18 | [#(p/user-has-thread-open? (db/db) user-id thread-id) 19 | :forbidden "User does not have thread open"]]) 20 | :effect 21 | (fn [{:keys [user-id thread-id]}] 22 | (db/run-txns! 23 | (db.thread/user-hide-thread-txn user-id thread-id)) 24 | (socket/chsk-send! user-id [:braid.client/hide-thread thread-id]))} 25 | 26 | {:id :braid.inbox/show-thread! 27 | :params {:thread-id uuid? 28 | :user-id uuid?} 29 | :conditions 30 | (fn [{:keys [user-id thread-id]}] 31 | [[#(p/user-exists? (db/db) user-id) 32 | :not-found "User does not exist"] 33 | [#(p/thread-exists? (db/db) thread-id) 34 | :not-found "Thread does not exist"] 35 | [#(not (p/user-has-thread-open? (db/db) user-id thread-id)) 36 | :forbidden "User already has thread open"] 37 | [#(p/user-can-access-thread? (db/db) user-id thread-id) 38 | :forbidden "User cannot access this thread"]]) 39 | :effect 40 | (fn [{:keys [user-id thread-id]}] 41 | (db/run-txns! (db.thread/user-show-thread-txn user-id thread-id)) 42 | (socket/chsk-send! user-id [:braid.client/show-thread 43 | (db.thread/thread-by-id thread-id)]))}]) 44 | -------------------------------------------------------------------------------- /src/braid/page_inbox/styles.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.page-inbox.styles 2 | (:require 3 | [braid.core.client.ui.styles.threads :refer [>threads]] 4 | [braid.core.client.ui.styles.mixins :as mixins])) 5 | 6 | (defn >new-thread [] 7 | [:>.new-thread 8 | {;; background is set inline to group-color 9 | :border-radius "50%" 10 | :border "none" 11 | :flex-shrink 0 12 | :color "white" 13 | :font-size "4em" 14 | :width "5rem" 15 | :height "5rem" 16 | :cursor "pointer"} 17 | 18 | [:&:hover 19 | ;; double invert *trick* keeps the + white 20 | {:filter "invert(100%) brightness(1.8) invert(100%)"}]]) 21 | 22 | (defn >inbox [] 23 | [:>.inbox 24 | (>threads) 25 | 26 | [:>.threads 27 | [:>.sidebar 28 | {:display "flex" 29 | :flex-direction "column" 30 | :align-items "center" 31 | ;; instead of margins; 32 | ;; to allow for button to appear without nudging layout 33 | :width "9rem" 34 | :margin "0 0 1rem 0"} 35 | 36 | [:>button.resort-inbox 37 | (mixins/outline-button 38 | {:text-color "#aaa" 39 | :border-color "#ccc" 40 | :hover-text-color "#999" 41 | :hover-border-color "aaa"}) 42 | {:margin-bottom "1em"}] 43 | 44 | (>new-thread)]]]) 45 | 46 | (def styles 47 | [:>.page.inbox 48 | (>inbox) 49 | 50 | [:>.intro 51 | 52 | [:>button.clear-inbox 53 | (mixins/outline-button 54 | {:text-color "#aaa" 55 | :border-color "#ccc" 56 | :hover-text-color "#999" 57 | :hover-border-color "aaa" 58 | :icon \uf0d0})]]]) 59 | -------------------------------------------------------------------------------- /src/braid/page_subscriptions/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.page-subscriptions.core 2 | "Page for user to manage tag subscriptions" 3 | (:require 4 | [braid.base.api :as base] 5 | [braid.chat.api :as chat] 6 | #?@(:cljs 7 | [[braid.core.client.routes :as routes] 8 | [braid.page-subscriptions.styles :as styles] 9 | [braid.page-subscriptions.ui :as ui]]))) 10 | 11 | (defn init! [] 12 | #?(:cljs 13 | (do 14 | (chat/register-group-page! 15 | {:key :tags 16 | :view ui/tags-page-view}) 17 | 18 | (chat/register-user-header-menu-item! 19 | {:body "Manage Subscriptions" 20 | :route-fn routes/group-page-path 21 | :route-args {:page-id "tags"} 22 | :icon \uf02c 23 | :priority 10}) 24 | 25 | (base/register-styles! 26 | [:.app>.main 27 | styles/tags-page])))) 28 | -------------------------------------------------------------------------------- /src/braid/page_subscriptions/styles.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.page-subscriptions.styles 2 | (:require 3 | [braid.core.client.ui.styles.mixins :as mixins] 4 | [braid.core.client.ui.styles.vars :as vars] 5 | [garden.units :refer [px rem em]])) 6 | 7 | (def tags-page 8 | [:>.page.tags 9 | [:>.title 10 | {:font-size "large"}] 11 | [:>.content 12 | (mixins/settings-container-style) 13 | [:>.new-tag 14 | (mixins/settings-item-style) 15 | [:input.error 16 | {:color "red"}]] 17 | [:>.tag-list 18 | (mixins/settings-item-style) 19 | [:>.tags 20 | {:margin-top (em 1) 21 | :color vars/grey-text} 22 | 23 | [:>.tag-info 24 | {:margin-bottom (em 1)} 25 | 26 | [:.description-edit 27 | [:textarea 28 | {:display "block" 29 | :width "100%"}]] 30 | 31 | [:>.button 32 | mixins/pill-button 33 | {:margin-left (em 1)}] 34 | 35 | [:button 36 | {:margin-right (rem 0.5)} 37 | [:&.delete 38 | {:color "red"} 39 | (mixins/fontawesome nil)]] 40 | 41 | [:>.count 42 | {:margin-right (em 0.5)} 43 | 44 | [:&::after 45 | {:margin-left (em 0.25)}] 46 | 47 | [:&.threads-count 48 | [:&::after 49 | (mixins/fontawesome \uf181)]] 50 | 51 | [:&.subscribers-count 52 | [:&::after 53 | (mixins/fontawesome \uf0c0)]]]]]]]]) 54 | -------------------------------------------------------------------------------- /src/braid/page_uploads/views/uploads_page_styles.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.page-uploads.views.uploads-page-styles 2 | (:require 3 | [garden.units :refer [rem em]] 4 | [braid.core.client.ui.styles.mixins :as mixins] 5 | [braid.core.client.ui.styles.vars :as vars])) 6 | 7 | (defn >uploads-page [] 8 | [:>.page.uploads 9 | 10 | [:>.content 11 | 12 | [:button 13 | (mixins/settings-button)] 14 | 15 | [:>table.uploads 16 | {:width "100%" 17 | :flex-direction "row" 18 | :flex-wrap "wrap" 19 | :align-content "space-between" 20 | :align-item "bottom"} 21 | 22 | [:>tbody 23 | 24 | [:>tr.upload 25 | 26 | ["&:nth-child(odd)" 27 | {:background-color "#f9f9f9"}] 28 | 29 | ["&:nth-child(even)" 30 | {:background-color "white"}] 31 | 32 | [:>td.delete 33 | 34 | [:>button 35 | {:color "red"} 36 | (mixins/fontawesome nil)]] 37 | 38 | [:>td.uploaded-file 39 | 40 | [:img :video 41 | {:width (rem 5) 42 | :margin 0}] 43 | 44 | [:>td.uploaded-thread 45 | {:height (rem 3) 46 | :margin (em 1)} 47 | 48 | [:>.tags 49 | {:display "inline"}]]]]]]]]) 50 | -------------------------------------------------------------------------------- /src/braid/permalinks/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.permalinks.core 2 | "Includes a permalink in thread headers that leads to a dedicated page" 3 | (:require 4 | [braid.chat.api :as chat] 5 | #?@(:cljs 6 | [[re-frame.core :refer [subscribe dispatch]] 7 | [braid.core.client.routes :as routes] 8 | [braid.core.client.ui.views.threads :refer [threads-view]]]))) 9 | 10 | (defn init! [] 11 | #?(:cljs 12 | (do 13 | (chat/register-thread-control! 14 | {:priority -1 15 | :view 16 | (fn [thread] 17 | [:a.control.permalink 18 | {:title "Go to Permalink" 19 | :href (routes/group-page-path 20 | {:query-params {:thread-id (thread :id)} 21 | :page-id "thread" 22 | :group-id (thread :group-id)})} 23 | \uf0c1])}) 24 | 25 | (chat/register-group-page! 26 | {:key :thread 27 | :on-load (fn [page] 28 | (dispatch [:load-threads! {:thread-ids [(uuid (page :thread-id))]}])) 29 | :view 30 | (fn [] 31 | [:div.page 32 | [:div.intro 33 | [:div.title "Thread"]] 34 | (let [page @(subscribe [:page])] 35 | (if-let [thread @(subscribe [:thread (uuid (page :thread-id))])] 36 | [threads-view {:threads [thread]}] 37 | [:div.loading]))])})))) 38 | 39 | -------------------------------------------------------------------------------- /src/braid/popovers/api.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.popovers.api 2 | (:require 3 | [braid.base.api :as base])) 4 | 5 | (defn register-popover-styles! [styles] 6 | (base/register-styles! 7 | [:#app>.app>.main 8 | [:>.popover 9 | styles]])) 10 | -------------------------------------------------------------------------------- /src/braid/popovers/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.popovers.core 2 | "Allows modules to render popovers. 3 | 4 | Register styles with: 5 | braid.popovers.register-popover-styles! 6 | 7 | Trigger popovers by: 8 | {:on-mouse-enter 9 | (braid.popovers.helpers/on-mouse-enter 10 | (fn [] 11 | [view-to-show]))}" 12 | (:require 13 | [braid.base.api :as base] 14 | #?@(:cljs 15 | [[braid.popovers.impl :as impl]]))) 16 | 17 | (defn init! [] 18 | #?(:cljs 19 | (do 20 | (base/register-root-view! impl/view)))) 21 | -------------------------------------------------------------------------------- /src/braid/popovers/helpers.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.popovers.helpers 2 | (:require 3 | [braid.popovers.impl :as impl])) 4 | 5 | (defn on-mouse-enter [view] 6 | (fn [e] 7 | (reset! impl/popover 8 | {:target (.-currentTarget e) 9 | :view view}))) 10 | 11 | (defn on-touch-start 12 | [view] 13 | (fn [e] 14 | (reset! impl/popover {:target (.-currentTarget e) 15 | :view view}))) 16 | 17 | (defn on-click 18 | [view] 19 | (fn [e] 20 | (reset! impl/popover {:target (.-currentTarget e) 21 | :view view 22 | :modal? true}))) 23 | 24 | (defn close! 25 | [] 26 | (reset! impl/popover nil)) 27 | -------------------------------------------------------------------------------- /src/braid/popovers/impl.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.popovers.impl 2 | (:require 3 | [reagent.core :as r])) 4 | 5 | (def popover (r/atom nil)) 6 | 7 | (defn view [] 8 | (when-let [{:keys [view target modal?]} @popover] 9 | (let [bounds (if modal? 10 | #js {:width js/document.documentElement.clientWidth 11 | :height js/document.documentElement.clientHeight 12 | :left js/document.documentElement.clientLeft 13 | :top js/document.documentElement.clientTop} 14 | (.getBoundingClientRect target))] 15 | [:div.popover 16 | {:on-mouse-leave (fn [] 17 | (when-not modal? 18 | (reset! popover nil))) 19 | :on-click (fn [e] 20 | (when (= (.-target e) (.-currentTarget e)) 21 | (reset! popover nil))) 22 | :on-touch-start (fn [e] 23 | (when (= (.-target e) (.-currentTarget e)) 24 | (reset! popover nil))) 25 | :style 26 | (merge {:position "absolute" 27 | :z-index 10000 28 | :width (.-width bounds) 29 | :height (.-height bounds) 30 | :top (+ (.-scrollY js/window) (.-top bounds)) 31 | :left (+ (.-scrollX js/window) (.-left bounds))} 32 | (when modal? 33 | {:background-color "rgba(0, 0, 0, 0.5)" 34 | :display "flex" 35 | :justify-content "center" 36 | :align-items "center"}))} 37 | 38 | [view]]))) 39 | -------------------------------------------------------------------------------- /src/braid/quests/client/core.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.quests.client.core 2 | (:require 3 | [clojure.spec.alpha :as s] 4 | [re-frame.core :as re-frame] 5 | [braid.quests.client.helpers :as helpers] 6 | [braid.quests.client.remote-handlers])) 7 | 8 | (defn initial-data-handler 9 | [db data] 10 | (helpers/set-quest-records db (data :quest-records))) 11 | 12 | (def event-listener 13 | (fn [event] 14 | (when (not= "quests" (namespace (first event))) 15 | (re-frame/dispatch [:quests/update-handler! event])))) 16 | 17 | (def initial-state {::quest-records {}}) 18 | (def schema {::quest-records 19 | {uuid? 20 | {:quest-record/id uuid? 21 | :quest-record/quest-id keyword? 22 | :quest-record/progress integer? 23 | :quest-record/state (s/spec #{:inactive 24 | :active 25 | :complete 26 | :skipped})}}}) 27 | -------------------------------------------------------------------------------- /src/braid/quests/client/helpers.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.quests.client.helpers 2 | (:require 3 | [braid.core.client.state.helpers :refer [key-by-id key-by]] 4 | [braid.quests.client.list :refer [quests]])) 5 | 6 | ; getters 7 | 8 | (defn get-active-quest-records [state] 9 | (->> state 10 | :braid.quests.client.core/quest-records 11 | vals 12 | (filter (fn [quest-record] 13 | (= (quest-record :quest-record/state) :active))) 14 | (sort-by :quest/order))) 15 | 16 | (defn get-next-quest [state] 17 | (let [quest-ids-with-records (->> state 18 | :braid.quests.client.core/quest-records 19 | vals 20 | (map :quest-record/quest-id) 21 | set) 22 | next-quest (->> quests 23 | (sort-by :quest/order) 24 | (remove (fn [quest] 25 | (contains? quest-ids-with-records (quest :quest/id)))) 26 | first)] 27 | next-quest)) 28 | 29 | (defn get-quest-record [state quest-record-id] 30 | (get-in state [:braid.quests.client.core/quest-records quest-record-id])) 31 | 32 | ; setters 33 | 34 | (defn set-quest-records [state quest-records] 35 | (assoc-in state [:braid.quests.client.core/quest-records] (key-by :quest-record/id quest-records))) 36 | 37 | (defn store-quest-record [state quest-record] 38 | (assoc-in state [:braid.quests.client.core/quest-records (quest-record :quest-record/id)] quest-record)) 39 | 40 | (defn complete-quest [state quest-record-id] 41 | (assoc-in state [:braid.quests.client.core/quest-records quest-record-id :quest-record/state] :complete)) 42 | 43 | (defn skip-quest [state quest-record-id] 44 | (assoc-in state [:braid.quests.client.core/quest-records quest-record-id :quest-record/state] :skipped)) 45 | 46 | (defn increment-quest [state quest-record-id] 47 | (update-in state [:braid.quests.client.core/quest-records quest-record-id :quest-record/progress] inc)) 48 | -------------------------------------------------------------------------------- /src/braid/quests/client/remote_handlers.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.quests.client.remote-handlers 2 | (:require 3 | [braid.base.client.socket :as socket] ;; FIXME should register! not use directly 4 | [re-frame.core :refer [dispatch]])) 5 | 6 | (defmethod socket/event-handler :braid.quests/upsert-quest-record 7 | [[_ quest-record]] 8 | (dispatch [:quests/upsert-quest-record! quest-record])) 9 | -------------------------------------------------------------------------------- /src/braid/quests/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.quests.core 2 | "Provides 'quests' for Braid - activies that users can complete to introduce them to the system" 3 | (:require 4 | [braid.base.api :as base] 5 | [braid.chat.api :as chat] 6 | #?@(:cljs 7 | [[braid.quests.client.views :refer [quests-header-view]] 8 | [braid.quests.client.styles :refer [quests-header]] 9 | [braid.quests.client.events :refer [events]] 10 | [braid.quests.client.helpers :as helpers] 11 | [braid.quests.client.core :refer [initial-data-handler 12 | event-listener 13 | initial-state 14 | schema]]] 15 | :clj 16 | [[braid.quests.server.core :refer [db-schema 17 | initial-user-data-fn 18 | server-message-handlers]] 19 | [braid.quests.server.db :refer [activate-first-quests-txn]]]))) 20 | 21 | (defn init! [] 22 | #?(:cljs 23 | (do 24 | (chat/register-header-view! quests-header-view) 25 | (base/register-styles! quests-header) 26 | (base/register-initial-user-data-handler! initial-data-handler) 27 | (base/register-event-listener! event-listener) 28 | (base/register-state! initial-state schema) 29 | (base/register-events! events) 30 | (base/register-subs! 31 | {:quests/active-quest-records 32 | (fn [state _] 33 | (helpers/get-active-quest-records state))})) 34 | 35 | :clj 36 | (do 37 | (base/register-db-schema! db-schema) 38 | (base/register-initial-user-data! initial-user-data-fn) 39 | (base/register-server-message-handlers! server-message-handlers) 40 | (chat/register-post-create-user-txn! activate-first-quests-txn)))) 41 | -------------------------------------------------------------------------------- /src/braid/quests/server/core.clj: -------------------------------------------------------------------------------- 1 | (ns braid.quests.server.core 2 | (:require 3 | [datomic.db] ; for reader macro 4 | [braid.quests.server.db :as db])) 5 | 6 | (def db-schema 7 | [{:db/ident :quest-record/id 8 | :db/valueType :db.type/uuid 9 | :db/cardinality :db.cardinality/one 10 | :db/unique :db.unique/identity} 11 | {:db/ident :quest-record/quest-id 12 | :db/valueType :db.type/keyword 13 | :db/cardinality :db.cardinality/one} 14 | {:db/ident :quest-record/user 15 | :db/valueType :db.type/ref 16 | :db/cardinality :db.cardinality/one} 17 | {:db/ident :quest-record/state 18 | :db/valueType :db.type/keyword 19 | :db/cardinality :db.cardinality/one} 20 | {:db/ident :quest-record/progress 21 | :db/valueType :db.type/long 22 | :db/cardinality :db.cardinality/one}]) 23 | 24 | (defn initial-user-data-fn 25 | [user-id] 26 | {:quest-records (db/get-active-quests-for-user-id user-id)}) 27 | 28 | (def server-message-handlers 29 | {:braid.server.quests/upsert-quest-record 30 | (fn [{:keys [?data user-id]}] 31 | {:chsk-send! [user-id [:braid.quests/upsert-quest-record ?data]] 32 | :db-run-txns! (db/upsert-quest-record-txn user-id ?data)})}) 33 | -------------------------------------------------------------------------------- /src/braid/quests/server/db.clj: -------------------------------------------------------------------------------- 1 | (ns braid.quests.server.db 2 | (:require 3 | [braid.core.server.db :as db] 4 | [datomic.api :as d])) 5 | 6 | ; Pull Patterns 7 | 8 | (def quest-record-pull-pattern 9 | [:quest-record/id 10 | :quest-record/quest-id 11 | :quest-record/progress 12 | :quest-record/state]) 13 | 14 | ; Queries 15 | 16 | (defn get-active-quests-for-user-id 17 | [user-id] 18 | (->> (d/q '[:find (pull ?qr pull-pattern) 19 | :in $ ?user-id pull-pattern 20 | :where 21 | [?qr :quest-record/user ?u] 22 | [?u :user/id ?user-id] 23 | [?qr :quest-record/state :active]] 24 | (db/db) 25 | user-id 26 | quest-record-pull-pattern) 27 | (map first))) 28 | 29 | ; Transactions 30 | 31 | (defn upsert-quest-record-txn 32 | [user-id quest-record] 33 | (let [db-id (if (d/entity (db/db) [:quest-record/id (quest-record :quest-record/id)]) 34 | [:quest-record/id (quest-record :quest-record/id)] 35 | (d/tempid :entities))] 36 | [(assoc quest-record 37 | :db/id db-id 38 | :quest-record/user [:user/id user-id])])) 39 | 40 | (defn activate-first-quests-txn 41 | [user-id] 42 | (->> [:quest/quest-complete :quest/conversation-new :quest/conversation-reply] 43 | (map (fn [quest-id] 44 | {:db/id (d/tempid :entities) 45 | :quest-record/id (d/squuid) 46 | :quest-record/user user-id 47 | :quest-record/quest-id quest-id 48 | :quest-record/progress 0 49 | :quest-record/state :active})))) 50 | -------------------------------------------------------------------------------- /src/braid/search/client.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.search.client 2 | (:require 3 | [braid.core.hooks :as hooks])) 4 | 5 | (defonce search-results-views 6 | (hooks/register! (atom {}) {keyword? {:view fn? 7 | :styles vector? 8 | :priority number?}})) 9 | -------------------------------------------------------------------------------- /src/braid/search/server.clj: -------------------------------------------------------------------------------- 1 | (ns braid.search.server 2 | (:require 3 | [clojure.set :as set] 4 | [braid.core.hooks :as hooks])) 5 | 6 | (defonce search-functions 7 | (hooks/register! (atom []) [fn?])) 8 | 9 | (defonce search-type-auth-check 10 | (hooks/register! (atom {}) {keyword? fn?})) 11 | 12 | (defn- identity-filter 13 | [_ results] 14 | results) 15 | 16 | (defn search-as 17 | "Return the ids of all threads, visible to the user, in the given group, 18 | matching the provided query. 19 | The query can specify tags by prefixing them with an octothorpe; for example, 20 | the query 'foo #bar' will find any threads tagged with 'bar' containing the 21 | text 'foo'" 22 | [{:keys [user-id query group-id]}] 23 | ; TODO: pagination? 24 | (let [results (pmap (fn [search-fn] 25 | (search-fn {:user-id user-id 26 | :query query 27 | :group-id group-id})) 28 | @search-functions)] 29 | (->> (remove nil? results) 30 | ;; group the results by type, without merging all the results 31 | ;; together, so we can take the intersection of the multiple 32 | ;; search functions for a given type 33 | (reduce 34 | (fn [m set-of-maps] 35 | (->> (group-by :search/type set-of-maps) 36 | (reduce (fn [m [type results]] 37 | (update m type (fnil conj []) (set results))) 38 | m))) 39 | {}) 40 | (reduce 41 | (fn [m [type results]] 42 | (assoc m type ((get @search-type-auth-check type identity-filter) 43 | user-id 44 | (apply set/intersection results)))) 45 | {})))) 46 | -------------------------------------------------------------------------------- /src/braid/search/tags.clj: -------------------------------------------------------------------------------- 1 | (ns braid.search.tags 2 | (:require 3 | [clojure.string :as string] 4 | [datomic.api :as d] 5 | [braid.core.server.db :as db])) 6 | 7 | (defn search-tags-by-name 8 | [{:keys [query group-id]}] 9 | (when-let [tags (->> query (re-seq #"[#]([^ ]+)") (map second) seq)] 10 | (->> (d/q '[:find ?t-id ?tag-name 11 | :in $ [?tag-name ...] ?g-id 12 | :where 13 | [?g :group/id ?g-id] 14 | [?t :tag/name ?tag-name-case] 15 | [(clojure.string/lower-case ?tag-name-case) ?tag-name] 16 | [?t :tag/group ?g] 17 | [?t :tag/id ?t-id]] 18 | (db/db) 19 | (map string/lower-case tags) 20 | group-id) 21 | (into #{} 22 | (map (fn [[tag-id tag-name]] 23 | {:search/type :tag 24 | :search/sort-key tag-name 25 | :tag-id tag-id})))))) 26 | -------------------------------------------------------------------------------- /src/braid/search/ui/search_bar.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.search.ui.search-bar 2 | (:require 3 | [reagent.core :as r] 4 | [re-frame.core :refer [subscribe dispatch]] 5 | [braid.core.client.routes :as routes] 6 | [braid.lib.color :as color])) 7 | 8 | (defn search-bar-view 9 | [] 10 | (r/with-let [search-query (r/atom @(subscribe [:braid.search/query])) 11 | prev-page (r/atom (:type @(subscribe [:page])))] 12 | (let [current-page (:type @(subscribe [:page]))] 13 | (when (not= @prev-page current-page) 14 | (if (= @prev-page :search) 15 | (reset! search-query "") 16 | (reset! search-query @(subscribe [:braid.search/query]))) 17 | (reset! prev-page current-page))) 18 | [:div.search-bar 19 | [:input {:type "text" 20 | :placeholder "Search..." 21 | :value @search-query 22 | :on-change 23 | (fn [e] 24 | (reset! search-query (.. e -target -value)) 25 | (dispatch [:braid.search/update-query! (.. e -target -value)]))}] 26 | (if (and @search-query (not= "" @search-query)) 27 | [:a.action.clear 28 | {:on-click (fn [] (reset! search-query "")) 29 | :href (routes/group-page-path {:group-id @(subscribe [:open-group-id]) 30 | :page-id "inbox"}) 31 | :style {:color (color/->color @(subscribe [:open-group-id]))}}] 32 | [:div.action.search])])) 33 | -------------------------------------------------------------------------------- /src/braid/search/ui/search_button.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.search.ui.search-button 2 | (:require 3 | [re-frame.core :refer [subscribe]] 4 | [braid.core.client.routes :as routes])) 5 | 6 | (defn search-button-view [query] 7 | [:a.search 8 | {:href (routes/group-page-path {:group-id @(subscribe [:open-group-id]) 9 | :page-id "search" 10 | :query-params {:query query}})} 11 | "Search"]) 12 | -------------------------------------------------------------------------------- /src/braid/search/ui/search_page_styles.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.search.ui.search-page-styles 2 | (:require 3 | [braid.core.client.ui.styles.vars :as vars])) 4 | 5 | (def >search-page 6 | [:>.page.search 7 | [:>.title 8 | {:position "absolute" 9 | :top "0"}] 10 | [:>.search-results 11 | {:position "absolute" 12 | :bottom 0 13 | :display "flex" 14 | :height "100%" 15 | :flex-direction "row" 16 | :align-items "flex-end"} 17 | [:>.result 18 | {:position "relative" 19 | :min-width "min-content" 20 | :display "flex" 21 | :align-items "flex-end" 22 | :height "90%"} 23 | [:>.description 24 | {:position "absolute" 25 | :color vars/grey-text 26 | :top "1rem" 27 | :left "1rem"}]]]]) 28 | -------------------------------------------------------------------------------- /src/braid/search/ui/tag_results.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.search.ui.tag-results 2 | (:require 3 | [braid.core.client.ui.views.tag-hover-card :as tag-card] 4 | [braid.core.client.ui.styles.hover-cards :as card-styles])) 5 | 6 | (defn search-tags-view 7 | [_ tags] 8 | [:div.result.tag 9 | [:div.description 10 | (str (count tags) " tags" (when (not= (count tags) 1) "s"))] 11 | [:div.tags.content 12 | (doall 13 | (for [{:keys [tag-id]} tags] 14 | ^{:key tag-id} 15 | [tag-card/tag-hover-card-view tag-id]))]]) 16 | 17 | (def styles 18 | [:>.result.tag 19 | [:>.tags.content 20 | ;; would like to just do "flex-wrap: wrap" but apparently a 21 | ;; flex-direction: column + flex-wrap: wrap doesn't grow the width 22 | ;; of the parent when it wraps, so then this ends up overlapping 23 | ;; with the search results next to it 24 | {:display "flex" 25 | :flex-direction "column" 26 | :align-items "flex-end" 27 | :max-height "90%" 28 | :gap "0.5rem" 29 | :margin-left "0.5rem" 30 | :overflow-y "auto" 31 | :padding "0.25rem"} 32 | card-styles/>tag-card 33 | [:>.card.tag 34 | {:min-height "3rem" 35 | :margin-top 0 36 | :margin-left "0.5rem"}]]]) 37 | -------------------------------------------------------------------------------- /src/braid/search/ui/user_results.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.search.ui.user-results 2 | (:require 3 | [braid.core.client.ui.views.user-hover-card :as user-card] 4 | [braid.core.client.ui.styles.hover-cards :as card-styles])) 5 | 6 | (defn search-users-view 7 | [_ users] 8 | [:div.result.user 9 | [:div.description 10 | (str (count users) " user" (when (not= (count users) 1) "s"))] 11 | [:div.users.content 12 | (doall 13 | (for [{:keys [user-id]} users] 14 | ^{:key user-id} 15 | [user-card/user-hover-card-view user-id]))]]) 16 | 17 | (def styles 18 | [:>.result.user 19 | [:>.users.content 20 | ;; would like to just do "flex-wrap: wrap" but apparently a 21 | ;; flex-direction: column + flex-wrap: wrap doesn't grow the width 22 | ;; of the parent when it wraps, so then this ends up overlapping 23 | ;; with the search results next to it 24 | {:display "flex" 25 | :flex-direction "column" 26 | :align-items "flex-end" 27 | :max-height "90%" 28 | :gap "0.5rem" 29 | :margin-left "0.5rem" 30 | :overflow-y "auto" 31 | :padding "0.25rem"} 32 | card-styles/>user-card 33 | [:>.card.user 34 | {:min-height "5rem" 35 | :margin-top 0 36 | :margin-left "0.5rem"}]]]) 37 | -------------------------------------------------------------------------------- /src/braid/search/users.clj: -------------------------------------------------------------------------------- 1 | (ns braid.search.users 2 | (:require 3 | [datomic.api :as d] 4 | [braid.core.server.db :as db])) 5 | 6 | (defn search-users-by-name 7 | [{:keys [query group-id]}] 8 | (when-let [users (->> query (re-seq #"[@]([^ ]+)") (map second) seq)] 9 | (->> (d/q '[:find ?u-id ?user-name 10 | :in $ [?user-name ...] ?g-id 11 | :where 12 | [?g :group/id ?g-id] 13 | [?g :group/user ?user] 14 | [?user :user/nickname ?user-name] 15 | [?user :user/id ?u-id]] 16 | (db/db) 17 | users 18 | group-id) 19 | (into #{} 20 | (map (fn [[user-id user-name]] 21 | {:search/type :user 22 | :search/sort-key user-name 23 | :user-id user-id})))))) 24 | -------------------------------------------------------------------------------- /src/braid/sidebar/api.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.sidebar.api 2 | (:require 3 | [braid.sidebar.ui :as ui])) 4 | 5 | ;; TODO make it so that it can also add styles 6 | (defn register-button! 7 | [view] 8 | (swap! ui/sidebar-extra-views conj view)) 9 | -------------------------------------------------------------------------------- /src/braid/sidebar/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.sidebar.core 2 | (:require 3 | [braid.base.api :as base] 4 | #?@(:cljs 5 | [[braid.sidebar.styles :as styles] 6 | [braid.sidebar.ui :as ui]]))) 7 | 8 | (defn init! [] 9 | #?(:cljs 10 | (do 11 | (base/register-styles! 12 | [:.main 13 | styles/>sidebar]) 14 | 15 | (base/register-root-view! 16 | ui/sidebar-view)))) 17 | -------------------------------------------------------------------------------- /src/braid/uploads/db.clj: -------------------------------------------------------------------------------- 1 | (ns braid.uploads.db 2 | (:require 3 | [datomic.api :as d] 4 | [braid.core.server.db :as db] 5 | [braid.chat.db.common :refer [create-entity-txn]])) 6 | 7 | (def upload-pull-pattern 8 | [:upload/id 9 | :upload/url 10 | :upload/uploaded-at 11 | {:upload/thread [:thread/id]} 12 | {:upload/uploaded-by [:user/id]}]) 13 | 14 | (defn db->upload [e] 15 | {:id (:upload/id e) 16 | :uploaded-at (:upload/uploaded-at e) 17 | :thread-id (get-in e [:upload/thread :thread/id]) 18 | :uploader-id (get-in e [:upload/uploaded-by :user/id]) 19 | :url (:upload/url e)}) 20 | 21 | ;; Queries 22 | 23 | (defn uploads-in-group 24 | [group-id] 25 | (->> (d/q '[:find (pull ?u pull-pattern) 26 | :in $ ?group-id pull-pattern 27 | :where 28 | [?g :group/id ?group-id] 29 | [?t :thread/group ?g] 30 | [?u :upload/thread ?t]] 31 | (db/db) group-id upload-pull-pattern) 32 | (map (comp db->upload first)) 33 | (sort-by :uploaded-at #(compare %2 %1)))) 34 | 35 | (defn upload-info 36 | [upload-id] 37 | (->> (d/pull (db/db) 38 | [{:upload/uploaded-by [:user/id]} 39 | {:upload/thread [{:thread/group [:group/id]}]} 40 | :upload/url] 41 | [:upload/id upload-id]) 42 | ((fn [up] 43 | {:id upload-id 44 | :group-id (get-in up [:upload/thread :thread/group :group/id]) 45 | :user-id (get-in up [:upload/uploaded-by :user/id]) 46 | :url (:upload/url up)})))) 47 | 48 | ;; Transactions 49 | 50 | (defn create-upload-txn 51 | [{:keys [id url thread-id group-id uploader-id uploaded-at]}] 52 | (let [thread (d/tempid :entities)] 53 | (concat 54 | [{:db/id thread 55 | :thread/id thread-id 56 | :thread/group [:group/id group-id]}] 57 | [[:db/add [:user/id uploader-id] :user/subscribed-thread thread]] 58 | (create-entity-txn 59 | {:upload/id id 60 | :upload/url url 61 | :upload/thread thread 62 | :upload/uploaded-by [:user/id uploader-id] 63 | :upload/uploaded-at uploaded-at} 64 | db->upload)))) 65 | 66 | (defn retract-upload-txn 67 | [upload-id] 68 | [[:db.fn/retractEntity [:upload/id upload-id]]]) 69 | -------------------------------------------------------------------------------- /src/braid/users/client/views/users_page.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.users.client.views.users-page 2 | (:require 3 | [re-frame.core :refer [dispatch subscribe]])) 4 | 5 | (defn user-view 6 | [{:keys [nickname id] :as user}] 7 | (let [group-id (subscribe [:open-group-id])] 8 | [:tr.user 9 | [:td.nickname nickname] 10 | [:td.action 11 | (if @(subscribe [:user-is-group-admin? id] [group-id]) 12 | [:span.admin "Admin"] 13 | [:button.make-admin 14 | {:on-click (fn [_] 15 | (dispatch [:make-admin! 16 | {:group-id @group-id 17 | :user-id id}]))} 18 | "Make Admin"]) 19 | (when (not= id @(subscribe [:user-id])) 20 | [:button.ban {:on-click (fn [] 21 | (dispatch [:remove-from-group! 22 | {:group-id @group-id 23 | :user-id id}]))} 24 | "Kick from Group"])]])) 25 | 26 | (defn users-page-view [] 27 | (when @(subscribe [:current-user-is-group-admin?] 28 | [(subscribe [:open-group-id])]) 29 | (let [users @(subscribe [:users])] 30 | [:div.page.users 31 | [:div.title "Users"] 32 | [:div.content 33 | (cond 34 | (nil? users) 35 | [:p.users "Loading..."] 36 | 37 | (empty? users) 38 | [:p.users "No users yet"] 39 | 40 | :else 41 | [:table.users 42 | [:thead 43 | [:tr 44 | [:th "Nickname"] 45 | [:th "Actions"]]] 46 | [:tbody 47 | (doall 48 | (for [user users] 49 | ^{:key (user :id)} 50 | [user-view user]))]])]]))) 51 | -------------------------------------------------------------------------------- /src/braid/users/client/views/users_page_styles.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.users.client.views.users-page-styles 2 | (:require 3 | [braid.core.client.ui.styles.mixins :as mixins] 4 | [braid.core.client.ui.styles.vars :as vars] 5 | [garden.units :refer [rem em px]])) 6 | 7 | (def users-page 8 | [:.app>.main>.page.users 9 | [:>.title 10 | {:font-size "large"}] 11 | 12 | [:>.content 13 | (mixins/settings-container-style) 14 | 15 | [:>.users 16 | (mixins/settings-item-style)]]]) 17 | -------------------------------------------------------------------------------- /src/braid/users/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.users.core 2 | (:require 3 | [braid.base.api :as base] 4 | [braid.chat.api :as chat] 5 | #?@(:cljs 6 | [[braid.users.client.views.users-page :as views] 7 | [braid.users.client.views.users-page-styles :as styles] 8 | [braid.core.client.routes :as routes]]))) 9 | 10 | (defn init! [] 11 | #?(:cljs 12 | (do 13 | (chat/register-group-page! 14 | {:key :users 15 | :view views/users-page-view}) 16 | (chat/register-admin-header-item! 17 | {:class "users" 18 | :route-fn routes/group-page-path 19 | :route-args {:page-id "users"} 20 | :icon \uf0c0 21 | :body "Users"}) 22 | (base/register-styles! styles/users-page)))) 23 | -------------------------------------------------------------------------------- /src/braid/version/core.cljc: -------------------------------------------------------------------------------- 1 | (ns braid.version.core 2 | (:require 3 | [braid.base.api :as base] 4 | #?@(:clj 5 | [[braid.base.conf :refer [config]]]))) 6 | 7 | (defn init! [] 8 | 9 | #?(:clj 10 | (do 11 | (base/register-config-var! :version :optional [:string]) 12 | 13 | (base/register-additional-script! 14 | {:body 15 | ;; can't just include a string here 16 | ;; b/c config is not ready at "compile" time 17 | ;; needs to be at run time 18 | (reify Object 19 | (toString [_] 20 | (str "window.version='" (config :version) "';")))})) 21 | 22 | :cljs 23 | (do 24 | (base/register-root-view! 25 | (fn [] 26 | [:div.version 27 | js/window.version])) 28 | 29 | (base/register-styles! 30 | [:.main>.version 31 | {:opacity 0 32 | :position "absolute" 33 | :bottom 0 34 | :right 0} 35 | 36 | [:&:hover 37 | {:opacity 1}]])))) 38 | -------------------------------------------------------------------------------- /test/braid/test/client/runners/doo.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.test.client.runners.doo 2 | "Test runner that runs tests using doo" 3 | (:require 4 | [braid.test.client.runners.tests] 5 | [doo.runner :refer-macros [doo-all-tests]])) 6 | 7 | (doo-all-tests #"(braid\.test\.client)\..*-test") 8 | -------------------------------------------------------------------------------- /test/braid/test/client/runners/tests.cljs: -------------------------------------------------------------------------------- 1 | (ns braid.test.client.runners.tests 2 | "A namespace that acts as a proxy for test runners to require all 3 | client test namespaces. This is useful if we ever have multiple test 4 | runners (e.g. doo, devcards, etc). New test namespaces need to be 5 | required here to ensure they are loaded, otherwise test runners 6 | cannot find them." 7 | (:require 8 | [braid.test.client.ui.views.message-test])) 9 | -------------------------------------------------------------------------------- /test/braid/test/fixtures/conf.clj: -------------------------------------------------------------------------------- 1 | (ns braid.test.fixtures.conf 2 | (:require 3 | [braid.core.modules :as modules] 4 | [braid.base.conf :as conf] 5 | [mount.core :as mount])) 6 | 7 | (defn start-config 8 | [t] 9 | (modules/init! modules/default) 10 | (-> (mount/only #{#'conf/config}) 11 | (mount/with-args {:port 0}) 12 | (mount/start)) 13 | (t) 14 | (mount/stop)) 15 | -------------------------------------------------------------------------------- /test/braid/test/fixtures/db.clj: -------------------------------------------------------------------------------- 1 | (ns braid.test.fixtures.db 2 | (:require 3 | [braid.core.modules :as modules] 4 | [braid.base.conf :as conf] 5 | [braid.core.server.db :as db] 6 | [datomic.api] 7 | [mount.core :as mount])) 8 | 9 | (defn drop-db [t] 10 | (modules/init! modules/default) 11 | (-> (mount/only #{#'conf/config #'db/conn}) 12 | (mount/with-args {:port 0 13 | :db-url "datomic:mem://chat-test"}) 14 | (mount/start)) 15 | (t) 16 | (datomic.api/delete-database (conf/config :db-url)) 17 | (mount/stop)) 18 | -------------------------------------------------------------------------------- /test/braid/test/server/test_utils.clj: -------------------------------------------------------------------------------- 1 | (ns braid.test.server.test-utils 2 | (:require 3 | [datomic.api :as d] 4 | [braid.core.server.db :as db])) 5 | 6 | (defn- db->message 7 | [e] 8 | {:id (:message/id e) 9 | :content (:message/content e) 10 | :user-id (:user/id (:message/user e)) 11 | :thread-id (:thread/id (:message/thread e)) 12 | :group-id (get-in e [:message/thread :thread/group :group/id]) 13 | :created-at (:message/created-at e)}) 14 | 15 | (defn fetch-messages 16 | "Helper function to fetch all messages" 17 | [] 18 | (->> (d/q '[:find (pull ?e 19 | [:message/id 20 | :message/content 21 | :message/created-at 22 | {:message/user [:user/id]} 23 | {:message/thread [:thread/id 24 | {:thread/group [:group/id]}]}]) 25 | :where [?e :message/id]] 26 | (d/db db/conn)) 27 | (map (comp db->message first)))) 28 | -------------------------------------------------------------------------------- /test/helpers/cookies.clj: -------------------------------------------------------------------------------- 1 | (ns helpers.cookies 2 | (:require 3 | [ring.util.codec :as codec] 4 | [clojure.string :as string]) 5 | (:import 6 | (java.time Instant) 7 | (java.time.format DateTimeFormatter))) 8 | 9 | (let [parser DateTimeFormatter/RFC_1123_DATE_TIME] 10 | (defn- parse-rfc1123 [s] 11 | "Parse RFC822/RFC1123 date per http://tools.ietf.org/html/rfc2616#section-3.3.1" 12 | (.. parser (parseDateTime s) getMillis))) 13 | 14 | (let [matchers [#(when-let [match (re-find #"Max-Age=(\d+)" %)] 15 | [:max-age (.. Instant now (plusMillis (long (* 1000 (Integer/parseInt (match 1))))) toEpochMilli)]) 16 | #(when-let [match (re-find #"Expires=(.+)" %)] 17 | [:expires (parse-rfc1123 (match 1))]) 18 | #(when-let [match (re-find #"Domain=(.+)" %)] 19 | [:domain (match 1)]) 20 | #(when-let [match (re-find #"Path=(.*)" %)] 21 | [:path (match 1)]) 22 | #(when (= "Secure" %) [:secure true]) 23 | #(when (= "HttpOnly" %) [:http-only true])]] 24 | 25 | (defn- parse-cookie-attrs [attr-string] 26 | "Parse optional cookie attributes per RFC 6265" 27 | (let [attrs (string/split attr-string #";") 28 | matches (map (fn [a] (some (fn [m] (m a)) matchers)) attrs)] 29 | ;; max-age dominates expires 30 | (reduce (fn [m [k v]] (if (= :max-age k) (merge {:expires v} m) (merge m {k v}))) {} matches)))) 31 | 32 | (defn parse-cookie [c] 33 | "Parse cookies per http://tools.ietf.org/html/rfc6265" 34 | (let [x (re-find #"([^;]+)(?:;(.*))*" c) 35 | [name value] (first (codec/form-decode (x 1))) 36 | av-map (parse-cookie-attrs (x 2))] 37 | {name (merge av-map {:value value})})) 38 | 39 | (defn write-cookie [[name {value :value}]] 40 | (codec/form-encode {name value})) 41 | 42 | (defn write-cookies [cookies] 43 | (string/join ";" (map write-cookie cookies))) 44 | -------------------------------------------------------------------------------- /test/integration/braid/system_start_stop_test.clj: -------------------------------------------------------------------------------- 1 | (ns integration.braid.system-start-stop-test 2 | (:require [braid.core :refer :all] 3 | [clojure.test :refer :all])) 4 | 5 | (deftest start-stop 6 | (is (start! 0)) 7 | (is (stop!))) 8 | --------------------------------------------------------------------------------