diff --git a/project.clj b/project.clj index c501a0a5e..e39fb180d 100644 --- a/project.clj +++ b/project.clj @@ -12,6 +12,7 @@ [ring-cors "0.1.8"] [compojure "1.5.1"] [environ "1.0.3"] + [org.clojure/data.json "0.2.6"] [com.taoensso/timbre "4.5.1" :exclusions [org.clojure/tools.reader]] [crypto-password "0.2.0"] [clj-time "0.12.0"] diff --git a/resources/public/css/out/chat.css b/resources/public/css/out/chat.css new file mode 100644 index 000000000..56c1cabf2 --- /dev/null +++ b/resources/public/css/out/chat.css @@ -0,0 +1,547 @@ +@import url("https://maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css"); +@-webkit-keyframes anim-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(359deg); + transform: rotate(359deg); + } +} +@keyframes anim-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(359deg); + transform: rotate(359deg); + } +} +.app > .main > .groups-nav { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4rem; + box-sizing: border-box; +} +.app > .main > .page { + position: absolute; + left: 4rem; + top: 0; + bottom: 0; + right: 0; +} +.app .main > .groups-nav { + background: #222; + padding: 1rem; +} +.app .main > .groups-nav .option { + width: 2rem; + height: 2rem; + border-radius: 0.3333333333333333rem; + display: block; + text-decoration: none; + line-height: 2rem; + text-align: center; + font-size: 1.5em; +} +.app .main > .groups-nav .option.active:before { + content: ""; + background: #eee; + width: 0.3333333333333333rem; + height: 100%; + position: absolute; + left: -1rem; + border-radius: 0 0.3333333333333333rem 0.3333333333333333rem 0; +} +.app .main > .groups-nav .group { + margin: 0 0 1rem 0; + color: #222; + position: relative; +} +.app .main > .groups-nav .plus { + position: absolute; + bottom: 1rem; + background: #333; + color: #222; + font-family: "fontawesome"; +} +.app .main > .groups-nav .plus:hover { + background: #555; +} +.app .main > .groups-nav .badge { + font-size: 0.75em; + display: inline-block; + padding: 0 0.5em; + border-radius: 0.5em; + text-transform: uppercase; + letter-spacing: 0.1em; + background-color: #222; + border: 1px solid #222; + height: 1.75em; + line-height: 1.75em; + max-width: 10em; + white-space: nowrap; + overflow: hidden; + vertical-align: middle; + cursor: pointer; + text-decoration: none; + font-size: 0.6rem; + color: #aaa; + background: #B53737; + color: white; + border-color: #B53737; + position: absolute; + bottom: -0.5rem; + right: -0.5rem; +} +.app .main > .groups-nav .badge.on { + color: white !important; +} +.app .main > .groups-nav .badge.off { + background-color: white !important; +} +.emojione { + font-size: inherit; + height: 3ex; + width: 3.1ex; + min-height: 20px; + min-width: 20px; + display: inline-block; + margin: -0.2ex 0.15em 0.2ex; + line-height: normal; + vertical-align: middle; +} +.error-banners { + z-index: 9999; + position: fixed; + top: 0; + right: 0; + width: 100%; +} +.error-banners .error-banner { + margin-bottom: 0.25rem; + font-size: 2em; + background-color: rgba(255, 5, 14, 0.6); + text-align: center; +} +.error-banners .error-banner .close { + margin-left: 1em; + cursor: pointer; +} +.tag { + font-size: 0.75em; + display: inline-block; + padding: 0 0.5em; + border-radius: 0.5em; + text-transform: uppercase; + letter-spacing: 0.1em; + background-color: #222; + border: 1px solid #222; + height: 1.75em; + line-height: 1.75em; + max-width: 10em; + white-space: nowrap; + overflow: hidden; + color: white; + vertical-align: middle; + cursor: pointer; + text-decoration: none; +} +.tag.on { + color: white !important; +} +.tag.off { + background-color: white !important; +} +.user { + font-size: 0.75em; + display: inline-block; + padding: 0 0.5em; + border-radius: 0.5em; + text-transform: uppercase; + letter-spacing: 0.1em; + background-color: #222; + border: 1px solid #222; + height: 1.75em; + line-height: 1.75em; + max-width: 10em; + white-space: nowrap; + overflow: hidden; + color: white; + vertical-align: middle; + cursor: pointer; + text-decoration: none; + /* removed for now + .status { + margin-left: 0.3em; + width: 0.85em; + height: 0.85em; + display: inline-block; + border: solid 1px white; + border-radius: 0.5em; + box-sizing: border-box; + vertical-align: middle; + margin-bottom: 0.15em; + &.online { + background: white; + } + &.offline { + + } + &.away { + + } + } + */ +} +.user.on { + color: white !important; +} +.user.off { + background-color: white !important; +} +.button { + font-size: 0.75em; + display: inline-block; + padding: 0 0.5em; + border-radius: 0.5em; + text-transform: uppercase; + letter-spacing: 0.1em; + background-color: #222; + border: 1px solid #222; + height: 1.75em; + line-height: 1.75em; + max-width: 10em; + white-space: nowrap; + overflow: hidden; + color: white; + vertical-align: middle; + cursor: pointer; + text-decoration: none; + color: #888888; + border: 1px solid #888888; + background: none; + margin-left: 1em; +} +.button.on { + color: white !important; +} +.button.off { + background-color: white !important; +} +.button:hover { + color: #eee; + background: #888888; + cursor: pointer; +} +.button:active { + color: #eee; + background: #666; + border-color: #666; + cursor: pointer; +} +.threads { + position: absolute; + top: 4rem; + right: 0; + bottom: 1rem; + left: 0; + padding-left: 1rem; + display: -webkit-flex; + display: flex; + -webkit-align-items: flex-end; + align-items: flex-end; + overflow-x: scroll; +} +.thread { + margin-right: 1rem; + min-width: 17rem; + width: 17rem; + box-sizing: border-box; + display: -webkit-flex; + display: flex; + -webkit-flex-direction: column; + flex-direction: column; + height: 100%; +} +.thread:before { + content: ""; + -webkit-flex-grow: 1; + flex-grow: 1; +} +.thread .card { + display: -webkit-flex; + display: flex; + -webkit-flex-direction: column; + flex-direction: column; + box-shadow: 0 1px 2px 0 #ccc; + max-height: 100%; + background: white; +} +.thread .card .head { + min-height: 3.5em; + width: 100%; + position: relative; + -webkit-flex-shrink: 0; + flex-shrink: 0; +} +.thread .card .head .tags { + margin: 1rem 2rem 1rem 1rem; +} +.thread .card .head .tags .pill { + margin-bottom: 0.5em; + margin-right: 0.5em; +} +.thread .card .head .close { + position: absolute; + padding: 1rem; + top: 0; + right: 0; + z-index: 10; + cursor: pointer; +} +.thread .card .messages { + position: relative; + overflow-y: scroll; + padding: 0 1rem; +} +.thread .card .message.new { + -webkit-flex-shrink: 0; + flex-shrink: 0; + padding-bottom: 1rem; +} +.thread .card .message.new textarea { + min-height: 3.5em; +} +.thread .tags-preview .ambiguous .group { + border: 1px dashed black; + cursor: pointer; +} +.thread .notice { + box-shadow: 0 1px 2px 0 #ccc; + padding: 1rem; + margin-bottom: 1rem; +} +.thread .notice:before { + float: left; + font-size: 2rem; + margin-right: 0.5rem; + font-family: "fontawesome"; +} +.thread.private .card { + max-height: 85%; +} +.thread.private .head:before { + content: ""; + display: block; + width: 100%; + height: 5px; + background: #5f7997; + position: absolute; +} +.thread.private .notice { + background: #D2E7FF; + color: #5f7997; +} +.thread.private .notice:before { + content: "\f21b"; +} +.thread.dragging { + background-color: gray; + border: 5px dashed black; +} +.thread .card { + -webkit-transition: box-shadow 0.2s; + transition: box-shadow 0.2s; +} +.thread.focused .card { + box-shadow: 0 10px 10px 10px #ccc; +} +.thread.limbo .card { + max-height: 85%; +} +.thread.limbo .head:before { + content: ""; + display: block; + width: 100%; + height: 5px; + background: #CA1414; + position: absolute; +} +.thread.limbo .notice { + background: #ffe4e4; + color: #CA1414; +} +.thread.limbo .notice:before { + content: "\f071"; +} +.thread .uploading-indicator { + font-size: 1.5em; + text-align: center; + content: "\f110"; + font-family: "fontawesome"; + -webkit-animation: anim-spin 1s infinite steps(8); + animation: anim-spin 1s infinite steps(8); +} +.login { + background: white; + box-shadow: 0 1px 2px 0 #ccc; + width: 17rem; + padding: 1rem; + box-sizing: border-box; + position: relative; +} +.login input { + width: 100%; + margin: 0 0 1em; + padding: 0.25em; + box-sizing: border-box; +} +.page.me .nickname .error { + color: red; +} +.page.me.active { + display: block; +} +.page.me .pending-invites { + color: #999; +} +.page.me .pending-invites ul.invites { + padding-left: 15px; +} +.page.me .group .name { + color: #999; + margin-bottom: 0.25em; +} +.page.me .group .invite-form .autocomplete { + background-color: #999; +} +.page.me .group .invite-form .autocomplete .results { + list-style-type: none; + padding-left: 10px; +} +.page.me .group .invite-form .autocomplete .results .result:hover { + background: #eee; +} +.page.me .group .invite-form .autocomplete .results .active { + font-weight: bold; +} +.page.me .group .new-tag.error { + border-color: red; +} +.app .main > .header { + position: absolute; + top: 1rem; + right: 1rem; + z-index: 100; + line-height: 2rem; + color: #888888; +} +.app .main > .header .modal { + display: none; + background: white; + position: absolute; + cursor: default; +} +.app .main > .header .modal p { + line-height: 1.2em; +} +.app .main > .header .shortcut { + display: inline-block; + margin-right: 1rem; +} +.app .main > .header .shortcut .title { + text-decoration: none; + color: #888888; +} +.app .main > .header .shortcut.active .title, +.app .main > .header .shortcut:hover .title { + color: #000; +} +.app .main > .header .shortcut:hover .modal { + display: block; + padding: 1rem; +} +.app .main > .header .inbox .title:after { + font-family: "fontawesome"; + content: "\f01c"; +} +.app .main > .header .recent .title:after { + font-family: "fontawesome"; + content: "\f1da"; +} +.app .main > .header .help .title:after { + font-family: "fontawesome"; + content: "\f059"; +} +.app .main > .header .users .title:after { + font-family: "fontawesome"; + content: "\f007"; + margin-left: 0.25em; +} +.app .main > .header .tags .title:after { + font-family: "fontawesome"; + content: "\f02c"; +} +.app .main > .header .extensions .title:after { + font-family: "fontawesome"; + content: "\f1e6"; +} +.app .main > .header .search-bar { + display: inline-block; +} +.app .main > .header .search-bar input { + width: 20em; +} +.app .main > .header .avatar { + width: 2rem; + height: 2rem; + vertical-align: middle; + margin-left: 1rem; + border-radius: 20%; +} +.page > .title { + height: 2rem; + line-height: 2rem; + color: #888888; + margin: 1rem; +} +.page > .content { + overflow: scroll; + padding: 1rem; + color: #888888; +} +.page > .content .description { + width: 17rem; +} +.page > .content .description .avatar { + width: 4rem; + height: 4rem; + display: block; + border-radius: 1rem; + margin-bottom: 1rem; +} +.page.channels .tags { + margin-top: 1em; + color: #888888; +} +.page.channels .tags .tag-info { + margin-bottom: 1em; +} +.page.channels .count { + margin-right: 0.5em; +} +.page.channels .count:after { + font-family: "fontawesome"; + margin-left: 0.25em; +} +.page.channels .count.threads-count:after { + content: "\f181"; +} +.page.channels .count.subscribers-count:after { + content: "\f0c0"; +} diff --git a/src/braid/client/calls/handlers.cljs b/src/braid/client/calls/handlers.cljs new file mode 100644 index 000000000..9fc6bc489 --- /dev/null +++ b/src/braid/client/calls/handlers.cljs @@ -0,0 +1,37 @@ +(ns braid.client.calls.handlers + (:require [braid.client.webrtc :as rtc] + [braid.client.dispatcher :refer [dispatch!]] + [braid.client.sync :as sync] + [braid.client.schema :as schema] + [braid.client.calls.helpers :as helpers] + [braid.client.state.handler.core :refer [handler]])) + +(defmethod handler :calls/start-new-call [state [_ data]] + (rtc/get-ice-servers + (fn [servers] + (rtc/create-connections servers) + (let [call (schema/make-call data)] + (sync/chsk-send! [:braid.server/make-new-call call]) + (dispatch! :calls/add-new-call call)))) + state) + +(defmethod handler :calls/receive-new-call [state [_ call]] + (rtc/get-ice-servers + (fn [servers] + (rtc/create-connections servers) + (dispatch! :calls/add-new-call call))) + state) + +(defmethod handler :calls/add-new-call [state [_ call]] + (helpers/add-call state call)) + +(defmethod handler :calls/set-requester-call-status [state [_ [call status]]] + (when (= status :accepted) + (rtc/set-stream)) + (sync/chsk-send! [:braid.server/change-call-status {:call call :status status}]) + (helpers/set-call-status state (call :id) status)) + +(defmethod handler :calls/set-receiver-call-status [state [_ [call status]]] + (when (= status :accepted) + (rtc/set-stream)) + (helpers/set-call-status state (call :id) status)) diff --git a/src/braid/client/calls/helpers.cljs b/src/braid/client/calls/helpers.cljs new file mode 100644 index 000000000..d677dfb57 --- /dev/null +++ b/src/braid/client/calls/helpers.cljs @@ -0,0 +1,7 @@ +(ns braid.client.calls.helpers) + +(defn add-call [state call] + (update-in state [:calls] #(assoc % (:id call) call))) + +(defn set-call-status [state call-id status] + (assoc-in state [:calls call-id :status] status)) diff --git a/src/braid/client/calls/remote_handlers.cljs b/src/braid/client/calls/remote_handlers.cljs new file mode 100644 index 000000000..448ae7e8f --- /dev/null +++ b/src/braid/client/calls/remote_handlers.cljs @@ -0,0 +1,17 @@ +(ns braid.client.calls.remote-handlers + (:require + [braid.client.sync :as sync] + [braid.client.webrtc :as rtc] + [braid.client.dispatcher :refer [dispatch!]])) + +(defmethod sync/event-handler :braid.client/receive-new-call + [[_ call]] + (dispatch! :calls/receive-new-call call)) + +(defmethod sync/event-handler :braid.client/receive-new-call-status + [[_ [call status]]] + (dispatch! :calls/set-receiver-call-status [call status])) + +(defmethod sync/event-handler :braid.client/receive-protocol-signal + [[_ signal]] + (rtc/receive-protocol-signal signal)) diff --git a/src/braid/client/calls/state.cljs b/src/braid/client/calls/state.cljs new file mode 100644 index 000000000..e2f107a6c --- /dev/null +++ b/src/braid/client/calls/state.cljs @@ -0,0 +1,9 @@ +(ns braid.client.calls.state + (:require [schema.core :as s :include-macros true] + [braid.common.schema :as app-schema])) + +(def init-state + {:calls {}}) + +(def CallsState + {:calls {s/Uuid app-schema/Call}}) diff --git a/src/braid/client/calls/styles.cljs b/src/braid/client/calls/styles.cljs new file mode 100644 index 000000000..8d8bdf4ea --- /dev/null +++ b/src/braid/client/calls/styles.cljs @@ -0,0 +1,15 @@ +(ns braid.client.calls.styles + (:require [garden.units :refer [em px rem]] + [garden.arithmetic :as m] + [braid.client.ui.styles.vars :as vars] + [braid.client.ui.styles.mixins :as mixins])) + +(defn call-view [] + [:.call + {:position "absolute" + :top "20px" + :left "20px" + :width "200px" + :height "200px" + :z-index 1005 + :background "white"}]) diff --git a/src/braid/client/calls/subscriptions.cljs b/src/braid/client/calls/subscriptions.cljs new file mode 100644 index 000000000..258f629d0 --- /dev/null +++ b/src/braid/client/calls/subscriptions.cljs @@ -0,0 +1,30 @@ +(ns braid.client.calls.subscriptions + (:require [reagent.ratom :include-macros true :refer-macros [reaction]] + [braid.client.state.subscription :refer [subscription]])) + +(defmethod subscription :calls + [state _] + (reaction (vals (@state :calls)))) + +(defmethod subscription :call-status + [state [_ call]] + (reaction (get-in @state [:calls (call :id) :status]))) + +(defmethod subscription :new-call + [state _] + (reaction (->> (@state :calls) + vals + (filter (fn [c] (not= :archived (c :status)))) + (sort-by :created-at) + first))) + +(defmethod subscription :current-user-is-caller? + [state [_ caller-id]] + (reaction (= @(subscription state [:user-id]) caller-id))) + +(defmethod subscription :correct-nickname + [state [_ call]] + (let [is-caller? (reaction @(subscription state [:current-user-is-caller? (call :caller-id)])) + caller-nickname (reaction @(subscription state [:nickname (call :caller-id)])) + callee-nickname (reaction @(subscription state [:nickname (call :callee-id)]))] + (reaction (if @is-caller? @callee-nickname @caller-nickname)))) diff --git a/src/braid/client/calls/views.cljs b/src/braid/client/calls/views.cljs new file mode 100644 index 000000000..86aaeb756 --- /dev/null +++ b/src/braid/client/calls/views.cljs @@ -0,0 +1,101 @@ +(ns braid.client.calls.views + (:require [reagent.core :as r] + [reagent.ratom :include-macros true :refer-macros [reaction]] + [braid.client.ui.views.pills :refer [user-pill-view]] + [braid.client.webrtc :as rtc] + [braid.client.dispatcher :refer [dispatch!]] + [braid.client.state :refer [subscribe]])) + +(defn ended-call-view + [call] + (let [correct-nickname (subscribe [:correct-nickname call])] + (fn [call] + [:div + [:a.button {:on-click (fn [_] (dispatch! :calls/set-requester-call-status [call :archived]))} "X"] + [:p (str "Call with " @correct-nickname " ended")]]))) + +(defn dropped-call-view + [call] + (let [correct-nickname (subscribe [:correct-nickname call])] + (fn [call] + [:div + [:a.button {:on-click (fn [_] (dispatch! :calls/set-requester-call-status [call :archived]))} "X"] + [:p (str "Call with " @correct-nickname " dropped")]]))) + +(defn declined-call-view + [call] + (let [user-is-caller? (subscribe [:current-user-is-caller? (call :caller-id)]) + caller-nickname (subscribe [:nickname (call :caller-id)]) + callee-nickname (subscribe [:nickname (call :callee-id)])] + (fn [call] + [:div + [:a.button {:on-click (fn [_] (dispatch! :calls/set-requester-call-status [call :archived]))} "X"] + (if @user-is-caller? + [:p (str @callee-nickname " declined your call")] + [:p (str "Call with " @caller-nickname "declined")])]))) + +(defn accepted-call-view + [call] + (let [call-time (r/atom 0) + correct-nickname (subscribe [:correct-nickname call])] + (fn [call] + (js/setTimeout #(swap! call-time inc) 1000) + [:div + [:h4 (str "Call with " @correct-nickname "...")] + [:div (str @call-time)] + [:br] + [:a.button "A"] + [:a.button "M"] + [:a.button "V"] + [:video {:id "vid" + :class (if (= (call :type) :video) "video" "audio")}] + [:a.button {:on-click + (fn [_] + (dispatch! :calls/set-requester-call-status [call :ended]))} "End"]]))) + +(defn incoming-call-view + [call] + (let [user-is-caller? (subscribe [:current-user-is-caller? (call :caller-id)]) + caller-nickname (subscribe [:nickname (call :caller-id)]) + callee-nickname (subscribe [:nickname (call :callee-id)])] + (fn [call] + (if @user-is-caller? + [:div + [:p (str "Calling " @callee-nickname "...")] + [:a.button {:on-click + (fn [_] + (dispatch! :calls/set-requester-call-status [call :dropped]))} "Drop"]] + [:div + [:p (str "Call from " @caller-nickname)] + [:a.button {:on-click + (fn [_] + (dispatch! :calls/set-requester-call-status [call :accepted]))} "Accept"] + [:a.button {:on-click + (fn [_] + (dispatch! :calls/set-requester-call-status [call :declined]))} "Decline"]])))) + +(defn during-call-view + [call] + (let [call-atom (r/atom call) + call-status (subscribe [:call-status] [call-atom]) + correct-nickname (subscribe [:correct-nickname] [call-atom])] + (r/create-class + {:display-name "during-call-view" + :component-will-receive-props + (fn [_ [_ new-call]] + (reset! call-atom new-call)) + :reagent-render + (fn [call] + [:div.call + (case @call-status + :incoming [incoming-call-view call] + :accepted [accepted-call-view call] + :declined [declined-call-view call] + :dropped [dropped-call-view call] + :ended [ended-call-view call])])}))) + +(defn call-view [] + (let [new-call (subscribe [:new-call])] + (fn [] + (when @new-call + [during-call-view @new-call])))) diff --git a/src/braid/client/schema.cljs b/src/braid/client/schema.cljs index 564911c2e..ea9de6ee7 100644 --- a/src/braid/client/schema.cljs +++ b/src/braid/client/schema.cljs @@ -34,6 +34,14 @@ :invitee-email (data :invitee-email) :group-id (data :group-id)}) +(defn make-call [data] + {:id (uuid/make-random-squuid) + :created-at (or (data :created-at) (js/Date.)) + :type (data :type) + :caller-id (data :caller-id) + :callee-id (data :callee-id) + :status :incoming}) + (defn make-bot [data] (merge {:id (uuid/make-random-squuid)} data)) diff --git a/src/braid/client/state.cljs b/src/braid/client/state.cljs index 138ced397..0613569f3 100644 --- a/src/braid/client/state.cljs +++ b/src/braid/client/state.cljs @@ -3,6 +3,7 @@ [braid.client.store :as store] [clojure.set :refer [union intersection subset?]] [braid.client.state.subscription :refer [subscription]] + [braid.client.calls.subscriptions] [braid.client.quests.subscriptions]) (:import goog.Uri)) diff --git a/src/braid/client/state/handler/impl.cljs b/src/braid/client/state/handler/impl.cljs index 1a8df8c11..fac2d6621 100644 --- a/src/braid/client/state/handler/impl.cljs +++ b/src/braid/client/state/handler/impl.cljs @@ -9,6 +9,7 @@ [braid.client.state.helpers :as helpers] [braid.client.dispatcher :refer [dispatch!]] [braid.client.state.handler.core :refer [handler]] + [braid.client.calls.handlers] [braid.client.quests.handlers] [braid.client.quests.helpers :as quest-helpers])) @@ -52,6 +53,8 @@ "#" (or (store/name->open-tag-id tag-name) tag-name)))))) + + (defmethod handler :clear-session [state _] (helpers/clear-session state)) @@ -520,3 +523,4 @@ (defmethod handler :add-group-bot [state [_ [group-id bot]]] (helpers/add-group-bot state group-id bot)) + diff --git a/src/braid/client/state/remote_handlers.cljs b/src/braid/client/state/remote_handlers.cljs index 97350a42d..b7951a332 100644 --- a/src/braid/client/state/remote_handlers.cljs +++ b/src/braid/client/state/remote_handlers.cljs @@ -4,6 +4,7 @@ [braid.client.router :as router] [braid.client.desktop.notify :as notify] [braid.client.dispatcher :refer [dispatch!]] + [braid.client.calls.remote-handlers] [braid.client.quests.remote-handlers])) (defmethod sync/event-handler :braid.client/thread @@ -116,5 +117,3 @@ (defmethod sync/event-handler :braid.client/show-thread [[_ thread]] (dispatch! :add-open-thread thread)) - - diff --git a/src/braid/client/store.cljs b/src/braid/client/store.cljs index 555f3d4d0..1347e855b 100644 --- a/src/braid/client/store.cljs +++ b/src/braid/client/store.cljs @@ -7,6 +7,7 @@ [braid.common.schema :as app-schema] [reagent.core :as r] [braid.client.state.helpers :as helpers] + [braid.client.calls.state :as calls] [braid.client.quests.state :as quests])) (defonce app-state @@ -32,6 +33,7 @@ :subscribed-tag-ids #{}} :new-thread-id (uuid/make-random-squuid) :focused-thread-id nil} + calls/init-state quests/init-state))) (def AppState @@ -62,6 +64,7 @@ :subscribed-tag-ids #{s/Uuid}} :new-thread-id s/Uuid :focused-thread-id (s/maybe s/Uuid)} + calls/CallsState quests/QuestsState)) (def check-app-state! (s/validator AppState)) @@ -146,4 +149,3 @@ (defn open-group-id [] (get @app-state :open-group-id)) - diff --git a/src/braid/client/ui/views/main.cljs b/src/braid/client/ui/views/main.cljs index add312029..13b0c300a 100644 --- a/src/braid/client/ui/views/main.cljs +++ b/src/braid/client/ui/views/main.cljs @@ -4,6 +4,7 @@ [braid.client.ui.views.error-banner :refer [error-banner-view]] [braid.client.ui.views.sidebar :refer [sidebar-view]] [braid.client.ui.views.header :refer [header-view]] + [braid.client.calls.views :refer [call-view]] [braid.client.ui.views.pages.inbox :refer [inbox-page-view]] [braid.client.ui.views.pages.recent :refer [recent-page-view]] [braid.client.ui.views.pages.users :refer [users-page-view]] @@ -48,4 +49,5 @@ [sidebar-view] (when @group-id [header-view]) + [call-view] [page-view]]))) diff --git a/src/braid/client/ui/views/pills.cljs b/src/braid/client/ui/views/pills.cljs index c604f42c0..5d239bf80 100644 --- a/src/braid/client/ui/views/pills.cljs +++ b/src/braid/client/ui/views/pills.cljs @@ -68,6 +68,20 @@ [tag-pill tag-id] [tag-car-view tag-id]]) +(defn call-button-view + [callee-id] + (let [caller-id (subscribe [:user-id]) + callee-status (subscribe [:user-status callee-id])] + (fn [callee-id] + (when (and (= @callee-status :online) + (not= @caller-id callee-id)) + [:a.button {:on-click + (fn [_] + (dispatch! :calls/start-new-call {:type :audio + :caller-id @caller-id + :callee-id callee-id}))} + "Call"])))) + (defn user-pill [user-id] (let [user (subscribe [:user user-id]) @@ -86,7 +100,8 @@ (let [user (subscribe [:user user-id]) open-group-id (subscribe [:open-group-id]) admin? (subscribe [:user-is-group-admin? user-id open-group-id]) - user-status (subscribe [:user-status user-id])] + user-status (subscribe [:user-status user-id]) + current-user-id (subscribe [:user-id])] (fn [user-id] [:div.card [:div.header {:style {:background-color (id->color user-id)}} @@ -108,7 +123,8 @@ [:div.actions ; [:a.pm "PM"] ; [:a.mute "Mute"] - [search-button-view (str "@" (@user :nickname))]]]))) + [search-button-view (str "@" (@user :nickname))] + [call-button-view user-id]]]))) (defn user-pill-view [user-id] diff --git a/src/braid/client/ui/views/styles.cljs b/src/braid/client/ui/views/styles.cljs index 9891e3d53..48e84824b 100644 --- a/src/braid/client/ui/views/styles.cljs +++ b/src/braid/client/ui/views/styles.cljs @@ -17,6 +17,7 @@ [braid.client.ui.styles.pages.me] [braid.client.ui.styles.pages.bots] [braid.client.ui.styles.pages.uploads] + [braid.client.calls.styles] [braid.client.ui.styles.vars :as vars])) (defn styles-view [] @@ -50,6 +51,7 @@ braid.client.ui.styles.pills/tag braid.client.ui.styles.pills/user braid.client.ui.styles.misc/status + (braid.client.calls.styles/call-view) (braid.client.ui.styles.misc/threads vars/pad) (braid.client.ui.styles.thread/thread vars/pad) (braid.client.ui.styles.thread/head vars/pad) diff --git a/src/braid/client/webrtc.cljs b/src/braid/client/webrtc.cljs new file mode 100644 index 000000000..0376df742 --- /dev/null +++ b/src/braid/client/webrtc.cljs @@ -0,0 +1,92 @@ +(ns braid.client.webrtc + (:require [braid.client.sync :as sync] + [braid.client.dispatcher :refer [dispatch!]])) + +(defonce svga-dimensions + (clj->js {:mandatory {:maxWidth 320 :maxHeight 180}})) + +(def peer-connection (atom nil)) + +; REMOTE STUFF + +; Protocols + +(defn signal-sdp-description [connection description] + (.setLocalDescription @connection description) + (sync/chsk-send! + [:braid.server/send-protocol-signal + {:sdp (aget description "sdp") + :type (aget description "type")}])) + +(defn create-answer [connection] + (.createAnswer + @connection + (fn [description] + (signal-sdp-description connection description)) + (fn [error] + (println "Error creating offer description: " (aget error "message"))))) + +(defn create-offer [connection] + (.createOffer + @connection + (fn [description] + (signal-sdp-description connection description)) + (fn [error] + (println "Error creating offer description: " (aget error "message"))))) + +(defn receive-protocol-signal [signal] + (if (signal :candidate) + (.addIceCandidate @peer-connection (js/RTCIceCandidate. (clj->js signal))) + (do + (.setRemoteDescription @peer-connection (js/RTCSessionDescription. (clj->js signal))) + (when (= "offer" (signal :type)) + (create-answer peer-connection))))) + +; Media + +(defn set-stream [] + (letfn [(stream-success [stream] + (.addStream @peer-connection stream) + (create-offer peer-connection)) + (stream-failure [error] + (println "Error opening stream: " (aget error "message")))] + (. js/navigator + (webkitGetUserMedia + (clj->js {:audio true :video svga-dimensions}) stream-success stream-failure)))) + +; Setup + +(defn handle-ice-candidate [evt] + (let [candidate (aget evt "candidate")] + (when candidate + (sync/chsk-send! + [:braid.server/send-protocol-signal + {:candidate (aget candidate "candidate") + :sdpMid (aget candidate "sdpMid") + :sdpMLineIndex (aget candidate "sdpMLineIndex")}])))) + +(defn handle-stream [evt] + (let [stream (aget evt "stream") + stream-url (.. js/window -URL (createObjectURL stream)) + video-player (. js/document (getElementById "vid"))] + (aset video-player "src" stream-url) + (aset video-player "onloadedmetadata" (fn [_] (.play video-player))))) + +(defn set-connection-atom [conn conn-atom on-ice on-stream] + (aset conn "onicecandidate" on-ice) + (aset conn "onaddstream" on-stream) + (reset! conn-atom conn)) + +(defn create-connections [servers] + (set-connection-atom + (js/webkitRTCPeerConnection. (clj->js {:iceServers servers})) + peer-connection + handle-ice-candidate + handle-stream)) + +(defn get-ice-servers [handler] + (sync/chsk-send! [:braid.server/get-ice-servers] 2500 + (fn [servers] + (if (= servers :chsk/timeout) + (get-ice-servers handler) + (handler servers))))) diff --git a/src/braid/common/schema.cljc b/src/braid/common/schema.cljc index 6305efef0..2b4991ccf 100644 --- a/src/braid/common/schema.cljc +++ b/src/braid/common/schema.cljc @@ -111,6 +111,14 @@ :group-id s/Uuid :group-name s/Str}) +(def Call + {:id s/Uuid + :created-at s/Inst + :type (s/enum :audio :video) + :caller-id s/Uuid + :callee-id s/Uuid + :status s/Keyword}) + (def Upload {:id s/Uuid :thread-id s/Uuid diff --git a/src/braid/server/sync.clj b/src/braid/server/sync.clj index 0a4bf5c93..e7505a3e5 100644 --- a/src/braid/server/sync.clj +++ b/src/braid/server/sync.clj @@ -6,6 +6,7 @@ [braid.server.search :as search] [braid.server.invite :as invites] [braid.server.digest :as digest] + [braid.server.webrtc :as rtc] [clojure.set :refer [difference intersection]] [braid.common.util :as util :refer [valid-nickname? valid-tag-name?]] [braid.server.email-digest :as email] @@ -486,6 +487,32 @@ (?reply-fn {:braid/ok (db/uploads-in-group ?data)}) (?reply-fn {:braid/error "Not allowed"})))) +(defmethod event-msg-handler :braid.server/get-ice-servers + [{:as ev-msg :keys [event id ?data ring-req ?reply-fn send-fn]}] + (?reply-fn (rtc/get-ice-servers))) + +(defmethod event-msg-handler :braid.server/send-protocol-signal + [{:as ev-msg :keys [event id ?data ring-req ?reply-fn send-fn]}] + (let [signal-id (get-in ring-req [:session :user-id]) + signal-data ?data] + (doseq [user-id (:any @connected-uids) :when (not= signal-id user-id)] + (chsk-send! user-id [:braid.client/receive-protocol-signal signal-data])))) + +(defmethod event-msg-handler :braid.server/make-new-call + [{:as ev-msg :keys [event id ?data ring-req ?reply-fn send-fn]}] + (when (= (get-in ring-req [:session :user-id]) (?data :caller-id)) + (chsk-send! (?data :callee-id) [:braid.client/receive-new-call ?data]))) + +(defmethod event-msg-handler :braid.server/change-call-status + [{:as ev-msg :keys [event id ?data ring-req ?reply-fn send-fn]}] + (let [call (?data :call) + call-id (call :id) + status (?data :status) + source-id (get-in ring-req [:session :user-id])] + (if (= source-id (call :caller-id)) + (chsk-send! (call :callee-id) [:braid.client/receive-new-call-status [call status]]) + (chsk-send! (call :caller-id) [:braid.client/receive-new-call-status [call status]])))) + (defmethod event-msg-handler :braid.server/start [{:as ev-msg :keys [user-id]}] (let [connected (set (:any @connected-uids))] diff --git a/src/braid/server/webrtc.clj b/src/braid/server/webrtc.clj new file mode 100644 index 000000000..bc9831cc7 --- /dev/null +++ b/src/braid/server/webrtc.clj @@ -0,0 +1,16 @@ +(ns braid.server.webrtc + (:require [org.httpkit.client :as http] + [clojure.data.json :as json] + [environ.core :refer [env]])) + +(defn get-ice-servers [] + (let [response @(http/request + {:url "https://api.twilio.com/2010-04-01/Accounts/AC01773bdc00cc61649a19c84303b85c82/Tokens.json" + :method :post + :basic-auth [(env :twilio-key) + (env :twilio-secret)]}) + ice-servers (-> response + (get :body) + (json/read-str :key-fn keyword) + (get :ice_servers))] + ice-servers))