diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index fe7159848b..0000000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "python.linting.pylintEnabled": false
-}
\ No newline at end of file
diff --git a/frappe/async.py b/frappe/async.py
index 622a1ad805..15ba8a4af6 100644
--- a/frappe/async.py
+++ b/frappe/async.py
@@ -89,8 +89,10 @@ def publish_realtime(event=None, message=None, room=None,
room = get_user_room(user)
elif doctype and docname:
room = get_doc_room(doctype, docname)
- else:
- room = get_site_room()
+ else:
+ # frappe.chat
+ room = get_chat_room(room)
+ # end frappe.chat
if after_commit:
params = [event, message, room]
@@ -110,7 +112,7 @@ def emit_via_redis(event, message, room):
try:
r.publish('events', frappe.as_json({'event': event, 'message': message, 'room': room}))
except redis.exceptions.ConnectionError:
- # print frappe.get_traceback()
+ # print(frappe.get_traceback())
pass
def put_log(line_no, line, task_id=None):
@@ -194,3 +196,10 @@ def get_site_room():
def get_task_progress_room(task_id):
return "".join([frappe.local.site, ":task_progress:", task_id])
+
+# frappe.chat
+def get_chat_room(room):
+ room = ''.join([frappe.local.site, ":room:", room])
+
+ return room
+# end frappe.chat room
\ No newline at end of file
diff --git a/frappe/build.js b/frappe/build.js
index 103b66fdaa..ae7545f22a 100644
--- a/frappe/build.js
+++ b/frappe/build.js
@@ -154,7 +154,7 @@ function get_compiled_file(file, output_path, minify, force_compile) {
function babelify(content, path, minify) {
let presets = ['env'];
- var plugins = ['transform-object-rest-spread']
+ const plugins = ['transform-object-rest-spread']
// Minification doesn't work when loading Frappe Desk
// Avoid for now, trace the error and come back.
try {
diff --git a/frappe/desk/page/chat/__init__.py b/frappe/chat/README.md
similarity index 100%
rename from frappe/desk/page/chat/__init__.py
rename to frappe/chat/README.md
diff --git a/frappe/chat/__init__.py b/frappe/chat/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/chat/doctype/__init__.py b/frappe/chat/doctype/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/chat/doctype/chat_message/__init__.py b/frappe/chat/doctype/chat_message/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/chat/doctype/chat_message/chat_message.js b/frappe/chat/doctype/chat_message/chat_message.js
new file mode 100644
index 0000000000..03a0aa012e
--- /dev/null
+++ b/frappe/chat/doctype/chat_message/chat_message.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2017, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Chat Message', {
+ refresh: function(frm) {
+
+ }
+});
diff --git a/frappe/chat/doctype/chat_message/chat_message.json b/frappe/chat/doctype/chat_message/chat_message.json
new file mode 100644
index 0000000000..1d3d14dd80
--- /dev/null
+++ b/frappe/chat/doctype/chat_message/chat_message.json
@@ -0,0 +1,245 @@
+{
+ "allow_copy": 0,
+ "allow_guest_to_view": 0,
+ "allow_import": 0,
+ "allow_rename": 0,
+ "beta": 1,
+ "creation": "2017-11-10 11:10:40.011099",
+ "custom": 0,
+ "docstatus": 0,
+ "doctype": "DocType",
+ "document_type": "",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "fields": [
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "type",
+ "fieldtype": "Select",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_standard_filter": 0,
+ "label": "Type",
+ "length": 0,
+ "no_copy": 0,
+ "options": "Direct\nGroup\nVisitor",
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 1,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "user",
+ "fieldtype": "Link",
+ "hidden": 1,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "User",
+ "length": 0,
+ "no_copy": 0,
+ "options": "User",
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 1,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "room",
+ "fieldtype": "Link",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Room",
+ "length": 0,
+ "no_copy": 0,
+ "options": "Chat Room",
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 1,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "content",
+ "fieldtype": "Text",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Content",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 1,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "mentions",
+ "fieldtype": "Code",
+ "hidden": 1,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Mentions",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "urls",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "URLs",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ }
+ ],
+ "has_web_view": 0,
+ "hide_heading": 0,
+ "hide_toolbar": 0,
+ "idx": 0,
+ "image_view": 0,
+ "in_create": 0,
+ "is_submittable": 0,
+ "issingle": 0,
+ "istable": 0,
+ "max_attachments": 0,
+ "modified": "2017-12-28 16:26:24.142473",
+ "modified_by": "achilles@erpnext.com",
+ "module": "Chat",
+ "name": "Chat Message",
+ "name_case": "",
+ "owner": "arjun@gmail.com",
+ "permissions": [
+ {
+ "amend": 0,
+ "apply_user_permissions": 0,
+ "cancel": 0,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "if_owner": 0,
+ "import": 0,
+ "permlevel": 0,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "set_user_permissions": 0,
+ "share": 1,
+ "submit": 0,
+ "write": 1
+ }
+ ],
+ "quick_entry": 1,
+ "read_only": 0,
+ "read_only_onload": 0,
+ "show_name_in_global_search": 0,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1,
+ "track_seen": 1
+}
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_message/chat_message.py b/frappe/chat/doctype/chat_message/chat_message.py
new file mode 100644
index 0000000000..9309c910b2
--- /dev/null
+++ b/frappe/chat/doctype/chat_message/chat_message.py
@@ -0,0 +1,162 @@
+# imports - standard imports
+import json
+
+# imports - third-party imports
+import requests
+from bs4 import BeautifulSoup as Soup
+
+# imports - module imports
+from frappe.model.document import Document
+from frappe import _, _dict
+import frappe
+
+# imports - frappe module imports
+from frappe.chat.util import (
+ get_user_doc,
+ check_url,
+ dictify,
+ get_emojis
+)
+
+session = frappe.session
+
+class ChatMessage(Document):
+ pass
+
+def get_message_urls(content):
+ soup = Soup(content, 'html.parser')
+ anchors = soup.find_all('a')
+ urls = [ ]
+
+ for anchor in anchors:
+ text = anchor.text
+
+ if check_url(text):
+ urls.append(text)
+
+ return urls
+
+def get_message_mentions(content):
+ mentions = [ ]
+ tokens = content.split(' ')
+
+ for token in tokens:
+ if token.startswith('@'):
+ what = token[1:]
+ if frappe.db.exists('User', what):
+ mentions.append(what)
+ else:
+ if frappe.db.exists('User', token):
+ mentions.append(token)
+
+ return mentions
+
+def get_message_meta(content):
+ '''
+ Assumes content to be HTML. Sanitizes the content
+ into a dict of metadata values.
+ '''
+ meta = _dict(
+ links = [ ],
+ mentions = [ ]
+ )
+
+ meta.content = content
+ meta.urls = get_message_urls(content)
+ meta.mentions = get_message_mentions(content)
+
+ return meta
+
+def sanitize_message_content(content):
+ emojis = get_emojis()
+
+ tokens = content.split(' ')
+ for token in tokens:
+ if token.startswith(':') and token.endswith(':'):
+ what = token[1:-1]
+
+ # Expensive, I know.
+ for emoji in emojis:
+ for alias in emoji.aliases:
+ if what == alias:
+ content = content.replace(token, emoji.emoji)
+
+ return content
+
+def get_new_chat_message_doc(user, room, content, link = True):
+ user = get_user_doc(user)
+ room = frappe.get_doc('Chat Room', room)
+
+ meta = get_message_meta(content)
+ mess = frappe.new_doc('Chat Message')
+ mess.type = room.type
+ mess.room = room.name
+ mess.content = sanitize_message_content(content)
+ mess.user = user.name
+
+ mess.mentions = json.dumps(meta.mentions)
+ mess.urls = ','.join(meta.urls)
+ mess.save()
+
+ if link:
+ room.update(dict(
+ last_message = mess.name
+ ))
+ room.save()
+
+ return mess
+
+def get_new_chat_message(user, room, content):
+ mess = get_new_chat_message_doc(user, room, content)
+
+ resp = dict(
+ name = mess.name,
+ user = mess.user,
+ room = mess.room,
+ content = mess.content,
+ urls = mess.urls,
+ mentions = json.loads(mess.mentions),
+ creation = mess.creation,
+ seen = json.loads(mess._seen) if mess._seen else [ ],
+ )
+
+ return resp
+
+@frappe.whitelist()
+def send(user, room, content):
+ mess = get_new_chat_message(user, room, content)
+
+ frappe.publish_realtime('frappe.chat.message:create', mess, room = room,
+ after_commit = True)
+
+@frappe.whitelist()
+def seen(message, user = None):
+ mess = frappe.get_doc('Chat Message', message)
+ mess.add_seen(user)
+
+ room = mess.room
+ resp = dict(message = message, data = dict(seen = json.loads(mess._seen)))
+
+ frappe.publish_realtime('frappe.chat.message:update', resp, room = room, after_commit = True)
+
+# This is fine for now. If you're "ReST"-ing it,
+# make sure you don't let the user see them.
+# Come again, Why are we even passing user?
+def get_messages(room, user = None, fields = None, pagination = 20):
+ user = get_user_doc(user)
+
+ room = frappe.get_doc('Chat Room', room)
+ mess = frappe.get_list('Chat Message',
+ filters = [
+ ('Chat Message', 'room', '=', room.name),
+ ('Chat Message', 'type', '=', room.type)
+ ],
+ fields = fields if fields else [
+ 'name', 'type',
+ 'room', 'content',
+ 'user', 'mentions', 'urls',
+ 'creation'
+ ]
+ )
+
+ return mess
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_message/test_chat_message.js b/frappe/chat/doctype/chat_message/test_chat_message.js
new file mode 100644
index 0000000000..b117f366ce
--- /dev/null
+++ b/frappe/chat/doctype/chat_message/test_chat_message.js
@@ -0,0 +1,23 @@
+/* eslint-disable */
+// rename this file from _test_[name] to test_[name] to activate
+// and remove above this line
+
+QUnit.test("test: Chat Message", function (assert) {
+ let done = assert.async();
+
+ // number of asserts
+ assert.expect(1);
+
+ frappe.run_serially([
+ // insert a new Chat Message
+ () => frappe.tests.make('Chat Message', [
+ // values to be set
+ {key: 'value'}
+ ]),
+ () => {
+ assert.equal(cur_frm.doc.key, 'value');
+ },
+ () => done()
+ ]);
+
+});
diff --git a/frappe/chat/doctype/chat_message/test_chat_message.py b/frappe/chat/doctype/chat_message/test_chat_message.py
new file mode 100644
index 0000000000..fc4e750719
--- /dev/null
+++ b/frappe/chat/doctype/chat_message/test_chat_message.py
@@ -0,0 +1,19 @@
+# imports - standard imports
+import unittest
+
+# imports - module imports
+import frappe
+
+# imports - frappe module imports
+from frappe.chat.doctype.chat_message import chat_message
+from frappe.chat.util import create_test_user
+
+session = frappe.session
+test_user = create_test_user(__name__)
+
+class TestChatMessage(unittest.TestCase):
+ def test_send(self):
+ # TODO - Write the case once you're done with Chat Room
+ # user = test_user
+ # chat_message.send(user, room, 'foobar')
+ pass
diff --git a/frappe/chat/doctype/chat_message_attachment/__init__.py b/frappe/chat/doctype/chat_message_attachment/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/chat/doctype/chat_message_attachment/chat_message_attachment.json b/frappe/chat/doctype/chat_message_attachment/chat_message_attachment.json
new file mode 100644
index 0000000000..e49f75a124
--- /dev/null
+++ b/frappe/chat/doctype/chat_message_attachment/chat_message_attachment.json
@@ -0,0 +1,71 @@
+{
+ "allow_copy": 0,
+ "allow_guest_to_view": 0,
+ "allow_import": 0,
+ "allow_rename": 0,
+ "beta": 1,
+ "creation": "2017-11-15 13:27:05.706207",
+ "custom": 0,
+ "docstatus": 0,
+ "doctype": "DocType",
+ "document_type": "",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "fields": [
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "attachment",
+ "fieldtype": "Attach",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_standard_filter": 0,
+ "label": "Attachment",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 1,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ }
+ ],
+ "has_web_view": 0,
+ "hide_heading": 0,
+ "hide_toolbar": 0,
+ "idx": 0,
+ "image_view": 0,
+ "in_create": 0,
+ "is_submittable": 0,
+ "issingle": 0,
+ "istable": 1,
+ "max_attachments": 0,
+ "modified": "2017-11-15 13:33:27.405470",
+ "modified_by": "Administrator",
+ "module": "Chat",
+ "name": "Chat Message Attachment",
+ "name_case": "",
+ "owner": "Administrator",
+ "permissions": [],
+ "quick_entry": 1,
+ "read_only": 0,
+ "read_only_onload": 0,
+ "show_name_in_global_search": 0,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1,
+ "track_seen": 0
+}
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_message_attachment/chat_message_attachment.py b/frappe/chat/doctype/chat_message_attachment/chat_message_attachment.py
new file mode 100644
index 0000000000..b399628e89
--- /dev/null
+++ b/frappe/chat/doctype/chat_message_attachment/chat_message_attachment.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+from __future__ import unicode_literals
+import frappe
+from frappe.model.document import Document
+
+class ChatMessageAttachment(Document):
+ pass
diff --git a/frappe/chat/doctype/chat_profile/__init__.py b/frappe/chat/doctype/chat_profile/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/chat/doctype/chat_profile/chat_profile.js b/frappe/chat/doctype/chat_profile/chat_profile.js
new file mode 100644
index 0000000000..d10c66379e
--- /dev/null
+++ b/frappe/chat/doctype/chat_profile/chat_profile.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2017, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Chat Profile', {
+ refresh: function(frm) {
+
+ }
+});
diff --git a/frappe/chat/doctype/chat_profile/chat_profile.json b/frappe/chat/doctype/chat_profile/chat_profile.json
new file mode 100644
index 0000000000..4a9348bf3a
--- /dev/null
+++ b/frappe/chat/doctype/chat_profile/chat_profile.json
@@ -0,0 +1,278 @@
+{
+ "allow_copy": 0,
+ "allow_guest_to_view": 0,
+ "allow_import": 0,
+ "allow_rename": 0,
+ "autoname": "CP.#####",
+ "beta": 1,
+ "creation": "2017-11-13 18:26:57.943027",
+ "custom": 0,
+ "docstatus": 0,
+ "doctype": "DocType",
+ "document_type": "",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "fields": [
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "default": "Online",
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_standard_filter": 0,
+ "label": "Status",
+ "length": 0,
+ "no_copy": 0,
+ "options": "Online\nAway\nBusy\nOffline",
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 1,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "chat_background",
+ "fieldtype": "Attach Image",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Chat Background",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "notifications",
+ "fieldtype": "Section Break",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Notifications",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "default": "1",
+ "fieldname": "notification_tones",
+ "fieldtype": "Check",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Notification Tones",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "default": "1",
+ "fieldname": "conversation_tones",
+ "fieldtype": "Check",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Conversation Tones",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "settings",
+ "fieldtype": "Section Break",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Settings",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "default": "1",
+ "fieldname": "display_widget",
+ "fieldtype": "Check",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Display Widget",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ }
+ ],
+ "has_web_view": 0,
+ "hide_heading": 0,
+ "hide_toolbar": 0,
+ "idx": 0,
+ "image_view": 0,
+ "in_create": 0,
+ "is_submittable": 0,
+ "issingle": 0,
+ "istable": 0,
+ "max_attachments": 0,
+ "modified": "2017-12-19 11:23:04.791395",
+ "modified_by": "achilles@erpnext.com",
+ "module": "Chat",
+ "name": "Chat Profile",
+ "name_case": "",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "amend": 0,
+ "apply_user_permissions": 0,
+ "cancel": 0,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "if_owner": 0,
+ "import": 0,
+ "permlevel": 0,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "set_user_permissions": 0,
+ "share": 1,
+ "submit": 0,
+ "write": 1
+ }
+ ],
+ "quick_entry": 1,
+ "read_only": 0,
+ "read_only_onload": 0,
+ "show_name_in_global_search": 0,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1,
+ "track_seen": 0
+}
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_profile/chat_profile.py b/frappe/chat/doctype/chat_profile/chat_profile.py
new file mode 100644
index 0000000000..55f3ec812b
--- /dev/null
+++ b/frappe/chat/doctype/chat_profile/chat_profile.py
@@ -0,0 +1,173 @@
+# imports - module imports
+from frappe.model.document import Document
+from frappe import _, _dict # <- the best thing ever happened to frappe
+import frappe
+
+# imports - frappe module imports
+from frappe.core.doctype.version.version import get_diff
+from frappe.chat.doctype.chat_room.chat_room import get_user_chat_rooms
+from frappe.chat.util import (
+ get_user_doc,
+ safe_json_loads,
+ filter_dict,
+ dictify
+)
+
+session = frappe.session
+
+# TODO
+# User
+# [ ] Deleting a User should also delete its Chat Profile.
+# [ ] Ensuring username is mandatory when User has been created.
+
+# Chat Profile
+# [x] Link Chat Profile DocType to User when User has been created.
+# [x] Once done, add a validator to check Chat Profile has been
+# created only once.
+# [x] Users can view other Users Chat Profile, but not update the same.
+# Not sure, but circular link would be helpful.
+
+class ChatProfile(Document):
+ # trigger from DocType
+ def before_save(self):
+ if not self.is_new():
+ self.get_doc_before_save()
+
+ def on_update(self):
+ user = get_user_doc()
+
+ if user.chat_profile:
+ if user.chat_profile != self.name:
+ frappe.throw(_("Sorry! You don't have permission to update this profile."))
+ else:
+ if not self.is_new():
+ before = self.get_doc_before_save()
+ after = self
+
+ diff = dictify(get_diff(before, after))
+ if diff:
+ fields = [change[0] for change in diff.changed]
+
+ # NOTE: Version DocType is the best thing ever. Selective Updates to Chat Rooms/Users FTW.
+
+ # status update are dispatched to current user and Direct Chat Rooms.
+ if 'status' in fields:
+ # TODO: you can add filters within get_user_chat_rooms
+ rooms = get_user_chat_rooms(user)
+ rooms = [r for r in rooms if r.type == 'Direct']
+ resp = dict(
+ user = user.name,
+ data = dict(
+ status = self.status
+ )
+ )
+
+ for room in rooms:
+ frappe.publish_realtime('frappe.chat.profile:update', resp, room = room.name, after_commit = True)
+
+ if 'display_widget' in fields:
+ resp = dict(
+ user = user.name,
+ data = dict(
+ display_widget = bool(self.display_widget)
+ )
+ )
+ frappe.publish_realtime('frappe.chat.profile:update', resp, user = user.name, after_commit = True)
+
+def get_user_chat_profile_doc(user = None):
+ user = get_user_doc(user)
+ prof = frappe.get_doc('Chat Profile', user.chat_profile)
+
+ return prof
+
+def get_user_chat_profile(user = None, fields = None):
+ '''
+ Returns the Chat Profile for a given user.
+ '''
+ user = get_user_doc(user)
+ prof = get_user_chat_profile_doc(user)
+
+ data = dict(
+ name = user.name,
+ email = user.email,
+ first_name = user.first_name,
+ last_name = user.last_name,
+ username = user.username,
+ avatar = user.user_image,
+ bio = user.bio,
+
+ status = prof.status,
+ chat_bg = prof.chat_background,
+
+ notification_tones = bool(prof.notification_tones),
+ conversation_tones = bool(prof.conversation_tones), # frappe, y u no jsonify 0,1 bools? :(
+ display_widget = bool(prof.display_widget)
+ )
+
+ try:
+ data = filter_dict(data, fields)
+ except KeyError as e:
+ frappe.throw(str(e))
+
+ return data
+
+def get_new_chat_profile_doc(user = None, link = True):
+ user = get_user_doc(user)
+ prof = frappe.new_doc('Chat Profile')
+ prof.save()
+
+ if link:
+ user.update(dict(
+ chat_profile = prof.name
+ ))
+ user.save()
+
+ return prof
+
+@frappe.whitelist()
+def create(user, exists_ok = False, fields = None):
+ '''
+ Creates a Chat Profile for the current session user, throws error if exists.
+ '''
+ exists, fields = safe_json_loads(exists_ok, fields)
+ user = get_user_doc(user)
+
+ if user.name != session.user:
+ frappe.throw(_("Sorry! You don't have permission to create a profile for user {name}.".format(
+ name = user.name
+ )))
+
+ if user.chat_profile:
+ if not exists:
+ frappe.throw(_("Sorry! You cannot create more than one Chat Profile."))
+
+ prof = get_user_chat_profile(user, fields)
+ else:
+ prof = get_new_chat_profile_doc(user)
+ prof = get_user_chat_profile(user, fields)
+
+ return dictify(prof)
+
+@frappe.whitelist()
+def get(user = None, fields = None):
+ '''
+ Returns a user's Chat Profile.
+ '''
+ fields = safe_json_loads(fields)
+ prof = get_user_chat_profile(user, fields)
+
+ return dictify(prof)
+
+@frappe.whitelist()
+def update(user, data):
+ data = safe_json_loads(data)
+ user = get_user_doc(user)
+
+ if user.name != session.user:
+ frappe.throw(_("Sorry! You don't have permission to update Chat Profile for user {name}.".format(
+ name = user.name
+ )))
+
+ prof = get_user_chat_profile_doc(user)
+ prof.update(data)
+ prof.save()
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_profile/chat_profile_list.js b/frappe/chat/doctype/chat_profile/chat_profile_list.js
new file mode 100644
index 0000000000..a204619078
--- /dev/null
+++ b/frappe/chat/doctype/chat_profile/chat_profile_list.js
@@ -0,0 +1,11 @@
+frappe.listview_settings['Chat Profile'] =
+{
+ get_indicator: function (doc)
+ {
+ const status = frappe._.squash(frappe.chat.profile.STATUSES.filter(
+ s => s.name === doc.status
+ ));
+
+ return [__(status.name), status.color, `status,=,${status.name}`]
+ }
+};
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_profile/test_chat_profile.js b/frappe/chat/doctype/chat_profile/test_chat_profile.js
new file mode 100644
index 0000000000..20a8eb8708
--- /dev/null
+++ b/frappe/chat/doctype/chat_profile/test_chat_profile.js
@@ -0,0 +1,23 @@
+/* eslint-disable */
+// rename this file from _test_[name] to test_[name] to activate
+// and remove above this line
+
+QUnit.test("test: Chat Profile", function (assert) {
+ let done = assert.async();
+
+ // number of asserts
+ assert.expect(1);
+
+ frappe.run_serially([
+ // insert a new Chat Profile
+ () => frappe.tests.make('Chat Profile', [
+ // values to be set
+ {key: 'value'}
+ ]),
+ () => {
+ assert.equal(cur_frm.doc.key, 'value');
+ },
+ () => done()
+ ]);
+
+});
diff --git a/frappe/chat/doctype/chat_profile/test_chat_profile.py b/frappe/chat/doctype/chat_profile/test_chat_profile.py
new file mode 100644
index 0000000000..7dd8bc3669
--- /dev/null
+++ b/frappe/chat/doctype/chat_profile/test_chat_profile.py
@@ -0,0 +1,59 @@
+# imports - standard imports
+import unittest
+
+# imports - module imports
+import frappe
+
+# imports - frappe module imports
+from frappe.chat.doctype.chat_profile import chat_profile
+from frappe.chat.util import get_user_doc, create_test_user
+
+session = frappe.session
+test_user = create_test_user(__name__)
+
+class TestChatProfile(unittest.TestCase):
+ def test_create(self):
+ with self.assertRaises(frappe.ValidationError):
+ chat_profile.create(test_user)
+
+ user = get_user_doc(session.user)
+ if not user.chat_profile:
+ chat_profile.create(user.name)
+ prof = chat_profile.get(user.name)
+ self.assertEquals(prof.status, 'Online')
+ else:
+ with self.assertRaises(frappe.ValidationError):
+ chat_profile.create(user.name)
+
+ def test_get(self):
+ user = session.user
+ prof = chat_profile.get(user)
+
+ self.assertNotEquals(len(prof), 1)
+
+ prof = chat_profile.get(user, fields = ['status'])
+ self.assertEquals(len(prof), 1)
+ self.assertEquals(prof.status, 'Online')
+
+ prof = chat_profile.get(user, fields = ['status', 'chat_bg'])
+ self.assertEquals(len(prof), 2)
+
+ def test_update(self):
+ user = test_user
+ with self.assertRaises(frappe.ValidationError):
+ prof = chat_profile.update(user, data = dict(
+ status = 'Online'
+ ))
+
+ user = get_user_doc(session.user)
+ prev = chat_profile.get(user.name)
+
+ chat_profile.update(user.name, data = dict(
+ status = 'Offline'
+ ))
+ prof = chat_profile.get(user.name)
+ self.assertEquals(prof.status, 'Offline')
+ # revert
+ chat_profile.update(user.name, data = dict(
+ status = prev.status
+ ))
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_room/__init__.py b/frappe/chat/doctype/chat_room/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/chat/doctype/chat_room/chat_room.js b/frappe/chat/doctype/chat_room/chat_room.js
new file mode 100644
index 0000000000..01d8f9f2c2
--- /dev/null
+++ b/frappe/chat/doctype/chat_room/chat_room.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2017, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Chat Room', {
+ refresh: function(frm) {
+
+ }
+});
diff --git a/frappe/chat/doctype/chat_room/chat_room.json b/frappe/chat/doctype/chat_room/chat_room.json
new file mode 100644
index 0000000000..8dc4ce6a18
--- /dev/null
+++ b/frappe/chat/doctype/chat_room/chat_room.json
@@ -0,0 +1,314 @@
+{
+ "allow_copy": 0,
+ "allow_guest_to_view": 0,
+ "allow_import": 0,
+ "allow_rename": 0,
+ "autoname": "CR.#####",
+ "beta": 1,
+ "creation": "2017-11-08 15:27:21.156667",
+ "custom": 0,
+ "docstatus": 0,
+ "doctype": "DocType",
+ "document_type": "",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "fields": [
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "default": "Direct",
+ "fieldname": "type",
+ "fieldtype": "Select",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_standard_filter": 0,
+ "label": "Type",
+ "length": 0,
+ "no_copy": 0,
+ "options": "Direct\nGroup\nVisitor",
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 1,
+ "search_index": 0,
+ "set_only_once": 1,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "depends_on": "eval:doc.type==\"Group\"",
+ "fieldname": "room_name",
+ "fieldtype": "Data",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Name",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "depends_on": "eval:doc.type==\"Group\"",
+ "fieldname": "avatar",
+ "fieldtype": "Attach Image",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Avatar",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "last_message",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Last Message",
+ "length": 0,
+ "no_copy": 0,
+ "options": "",
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "message_count",
+ "fieldtype": "Int",
+ "hidden": 1,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Message Count",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "default": "",
+ "fieldname": "owner",
+ "fieldtype": "Link",
+ "hidden": 1,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Owner",
+ "length": 0,
+ "no_copy": 0,
+ "options": "User",
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 1,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "user_list",
+ "fieldtype": "Section Break",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Users",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "users",
+ "fieldtype": "Table",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Users",
+ "length": 0,
+ "no_copy": 0,
+ "options": "Chat Room User",
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ }
+ ],
+ "has_web_view": 0,
+ "hide_heading": 0,
+ "hide_toolbar": 0,
+ "idx": 0,
+ "image_field": "avatar",
+ "image_view": 0,
+ "in_create": 0,
+ "is_submittable": 0,
+ "issingle": 0,
+ "istable": 0,
+ "max_attachments": 0,
+ "modified": "2017-12-17 15:53:24.103274",
+ "modified_by": "achilles@erpnext.com",
+ "module": "Chat",
+ "name": "Chat Room",
+ "name_case": "",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "amend": 0,
+ "apply_user_permissions": 0,
+ "cancel": 0,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "if_owner": 0,
+ "import": 0,
+ "permlevel": 0,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "set_user_permissions": 1,
+ "share": 1,
+ "submit": 0,
+ "write": 1
+ }
+ ],
+ "quick_entry": 0,
+ "read_only": 0,
+ "read_only_onload": 0,
+ "search_fields": "room_name",
+ "show_name_in_global_search": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "title_field": "room_name",
+ "track_changes": 1,
+ "track_seen": 0
+}
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_room/chat_room.py b/frappe/chat/doctype/chat_room/chat_room.py
new file mode 100644
index 0000000000..d30d65fd20
--- /dev/null
+++ b/frappe/chat/doctype/chat_room/chat_room.py
@@ -0,0 +1,296 @@
+# imports - standard imports
+import json
+
+# imports - module imports
+import frappe
+from frappe.model.document import Document
+from frappe import _, _dict
+
+# imports - frappe module imports
+from frappe.core.doctype.version.version import get_diff
+from frappe.chat.doctype.chat_message.chat_message import get_messages
+from frappe.chat.util import (
+ get_user_doc,
+ safe_json_loads,
+ dictify,
+ listify,
+ squashify,
+ assign_if_none
+)
+
+session = frappe.session
+
+# TODO
+# [x] Only Owners can edit the DocType Record.
+# [ ] Show Chat Room List that only has session.user in it.
+# [ ] Make Chat Room pagination configurable.
+
+class ChatRoom(Document):
+ def validate(self):
+ # TODO - Validations
+ # [x] Direct/Visitor must have 2 users only.
+ # [x] Groups must have 1 (1 being ther session user) or more users.
+ # [x] Ensure group has a name.
+ # [x] Check if there is a one-to-one conversation between 2 users (if Direct/Visitor).
+
+ # First, check if user isn't stupid by adding many users or himself/herself.
+ # C'mon yo, you're the owner.
+ users = get_chat_room_user_set(self.users)
+ users = [u for u in users if u.user != session.user]
+
+ self.update(dict(
+ users = users
+ ))
+
+ if self.type in ("Direct", "Visitor"):
+ if len(users) != 1:
+ frappe.throw(_('{type} room must have atmost one user.'.format(
+ type = self.type
+ )))
+
+ # squash to a single object if it's a list.
+ other = squashify(users)
+
+ # I don't know which idiot would make username not unique.
+ # Remember, this entire app assumes validation based on user's email.
+
+ # Okay, this must go only during creation. But alas, on click "Save" it does the same.
+ if self.is_new():
+ if is_one_on_one(self.owner, other.user, bidirectional = True):
+ frappe.throw(_('Direct room with {other} already exists.'.format(
+ other = other.user
+ )))
+
+ if self.type == "Group" and not self.room_name:
+ frappe.throw(_('Group name cannot be empty.'))
+
+ # trigger from DocType
+ def before_save(self):
+ if not self.is_new():
+ self.get_doc_before_save()
+
+ def on_update(self):
+ user = session.user
+ if user != self.owner:
+ frappe.throw(_("Sorry! You don't enough permissions to update this room."))
+
+ if not self.is_new():
+ before = self.get_doc_before_save()
+ after = self
+
+ # TODO
+ # [ ] Check if DocType is itself updated. WARN if not.
+ diff = dictify(get_diff(before, after)) # whoever you are, thank you for this.
+ if diff:
+ # notify only if there is an update.
+ update = dict() # Update Goodies.
+ # Types of Differences
+ # 1. Changes
+ for changed in diff.changed:
+ field, old, new = changed
+
+ if field == 'last_message':
+ doc_message = frappe.get_doc('Chat Message', new)
+ update.update({
+ field: dict(
+ name = doc_message.name,
+ user = doc_message.user,
+ room = doc_message.room,
+ content = doc_message.content,
+ urls = doc_message.urls,
+ mentions = doc_message.mentions,
+ creation = doc_message.creation
+ )
+ })
+ else:
+ update.update({
+ field: new
+ })
+ # 2. Added or Removed
+ # TODO
+ # [ ] Handle users.
+ # I personally feel this could be done better by either creating a new event
+ # or attaching to the below event. Questions like Who removed Whom? Who added Whom? etc.
+ # For first-cut, let's simply update the latest user list.
+ # This is because the Version DocType already handles it.
+
+ if diff.added or diff.removed:
+ update.update({
+ 'users': [u.user for u in self.users]
+ })
+
+ resp = dict(
+ room = self.name,
+ data = update
+ )
+
+ frappe.publish_realtime('frappe.chat.room:update', resp,
+ room = self.name, after_commit = True)
+
+ def on_trash(self):
+ if self.owner != session.user:
+ frappe.throw(_("Sorry, you're not authorized to delete this room."))
+
+def is_admin(user, room):
+ if user != session.user:
+ frappe.throw(_("Sorry, you're not authorized to view this information."))
+
+ # TODO - I'm tired searching the bug.
+
+def is_one_on_one(owner, other, bidirectional = False):
+ '''
+ checks if the owner and other have a direct conversation room.
+ '''
+ def get_room(owner, other):
+ room = frappe.get_list('Chat Room', filters = [
+ ['Chat Room', 'type' , 'in', ('Direct', 'Visitor')],
+ ['Chat Room', 'owner', '=' , owner],
+ ['Chat Room User', 'user' , '=' , other]
+ ], distinct = True)
+
+ return room
+
+ exists = len(get_room(owner, other)) == 1
+ if bidirectional:
+ exists = exists or len(get_room(other, owner)) == 1
+
+ return exists
+
+def get_chat_room_user_set(users):
+ '''
+ Returns a set of Chat Room Users
+
+ :param users: sequence of Chat Room Users
+ :return: set of Chat Room Users
+ '''
+ seen, news = set(), list()
+
+ for u in users:
+ if u.user not in seen:
+ news.append(u)
+ seen.add(u.user)
+
+ return news
+
+def get_new_chat_room_doc(kind, owner, users = None, name = None):
+ room = frappe.new_doc('Chat Room')
+ room.type = kind
+ room.owner = owner
+ room.room_name = name
+
+ users = users if isinstance(users, list) or users is None else [users]
+ docs = [ ]
+ if users:
+ for user in users:
+ doc = frappe.new_doc('Chat Room User')
+ doc.user = user
+
+ docs.append(doc)
+
+ room.users = docs
+ room.save()
+
+ return room
+
+def get_new_chat_room(kind, owner, users = None, name = None):
+ room = get_new_chat_room_doc(kind = kind, owner = owner, users = users, name = name)
+ room = get_user_chat_rooms(user = owner, rooms = room.name)
+
+ return room
+
+def get_user_chat_rooms(user = None, rooms = None, fields = None):
+ '''
+ if user is None, defaults to session user.
+ if room is None, returns the entire list of rooms subscribed by user.
+ '''
+ user = get_user_doc(user)
+
+ rooms = assign_if_none(rooms, [ ])
+ fields = assign_if_none(fields, [ ])
+
+ param = [f for f in fields if f != 'users' or f != 'last_message']
+
+ rooms = frappe.get_list('Chat Room',
+ or_filters = [
+ ['Chat Room', 'owner', '=', user.name],
+ ['Chat Room User', 'user', '=', user.name]
+ ],
+ filters = [
+ ['Chat Room', 'name', 'in', rooms]
+ ] if rooms else None,
+ fields = param + ['name'] if param or 'users' in fields else [
+ 'type', 'name', 'owner', 'room_name', 'avatar', 'creation'
+ ],
+ distinct = True
+ )
+
+ if not fields or 'users' in fields:
+ for i, r in enumerate(rooms):
+ doc_room = frappe.get_doc('Chat Room', r.name)
+ rooms[i]['users'] = [ ]
+
+ for user in doc_room.users:
+ rooms[i]['users'].append(user.user)
+
+ if not fields or 'last_message' in fields:
+ for i, r in enumerate(rooms):
+ doc_room = frappe.get_doc('Chat Room', r.name)
+ if doc_room.last_message:
+ doc_message = frappe.get_doc('Chat Message', doc_room.last_message)
+ rooms[i]['last_message'] = dict(
+ name = doc_message.name,
+ user = doc_message.user,
+ room = doc_message.room,
+ content = doc_message.content,
+ urls = doc_message.urls,
+ mentions = doc_message.mentions,
+ creation = doc_message.creation
+ )
+ else:
+ rooms[i]['last_message'] = None
+
+ rooms = dictify(rooms)
+
+ return rooms
+
+@frappe.whitelist()
+def create(kind, owner, users = None, name = None):
+ users = safe_json_loads(users)
+ if owner != session.user:
+ frappe.throw(_("Sorry! You're not authorized to create a Chat Room."))
+
+ room = get_new_chat_room(kind = kind, owner = owner, users = users, name = name)
+ room = squashify(room)
+
+ users = [room.owner] + [u for u in room.users]
+ for u in users:
+ frappe.publish_realtime('frappe.chat.room:create', room,
+ user = u, after_commit = True)
+
+ return room
+
+@frappe.whitelist()
+def get(user, rooms = None, fields = None):
+ rooms = safe_json_loads(rooms)
+ fields = safe_json_loads(fields)
+
+ user = get_user_doc(user)
+
+ if user.name != frappe.session.user:
+ frappe.throw(_("You're not authorized to view this room."))
+
+ data = get_user_chat_rooms(user = user, rooms = rooms, fields = fields)
+
+ return data
+
+# Could we move pagination to a config, but how?
+# One possibility is to add to Chat Profile itself.
+# Actually yes.
+@frappe.whitelist()
+def get_history(room, user = None, pagination = 20):
+ user = get_user_doc(user)
+ mess = get_messages(room, pagination = pagination)
+
+ mess = squashify(mess)
+
+ return dictify(mess)
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_room/test_chat_room.js b/frappe/chat/doctype/chat_room/test_chat_room.js
new file mode 100644
index 0000000000..bc07a0e7f5
--- /dev/null
+++ b/frappe/chat/doctype/chat_room/test_chat_room.js
@@ -0,0 +1,23 @@
+/* eslint-disable */
+// rename this file from _test_[name] to test_[name] to activate
+// and remove above this line
+
+QUnit.test("test: Chat Room", function (assert) {
+ let done = assert.async();
+
+ // number of asserts
+ assert.expect(1);
+
+ frappe.run_serially([
+ // insert a new Chat Room
+ () => frappe.tests.make('Chat Room', [
+ // values to be set
+ {key: 'value'}
+ ]),
+ () => {
+ assert.equal(cur_frm.doc.key, 'value');
+ },
+ () => done()
+ ]);
+
+});
diff --git a/frappe/chat/doctype/chat_room/test_chat_room.py b/frappe/chat/doctype/chat_room/test_chat_room.py
new file mode 100644
index 0000000000..046d781d6f
--- /dev/null
+++ b/frappe/chat/doctype/chat_room/test_chat_room.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017, Frappe Technologies and Contributors
+# See license.txt
+from __future__ import unicode_literals
+
+import frappe
+import unittest
+
+class TestChatRoom(unittest.TestCase):
+ pass
diff --git a/frappe/chat/doctype/chat_room_user/__init__.py b/frappe/chat/doctype/chat_room_user/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/chat/doctype/chat_room_user/chat_room_user.json b/frappe/chat/doctype/chat_room_user/chat_room_user.json
new file mode 100644
index 0000000000..00cd2499e7
--- /dev/null
+++ b/frappe/chat/doctype/chat_room_user/chat_room_user.json
@@ -0,0 +1,102 @@
+{
+ "allow_copy": 0,
+ "allow_guest_to_view": 0,
+ "allow_import": 0,
+ "allow_rename": 0,
+ "beta": 1,
+ "creation": "2017-11-08 15:24:21.029314",
+ "custom": 0,
+ "docstatus": 0,
+ "doctype": "DocType",
+ "document_type": "",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "fields": [
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "user",
+ "fieldtype": "Link",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_standard_filter": 0,
+ "label": "User",
+ "length": 0,
+ "no_copy": 0,
+ "options": "User",
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "is_admin",
+ "fieldtype": "Check",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Admin",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ }
+ ],
+ "has_web_view": 0,
+ "hide_heading": 0,
+ "hide_toolbar": 0,
+ "idx": 0,
+ "image_view": 0,
+ "in_create": 0,
+ "is_submittable": 0,
+ "issingle": 0,
+ "istable": 1,
+ "max_attachments": 0,
+ "modified": "2017-11-28 11:50:06.165435",
+ "modified_by": "achilles@erpnext.com",
+ "module": "Chat",
+ "name": "Chat Room User",
+ "name_case": "",
+ "owner": "Administrator",
+ "permissions": [],
+ "quick_entry": 1,
+ "read_only": 0,
+ "read_only_onload": 0,
+ "show_name_in_global_search": 0,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1,
+ "track_seen": 0
+}
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_room_user/chat_room_user.py b/frappe/chat/doctype/chat_room_user/chat_room_user.py
new file mode 100644
index 0000000000..f6dbdc7659
--- /dev/null
+++ b/frappe/chat/doctype/chat_room_user/chat_room_user.py
@@ -0,0 +1,8 @@
+# imports - module imports
+from frappe.model.document import Document
+import frappe
+
+session = frappe.session
+
+class ChatRoomUser(Document):
+ pass
\ No newline at end of file
diff --git a/frappe/chat/page/__init__.py b/frappe/chat/page/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/chat/page/chat/__init__.py b/frappe/chat/page/chat/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/chat/page/chat/chat.js b/frappe/chat/page/chat/chat.js
new file mode 100644
index 0000000000..1ec9d0c803
--- /dev/null
+++ b/frappe/chat/page/chat/chat.js
@@ -0,0 +1,11 @@
+frappe.pages.chat.on_page_load = function (container)
+{
+ const page = new frappe.ui.Page({
+ title: __('Chat'), parent: container
+ });
+ const $container = $(container).find('.layout-main')
+ $container.html("")
+
+ // const chat = new frappe.Chat($container, { layout: frappe.Chat.Layout.PAGE });
+ // chat.render();
+};
\ No newline at end of file
diff --git a/frappe/chat/page/chat/chat.json b/frappe/chat/page/chat/chat.json
new file mode 100644
index 0000000000..a7d35c6e94
--- /dev/null
+++ b/frappe/chat/page/chat/chat.json
@@ -0,0 +1,20 @@
+{
+ "content": null,
+ "creation": "2017-11-08 14:55:47.986307",
+ "docstatus": 0,
+ "doctype": "Page",
+ "icon": "octicon octiocn-comment",
+ "idx": 0,
+ "modified": "2017-12-17 10:44:23.698446",
+ "modified_by": "Administrator",
+ "module": "Chat",
+ "name": "chat",
+ "owner": "Administrator",
+ "page_name": "chat",
+ "roles": [],
+ "script": null,
+ "standard": "Yes",
+ "style": null,
+ "system_page": 0,
+ "title": "Chat"
+}
\ No newline at end of file
diff --git a/frappe/chat/page/chat/test_chat.js b/frappe/chat/page/chat/test_chat.js
new file mode 100644
index 0000000000..58e8c30725
--- /dev/null
+++ b/frappe/chat/page/chat/test_chat.js
@@ -0,0 +1,27 @@
+QUnit.test("test: Chat", function (assert)
+{
+ const done = assert.async(3);
+
+ assert.expect(3);
+
+ // test - frappe._.fuzzy_search
+ frappe.run_serially([
+ () => assert.equal(frappe._.fuzzy_search("foo", ["foobar", "tooti"]), "foobar"),
+ ]);
+
+ // test - frappe.chat.profile.create
+ frappe.run_serially([
+ () => frappe.set_route('chat'),
+ // empty promise
+ () => frappe.chat.profile.create(),
+ (profile) => {
+ assert.equal(profile.status, "Online");
+ },
+ // one key only
+ () => frappe.chat.profile.create("status"),
+ (profile) => {
+ assert.equal(Object.keys(profile).length, 1);
+ },
+ () => done()
+ ]);
+});
\ No newline at end of file
diff --git a/frappe/chat/util/__init__.py b/frappe/chat/util/__init__.py
new file mode 100644
index 0000000000..1d3a7de5a3
--- /dev/null
+++ b/frappe/chat/util/__init__.py
@@ -0,0 +1,13 @@
+# imports - module imports
+from frappe.chat.util.util import (
+ get_user_doc,
+ squashify,
+ safe_json_loads,
+ filter_dict,
+ assign_if_none,
+ listify,
+ dictify,
+ check_url,
+ create_test_user,
+ get_emojis
+)
\ No newline at end of file
diff --git a/frappe/chat/util/test_util.py b/frappe/chat/util/test_util.py
new file mode 100644
index 0000000000..960d6a8fa8
--- /dev/null
+++ b/frappe/chat/util/test_util.py
@@ -0,0 +1,35 @@
+# imports - standard imports
+import unittest
+
+# imports - module imports
+from frappe.chat.util import (
+ get_user_doc,
+ safe_json_loads
+)
+import frappe
+
+class TestChatUtil(unittest.TestCase):
+ def test_safe_json_loads(self):
+ number = safe_json_loads("1")
+ self.assertEquals(type(number), int)
+
+ number = safe_json_loads("1.0")
+ self.assertEquals(type(number), float)
+
+ string = safe_json_loads("foobar")
+ self.assertEquals(type(string), str)
+
+ array = safe_json_loads('[{ "foo": "bar" }]')
+ self.assertEquals(type(array), list)
+
+ objekt = safe_json_loads('{ "foo": "bar" }')
+ self.assertEquals(type(objekt), dict)
+
+ true, null = safe_json_loads("true", "null")
+ self.assertEquals(true, True)
+ self.assertEquals(null, None)
+
+ def test_get_user_doc(self):
+ # Needs more test cases.
+ user = get_user_doc()
+ self.assertEquals(user.name, frappe.session.user)
\ No newline at end of file
diff --git a/frappe/chat/util/util.py b/frappe/chat/util/util.py
new file mode 100644
index 0000000000..d61139b824
--- /dev/null
+++ b/frappe/chat/util/util.py
@@ -0,0 +1,114 @@
+# imports - third-party imports
+import requests
+
+# imports - compatibility imports
+import six
+
+# imports - standard imports
+from collections import MutableSequence, Mapping, MutableMapping
+if six.PY2:
+ from urlparse import urlparse # PY2
+else:
+ from urllib.parse import urlparse # PY3
+import json
+
+# imports - module imports
+from frappe.model.document import Document
+from frappe.exceptions import DuplicateEntryError
+from frappe import _dict
+import frappe
+
+session = frappe.session
+
+def get_user_doc(user = None):
+ if isinstance(user, Document):
+ return user
+
+ user = user or session.user
+ user = frappe.get_doc('User', user)
+
+ return user
+
+def squashify(what):
+ if isinstance(what, MutableSequence) and len(what) == 1:
+ return what[0]
+
+ return what
+
+def safe_json_loads(*args):
+ results = [ ]
+
+ for arg in args:
+ try:
+ arg = json.loads(arg)
+ except Exception as e:
+ pass
+
+ results.append(arg)
+
+ return squashify(results)
+
+def filter_dict(what, keys, ignore = False):
+ copy = dict()
+
+ if keys:
+ for k in keys:
+ if k not in what and not ignore:
+ raise KeyError('{key} not in dict.'.format(key = k))
+ else:
+ copy.update({
+ k: what[k]
+ })
+ else:
+ copy = what.copy()
+
+ return copy
+
+def assign_if_none(a, b):
+ if a is None:
+ a = b
+ return a
+
+def listify(arg):
+ if not isinstance(arg, list):
+ arg = [arg]
+ return arg
+
+def dictify(arg):
+ if isinstance(arg, MutableSequence):
+ for i, a in enumerate(arg):
+ arg[i] = dictify(a)
+ elif isinstance(arg, MutableMapping):
+ arg = _dict(arg)
+
+ return arg
+
+def check_url(what, raise_err = False):
+ if not urlparse(what).scheme:
+ if raise_err:
+ raise ValueError('{what} not a valid URL.')
+ else:
+ return False
+
+ return True
+
+def create_test_user(module):
+ try:
+ test_user = frappe.new_doc('User')
+ test_user.first_name = '{module}'.format(module = module)
+ test_user.email = 'testuser.{module}@example.com'.format(module = module)
+ test_user.save()
+ except DuplicateEntryError:
+ frappe.log('Test User Chat Profile exists.')
+
+def get_emojis():
+ redis = frappe.cache()
+ emojis = redis.hget('frappe_emojis', 'emojis')
+
+ if not emojis:
+ resp = requests.get('http://git.io/frappe-emoji')
+ if resp.ok:
+ emojis = resp.json()
+ redis.hset('frappe_emojis', 'emojis', emojis)
+
+ return dictify(emojis)
\ No newline at end of file
diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py
index ff606d940d..83b03281c6 100644
--- a/frappe/commands/utils.py
+++ b/frappe/commands/utils.py
@@ -306,6 +306,7 @@ def console(context):
@click.option('--test', multiple=True, help="Specific test")
@click.option('--driver', help="For Travis")
@click.option('--ui-tests', is_flag=True, default=False, help="Run UI Tests")
+@click.option('--coverage', is_flag=True, default=False, help='Display Coverage Report')
@click.option('--module', help="Run tests in a module")
@click.option('--profile', is_flag=True, default=False)
@click.option('--junit-xml-output', help="Destination file path for junit xml report")
diff --git a/frappe/core/doctype/page/page.json b/frappe/core/doctype/page/page.json
index 59c94ec35d..0c586643d4 100644
--- a/frappe/core/doctype/page/page.json
+++ b/frappe/core/doctype/page/page.json
@@ -273,7 +273,7 @@
"no_copy": 0,
"oldfieldname": "standard",
"oldfieldtype": "Select",
- "options": "\nYes\nNo",
+ "options": "Yes\nNo",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
@@ -358,7 +358,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
- "modified": "2017-05-03 17:24:10.162110",
+ "modified": "2017-11-13 16:37:04.422547",
"modified_by": "Administrator",
"module": "Core",
"name": "Page",
diff --git a/frappe/core/doctype/page/test_page.js b/frappe/core/doctype/page/test_page.js
new file mode 100644
index 0000000000..7e45fd8639
--- /dev/null
+++ b/frappe/core/doctype/page/test_page.js
@@ -0,0 +1,23 @@
+/* eslint-disable */
+// rename this file from _test_[name] to test_[name] to activate
+// and remove above this line
+
+QUnit.test("test: Page", function (assert) {
+ let done = assert.async();
+
+ // number of asserts
+ assert.expect(1);
+
+ frappe.run_serially([
+ // insert a new Page
+ () => frappe.tests.make('Page', [
+ // values to be set
+ {key: 'value'}
+ ]),
+ () => {
+ assert.equal(cur_frm.doc.key, 'value');
+ },
+ () => done()
+ ]);
+
+});
diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json
index 984ca89b72..6d6eb6748d 100644
--- a/frappe/core/doctype/user/user.json
+++ b/frappe/core/doctype/user/user.json
@@ -2048,6 +2048,67 @@
"search_index": 0,
"set_only_once": 0,
"unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "chat_section_break",
+ "fieldtype": "Section Break",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Chat",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "chat_profile",
+ "fieldtype": "Link",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Chat Profile",
+ "length": 0,
+ "no_copy": 0,
+ "options": "Chat Profile",
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 1,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
}
],
"has_web_view": 0,
@@ -2063,8 +2124,8 @@
"istable": 0,
"max_attachments": 5,
"menu_index": 0,
- "modified": "2017-11-01 09:04:51.151347",
- "modified_by": "manas@erpnext.com",
+ "modified": "2017-11-15 13:01:00.085916",
+ "modified_by": "Administrator",
"module": "Core",
"name": "User",
"owner": "Administrator",
diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py
index edf4c285b4..5e9d33bc67 100644
--- a/frappe/core/doctype/user/user.py
+++ b/frappe/core/doctype/user/user.py
@@ -16,6 +16,9 @@ from frappe.limits import get_limits
from frappe.website.utils import is_signup_enabled
from frappe.utils.background_jobs import enqueue
+# imports - frappe.chat
+from frappe.chat.doctype.chat_profile.chat_profile import get_user_chat_profile_doc
+
STANDARD_USERS = ("Guest", "Administrator")
class MaxUsersReachedError(frappe.ValidationError): pass
diff --git a/frappe/desk/page/chat/chat.css b/frappe/desk/page/chat/chat.css
deleted file mode 100644
index 9abbf93193..0000000000
--- a/frappe/desk/page/chat/chat.css
+++ /dev/null
@@ -1,42 +0,0 @@
-.message-row .bot-icon {
- width: 24px;
- margin-right: 5px;
- padding-left: 7px;
- font-size: 18px;
- /*color: #FEEF72;*/
-}
-
-
-.message-bubble {
- padding: 0px 10px;
- padding-top: 2px;
- border-radius: 10px;
- margin-bottom: 10px;
- background-color: #d1d8dd;
- width: 70%;
-}
-
-.message-bubble::before {
- background-color: #d1d8dd;
- content: "\00a0";
- display: block;
- height: 6px;
- position: absolute;
- top: 7px;
- left: 70px;
- transform: rotate( 45deg ) skew( -35deg );
- -moz-transform: rotate( 45deg ) skew( -35deg );
- -ms-transform: rotate( 45deg ) skew( -35deg );
- -o-transform: rotate( 45deg ) skew( -35deg );
- -webkit-transform: rotate( 45deg ) skew( -35deg );
- width: 14px;
-}
-
-.message-bubble.my-message {
- color: white;
- background-color: #5E64FF;
-}
-
-.message-bubble.my-message::before {
- background-color: #5E64FF;
-}
\ No newline at end of file
diff --git a/frappe/desk/page/chat/chat.js b/frappe/desk/page/chat/chat.js
deleted file mode 100644
index b1bb2e0a28..0000000000
--- a/frappe/desk/page/chat/chat.js
+++ /dev/null
@@ -1,239 +0,0 @@
-// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-// MIT License. See license.txt
-
-frappe.pages.chat.on_page_load = function(parent) {
- var page = frappe.ui.make_app_page({
- parent: parent,
- });
-
- page.set_title('' + __("Chat") + ''
- + '');
-
- $(".navbar-center").html(__("Chat"));
-
- frappe.pages.chat.chat = new frappe.Chat(parent);
-}
-
-frappe.pages.chat.on_page_show = function() {
- // clear title prefix
- frappe.utils.set_title_prefix("");
-
- frappe.breadcrumbs.add("Desk");
-}
-
-frappe.Chat = Class.extend({
- init: function(wrapper, page) {
- this.wrapper = wrapper;
- this.page = wrapper.page;
- this.make();
- this.page.sidebar.addClass("col-sm-3");
- this.page.wrapper.find(".layout-main-section-wrapper").addClass("col-sm-9");
- this.page.wrapper.find(".page-title").removeClass("col-xs-6").addClass("col-xs-12");
- this.page.wrapper.find(".page-actions").removeClass("col-xs-6").addClass("hidden-xs");
- this.setup_realtime();
- },
-
- make: function() {
- this.make_sidebar();
- },
-
- setup_realtime: function() {
- var me = this;
- frappe.realtime.on('new_message', function(comment) {
- if(comment.modified_by !== frappe.session.user || comment.communication_type === 'Bot') {
- if(frappe.get_route()[0] === 'chat') {
- var current_contact = $(cur_page.page).find('[data-contact]').data('contact');
- var on_broadcast_page = current_contact === frappe.session.user;
- if ((current_contact == comment.owner)
- || (on_broadcast_page && comment.broadcast)
- || current_contact === 'Bot' && comment.communication_type === 'Bot') {
-
- setTimeout(function() { me.prepend_comment(comment); }, 1000);
- }
- } else {
- frappe.utils.notify(__("Message from {0}", [frappe.user_info(comment.owner).fullname]), comment.content);
- }
- }
- });
- },
-
- prepend_comment: function(comment) {
- frappe.pages.chat.chat.list.data.unshift(comment);
- this.render_row(comment, true);
- },
-
- make_sidebar: function() {
- var me = this;
- return frappe.call({
- module:'frappe.desk',
- page:'chat',
- method:'get_active_users',
- callback: function(r,rt) {
- // sort
- r.message.sort(function(a, b) { return cint(b.has_session) - cint(a.has_session); });
-
- // render
- me.page.sidebar.html(frappe.render_template("chat_sidebar", {data: r.message}));
-
- // bind click
- me.page.sidebar.find("a").on("click", function() {
- var li = $(this).parents("li:first");
- if (li.hasClass("active"))
- return false;
-
- var contact = li.attr("data-user");
-
- // active
- me.page.sidebar.find("li.active").removeClass("active");
- me.page.sidebar.find('[data-user="'+ contact +'"]').addClass("active");
-
- me.make_messages(contact);
- });
-
- $(me.page.sidebar.find("a")[0]).click();
- }
- });
- },
-
- make_messages: function(contact) {
- var me = this;
-
- this.page.main.html($(frappe.render_template("chat_main", { "contact": contact })));
-
- var text_area = this.page.main.find(".messages-textarea").on("focusout", function() {
- // on touchscreen devices, scroll to top
- // so that static navbar and page head don't overlap the textarea
- if (frappe.dom.is_touchscreen()) {
- frappe.utils.scroll_to($(this).parents(".message-box"));
- }
- });
-
-
- var post_btn = this.page.main.find(".btn-post").on("click", function() {
- var btn = $(this);
- var message_box = btn.parents(".message-box");
- var textarea = message_box.find("textarea");
- var contact = btn.attr("data-contact");
- var txt = textarea.val();
- var send_email = message_box.find('input[type="checkbox"]:checked').length > 0;
-
- if(txt) {
- return frappe.call({
- module: 'frappe.desk',
- page:'chat',
- method:'post',
- args: {
- txt: txt,
- contact: contact,
- notify: send_email ? 1 : 0
- },
- callback:function(r,rt) {
- textarea.val('');
- if (!r.exc) {
- me.prepend_comment(r.message);
- }
- },
- btn: this
- });
- }
- });
-
- text_area.keydown("meta+return ctrl+return", function(e) {
- post_btn.trigger("click");
- });
-
- this.page.wrapper.find(".page-head .message-to").html(frappe.user.full_name(contact));
-
- this.make_message_list(contact);
-
- this.list.run();
-
- frappe.utils.scroll_to();
- },
-
- make_message_list: function(contact) {
- var me = this;
-
- this.list = new frappe.ui.BaseList({
- parent: this.page.main.find(".message-list"),
- page: this.page,
- method: 'frappe.desk.page.chat.chat.get_list',
- args: {
- contact: contact
- },
- hide_refresh: true,
- freeze: false,
- render_view: function (values) {
- values.map(function (value) {
- me.render_row(value);
- });
- },
- });
- },
-
- render_row: function(value, prepend) {
- this.prepare(value)
-
- var wrapper = $('
')
- .data("data", this.meta)
-
- if(!prepend)
- wrapper.appendTo($(".result-list")).get(0);
- else
- wrapper.prependTo($(".result-list")).get(0);
-
- var row = $(frappe.render_template("chat_row", {
- data: value
- })).appendTo(wrapper)
- row.find(".avatar, .indicator").tooltip();
- },
-
- delete: function(ele) {
- $(ele).parent().css('opacity', 0.6);
- return frappe.call({
- method: 'frappe.desk.page.chat.chat.delete',
- args: {name : $(ele).attr('data-name')},
- callback: function() {
- $(ele).parents(".list-row:first").toggle(false);
- }
- });
- },
-
- refresh: function() {},
-
- get_contact: function() {
- var route = location.hash;
- if(route.indexOf('/')!=-1) {
- var name = decodeURIComponent(route.split('/')[1]);
- if(name.indexOf('__at__')!=-1) {
- name = name.replace('__at__', '@');
- }
- return name;
- }
- },
-
- prepare: function(data) {
- if(data.communication_type==="Notification" || data.comment_type==="Shared") {
- data.is_system_message = 1;
- }
-
- if(data.owner==data.reference_name
- && data.communication_type!=="Notification"
- && data.comment_type!=="Bot") {
- data.is_public = true;
- }
-
- if(data.owner==data.reference_name && data.communication_type !== "Bot") {
- data.is_mine = true;
- }
-
- if(data.owner==data.reference_name && data.communication_type === "Bot") {
- data.owner = 'bot';
- }
-
- data.content = frappe.markdown(data.content.substr(0, 1000));
- }
-
-
-
-});
diff --git a/frappe/desk/page/chat/chat.json b/frappe/desk/page/chat/chat.json
deleted file mode 100644
index 0b3504664b..0000000000
--- a/frappe/desk/page/chat/chat.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "content": null,
- "creation": "2012-06-14 18:44:56",
- "docstatus": 0,
- "doctype": "Page",
- "icon": "",
- "idx": 1,
- "modified": "2016-03-31 02:02:13.503910",
- "modified_by": "Administrator",
- "module": "Desk",
- "name": "chat",
- "owner": "Administrator",
- "page_name": "chat",
- "roles": [
- {
- "role": "All"
- }
- ],
- "script": null,
- "standard": "Yes",
- "style": null,
- "title": "Chat"
-}
diff --git a/frappe/desk/page/chat/chat.py b/frappe/desk/page/chat/chat.py
deleted file mode 100644
index 19f4d4cae4..0000000000
--- a/frappe/desk/page/chat/chat.py
+++ /dev/null
@@ -1,145 +0,0 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# MIT License. See license.txt
-
-from __future__ import unicode_literals
-import frappe
-from frappe.desk.notifications import delete_notification_count_for
-from frappe.core.doctype.user.user import STANDARD_USERS
-from frappe.utils import cint
-from frappe import _
-
-@frappe.whitelist()
-def get_list(arg=None):
- """get list of messages"""
- frappe.form_dict['start'] = int(frappe.form_dict['start'])
- frappe.form_dict['page_length'] = int(frappe.form_dict['page_length'])
- frappe.form_dict['user'] = frappe.session['user']
-
- # set all messages as read
- frappe.db.sql("""UPDATE `tabCommunication` set seen = 1
- where
- communication_type in ('Chat', 'Notification')
- and seen = 0
- and reference_doctype = 'User'
- and reference_name = %s""", frappe.session.user)
-
- delete_notification_count_for("Chat")
-
- frappe.local.flags.commit = True
-
- fields = '''name, owner, modified, content, communication_type,
- comment_type, reference_doctype, reference_name'''
-
- if frappe.form_dict.contact == 'Bot':
- return frappe.db.sql("""select {0} from `tabCommunication`
- where
- comment_type = 'Bot'
- and reference_doctype = 'User'
- and reference_name = %(user)s
- order by creation desc
- limit %(start)s, %(page_length)s""".format(fields),
- frappe.local.form_dict, as_dict=1)
-
- if frappe.form_dict.contact == frappe.session.user:
- # return messages
- return frappe.db.sql("""select {0} from `tabCommunication`
- where
- communication_type in ('Chat', 'Notification')
- and comment_type != 'Bot'
- and reference_doctype ='User'
- and (owner=%(contact)s
- or reference_name=%(user)s
- or owner=reference_name)
- order by creation desc
- limit %(start)s, %(page_length)s""".format(fields),
- frappe.local.form_dict, as_dict=1)
- else:
- return frappe.db.sql("""select {0} from `tabCommunication`
- where
- communication_type in ('Chat', 'Notification')
- and comment_type != 'Bot'
- and reference_doctype ='User'
- and ((owner=%(contact)s and reference_name=%(user)s)
- or (owner=%(user)s and reference_name=%(contact)s))
- order by creation desc
- limit %(start)s, %(page_length)s""".format(fields),
- frappe.local.form_dict, as_dict=1)
-
-@frappe.whitelist()
-def get_active_users():
- data = frappe.db.sql("""select name,
- (select count(*) from tabSessions where user=tabUser.name
- and timediff(now(), lastupdate) < time("01:00:00")) as has_session
- from tabUser
- where enabled=1 and
- ifnull(user_type, '')!='Website User' and
- name not in ({})
- order by first_name""".format(", ".join(["%s"]*len(STANDARD_USERS))), STANDARD_USERS, as_dict=1)
-
- # make sure current user is at the top, using has_session = 100
- users = [d.name for d in data]
-
- if frappe.session.user in users:
- data[users.index(frappe.session.user)]["has_session"] = 100
-
- else:
- # in case of administrator
- data.append({"name": frappe.session.user, "has_session": 100})
-
- if 'System Manager' in frappe.get_roles():
- data.append({"name": "Bot", "has_session": 100})
-
- return data
-
-@frappe.whitelist()
-def post(txt, contact, parenttype=None, notify=False, subject=None):
- """post message"""
-
- comment_type = None
- if contact == 'Bot':
- contact = frappe.session.user
- comment_type = 'Bot'
-
- d = frappe.new_doc('Communication')
- d.communication_type = 'Notification' if parenttype else 'Chat'
- d.subject = subject
- d.content = txt
- d.reference_doctype = 'User'
- d.reference_name = contact
- d.sender = frappe.session.user
-
- if comment_type:
- d.comment_type = comment_type
-
- d.insert(ignore_permissions=True)
-
- delete_notification_count_for("Chat")
-
- if notify and cint(notify):
- _notify(contact, txt, subject)
-
- return d
-
-@frappe.whitelist()
-def delete(arg=None):
- frappe.get_doc("Communication", frappe.form_dict['name']).delete()
-
-def _notify(contact, txt, subject=None):
- from frappe.utils import get_fullname, get_url
-
- try:
- if not isinstance(contact, list):
- contact = [frappe.db.get_value("User", contact, "email") or contact]
- frappe.sendmail(\
- recipients=contact,
- sender= frappe.db.get_value("User", frappe.session.user, "email"),
- subject=subject or _("New Message from {0}").format(get_fullname(frappe.session.user)),
- template="new_message",
- args={
- "from": get_fullname(frappe.session.user),
- "message": txt,
- "link": get_url()
- },
- header=[_('New Message'), 'orange'])
- except frappe.OutgoingEmailError:
- pass
diff --git a/frappe/desk/page/chat/chat_main.html b/frappe/desk/page/chat/chat_main.html
deleted file mode 100644
index 063afb3756..0000000000
--- a/frappe/desk/page/chat/chat_main.html
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
diff --git a/frappe/desk/page/chat/chat_row.html b/frappe/desk/page/chat/chat_row.html
deleted file mode 100644
index 3c90e7af98..0000000000
--- a/frappe/desk/page/chat/chat_row.html
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
- {%= frappe.user.full_name(data.owner) %},
-
- {%= comment_when(data.modified) %}
-
- {% if (data.owner==user) { %}
-
-
-
- {% } %}
-
-
diff --git a/frappe/desk/page/chat/chat_sidebar.html b/frappe/desk/page/chat/chat_sidebar.html
deleted file mode 100644
index 5b77fd264f..0000000000
--- a/frappe/desk/page/chat/chat_sidebar.html
+++ /dev/null
@@ -1,14 +0,0 @@
-
- {% for (var i=0, l= data.length; i < l; i++) { var contact = data[i]; %}
-
- {% } %}
-
diff --git a/frappe/hooks.py b/frappe/hooks.py
index 11ab305296..b2af8d105b 100755
--- a/frappe/hooks.py
+++ b/frappe/hooks.py
@@ -63,6 +63,8 @@ before_tests = "frappe.utils.install.before_tests"
email_append_to = ["Event", "ToDo", "Communication"]
+get_rooms = 'frappe.chat.doctype.chat_room.chat_room.get_rooms'
+
calendars = ["Event"]
# login
@@ -185,6 +187,10 @@ sounds = [
{"name": "error", "src": "/assets/frappe/sounds/error.mp3", "volume": 0.1},
# {"name": "alert", "src": "/assets/frappe/sounds/alert.mp3"},
# {"name": "chime", "src": "/assets/frappe/sounds/chime.mp3"},
+
+ # frappe chat sounds
+ { "name": "chat-message", "src": "/assets/frappe/sounds/chat-message.mp3", "volume": 0.1 },
+ { "name": "chat-notification", "src": "/assets/frappe/sounds/chat-noticication.mp3", "volume": 0.1 }
]
bot_parsers = [
diff --git a/frappe/modules.txt b/frappe/modules.txt
index a4ceff3d39..aac896a816 100644
--- a/frappe/modules.txt
+++ b/frappe/modules.txt
@@ -8,4 +8,5 @@ Desk
Integrations
Printing
Contacts
-Data Migration
\ No newline at end of file
+Data Migration
+Chat
\ No newline at end of file
diff --git a/frappe/public/build.json b/frappe/public/build.json
index d5bab815ff..3fbaca83ba 100755
--- a/frappe/public/build.json
+++ b/frappe/public/build.json
@@ -3,7 +3,9 @@
"public/css/font-awesome.css",
"public/css/octicons/octicons.css",
"public/css/website.css",
- "public/css/avatar.css"
+ "public/css/avatar.css",
+
+ "public/css/chat.css"
],
"js/frappe-web.min.js": [
"public/js/frappe/class.js",
@@ -20,7 +22,11 @@
"public/js/lib/microtemplate.js",
"public/js/frappe/query_string.js",
"website/js/website.js",
- "public/js/frappe/misc/rating_icons.html"
+ "public/js/frappe/misc/rating_icons.html",
+
+ "public/js/lib/hyper.min.js",
+ "public/js/lib/fuse.min.js",
+ "public/js/frappe/chat.js"
],
"js/control.min.js": [
"public/js/frappe/ui/capture.js",
@@ -124,7 +130,7 @@
"public/css/mobile.css",
"public/css/kanban.css",
"public/css/controls.css",
- "public/css/tags.css"
+ "public/css/chat.css"
],
"css/frappe-rtl.css": [
"public/css/bootstrap-rtl.css",
@@ -146,11 +152,13 @@
"public/js/lib/datepicker/datepicker.min.js",
"public/js/lib/datepicker/locale-all.js",
"public/js/lib/frappe-charts/frappe-charts.min.iife.js",
- "public/js/lib/webcam.min.js",
"public/js/lib/leaflet/leaflet.js",
"public/js/lib/leaflet/leaflet.draw.js",
"public/js/lib/leaflet/L.Control.Locate.js",
- "public/js/lib/leaflet/easy-button.js"
+ "public/js/lib/leaflet/easy-button.js",
+
+ "public/js/lib/hyper.min.js",
+ "public/js/lib/fuse.min.js"
],
"js/desk.min.js": [
"public/js/frappe/class.js",
@@ -247,7 +255,9 @@
"public/js/frappe/ui/comment.js",
"public/js/frappe/misc/rating_icons.html",
- "public/js/frappe/feedback.js"
+ "public/js/frappe/feedback.js",
+
+ "public/js/frappe/chat.js"
],
"css/module.min.css": [
"public/css/module.css"
diff --git a/frappe/public/css/chat.css b/frappe/public/css/chat.css
new file mode 100644
index 0000000000..2bdd9cfd1d
--- /dev/null
+++ b/frappe/public/css/chat.css
@@ -0,0 +1,121 @@
+.font-bold {
+ font-weight: 700;
+}
+.font-heavy {
+ font-weight: 900;
+}
+.cursor-pointer {
+ cursor: pointer;
+}
+.avatar {
+ padding: 1px;
+}
+.frappe-fab {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ box-shadow: 0px 3px 6px 0px rgba(0, 0, 0, 0.25);
+}
+.frappe-fab:hover {
+ box-shadow: 0px 5px 9px 0px rgba(0, 0, 0, 0.25);
+}
+.frappe-fab.frappe-fab-sm {
+ width: 40px;
+ height: 40px;
+}
+.frappe-fab.frappe-fab-lg {
+ width: 56px;
+ height: 56px;
+}
+.frappe-chat .panel {
+ margin-bottom: 0px !important;
+}
+.frappe-chat .panel .panel-heading {
+ box-shadow: 0px 2px 2px 0px rgba(0, 0, 0, 0.14);
+}
+.frappe-chat .panel .panel-body {
+ padding: 10px;
+}
+.frappe-chat .panel .frappe-chat-room-footer {
+ position: absolute;
+ bottom: 0px;
+}
+.frappe-chat .frappe-chat-form .form-control {
+ font-size: 12px;
+}
+.frappe-chat .frappe-chat-form .dropdown-menu {
+ border-radius: 4px;
+}
+.frappe-chat .frappe-chat-form .btn {
+ border-radius: 0px !important;
+}
+.frappe-chat .frappe-chat-form .list-group {
+ margin-bottom: 0px !important;
+ max-height: 150px;
+ overflow-y: auto;
+}
+.frappe-chat .frappe-chat-form .list-group .list-group-item:first-child,
+.frappe-chat .frappe-chat-form .list-group .list-group-item:last-child {
+ border-radius: 0px !important;
+}
+.frappe-chat-popper {
+ position: fixed;
+ bottom: 0px;
+ right: 0px;
+ margin: 20px;
+ z-index: 1035;
+}
+.frappe-chat-popper .frappe-chat-popper-collapse {
+ position: fixed;
+ bottom: 0px;
+ right: 0px;
+ margin: 20px;
+}
+.frappe-chat-popper .frappe-chat-popper-collapse > .panel {
+ position: relative;
+ box-shadow: 0px 3px 6px 0px rgba(0, 0, 0, 0.25);
+ width: 350px;
+ height: 500px;
+ overflow-y: auto;
+}
+.frappe-chat-popper .frappe-chat-popper-collapse > .panel .panel-body {
+ width: 350px;
+ height: 500px;
+ overflow-y: auto;
+}
+.frappe-chat-popper .frappe-chat-popper-collapse > .panel > .panel-heading {
+ box-shadow: 0px 2px 2px 0px rgba(0, 0, 0, 0.14);
+}
+.frappe-chat-popper .frappe-chat-popper-collapse > .panel > .panel-heading .action {
+ padding: 5px;
+}
+.frappe-chat-popper .frappe-chat-popper-collapse > .panel > .panel-heading a {
+ color: #FFF;
+}
+.frappe-chat-popper .frappe-chat-popper-collapse > .panel > .panel-heading .text-muted {
+ color: #FFF !important;
+}
+.frappe-chat-popper .frappe-chat-popper-collapse .panel-span {
+ position: fixed;
+ width: 100%;
+ height: 100%;
+ top: 0px;
+ left: 0px;
+ bottom: 0px;
+ right: 0px;
+ z-index: 1037;
+ overflow: auto;
+ border-radius: none;
+}
+.frappe-chat-emoji .dropdown-menu {
+ min-width: 250px;
+ background: none !important;
+ border: none !important;
+}
+.frappe-chat-emoji .panel {
+ margin-bottom: 0 !important;
+ height: 300px;
+}
+.frappe-chat-emoji .panel .form-group {
+ margin-bottom: 0 !important;
+}
diff --git a/frappe/public/css/desk-rtl.css b/frappe/public/css/desk-rtl.css
index bf9848d525..90a71e2f56 100644
--- a/frappe/public/css/desk-rtl.css
+++ b/frappe/public/css/desk-rtl.css
@@ -45,7 +45,7 @@
.list-id {
margin-left: 7px !important;
}
-.avatar-small {
+.avatar-small .avatar-sm {
margin-left: 5px;
margin-right: auto;
}
diff --git a/frappe/public/js/frappe/chat.js b/frappe/public/js/frappe/chat.js
new file mode 100644
index 0000000000..893b540483
--- /dev/null
+++ b/frappe/public/js/frappe/chat.js
@@ -0,0 +1,2551 @@
+// frappe Chat
+// Author - Achilles Rasquinha
+
+/* eslint semi: "never" */
+// Fuck semicolons - https://mislav.net/2010/05/semicolons
+
+// frappe extensions
+
+// frappe.user extensions
+/**
+ * @description Returns the first name of a User.
+ *
+ * @param {string} user - User
+ *
+ * @returns The first name of the user.
+ *
+ * @example
+ * frappe.user.first_name("Rahul Malhotra")
+ * // returns "Rahul"
+ */
+frappe.user.first_name = user => frappe.user.full_name(user).split(" ")[0]
+
+// frappe.ui extensions
+frappe.provide('frappe.ui')
+frappe.ui.Uploader = class
+{
+ constructor (wrapper, options = { })
+ {
+ this.options = frappe.ui.Uploader.OPTIONS
+ this.set_wrapper(wrapper)
+ this.set_options(options)
+ }
+
+ set_wrapper (wrapper)
+ {
+ this.$wrapper = $(wrapper)
+
+ return this
+ }
+
+ set_options (options)
+ {
+ this.options = { ...this.options, ...options }
+
+ return this
+ }
+
+ render ( )
+ {
+ const $template = $(frappe.ui.Uploader.TEMPLATE)
+ this.$wrapper.html($template)
+ }
+}
+frappe.ui.Uploader.Layout = { DIALOG: 'DIALOG' }
+frappe.ui.Uploader.OPTIONS =
+{
+ layout: frappe.ui.Uploader.Layout.DIALOG
+}
+frappe.ui.Uploader.TEMPLATE =
+`
+
+ FooBar
+
+`
+
+frappe.provide('frappe.ui.keycode')
+frappe.ui.keycode = { RETURN: 13 }
+
+/**
+ * @description The base class for all Frappe Errors.
+ *
+ * @example
+ * try
+ * throw new frappe.Error("foobar")
+ * catch (e)
+ * console.log(e.name)
+ * // returns "FrappeError"
+ *
+ * @see https://stackoverflow.com/a/32749533
+ * @todo Requires "transform-builtin-extend" for Babel 6
+ */
+frappe.Error = class extends Error
+{
+ constructor (message)
+ {
+ super (message)
+
+ this.name = 'FrappeError'
+
+ if ( typeof Error.captureStackTrace === 'function' )
+ Error.captureStackTrace(this, this.constructor)
+ else
+ this.stack = (new Error(message)).stack
+ }
+}
+
+/**
+ * @description TypeError
+ */
+frappe.TypeError = class extends frappe.Error
+{
+ constructor (message)
+ {
+ super (message)
+
+ this.name = this.constructor.name
+ }
+}
+
+/**
+ * @description ValueError
+ */
+frappe.ValueError = class extends frappe.Error
+{
+ constructor (message)
+ {
+ super (message)
+
+ this.name = this.constructor.name
+ }
+}
+
+/**
+ * @description ImportError
+ */
+frappe.ImportError = class extends frappe.Error
+{
+ constructor (message)
+ {
+ super (message)
+
+ this.name = this.constructor.name
+ }
+}
+
+// frappe.datetime
+frappe.provide('frappe.datetime')
+
+/**
+ * @description Frappe's datetime object. (Inspired by Python's datetime object).
+ *
+ * @example
+ * const datetime = new frappe.datetime.datetime()
+ */
+frappe.datetime.datetime = class
+{
+ /**
+ * @description Frappe's datetime Class's constructor.
+ */
+ constructor (instance)
+ {
+ if ( typeof moment === undefined )
+ throw new frappe.ImportError(`Moment.js not installed.`)
+
+ this.moment = instance ? moment(instance) : moment()
+ }
+
+ /**
+ * @description Returns a formatted string of the datetime object.
+ */
+ format (format)
+ {
+ const formatted = this.moment.format(format)
+ return formatted
+ }
+}
+
+/**
+ * @description Returns the current datetime.
+ *
+ * @example
+ * const datetime = new frappe.datetime.now()
+ */
+frappe.datetime.now = () => new frappe.datetime.datetime()
+
+/**
+ * @description Compares two frappe.datetime.datetime objects.
+ *
+ * @param {frappe.datetime.datetime} a - A frappe.datetime.datetime/moment object.
+ * @param {frappe.datetime.datetime} b - A frappe.datetime.datetime/moment object.
+ *
+ * @returns {number} 0 (if a and b are equal), 1 (if a is before b), -1 (if a is after b).
+ *
+ * @example
+ * frappe.datetime.compare(frappe.datetime.now(), frappe.datetime.now())
+ * // returns 0
+ * const then = frappe.datetime.now()
+ *
+ * frappe.datetime.compare(then, frappe.datetime.now())
+ * // returns 1
+ */
+frappe.datetime.compare = (a, b) =>
+{
+ a = a.moment
+ b = b.moment
+
+ if ( a.isBefore(b) )
+ return 1
+ else
+ if ( b.isBefore(a) )
+ return -1
+ else
+ return 0
+}
+
+// frappe._
+// frappe's utility namespace.
+frappe.provide('frappe._')
+
+/**
+ * @description Python-inspired format extension for string objects.
+ *
+ * @param {string} string - A string with placeholders.
+ * @param {object} object - An object with placeholder, value pairs.
+ *
+ * @return {string} - The formatted string.
+ *
+ * @example
+ * frappe._.format('{foo} {bar}', { bar: 'foo', foo: 'bar' })
+ * // returns "bar foo"
+ */
+frappe._.format = (string, object) =>
+{
+ for (const key in object)
+ string = string.replace(`{${key}}`, object[key])
+
+ return string
+}
+
+/**
+ * @description Fuzzy Search a given query within a dataset.
+ *
+ * @param {string} query - A query string.
+ * @param {array} dataset - A dataset to search within, can contain singletons or objects.
+ * @param {object} options - Options as per fuze.js
+ *
+ * @return {array} - The fuzzy matched index/object within the dataset.
+ *
+ * @example
+ * frappe._.fuzzy_search("foobar", ["foobar", "bartender"])
+ * // returns [0, 1]
+ *
+ * @see http://fusejs.io
+ */
+frappe._.fuzzy_search = (query, dataset, options) =>
+{
+ const DEFAULT =
+ {
+ shouldSort: true,
+ threshold: 0.6,
+ location: 0,
+ distance: 100,
+ minMatchCharLength: 1,
+ maxPatternLength: 32
+ }
+ options = { ...DEFAULT, ...options }
+
+ const fuse = new Fuse(dataset, options)
+ const result = fuse.search(query)
+
+ return result
+}
+
+/**
+ * @description Pluralizes a given word.
+ *
+ * @param {string} word - The word to be pluralized.
+ * @param {number} count - The count.
+ *
+ * @return {string} - The pluralized string.
+ *
+ * @example
+ * frappe._.pluralize('member', 1)
+ * // returns "member"
+ * frappe._.pluralize('members', 0)
+ * // returns "members"
+ *
+ * @todo Handle more edge cases.
+ */
+frappe._.pluralize = (word, count = 0, suffix = 's') => `${word}${count === 1 ? '' : suffix}`
+
+/**
+ * @description Captializes a given string.
+ *
+ * @param {word} - The word to be capitalized.
+ *
+ * @return {string} - The capitalized word.
+ *
+ * @example
+ * frappe._.capitalize('foobar')
+ * // returns "Foobar"
+ */
+frappe._.capitalize = word => `${word.charAt(0).toUpperCase()}${word.slice(1)}`
+
+/**
+ * @description Returns a copy of the given array (shallow).
+ *
+ * @param {array} array - The array to be copied.
+ *
+ * @returns {array} - The copied array.
+ *
+ * @example
+ * frappe._.copy_array(["foobar", "barfoo"])
+ * // returns ["foobar", "barfoo"]
+ *
+ * @todo Add optional deep copy.
+ */
+frappe._.copy_array = array =>
+{
+ if ( Array.isArray(array) )
+ return array.slice()
+ else
+ throw frappe.TypeError(`Expected Array, recieved ${typeof array} instead.`)
+}
+
+/**
+ * @description Check whether an array|string|object is empty.
+ *
+ * @param {any} value - The value to be checked on.
+ *
+ * @returns {boolean} - Returns if the object is empty.
+ *
+ * @example
+ * frappe._.is_empty([]) // returns true
+ * frappe._.is_empty(["foo"]) // returns false
+ *
+ * frappe._.is_empty("") // returns true
+ * frappe._.is_empty("foo") // returns false
+ *
+ * frappe._.is_empty({ }) // returns true
+ * frappe._.is_empty({ foo: "bar" }) // returns false
+ *
+ * @todo Handle other cases.
+ */
+frappe._.is_empty = value =>
+{
+ let empty = false
+
+ if ( value === undefined || value === null )
+ empty = true
+ else
+ if ( Array.isArray(value) || typeof value === 'string' )
+ empty = value.length === 0
+ else
+ if ( typeof value === 'object' )
+ empty = Object.keys(value).length === 0
+
+ return empty
+}
+
+/**
+ * @description Converts a singleton to an array, if required.
+ *
+ * @param {object} item - An object
+ *
+ * @example
+ * frappe._.as_array("foo")
+ * // returns ["foo"]
+ *
+ * frappe._.as_array(["foo"])
+ * // returns ["foo"]
+ *
+ * @see https://docs.oracle.com/javase/8/docs/api/java/util/Arrays.html#asList-T...-
+ */
+frappe._.as_array = item => Array.isArray(item) ? item : [item]
+
+/**
+ * @description Return a singleton if array contains a single element.
+ *
+ * @param {array} list - An array to squash.
+ *
+ * @returns {array|object} - Returns an array if there's more than 1 object else the first object itself.
+ *
+ * @example
+ * frappe._.squash(["foo"])
+ * // returns "foo"
+ *
+ * frappe._.squash(["foo", "bar"])
+ * // returns ["foo", "bar"]
+ */
+frappe._.squash = list => Array.isArray(list) && list.length === 1 ? list[0] : list
+
+/**
+ * @description Returns true, if the current device is a mobile device.
+ *
+ * @example
+ * frappe._.is_mobile()
+ * // returns true|false
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
+ */
+frappe._.is_mobile = () =>
+{
+ const regex = new RegExp("Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini", "i")
+ const agent = navigator.userAgent
+ const mobile = regex.test(agent)
+
+ return mobile
+}
+
+/**
+ * @description Removes falsey values from an array.
+ *
+ * @example
+ * frappe._.compact([1, 2, false, NaN, ''])
+ * // returns [1, 2]
+ */
+frappe._.compact = array => array.filter(Boolean)
+
+// frappe.loggers - A registry for frappe loggers.
+frappe.provide('frappe.loggers')
+
+/**
+ * @description Frappe's Logger Class
+ *
+ * @example
+ * frappe.log = frappe.Logger.get('foobar')
+ * frappe.log.level = frappe.Logger.DEBUG
+ *
+ * frappe.log.info('foobar')
+ * // prints '[timestamp] foobar: foobar'
+ */
+frappe.Logger = class
+{
+ /**
+ * @description Frappe's Logger Class's constructor.
+ *
+ * @param {string} name - Name of the logger.
+ */
+ constructor (name)
+ {
+ if ( typeof name !== 'string' )
+ throw new frappe.TypeError(`Expected string for name, got ${typeof name} instead.`)
+
+ this.name = name
+ this.level = frappe.Logger.NOTSET
+ this.format = frappe.Logger.FORMAT
+ }
+
+ /**
+ * @description Get instance of frappe.Logger (return registered one if declared).
+ *
+ * @param {string} name - Name of the logger.
+ */
+ static get (name)
+ {
+ if ( !(name in frappe.loggers) )
+ frappe.loggers[name] = new frappe.Logger(name)
+ return frappe.loggers[name]
+ }
+
+ debug (message) { this.log(message, frappe.Logger.DEBUG) }
+ info (message) { this.log(message, frappe.Logger.INFO) }
+ warn (message) { this.log(message, frappe.Logger.WARN) }
+ error (message) { this.log(message, frappe.Logger.ERROR) }
+
+ log (message, level)
+ {
+ const timestamp = frappe.datetime.now()
+
+ if ( level.value <= this.level.value )
+ {
+ const format = frappe._.format(this.format,
+ {
+ time: timestamp.format('HH:mm:ss'),
+ name: this.name
+ })
+ console.log(`%c ${format}:`, `color: ${level.color}`, message)
+ }
+ }
+}
+
+frappe.Logger.DEBUG = { value: 10, color: '#616161', name: 'DEBUG' }
+frappe.Logger.INFO = { value: 20, color: '#2196F3', name: 'INFO' }
+frappe.Logger.WARN = { value: 30, color: '#FFC107', name: 'WARN' }
+frappe.Logger.ERROR = { value: 40, color: '#F44336', name: 'ERROR' }
+frappe.Logger.NOTSET = { value: 99, name: 'NOTSET' }
+
+frappe.Logger.FORMAT = '{time} {name}'
+
+// frappe.chat
+frappe.provide('frappe.chat')
+
+frappe.log = frappe.Logger.get('frappe.chat')
+frappe.log.level = frappe.Logger.ERROR
+
+// frappe.chat.profile
+frappe.provide('frappe.chat.profile')
+
+/**
+ * @description Create a Chat Profile.
+ *
+ * @param {string|array} fields - (Optional) fields to be retrieved after creating a Chat Profile.
+ * @param {function} fn - (Optional) callback with the returned Chat Profile.
+ *
+ * @returns {Promise}
+ *
+ * @example
+ * frappe.chat.profile.create((profile) =>
+ * {
+ * // do stuff
+ * })
+ * frappe.chat.profile.create("status").then((profile) =>
+ * {
+ * console.log(profile) // { status: "Online" }
+ * })
+ */
+frappe.chat.profile.create = (fields, fn) =>
+{
+ if ( typeof fields === "function" )
+ {
+ fn = fields
+ fields = null
+ } else
+ if ( typeof fields === "string" )
+ fields = frappe._.as_array(fields)
+
+ return new Promise(resolve =>
+ {
+ frappe.call("frappe.chat.doctype.chat_profile.chat_profile.create",
+ { user: frappe.session.user, exists_ok: true, fields: fields },
+ response =>
+ {
+ if ( fn )
+ fn(response.message)
+
+ resolve(response.message)
+ })
+ })
+}
+
+// frappe.chat.profile.on
+frappe.provide('frappe.chat.profile.on')
+
+/**
+ * @description Triggers on a Chat Profile update of a user (Only if there's a one-on-one conversation).
+ *
+ * @param {function} fn - (Optional) callback with the User and the Chat Profile update.
+ *
+ * @returns {Promise}
+ *
+ * @example
+ * frappe.chat.profile.on.update(function (user, update)
+ * {
+ * // do stuff
+ * })
+ */
+frappe.chat.profile.on.update = function (fn)
+{
+ frappe.realtime.on("frappe.chat.profile:update", r => fn(r.user, r.data))
+}
+frappe.chat.profile.STATUSES
+=
+[
+ {
+ name: "Online",
+ color: "green"
+ },
+ {
+ name: "Away",
+ color: "yellow"
+ },
+ {
+ name: "Busy",
+ color: "red"
+ },
+ {
+ name: "Offline",
+ color: "darkgrey"
+ }
+]
+
+// frappe.chat.room
+frappe.provide('frappe.chat.room')
+
+/**
+ * @description Creates a Chat Room.
+ *
+ * @param {string} kind - (Required) "Direct", "Group" or "Visitor".
+ * @param {string} owner - (Optional) Chat Room owner (defaults to current user).
+ * @param {string|array} users - (Required for "Direct" and "Visitor", Optional for "Group") User(s) within Chat Room.
+ * @param {string} name - Chat Room name.
+ * @param {function} fn - callback with created Chat Room.
+ *
+ * @returns {Promise}
+ *
+ * @example
+ * frappe.chat.room.create("Direct", frappe.session.user, "foo@bar.com", function (room) {
+ * // do stuff
+ * })
+ * frappe.chat.room.create("Group", frappe.session.user, ["santa@gmail.com", "banta@gmail.com"], "Santa and Banta", function (room) {
+ * // do stuff
+ * })
+ */
+frappe.chat.room.create = function (kind, owner, users, name, fn)
+{
+ if ( typeof name === "function" )
+ {
+ fn = name
+ name = null
+ }
+
+ users = frappe._.as_array(users)
+
+ return new Promise(resolve =>
+ {
+ frappe.call("frappe.chat.doctype.chat_room.chat_room.create",
+ { kind: kind, owner: owner || frappe.session.user, users: users, name: name },
+ r =>
+ {
+ let room = r.message
+ room = { ...room, creation: new frappe.datetime.datetime(room.creation) }
+
+ if ( fn )
+ fn(room)
+
+ resolve(room)
+ })
+ })
+}
+
+/**
+ * @description Returns Chat Room(s).
+ *
+ * @param {string|array} names - (Optional) Chat Room(s) to retrieve.
+ * @param {string|array} fields - (Optional) fields to be retrieved for each Chat Room.
+ * @param {function} fn - (Optional) callback with the returned Chat Room(s).
+ *
+ * @returns {Promise}
+ *
+ * @example
+ * frappe.chat.room.get(function (rooms) {
+ * // do stuff
+ * })
+ * frappe.chat.room.get().then(function (rooms) {
+ * // do stuff
+ * })
+ *
+ * frappe.chat.room.get(null, ["room_name", "avatar"], function (rooms) {
+ * // do stuff
+ * })
+ *
+ * frappe.chat.room.get("CR00001", "room_name", function (room) {
+ * // do stuff
+ * })
+ *
+ * frappe.chat.room.get(["CR00001", "CR00002"], ["room_name", "last_message"], function (rooms) {
+ *
+ * })
+ */
+frappe.chat.room.get = function (names, fields, fn)
+{
+ if ( typeof names === "function" )
+ {
+ fn = names
+ names = null
+ fields = null
+ }
+ else
+ if ( typeof names === "string" )
+ {
+ names = frappe._.as_array(names)
+
+ if ( typeof fields === "function" ) {
+ fn = fields
+ fields = null
+ }
+ else
+ if ( typeof fields === "string" )
+ fields = frappe._.as_array(fields)
+ }
+
+ return new Promise(resolve =>
+ {
+
+ frappe.call("frappe.chat.doctype.chat_room.chat_room.get",
+ { user: frappe.session.user, rooms: names, fields: fields },
+ response =>
+ {
+ let rooms = response.message
+ if ( rooms ) // frappe.api BOGZ! (emtpy arrays are falsified, not good design).
+ {
+ rooms = frappe._.as_array(rooms)
+ rooms = rooms.map(room =>
+ {
+ return { ...room, creation: new frappe.datetime.datetime(room.creation),
+ last_message: room.last_message ? { ...room.last_message, creation: new frappe.datetime.datetime(room.last_message.creation) } : null
+ }
+ })
+ rooms = frappe._.squash(rooms)
+ }
+ else
+ rooms = [ ]
+
+ if ( fn )
+ fn(rooms)
+
+ resolve(rooms)
+ })
+ })
+}
+
+/**
+ * @description Subscribe current user to said Chat Room(s).
+ *
+ * @param {string|array} rooms - Chat Room(s).
+ *
+ * @example
+ * frappe.chat.room.subscribe("CR00001")
+ */
+frappe.chat.room.subscribe = function (rooms)
+{
+ frappe.realtime.publish("frappe.chat.room:subscribe", rooms)
+}
+
+/**
+ * @description Get Chat Room history.
+ *
+ * @param {string} name - Chat Room name
+ *
+ * @returns {Promise} - Chat Message(s)
+ *
+ * @example
+ * frappe.chat.room.history(function (messages)
+ * {
+ * // do stuff.
+ * })
+ */
+frappe.chat.room.history = function (name, fn)
+{
+ return new Promise(resolve =>
+ {
+ frappe.call("frappe.chat.doctype.chat_room.chat_room.get_history",
+ { room: name },
+ r =>
+ {
+ let messages = r.message ? frappe._.as_array(r.message) : [ ] // frappe.api BOGZ! (emtpy arrays are falsified, not good design).
+ messages = messages.map(m => { return { ...m, creation: new frappe.datetime.datetime(m.creation) } })
+
+ if ( fn )
+ fn(messages)
+
+ resolve(messages)
+ })
+ })
+}
+
+/**
+ * @description Searches Rooms based on a query.
+ *
+ * @param {string} query - The query string.
+ * @param {array} rooms - A list of Chat Rooms.
+ *
+ * @returns {array} - A fuzzy searched list of rooms.
+ */
+frappe.chat.room.search = function (query, rooms)
+{
+ const dataset = rooms.map(r =>
+ {
+ if ( r.room_name )
+ return r.room_name
+ else
+ if ( r.owner === frappe.session.user )
+ return frappe.user.full_name(frappe._.squash(r.users))
+ else
+ return frappe.user.full_name(r.owner)
+ })
+ const results = frappe._.fuzzy_search(query, dataset)
+ rooms = results.map(i => rooms[i])
+
+ return rooms
+}
+
+/**
+ * @description Sort Chat Room(s) based on Last Message Timestamp or Creation Date.
+ *
+ * @param {array} - A list of Chat Room(s)
+ * @param {compare} - (Optional) a comparision function.
+ */
+frappe.chat.room.sort = function (rooms, compare = null)
+{
+ compare = compare || function (a, b)
+ {
+ if ( a.last_message && b.last_message )
+ return frappe.datetime.compare(a.last_message.creation, b.last_message.creation)
+ else
+ if ( a.last_message )
+ return frappe.datetime.compare(a.last_message.creation, b.creation)
+ else
+ if ( b.last_message )
+ return frappe.datetime.compare(a.creation, b.last_message.creation)
+ else
+ return frappe.datetime.compare(a.creation, b.creation)
+ }
+ rooms.sort(compare)
+
+ return rooms
+}
+
+// frappe.chat.room.on
+frappe.provide('frappe.chat.room.on')
+
+/**
+ * @description Triggers on Chat Room updated.
+ *
+ * @param {function} fn - callback with the Chat Room and Update.
+ */
+frappe.chat.room.on.update = function (fn)
+{
+ frappe.realtime.on("frappe.chat.room:update", r =>
+ {
+ if ( r.data.last_message )
+ // creation to frappe.datetime.datetime (easier to manipulate).
+ r.data = { ...r.data, last_message: { ...r.data.last_message, creation: new frappe.datetime.datetime(r.data.last_message.creation) } }
+
+ fn(r.room, r.data)
+ })
+}
+
+/**
+ * @description Triggers on Chat Room created.
+ *
+ * @param {function} fn - callback with the created Chat Room.
+ */
+frappe.chat.room.on.create = function (fn)
+{
+ frappe.realtime.on("frappe.chat.room:create", r => fn({ ...r, creation: new frappe.datetime.datetime(r.creation) }))
+}
+
+/**
+ * @description Triggers when a User is typing in a Chat Room.
+ *
+ * @param {function} fn - callback with the typing User within the Chat Room.
+ */
+frappe.chat.room.on.typing = function (fn)
+{
+ frappe.realtime.on("frappe.chat.room:typing", r => fn(r.room, r.user))
+}
+
+// frappe.chat.message
+frappe.provide('frappe.chat.message')
+
+frappe.chat.message.typing = function (room, user)
+{
+ frappe.realtime.publish("frappe.chat.message:typing", { user: user || frappe.session.user, room: room })
+}
+
+frappe.chat.message.send = function (room, message)
+{
+ frappe.call("frappe.chat.doctype.chat_message.chat_message.send",
+ { user: frappe.session.user, room: room, content: message })
+}
+
+frappe.chat.message.update = function (message, update, fn)
+{
+ return new Promise(resolve => {
+ frappe.call('frappe.chat.doctype.chat_message.chat_message.update',
+ { user: frappe.session.user, message: message, update: update },
+ r =>
+ {
+ if ( fn )
+ fn(response.message)
+
+ resolve(response.message)
+ })
+ })
+}
+
+frappe.chat.message.sort = (messages) =>
+{
+ if ( !frappe._.is_empty(messages) )
+ messages.sort((a, b) => frappe.datetime.compare(b.creation, a.creation));
+
+ return messages
+}
+
+/**
+ * @description Add user to seen (defaults to session.user)
+ */
+frappe.chat.message.seen = (mess, user) =>
+{
+ frappe.call('frappe.chat.doctype.chat_message.chat_message.seen',
+ { message: mess, user: user || frappe.session.user })
+}
+
+frappe.provide('frappe.chat.message.on')
+frappe.chat.message.on.create = function (fn)
+{
+ frappe.realtime.on("frappe.chat.message:create", r => fn({ ...r, creation: new frappe.datetime.datetime(r.creation) }))
+}
+
+
+frappe.chat.message.on.update = function (fn)
+{
+ frappe.realtime.on("frappe.chat.message:update", r => fn(r.message, r.data))
+}
+
+frappe.chat.pretty_datetime = function (date)
+{
+ const today = moment()
+ const instance = date.moment
+
+ if ( today.isSame(instance, "d") )
+ return today.format("hh:mm A")
+ else
+ if ( today.isSame(instance, "week") )
+ return today.format("dddd")
+ else
+ return today.format("DD/MM/YYYY")
+}
+
+// frappe.chat.sound
+frappe.provide('frappe.chat.sound')
+
+/**
+ * @description Plays a given registered sound.
+ *
+ * @param {value} - The name of the registered sound.
+ *
+ * @example
+ * frappe.chat.sound.play("message")
+ */
+frappe.chat.sound.play = function (name, volume = 0.1)
+{
+ // frappe.utils.play_sound(`chat-${name}`)
+ const $audio = $(``)
+ if ( $audio.length === 0 )
+ $(document).append($audio)
+
+ if ( !$audio.paused )
+ {
+ frappe.log.info('Stopping sound playing.')
+ $audio[0].pause()
+ $audio.attr('currentTime', 0)
+ }
+
+ frappe.log.info('Playing sound.')
+ $audio.attr('src', `${frappe.chat.sound.PATH}/chat-${name}.mp3`)
+ $audio[0].play()
+}
+frappe.chat.sound.PATH = '/assets/frappe/sounds'
+
+// frappe.chat.emoji
+frappe.chat.emojis = [ ]
+frappe.chat.emoji = function (fn)
+{
+ return new Promise(resolve => {
+ if ( !frappe._.is_empty(frappe.chat.emojis) )
+ {
+ if ( fn )
+ fn(frappe.chat.emojis)
+
+ resolve(frappe.chat.emojis)
+ }
+ else
+ $.get('https://cdn.rawgit.com/achillesrasquinha/emoji/master/emoji', (data) => {
+ frappe.chat.emojis = JSON.parse(data)
+
+ if ( fn )
+ fn(frappe.chat.emojis)
+
+ resolve(frappe.chat.emojis)
+ })
+ })
+}
+
+const { h, Component } = hyper
+
+// frappe.components
+// frappe's component namespace.
+frappe.provide('frappe.components')
+
+/**
+ * @description Button Component
+ *
+ * @prop {string} type - (Optional) "default", "primary", "info", "success", "warning", "danger" (defaults to "default")
+ * @prop {boolean} block - (Optional) Render a button block (defaults to false).
+ */
+frappe.components.Button
+=
+class extends Component
+{
+ render ( )
+ {
+ const { props } = this
+
+ return (
+ h("button", { ...props, class: `btn btn-${props.type} ${props.block ? "btn-block" : ""} ${props.class ? props.class : ""}` },
+ props.children
+ )
+ )
+ }
+}
+frappe.components.Button.defaultProps
+=
+{
+ type: "default",
+ block: false
+}
+
+/**
+ * @description FAB Component
+ *
+ * @extends frappe.components.Button
+ */
+frappe.components.FAB
+=
+class extends frappe.components.Button
+{
+ render ( )
+ {
+ const { props } = this
+ const size = frappe.components.FAB.SIZE[props.size]
+
+ return (
+ h(frappe.components.Button, { ...props, class: `${props.class} ${size && size.class}`},
+ h("i", { class: props.icon })
+ )
+ )
+ }
+}
+frappe.components.FAB.defaultProps
+=
+{
+ icon: "octicon octicon-plus"
+}
+frappe.components.FAB.SIZE
+=
+{
+ small:
+ {
+ class: "frappe-fab-sm"
+ },
+ large:
+ {
+ class: "frappe-fab-lg"
+ }
+}
+
+/**
+ * @description Octicon Component
+ *
+ * @prop color - (Required) color for the indicator
+ */
+frappe.components.Indicator
+=
+class extends Component
+{
+ render ( ) {
+ const { props } = this
+
+ return props.color ? h("span", { ...props, class: `indicator ${props.color}` }) : null
+ }
+}
+
+/**
+ * @description FontAwesome Component
+ */
+frappe.components.FontAwesome
+=
+class extends Component
+{
+ render ( )
+ {
+ const { props } = this
+
+ return props.type ? h("i", { ...props, class: `fa ${props.fixed ? "fa-fw" : ""} fa-${props.type}` }) : null
+ }
+}
+frappe.components.FontAwesome.defaultProps
+=
+{
+ fixed: false
+}
+
+/**
+ * @description Octicon Component
+ *
+ * @extends frappe.Component
+ */
+frappe.components.Octicon
+=
+class extends Component
+{
+ render ( )
+ {
+ const { props } = this
+
+ return props.type ? h("i", { ...props, class: `octicon octicon-${props.type}` }) : null
+ }
+}
+
+/**
+ * @description Avatar Component
+ *
+ * @prop {string} title - (Optional) title for the avatar.
+ * @prop {string} abbr - (Optional) abbreviation for the avatar, defaults to the first letter of the title.
+ * @prop {string} size - (Optional) size of the avatar to be displayed.
+ * @prop {image} image - (Optional) image for the avatar, defaults to the first letter of the title.
+ */
+frappe.components.Avatar
+=
+class extends Component
+{
+ render ( )
+ {
+ const { props } = this
+ const abbr = props.abbr || props.title.substr(0, 1)
+ const size = frappe.components.Avatar.SIZE[props.size] || frappe.components.Avatar.SIZE.medium
+
+ return (
+ h("span", { class: `avatar ${size.class} ${props.class ? props.class : ""}` },
+ props.image ?
+ h("img", { class: "media-object", src: props.image })
+ :
+ h("div", { class: "standard-image" }, abbr)
+ )
+ )
+ }
+}
+frappe.components.Avatar.SIZE
+=
+{
+ small:
+ {
+ class: "avatar-small"
+ },
+ large:
+ {
+ class: "avatar-large"
+ },
+ medium:
+ {
+ class: "avatar-medium"
+ }
+}
+
+/**
+ * @description Frappe Chat Object.
+ *
+ * @example
+ * const chat = new frappe.Chat(options) // appends to "body"
+ * chat.render()
+ * const chat = new frappe.Chat(".selector", options)
+ * chat.render()
+ *
+ * const chat = new frappe.Chat()
+ * chat.set_wrapper('.selector')
+ * .set_options(options)
+ * .render()
+ */
+frappe.Chat
+=
+class
+{
+ /**
+ * @description Frappe Chat Object.
+ *
+ * @param {string} selector - A query selector, HTML Element or jQuery object.
+ * @param {object} options - Optional configurations.
+ */
+ constructor (selector, options)
+ {
+ if ( !(typeof selector === "string" || selector instanceof $ || selector instanceof HTMLElement) )
+ {
+ options = selector
+ selector = null
+ }
+
+ this.options = frappe.Chat.OPTIONS
+
+ this.set_wrapper(selector ? selector : "body")
+ this.set_options(options)
+
+ // Load Emojis.
+ frappe.chat.emoji()
+ }
+
+ /**
+ * Set the container on which the chat widget is mounted on.
+ * @param {string|HTMLElement} selector - A query selector, HTML Element or jQuery object.
+ *
+ * @returns {frappe.Chat} - The instance.
+ *
+ * @example
+ * const chat = new frappe.Chat()
+ * chat.set_wrapper(".selector")
+ */
+ set_wrapper (selector)
+ {
+ this.$wrapper = $(selector)
+
+ return this
+ }
+
+ /**
+ * Set the configurations for the chat interface.
+ * @param {object} options - Optional Configurations.
+ *
+ * @returns {frappe.Chat} - The instance.
+ *
+ * @example
+ * const chat = new frappe.Chat()
+ * chat.set_options({ layout: frappe.Chat.Layout.PAGE })
+ */
+ set_options (options)
+ {
+ this.options = { ...this.options, ...options }
+
+ return this
+ }
+
+ /**
+ * @description Destory the chat widget.
+ *
+ * @returns {frappe.Chat} - The instance.
+ *
+ * @example
+ * const chat = new frappe.Chat()
+ * chat.render()
+ * .destroy()
+ */
+ destroy ( )
+ {
+ const $wrapper = this.$wrapper
+ $wrapper.remove(".frappe-chat")
+
+ return this
+ }
+
+ /**
+ * @description Render the chat widget component onto destined wrapper.
+ *
+ * @returns {frappe.Chat} - The instance.
+ *
+ * @example
+ * const chat = new frappe.Chat()
+ * chat.render()
+ */
+ render ( )
+ {
+ this.destroy()
+
+ const $wrapper = this.$wrapper
+ const options = this.options
+
+ const component = h(frappe.Chat.Widget,
+ {
+ layout: options.layout,
+ target: options.target
+ })
+
+ hyper.render(component, $wrapper[0])
+
+ return this
+ }
+}
+frappe.Chat.Layout
+=
+{
+ PAGE: "page", POPPER: "popper"
+}
+frappe.Chat.OPTIONS
+=
+{
+ layout: frappe.Chat.Layout.POPPER
+}
+
+/**
+ * @description The base Component for Frappe Chat
+ */
+frappe.Chat.Widget
+=
+class extends Component
+{
+ constructor (props)
+ {
+ super (props)
+
+ this.room = { }
+ this.room.add = (rooms) =>
+ {
+ rooms = frappe._.as_array(rooms)
+ const names = rooms.map(r => r.name)
+
+ frappe.log.info(`Subscribing ${frappe.session.user} to Chat Rooms ${names.join(", ")}.`)
+ frappe.chat.room.subscribe(names)
+
+ const state = [ ]
+
+ for (const room of rooms)
+ if ( room.type === "Group" || room.owner === frappe.session.user || room.last_message )
+ {
+ frappe.log.info(`Adding ${room.name} to component.`)
+ state.push(room)
+ }
+
+ this.set_state({ rooms: [ ...this.state.rooms, ...state ] })
+ }
+ this.room.update = (room, update) =>
+ {
+ const { state } = this
+ var exists = false
+ const rooms = state.rooms.map(r =>
+ {
+ if ( r.name === room )
+ {
+ exists = true
+ if ( update.typing )
+ {
+ if ( !frappe._.is_empty(r.typing) )
+ {
+ const usr = update.typing
+ if ( !r.typing.includes(usr) )
+ {
+ update.typing = frappe._.copy_array(r.typing)
+ update.typing.push(usr)
+ }
+ }
+ else
+ update.typing = frappe._.as_array(update.typing)
+ }
+
+ return { ...r, ...update }
+ }
+
+ return r
+ })
+
+ if ( !exists )
+ frappe.chat.room.get(room, (room) => this.room.add(room))
+ else
+ this.set_state({ rooms })
+
+ if ( state.room.name === room )
+ {
+ if ( update.typing )
+ {
+ if ( !frappe._.is_empty(state.room.typing) )
+ {
+ const usr = update.typing
+ if ( !state.room.typing.includes(usr) )
+ {
+ update.typing = frappe._.copy_array(state.room.typing)
+ update.typing.push(usr)
+ }
+ } else
+ update.typing = frappe._.as_array(update.typing)
+ }
+
+ const room = { ...state.room, ...update }
+
+ this.set_state({ room })
+ }
+ }
+ this.room.select = (name) =>
+ {
+ frappe.chat.room.history(name, (messages) =>
+ {
+ const { state } = this
+ const room = state.rooms.find(r => r.name === name)
+
+ this.set_state({
+ room: { ...state.room, ...room, messages: messages }
+ })
+ })
+ }
+
+ this.state = frappe.Chat.Widget.defaultState
+
+ this.make()
+ }
+
+ make ( ) {
+ frappe.chat.profile.create([
+ "status", "display_widget", "notification_tones", "conversation_tones"
+ ], profile =>
+ {
+ frappe.log.info(`Chat Profile created for User ${frappe.session.user}.`)
+ this.set_state({ profile })
+
+ frappe.chat.room.get(rooms =>
+ {
+ rooms = frappe._.as_array(rooms)
+ frappe.log.info(`User ${frappe.session.user} is subscribed to ${rooms.length} ${frappe._.pluralize('room', rooms.length)}.`)
+
+ if ( rooms.length )
+ this.room.add(rooms)
+ })
+
+ this.bind()
+ })
+ }
+
+ bind ( ) {
+ frappe.chat.profile.on.update((user, update) =>
+ {
+ frappe.log.warn(`TRIGGER: Chat Profile update ${JSON.stringify(update)} of User ${user}.`)
+
+ if ( 'status' in update )
+ {
+ if ( user === frappe.session.user )
+ {
+ this.set_state({
+ profile: { ...this.state.profile, status: update.status }
+ })
+ } else
+ {
+ const status = frappe.chat.profile.STATUSES.find(s => s.name === update.status)
+ const color = status.color
+
+ const alert = ` ${frappe.user.full_name(user)} is currently ${update.status}`
+ frappe.show_alert(alert, 3)
+ }
+ }
+
+ if ( 'display_widget' in update )
+ {
+ this.set_state({
+ profile: { ...this.state.profile, display_widget: update.display_widget }
+ })
+ }
+ })
+
+ frappe.chat.room.on.create((room) =>
+ {
+ frappe.log.warn(`TRIGGER: Chat Room ${room.name} created.`)
+ this.room.add(room)
+ })
+
+ frappe.chat.room.on.update((room, update) =>
+ {
+ frappe.log.warn(`TRIGGER: Chat Room ${room} update ${JSON.stringify(update)} recieved.`)
+ this.room.update(room, update)
+ })
+
+ frappe.chat.room.on.typing((room, user) => {
+ if ( user !== frappe.session.user )
+ {
+ frappe.log.warn(`User ${user} typing in Chat Room ${room}.`)
+ this.room.update(room, { typing: user })
+
+ setTimeout(() => this.room.update(room, { typing: null }), 1500)
+ }
+ })
+
+ frappe.chat.message.on.create((r) =>
+ {
+ const { state } = this
+
+ // play sound.
+ if ( state.room.name )
+ state.profile.conversation_tones && frappe.chat.sound.play('message')
+ else
+ state.profile.notification_tones && frappe.chat.sound.play('notification')
+
+ if ( r.room === state.room.name )
+ {
+ const mess = frappe._.copy_array(state.room.messages)
+ mess.push(r)
+
+ this.set_state({ room: { ...state.room, messages: mess } })
+ }
+ })
+
+ frappe.chat.message.on.update((message, update) =>
+ {
+ frappe.log.warn(`TRIGGER: Chat Message ${message} update ${JSON.stringify(update)} recieved.`)
+ })
+ }
+
+ render ( )
+ {
+ const { props, state } = this
+ const me = this
+
+ const ActionBar = h(frappe.Chat.Widget.ActionBar,
+ {
+ layout: props.layout,
+ actions:
+ [
+ {
+ label: __("New Message"),
+ icon: "octicon octicon-comment",
+ click: function ( )
+ {
+ const dialog = new frappe.ui.Dialog({
+ title: __("New Message"),
+ animate: false,
+ fields: [
+ {
+ label: __("Select User"),
+ fieldname: "user",
+ fieldtype: "Link",
+ options: "User",
+ reqd: true,
+ filters: { name: ["!=", frappe.session.user] } // not working?
+ }
+ ],
+ action:
+ {
+ primary:
+ {
+ label: __("Create"),
+ click: function ({ user })
+ {
+ dialog.hide()
+
+ // Don't Worry, frappe.chat.room.on.create gets triggered that then subscribes and adds to DOM. :)
+ frappe.chat.room.create("Direct", null, user)
+ }
+ },
+ secondary:
+ {
+ label: frappe._.is_mobile() ? "×" : __(`Cancel`)
+ }
+ }
+ })
+ dialog.show()
+ }
+ },
+ {
+ label: __("New Group"),
+ icon: "octicon octicon-organization",
+ click: function ( )
+ {
+ const dialog = new frappe.ui.Dialog({
+ title: __("New Group"),
+ animate: false,
+ fields: [
+ {
+ label: __("Name"),
+ fieldname: "name",
+ fieldtype: "Data",
+ reqd: true
+ },
+ {
+ label: __("Select Users"),
+ fieldname: "users",
+ fieldtype: "MultiSelect",
+ options: Object.keys(frappe.boot.user_info).map(key => frappe.boot.user_info[key].email)
+ }
+ ],
+ action:
+ {
+ primary:
+ {
+ label: __(`Create`),
+ click: function ({ name, users })
+ {
+ dialog.hide()
+
+ // MultiSelect, y u no JSON? :(
+ if ( users )
+ {
+ users = users.split(", ")
+ users = users.slice(0, users.length - 1)
+ }
+
+ // Don't Worry, frappe.chat.room.on.create gets triggered that then subscribes and adds to DOM. :)
+ frappe.chat.room.create("Group", null, users, name)
+ }
+ },
+ secondary:
+ {
+ label: frappe._.is_mobile() ? "×" : __(`Cancel`)
+ }
+ }
+ })
+
+ dialog.show()
+ }
+ }
+ ],
+ change: function (query)
+ {
+ me.set_state({
+ query: query
+ })
+ }
+ })
+
+ const rooms = state.query ? frappe.chat.room.search(state.query, state.rooms) : frappe.chat.room.sort(state.rooms)
+
+ const RoomList = h(frappe.Chat.Widget.RoomList, { rooms: rooms, click: this.room.select })
+ const Room = h(frappe.Chat.Widget.Room, { ...state.room, layout: props.layout, destroy: () => {
+ this.set_state({
+ room: { name: null, messages: [ ] }
+ })
+ }})
+
+ const component = props.layout === frappe.Chat.Layout.POPPER ?
+ state.profile.display_widget ?
+ h(frappe.Chat.Widget.Popper, { page: state.room.name && Room, target: props.target },
+ h("span", null,
+ ActionBar, RoomList
+ )
+ ) : null
+ :
+ h("div", { class: "row" },
+ h("div", { class: "col-md-2 col-sm-3 layout-side-section" },
+ ActionBar, RoomList
+ ),
+ h("div", { class: "col-md-10 col-sm-9 layout-main-section-wrapper" },
+ state.room.name ?
+ Room : (
+ h("div", { style: "margin-top: 240px" },
+ h("div", { class: "text-center text-extra-muted" },
+ h(frappe.components.Octicon, { type: "comment-discussion", style: "font-size: 48px" }),
+ h("p", null, __("Select a chat to start messaging."))
+ )
+ )
+ )
+ )
+ )
+
+ return component ?
+ h("div", { class: "frappe-chat" },
+ component
+ ) : null
+ }
+}
+frappe.Chat.Widget.defaultState =
+{
+ query: "",
+ profile: { },
+ rooms: [ ],
+ room: { name: null, messages: [ ], typing: [ ] }
+}
+frappe.Chat.Widget.defaultProps =
+{
+ layout: frappe.Chat.Layout.POPPER
+}
+
+/**
+ * @description Chat Widget Popper HOC.
+ */
+frappe.Chat.Widget.Popper
+=
+class extends Component
+{
+ constructor (props) {
+ super (props)
+
+ this.toggle = this.toggle.bind(this)
+
+ this.state = frappe.Chat.Widget.Popper.defaultState
+
+ if ( props.target )
+ $(props.target).click(() => this.toggle())
+ }
+
+ toggle (active)
+ {
+
+ let toggle
+ if ( arguments.length === 1 )
+ toggle = active
+ else
+ toggle = this.state.active ? false : true
+
+ this.set_state({ active: toggle })
+ }
+
+ render ( )
+ {
+ const { props, state } = this
+
+ return !state.destroy ?
+ (
+ h("div", { class: "frappe-chat-popper" },
+ !props.target ?
+ h(frappe.components.FAB, {
+ class: "frappe-fab",
+ icon: state.active ? "fa fa-fw fa-times" : "font-heavy octicon octicon-comment",
+ size: frappe._.is_mobile() ? null : "large",
+ type: "primary",
+ onclick: () => this.toggle(),
+ }) : null,
+ state.active ?
+ h("div", { class: "frappe-chat-popper-collapse" },
+ props.page ? props.page : (
+ h("div", { class: `panel panel-primary ${frappe._.is_mobile() ? "panel-span" : ""}` },
+ h("div", { class: "panel-heading cursor-pointer", onclick: () => this.toggle(false) },
+ h("div", { class: "row" },
+ h("div", { class: "col-xs-9" }),
+ h("div", { class: "col-xs-3" },
+ h("div", { class: "text-right" },
+ // !frappe._.is_mobile() ?
+ // h("a", { class: "action", onclick: () =>
+ // {
+ // frappe.set_route('chat')
+ // this.toggle(false)
+ // }},
+ // h(frappe.components.FontAwesome, { type: "expand", fixed: true })
+ // ) : null,
+ h("a", { class: "action", onclick: () => this.toggle(false) },
+ h(frappe.components.Octicon, { type: "x" })
+ )
+ )
+ )
+ )
+ ),
+ h("div", { class: "panel-body" },
+ props.children
+ )
+ )
+ )
+ ) : null
+ )
+ ) : null
+ }
+}
+frappe.Chat.Widget.Popper.defaultState
+=
+{
+ active: false,
+ destroy: false
+}
+
+/**
+ * @description frappe.Chat.Widget ActionBar Component
+ */
+frappe.Chat.Widget.ActionBar
+=
+class extends Component
+{
+ constructor (props)
+ {
+ super (props)
+
+ this.change = this.change.bind(this)
+ this.submit = this.submit.bind(this)
+
+ this.state = frappe.Chat.Widget.ActionBar.defaultState
+ }
+
+ change (e)
+ {
+ const { props, state } = this
+
+ this.set_state({
+ [e.target.name]: e.target.value
+ })
+
+ props.change(state.query)
+ }
+
+ submit (e)
+ {
+ const { props, state } = this
+
+ e.preventDefault()
+
+ props.submit(state.query)
+ }
+
+ render ( )
+ {
+ const { props, state } = this
+ const popper = props.layout === frappe.Chat.Layout.POPPER
+
+ return (
+ h("form", { oninput: this.change, onsubmit: this.submit, style: popper ? { "padding-left": "15px", "padding-right": "15px" } : null },
+ h("div", { class: "form-group" },
+ h("div", { class: "input-group input-group-sm" },
+ props.span || props.layout !== frappe.Chat.Layout.PAGE ?
+ h("div", { class: "input-group-addon" },
+ h(frappe.components.Octicon, { type: "search" })
+ ) : null,
+ h("input", { class: "form-control", name: "query", value: state.query, placeholder: "Search" }),
+ Array.isArray(props.actions) ?
+ h("div", { class: "input-group-btn" },
+ h(frappe.components.Button, { type: "primary", class: "dropdown-toggle", "data-toggle": "dropdown" },
+ h(frappe.components.Octicon, { type: "plus" })
+ ),
+ h("ul", { class: "dropdown-menu dropdown-menu-right" },
+ props.actions.map(action =>
+ h("li", null,
+ h("a", { onclick: action.click },
+ h(frappe.Chat.Widget.ActionBar.Action, { ...action })
+ )
+ )
+ )
+ )
+ ) : null
+ )
+ )
+ )
+ )
+ }
+}
+frappe.Chat.Widget.ActionBar.defaultState
+=
+{
+ span: false,
+ query: null
+}
+
+/**
+ * @description frappe.Chat.Widget ActionBar's Action Component.
+ */
+frappe.Chat.Widget.ActionBar.Action
+=
+class extends Component
+{
+ render ( )
+ {
+ const { props } = this
+
+ return (
+ h("span", null,
+ props.icon ?
+ h("i", { class: props.icon })
+ :
+ null,
+ `${props.icon ? " " : ""}${props.label}`
+ )
+ )
+ }
+}
+
+/**
+ * @description frappe.Chat.Widget RoomList Component
+ */
+frappe.Chat.Widget.RoomList
+=
+class extends Component
+{
+ render ( )
+ {
+ const { props } = this
+ const rooms = props.rooms
+
+ return rooms.length ? (
+ h("ul", { class: "nav nav-pills nav-stacked" },
+ rooms.map(room => h(frappe.Chat.Widget.RoomList.Item, { ...room, click: props.click }))
+ )
+ ) : null
+ }
+}
+
+/**
+ * @description frappe.Chat.Widget RoomList's Item Component
+ */
+frappe.Chat.Widget.RoomList.Item
+=
+class extends Component
+{
+ render ( )
+ {
+ const { props } = this
+ const item = { }
+
+ if ( props.type === "Group" ) {
+ item.title = props.room_name
+ item.image = props.avatar
+
+ if ( !frappe._.is_empty(props.typing) )
+ {
+ props.typing = frappe._.as_array(props.typing) // HACK: (BUG) why does typing return a string?
+ const names = props.typing.map(user => frappe.user.first_name(user))
+ item.subtitle = `${names.join(", ")} typing...`
+ } else
+ if ( props.last_message )
+ item.subtitle = props.last_message.content
+ } else {
+ const user = props.owner === frappe.session.user ? frappe._.squash(props.users) : props.owner
+
+ item.title = frappe.user.full_name(user)
+ item.image = frappe.user.image(user)
+ item.abbr = frappe.user.abbr(user)
+
+ if ( !frappe._.is_empty(props.typing) )
+ item.subtitle = 'typing...'
+ else
+ if ( props.last_message )
+ item.subtitle = props.last_message.content
+ }
+
+ if ( props.last_message )
+ item.timestamp = frappe.chat.pretty_datetime(props.last_message.creation)
+
+ return (
+ h("li", null,
+ h("a", { class: props.active ? "active": "", onclick: () => props.click(props.name) },
+ h("div", { class: "row" },
+ h("div", { class: "col-xs-9" },
+ h(frappe.Chat.Widget.MediaProfile, { ...item })
+ ),
+ h("div", { class: "col-xs-3 text-right" },
+ h("div", { class: "text-muted", style: { "font-size": "9px" } }, item.timestamp)
+ ),
+ )
+
+ )
+ )
+ )
+ }
+}
+
+/**
+ * @description frappe.Chat.Widget's MediProfile Component.
+ */
+frappe.Chat.Widget.MediaProfile
+=
+class extends Component
+{
+ render ( )
+ {
+ const { props } = this
+ const position = frappe.Chat.Widget.MediaProfile.POSITION[props.position || "left"]
+ const avatar = (
+ h("div", { class: `${position.class} media-top` },
+ h(frappe.components.Avatar, { ...props,
+ title: props.title,
+ image: props.image,
+ size: props.size,
+ abbr: props.abbr
+ })
+ )
+ )
+
+ return (
+ h("div", { class: "media", style: position.class === "media-right" ? { "text-align": "right" } : null },
+ position.class === "media-left" ? avatar : null,
+ h("div", { class: "media-body" },
+ h("div", { class: "media-heading h6 ellipsis", style: `max-width: ${props.width_title || "100%"} display: inline-block` }, props.title),
+ props.content ? h("div", null, h("small", { class: "h6" }, props.content)) : null,
+ props.subtitle ? h("div", null, h("small", { class: "text-muted" }, props.subtitle)) : null
+ ),
+ position.class === "media-right" ? avatar : null
+ )
+ )
+ }
+}
+frappe.Chat.Widget.MediaProfile.POSITION
+=
+{
+ left: { class: "media-left" }, right: { class: "media-right" }
+}
+
+/**
+ * @description frappe.Chat.Widget Room Component
+ */
+frappe.Chat.Widget.Room
+=
+class extends Component
+{
+ render ( )
+ {
+ const { props, state } = this
+ const hints =
+ [
+ {
+ match: /@(\w*)$/,
+ search: function (keyword, callback)
+ {
+ if ( props.type === 'Group' )
+ {
+ const query = keyword.slice(1)
+ const users = [].concat(frappe._.as_array(props.owner), props.users)
+ const grep = users.filter(user => user !== frappe.session.user && user.indexOf(query) === 0)
+
+ callback(grep)
+ }
+ },
+ component: function (item)
+ {
+ return (
+ h(frappe.Chat.Widget.MediaProfile,
+ {
+ title: frappe.user.full_name(item),
+ image: frappe.user.image(item),
+ size: "small"
+ })
+ )
+ }
+ },
+ {
+ match: /:([a-z]*)$/,
+ search: function (keyword, callback)
+ {
+ frappe.chat.emoji(function (emojis)
+ {
+ const query = keyword.slice(1)
+ const items = [ ]
+ for (const emoji of emojis)
+ for (const alias of emoji.aliases)
+ if ( alias.indexOf(query) === 0 )
+ items.push({ name: alias, value: emoji.emoji })
+
+ callback(items)
+ })
+ },
+ content: (item) => item.value,
+ component: function (item)
+ {
+ return (
+ h(frappe.Chat.Widget.MediaProfile,
+ {
+ title: item.name,
+ abbr: item.value,
+ size: "small"
+ })
+ )
+ }
+ }
+ ]
+
+ const actions = frappe._.compact([
+ !frappe._.is_mobile() &&
+ {
+ icon: "camera",
+ label: "Camera",
+ click: ( ) => {
+ const capture = new frappe.ui.Capture({
+ animate: false,
+ error: true
+ })
+ capture.show()
+
+ capture.submit(data_url =>
+ {
+ // data_url
+ })
+ }
+ },
+ {
+ icon: "file",
+ label: "File",
+ click: ( ) => {
+
+ }
+ }
+ ])
+
+ if (props.messages)
+ {
+ props.messages = frappe._.as_array(props.messages)
+ for (const message of props.messages)
+ frappe.chat.message.seen(message.name)
+ }
+
+ return (
+ h("div", { class: `panel panel-primary ${frappe._.is_mobile() ? "panel-span" : ""}` },
+ h(frappe.Chat.Widget.Room.Header, { ...props, back: props.destroy }),
+ !frappe._.is_empty(props.messages) ?
+ h(frappe.Chat.Widget.ChatList, {
+ messages: !frappe._.is_empty(props.messages) && frappe.chat.message.sort(props.messages)
+ })
+ :
+ h("div", { class: "panel-body" },
+ h("div", { style: "margin-top: 145px" },
+ h("div", { class: "text-center text-extra-muted" },
+ h(frappe.components.Octicon, { type: "comment-discussion", style: "font-size: 48px" }),
+ h("p", null, __("Start a conversation."))
+ )
+ )
+ ),
+ h("div", { class: "frappe-chat-room-footer" },
+ h(frappe.Chat.Widget.ChatForm, { actions: actions,
+ change: () => {
+ frappe.chat.message.typing(props.name)
+ },
+ submit: (message) => {
+ frappe.chat.message.send(props.name, message)
+ },
+ hint: hints
+ })
+ )
+ )
+ )
+ }
+}
+
+frappe.Chat.Widget.Room.Header
+=
+class extends Component
+{
+ render ( )
+ {
+ const { props } = this
+
+ const item = { }
+
+ if ( props.type === "Group" ) {
+ item.route = `Form/Chat Room/${props.name}`
+
+ item.title = props.room_name
+ item.image = props.avatar
+
+ if ( !frappe._.is_empty(props.typing) )
+ {
+ props.typing = frappe._.as_array(props.typing) // HACK: (BUG) why does typing return as a string?
+ const users = props.typing.map(user => frappe.user.first_name(user))
+ item.subtitle = `${users.join(", ")} typing...`
+ } else
+ item.subtitle = __(`${props.users.length} ${frappe._.pluralize('member', props.users.length)}`)
+ }
+ else
+ {
+ const user = props.owner === frappe.session.user ? frappe._.squash(props.users) : props.owner
+
+ item.route = `Form/User/${user}`
+
+ item.title = frappe.user.full_name(user)
+ item.image = frappe.user.image(user)
+
+ if ( !frappe._.is_empty(props.typing) )
+ item.subtitle = 'typing...'
+ }
+
+ const popper = props.layout === frappe.Chat.Layout.POPPER || frappe._.is_mobile()
+
+ return (
+ h("div", { class: "panel-heading" },
+ h("div", { class: "row" },
+ popper ?
+ h("div", { class: "col-xs-1" },
+ h("a", { onclick: props.back }, h(frappe.components.Octicon, { type: "chevron-left" }))
+ ) : null,
+ h("div", { class: popper ? "col-xs-10" : "col-xs-9" },
+ h("div", { class: "panel-title" },
+ h("div", { class: "cursor-pointer", onclick: () => { frappe.set_route(item.route) }},
+ h(frappe.Chat.Widget.MediaProfile, { ...item })
+ )
+ )
+ ),
+ h("div", { class: popper ? "col-xs-1" : "col-xs-3" },
+ h("div", { class: "text-right" },
+
+ )
+ )
+ )
+ )
+ )
+ }
+}
+
+/**
+ * @description Chat Form Component
+ */
+frappe.Chat.Widget.ChatForm
+=
+class extends Component {
+ constructor (props) {
+ super (props)
+
+ this.change = this.change.bind(this)
+ this.submit = this.submit.bind(this)
+
+ this.hint = this.hint.bind(this)
+
+ this.state = frappe.Chat.Widget.ChatForm.defaultState
+ }
+
+ change (e)
+ {
+ const { props, state } = this
+ const value = e.target.value
+
+ this.set_state({
+ [e.target.name]: value
+ })
+
+ props.change(state)
+
+ this.hint(value)
+ }
+
+ hint (value)
+ {
+ const { props, state } = this
+
+ if ( props.hint )
+ {
+ const tokens = value.split(" ")
+ const sliced = tokens.slice(0, tokens.length - 1)
+
+ const token = tokens[tokens.length - 1]
+
+ if ( token )
+ {
+ props.hint = frappe._.as_array(props.hint)
+ const hint = props.hint.find(hint => hint.match.test(token))
+
+ if ( hint )
+ {
+ hint.search(token, items =>
+ {
+ const hints = items.map(item =>
+ {
+ // You should stop writing one-liners! >_>
+ const replace = token.replace(hint.match, hint.content ? hint.content(item) : item)
+ const content = `${sliced.join(" ")} ${replace}`.trim()
+ item = { component: hint.component(item), content: content }
+
+ return item
+ }).slice(0, hint.max || 5)
+
+ this.set_state({ hints })
+ })
+ }
+ else
+ this.set_state({ hints: [ ] })
+ } else
+ this.set_state({ hints: [ ] })
+ }
+ }
+
+ submit (e)
+ {
+ e.preventDefault()
+
+ if ( this.state.content )
+ {
+ this.props.submit(this.state.content)
+
+ this.set_state({ content: null })
+ }
+ }
+
+ render ( ) {
+ const { props, state } = this
+
+ return (
+ h("div", { class: "frappe-chat-form" },
+ state.hints.length ?
+ h("li", { class: "list-group" },
+ state.hints.map((item) =>
+ {
+ return (
+ h("a", { class: "list-group-item", href: "javascript:void(0)", onclick: () =>
+ {
+ this.set_state({ content: item.content, hints: [ ] })
+ }},
+ item.component
+ )
+ )
+ })
+ ) : null,
+ h("form", { oninput: this.change, onsubmit: this.submit },
+ h("div", { class: "input-group input-group-lg" },
+ h("div", { class: "input-group-btn dropup" },
+ h(frappe.components.Button, { class: "dropdown-toggle", "data-toggle": "dropdown" },
+ h(frappe.components.FontAwesome, { type: "paperclip", fixed: true, style: { "font-size": "14px" } })
+ ),
+ h("div", { class: "dropdown-menu dropdown-menu-left", onclick: e => e.stopPropagation() },
+ !frappe._.is_empty(props.actions) && props.actions.map((action) => {
+ return (
+ h("li", null,
+ h("a", { onclick: action.click },
+ h(frappe.components.FontAwesome, { type: action.icon, fixed: true }), ` ${action.label}`,
+ )
+ )
+ )
+ })
+ )
+ ),
+ h("input",
+ {
+ class: "form-control",
+ name: "content",
+ value: state.content,
+ placeholder: "Type a message",
+ autofocus: true,
+ onkeypress: (e) =>
+ {
+ if ( e.which === frappe.ui.keycode.RETURN && !e.shiftKey )
+ this.submit(e)
+ }
+ }),
+ h("div", { class: "input-group-btn" },
+ h(frappe.components.Button, { type: "primary", class: "dropdown-toggle", "data-toggle": "dropdown", onclick: this.submit },
+ h(frappe.components.FontAwesome, { type: "send", fixed: true, style: { "font-size": "14px" } })
+ ),
+ )
+ )
+ )
+ )
+ )
+ }
+}
+frappe.Chat.Widget.ChatForm.defaultState
+=
+{
+ content: null,
+ hints: [ ],
+}
+
+frappe.Chat.Widget.EmojiPicker
+=
+class extends Component
+{
+ render ( )
+ {
+ const { props } = this
+
+ return (
+ h("div", { class: `frappe-chat-emoji dropup ${props.class}` },
+ h(frappe.components.Button, { type: "primary", class: "dropdown-toggle", "data-toggle": "dropdown" },
+ h(frappe.components.FontAwesome, { type: "smile-o", fixed: true })
+ ),
+ h("div", { class: "dropdown-menu dropdown-menu-right", onclick: e => e.stopPropagation() },
+ h("div", { class: "panel panel-default" },
+ h(frappe.Chat.Widget.EmojiPicker.List)
+ )
+ )
+ )
+ )
+ }
+}
+frappe.Chat.Widget.EmojiPicker.List
+=
+class extends Component
+{
+ render ( )
+ {
+ const { props } = this
+
+ return (
+ h("div", { class: "list-group" },
+ frappe.ui.Emoji.map((category) =>
+ {
+ return (
+ h("div", { class: "list-group-item" },
+ h("div", { class: "h6" }, frappe._.capitalize(category.name)),
+ h("div", null,
+
+ )
+ )
+ )
+ })
+ )
+ )
+ }
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+// return (
+// h("a", { class: "list-group-item", href: "#", onclick: () => {
+// this.set_state({
+// content: `${this.state.content}${item.value}`
+// })
+// }},
+// props.hint.component(item)
+// )
+// )
+
+
+frappe.Chat.Widget.Account
+=
+class extends Component {
+ render ( ) {
+ const { props } = this
+ const statuses = frappe.chat.profile.STATUSES.map(s => {
+ return {
+ value: s.name,
+ label: s.name,
+ color: s.color
+ }
+ })
+ return (
+ h(frappe.components.Select, { value: props.status, options: statuses, click: (value) => {
+ if ( props.status != value )
+ props.on_change_status(value)
+ }})
+ )
+ }
+}
+
+
+
+
+
+/**
+ * @description Chat List HOC
+ *
+ *
+ */
+frappe.Chat.Widget.ChatList
+=
+class extends Component {
+ render ( ) {
+ const { props } = this
+
+ return !frappe._.is_empty(props.messages) ? (
+ h("ul", { class: "list-group" },
+ props.messages.map(m => h(frappe.Chat.Widget.ChatList.Item, {
+ ...m
+ }))
+ )
+ ) : null
+ }
+}
+
+frappe.Chat.Widget.ChatList.Item
+=
+class extends Component {
+ render ( ) {
+ const { props } = this
+
+ return (
+ h("li", { class: "list-group-item", style: "border: none !important" },
+ h(frappe.Chat.Widget.ChatList.Bubble, props)
+ )
+ )
+ }
+}
+
+frappe.Chat.Widget.ChatList.Bubble
+=
+class extends Component {
+ render ( ) {
+ const { props } = this
+
+ return (
+ h(frappe.Chat.Widget.MediaProfile, {
+ title: frappe.user.full_name(props.user),
+ subtitle: frappe.chat.pretty_datetime(props.creation),
+ content: props.content,
+ image: frappe.user.image(props.user),
+ width_title: "100%",
+ position: frappe.user.full_name(props.user) === "You" ? "right" : "left"
+ })
+ )
+ }
+}
+frappe.Chat.Widget.ChatList.Bubble.defaultState =
+{
+ creation: ""
+}
+
+
+
+
+
+
+
+
+
+
+// frappe.components.Select
+// options - (Required) array of options of the format
+// {
+// label: "foo",
+// value: "bar"
+// }
+// value - (Required) default value.
+// click - (Optional) click handler on click event.
+frappe.components.Select
+=
+class extends Component {
+ render ( ) {
+ const { props } = this
+ const selected = props.options.find(o => o.value === props.value)
+
+ return (
+ h("div", { class: "dropdown" },
+ h("button", { class: "btn btn-sm btn-default btn-block dropdown-toggle", "data-toggle": "dropdown" },
+ selected.color ?
+ h(frappe.components.Indicator, { color: selected.color }) : null,
+ selected.label ?
+ selected.label : selected.value,
+ ),
+ h("ul", { class: "dropdown-menu" },
+ props.options.map(o => h(frappe.components.Select.Option, { ...o, click: props.click }))
+ )
+ )
+ )
+ }
+}
+
+frappe.components.Select.Option
+=
+class extends Component {
+ render ( ) {
+ const { props } = this
+
+ return (
+ h("li", null,
+ h("a", { onclick: () => props.click(props.value) },
+ props.color ?
+ h(frappe.components.Indicator, { color: props.color }) : null,
+ props.label ?
+ props.label : props.value
+ )
+ )
+ )
+ }
+}
+// frappe.components.Select.Option props
+// same as frappe.components.Select.
+
+frappe.chat.profile.update
+=
+function (user, update, fn)
+{
+ return new Promise(resolve =>
+ {
+ frappe.call("frappe.chat.doctype.chat_profile.chat_profile.update",
+ { user: user || frappe.session.user, data: update },
+ response =>
+ {
+ if ( fn )
+ fn(response.message)
+
+ resolve(response.message)
+ })
+ })
+}
\ No newline at end of file
diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js
index 343c39d5f4..7aad51507a 100644
--- a/frappe/public/js/frappe/desk.js
+++ b/frappe/public/js/frappe/desk.js
@@ -18,6 +18,12 @@ $(document).ready(function() {
});
}
frappe.start_app();
+
+ // frappe.Chat
+ // Removing it from here as per rushabh@frappe.io's request.
+ // const chat = new frappe.Chat()
+ // chat.render();
+ // end frappe.Chat
});
frappe.Application = Class.extend({
@@ -29,7 +35,7 @@ frappe.Application = Class.extend({
this.startup();
},
startup: function() {
- frappe.socketio.init();
+ frappe.socketio.init()
frappe.model.init();
if(frappe.boot.status==='failed') {
diff --git a/frappe/public/js/frappe/form/controls/text_editor.js b/frappe/public/js/frappe/form/controls/text_editor.js
index 10783d0d7a..04164aa650 100644
--- a/frappe/public/js/frappe/form/controls/text_editor.js
+++ b/frappe/public/js/frappe/form/controls/text_editor.js
@@ -19,9 +19,9 @@ frappe.ui.form.ControlTextEditor = frappe.ui.form.ControlCode.extend({
tooltip: 'Camera',
click: () => {
const capture = new frappe.ui.Capture();
- capture.open();
+ capture.show();
- capture.click((data) => {
+ capture.submit((data) => {
context.invoke('editor.insertImage', data);
});
}
diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js
index 63710c959f..21aa20076a 100644
--- a/frappe/public/js/frappe/form/grid.js
+++ b/frappe/public/js/frappe/form/grid.js
@@ -58,6 +58,10 @@ frappe.ui.form.Grid = Class.extend({
this.wrapper.find(".grid-add-row").click(function() {
me.add_new_row(null, null, true);
me.set_focus_on_row();
+
+ // excel like paste.
+ me.setup_paste();
+
return false;
});
@@ -71,7 +75,69 @@ frappe.ui.form.Grid = Class.extend({
this.df.on_setup(this);
}
+ this.setup_paste();
},
+
+ setup_paste: function ( ) {
+ const me = this;
+ $(document).ready(function ( ) {
+ const $grid = $(me.wrapper).find('.form-grid');
+ const $rows = $grid.find('.grid-body .rows');
+ const $areas = $rows.find('.field-area');
+ $areas.click(function ( ) {
+ // Who even comes up with this BS?
+ if ( $(this).css('display') === 'block' ) {
+ const $input = $(this).find('input');
+
+ $input.on('paste', function (e) {
+ setTimeout(function ( ) {
+ const value = e.target.value;
+
+ // Do stuff here.
+ const rows = value.split(' ');
+ if ( rows.length ) {
+ const first = rows[0];
+
+ // Who cares validating? They'll do it for us.
+ const $area = $rows.find('.field-area').last();
+ if ( $area.css('display') === 'block' ) {
+ const $input = $area.find('input');
+ $input.val(first);
+ }
+
+ for (var i = 1 ; i < rows.length ; ++i) {
+ me.add_new_row(null, null, true);
+ me.set_focus_on_row();
+
+ // const $area = $rows.find('.field-area').last();
+ // if ( $area.css('display') === 'block' ) {
+ // const $input = $area.find('input');
+ // $input.val(rows[i]);
+ // }
+ }
+ }
+
+ // for (var i = 1 ; i < rows.length - 1 ; ++i) {
+ // // // Assuming only single column.
+ // // const elements = rows[i];
+
+ // // me.add_new_row(null, null, true);
+ // // me.set_focus_on_row();
+
+ // // // const grid_row = me.grid_rows[me.grid_rows.length - rows.length];
+ // // // console.log(me.grid_rows.length);
+ // // const grid_row = me.grid_rows[me.grid_rows.length - 1];
+ // // console.log(me.grid_rows.length - 1);
+ // }
+ // end stuff
+
+ }, 100); // When you can't even
+ });
+ }
+ });
+ });
+ },
+
setup_check: function() {
var me = this;
this.wrapper.on('click', '.grid-row-check', function(e) {
diff --git a/frappe/public/js/frappe/misc/common.js b/frappe/public/js/frappe/misc/common.js
index 15e698f498..f56a523569 100644
--- a/frappe/public/js/frappe/misc/common.js
+++ b/frappe/public/js/frappe/misc/common.js
@@ -82,7 +82,7 @@ frappe.get_abbr = function(txt, max_length) {
// continue
return true;
}
-
+87
abbr += w.trim()[0];
});
diff --git a/frappe/public/js/frappe/peer.js b/frappe/public/js/frappe/peer.js
new file mode 100644
index 0000000000..732a0a219f
--- /dev/null
+++ b/frappe/public/js/frappe/peer.js
@@ -0,0 +1,15 @@
+frappe.Peer = class
+{
+ constructor ( )
+ {
+ this.peer = new Peer()
+ this.peer.on('open', (ID) => {
+ console.log(`A new peer connection has occured with ID: ${ID}`)
+ })
+ }
+}
+
+frappe.Peer.boot = ( ) =>
+{
+ const client = new frappe.Peer()
+}
\ No newline at end of file
diff --git a/frappe/public/js/frappe/request.js b/frappe/public/js/frappe/request.js
index de474538ab..28f697819e 100644
--- a/frappe/public/js/frappe/request.js
+++ b/frappe/public/js/frappe/request.js
@@ -10,6 +10,7 @@ frappe.request.waiting_for_ajax = [];
// generic server call (call page, object)
frappe.call = function(opts) {
+
if (typeof arguments[0]==='string') {
opts = {
method: arguments[0],
diff --git a/frappe/public/js/frappe/socketio_client.js b/frappe/public/js/frappe/socketio_client.js
index 2c4477e1e6..b6785de49f 100644
--- a/frappe/public/js/frappe/socketio_client.js
+++ b/frappe/public/js/frappe/socketio_client.js
@@ -163,6 +163,7 @@ frappe.socketio = {
// notify that the user has closed this doc
frappe.socketio.socket.emit('doc_close', doctype, docname);
},
+
setup_listeners: function() {
frappe.socketio.socket.on('task_status_change', function(data) {
frappe.socketio.process_response(data, data.status.toLowerCase());
@@ -390,5 +391,4 @@ frappe.socketio.SocketIOUploader = class SocketIOUploader {
}
}
}
-
}
\ No newline at end of file
diff --git a/frappe/public/js/frappe/ui/capture.js b/frappe/public/js/frappe/ui/capture.js
index f555243975..2161970bf7 100644
--- a/frappe/public/js/frappe/ui/capture.js
+++ b/frappe/public/js/frappe/ui/capture.js
@@ -1,94 +1,204 @@
+// frappe.ui.Capture
+// Author - Achilles Rasquinha
+
+/**
+ * @description Converts a canvas, image or a video to a data URL string.
+ *
+ * @param {HTMLElement} element - canvas, img or video.
+ * @returns {string} - The data URL string.
+ *
+ * @example
+ * frappe._.get_data_uri(video)
+ * // returns "data:image/pngbase64,..."
+ */
+frappe._.get_data_uri = element =>
+{
+ const $element = $(element)
+ const width = $element.width()
+ const height = $element.height()
+
+ const $canvas = $('')
+ $canvas[0].width = width
+ $canvas[0].height = height
+
+ const context = $canvas[0].getContext('2d')
+ context.drawImage($element[0], 0, 0, width, height)
+
+ const data_uri = $canvas[0].toDataURL('image/png')
+
+ return data_uri
+}
+
+/**
+ * @description Frappe's Capture object.
+ *
+ * @example
+ * const capture = frappe.ui.Capture()
+ * capture.show()
+ *
+ * capture.click((data_uri) => {
+ * // do stuff
+ * })
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Taking_still_photos
+ */
frappe.ui.Capture = class
{
constructor (options = { })
{
- this.options = Object.assign({}, frappe.ui.Capture.DEFAULT_OPTIONS, options);
- this.dialog = new frappe.ui.Dialog();
- this.template =
- `
-
-
-
- `;
- $(this.dialog.body).append(this.template);
-
- this.$btnBarSnap = $(this.dialog.body).find('#frappe-capture-btn-toolbar-snap');
- this.$btnBarKnap = $(this.dialog.body).find('#frappe-capture-btn-toolbar-knap');
- this.$btnBarKnap.hide();
-
- Webcam.set(this.options);
+ this.options = frappe.ui.Capture.OPTIONS
+ this.set_options(options)
}
-
- open ( )
+
+ set_options (options)
{
- this.dialog.show();
-
- Webcam.attach('#frappe-capture');
- }
-
- freeze ( )
- {
- this.$btnBarSnap.hide();
- this.$btnBarKnap.show();
+ this.options = { ...frappe.ui.Capture.OPTIONS, ...options }
- Webcam.freeze();
+ return this
+ }
+
+ render ( )
+ {
+ return navigator.mediaDevices.getUserMedia({ video: true }).then(stream =>
+ {
+ this.dialog = new frappe.ui.Dialog({
+ title: this.options.title,
+ animate: this.options.animate,
+ action:
+ {
+ secondary:
+ {
+ label: "×"
+ }
+ }
+ })
+
+ const $e = $(frappe.ui.Capture.TEMPLATE)
+
+ const video = $e.find('video')[0]
+ video.srcObject = stream
+ video.play()
+
+ const $container = $(this.dialog.body)
+ $container.html($e)
+
+ $e.find('.fc-btf').hide()
+
+ $e.find('.fc-bcp').click(() =>
+ {
+ const data_url = frappe._.get_data_uri(video)
+ $e.find('.fc-p').attr('src', data_url)
+
+ $e.find('.fc-s').hide()
+ $e.find('.fc-p').show()
+
+ $e.find('.fc-btu').hide()
+ $e.find('.fc-btf').show()
+ })
+
+ $e.find('.fc-br').click(() =>
+ {
+ $e.find('.fc-p').hide()
+ $e.find('.fc-s').show()
+
+ $e.find('.fc-btf').hide()
+ $e.find('.fc-btu').show()
+ })
+
+ $e.find('.fc-bs').click(() =>
+ {
+ const data_url = frappe._.get_data_uri(video)
+ this.hide()
+
+ if (this.callback)
+ this.callback(data_url)
+ })
+ })
}
- unfreeze ( )
+ show ( )
{
- this.$btnBarSnap.show();
- this.$btnBarKnap.hide();
+ this.render().then(() =>
+ {
+ this.dialog.show()
+ }).catch(err => {
+ if ( this.options.error )
+ {
+ const alert = ` ${frappe.ui.Capture.ERR_MESSAGE}`
+ frappe.show_alert(alert, 3)
+ }
- Webcam.unfreeze();
- }
-
- click (callback)
- {
- $(this.dialog.body).find('#frappe-capture-btn-snap').click(() => {
- this.freeze();
-
- $(this.dialog.body).find('#frappe-capture-btn-discard').click(() => {
- this.unfreeze();
- });
-
- $(this.dialog.body).find('#frappe-capture-btn-accept').click(() => {
- Webcam.snap((data) => {
- callback(data);
- });
-
- this.hide();
- });
- });
+ throw err
+ })
}
hide ( )
{
- Webcam.reset();
-
- $(this.dialog.$wrapper).remove();
+ if ( this.dialog )
+ this.dialog.hide()
}
-};
-frappe.ui.Capture.DEFAULT_OPTIONS =
+
+ submit (fn)
+ {
+ this.callback = fn
+ }
+}
+frappe.ui.Capture.OPTIONS =
{
- width: 480, height: 320, flip_horiz: true
-};
\ No newline at end of file
+ title: __(`Camera`),
+ animate: false,
+ error: false,
+}
+frappe.ui.Capture.ERR_MESSAGE = __("Unable to load camera.")
+frappe.ui.Capture.TEMPLATE =
+`
+
+
+
![]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${
+ ''
+ //
+ //
+ //
+ }
+
+
+
+
+
+
+
+
+
+
+`
\ No newline at end of file
diff --git a/frappe/public/js/frappe/ui/dialog.js b/frappe/public/js/frappe/ui/dialog.js
index a3ebf22712..818b55de74 100644
--- a/frappe/public/js/frappe/ui/dialog.js
+++ b/frappe/public/js/frappe/ui/dialog.js
@@ -11,14 +11,20 @@ frappe.ui.Dialog = frappe.ui.FieldGroup.extend({
this.display = false;
this.is_dialog = true;
- $.extend(this, opts);
+ $.extend(this, { animate: true, size: null }, opts);
this._super();
this.make();
},
make: function() {
this.$wrapper = frappe.get_modal("", "");
+
this.wrapper = this.$wrapper.find('.modal-dialog')
.get(0);
+ if ( this.size == "small" )
+ $(this.wrapper).addClass("modal-sm");
+ else if ( this.size == "large" )
+ $(this.wrapper).addClass("modal-lg");
+
this.make_head();
this.body = this.$wrapper.find(".modal-body").get(0);
this.header = this.$wrapper.find(".modal-header");
@@ -27,12 +33,13 @@ frappe.ui.Dialog = frappe.ui.FieldGroup.extend({
this._super();
// show footer
- if(this.primary_action) {
- this.set_primary_action(this.primary_action_label || __("Submit"), this.primary_action);
+ this.action = this.action || { primary: { }, secondary: { } };
+ if(this.primary_action || this.action.primary) {
+ this.set_primary_action(this.primary_action_label || this.action.primary.label || __("Submit"), this.primary_action || this.action.primary.click);
}
- if (this.secondary_action_label) {
- this.get_close_btn().html(this.secondary_action_label);
+ if (this.secondary_action_label || this.action.secondary.label) {
+ this.get_close_btn().html(this.secondary_action_label || this.action.secondary.label);
}
var me = this;
@@ -101,6 +108,11 @@ frappe.ui.Dialog = frappe.ui.FieldGroup.extend({
},
show: function() {
// show it
+ if ( this.animate ) {
+ this.$wrapper.addClass('fade')
+ } else {
+ this.$wrapper.removeClass('fade')
+ }
this.$wrapper.modal("show");
this.primary_action_fulfilled = false;
this.is_visible = true;
diff --git a/frappe/public/js/frappe/ui/toolbar/navbar.html b/frappe/public/js/frappe/ui/toolbar/navbar.html
index f702994dcb..fad7eb1d05 100644
--- a/frappe/public/js/frappe/ui/toolbar/navbar.html
+++ b/frappe/public/js/frappe/ui/toolbar/navbar.html
@@ -45,6 +45,7 @@
+
+
+
+
+
+
+
+
+
+
+