Browse Source

Added messages

Samson Mutunga 3 years ago
parent
commit
e02b59d844

+ 5 - 1
.env-example

@@ -54,4 +54,8 @@ BACKEND_WS_URL=http://localhost:8080/ws
 AGORA_APPID=9d04292b03524927a8fe9d937a448d85
 AGORA_MODE=screen
 
-POINT_IMPL_DATE=2021-10-29
+POINT_IMPL_DATE=2021-10-29
+
+OPENTOK_API_KEY=46871644
+OPENTOK_API_SECRET=48c39d640cbcfb1032606d7c40ab5971290a5163
+OPENTOK_SESSION_ID=1_MX40Njg3MTY0NH5-MTU5NjQyMzcxMjQ4OX5PRnNIVmFDU2t2d3BnWG1YbkMvSWFRNk1-fg

+ 15 - 0
app/Helpers/helpers.php

@@ -785,4 +785,19 @@ if(!function_exists('segment_template_summary_value_display')) {
         if($value && strlen($value)) return '<span class="segment-template-summary-value '.$class.'">' . $value . '</span>';
         return $default;
     }
+}
+
+if(!function_exists('friendlier_date_time_in_est')) {
+    function friendlier_date_time_in_est($value, $includeTime = true, $default = '-') {
+        if(!$value || empty($value)) return $default;
+        $value = convertToTimezone($value, 'US/Easters');
+        try {
+            $result = strtotime($value);
+            $result = date("j M, y" . ($includeTime ? ", h:i a" : ""), $result);
+            return $result;
+        }
+        catch (Exception $e) {
+            return $value;
+        }
+    }
 }

+ 4 - 0
app/Http/Controllers/AdminController.php

@@ -192,5 +192,9 @@ class AdminController extends Controller
         return $this->pass($templateContent);
     }
 
+    public function messages(Request $request)
+    {
+        return view('app.messages.index');
+    }
 
 }

+ 259 - 0
app/Http/Controllers/MessageController.php

@@ -0,0 +1,259 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Client;
+use App\Models\Pro;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\File;
+
+use App\Models\InternalMessage;
+use Ramsey\Collection\CollectionInterface;
+
+use OpenTok\MediaMode;
+use OpenTok\OpenTok;
+use Jenssegers\Agent\Agent;
+
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use OpenTok\ArchiveMode;
+use OpenTok\OutputMode;
+
+class MessageController extends Controller
+{
+
+    public function index(Request $request)
+    {
+
+        if($request->input('m')) {
+            $im = InternalMessage::where('uid', $request->input('m'))->first();
+            if($im) {
+                return redirect(route('messages') . '?r=' . $im->regarding_client_id);
+            }
+        }
+
+        $currentPro = $this->performer()->pro;
+
+        $myProID = $currentPro->id;
+        
+        // SELECT * FROM internal_message WHERE regarding_client_id IN (SELECT shadow_client_id FROM pro WHERE hr_rep_pro_id = $myProID);
+
+        $conversations = DB::select("
+        SELECT * FROM (
+select
+    distinct on (im.regarding_client_id) regarding_client_id, im.content_text, im.id,
+    (c.name_first  || ' ' || c.name_last) as regarding_client_name,
+    (p.name_first) as from_pro_short_name,
+    im.created_at,
+    (select count(ima.id) from internal_message_attachment ima where ima.internal_message_id = im.id) as num_attachments,
+    (select count(id) from internal_message imc
+        where imc.regarding_client_id = im.regarding_client_id
+          and imc.is_from_shadow_client is true
+          and imc.is_removed = false
+          and imc.is_read = false) as num_unread
+from internal_message im
+    join client c on im.regarding_client_id = c.id
+    join pro sp on c.shadow_pro_id = sp.id
+    join pro p on im.from_pro_id= p.id
+where im.is_removed = false AND im.regarding_client_id IN (SELECT shadow_client_id FROM pro WHERE hr_rep_pro_id = ?)
+order by im.regarding_client_id, im.created_at desc
+        ) as x ORDER BY x.created_at DESC", [$myProID]);
+
+        $messages = [];
+        $flattenedAttachments = [];
+        $regardingClient = null;
+        if($request->input('r')) {
+            $regardingClient = Client::where('id', $request->input('r'))->first();
+        }
+
+        if($regardingClient) {
+
+            $messages = DB::select("
+select
+    im.uid, im.regarding_client_id, im.is_read, im.message_video_file_id, sf.uid as message_video_file_system_file_uid,
+    im.content_text, im.id, im.from_pro_id, im.created_at,
+    (p.name_first  || ' ' || p.name_last) as from_name,
+    im.is_from_shadow_client, im.is_to_shadow_client,
+    im.is_removed, im.is_cleared, im.is_edited,
+    im.original_content_text, im.cleared_content_text,
+    (select count(ima.id) from internal_message_attachment ima where ima.internal_message_id = im.id) as num_attachments
+from internal_message im
+    join pro p on im.from_pro_id = p.id
+    left join system_file sf ON sf.id = im.message_video_file_id
+where im.regarding_client_id = ?
+order by im.created_at asc
+        ",
+                [$regardingClient->id]);
+
+        }
+        else {
+            $regardingClient = null;
+        }
+
+        $opentok = new OpenTok(config('app.opentokApiKey'),config('app.opentokApiSecret'));
+        $otSession = $opentok->createSession(array('mediaMode' => MediaMode::ROUTED));
+        $otSessionId = $otSession->getSessionId();
+        $otToken = $opentok->generateToken($otSessionId);
+
+        
+        $step = 'webcam-test';
+        $agent = new Agent();
+        $allow = !$agent->isPhone() && !$agent->isTablet();
+
+        return view('app.messages.index', compact('conversations', 'regardingClient', 'messages', 'flattenedAttachments','otSessionId', 'otToken',  'step', 'allow'));
+    }
+
+    public function thread(Request $request)
+    {
+
+        $messages = [];
+        $flattenedAttachments = [];
+        $regardingClient = null;
+        if($request->input('r')) {
+            $regardingClient = Client::where('id', $request->input('r'))->first();
+        }
+
+        if($regardingClient) {
+
+            $messages = DB::select("
+select
+    im.uid, im.regarding_client_id, im.is_read, im.message_video_file_id, sf.uid as message_video_file_system_file_uid,
+    im.content_text, im.id, im.from_pro_id, im.created_at,
+    (p.name_first  || ' ' || p.name_last) as from_name,
+    im.is_from_shadow_client, im.is_to_shadow_client,
+    im.is_removed, im.is_cleared, im.is_edited,
+    im.original_content_text, im.cleared_content_text,
+    (select count(ima.id) from internal_message_attachment ima where ima.internal_message_id = im.id) as num_attachments
+from internal_message im
+    join pro p on im.from_pro_id = p.id
+    left join system_file sf ON sf.id = im.message_video_file_id
+where im.regarding_client_id = ?
+order by im.created_at asc
+        ",
+                [$regardingClient->id]);
+
+        }
+        else {
+            $regardingClient = null;
+        }
+
+        $opentok = new OpenTok(config('app.opentokApiKey'),config('app.opentokApiSecret'));
+        $otSession = $opentok->createSession(array('mediaMode' => MediaMode::ROUTED));
+        $otSessionId = $otSession->getSessionId();
+        $otToken = $opentok->generateToken($otSessionId);
+
+        return view('app.messages.thread', compact('regardingClient', 'messages', 'flattenedAttachments', 'otSessionId'));
+    }
+
+    public function proofread(Request $request)
+    {
+
+        $messages = [];
+        $flattenedAttachments = [];
+        $regardingClient = null;
+        if($request->input('r')) {
+            $regardingClient = Client::where('id', $request->input('r'))->first();
+        }
+
+        if($regardingClient) {
+
+            $messages = DB::select("
+select
+    im.uid, im.regarding_client_id, im.is_read, im.message_video_file_id, sf.uid as message_video_file_system_file_uid,
+    im.content_text, im.id, im.from_pro_id, im.created_at,
+    (p.name_first  || ' ' || p.name_last) as from_name,
+    im.is_from_shadow_client, im.is_to_shadow_client,
+    im.is_removed, im.is_cleared, im.is_edited,
+    im.original_content_text, im.cleared_content_text, im.proofreader_memo,
+    (select count(ima.id) from internal_message_attachment ima where ima.internal_message_id = im.id) as num_attachments
+from internal_message im
+    join pro p on im.from_pro_id = p.id
+    left join system_file sf ON sf.id = im.message_video_file_id
+where im.regarding_client_id = ?
+order by im.created_at asc
+        ",
+                [$regardingClient->id]);
+
+        }
+        else {
+            $regardingClient = null;
+        }
+
+        $opentok = new OpenTok(config('app.opentokApiKey'),config('app.opentokApiSecret'));
+        $otSession = $opentok->createSession(array('mediaMode' => MediaMode::ROUTED));
+        $otSessionId = $otSession->getSessionId();
+        $otToken = $opentok->generateToken($otSessionId);
+
+        return view('app.messages.proofread', compact('regardingClient', 'messages', 'flattenedAttachments', 'otSessionId'));
+    }
+
+    public function attachments(Request $request, InternalMessage $message) {
+        if(!$message) return '';
+        $output = [];
+        foreach ($message->attachments as $attachment) {
+            $output[] = '<a native target="_blank" ' .
+                'href="/api/internalMessageAttachment/download/' . $attachment->uid . '" ' .
+                'class="attachment text-sm my-1">' .
+                '<i class="fa fa-paperclip"></i>&nbsp;' .
+                $attachment->systemFile->file_name .
+                '</a>';
+        }
+        return implode("", $output);
+    }
+
+    public function clients(Request $request)
+    {
+        $term = $request->input('term') ? trim($request->input('term')) : '';
+        if (empty($term)) return '';
+        $term = strtolower($term);
+
+        $clients = $this->performer->pro->getAccessibleClientsQuery()
+            ->where(function ($q) use ($term) {
+                $q->orWhereRaw('LOWER(name_first::text) LIKE ?', ['%' . $term . '%'])
+                    ->orWhereRaw('LOWER(name_last::text) LIKE ?', ['%' . $term . '%']);
+            })
+            ->orderBy('name_last', 'asc')
+            ->orderBy('name_first', 'asc')
+            ->get();
+
+        $clients = $clients->map(function($_client) {
+            return [
+                "uid" => $_client->uid,
+                "id" => $_client->uid,
+                "text" => $_client->displayName()
+            ];
+        });
+        return json_encode([
+            "results" => $clients
+        ]);
+    }
+
+    public function sendFromPros(Request $request)
+    {
+        $term = $request->input('term') ? trim($request->input('term')) : '';
+        if (empty($term)) return '';
+        $term = strtolower($term);
+
+        $results = Pro::where(function ($q) use ($term) {
+                $q->orWhereRaw('LOWER(name_first::text) LIKE ?', ['%' . $term . '%'])
+                    ->orWhereRaw('LOWER(name_last::text) LIKE ?', ['%' . $term . '%']);
+            })
+            ->orderBy('name_last', 'asc')
+            ->orderBy('name_first', 'asc')
+            ->get();
+
+        $pros = $results->map(function($_pro) {
+            return [
+                "uid" => $_pro->uid,
+                "id" => $_pro->uid,
+                "text" => $_pro->name_first.' '.$_pro->name_last
+            ];
+        });
+        return json_encode([
+            "results" => $pros
+        ]);
+    }
+
+
+}

+ 67 - 0
app/Models/InternalMessage.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class InternalMessage extends Model
+{
+
+    protected $table = 'internal_message';
+
+    public function getRouteKeyName()
+    {
+        return 'uid';
+    }
+
+    public function messageVideoFile()
+    {
+        return $this->hasOne(SystemFile::class, 'id', 'message_video_file_id');
+    }
+
+    public function fromPro()
+    {
+        return $this->hasOne(Pro::class, 'id', 'from_pro_id');
+    }
+
+    public function toPro()
+    {
+        return $this->hasOne(Pro::class, 'id', 'to_pro_id');
+    }
+
+    public function regardingClient()
+    {
+        return $this->hasOne(Client::class, 'id', 'regarding_client_id');
+    }
+
+    public function attachments()
+    {
+        return $this->hasMany(InternalMessageAttachment::class, 'internal_message_id', 'id');
+    }
+
+    public function readBy() {
+        $readBy = '-';
+        if($this->read_by_id) {
+            $session = AppSession::where('id', $this->read_by_id)->first();
+            switch($session->session_type) {
+                case 'PRO':
+                    if($session->pro_id) {
+                        $pro = Pro::where('id', $session->pro_id)->first();
+                        if($pro) {
+                            $readBy = $pro->displayName() . ' (PRO)';
+                        }
+                    }
+                    break;
+                case 'CLIENT':
+                    if($session->pro_id) {
+                        $client = Client::where('id', $session->client_id)->first();
+                        if($client) {
+                            $readBy = $client->displayName() . ' (CLIENT)';
+                        }
+                    }
+                    break;
+            }
+        }
+        return $readBy;
+    }
+}

+ 22 - 0
app/Models/InternalMessageAttachment.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class InternalMessageAttachment extends Model
+{
+
+    protected $table = 'internal_message_attachment';
+
+    public function getRouteKeyName()
+    {
+        return 'uid';
+    }
+
+    public function systemFile()
+    {
+        return $this->hasOne(SystemFile::class, 'id', 'system_file_id');
+    }
+
+}

+ 3 - 1
composer.json

@@ -19,7 +19,9 @@
         "laravel/framework": "^7.0",
         "laravel/tinker": "^2.0",
         "picqer/php-barcode-generator": "^2.1",
-        "soundasleep/html2text": "^1.1"
+        "soundasleep/html2text": "^1.1",
+        "jenssegers/agent": "^2.6",
+        "opentok/opentok": "4.4.x"
     },
     "require-dev": {
         "barryvdh/laravel-debugbar": "^3.5",

File diff suppressed because it is too large
+ 547 - 166
composer.lock


+ 4 - 0
config/app.php

@@ -73,6 +73,10 @@ return [
 
     'point_impl_date' => env('POINT_IMPL_DATE', '2021-10-29'),
 
+    'opentokApiKey' => env('OPENTOK_API_KEY'),
+    'opentokApiSecret' => env('OPENTOK_API_SECRET'),
+    'opentokSessionId' => env('OPENTOK_SESSION_ID'),
+
     /*
     |--------------------------------------------------------------------------
     | Application Timezone

+ 200 - 1
public/css/style.css

@@ -2965,4 +2965,203 @@ body .vakata-context .vakata-context-separator>a {
 
 .phq .table thead th {
     border-bottom: 0;
-}
+}
+
+
+
+/* internal messages */
+.im-body:not(.inline) {
+    height: calc(100vh - 55px) !important;
+}
+.im-container {
+    display: flex;
+    align-items: stretch;
+}
+.im-container .im-lhs {
+    min-width: 300px;
+    max-width: 300px;
+    border-right: 1px solid #ddd;
+    padding: 0.5rem;
+    overflow: auto;
+}
+.im-container .im-lhs .im-conversation {
+    border-radius: 4px;
+    padding: 0.5rem;
+    border: 1px solid #ddd;
+    background: white;
+    cursor: pointer;
+    position: relative;
+    overflow: hidden;
+}
+.im-container .im-lhs .im-conversation:hover {
+    background: aliceblue;
+    text-decoration: none;
+}
+.im-container .im-lhs .im-conversation.active {
+    background: aliceblue;
+    border-color: #288ace;
+}
+.im-container .im-lhs .im-conversation:not(:last-child) {
+     margin-bottom: 0.5rem;
+}
+.im-container .im-lhs .im-conversation .unread-conversation {
+    position: absolute;
+    right: 0;
+    top: 0;
+}
+.im-container .im-lhs .im-conversation span.unread-badge {
+    position: absolute;
+    right: -0.5rem;
+    top: -0.5rem;
+    padding: 0.1rem 0.3rem;
+    font-weight: normal;
+    color: #fff;
+    background: #288ace;
+    font-size: 10px;
+    border-bottom-left-radius: 3px;
+}
+.im-container .im-rhs {
+    flex-grow: 1;
+    padding: 1rem;
+    display: inline-flex;
+    flex-direction: column;
+}
+.im-container .im-rhs.inline .im-messages {
+    max-height: 300px;
+}
+.im-container .im-rhs .im-messages {
+    flex-grow: 1;
+    display: inline-flex;
+    flex-direction: column;
+    justify-content: flex-end;
+    overflow: auto;
+    margin-bottom: 1rem;
+}
+.im-container .im-rhs .im-messages .im-message {
+    margin-top: 0.5rem;
+    padding: 0 0.5rem;
+    text-align: left;
+}
+.im-container .im-rhs .im-messages .im-message.sent {
+    text-align: right;
+}
+.im-container .im-rhs .im-messages-header {
+    padding-bottom: 0.5rem;
+    border-bottom: 1px solid #eee;
+}
+.im-container .im-rhs .im-messages .im-message .im-message-sender {
+    display: flex;
+    justify-content: flex-start;
+}
+.im-container .im-rhs .im-messages .im-message .im-message-sender .unread-message {
+    margin-bottom: -5px;
+    margin-top: -5px;
+    font-size: 16px;
+}
+.im-container .im-rhs .im-messages .im-message .im-message-content {
+    background: #eee;
+    border-radius: 6px;
+    padding: 0.5rem;
+    margin-top: 0.25rem;
+    white-space: pre-wrap;
+    word-break: break-word;
+    text-align: left;
+    display: inline-block;
+    min-width: 150px;
+    max-width: 75%;
+    flex-grow: 0;
+}
+.im-container .im-rhs .im-messages .im-message.sent .im-message-sender {
+    justify-content: flex-end;
+}
+.im-container .im-rhs .im-messages .im-message.sent .im-message-content {
+    margin-left: auto;
+    background: #d6eff7;
+}
+
+.im-container .im-rhs .im-messages .im-message.sent .header-item {
+    margin-left: 0.75rem;
+}
+.im-container .im-rhs .im-messages .im-message.received .header-item {
+    margin-right: 0.75rem;
+}
+.im-container .im-rhs .im-messages .im-message.sent .attachment {
+    margin-left: 0.75rem;
+}
+.im-container .im-rhs .im-messages .im-message.received .attachment {
+    margin-right: 0.75rem;
+}
+
+.im-container .im-rhs .im-input textarea {
+    height: 60px;
+    border-color: #ccc;
+    padding: 0.3rem;
+    box-shadow: none !important;
+}
+.im-container .im-rhs .im-input.pr-input textarea {
+    height: 150px;
+}
+.im-container .im-rhs .im-input textarea:focus,
+.im-container .im-rhs .im-input textarea:active,
+.im-container .im-rhs .im-input textarea:focus-visible {
+    border: 1px solid #307899 !important;
+}
+.im-container .im-rhs .im-input #selected-files .selected-file {
+    background: #eee;
+    border: 1px solid #ccc;
+    border-radius: 3px;
+    padding: 2px 20px 2px 5px;
+    cursor: pointer;
+    position: relative;
+    margin-top: 6px;
+    margin-right: 6px;
+}
+.im-container .im-rhs .im-input #selected-files .selected-file:hover {
+    color: #b11313;
+}
+.im-container .im-rhs .im-input #selected-files .selected-file:after {
+    position: absolute;
+    content: '✕';
+    right: 4px;
+    color: #b11313;
+}
+.im-container .im-rhs .im-video-container {
+    max-width: 480px;
+    display: inline-block;
+    position: relative;
+    margin-top: 4px;
+}
+.im-container .im-rhs .im-video-container video {
+    max-width: 100%;
+}
+.im-container .im-rhs .im-video-container .vs-control {
+    opacity: 0;
+    transition: opacity 0.3s ease;
+    position: absolute;
+    margin: 0 !important;
+    top: 0;
+    right: 0;
+    background: rgba(238, 238, 238, 0.75) !important;
+    border: 0 !important;
+    border-bottom-left-radius: 3px;
+    text-align: right !important;
+    width: auto !important;
+    font-size: 11px !important;
+}
+.im-container .im-rhs .im-video-container:hover .vs-control {
+    opacity: 1;
+    background: rgba(238, 238, 238, 1) !important;
+}
+.im-container .im-rhs .im-video-container .vs-control select {
+    width: auto !important;
+    border: 0 !important;
+}
+.im-message.proofreading {
+    border: 2px solid #9aceb8;
+    padding: 4px !important;
+    background: #f1fff9;
+    border-radius: 5px;
+}
+.opacity-0 {
+    opacity: 0;
+}

BIN
public/img/loading.gif


+ 1 - 0
public/img/loading.svg

@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0" width="64px" height="64px" viewBox="0 0 128 128" xml:space="preserve"><g><path d="M78.75 16.18V1.56a64.1 64.1 0 0 1 47.7 47.7H111.8a49.98 49.98 0 0 0-33.07-33.08zM16.43 49.25H1.8a64.1 64.1 0 0 1 47.7-47.7V16.2a49.98 49.98 0 0 0-33.07 33.07zm33.07 62.32v14.62A64.1 64.1 0 0 1 1.8 78.5h14.63a49.98 49.98 0 0 0 33.07 33.07zm62.32-33.07h14.62a64.1 64.1 0 0 1-47.7 47.7v-14.63a49.98 49.98 0 0 0 33.08-33.07z" fill="#595859"/><animateTransform attributeName="transform" type="rotate" from="0 64 64" to="-90 64 64" dur="400ms" repeatCount="indefinite"></animateTransform></g></svg>

+ 71 - 0
public/moe-video/moe-video.css

@@ -0,0 +1,71 @@
+[moe-video-backdrop] {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background: rgba(0, 0, 0, 0.1);
+    align-items: center;
+    justify-content: center;
+    display: none;
+}
+[moe-video-backdrop]>.card {
+    width: 640px;
+    max-width: 100%;
+    border-radius: 0.35rem;
+    box-shadow: 0 0 1rem #aaa;
+}
+[moe-video-backdrop]>.card .card-header,
+[moe-video-backdrop]>.card .card-footer{
+    padding: 1rem;
+}
+[moe-video-backdrop]>.card .card-title {
+    margin-bottom: 0;
+    font-weight: bold;
+}
+[moe-video-backdrop] [moe-video-canvas] {
+    width: 540px !important;
+    max-width: 100% !important;
+    height: 360px !important;
+    background: #333;
+    margin: 0 auto;
+    border-radius: 0.20rem;
+}
+[moe-video-backdrop] [moe-video-player] {
+    width: 540px !important;
+    height: 360px !important;
+    background: #333;
+    margin: 0 auto;
+    border-radius: 0.20rem;
+}
+[moe-wait] {
+    font-size: 0.8rem;
+    color: #888;
+    display: none;
+    height: 31px;
+    line-height: 31px;
+}
+[moe-answer-video] {
+    max-width: 450px;
+    height: auto;
+    border-radius: 5px;
+}
+.access-information {
+    font-size: 13px;
+}
+[moe-video-progress] {
+    position: fixed;
+    top:0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background: rgba(0,0,0,0.2);
+    z-index: 9999;
+    display: none;
+    align-items: center;
+    justify-content: center;
+}
+[moe-video-progress]>img {
+    width: 64px;
+    height: 64px;
+}

+ 386 - 0
public/moe-video/moe-video.js

@@ -0,0 +1,386 @@
+
+/** moe-video enabler component **/
+
+(function() {
+
+    // state
+    let moeOTSession = false;
+    let moePublisher = false;
+    let moeVideoElement = false;
+    let moeArchiveID = false;
+    let uploadOrRecord = 'record';
+
+    // initialize elements, events
+    function init(_container = false) {
+
+        $('[moe-video-progress]').remove();
+        $('<div/>')
+            .attr('moe-video-progress', 1)
+            .append('<img src="/img/loading.svg">')
+            .appendTo('body');
+
+        _container = $(_container || 'body');
+        _container
+            .find('[moe-video]:not([moe-initialized])')
+                .each(function() {
+
+                    // assign a unique id
+                    let moeID = Math.ceil(Math.random() * 1000000);
+                    $(this).attr('moe-id', moeID);
+
+                   
+                    var buttonsDiv = $('<div class="d-flex flex-wrap" />').appendTo(this);
+
+                    // record button
+                    $('<button type="button" />')
+                        .addClass('btn btn-sm btn-info ml-2')
+                        .attr('moe-show-popup', 1)
+                        .html('<i class="fa fa-video"></i>')
+                        .appendTo(buttonsDiv);
+
+                    // mark as initialized
+                    $(this).attr('moe-initialized', 1);
+
+                });
+
+        // bind events
+        $(document).off('click.moe-show-popup', '[moe-video] [moe-show-popup]')
+        $(document).on('click.moe-show-popup', '[moe-video] [moe-show-popup]', function() {
+            initVideoPopup();
+            moeVideoElement = $(this).closest('[moe-video]');
+            let title = moeVideoElement.find('p').first().text();
+            if(!title) {
+                title = moeVideoElement.attr('data-title');
+            }
+            $('[moe-video-backdrop]')
+                .css('display', 'flex')
+                .find('.card-title').first().text(title);
+
+        });
+
+        $(document).off('change.moe-video-file', '[moe-video-file]')
+        $(document).on('change.moe-video-file', '[moe-video-file]', function() {
+
+            // check if video
+            let file = this.files[0];
+            if(file.type !== 'video/mp4' && file.type !== 'video/mpeg' && file.type !== 'video/webm') {
+                toastr.error('Invalid video file!');
+                return false;
+            }
+            let blobURL = URL.createObjectURL(file);
+
+            $('[moe-video-player]')
+                .attr('src', blobURL)
+                .removeClass('d-none')
+                .addClass('d-block');
+            $('.upload-or-record')
+                .removeClass('d-block')
+                .addClass('d-none');
+            $('[moe-play-recording]').show();
+            $('[moe-submit-recording]').show();
+            $('[moe-close-popup]').show();
+
+            uploadOrRecord = 'upload';
+
+            return false;
+        });
+
+        $(document).off('click.moe-choose-record', '[moe-choose-record]')
+        $(document).on('click.moe-choose-record', '[moe-choose-record]', function() {
+            initCamera(moeVideoElement);
+            uploadOrRecord = 'record';
+            return false;
+        });
+
+        $(document).off('click.moe-start-recording', '[moe-video-backdrop] [moe-start-recording]')
+        $(document).on('click.moe-start-recording', '[moe-video-backdrop] [moe-start-recording]', function() {
+            startWait();
+            $('[moe-video-player]')
+                .attr('src', '')
+                .removeClass('d-block')
+                .addClass('d-none');
+            $('[moe-video-canvas]')
+                .removeClass('d-none')
+                .addClass('d-block');
+            $.post('/start/' + moeVideoElement.attr('moe-ot-session-id'));
+        });
+
+        $(document).off('click.moe-stop-recording', '[moe-video-backdrop] [moe-stop-recording]')
+        $(document).on('click.moe-stop-recording', '[moe-video-backdrop] [moe-stop-recording]', function() {
+            startWait();
+            $.post('stop/' +  moeArchiveID).done(function () { 
+                $.ajax('/archive/'+ moeArchiveID, {
+                    success: function (data) { // success callback function
+                        stopWait();
+                        $('[moe-video-player]')
+                            .attr('src', data.video)
+                            .removeClass('d-none')
+                            .addClass('d-block');
+                        $('[moe-video-canvas]')
+                            .removeClass('d-block')
+                            .addClass('d-none');
+                        $('[moe-start-recording]').show();
+                        $('[moe-stop-recording]').hide();
+                        $('[moe-play-recording]').show();
+                        $('[moe-restart-recording]').hide();
+                        $('[moe-submit-recording]').show();
+                        $('[moe-close-popup]').show();
+                    }
+                });
+            });
+        });
+
+        $(document).off('click.moe-play-recording', '[moe-video-backdrop] [moe-play-recording]')
+        $(document).on('click.moe-play-recording', '[moe-video-backdrop] [moe-play-recording]', function() {
+            $('[moe-video-player]')[0].play();
+        });
+
+        $(document).off('click.moe-submit-recording', '[moe-video-backdrop] [moe-submit-recording]')
+        $(document).on('click.moe-submit-recording', '[moe-video-backdrop] [moe-submit-recording]', function() {
+
+            //TODO: remove closest('body')
+            var toProUid = $('[moe-submit-recording]').closest('body').find('[to-pro-uid]').attr('to-pro-uid');
+            var regardingClientUid = $('[moe-submit-recording]').closest('body').find('[regarding-client-uid]').attr('regarding-client-uid');
+
+            if(uploadOrRecord === 'upload') {
+
+                startWait();
+                let formData = new FormData();
+                formData.set('regardingClientUid', regardingClientUid);
+                formData.set('contentText', '');
+                let videoInput = $('[moe-submit-recording]').closest('body').find('[moe-video-file]').first()[0];
+                formData.append('messageVideo', videoInput.files[0]);
+
+                $.ajax('/api/internalMessage/create', {
+                    dataType: 'json',
+                    data: formData,
+                    processData: false,
+                    contentType: false,
+                    type: 'POST',
+                }).done(function (_data) {
+                    if(!hasResponseError(_data)) {
+                        window.top.location.reload();
+                    }
+                });
+                return false;
+
+            }
+            else if(uploadOrRecord === 'record') {
+                startWait();
+                $.post('/save-archive', {
+                    archiveID: moeArchiveID,
+                    toProUid: toProUid,
+                    regardingClientUid: regardingClientUid,
+                    contentText: '',
+                }, function(_data) {
+                    stopWait();
+                    if(!!moeOTSession) {
+                        moeOTSession.disconnect();
+                        moeOTSession = false;
+                        moePublisher = false;
+                    }
+                    // $('[moe-video-backdrop]').css('display', 'none');
+                    window.top.location.reload();
+                }, 'json');
+            }
+
+            return false;
+        });
+
+        $(document).off('click.moe-close-popup', '[moe-video-backdrop] [moe-close-popup]')
+        $(document).on('click.moe-close-popup', '[moe-video-backdrop] [moe-close-popup]', function() {
+            if(!!moeOTSession) {
+                moeOTSession.disconnect();
+                moeOTSession = false;
+                moePublisher = false;
+            }
+            $('[moe-video-backdrop]').css('display', 'none');
+        });
+
+        $(document).off('change.input-file', '[moe-video] .input-file')
+        $(document).on('change.input-file', '[moe-video] .input-file', function() {
+            $(this).closest('form').submit();
+        });
+    }
+
+    function initVideoPopup() {
+        $('[moe-video-backdrop]').remove();
+        $('<div moe-video-backdrop>' +
+            '<div class="card mcp-theme-1">' +
+                '<div class="card-header py-2">' +
+                    '<div class="card-title text-center"></div>' +
+                '</div>' +
+                '<div class="card-body">' +
+                    '<div class="upload-or-record">' +
+                        '<div class="d-flex align-items-center justify-content-center">' +
+                            '<button type="button" class="btn btn-primary font-weight-bold mr-3 position-relative overflow-hidden" moe-choose-upload>' +
+                                '<i class="fa fa-file-upload mr-2"></i>Upload' +
+                                '<input type="file" moe-video-file class="position-absolute opacity-0x" style="bottom: 0; right: 0">' +
+                            '</button>' +
+                            '<button type="button" class="btn btn-primary font-weight-bold" moe-choose-record>' +
+                                '<i class="fa fa-video mr-2"></i>Record' +
+                            '</button>' +
+                        '</div>' +
+                    '</div>' +
+                    '<div class="access-information d-none">' +
+                        '<p>Setting up camera and microphone access...</p>' +
+                        '<p class="click-allow d-none">Click "Allow" to allow hardware access and begin the recording</p>' +
+                        '<div class="denied d-none">' +
+                            '<p class="text-danger"><b>It appears we do not have access to your input hardware.</b></p>' +
+                            '<p class="font-weight-normal">Please allow access to your hardware by clicking the ' +
+                                '<img class="border border-dark" src="/img/denied.png" alt=""> icon ' +
+                                'towards the far right of the browser address bar. <b>Once done, please click <a href="">here</a> to refresh the page and retry.</b></p>' +
+                        '</div>' +
+                    '</div>' +
+                    '<div moe-video-canvas class="d-none"></div>' +
+                    '<video moe-video-player class="d-none" controls><source src="" type="video/mp4"></video>' +
+                '</div>' +
+                '<div class="card-footer">' +
+                    '<div class="d-flex align-items-center justify-content-center">' +
+                        '<button type="button" class="btn btn-danger btn-sm mr-3" moe-start-recording style="display: none">' +
+                            '<i class="fa fa-video mr-2"></i>Record' +
+                        '</button>' +
+                        '<button type="button" class="btn btn-danger btn-sm mr-3" moe-stop-recording style="display: none">' +
+                            '<i class="fa fa-stop mr-2"></i>Stop' +
+                        '</button>' +
+                        '<button type="button" class="btn btn-info btn-sm mr-3" moe-play-recording style="display: none">' +
+                            '<i class="fa fa-play mr-2"></i>Play' +
+                        '</button>' +
+                        '<button type="button" class="btn btn-info btn-sm mr-3" moe-restart-recording style="display: none">' +
+                            '<i class="fa fa-play mr-2"></i>Play' +
+                        '</button>' +
+                        '<button type="button" class="btn btn-success btn-sm mr-3" moe-submit-recording style="display: none">' +
+                            '<i class="fa fa-check mr-2"></i>Submit' +
+                        '</button>' +
+                        '<button type="button" class="btn btn-default bg-light border border-default btn-sm mr-3" moe-close-popup style="display: none">' +
+                            '<i class="fa fa-times mr-2"></i>Close' +
+                        '</button>' +
+                        '<span moe-wait>Please wait...</span>' +
+                    '</div>' +
+                '</div>' +
+            '</div>' +
+        '</div>').appendTo('body');
+    }
+
+    function startWait() {
+        $('[moe-start-recording]').hide();
+        $('[moe-stop-recording]').hide();
+        $('[moe-play-recording]').hide();
+        $('[moe-restart-recording]').hide();
+        $('[moe-submit-recording]').hide();
+        $('[moe-close-popup]').hide();
+        $('[moe-wait]').show();
+        $('[moe-video-progress]').removeClass('d-none').addClass('d-flex');
+    }
+
+    function stopWait() {
+        $('[moe-wait]').hide();
+        $('[moe-video-progress]').removeClass('d-flex').addClass('d-none');
+    }
+
+    // init ot, publisher and show cam feed
+    function initCamera(_moeVideoElement) {
+
+        startWait();
+
+        $('.upload-or-record').removeClass('d-block').addClass('d-none');
+        $('.access-information').removeClass('d-none').addClass('d-block');
+
+        // cleanup
+        if(!!moeOTSession) {
+            moeOTSession.disconnect();
+            moeOTSession = false;
+            moePublisher = false;
+        }
+
+        // create session (using sessionID given by the server)
+        moeOTSession = OT.initSession(
+            _moeVideoElement.attr('moe-ot-api-key'),
+            _moeVideoElement.attr('moe-ot-session-id')
+        );
+
+        // init publisher
+        moePublisher = OT.initPublisher(
+            $('[moe-video-canvas]').first()[0],
+            {
+                //videoSource: 'screen', // comment out in prod
+                width: 540
+            }
+        );
+
+        // publisher events
+        moePublisher.on({
+            accessAllowed: function (event) {
+                // The user has granted access to the camera and mic.
+                console.log('ALIX: accessAllowed');
+                $('.access-information').removeClass('d-block').addClass('d-none');
+                $('[moe-video-canvas]').removeClass('d-none').addClass('d-block');
+                stopWait()
+            },
+            accessDenied: function accessDeniedHandler(event) {
+                // The user has denied access to the camera and mic.
+                console.log('ALIX: accessDenied');
+                $('.click-allow').addClass('d-none');
+                $('.denied').removeClass('d-none');
+                stopWait()
+            },
+            accessDialogOpened: function (event) {
+                // The Allow/Deny dialog box is opened.
+                console.log('ALIX: accessDialogOpened');
+                $('.click-allow').removeClass('d-none');
+                stopWait()
+            },
+            accessDialogClosed: function (event) {
+                // The Allow/Deny dialog box is closed.
+                console.log('ALIX: accessDialogClosed');
+                stopWait()
+            }
+        });
+
+        // OT events
+        moeOTSession.connect(_moeVideoElement.attr('moe-ot-client-token'), function(error) {
+            if (error) {
+                console.error('Failed to connect', error);
+            } else {
+                moeOTSession.publish(moePublisher, function(error) {
+                    if (error) {
+                        console.error('Failed to publish', error);
+                    }
+                    else {
+                        stopWait();
+                        $('[moe-start-recording]').show();
+                        $('[moe-stop-recording]').hide();
+                        $('[moe-play-recording]').hide();
+                        $('[moe-restart-recording]').hide();
+                        $('[moe-submit-recording]').hide();
+                        $('[moe-close-popup]').show();
+                    }
+                });
+            }
+        });
+        moeOTSession.on('archiveStarted', function(event) {
+            stopWait();
+            moeArchiveID = event.id;
+            $('[moe-start-recording]').hide();
+            $('[moe-stop-recording]').show();
+            $('[moe-play-recording]').hide();
+            $('[moe-restart-recording]').hide();
+            $('[moe-submit-recording]').hide();
+            $('[moe-close-popup]').hide();
+        });
+        moeOTSession.on('archiveStopped', function() {
+            // moeArchiveID = false;
+            // stopWait();
+            // $('[moe-start-recording]').show();
+            // $('[moe-stop-recording]').hide();
+            // $('[moe-play-recording]').hide();
+            // $('[moe-restart-recording]').hide();
+            // $('[moe-submit-recording]').hide();
+            // $('[moe-close-popup]').show();
+        });
+    }
+
+    $(document).ready(function() {
+        init();
+    });
+})();

+ 123 - 0
resources/views/app/messages/index.blade.php

@@ -0,0 +1,123 @@
+@extends ('layouts/template')
+
+@section('content')
+
+    <link href="/select2/select2.min.css" rel="stylesheet" />
+    <script src="/select2/select2.min.js"></script>
+
+    <div class="p-3 mcp-theme-1 im-body" id="messages">
+        <div class="card overflow-hidden h-100">
+
+            <div class="card-header px-3 py-2 d-flex align-items-center border-bottom-0">
+                <strong class="">
+                    <i class="fas fa-building"></i>
+                    Messages
+                </strong>
+                <div class="ml-3">
+                    <div moe relative>
+                        <a start show class="font-weight-bold">+ New</a>
+                        <form url="/api/internalMessage/create" class="mcp-theme-1" redir="/messages?m=[data]">
+                            <input type="hidden" name="fromProUid" value="{{$performer->pro->uid}}">
+                            <div class="mb-2">
+                                <label class="text-secondary text-sm mb-1">Regarding Pro</label>
+                                <div>
+                                    <select name="regardingClientUid"  class="form-control">
+                                        <option value="">--select--</option>
+                                    </select>
+                                </div>
+                            </div>
+                            <div class="mb-2">
+                                <label class="text-secondary text-sm mb-1">Content</label>
+                                <textarea name="contentText" class="form-control form-control-sm"></textarea>
+                            </div>
+
+                            <div>
+                                <button submit class="btn btn-sm btn-primary mr-1">Submit</button>
+                                <button cancel class="btn btn-sm btn-default border">Cancel</button>
+                            </div>
+                        </form>
+                    </div>
+                </div>
+                @if($performer->pro->id === 1 || $performer->pro->id === 16 || $performer->pro->id === 2)
+                    <div class="ml-3">
+                        <div moe large relative>
+                            <a start show class="font-weight-bold">+ Send Bulk</a>
+                            <form url="/api/internalMessage/bulkMessage" class="mcp-theme-1" redir="/messages?m=[data]">
+                                <input type="hidden" name="fromProUid" value="{{$performer->pro->uid}}">
+                                <div class="mb-2">
+                                    <label class="text-secondary text-sm mb-1">From Pro</label>
+                                    <div>
+                                        <select name="sendFromProUid"  class="form-control">
+                                            <option value="">--select--</option>
+                                        </select>
+                                    </div>
+                                </div>
+                                <div class="mb-2">
+                                    <label class="text-secondary text-sm mb-1">To Pro UIDs</label>
+                                    <textarea name="toProUids" class="form-control form-control-sm"></textarea>
+                                </div>
+
+                                <div class="mb-2">
+                                    <label class="text-secondary text-sm mb-1">Message Template</label>
+                                    <textarea name="messageTemplate" class="form-control form-control-sm"></textarea>
+                                </div>
+
+                                <div>
+                                    <button submit class="btn btn-sm btn-primary mr-1">Submit</button>
+                                    <button cancel class="btn btn-sm btn-default border">Cancel</button>
+                                </div>
+                            </form>
+                        </div>
+                    </div>
+                @endif
+            </div>
+            <div class="card-body p-0 h-100">
+                <div class="im-container border-top h-100">
+                    <div class="im-lhs bg-light">
+                        @if($conversations && count($conversations))
+                            @foreach($conversations as $conversation)
+                                <a class="im-conversation d-block {{$regardingClient && $regardingClient->id === $conversation->regarding_client_id ? 'active' : ''}}"
+                                   href="/messages?r={{$conversation->regarding_client_id}}">
+                                    <span class="position-relative d-block">
+                                        <span class="d-block font-weight-bold">
+                                            <span class="text-secondary font-weight-normal">Regarding </span>{{ $conversation->regarding_client_name }}
+                                            @if($conversation->num_unread)
+                                                <span class="unread-badge" data-regarding-client-id="{{$conversation->regarding_client_id}}">{{$conversation->num_unread}}</span>
+                                            @endif
+                                        </span>
+                                        <div class="my-1 d-flex align-items-center">
+                                            <span class="text-secondary text-sm mr-1">{{$conversation->from_pro_short_name}}: </span>
+                                            <span class="text-nowrap overflow-hidden text-ellipsis text-dark flex-grow-1">{{$conversation->content_text}}</span>
+                                            @if($conversation->num_attachments)
+                                                <span class="ml-2 text-sm text-nowrap">
+                                                    <i class="fa fa-paperclip"></i>
+                                                    {{$conversation->num_attachments}}
+                                                </span>
+                                            @endif
+                                        </div>
+                                        <span class="d-block text-secondary text-sm">{{friendlier_date_time_in_est($conversation->created_at)}}</span>
+                                        {{-- NOTE: we dont care about hrm2 side is_read for now --}}
+                                        {{--@if(!$conversation->is_read && $conversation->to_pro_id === $performer->pro_id)
+                                            <span class="d-block unread-conversation"><i class="fa fa-circle text-info"></i></span>
+                                        @endif--}}
+                                    </span>
+                                </a>
+                            @endforeach
+                        @else
+                            <div class="p-3 text-secondary text-center">No messages yet!</div>
+                        @endif
+                    </div>
+                    <div class="im-rhs">
+                        @if($regardingClient)
+                            <div class="im-messages-header">
+                                Regarding <a href=""><b>{{$regardingClient->name_first}} {{$regardingClient->name_last}}</b></a>
+                            </div>
+                        @endif
+                        @include('app.messages.rhs')
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    @include('app.messages.script')
+@endsection

+ 19 - 0
resources/views/app/messages/markAsIntroVideoMessageFromHrRep.blade.php

@@ -0,0 +1,19 @@
+<?php
+
+use App\Models\Pro;
+
+$offerCallReps = @$adminPros ?: Pro::where('pro_type', 'ADMIN')->get();//Pro::where('is_offer_call_rep', true)->get();
+?>
+<div moe>
+	<a start show>Mark As Intro Video Message From HR Rep</a>
+	<form url="/api/internalMessage/markAsIntroVideoMessageFromHrRep">
+		<input type="hidden" name="uid" value="{{$internalMessage->uid}}">
+		<div class="form-group">
+			<label class="control-label">Are you sure?</label>
+		</div>
+		<div class="d-flex align-items-center">
+			<button class="btn btn-sm btn-primary mr-2" submit>Save</button>
+			<button class="btn btn-sm btn-default mr-2 border" cancel>Cancel</button>
+		</div>
+	</form>
+</div>

+ 130 - 0
resources/views/app/messages/proofread.blade.php

@@ -0,0 +1,130 @@
+<div class="im-body inline py-3" id="messages">
+    <div class="im-container border-top h-100">
+        <div class="im-rhs py-0 inline w-100">
+            @if($regardingClient)
+                @if(!$messages || !count($messages))
+                    <div class="py-3 text-secondary">No messages yet!</div>
+                @endif
+                <?php $editMessage = null; ?>
+                <div class="im-messages">
+                    <div class="mh-100 overflow-auto" id="im-scroller">
+                        @foreach($messages as $message)
+                            <?php if(!$editMessage) {
+                                $editMessage = ($message->id === +(request()->input('i')) ? $message : null);
+                            } ?>
+                            <div class="im-message {{$message->from_pro_id === $performer->pro->id ? 'sent' : 'received'}} {{$message->id === +(request()->input('i')) ? 'proofreading' : ''}}"
+                                 data-uid="{{$message->uid}}"
+                                 data-mark-as-read="{{$message->is_from_shadow_client === true && $message->is_to_shadow_client === false && !$message->is_read ? 1 : 0}}">
+                                <div class="im-message-sender align-items-center">
+                                    @if(!$message->is_removed)
+                                        @if($message->is_from_shadow_client)
+                                            <i class="fa fa-user-nurse mr-1 text-sm"></i>
+                                        @endif
+                                        <span class="header-item text-secondary text-sm font-weight-bold">{{$message->from_name}}</span>
+                                        <span class="header-item text-secondary text-sm">{{friendlier_date_time_in_est($message->created_at)}}</span>
+                                    @endif
+                                </div>
+                                @if($message->is_removed)
+                                    <div class="im-message-content text-secondary font-italic">This message was
+                                        removed.
+                                    </div>
+                                @else
+                                    @if($message->content_text)
+                                        <?php
+                                        $message->content_text = preg_replace_callback(
+                                            '~(?:http|ftp)s?://(?:www\.)?([a-z0-9.-]+\.[a-z]{2,3}(?:/\S*)?)~i',
+                                            function ($match) {
+                                                return '<a native target="_blank" href="' . $match[0] . '">' . $match[0] . '</a>';
+                                            }, $message->content_text);
+                                        ?>
+                                        <div class="im-message-content">{!! $message->content_text !!}</div>
+                                    @endif
+                                @endif
+                            </div>
+                        @endforeach
+                    </div>
+                </div>
+                <hr class="m-neg-4">
+                <div class="im-input pr-input">
+                    <div class="d-flex align-items-end">
+                        <div class="flex-grow-1">
+                            <label for="">Edit Message</label>
+                            <textarea class="form-control form-control-sm rounded proofreading"
+                                      placeholder="Edit Message...">{!! $editMessage->content_text !!}</textarea>
+                        </div>
+                        <div class="flex-grow-1 ml-3">
+                            <label for="">Proofreader Memo</label>
+                            <textarea class="form-control form-control-sm rounded memo"
+                                      placeholder="Proofreader Memo (optional)">{!! $editMessage->proofreader_memo !!}</textarea>
+                        </div>
+                    </div>
+                </div>
+            @else
+                <div class="text-secondary">Nothing to show here!</div>
+            @endif
+        </div>
+    </div>
+</div>
+<script>
+    (function() {
+        let inProgress = false;
+        function hasError(_data) {
+            let msg = 'Unknown error!';
+            if (_data) {
+                if (_data.success) return false;
+                else if (_data.message) msg = _data.message;
+            }
+            toastr.error(msg);
+            return true;
+        }
+
+        function save(_e) {
+            if(_e.which === 13 && !_e.shiftKey) {
+                if(inProgress) return false;
+                inProgress = true;
+                showMask();
+                let text = $.trim($('.im-input textarea.proofreading').val()),
+                    memo = $.trim($('.im-input textarea.memo').val());
+
+                if(!text) { // either attachment or text or both should be there
+                    inProgress = false;
+                    hideMask();
+                    return false;
+                }
+
+                $.post('/api/internalMessage/edit', {
+                    uid: '{{$editMessage->uid}}',
+                    contentText: text,
+                    proofreaderMemo: memo,
+                }, function (_data) {
+                    if(!hasError(_data)) {
+                        closeStagPopupAndRefreshParent();
+                    }
+                    else {
+                        hideMask();
+                        inProgress = false;
+                    }
+                }, 'json');
+                return false;
+            }
+        }
+
+        function init() {
+
+            $('.im-input textarea.proofreading, .im-input textarea.memo')
+                .off('keydown')
+                .on('keydown', function (_e) {
+                    save(_e);
+                });
+
+            if($('.im-message.proofreading').length) {
+                $('.im-message.proofreading').first()[0].scrollIntoView({
+                    behavior: "auto",
+                    block: "end",
+                    inline: "end"
+                });
+            }
+        }
+        addMCInitializer('message-proofread', init)
+    }).call(window);
+</script>

+ 135 - 0
resources/views/app/messages/rhs.blade.php

@@ -0,0 +1,135 @@
+@if($regardingClient)
+    @if(!$messages || !count($messages))
+        <div class="py-3 text-secondary">No messages yet!</div>
+    @endif
+    <div class="im-messages">
+        <div class="mh-100 overflow-auto opacity-0" id="im-scroller">
+            @foreach($messages as $message)
+                <div class="im-message {{$message->from_pro_id === $performer->pro->id ? 'sent' : 'received'}}"
+                     data-uid="{{$message->uid}}"
+                     data-mark-as-read="{{$message->is_from_shadow_client === true && $message->is_to_shadow_client === false && !$message->is_read ? 1 : 0}}">
+                    <div class="im-message-sender align-items-center">
+                        @if(!$message->is_removed)
+                            @if($message->is_from_shadow_client)
+                                <i class="fa fa-user-nurse mr-1 text-sm"></i>
+                            @endif
+                            @if($message->from_pro_id === $performer->pro->id)
+                                @if($message->original_content_text !== $message->content_text)
+                                    <div class="header-item on-hover-opaque pr-comparison-trigger">
+                                        <i class="fa fa-pencil-alt text-sm"></i>
+                                        <div class="pr-comparison text-sm">
+                                            <b class="text-secondary">Original:</b>
+                                            <div class="pre-wrap">{{$message->original_content_text}}</div>
+                                        </div>
+                                    </div>
+                                @endif
+                            @endif
+                            <span class="header-item text-secondary text-sm font-weight-bold">{{$message->from_name}}</span>
+                            <span class="header-item text-secondary text-sm">{{friendlier_date_time_in_est($message->created_at)}}</span>
+                            @if($message->is_from_shadow_client)
+                                <span class="header-item text-secondary">
+                                                        @if(!$message->is_read)
+                                        <a href="#" native class="mark-as-read" data-message-uid="{{$message->uid}}"><i class="fa fa-circle text-primary text-sm"></i></a>
+                                    @else
+                                        <i class="fa fa-check text-secondary on-hover-opaque text-sm"></i>
+                                    @endif
+                                                    </span>
+                            @endif
+                            @if($message->is_to_shadow_client && $performer->pro->can_proofread)
+                                <div class="header-item">
+                                    <div moe large relative>
+                                        <a href="#" start show><i class="fa fa-edit on-hover-opaque text-sm"></i></a>
+                                        <form url="/api/internalMessage/edit" right>
+                                            <input type="hidden" name="uid" value="{{$message->uid}}">
+                                            <div class="mb-2">
+                                                <label class="mb-1 text-sm">Content</label>
+                                                <textarea name="contentText" rows="3" class="form-control form-control-sm">{{$message->content_text}}</textarea>
+                                            </div>
+                                            <div class="mt-3">
+                                                <button submit class="btn btn-sm btn-primary mr-2">Submit</button>
+                                                <button cancel class="btn btn-default border">Cancel</button>
+                                            </div>
+                                        </form>
+                                    </div>
+                                </div>
+                            @endif
+                            @if($message->from_pro_id === $performer->pro->id)
+                                <span class="header-item">
+                                                            <a href="#" native class="remove-message" data-message-uid="{{$message->uid}}"><i class="fa fa-trash-alt on-hover-opaque text-danger text-sm"></i></a>
+                                                        </span>
+                            @endif
+                            @if($message->from_pro_id === $performer->pro->id)
+                                @if(!$message->is_cleared)
+                                    <span class="header-item" title="Pending proof reading.">
+                                                                <i class="fa fa-glasses text-warning-mellow"></i>
+                                                            </span>
+                                @else
+                                    <span class="header-item" title="">
+                                                                <i class="fa fa-glasses text-success"></i>
+                                                            </span>
+                                @endif
+                            @endif
+                        @endif
+                    </div>
+                    @if($message->is_removed)
+                        <div class="im-message-content text-secondary font-italic">This message was removed.</div>
+                    @else
+                        @if($message->content_text)
+                            <?php
+                            $message->content_text = preg_replace_callback(
+                                '~(?:http|ftp)s?://(?:www\.)?([a-z0-9.-]+\.[a-z]{2,3}(?:/\S*)?)~i',
+                                function ($match) {
+                                    return '<a native target="_blank" href="' . $match[0] . '">' . $match[0] . '</a>';
+                                }, $message->content_text);
+                            ?>
+                            <div class="im-message-content">{!! $message->content_text !!}</div>
+                        @endif
+                        @if($message->message_video_file_id)
+                            <div class="im-video-container">
+                                <video src="{{route('serve-system-file', ['uid'=>$message->message_video_file_system_file_uid])}}" controls playsinline></video>
+                            </div>
+                        @endif
+                    @endif
+                    @if($message->num_attachments && !$message->is_removed)
+                        <div class="attachments-container mt-1 d-flex align-items-center flex-wrap {{$message->from_pro_id === $performer->pro->id ? 'justify-content-end' : 'justify-content-start'}}"
+                             data-message-uid="{{$message->uid}}" data-attachments-loaded="0">
+                            <span class="my-1 text-primary c-pointer text-sm">
+                                <i class="fa fa-paperclip"></i>
+                                {{$message->num_attachments}} attachment{{$message->num_attachments === 1 ? '' : 's'}}
+                            </span>
+                        </div>
+                    @endif
+                </div>
+            @endforeach
+        </div>
+    </div>
+    <div class="im-input">
+        <div class="d-flex align-items-end">
+            <textarea class="form-control form-control-sm rounded" placeholder="Enter your message here..."></textarea>
+            <div class="d-flex flex-column">
+                <button class="btn btn-sm btn-info ml-2 mb-1" id="im-btn-select-file"><i class="fa fa-paperclip"></i></button>
+                <div moe-video
+                     moe-csrf="{{ csrf_token() }}"
+                     moe-question-key="hcp_intro"
+                     moe-video-url="{{route('video-test')}}"
+                     moe-ot-api-key="{{ config('app.opentokApiKey') }}"
+                     moe-ot-session-id="{{ $otSessionId }}"
+                     moe-ot-client-token="{{ $otToken }}"
+                     data-title="Upload or Record Video Message"
+                     @if(isset($toPro))
+                     to-pro-uid="{{$toPro->uid}}"
+                     @endif
+                     @if(isset($regardingClient))
+                     regarding-client-uid="{{$regardingClient->uid}}"
+                     @endif>
+                </div>
+            </div>
+        </div>
+        <div class="d-flex align-items-end flex-wrap" id="selected-files">
+        </div>
+    </div>
+@else
+    <div class="text-secondary">Nothing to show here!</div>
+@endif
+<script src="https://static.opentok.com/v2/js/opentok.min.js"></script>
+<script src="moe-video/moe-video.js"></script>

+ 194 - 0
resources/views/app/messages/script.blade.php

@@ -0,0 +1,194 @@
+<script>
+    (function() {
+        let inProgress = false;
+        function showSelectedFiles() {
+            $('#selected-files').empty();
+            $('.im-file-upload').each(function() {
+                if(this.files && this.files.length) {
+                    for (let i = 0; i < this.files.length; i++) {
+                        $('#selected-files').append($('<div class="selected-file" data-id="' + this.id + '" title="Click to remove">').text(this.files[i].name));
+                    }
+                }
+            });
+        }
+        function hasError(_data) {
+            let msg = 'Unknown error!';
+            if (_data) {
+                if (_data.success) return false;
+                else if (_data.message) msg = _data.message;
+            }
+            toastr.error(msg);
+            return true;
+        }
+        function init() {
+
+            let imScroller = document.getElementById("im-scroller");
+
+            @if($regardingClient)
+            $('.im-input textarea').on('keydown', function(_e) {
+                if(_e.which === 13 && !_e.shiftKey) {
+                    if(inProgress) return false;
+                    inProgress = true;
+                    showMask();
+                    let text = $.trim(this.value);
+                    let formData = new FormData();
+                    formData.set('fromProUid', '{{$performer->pro->uid}}');
+                    formData.set('regardingClientUid', '{{$regardingClient->uid}}');
+                    formData.set('contentText', text);
+
+                    let hasFiles = false;
+                    $('.im-file-upload').each(function() {
+                        if(this.files && this.files.length) {
+                            for (let i = 0; i < this.files.length; i++) {
+                                formData.append('attachments', this.files[i]);
+                                hasFiles = true;
+                            }
+                        }
+                    });
+
+                    if(!hasFiles && !text) { // either attachment or text or both should be there
+                        inProgress = false;
+                        hideMask();
+                        return false;
+                    }
+
+                    jQuery.ajax('/api/internalMessage/create', {
+                        dataType: 'json',
+                        data: formData,
+                        processData: false,
+                        contentType: false,
+                        type: 'POST',
+                    }).done(function (_data) {
+                        if(!hasError(_data)) {
+                            // fastLoad('/messages?r={{$regardingClient->id}}');
+                            $.get('/messages/thread?r={{$regardingClient->id}}', _data => {
+                                $('#im-scroller').html(_data);
+                                imScroller.scrollTop = imScroller.scrollHeight;
+                                initMoes();
+                                $('.im-input textarea').val('');
+                                hideMask();
+                                inProgress = false;
+                            });
+                        }
+                        else {
+                            $('.im-input textarea').val('');
+                            hideMask();
+                            inProgress = false;
+                        }
+                    });
+                    return false;
+                }
+            });
+
+            $('#im-btn-select-file').click(function () {
+                let fiID = Math.floor(Math.random() * 10000);
+                let fileInput = $('<input type="file" class="d-none im-file-upload" id="fu-' + fiID + '">');
+                $('.im-input').append(fileInput)
+                fileInput.click();
+            });
+
+            $(document).on('change', '.im-file-upload', function() {
+                showSelectedFiles();
+            });
+
+            $(document)
+                .off('click', '.selected-file')
+                .on('click', '.selected-file', function() {
+                    $('#' + $(this).attr('data-id')).remove();
+                    showSelectedFiles();
+                });
+
+            $(document)
+                .off('click.load-attachments', '.attachments-container[data-attachments-loaded="0"]>span')
+                .on('click.load-attachments', '.attachments-container[data-attachments-loaded="0"]>span', function() {
+                    let container = $(this).closest('.attachments-container');
+                    if(inProgress) return false;
+                    inProgress = true;
+                    showMask();
+                    $.get('/messages/' + container.attr('data-message-uid') + '/attachments', (_data) => {
+                        container.html(_data).attr('data-attachments-loaded', 1);
+                    }).then(function() {
+                        inProgress = false;
+                        hideMask();
+                    });
+                });
+
+            $(document)
+                .off('click', '.mark-as-read')
+                .on('click', '.mark-as-read', function() {
+                    $.post('/api/internalMessage/markRead', {
+                        uid: $(this).attr('data-message-uid')
+                    }, () => {
+                        $(this).replaceWith('<i class="fa fa-check text-secondary on-hover-opaque text-sm"></i>');
+                        let unreadBadge = $('.unread-badge[data-regarding-client-id="' + {{$regardingClient->id}} + '"]').first();
+                        if(unreadBadge.length) {
+                            let newCount = (+unreadBadge.text()) - 1;
+                            if(newCount) {
+                                unreadBadge.text(newCount);
+                            }
+                            else {
+                                unreadBadge.remove();
+                            }
+                        }
+                    }, 'json');
+                    return false;
+                });
+
+            $(document)
+                .off('click', '.remove-message')
+                .on('click', '.remove-message', function() {
+                    $.post('/api/internalMessage/remove', {
+                        uid: $(this).attr('data-message-uid')
+                    }, () => {
+                        $(this).closest('.im-message').html('<div class="im-message-content text-secondary font-italic">This message was removed.</div>');
+                    }, 'json');
+                    return false;
+                });
+
+            imScroller.scrollTop = imScroller.scrollHeight;
+            $(imScroller).removeClass('opacity-0');
+
+            @endif
+            if($('.im-lhs .im-conversation.active').length) {
+                $('.im-lhs .im-conversation.active')[0].scrollIntoView();
+            }
+
+            $('select[name="regardingClientUid"]').select2({
+                width: '100%',
+                ajax: {
+                    url: '/messages/clients',
+                    dataType: 'json',
+                    data: function (params) {
+                        return {
+                            term: params.term,
+                            json: true
+                        };
+                    }
+                },
+            });
+
+            $('select[name="sendFromProUid"]').select2({
+                width: '100%',
+                ajax: {
+                    url: '/messages/send-from-pros',
+                    dataType: 'json',
+                    data: function (params) {
+                        return {
+                            term: params.term,
+                            json: true
+                        };
+                    }
+                },
+            });
+
+            @if($regardingClient)
+            $('.im-message[data-mark-as-read="1"]').each(function() {
+                $.post('/api/internalMessage/markRead', {
+                    uid: $(this).attr('data-uid')
+                }, function() {}, 'json');
+            });
+            @endif
+        }
+        addMCInitializer('messages', init, '#messages');
+    }).call(window);
+</script>

+ 92 - 0
resources/views/app/messages/thread.blade.php

@@ -0,0 +1,92 @@
+@foreach($messages as $message)
+    <div class="im-message {{$message->from_pro_id === $performer->pro->id ? 'sent' : 'received'}}"
+         data-uid="{{$message->uid}}"
+         data-mark-as-read="{{$message->is_from_shadow_client === true && $message->is_to_shadow_client === false && !$message->is_read ? 1 : 0}}">
+        <div class="im-message-sender align-items-center">
+            @if(!$message->is_removed)
+                @if($message->is_from_shadow_client)
+                    <i class="fa fa-user-nurse mr-1 text-sm"></i>
+                @endif
+                @if($message->from_pro_id === $performer->pro->id)
+                    @if($message->original_content_text !== $message->content_text)
+                        <div class="header-item on-hover-opaque pr-comparison-trigger">
+                            <i class="fa fa-pencil-alt text-sm"></i>
+                            <div class="pr-comparison text-sm">
+                                <b class="text-secondary">Original:</b>
+                                <div class="pre-wrap">{{$message->original_content_text}}</div>
+                            </div>
+                        </div>
+                    @endif
+                @endif
+                <span class="header-item text-secondary text-sm font-weight-bold">{{$message->from_name}}</span>
+                <span class="header-item text-secondary text-sm">{{friendlier_date_time_in_est($message->created_at)}}</span>
+                @if($message->is_from_shadow_client)
+                    <span class="header-item text-secondary">
+                                                        @if(!$message->is_read)
+                            <a href="#" native class="mark-as-read" data-message-uid="{{$message->uid}}"><i class="fa fa-circle text-primary text-sm"></i></a>
+                        @else
+                            <i class="fa fa-check text-secondary on-hover-opaque text-sm"></i>
+                        @endif
+                                                    </span>
+                @endif
+                @if($message->is_to_shadow_client && $performer->pro->can_proofread)
+                    <div class="header-item">
+                        <div moe large relative>
+                            <a href="#" start show><i class="fa fa-edit on-hover-opaque text-sm"></i></a>
+                            <form url="/api/internalMessage/edit" right>
+                                <input type="hidden" name="uid" value="{{$message->uid}}">
+                                <div class="mb-2">
+                                    <label class="mb-1 text-sm">Content</label>
+                                    <textarea name="contentText" rows="3" class="form-control form-control-sm">{{$message->content_text}}</textarea>
+                                </div>
+                                <div class="mt-3">
+                                    <button submit class="btn btn-sm btn-primary mr-2">Submit</button>
+                                    <button cancel class="btn btn-default border">Cancel</button>
+                                </div>
+                            </form>
+                        </div>
+                    </div>
+                @endif
+                @if($message->from_pro_id === $performer->pro->id)
+                    <span class="header-item">
+                                                            <a href="#" native class="remove-message" data-message-uid="{{$message->uid}}"><i class="fa fa-trash-alt on-hover-opaque text-danger text-sm"></i></a>
+                                                        </span>
+                @endif
+                @if($message->from_pro_id === $performer->pro->id)
+                    @if(!$message->is_cleared)
+                        <span class="header-item" title="Pending proof reading.">
+                                                                <i class="fa fa-glasses text-warning-mellow"></i>
+                                                            </span>
+                    @else
+                        <span class="header-item" title="">
+                                                                <i class="fa fa-glasses text-success"></i>
+                                                            </span>
+                    @endif
+                @endif
+            @endif
+        </div>
+        @if($message->is_removed)
+            <div class="im-message-content text-secondary font-italic">This message was removed.</div>
+        @else
+            @if($message->content_text)
+                <?php
+                $message->content_text = preg_replace_callback(
+                    '~(?:http|ftp)s?://(?:www\.)?([a-z0-9.-]+\.[a-z]{2,3}(?:/\S*)?)~i',
+                    function ($match) {
+                        return '<a native target="_blank" href="' . $match[0] . '">' . $match[0] . '</a>';
+                    }, $message->content_text);
+                ?>
+                <div class="im-message-content">{!! $message->content_text !!}</div>
+            @endif
+        @endif
+        @if($message->num_attachments && !$message->is_removed)
+            <div class="attachments-container mt-1 d-flex align-items-center flex-wrap {{$message->from_pro_id === $performer->pro->id ? 'justify-content-end' : 'justify-content-start'}}"
+                 data-message-uid="{{$message->uid}}" data-attachments-loaded="0">
+                                                    <span class="my-1 text-primary c-pointer text-sm">
+                                                        <i class="fa fa-paperclip"></i>
+                                                        {{$message->num_attachments}} attachment{{$message->num_attachments === 1 ? '' : 's'}}
+                                                    </span>
+            </div>
+        @endif
+    </div>
+@endforeach

+ 19 - 0
resources/views/app/messages/undoMarkAsIntroVideoMessageFromHrRep.blade.php

@@ -0,0 +1,19 @@
+<?php
+
+use App\Models\Pro;
+
+$offerCallReps = Pro::where('is_offer_call_rep', true)->get();
+?>
+<div moe>
+	<a start show>Undo Mark As Intro Video Message From HR Rep</a>
+	<form url="/api/internalMessage/undoMarkAsIntroVideoMessageFromHrRep">
+		<input type="hidden" name="uid" value="{{$internalMessage->uid}}">
+		<div class="form-group">
+			<label class="control-label">Are you sure?</label>
+		</div>
+		<div class="d-flex align-items-center">
+			<button class="btn btn-sm btn-primary mr-2" submit>Save</button>
+			<button class="btn btn-sm btn-default mr-2 border" cancel>Cancel</button>
+		</div>
+	</form>
+</div>

+ 1 - 0
resources/views/layouts/template.blade.php

@@ -99,6 +99,7 @@
             <ul class="navbar-nav mr-auto">
                 <li class="nav-item"><a class="nav-link" href="{{ route('admin.dashboard') }}"><i class="mr-1 fas fa-home"></i> Home</a> </li>
                 <li class="nav-item"><a class="nav-link" href="{{ route('admin.pros') }}"><i class="mr-1 fas fa-user"></i> Pros</a> </li>
+                <li class="nav-item"><a class="nav-link" href="{{ route('messages') }}"><i class="mr-1 fas fa-envelope"></i> Messages</a> </li>
 
                 <li class="nav-item dropdown">
                     <a class="nav-link dropdown-toggle" href="#" id="practice-management" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">

+ 10 - 0
routes/web.php

@@ -518,6 +518,16 @@ Route::middleware('pro.auth')->group(function () {
     Route::get('/search-payer/json', 'PayerController@searchPayerV2JSON')->name('searchPayerV2JSON');
     Route::get('/search-facility/json', 'HomeController@facilitySuggestJSON')->name('facilitySuggestJSON');
 
+
+    Route::get('messages', 'MessageController@index')->name('messages');
+    Route::get('internal-messages', 'InternalMessageController@index')->name('internal-messages');
+    Route::get('messages/clients', 'MessageController@clients')->name('messages-clients');
+    Route::get('messages/send-from-pros', 'MessageController@sendFromPros')->name('messages-send-from-pros');
+    Route::get('messages/proofread', 'MessageController@proofread')->name('messages-proofread');
+    Route::get('messages/thread', 'MessageController@thread')->name('messages-thread');
+    Route::get('messages/{message}/attachments', 'MessageController@attachments')->name('messages-attachments');
+    Route::get("/video-test", 'VideoTestController@index')->name('video-test');
+
 });
 
 Route::post("/process_form_submit", 'NoteController@processFormSubmit')->name('process_form_submit');

Some files were not shown because too many files changed in this diff