From 005cfe3dc89d6586b7b3b19a0e6ed989eec666f4 Mon Sep 17 00:00:00 2001 From: Achilles Rasquinha Date: Thu, 28 Dec 2017 18:58:43 +0530 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20NEW=20Frappe=20Chat=20(#4612)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added doctypes, created frappe chat ui * added component layout with state-like abilities, added apis * updated user doctype, moved from state-like feature and component abstraction * added room component * fixed publish_realtime with after_commit = True * created room component and searchbar * minor fix * functional message parsing * update * Added Chat Profile * added chat message * more changes into chat room * fixed APIs, added client side scripting * added chat message attachements, more doc updates * Brand New UI with socket io room integration * completed socketio integration. off to room subscription and publish * realtime room update * raw update * initialized docs, added p2p connection for call tests * updated docs * added coverage, updated api for ease of use * raw commit * added test cases * Chat Room updates and new room creation * added chat group creation * added collapsible plugin * toggable room view * updated * [RAW] * updated UI for chat * Deleted Previous Chat Page * moved from frappe.Chat.Widget to frappe.Chat * modularized frappe-fab * added more docstrings * tried adding conversation tones * Added conversation_tones and refurbished chat popper * modified frappe.ui.Dialog, moved from AppBar to ActionBar, responsive for Mobile :dancer: * moved RoomList item namespace * Configurable Desktop update, moved profile updates to on_update * added state change listeners * removed AppBar to ActionBar customizable :dancer: * added destroy method * removed coverage, refactored group creation * Successful Chat Rooms and Group creation * sort rows based on last_message_timestamp or creation * added frappe._.compare * removed redundant less variables * Chat Room back button with custom routing and destroy methods * Added EmojiPicker * fixed multiple dialog render * setup quick access * added chat chime, functional chat message list updates at room list * deleted package-lock.json * realtime date updates * updated chat message list * functional message render and updates * added track seen * added typing status * updated typing status * valid typing statuses and quick search * Functional Quick Search * reverted fix * some more cleanup and promisifed * fixed hints close on click * updated fab boldness * close popper on click panel * close popper on click panel * reverted octicon-lg, fixed popper heading click * new frappe capture * removed webcamjs * added uploader and capture * removed chat FAB, added as notification instead * on message update --- .vscode/settings.json | 3 - frappe/async.py | 15 +- frappe/build.js | 2 +- .../page/chat/__init__.py => chat/README.md} | 0 frappe/chat/__init__.py | 0 frappe/chat/doctype/__init__.py | 0 frappe/chat/doctype/chat_message/__init__.py | 0 .../chat/doctype/chat_message/chat_message.js | 8 + .../doctype/chat_message/chat_message.json | 245 ++ .../chat/doctype/chat_message/chat_message.py | 162 ++ .../doctype/chat_message/test_chat_message.js | 23 + .../doctype/chat_message/test_chat_message.py | 19 + .../chat_message_attachment/__init__.py | 0 .../chat_message_attachment.json | 71 + .../chat_message_attachment.py | 10 + frappe/chat/doctype/chat_profile/__init__.py | 0 .../chat/doctype/chat_profile/chat_profile.js | 8 + .../doctype/chat_profile/chat_profile.json | 278 ++ .../chat/doctype/chat_profile/chat_profile.py | 173 ++ .../doctype/chat_profile/chat_profile_list.js | 11 + .../doctype/chat_profile/test_chat_profile.js | 23 + .../doctype/chat_profile/test_chat_profile.py | 59 + frappe/chat/doctype/chat_room/__init__.py | 0 frappe/chat/doctype/chat_room/chat_room.js | 8 + frappe/chat/doctype/chat_room/chat_room.json | 314 ++ frappe/chat/doctype/chat_room/chat_room.py | 296 ++ .../chat/doctype/chat_room/test_chat_room.js | 23 + .../chat/doctype/chat_room/test_chat_room.py | 10 + .../chat/doctype/chat_room_user/__init__.py | 0 .../chat_room_user/chat_room_user.json | 102 + .../doctype/chat_room_user/chat_room_user.py | 8 + frappe/chat/page/__init__.py | 0 frappe/chat/page/chat/__init__.py | 0 frappe/chat/page/chat/chat.js | 11 + frappe/chat/page/chat/chat.json | 20 + frappe/chat/page/chat/test_chat.js | 27 + frappe/chat/util/__init__.py | 13 + frappe/chat/util/test_util.py | 35 + frappe/chat/util/util.py | 114 + frappe/commands/utils.py | 1 + frappe/core/doctype/page/page.json | 4 +- frappe/core/doctype/page/test_page.js | 23 + frappe/core/doctype/user/user.json | 65 +- frappe/core/doctype/user/user.py | 3 + frappe/desk/page/chat/chat.css | 42 - frappe/desk/page/chat/chat.js | 239 -- frappe/desk/page/chat/chat.json | 23 - frappe/desk/page/chat/chat.py | 145 - frappe/desk/page/chat/chat_main.html | 35 - frappe/desk/page/chat/chat_row.html | 36 - frappe/desk/page/chat/chat_sidebar.html | 14 - frappe/hooks.py | 6 + frappe/modules.txt | 3 +- frappe/public/build.json | 22 +- frappe/public/css/chat.css | 121 + frappe/public/css/desk-rtl.css | 2 +- frappe/public/js/frappe/chat.js | 2551 +++++++++++++++++ frappe/public/js/frappe/desk.js | 8 +- .../js/frappe/form/controls/text_editor.js | 4 +- frappe/public/js/frappe/form/grid.js | 66 + frappe/public/js/frappe/misc/common.js | 2 +- frappe/public/js/frappe/peer.js | 15 + frappe/public/js/frappe/request.js | 1 + frappe/public/js/frappe/socketio_client.js | 2 +- frappe/public/js/frappe/ui/capture.js | 264 +- frappe/public/js/frappe/ui/dialog.js | 22 +- .../public/js/frappe/ui/toolbar/navbar.html | 12 + frappe/public/js/frappe/ui/toolbar/toolbar.js | 11 + .../frappe/ui/toolbar/user_progress_dialog.js | 2 +- frappe/public/js/lib/fuse.min.js | 1 + frappe/public/js/lib/hyper.min.js | 1 + frappe/public/js/lib/webcam.min.js | 2 - frappe/public/less/chat.less | 225 ++ frappe/public/sounds/chat-message.mp3 | Bin 0 -> 48621 bytes frappe/public/sounds/chat-notification.mp3 | Bin 0 -> 28977 bytes frappe/templates/includes/login/login.js | 1 + frappe/test_runner.py | 2 +- frappe/website/js/website.js | 8 + frappe/www/update-password.html | 2 +- socketio.js | 149 +- 80 files changed, 5526 insertions(+), 700 deletions(-) delete mode 100644 .vscode/settings.json rename frappe/{desk/page/chat/__init__.py => chat/README.md} (100%) create mode 100644 frappe/chat/__init__.py create mode 100644 frappe/chat/doctype/__init__.py create mode 100644 frappe/chat/doctype/chat_message/__init__.py create mode 100644 frappe/chat/doctype/chat_message/chat_message.js create mode 100644 frappe/chat/doctype/chat_message/chat_message.json create mode 100644 frappe/chat/doctype/chat_message/chat_message.py create mode 100644 frappe/chat/doctype/chat_message/test_chat_message.js create mode 100644 frappe/chat/doctype/chat_message/test_chat_message.py create mode 100644 frappe/chat/doctype/chat_message_attachment/__init__.py create mode 100644 frappe/chat/doctype/chat_message_attachment/chat_message_attachment.json create mode 100644 frappe/chat/doctype/chat_message_attachment/chat_message_attachment.py create mode 100644 frappe/chat/doctype/chat_profile/__init__.py create mode 100644 frappe/chat/doctype/chat_profile/chat_profile.js create mode 100644 frappe/chat/doctype/chat_profile/chat_profile.json create mode 100644 frappe/chat/doctype/chat_profile/chat_profile.py create mode 100644 frappe/chat/doctype/chat_profile/chat_profile_list.js create mode 100644 frappe/chat/doctype/chat_profile/test_chat_profile.js create mode 100644 frappe/chat/doctype/chat_profile/test_chat_profile.py create mode 100644 frappe/chat/doctype/chat_room/__init__.py create mode 100644 frappe/chat/doctype/chat_room/chat_room.js create mode 100644 frappe/chat/doctype/chat_room/chat_room.json create mode 100644 frappe/chat/doctype/chat_room/chat_room.py create mode 100644 frappe/chat/doctype/chat_room/test_chat_room.js create mode 100644 frappe/chat/doctype/chat_room/test_chat_room.py create mode 100644 frappe/chat/doctype/chat_room_user/__init__.py create mode 100644 frappe/chat/doctype/chat_room_user/chat_room_user.json create mode 100644 frappe/chat/doctype/chat_room_user/chat_room_user.py create mode 100644 frappe/chat/page/__init__.py create mode 100644 frappe/chat/page/chat/__init__.py create mode 100644 frappe/chat/page/chat/chat.js create mode 100644 frappe/chat/page/chat/chat.json create mode 100644 frappe/chat/page/chat/test_chat.js create mode 100644 frappe/chat/util/__init__.py create mode 100644 frappe/chat/util/test_util.py create mode 100644 frappe/chat/util/util.py create mode 100644 frappe/core/doctype/page/test_page.js delete mode 100644 frappe/desk/page/chat/chat.css delete mode 100644 frappe/desk/page/chat/chat.js delete mode 100644 frappe/desk/page/chat/chat.json delete mode 100644 frappe/desk/page/chat/chat.py delete mode 100644 frappe/desk/page/chat/chat_main.html delete mode 100644 frappe/desk/page/chat/chat_row.html delete mode 100644 frappe/desk/page/chat/chat_sidebar.html create mode 100644 frappe/public/css/chat.css create mode 100644 frappe/public/js/frappe/chat.js create mode 100644 frappe/public/js/frappe/peer.js create mode 100644 frappe/public/js/lib/fuse.min.js create mode 100644 frappe/public/js/lib/hyper.min.js delete mode 100644 frappe/public/js/lib/webcam.min.js create mode 100644 frappe/public/less/chat.less create mode 100644 frappe/public/sounds/chat-message.mp3 create mode 100644 frappe/public/sounds/chat-notification.mp3 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('' - + ''); - - $(".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 @@ -
-
- -
- -
-
- - - {% if (contact === user) { %} - - - {%= __("Public") %} - - {% } %} -
- -
-
-
-
-
-
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 @@ -
-
-
- {% if (data.is_public) { %} - - {% } else { %} - - {% } %} - -
- {%= data.content %} -
-
-
-
-
- - {%= comment_when(data.modified) %} -
- {% if (data.owner==user) { %} -
- Delete -
- {% } %} -
-
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 @@ - 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 = $(`