diff --git a/CMakeLists.txt b/CMakeLists.txt index 1676d5dc28c509b12e1842c40395fc9aaccb3c11..797c6ed702a1b0f4bc8d1160c66f91bfe5c4f8d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -344,6 +344,19 @@ SET(libringclient_api_LIB_HDRS src/api/newvideo.h ) +SET(libringclient_WEB_chatview + src/web-chatview/.eslintrc.json + src/web-chatview/chatview-gnome.css + src/web-chatview/chatview.css + src/web-chatview/chatview.html + src/web-chatview/chatview.js + src/web-chatview/jed.js + src/web-chatview/linkify-html.js + src/web-chatview/linkify-string.js + src/web-chatview/linkify.js + src/web-chatview/qwebchannel.js + src/web-chatview/web.gresource.xml +) SET(libringclient_video_LIB_HDRS src/video/renderer.h @@ -580,6 +593,11 @@ INSTALL( FILES ${libringclient_video_LIB_HDRS} COMPONENT Devel ) +INSTALL( FILES ${libringclient_WEB_chatview} + DESTINATION ${INCLUDE_INSTALL_DIR}/libringclient/web-chatview + COMPONENT Devel +) + INSTALL( FILES ${libringclient_extensions_LIB_HDRS} DESTINATION ${INCLUDE_INSTALL_DIR}/libringclient/extensions COMPONENT Devel diff --git a/src/web-chatview/.eslintrc.json b/src/web-chatview/.eslintrc.json new file mode 100644 index 0000000000000000000000000000000000000000..746c54a40a2dda347f83854dd478ca9d217ac13b --- /dev/null +++ b/src/web-chatview/.eslintrc.json @@ -0,0 +1,31 @@ +{ + "env": { + "browser": true + }, + "plugins": ["html"], + "extends": "eslint:recommended", + "parserOptions": { + "ecmaVersion": 6 + }, + "rules": { + "indent": [ + "error", + 4 + ], + "linebreak-style": [ + "error", + "unix" + ], + "quotes": [ + "error", + "double" + ], + "semi": [ + "error", + "never" + ], + "no-inner-declarations": [ + 0 + ] + } +} diff --git a/src/web-chatview/README b/src/web-chatview/README new file mode 100644 index 0000000000000000000000000000000000000000..75b6bf9fe20b27b42a99b5e2416e0e0381d68219 --- /dev/null +++ b/src/web-chatview/README @@ -0,0 +1,33 @@ +# README - chatview + +The chatview runs under a WebKit GTK view. It is written using web technologies +(HTML5/CSS3/JS) and is responsible for displaying everything that deals with the +navbar, the messages, and the message bar. + +## Contributing - syntax + +We have a set of ESLint rules that define clear syntax rules (web/.eslintrc.json). + +You will need the following tools: + +- ESLint (The pluggable linting utility for JavaScript and JSX) + https://eslint.org/ +- ESLint HTML plugin (eslint-plugin-html) + https://www.npmjs.com/package/eslint-plugin-html + +Before pushing a patch, make sure that it passes ESLint: +$ eslint chatview.html + +Most trivial issues can be fixed using +$ eslint chatview.js --fix + +We will not accept patches introducing non-ESLint-compliant code. + +## WebKit GTK + +Everything runs under WebKit GTK, that is if you need to write browser specific +code, you will only need to support WebKit (CSS -webkit- prefix). + +Do not use querySelector if getElementById or getElementByClassName can be used +instead. querySelector doesn't always make the code easier and has very bad +performances. diff --git a/src/web-chatview/chatview-gnome.css b/src/web-chatview/chatview-gnome.css new file mode 100644 index 0000000000000000000000000000000000000000..bc336eed23569ce3ff7c2b2d7901e24a505f9f76 --- /dev/null +++ b/src/web-chatview/chatview-gnome.css @@ -0,0 +1,90 @@ +.internal_mes_wrapper { + margin: 3px 0 0 0; +} + +.message_wrapper { + padding: 1em; +} + +.sender_image { + margin: 10px; +} + +.message_out + .message_out .message_wrapper { + border-top-right-radius: 10px; +} + +.message_in + .message_in .message_wrapper { + border-top-left-radius: 10px; +} + +.message_in + .message_in .sender_image { + visibility: hidden; +} + +.message_out + .message_out .sender_image { + visibility: hidden; +} + +.message_text { + hyphens: auto; + word-break: break-word; + word-wrap: break-word; +} + +.nav-right { + align-self: flex-end; +} + +.nav-left { + align-self: flex-start; +} + +#nav-contactid { + margin: 0px; + margin-left: 5px; + padding: 0px; + height: 100%; + font-family: emoji; + + /* enable selection (it is globally disabled in the body) */ + -webkit-user-select: auto; + + /* contactid field should take as much place as possible, but it should + also be the first one to shrink if necesary. */ + flex-grow: 2; + flex-shrink: 2; + min-width: 0; /* necessary for child to be able to shrink correctly */ + + /* center vertically */ + display: flex; + align-items: center; +} + +#nav-contactid-wrapper { + width: 100%; +} + +#nav-contactid-alias { + font-size: 14px; + font-weight: bold; + margin-bottom: 3px; + + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#nav-contactid-bestId { + font-size: 11px; + + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.oneEntry #nav-contactid-bestId { + display: none; +} \ No newline at end of file diff --git a/src/web-chatview/chatview-windows.css b/src/web-chatview/chatview-windows.css new file mode 100644 index 0000000000000000000000000000000000000000..566ca0d2883557371439478168e910d90b5d4f58 --- /dev/null +++ b/src/web-chatview/chatview-windows.css @@ -0,0 +1,198 @@ +:root { + /* color definitions */ + --jami-light-blue: rgba(59, 193, 211, 0.3); + --jami-dark-blue: #004e86; + --jami-green: #219d55; + --jami-green-hover: #1f8b4c; + --jami-red: #dc2719; + --jami-red-hover: #b02e2c; + /* main properties */ + --bg-color: #ffffff; + /* navbar properties */ + --navbar-height: 40px; + --navbar-padding-top: 8px; + --navbar-padding-bottom: var(--navbar-padding-top); + /* message bar properties */ + --textarea-max-height: 150px; + --placeholder-text-color: #d3d3d3; + /* button properties */ + --action-icon-color: #00; + --deactivated-icon-color: #bebebe; + --action-icon-hover-color: #ededed; + --action-critical-icon-hover-color: rgba(211, 77, 59, 0.3); /* complementary color of jami light blue */ + --action-critical-icon-press-color: rgba(211, 77, 59, 0.5); + --action-critical-icon-color: #4E1300; + --non-action-icon-color: #212121; + --action-icon-press-color: rgba(212, 212, 212, 1.0); + --invite-hover-color: white; + /* hairline properties */ + --hairline-color: #f0f0f0; + --hairline-thickness: 2px; +} + +#invite_contact_name { + font-weight: 500; +} + + +.internal_mes_wrapper { + margin: 0; +} + +.message_wrapper { + padding: 0.5em 1em 0.5em 1em; +} + +.sender_image { + margin: 0px 10px 0px 10px; +} + +.message_in .message_wrapper { + background-color: #cfebf5; +} + +.message_in .sender_image, +.message_out .sender_image { + visibility: hidden; +} + +.message_in.last_of_sequence .sender_image, +.message_in.single_message .sender_image { + visibility: visible; +} + +.message_in.last_of_sequence .sender_image { + margin-top: 2px; +} + +.message_in.middle_of_sequence .sender_image { + margin-top: 0px; +} + + +.generated_message.message_in .message_wrapper, +.generated_message.message_out .message_wrapper { + background-color: transparent !important; + border-radius: 0px !important; +} + +.single_message.message_in .message_wrapper, +.single_message.message_out .message_wrapper { + border-radius: 20px 20px 20px 20px !important; +} + +.last_of_sequence.message_in .message_wrapper { + border-radius: 4px 20px 20px 20px; +} + +.first_of_sequence.message_in .message_wrapper { + border-radius: 20px 20px 20px 4px; +} + +.middle_of_sequence.message_in .message_wrapper { + border-radius: 4px 20px 20px 5px; +} + +.last_of_sequence.message_out .message_wrapper { + border-radius: 20px 4px 20px 20px; +} + +.first_of_sequence.message_out .message_wrapper { + border-radius: 20px 20px 4px 20px; +} + +.middle_of_sequence.message_out .message_wrapper { + border-radius: 20px 5px 4px 20px; +} + +.middle_of_sequence .internal_mes_wrapper, +.last_of_sequence .internal_mes_wrapper, +.last_message .internal_mes_wrapper { + margin: 0px 0 0 0 !important; +} + +.message_out .sender_image { + margin: 8px; +} + +.first_of_sequence.message_out .internal_mes_wrapper, +.single_message.message_out .internal_mes_wrapper { + margin: 0px 0 0 0 !important; +} + +.sender_image_cell { + vertical-align: bottom; + min-width: 16px; +} + +.message_in .sender_image_cell { + min-width: 56px; +} + +.dummy_cell { + padding: 0; +} + +.timestamp_cell { + padding: 0; + max-width: 0px; + overflow: visible; + white-space: nowrap; +} + +.timestamp_cell_out { + padding: 0; + text-align: right; + direction: rtl; + max-width: 0px; + overflow: visible; + white-space: nowrap; +} + +table { + border-collapse: collapse; + border-spacing: 0 0px; + margin: 0; + padding: 0; +} + +.message_text { + word-break: break-all; + word-wrap: hyphenate; +} + +pre { + white-space: pre-wrap; +} + +.message:hover:not(.message_type_contact) .menu_interaction { + display: block; + opacity: 1; +} + +.menuoption { + user-select: none; + cursor: pointer; +} + +.nav-button { + width: 30px; + height: 30px; + margin: 8px; + padding: 2px; + display: flex; + cursor: pointer; + align-self: center; + border-radius: 16px; +} + +.nav-button.deactivated { + width: 30px; + height: 30px; + margin: 8px; + padding: 2px; + align-self: center; + display: flex; + border-radius: 16px; + cursor: auto; +} \ No newline at end of file diff --git a/src/web-chatview/chatview.css b/src/web-chatview/chatview.css new file mode 100644 index 0000000000000000000000000000000000000000..9466a5ee16f4c709375343b9bc5c82c78a91c948 --- /dev/null +++ b/src/web-chatview/chatview.css @@ -0,0 +1,1106 @@ +/** Variable and font definitions */ + +:root { + /* color definitions */ + --jami-light-blue: rgba(59, 193, 211, 0.3); + --jami-dark-blue: #003b4e; + --jami-green: #219d55; + --jami-green-hover: #1f8b4c; + --jami-red: #dc2719; + --jami-red-hover: #b02e2c; + + /* main properties */ + --bg-color: #f2f2f2; /* same as macOS client */ + + /* navbar properties */ + --navbar-height: 40px; + --navbar-padding-top: 8px; + --navbar-padding-bottom: var(--navbar-padding-top); + + /* message bar properties */ + --textarea-max-height: 150px; + --placeholder-text-color: #d3d3d3; + + /* button properties */ + --action-icon-color: var(--jami-dark-blue); + --deactivated-icon-color: #BEBEBE; + --action-icon-hover-color: var(--jami-light-blue); + --action-critical-icon-hover-color: rgba(211, 77, 59, 0.3); /* complementary color of ring light blue */ + --action-critical-icon-press-color: rgba(211, 77, 59, 0.5); + --action-critical-icon-color: #4E1300; + --non-action-icon-color: #212121; + --action-icon-press-color: rgba(59, 193, 211, 0.5); + --invite-hover-color: white; + + /* hairline properties */ + --hairline-color: #d9d9d9; + --hairline-thickness: 0.2px; +} + +@font-face { + font-family: emoji; + + /* Fonts for text outside emoji blocks */ + src: local('Open sans'), + local('Helvetica'), + local('Segoe UI'), + local('sans-serif'); +} + +@font-face { + font-family: emoji; + + src: local('Noto Color Emoji'), + local('Android Emoji'), + local('Twitter Color Emoji'); + + /* Emoji unicode blocks */ + unicode-range: U+1F300-1F5FF, U+1F600-1F64F, U+1F680-1F6FF, U+2600-26FF; +} + +/** Body */ + +body { + --messagebar-size: 47px; + margin: 0; + overflow: hidden; + background-color: var(--bg-color); + padding-bottom: var(--messagebar-size); + /* disable selection highlight because it looks very bad */ + -webkit-user-select: text; +} + +::-webkit-scrollbar-track { + background-color: var(--bg-color); +} + +::-webkit-scrollbar { + width: 8px; + background-color: var(--bg-color); +} + +::-webkit-scrollbar-thumb { + background-color: var(--bg-color); + } + + +/** Navbar */ + +.navbar-wrapper { + /* on top, over everything and full width */ + position: fixed; + left:0; right:0; + z-index: 500; + top: 0; +} + +#navbar { + background-color: var(--bg-color); + padding-right: 8px; + padding-left: 8px; + padding-top: var(--navbar-padding-top); + padding-bottom: var(--navbar-padding-bottom); + height: var(--navbar-height); + overflow: hidden; + align-items: center; + + /* takes whole width */ + left:0; right:0; + + /* hairline */ + border-bottom: var(--hairline-thickness) solid var(--hairline-color); + + display: flex; +} + +.hiddenState { + /* Used to hide navbar and message bar */ + display: none !important; +} + +.svgicon { + display: block; + margin: auto; + height: 70%; +} + +.nav-button { + width: 40px; + height: 40px; + display: flex; + cursor: pointer; + align-self: center; + border-radius: 50%; +} + +.nav-button.deactivated { + width: 40px; + height: 40px; + align-self: center; + display: flex; + border-radius: 50%; + cursor: auto; +} + +.action-button svg { + fill: var(--action-icon-color); +} + +.action-button.deactivated svg { + fill: var(--deactivated-icon-color); +} + +.non-action-button svg { + fill: var(--non-action-icon-color); +} + +.non-action-button:hover, .action-button:hover { + background: var(--action-icon-hover-color); +} + +.non-action-button:active, .action-button:active { + background: var(--action-icon-press-color); +} + +.action-button.deactivated:hover, .action-button.deactivated:active { + background: none; +} + +.action-critical-button svg { + fill: var(--action-critical-icon-color); +} + +.action-critical-button:hover { + background: var(--action-critical-icon-hover-color); +} + +.action-critical-button:active { + background: var(--action-critical-icon-press-color); +} + +#callButtons { + display: flex; +} + +#navbar #unbanButton, #navbar #addToConversationsButton { + display: none; +} + +#navbar.onBannedState #addToConvButton, #navbar.onBannedState #callButtons, #navbar.onBannedState #addToConversationsButton { + display: none; +} + +#navbar.onBannedState #unbanButton { + display: flex; +} + +/** Invitation bar */ + +#invitation { + visibility: hidden; + background: var(--bg-color); + position: absolute; + width: 100%; + padding-bottom: 10px; + + /* hairline */ + border-bottom: var(--hairline-thickness) solid var(--hairline-color); +} + +#invitation #invite_header { + margin: 10px; + list-style: none; + display: flex; + align-items: center; + justify-content: center; + + /* enable selection (it is globally disabled in the body) */ + -webkit-user-select: auto; +} + +#invitation .sender_image { + width: 50px; + height: 50px; +} + +#invitation #actions { + margin-right: auto; + margin-left: auto; + list-style: none; + display: flex; + align-items: center; + justify-content: center; + + /* enable selection (it is globally disabled in the body) */ + -webkit-user-select: auto; +} + +#invitation #actions div { + display: flex; + align-items: center; + justify-content: center; + margin-right: 5px; + margin-left: 5px; +} + +#invitation #text { + text-align: center; + font-size: 0.8em; + + /* enable selection (it is globally disabled in the body) */ + -webkit-user-select: auto; +} + + +/** Messaging bar */ + +#sendMessage { + background-color: var(--bg-color); + display: flex; + overflow: hidden; + padding: 4px; + align-items: center; + position: fixed; + left: 0; + right: 0; + z-index: 500; + bottom: 0; + /* hairline */ + border-top: var(--hairline-thickness) solid var(--hairline-color); +} + +#message { + font-family: emoji; + flex: 1; + background-color: var(--bg-color); + border: 0; + overflow-y: scroll; + color: black; + max-height: var(--textarea-max-height); + margin-right: 10px; + resize: none; + + /* enable selection (it is globally disabled in the body) */ + -webkit-user-select: auto; +} + +#message:focus, +#message.focus { + outline: none; +} + +#container[disabled] { + background-color: #ccc; +} + +input[placeholder], [placeholder], *[placeholder] { + color: var(--placeholder-text-color); +} + +/** Main chat view */ + +#lazyloading-icon { + margin: auto; + margin-bottom: 10px; + margin-top: 5px; + vertical-align: center; + width: 30px; + display: flex; +} + +#container { + position: relative; + height: 100%; + width: 100%; + margin: 0; + padding: 0; + + display: flex; + flex-direction: column; + + /* When there are not enough messages to occupy full height of the + container, make sure they are displayed at the bottom, not at the + top. */ + justify-content: flex-end; +} + +a:link { + text-decoration: none; + color: #0e649b; + transition: all 0.2s ease-in-out; + border-bottom: dotted 1px; +} + +a:hover { + border: 0; +} + +#file_image_send_container { + visibility: hidden; + position: fixed; + bottom: var(--messagebar-size); + z-index: 1; + display: flex; + justify-content: flex-start; + left: 0; + right: 0; + height: 8em; + /*border-top-left-radius: 25px; + border-top-right-radius: 25px;*/ + border: 2px solid lightgray; + padding: 20px; + border-bottom: none; + background-color: #cfdbdd; + overflow-x: overlay; +} + +#file_image_send_container::-webkit-scrollbar { + height: 10px; +} + +#file_image_send_container::after { + /*Create the margins with pseudo-elements*/ + /*to solve overflow:scroll and The Right Padding Problem*/ + content: ' '; + min-width: 20px; +} + +.img_wrapper { + position: relative; + max-width: 65px; + min-width: 65px; + max-height: 80px; + border: 3px solid rgba(255,255,255,0); + padding: 30px; + border-radius: 20px; + background-color: #cfdbdd; + display: flex; + justify-content: center; + align-items: center; + margin: 5px; +} + +/* Make the image responsive */ +.img_wrapper img { + height: 118px; + min-width: 131px; + object-fit: cover; + border-radius: 20px; +} + +/* Style the button and place it at the top right corner of the image */ +.img_wrapper .btn { + position: absolute; + color: #fff; + border: 1px solid #AEAEAE; + border-radius: 40%; + background: rgba(96,95,97,0.5); + font-size: 10px; + font-weight: bold; + top: -5%; + right: -5%; +} + +.img_wrapper .btn:hover { + background-color: lightgray; +} + +.img_wrapper .btn:focus { + outline: none; + color: black; +} + +.file_wrapper { + position: relative; + max-width: 65px; + min-width: 65px; + max-height: 80px; + border: 3px solid rgba(255,255,255,0); + padding: 30px; + border-radius: 20px; + background-color: white; + display: flex; + justify-content: flex-start; + align-items: center; + margin: 5px; + font-family: sans-serif; +} + +/* Style the button and place it at the top right corner of the image */ +.file_wrapper .btn { + position: absolute; + color: #fff; + border: 1px solid #AEAEAE; + border-radius: 40%; + background: rgba(96,95,97,0.5); + font-size: 10px; + font-weight: bold; + top: -5%; + right: -5%; +} + +.file_wrapper .btn:hover { + background-color: lightgray; +} + +.file_wrapper .btn:focus { + outline: none; + color: black; +} + +.file_wrapper .svg-icon { + position: absolute; + max-width: 2em; + max-height: 25px; + margin-right: 2em; + top: 8%; + left: 1px; +} + +.file_wrapper .svg-icon path, +.file_wrapper .svg-icon polygon, +.file_wrapper .svg-icon rect { + fill: #000000; +} + +.file_wrapper .svg-icon circle { + stroke: #4691f6; + stroke-width: 1; +} + +.file_wrapper .fileinfo { + position: absolute; + top: 30%; + left: 7%; +} + +#back_to_bottom_button_container { + position: absolute; + z-index: 1; + display: flex; + justify-content: center; + width: 100%; + height: 4em; + pointer-events: none; +} + +#back_to_bottom_button { + visibility: hidden; + margin: auto; + font: 0.875em emoji; + text-align: center; + width: 10em; + border-radius: 2em; + background-color: var(--jami-dark-blue); + color: #fff; + padding: 0.5em; + box-shadow: 2px 2px 4px black; + opacity: 1; + overflow: hidden; + white-space: nowrap; + transition: opacity .5s ease, width .2s ease, color .1s ease .2s; + pointer-events: all; +} + +#back_to_bottom_button:hover { + cursor: pointer; +} + +#back_to_bottom_button.fade { + opacity: 0; + width: 1em; + color: transparent; + transition: .2s opacity ease .1s, .2s width ease .1s, color .1s ease; +} + +#back_to_bottom_button.fade:hover { + cursor: context-menu; +} + +#messages { + position: relative; + z-index: 0; + width: 100%; + overflow: hidden; + height: auto; + padding-top: 0.5em; + opacity: 1; + transition: 0.5s opacity; +} + +#messages.fade { + opacity: 0; + transition: none; +} + +#messages:hover { + overflow-y: overlay; +} + +.last_message { + /* The last message gets a bigger bottom padding so that it is not + "glued" to the message bar. */ + padding-bottom: 1.5em !important; + margin-top: 4px; +} + + +/* General messages */ + +.internal_mes_wrapper { + max-width: 70%; + + display: flex; + flex-direction: column; + + /* If a message is smaller (in width) than the timestamp, do not fill + full width and pack message at the left. */ + align-items: flex-start; + align-content: flex-start; +} + +.message_out > .internal_mes_wrapper { + /* If message is in the outgoing direction, pack at the right. */ + align-items: flex-end; + align-content: flex-end; +} + +.message_wrapper { + max-width: calc(100% - 2em); + border-radius: 10px; + position: relative; + display: flex; + flex-direction: row; +} + +.message_type_data_transfer .message_wrapper { + display: flex; + flex-direction: column; + padding: 0; + width: 450px; + max-width: none; +} + +.transfer_info_wrapper { + display: flex; + flex-direction: row; + padding-bottom: .8em; + padding-top: .8em; +} + +.message { + font: 0.875em emoji; + margin: 0; + display: flex; + justify-content: flex-start; + align-items: top; + overflow: hidden; + + /* enable selection (it is globally disabled in the body) */ + -webkit-user-select: auto; +} + +.message_in { + padding-left: 25%; +} + +.message_out { + padding-right: 25%; + + /* Message sent by the user should be displayed at the right side of + the screen. */ + flex-direction: row-reverse; +} + +.message_delivery_status { + margin: 10px 10px; + color: #A0A0A0; +} + +.message_sender { + display: none; +} + +.sender_image { + border-radius: 50%; + width: 35px; + height: 35px; +} + +div.last_message > span { + margin: 0px 10px 0px 10px; +} + +.message_out .message_wrapper { + border-top-right-radius: 0; + transform-origin: top right; +} + +.message_in .message_wrapper { + border-top-left-radius: 0; + transform-origin: top left; +} + +.message_out .message_wrapper { + background-color: #cfd8dc; +} +.message_in .message_wrapper { + background-color: #fdfdfd; +} + +@-webkit-keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.timestamp { + display: inline-flex; + justify-content: flex-start; + align-self: stretch; + color: #333; + font-size: 10px; + padding: 5px; +} + +.timestamp_out { + flex-direction: row-reverse; +} + +.timestamp_action { + margin: auto; + padding: 0; + vertical-align: center; + opacity: 0; + transition: visibility 0.3s linear, opacity 0.3s linear; +} + +.message_type_contact .message_wrapper:hover .timestamp_action, +.message_type_call .message_wrapper:hover .timestamp_action { + opacity: 1; +} + +/* Ellipsis - dropdown menu */ + +input[type=checkbox] { + display: none; +} + +.menu_interaction +{ + margin: 5px; + padding: 10px; + padding-top: 0; + opacity: 0; + height: 20px; + transition: visibility 0.3s linear, opacity 0.3s linear; +} + +.message_type_call .menu_interaction +.message_type_contact .menu_interaction +{ + margin: auto; + padding: 0; + vertical-align: center; +} + +.message_type_call .menu_interaction .dropdown +.message_type_contact .menu_interaction .dropdown +{ + margin-top: -17px; +} + +.message:hover .menu_interaction +{ + display: block; + opacity: 1; +} + +.dropdown { + display: none; + z-index: 1; + position: absolute; + background-color: #fff; + padding-top: 3px; + padding-bottom: 3px; +} + +.dropdown div +{ + color: #111; + padding: 10px; +} + +.dropdown div:hover +{ + background-color: #ddd; +} + +.showmenu:checked ~ .dropdown{ + display: block; +} + +/* Buttons */ + +.flat-button { + border: 0; + border-radius: 5px; + transition: all 0.3s ease; + color: #f9f9f9; + padding: 10px 20px 10px 20px; + vertical-align: middle; + cursor: pointer; + flex: 1; + padding: 0; +} + +.left_buttons { + align-self: center; + max-width: 90px; + padding-left: 1em; +} + +/* Status */ + +.status_circle { + fill: #A0A0A0; + -webkit-animation: circle-dance; + -webkit-animation-duration: 0.8s; + -webkit-animation-iteration-count: infinite; + -webkit-animation-direction: alternate; + -webkit-animation-timing-function: ease-in-out; +} + +.anim-first { + -webkit-animation-delay: 0.7s; +} + +.anim-second { + -webkit-animation-delay: 0.9s; +} + +.anim-third { + -webkit-animation-delay: 1.1s; +} + +@-webkit-keyframes circle-dance { + 0%,50% { + -webkit-transform: translateY(0); + fill: #A0A0A0; + } + 100% { + -webkit-transform: translateY(-8px); + fill: #000; + } +} + +.status-x { + stroke-dasharray: 12; +} + +/* Contact + Call interactions */ +.message_type_contact .message_wrapper, +.message_type_call .message_wrapper { + width: auto; + margin: auto; + display: flex; + flex-wrap: wrap; + background-color: var(--bg-color); + padding: 0; +} + +.message_type_contact .message_wrapper:before, +.message_type_call .message_wrapper:before { + display: none; +} + +.message_type_contact .text, +.message_type_call .text { + align-self: center; + font-size: 1.2em; + padding: 1em; +} + +/* file interactions */ + +.message_type_data_transfer .internal_mes_wrapper { + padding: 0.1em; + display: flex; + flex-wrap: wrap; +} + +.invite-btn-red { + transition: background-color 0.5s ease; +} + +.invite-btn-red:hover { + background: var(--jami-red); +} + +.invite-btn-red svg { + fill: var(--jami-red); + transition: fill 0.5s ease; +} + +.invite-btn-red:hover svg { + fill: var(--invite-hover-color); +} + +.invite-btn-green { + transition: background-color 0.5s ease; +} + +.invite-btn-green:hover { + background: var(--jami-green); +} + +.invite-btn-green svg { + fill: var(--jami-green); + transition: fill 0.5s ease; +} + +.invite-btn-green:hover svg { + fill: var(--invite-hover-color); +} + +.accept, .refuse { + border-radius: 50%; + cursor: pointer; +} + +.accept svg, +.refuse svg { + padding: 8px; + width: 24px; + height: 24px; +} + +.accept { + fill: var(--jami-green); +} + +.accept:hover { + fill: var(--invite-hover-color); + background: var(--jami-green-hover); +} + +.refuse { + fill: var(--jami-red); +} + +.refuse:hover { + fill: var(--invite-hover-color); + background: var(--jami-red-hover); +} + +.message_type_data_transfer .text { + text-align: left; + align-self: left; + padding: 0.3em; +} + +.truncate-ellipsis { + display: table; + table-layout: fixed; + width: 100%; + white-space: nowrap; +} + +.truncate-ellipsis > * { + display: table-cell; + overflow: hidden; + text-overflow: ellipsis; +} + +.message_type_data_transfer .filename { + cursor: pointer; + font-size: 1.1em; +} + +.message_type_data_transfer .informations { + color: #555; + font-size: 0.8em; +} + +.message_progress_bar { + width: 100%; + height: 1em; + position: relative; + overflow: hidden; + background-color: #eee; + border-radius: 0; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.25) inset; +} + +.message_progress_bar > span { + display: inline; + height: 100%; + background-color: #01a2b8; + position: absolute; + overflow: hidden; +} + +/* text interactions */ + +.message_type_text .internal_mes_wrapper { + padding: 0.1em; +} + +.message_text { + max-width: 100%; +} + +.message_text pre { + display: inline; +} + +pre { + font : inherit; + font-family : inherit; + font-size : inherit; + font-style : inherit; + font-variant : inherit; + font-weight : inherit; + margin: 0; + padding: 0; +} + +/* Media interactions */ +.media_wrapper img { + max-width: 800px; + max-height: 700px; + margin: 2px 0 2px 0; + border-radius: 10px; +} + +.playVideo { + background-color: rgba(0, 0, 0, 0.6); + height: 50px; + width: 50px; + border-radius: 5px; + float: right; + position: absolute; + top: calc(50% - 25px); + left: calc(50% - 25px); + z-index: 3; +} + +.containerVideo { + width: 100%; + position: relative; +} + +.playVideo svg { + height: 40px; + width: 40px; + margin: 5px; +} + +/* Text interaction */ +.failure, +.sending { + margin: 10px 10px; + color: #A0A0A0; +} + +.no-audio-overlay { + overflow: visible; +} + +audio { + align-self: center; +} + +/* classic screens */ +@media screen and (max-width: 1920px), screen and (max-height: 1080px) { + .message_in { + padding-left: 15%; + } + + .message_out { + padding-right: 15%; + } + + .message_type_contact, + .message_type_call { + padding: 0; + } + + + .internal_mes_wrapper { + max-width: 60%; + } + + .media_wrapper img { + /* It is perfectly fine to specify max-widths in px when the + wrapper's max-width is in %, as long as the max-width in px + doesn't exceed the one in %. */ + max-width: 450px; + max-height: 450px; + } + + .menu_interaction + { + margin: 5px; + padding: 2px; + height: 10px; + font-size: 0.7em; + transition: visibility 0.3s linear,opacity 0.3s linear; + } +} + +/* lower resolutions */ +@media screen and (max-width: 1000px), screen and (max-height: 480px) { + .message_in { + padding-left: 0; + } + + .message_out { + padding-right: 0; + } + + .message_type_contact, + .message_type_call { + max-width: 100%; + } + + .internal_mes_wrapper { + max-width: 90%; + } + + /* Media interactions */ + .media_wrapper img { + max-width: 200px; + max-height: 200px; + } +} + +@media screen and (max-width: 550px) { + .message_type_data_transfer .message_wrapper { + width: 250px; + } +} + +/* Special case */ +@media screen and (max-width: 350px) { + .sender_image { + display: none; + } + + /* File interactions */ + .message_type_data_transfer .left_buttons { + max-width: 100%; + } + + .message_type_data_transfer .text { + max-width: 100%; + padding-left: 0; + } + + .message_type_data_transfer .message_wrapper { + width: 200px; + } +} diff --git a/src/web-chatview/chatview.html b/src/web-chatview/chatview.html new file mode 100644 index 0000000000000000000000000000000000000000..3b8f16fad7aedd1b04dd8683cf09bc1b8b9c7369 --- /dev/null +++ b/src/web-chatview/chatview.html @@ -0,0 +1,111 @@ +<html> +<!-- Empty head might be needed for setSenderImage --> + +<head> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta charset="utf-8"> +</head> + +<body> + <div id="wrapperOfNavbar" class="navbar-wrapper"> + <div id="navbar"> + <div id="backButton" class="nav-button non-action-button nav-left" onmouseover="addBackButtonHoverProperty()" + onclick="backToWelcomeView()"> + <svg class="svgicon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"> + <path d="M0 0h24v24H0z" fill="none" /> + <path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" /> + </svg> + </div> + <div id="nav-contactid" class="nav-left"> + <div id="nav-contactid-wrapper"> + <div id="nav-contactid-alias"></div> + <div id="nav-contactid-bestId"></div> + </div> + </div> + <div id="optionsButton" style="display:none" class="deactivated nav-button action-button nav-right" onclick="moreOptions()"> + <svg class="svgicon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"> + <path d="M0 0h24v24H0z" fill="none" /> + <path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" /> + </svg> + </div> + <div id="callButtons"> + <!-- callButtons block allows more efficient hiding of placeCallButton and placeAudioCallButton --> + <div id="placeCallButton" class="nav-button action-button nav-right" onclick="placeCall()"> + <svg class="svgicon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <path d="M0 0h24v24H0z" fill="none" /> + <path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" /> + </svg> + </div> + <div id="placeAudioCallButton" class="nav-button action-button nav-right" onclick="placeAudioCall()"> + <svg class="svgicon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <path fill="none" d="M0 0h24v24H0z" /> + <path d="M20.01 15.38c-1.23 0-2.42-.2-3.53-.56-.35-.12-.74-.03-1.01.24l-1.57 1.97c-2.83-1.35-5.48-3.9-6.89-6.83l1.95-1.66c.27-.28.35-.67.24-1.02-.37-1.11-.56-2.3-.56-3.53 0-.54-.45-.99-.99-.99H4.19C3.65 3 3 3.24 3 3.99 3 13.28 10.73 21 20.01 21c.71 0 .99-.63.99-1.18v-3.45c0-.54-.45-.99-.99-.99z" /> + </svg> + </div> + </div> + <div id="addToConversationsButton" class="nav-button action-button nav-right" onclick="addToConversations()"> + <svg class="svgicon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <path d="M0 0h24v24H0z" fill="none" /> + <path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" /> + </svg> + </div> + <div id="unbanButton" class="nav-button action-critical-button nav-right" onclick="addBannedContact()"> + <svg class="svgicon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <path fill="none" d="M0 0h24v24H0V0z" /> + <circle cx="15" cy="8" r="4" /> + <path d="M23 20v-2c0-2.3-4.1-3.7-6.9-3.9l6 5.9h.9zm-11.6-5.5C9.2 15.1 7 16.3 7 18v2h9.9l4 4 1.3-1.3-21-20.9L0 3.1l4 4V10H1v2h3v3h2v-3h2.9l2.5 2.5zM6 10v-.9l.9.9H6z" /> + </svg> + </div> + </div> + <div id="invitation"> + <div id="invite_header"> + <span id="invite_image"></span> + <div id="text"></div> + </div> + <div id="actions"> + <div id="acceptButton" class="nav-button action-button invite-btn-green" onclick="acceptInvitation()"> + <svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"> + <path d="M0 0h24v24H0z" fill="none" /> + <path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" /> + </svg> + </div> + <div id="refuseButton" class="nav-button action-button invite-btn-red" onclick="refuseInvitation()"> + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <path fill="none" d="M0 0h24v24H0V0z" /> + <path d="M18.3 5.71c-.39-.39-1.02-.39-1.41 0L12 10.59 7.11 5.7c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41L10.59 12 5.7 16.89c-.39.39-.39 1.02 0 1.41.39.39 1.02.39 1.41 0L12 13.41l4.89 4.89c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L13.41 12l4.89-4.89c.38-.38.38-1.02 0-1.4z" /> + </svg> + </div> + <div id="blockButton" class="nav-button action-button invite-btn-red" onclick="blockConversation()"> + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <path d="M0 0h24v24H0z" fill="none" /> + <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z" /> + </svg> + </div> + </div> + </div> + </div> + <div id="container"> + <div id="messages" onscroll="onScrolled()"></div> + <div id="back_to_bottom_button_container"> + <div id="back_to_bottom_button" onclick="back_to_bottom()">Jump to latest ▼</div> + </div> + <div id="file_image_send_container"></div> + <div id="sendMessage"> + <div id="sendFileButton" class="nav-button action-button" onclick="selectFileToSend()" title="select file to send"> + <svg class="svgicon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" /> + <path d="M0 0h24v24H0z" fill="none" /> + </svg> + </div> + <textarea id="message" autofocus placeholder="Type a message" onkeyup="grow_text_area()" onkeydown="process_messagebar_keydown()" + rows="1"></textarea> + <div id="sendButton" class="nav-button action-button" onclick="sendMessage(); grow_text_area()"> + <svg class="svgicon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" /> + <path d="M0 0h24v24H0z" fill="none" /> + </svg> + </div> + </div> + </div> +</body> +</html> diff --git a/src/web-chatview/chatview.js b/src/web-chatview/chatview.js new file mode 100644 index 0000000000000000000000000000000000000000..0519905b7ae09efe059716ec17314156bbfa8c77 --- /dev/null +++ b/src/web-chatview/chatview.js @@ -0,0 +1,2251 @@ +"use strict" + +/* Constants used at several places */ +// scrollDetectionThresh represents the number of pixels a user can scroll +// without disabling the automatic go-back-to-bottom when a new message is +// received +const scrollDetectionThresh = 200 +// printHistoryPart loads blocks of messages. Each block contains +// scrollBuffer messages +const scrollBuffer = 20 +// The first time a conversation is loaded, the lazy loading system makes +// sure at least initialScrollBufferFactor screens of messages are loaded +const initialScrollBufferFactor = 3 +// Some signal like the onscrolled signals are debounced so that the their +// assigned function isn't fired too often +const debounceTime = 200 + +/* Buffers */ +// current index in the history buffer +var historyBufferIndex = 0 +// buffer containing the conversation's messages +var historyBuffer = [] + +// If we use qt +var use_qt = false + +if (navigator.userAgent == "jami-windows") { + use_qt = true +} + +/* We retrieve refs to the most used navbar and message bar elements for efficiency purposes */ +/* NOTE: use getElementById when possible (more readable and efficient) */ +const addToConversationsButton = document.getElementById("addToConversationsButton") +const placeAudioCallButton = document.getElementById("placeAudioCallButton") +const backButton = document.getElementById("backButton") +const placeCallButton = document.getElementById("placeCallButton") +const unbanButton = document.getElementById("unbanButton") +const acceptButton = document.getElementById("acceptButton") +const refuseButton = document.getElementById("refuseButton") +const blockButton = document.getElementById("blockButton") +const callButtons = document.getElementById("callButtons") +const sendButton = document.getElementById("sendButton") +const optionsButton = document.getElementById("optionsButton") +const backToBottomBtn = document.getElementById("back_to_bottom_button") +const backToBottomBtnContainer = document.getElementById("back_to_bottom_button_container") +const sendFileButton = document.getElementById("sendFileButton") +const aliasField = document.getElementById("nav-contactid-alias") +const bestIdField = document.getElementById("nav-contactid-bestId") +const idField = document.getElementById("nav-contactid") +const messageBar = document.getElementById("sendMessage") +const messageBarInput = document.getElementById("message") +const addToConvButton = document.getElementById("addToConversationsButton") +const invitation = document.getElementById("invitation") +const inviteImage = document.getElementById("invite_image") +const navbar = document.getElementById("navbar") +const invitationText = document.getElementById("text") +var messages = document.getElementById("messages") +var sendContainer = document.getElementById("file_image_send_container") +var wrapperOfNavbar = document.getElementById("wrapperOfNavbar") + +/* States: allows us to avoid re-doing something if it isn't meaningful */ +var displayLinksEnabled = true +var hoverBackButtonAllowed = true +var hasInvitation = false +var isTemporary = false +var isBanned = false +var isAccountEnabled = true +var isInitialLoading = false +var imagesLoadingCounter = 0 +var canLazyLoad = false + +/* Set the default target to _self and handle with QWebEnginePage::acceptNavigationRequest */ +var linkifyOptions = {} +if (use_qt) { + messageBarInput.onpaste = pasteKeyDetected; + wrapperOfNavbar.outerHTML = "" + linkifyOptions = { + attributes: null, + className: "linkified", + defaultProtocol: "http", + events: null, + format: function (value, type) { + return value + }, + formatHref: function (href, type) { + return href + }, + ignoreTags: [], + nl2br: false, + tagName: "a", + target: { + url: "_self" + }, + validate: true + } + new QWebChannel(qt.webChannelTransport, function (channel) { + window.jsbridge = channel.objects.jsbridge + }) +} else { + sendContainer.outerHTML = "" +} + +/* i18n manager */ +var i18n = null + +/* exported init_i18n */ +function init_i18n(data) { + if (data === undefined) { + i18n = new Jed({ locale_data: { "messages": { "": {} } } }) // eslint-disable-line no-undef + } else { + i18n = new Jed(data) // eslint-disable-line no-undef + } + + reset_message_bar_input() + set_titles() +} + +function set_titles() { + if (use_qt) { + backButton.title = "Hide chat view" + placeCallButton.title = "Place video call" + placeAudioCallButton.title = "Place audio call" + addToConversationsButton.title = "Add to conversations" + unbanButton.title = "Unban contact" + sendButton.title = "Send" + optionsButton.title = "Options" + backToBottomBtn.innerHTML = "'Jump to latest' ▼" + sendFileButton.title = "Send File" + acceptButton.title = "Accept" + refuseButton.title = "Refuse" + blockButton.title = "Block" + } else { + backButton.title = i18n.gettext("Hide chat view") + placeCallButton.title = i18n.gettext("Place video call") + placeAudioCallButton.title = i18n.gettext("Place audio call") + addToConversationsButton.title = i18n.gettext("Add to conversations") + unbanButton.title = i18n.gettext("Unban contact") + sendButton.title = i18n.gettext("Send") + optionsButton.title = i18n.gettext("Options") + backToBottomBtn.innerHTML = `${i18n.gettext("Jump to latest")} ▼` + sendFileButton.title = i18n.gettext("Send File") + acceptButton.title = i18n.gettext("Accept") + refuseButton.title = i18n.gettext("Refuse") + blockButton.title = i18n.gettext("Block") + } +} + +function reset_message_bar_input() { + if (use_qt) { + messageBarInput.placeholder = "Type a message" + } + else { + messageBarInput.placeholder = i18n.gettext("Type a message") + } + +} + +function onScrolled_() { + if (!canLazyLoad) + return + // back to bottom button + if(messages.scrollTop >= messages.scrollHeight - messages.clientHeight - scrollDetectionThresh) { + // fade out + if (!backToBottomBtn.classList.contains("fade")) { + backToBottomBtn.classList.add("fade") + backToBottomBtn.removeAttribute("onclick") + } + } else { + // fade in + if (backToBottomBtn.classList.contains("fade")) { + backToBottomBtn.style.visibility = "visible" + backToBottomBtnContainer.style.visibility = "visible" + backToBottomBtn.classList.remove("fade") + backToBottomBtn.onclick = back_to_bottom + } + } + if (messages.scrollTop == 0 && historyBufferIndex != historyBuffer.length) { + /* At the top and there's something to print */ + printHistoryPart(messages, messages.scrollHeight) + } +} + +const debounce = (fn, time) => { + let timeout + + return function () { + const functionCall = () => fn.apply(this, arguments) + + clearTimeout(timeout) + timeout = setTimeout(functionCall, time) + } +} + +/* exported onScrolled */ +var onScrolled = debounce(onScrolled_, debounceTime) + +/** + * Generic wrapper. Execute passed function keeping scroll position identical. + * + * @param func function to execute + * @param args parameters as array + */ +function exec_keeping_scroll_position(func, args) { + var atEnd = messages.scrollTop >= messages.scrollHeight - messages.clientHeight - scrollDetectionThresh + func(...args) + if (atEnd) { + messages.scrollTop = messages.scrollHeight + } +} + +/** + * Reset scrollbar at a given position. + * @param scroll position at which the scrollbar should be set. + * Here position means the number of pixels scrolled, + * i.e. scroll = 0 resets the scrollbar at the bottom. + */ +function back_to_scroll(scroll) { + messages.scrollTop = messages.scrollHeight - scroll +} + +/** + * Reset scrollbar at bottom. + */ +function back_to_bottom() { + back_to_scroll(0) +} + +/** + * Update common frame between conversations. + * + * Whenever the current conversation is switched, information from the navbar + * and message bar have to be updated to match new contact. This function + * handles most of the required work (except the showing/hiding the invitation, + * which is handled by showInvitation()). + * + * @param accountEnabled whether account is enabled or not + * @param banned whether contact is banned or not + * @param temporary whether contact is temporary or not + * @param alias + * @param bestId + */ +/* exported update_chatview_frame */ +function update_chatview_frame(accountEnabled, banned, temporary, alias, bestid) { + /* This function updates lots of things in the navbar and we don't want to + trigger that many DOM updates. Instead set display to none so DOM is + updated only once. */ + navbar.style.display = "none" + + hoverBackButtonAllowed = true + + aliasField.innerHTML = (alias ? alias : bestid) + + if (alias) { + bestIdField.innerHTML = bestid + idField.classList.remove("oneEntry") + } else { + idField.classList.add("oneEntry") + } + + if (isAccountEnabled !== accountEnabled) { + isAccountEnabled = accountEnabled + hideMessageBar(!accountEnabled) + hideControls(accountEnabled) + } + + if (isBanned !== banned) { + isBanned = banned + hideMessageBar(banned) + + if (banned) { + // contact is banned. update navbar and states + navbar.classList.add("onBannedState") + } else { + navbar.classList.remove("onBannedState") + } + } else if (isTemporary !== temporary) { + isTemporary = temporary + if (temporary) { + addToConvButton.style.display = "flex" + if (use_qt) { + messageBarInput.placeholder = "Note: an interaction will create a new contact." + } + else { + messageBarInput.placeholder = i18n.gettext("Note: an interaction will create a new contact.") + } + } else { + addToConvButton.style.display = "" + reset_message_bar_input() + } + } + + navbar.style.display = "" +} + +/** + * Hide or show invitation. + * + * Invitation is hidden if no contactAlias/invalid alias is passed. + * Otherwise, invitation div is updated. + * + * @param contactAlias + * @param contactId + */ +/* exported showInvitation */ +function showInvitation(contactAlias, contactId) { + if (!contactAlias) { + if (hasInvitation) { + hasInvitation = false + invitation.style.visibility = "" + } + } else { + if (!inviteImage.classList.contains("sender_image")) { + inviteImage.classList.add("sender_image") + } + if (use_qt) { + if (!inviteImage.classList.contains(`sender_image_${contactId}`)) { + inviteImage.classList.add(`sender_image_${contactId}`) + } + invitationText.innerHTML = "<span id='invite_contact_name'>" + contactAlias + "</span> is not in your contacts<br/>" + + "Note: you can automatically accept this invitation by sending a message." + invitation.style.visibility = "visible" + } else { + const className = `sender_image_${contactId}`.replace(/@/g, "_").replace(/\./g, "_") + if (!inviteImage.classList.contains(className)) { + inviteImage.classList.add(className); + } + invitationText.innerHTML = "<b>" + + i18n.sprintf(i18n.gettext("%s is not in your contacts"), contactAlias) + + "</b><br/>" + + i18n.gettext("Note: you can automatically accept this invitation by sending a message.") + } + hasInvitation = true + invitation.style.visibility = "visible" + } +} + +/** + * Hide or show navbar, and update body top padding accordingly. + * + * @param isVisible whether navbar should be displayed or not + */ +/* exported displayNavbar */ +function displayNavbar(isVisible) { + if (isVisible) { + navbar.classList.remove("hiddenState") + document.body.style.setProperty("--navbar-size", undefined) + } else { + navbar.classList.add("hiddenState") + document.body.style.setProperty("--navbar-size", "0") + } +} + +/** + * Hide or show message bar, and update body bottom padding accordingly. + * + * @param isHidden whether message bar should be displayed or not + */ +/* exported hideMessageBar */ +function hideMessageBar(isHidden) { + if (isHidden) { + messageBar.classList.add("hiddenState") + document.body.style.setProperty("--messagebar-size", "0") + } else { + messageBar.classList.remove("hiddenState") + document.body.style.removeProperty("--messagebar-size") + } +} + +/* exported setDisplayLinks */ +function setDisplayLinks(display) { + displayLinksEnabled = display +} + +/** + * This event handler dynamically resizes the message bar depending on the amount of + * text entered, while adjusting the body paddings so that that the message bar doesn't + * overlap messages when it grows. + */ +/* exported grow_text_area */ +function grow_text_area() { + exec_keeping_scroll_position(function () { + var old_height = window.getComputedStyle(messageBar).height + messageBarInput.style.height = "auto" /* <-- necessary, no clue why */ + messageBarInput.style.height = messageBarInput.scrollHeight + "px" + var new_height = window.getComputedStyle(messageBar).height + + var msgbar_size = window.getComputedStyle(document.body).getPropertyValue("--messagebar-size") + var total_size = parseInt(msgbar_size) + parseInt(new_height) - parseInt(old_height) + + document.body.style.setProperty("--messagebar-size", total_size.toString() + "px") + }, []) +} + +/** + * This event handler processes keydown events from the message bar. When pressed key is + * the enter key, send the message unless shift or control was pressed too. + * + * @param key the pressed key + */ +/* exported process_messagebar_keydown */ +function process_messagebar_keydown(key) { + key = key || event + var map = {} + map[key.keyCode] = key.type == "keydown" + if (key.ctrlKey && map[13]) { + messageBarInput.value += "\n" + } + if (key.ctrlKey || key.shiftKey) { + return true + } + if (map[13]) { + sendMessage() + key.preventDefault() + } + return true +} + +/* exported clearSenderImages */ +function clearSenderImages() { + var styles = document.head.querySelectorAll("style"), + i = styles.length + + while (i--) { + document.head.removeChild(styles[i]) + } +} + +/** + * This event handler adds the hover property back to the "back to welcome view" + * button. + * + * This is a hack. It needs some explanations. + * + * Problem: Whenever the "back to welcome view" button is clicked, the webview + * freezes and the GTK ring welcome view is displayed. While the freeze + * itself is perfectly fine (probably necessary for good performances), this + * is a big problem for us when the user opens a chatview again: Since the + * chatview was freezed, the back button has «remembered» the hover state and + * still displays the blue background for a small instant. This is a very bad + * looking artefact. + * + * In order to counter this problem, we introduced the following evil mechanism: + * Whenever a user clicks on the "back to welcome view" button, the hover + * property is disabled. The hover property stays disabled until the user calls + * this event handler by hover-ing the button. + */ +/* exported addBackButtonHoverProperty */ +function addBackButtonHoverProperty() { + if (hoverBackButtonAllowed) { + backButton.classList.add("non-action-button") + } +} + +/** + * Disable or enable textarea. + * + * @param isDisabled whether message bar should be enabled or disabled + */ +/* exported disableSendMessage */ +function disableSendMessage(isDisabled) { + messageBarInput.disabled = isDisabled +} + +/* + * Update timestamps messages. + */ +function updateView() { + updateTimestamps(messages) +} + +setInterval(updateView, 60000) + +/* exported addBannedContact */ +function addBannedContact() { + window.prompt("UNBLOCK") +} + +/* exported addToConversations */ +function addToConversations() { + window.prompt("ADD_TO_CONVERSATIONS") +} + +/* exported placeCall */ +function placeCall() { + window.prompt("PLACE_CALL") +} + +/* exported placeAudioCall */ +function placeAudioCall() { + window.prompt("PLACE_AUDIO_CALL") +} + +/* exported backToWelcomeView */ +function backToWelcomeView() { + backButton.classList.remove("non-action-button") + hoverBackButtonAllowed = false + window.prompt("CLOSE_CHATVIEW") +} + +/** + * Transform a date to a string group like "1 hour ago". + * + * @param date + */ +function formatDate(date) { + const seconds = Math.floor((new Date() - date) / 1000) + var interval = Math.floor(seconds / (3600 * 24)) + + + if (use_qt) { + if (interval > 5) { + return date.toLocaleDateString() + } + if (interval > 1) { + return interval + "\u200E days ago" + } + if (interval === 1) { + return interval + "\u200E day ago" + } + interval = Math.floor(seconds / 3600) + if (interval > 1) { + return interval + "\u200E hours ago" + } + if (interval === 1) { + return interval + "\u200E hour ago" + } + interval = Math.floor(seconds / 60) + if (interval > 1) { + return interval + "\u200E minutes ago" + } + return "just now" + + } else { + if (interval > 5) { + return date.toLocaleDateString() + } + + if (interval > 1) { + return i18n.sprintf(i18n.gettext("%d days ago"), interval) + } + if (interval === 1) { + return i18n.gettext("one day ago") // what about "yesterday"? + } + + interval = Math.floor(seconds / 3600) + if (interval > 1) { + return i18n.sprintf(i18n.gettext("%d hours ago"), interval) + } + if (interval === 1) { + return i18n.gettext("one hour ago") + } + + interval = Math.floor(seconds / 60) + if (interval > 1) { + return i18n.sprintf(i18n.gettext("%d minutes ago"), interval) + } + + return i18n.gettext("just now") + } +} + +/** + * Send content of message bar + */ +function sendMessage() { + if (use_qt) { + //send image in sendContainer + var data_to_send = sendContainer.innerHTML + var imgSrcExtract = new RegExp("<img src=\"(.*?)\">", "g") + var fileSrcExtract = new RegExp("<div class=\"file_wrapper\" data-path=\"(.*?)\">", "g") + + var img_src + while ((img_src = imgSrcExtract.exec(data_to_send)) !== null) { + window.jsbridge.sendImage(img_src[1]) + } + + var file_src + while ((file_src = fileSrcExtract.exec(data_to_send)) !== null) { + window.jsbridge.sendFile(file_src[1]) + } + + sendContainer.innerHTML = "" + sendContainer.style.visibility = "hidden" + reduce_send_container(); + } + + var message = messageBarInput.value + if (message.length > 0) { + messageBarInput.value = "" + if (use_qt) { + window.jsbridge.sendMessage(message) + } else { + window.prompt("SEND:" + message) + } + } +} + +/* exported acceptInvitation */ +function acceptInvitation() { + if (use_qt) { + window.jsbridge.acceptInvitation() + } else { + window.prompt("ACCEPT") + } +} +/* exported refuseInvitation */ +function refuseInvitation() { + if (use_qt) { + window.jsbridge.refuseInvitation() + } else { + window.prompt("REFUSE") + } +} +/* exported blockConversation */ +function blockConversation() { + if (use_qt) { + window.jsbridge.blockConversation() + } else { + window.prompt("BLOCK") + } +} + +/* exported sendFile */ +function selectFileToSend() { + if (use_qt) { + window.jsbridge.selectFile() + } else { + window.prompt("SEND_FILE") + } +} + +/** + * Clear all messages. + */ +/* exported clearMessages */ +function clearMessages() { + canLazyLoad = false + while (messages.firstChild) { + messages.removeChild(messages.firstChild) + } + + if (use_qt) { + backToBottomBtn.style.visibility="hidden" + window.jsbridge.emitMessagesCleared() + } else { + window.prompt("MESSAGE_CLEARED") + } +} + +/** + * Convert text to HTML. + */ +function escapeHtml(html) { + var text = document.createTextNode(html) + var div = document.createElement("div") + div.appendChild(text) + return div.innerHTML +} + +/** + * Get the youtube video id from a URL. + * @param url + */ +function youtube_id(url) { + const regExp = /^.*(youtu\.be\/|v\/|\/u\/w|embed\/|watch\?v=|&v=)([^#&?]*).*/ + const match = url.match(regExp) + return (match && match[2].length == 11) ? match[2] : null +} + +/** + * Returns HTML message from the message text, cleaned and linkified. + * @param message_text + */ +function getMessageHtml(message_text) { + const escaped_message = escapeHtml(message_text) + + var linkified_message = linkifyHtml(escaped_message, linkifyOptions) // eslint-disable-line no-undef + + const textPart = document.createElement("pre") + textPart.innerHTML = linkified_message + + return textPart.outerHTML +} + +/** + * Returns the message status, formatted for display + * @param message_delivery_status + */ +/* exported getMessageDeliveryStatusText */ +function getMessageDeliveryStatusText(message_delivery_status) { + var formatted_delivery_status = message_delivery_status + + if (use_qt) { + switch (message_delivery_status) { + case "sending": + case "ongoing": + formatted_delivery_status = "Sending<svg overflow='visible' viewBox='0 -2 16 14' height='16px' width='16px'><circle class='status_circle anim-first' cx='4' cy='12' r='1'/><circle class='status_circle anim-second' cx='8' cy='12' r='1'/><circle class='status_circle anim-third' cx='12' cy='12' r='1'/></svg>" + break + case "failure": + formatted_delivery_status = "Failure <svg overflow='visible' viewBox='0 -2 16 14' height='16px' width='16px'><path class='status-x x-first' stroke='#AA0000' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' fill='none' d='M4,4 L12,12'/><path class='status-x x-second' stroke='#AA0000' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' fill='none' d='M12,4 L4,12'/></svg>" + break + case "sent": + case "finished": + case "unknown": + case "read": + formatted_delivery_status = "" + break + default: + break + } + } else { + switch (message_delivery_status) { + case "sending": + case "ongoing": + formatted_delivery_status = i18n.gettext("Sending") + "<svg overflow='visible' viewBox='0 -2 16 14' height='16px' width='16px'><circle class='status_circle anim-first' cx='4' cy='12' r='1'/><circle class='status_circle anim-second' cx='8' cy='12' r='1'/><circle class='status_circle anim-third' cx='12' cy='12' r='1'/></svg>" + break + case "failure": + formatted_delivery_status = i18n.gettext("Failure") + "<svg overflow='visible' viewBox='0 -2 16 14' height='16px' width='16px'><path class='status-x x-first' stroke='#AA0000' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' fill='none' d='M4,4 L12,12'/><path class='status-x x-second' stroke='#AA0000' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' fill='none' d='M12,4 L4,12'/></svg>" + break + case "sent": + case "finished": + case "unknown": + case "read": + formatted_delivery_status = "" + break + default: + break + } + } + + return formatted_delivery_status +} + +/** + * Returns the message date, formatted for display + */ +function getMessageTimestampText(message_timestamp, custom_format) { + const date = new Date(1000 * message_timestamp) + if (custom_format) { + return formatDate(date) + } else { + return date.toLocaleString() + } +} + +/** + * Update timestamps. + * @param message_div + */ +function updateTimestamps(messages_div) { + const timestamps = messages_div.getElementsByClassName("timestamp") + for (var c = timestamps.length - 1; c >= 0; --c) { + var timestamp = timestamps.item(c) + timestamp.innerHTML = getMessageTimestampText(timestamp.getAttribute("message_timestamp"), true) + } +} + +/** + * Convert a value in filesize + */ +function humanFileSize(bytes) { + var thresh = 1024 + if (Math.abs(bytes) < thresh) { + return bytes + " B" + } + var units = ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] + var u = -1 + do { + bytes /= thresh + ++u + } while (Math.abs(bytes) >= thresh && u < units.length - 1) + return bytes.toFixed(1) + " " + units[u] +} + +/** + * Hide or show add to conversations/calls whether the account is enabled + * @param accountEnabled true if account is enabled + */ +function hideControls(accountEnabled) { + if (!accountEnabled) { + callButtons.style.display = "none" + } else { + callButtons.style.display = "" + } +} + +/** + * Change the value of the progress bar. + * + * @param progress_bar + * @param message_object + */ +function updateProgressBar(progress_bar, message_object) { + var delivery_status = message_object["delivery_status"] + if ("progress" in message_object && !isErrorStatus(delivery_status) && message_object["progress"] !== 100) { + var progress_percent = (100 * message_object["progress"] / message_object["totalSize"]) + if (progress_percent !== 100) + progress_bar.childNodes[0].setAttribute("style", "width: " + progress_percent + "%") + else + progress_bar.setAttribute("style", "display: none") + } else + progress_bar.setAttribute("style", "display: none") +} + +/** + * Check if a status is an error status + * @param + */ +function isErrorStatus(status) { + return (status === "failure" + || status === "awaiting peer timeout" + || status === "canceled" + || status === "unjoinable peer") +} + +/** + * Build a new file interaction + * @param message_id + */ +function fileInteraction(message_id, message_direction) { + var message_wrapper = document.createElement("div") + message_wrapper.setAttribute("class", "message_wrapper") + + var transfer_info_wrapper = document.createElement("div") + transfer_info_wrapper.setAttribute("class", "transfer_info_wrapper") + message_wrapper.appendChild(transfer_info_wrapper) + + /* Buttons at the left for status information or accept/refuse actions. + The text is bold and clickable. */ + var left_buttons = document.createElement("div") + left_buttons.setAttribute("class", "left_buttons") + transfer_info_wrapper.appendChild(left_buttons) + + var full_div = document.createElement("div") + full_div.setAttribute("class", "full") + full_div.style.visibility = "hidden" + full_div.style.display = "none" + + var filename_wrapper = document.createElement("div") + filename_wrapper.setAttribute("class", "truncate-ellipsis") + + var message_text = document.createElement("span") + message_text.setAttribute("class", "filename") + filename_wrapper.appendChild(message_text) + + // And information like size or error message. + var informations_div = document.createElement("div") + informations_div.setAttribute("class", "informations") + + var text_div = document.createElement("div") + text_div.setAttribute("class", "text") + text_div.addEventListener("click", function () { + // ask ring to open the file + const filename = document.querySelector("#message_" + message_id + " .full").innerText + if (use_qt) { + window.jsbridge.openFile(filename) + } else { + window.prompt(`OPEN_FILE:${filename}`) + } + }) + + text_div.appendChild(filename_wrapper) + text_div.appendChild(full_div) + text_div.appendChild(informations_div) + transfer_info_wrapper.appendChild(text_div) + + // And finally, a progress bar + var message_transfer_progress_bar = document.createElement("span") + message_transfer_progress_bar.setAttribute("class", "message_progress_bar") + + var message_transfer_progress_completion = document.createElement("span") + message_transfer_progress_bar.appendChild(message_transfer_progress_completion) + message_wrapper.appendChild(message_transfer_progress_bar) + + const internal_mes_wrapper = document.createElement("div") + internal_mes_wrapper.setAttribute("class", "internal_mes_wrapper") + if(use_qt) { + var tbl = buildMsgTable(message_direction); + var msg_cell = tbl.querySelector(".msg_cell"); + msg_cell.appendChild(message_wrapper); + internal_mes_wrapper.appendChild(tbl); + } else { + internal_mes_wrapper.appendChild(message_wrapper) + } + + + return internal_mes_wrapper +} + +function buildMsgTable(message_direction) { + var tbl = document.createElement("table"); + + var row0 = document.createElement("tr"); + var sender_image_cell = document.createElement("td"); + sender_image_cell.setAttribute("class", "sender_image_cell") + var msg_cell = document.createElement("td"); + msg_cell.setAttribute("class", "msg_cell") + + row0.appendChild((message_direction === "in") ? sender_image_cell : msg_cell); + row0.appendChild((message_direction === "in") ? msg_cell : sender_image_cell); + + tbl.appendChild(row0); + + var row1 = document.createElement("tr"); + var dummy_cell = document.createElement("td"); + dummy_cell.setAttribute("class", "dummy_cell") + var timestamp_cell = document.createElement("td"); + timestamp_cell.setAttribute("class", "timestamp_cell") + + row1.appendChild((message_direction === "in") ? dummy_cell : timestamp_cell); + row1.appendChild((message_direction === "in") ? timestamp_cell : dummy_cell); + + tbl.appendChild(row1); + + return tbl; +} + +/** + * Build information text for passed file interaction message object + * + * @param message_object message object containing file interaction info + */ +function buildFileInformationText(message_object) { + var informations_txt = getMessageTimestampText(message_object["timestamp"], true) + if (message_object["totalSize"] && message_object["progress"]) { + if (message_object["delivery_status"] === "finished") { + informations_txt += " - " + humanFileSize(message_object["totalSize"]) + } else { + informations_txt += " - " + humanFileSize(message_object["progress"]) + + " / " + humanFileSize(message_object["totalSize"]) + } + } + + return informations_txt + " - " + message_object["delivery_status"] +} + +/** + * Update a file interaction (icons + filename + status + progress bar) + * + * @param message_div the message to update + * @param message_object new informations + * @param forceTypeToFile + */ +function updateFileInteraction(message_div, message_object, forceTypeToFile = false) { + if (!message_div.querySelector(".informations")) return // media + + var acceptSvg = "<svg height=\"24\" viewBox=\"0 0 24 24\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z\"/></svg>", + refuseSvg = "<svg height=\"24\" viewBox=\"0 0 24 24\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z\"/><path d=\"M0 0h24v24H0z\" fill=\"none\"/></svg>", + fileSvg = "<svg fill=\"#000000\" height=\"24\" viewBox=\"0 0 24 24\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z\"/><path d=\"M0 0h24v24H0z\" fill=\"none\"/></svg>", + warningSvg = "<svg fill=\"#000000\" height=\"24\" viewBox=\"0 0 24 24\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z\"/></svg>" + var message_delivery_status = message_object["delivery_status"] + var message_direction = message_object["direction"] + var message_id = message_object["id"] + var message_text = message_object["text"] + + if (isImage(message_text) && message_delivery_status === "finished" && displayLinksEnabled && !forceTypeToFile) { + // Replace the old wrapper by the downloaded image + var old_wrapper = message_div.querySelector(".internal_mes_wrapper") + if (old_wrapper) { + old_wrapper.parentNode.removeChild(old_wrapper) + } + + var errorHandler = function () { + var wrapper = message_div.querySelector(".internal_mes_wrapper") + var message_wrapper = message_div.querySelector(".message_wrapper") + if (message_wrapper) { + message_wrapper.parentNode.removeChild(message_wrapper) + } + + var media_wrapper = message_div.querySelector(".media_wrapper") + if (media_wrapper) { + media_wrapper.parentNode.removeChild(media_wrapper) + } + + var new_interaction = fileInteraction(message_id, message_direction) + var new_message_wrapper = new_interaction.querySelector(".message_wrapper") + wrapper.prepend(new_message_wrapper) + updateFileInteraction(message_div, message_object, true) + } + + var new_wrapper = mediaInteraction(message_id, message_direction, message_text, null, errorHandler) + message_div.insertBefore(new_wrapper, message_div.querySelector(".menu_interaction")) + message_div.querySelector("img").id = message_id + message_div.querySelector("img").msg_obj = message_object + if (use_qt) { + addSenderImage(message_div, message_object["type"], message_object["sender_contact_method"]) + } + return + } + + + if (isAudio(message_text) && message_delivery_status === "finished" && displayLinksEnabled && !forceTypeToFile) { + // Replace the old wrapper by the downloaded audio + var old_wrapper = message_div.querySelector(".internal_mes_wrapper") + if (old_wrapper) { + old_wrapper.parentNode.removeChild(old_wrapper) + } + + var errorHandler = function () { + var wrapper = message_div.querySelector(".internal_mes_wrapper") + wrapper.parentNode.classList.remove("no-audio-overlay") + var message_wrapper = message_div.querySelector(".message_wrapper") + if (message_wrapper) { + message_wrapper.parentNode.removeChild(message_wrapper) + } + + var media_wrapper = message_div.querySelector(".audio") + if (media_wrapper) { + media_wrapper.parentNode.removeChild(media_wrapper) + } + + var new_interaction = fileInteraction(message_id) + var new_message_wrapper = new_interaction.querySelector(".message_wrapper") + wrapper.prepend(new_message_wrapper) + updateFileInteraction(message_div, message_object, true) + } + + const new_wrapper = document.createElement("audio") + new_wrapper.onerror = errorHandler + new_wrapper.setAttribute("src", "file://" + message_text) + new_wrapper.setAttribute("controls", "controls") + var audio_type = "audio/mpeg" + if (message_text.toLowerCase().match(/\.(ogg)$/)) { + audio_type = "audio/ogg" + } else if (message_text.toLowerCase().match(/\.(flac)$/)) { + audio_type = "audio/flac" + } else if (message_text.toLowerCase().match(/\.(wav)$/)) { + audio_type = "audio/wav" + } + new_wrapper.setAttribute("type", audio_type) + new_wrapper.setAttribute("class", "audio") + const internal_mes_wrapper = document.createElement("div") + internal_mes_wrapper.setAttribute("class", "internal_mes_wrapper") + internal_mes_wrapper.appendChild(new_wrapper) + message_div.insertBefore(internal_mes_wrapper, message_div.querySelector(".menu_interaction")) + internal_mes_wrapper.parentNode.classList.add("no-audio-overlay") + + return + } + + // Set informations text + var informations_div = message_div.querySelector(".informations") + informations_div.innerText = buildFileInformationText(message_object) + + // Update flat buttons + var left_buttons = message_div.querySelector(".left_buttons") + left_buttons.innerHTML = "" + if (message_delivery_status === "awaiting peer" || + message_delivery_status === "awaiting host" || + message_delivery_status.indexOf("ongoing") === 0) { + + if (message_direction === "in" && message_delivery_status.indexOf("ongoing") !== 0) { + // add buttons to accept or refuse a call. + var accept_button = document.createElement("div") + accept_button.innerHTML = acceptSvg + if (use_qt) { + accept_button.setAttribute("title", "Accept") + } else { + accept_button.setAttribute("title", i18n.gettext("Accept")) + } + accept_button.setAttribute("class", "flat-button accept") + accept_button.onclick = function () { + if (use_qt) { + window.jsbridge.acceptFile(message_id) + } else { + window.prompt(`ACCEPT_FILE:${message_id}`) + } + } + left_buttons.appendChild(accept_button) + } + + var refuse_button = document.createElement("div") + refuse_button.innerHTML = refuseSvg + if (use_qt) { + refuse_button.setAttribute("title", "Refuse") + } else { + refuse_button.setAttribute("title", i18n.gettext("Refuse")) + } + refuse_button.setAttribute("class", "flat-button refuse") + refuse_button.onclick = function () { + if (use_qt) { + window.jsbridge.refuseFile(message_id) + } else { + window.prompt(`REFUSE_FILE:${message_id}`) + } + } + left_buttons.appendChild(refuse_button) + } else { + var status_button = document.createElement("div") + var statusFile = fileSvg + if (isErrorStatus(message_delivery_status)) + statusFile = warningSvg + status_button.innerHTML = statusFile + status_button.setAttribute("class", "flat-button") + left_buttons.appendChild(status_button) + } + + message_div.querySelector(".full").innerText = message_text + message_div.querySelector(".filename").innerText = message_text.split("/").pop() + updateProgressBar(message_div.querySelector(".message_progress_bar"), message_object) +} + +/** + * Return if a file is an image + * @param file + */ +function isImage(file) { + return file.toLowerCase().match(/\.(jpeg|jpg|gif|png)$/) !== null +} + +/** + * Return if a file is an image + * @param file + */ +function isAudio(file) { + return file.toLowerCase().match(/\.(mp3|mpeg|ogg|flac|wav)$/) !== null +} + +/** + * Return if a file is a youtube video + * @param file + */ +function isVideo(file) { + const availableProtocols = ["http:", "https:"] + const videoHostname = ["youtube.com", "www.youtube.com", "youtu.be"] + const urlParser = document.createElement("a") + urlParser.href = file + return (availableProtocols.includes(urlParser.protocol) && videoHostname.includes(urlParser.hostname)) +} + +/** + * Build a container for passed video thumbnail + * @param linkElt video thumbnail div + */ +function buildVideoContainer(linkElt) { + const containerElt = document.createElement("div") + containerElt.setAttribute("class", "containerVideo") + const playDiv = document.createElement("div") + playDiv.setAttribute("class", "playVideo") + playDiv.innerHTML = "<svg fill=\"#ffffff\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\ + <path d=\"M8 5v14l11-7z\"/>\ + <path d=\"M0 0h24v24H0z\" fill=\"none\"/>\ + </svg>" + linkElt.appendChild(playDiv) + containerElt.appendChild(linkElt) + + return containerElt +} + +/** + * Try to show an image or a video link (youtube for now) + * @param message_id + * @param link to show + * @param ytid if it's a youtube video + * @param errorHandler the new media's onerror field will be set to this function + */ +function mediaInteraction(message_id, message_direction, link, ytid, errorHandler) { + /* TODO promise? + Try to display images. */ + const media_wrapper = document.createElement("div") + media_wrapper.setAttribute("class", "media_wrapper") + const linkElt = document.createElement("a") + linkElt.href = link + linkElt.style.textDecoration = "none" + linkElt.style.border = "none" + const imageElt = document.createElement("img") + + imageElt.src = ytid ? `http://img.youtube.com/vi/${ytid}/0.jpg` : link + + /* Note, here, we don't check the size of the image. + in the future, we can check the content-type and content-length with a request + and maybe disable svg */ + + if (isInitialLoading) { + /* During initial load, make sure the scrollbar stays at the bottom. + Also, the final scrollHeight is only known after the last image was + loaded. We want to display a specific number of messages screens so + we have to set up a callback (on_image_load_finished) which will + check on that and reschedule a new display batch if not enough + messages have been loaded in the DOM. */ + imagesLoadingCounter++ + imageElt.onload = function () { + back_to_bottom() + on_image_load_finished() + } + + if (errorHandler) { + imageElt.onerror = function () { + errorHandler() + back_to_bottom() + on_image_load_finished() + } + } + } else if (messages.scrollTop >= messages.scrollHeight - messages.clientHeight - scrollDetectionThresh) { + /* Keep the scrollbar at the bottom. Images are loaded asynchronously and + the scrollbar position is changed each time an image is loaded and displayed. + In order to make sure the scrollbar stays at the bottom, reset scrollbar + position each time an image was loaded. */ + imageElt.onload = back_to_bottom + + if (errorHandler) { + imageElt.onerror = function () { + errorHandler() + back_to_bottom() + } + } + } else if (errorHandler) { + imageElt.onerror = errorHandler + } + + linkElt.appendChild(imageElt) + + if (ytid) { + media_wrapper.appendChild(buildVideoContainer(linkElt)) + } else { + media_wrapper.appendChild(linkElt) + } + + const internal_mes_wrapper = document.createElement("div") + internal_mes_wrapper.setAttribute("class", "internal_mes_wrapper") + if(use_qt) { + var tbl = buildMsgTable(message_direction) + var msg_cell = tbl.querySelector(".msg_cell") + msg_cell.appendChild(media_wrapper) + internal_mes_wrapper.appendChild(tbl) + } else { + internal_mes_wrapper.appendChild(media_wrapper) + } + + return internal_mes_wrapper +} + +/** + * Build a new text interaction + * @param message_id + * @param htmlText the DOM to show + */ +function textInteraction(message_id, message_direction, htmlText) { + const message_wrapper = document.createElement("div") + message_wrapper.setAttribute("class", "message_wrapper") + var message_text = document.createElement("div") + message_text.setAttribute("class", "message_text") + message_text.innerHTML = htmlText + message_wrapper.appendChild(message_text) + // TODO STATUS + + const internal_mes_wrapper = document.createElement("div") + internal_mes_wrapper.setAttribute("class", "internal_mes_wrapper") + if(use_qt) { + var tbl = buildMsgTable(message_direction); + var msg_cell = tbl.querySelector(".msg_cell"); + msg_cell.appendChild(message_wrapper); + internal_mes_wrapper.appendChild(tbl); + } else { + internal_mes_wrapper.appendChild(message_wrapper) + } + + return internal_mes_wrapper +} + +/** + * Update a text interaction (text) + * @param message_div the message to update + * @param delivery_status the status of the message + */ +function updateTextInteraction(message_div, delivery_status) { + if (!message_div.querySelector(".message_text")) return // media + var sending = message_div.querySelector(".sending") + switch(delivery_status) + { + case "ongoing": + case "sending": + if (!sending) { + sending = document.createElement("div") + sending.setAttribute("class", "sending") + sending.innerHTML = "<svg overflow=\"hidden\" viewBox=\"0 -2 16 14\" height=\"16px\" width=\"16px\"><circle class=\"status_circle anim-first\" cx=\"4\" cy=\"12\" r=\"1\"/><circle class=\"status_circle anim-second\" cx=\"8\" cy=\"12\" r=\"1\"/><circle class=\"status_circle anim-third\" cx=\"12\" cy=\"12\" r=\"1\"/></svg>" + // add sending animation to message + message_div.insertBefore(sending, message_div.querySelector(".menu_interaction")) + } + message_div.querySelector(".message_text").style.color = "#888" + break + case "failure": + // change text color to red + message_div.querySelector(".message_text").color = "#000" + var failure_div = message_div.querySelector(".failure") + if (!failure_div) { + failure_div = document.createElement("div") + failure_div.setAttribute("class", "failure") + failure_div.innerHTML = "<svg overflow=\"visible\" viewBox=\"0 -2 16 14\" height=\"16px\" width=\"16px\"><path class=\"status-x x-first\" stroke=\"#AA0000\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"3\" fill=\"none\" d=\"M4,4 L12,12\"/><path class=\"status-x x-second\" stroke=\"#AA0000\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"3\" fill=\"none\" d=\"M12,4 L4,12\"/></svg>" + // add failure animation to message + message_div.insertBefore(failure_div, message_div.querySelector(".menu_interaction")) + } + message_div.querySelector(".message_text").style.color = "#000" + if (sending) sending.style.display = "none" + break + case "sent": + case "finished": + case "unknown": + case "read": + // change text color to black + message_div.querySelector(".message_text").style.color = "#000" + if (sending) sending.style.display = "none" + break + default: + break + } +} + +/** + * Build a new interaction (call or contact) + */ +function actionInteraction() { + var message_wrapper = document.createElement("div") + message_wrapper.setAttribute("class", "message_wrapper") + + // A file interaction contains buttons at the left of the interaction + // for the status or accept/refuse buttons + var left_buttons = document.createElement("div") + left_buttons.setAttribute("class", "left_buttons") + message_wrapper.appendChild(left_buttons) + // Also contains a bold clickable text + var text_div = document.createElement("div") + text_div.setAttribute("class", "text") + message_wrapper.appendChild(text_div) + return message_wrapper +} + +/** + * Update a call interaction (icon + text) + * @param message_div the message to update + * @param message_object new informations + */ +function updateCallInteraction(message_div, message_object) { + const outgoingCall = "<svg fill=\"#219d55\" height=\"24\" viewBox=\"0 0 24 24\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M9 5v2h6.59L4 18.59 5.41 20 17 8.41V15h2V5z\"/></svg>" + const callMissed = "<svg fill=\"#dc2719\" height=\"24\" viewBox=\"0 0 24 24\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M19.59 7L12 14.59 6.41 9H11V7H3v8h2v-4.59l7 7 9-9z\"/></svg>" + const outgoingMissed = "<svg fill=\"#dc2719\" height=\"24\" viewBox=\"0 0 24 24\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"><defs><path d=\"M24 24H0V0h24v24z\" id=\"a\"/></defs><clipPath id=\"b\"><use overflow=\"visible\" xlink:href=\"#a\"/></clipPath><path clip-path=\"url(#b)\" d=\"M3 8.41l9 9 7-7V15h2V7h-8v2h4.59L12 14.59 4.41 7 3 8.41z\"/></svg>" + const callReceived = "<svg fill=\"#219d55\" height=\"24\" viewBox=\"0 0 24 24\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M20 5.41L18.59 4 7 15.59V9H5v10h10v-2H8.41z\"/></svg>" + + const message_text = message_object["text"] + const message_direction = (message_text.toLowerCase().indexOf("incoming") !== -1) ? "in" : "out" + const missed = message_text.indexOf("Missed") !== -1 + + message_div.querySelector(".text").innerText = message_text.substring(2) + + var left_buttons = message_div.querySelector(".left_buttons") + left_buttons.innerHTML = "" + var status_button = document.createElement("div") + var statusFile = "" + if (missed) + statusFile = (message_direction === "in") ? callMissed : outgoingMissed + else + statusFile = (message_direction === "in") ? callReceived : outgoingCall + status_button.innerHTML = statusFile + status_button.setAttribute("class", "flat-button") + left_buttons.appendChild(status_button) +} + +/** + * Update a contact interaction (icon + text) + * @param message_div the message to update + * @param message_object new informations + */ +function updateContactInteraction(message_div, message_object) { + const message_text = message_object["text"] + + message_div.querySelector(".text").innerText = message_text + + var left_buttons = message_div.querySelector(".left_buttons") + left_buttons.innerHTML = "" + var status_button = document.createElement("div") + status_button.innerHTML = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\">\ +<path d=\"M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z\"/>\ +<path d=\"M0 0h24v24H0z\" fill=\"none\"/></svg>" + status_button.setAttribute("class", "flat-button") + left_buttons.appendChild(status_button) +} + +/** + * Remove an interaction from the conversation + * @param interaction_id + */ +/* exported removeInteraction */ +function removeInteraction(interaction_id) { + var interaction = document.getElementById(`message_${interaction_id}`) + if (!interaction) { + return + } + + if (interaction.previousSibling) { + /* if element was the most recently received message, make sure the + last_message property is given away to the previous sibling */ + if (interaction.classList.contains("last_message")) { + interaction.previousSibling.classList.add("last_message") + } + + /* same for timestamp */ + var timestamp = interaction.querySelector(".timestamp") + var previousTimeStamp = interaction.previousSibling.querySelector(".timestamp") + if (timestamp && !previousTimeStamp) { + if (use_qt) { + interaction.previousSibling.querySelector(".timestamp_cell").appendChild(timestamp) + } else { + interaction.previousSibling.querySelector(".internal_mes_wrapper").appendChild(timestamp) + } + } + } + + var firstMessage = getPreviousInteraction(interaction) + var secondMessage = getNextInteraction(interaction) + + updateSequencing(firstMessage, secondMessage) + + interaction.parentNode.removeChild(interaction) +} + +/** + * Build message dropdown + * @return a message dropdown for passed message id + */ +function buildMessageDropdown(message_id) { + const menu_element = document.createElement("div") + menu_element.setAttribute("class", "menu_interaction") + menu_element.innerHTML = + `<input type="checkbox" id="showmenu${message_id}" class="showmenu"> + <label for="showmenu${message_id}"> + <svg fill="#888888" height="12" viewBox="0 0 24 24" width="12" xmlns="http://www.w3.org/2000/svg"> + <path d="M0 0h24v24H0z" fill="none"/> + <path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/> + </svg> + </label>` + menu_element.onclick = function () { + const button = this.querySelector(".showmenu") + button.checked = !button.checked + } + menu_element.onmouseleave = function () { + const button = this.querySelector(".showmenu") + button.checked = false + } + const dropdown = document.createElement("div") + const dropdown_classes = [ + "dropdown", + `dropdown_${message_id}` + ] + dropdown.setAttribute("class", dropdown_classes.join(" ")) + + const remove = document.createElement("div") + remove.setAttribute("class", "menuoption") + if (use_qt) { + remove.innerHTML = "Delete" + } else { + remove.innerHTML = i18n.gettext("Delete") + } + remove.msg_id = message_id + remove.onclick = function () { + if (use_qt) { + window.jsbridge.deleteInteraction(`${this.msg_id}`) + } else { + window.prompt(`DELETE_INTERACTION:${this.msg_id}`) + } + } + dropdown.appendChild(remove) + menu_element.appendChild(dropdown) + + return menu_element +} + +/** + * Build a message div for passed message object + * @param message_object to treat + */ +function buildNewMessage(message_object) { + const message_id = message_object["id"] + const message_type = message_object["type"] + const message_text = message_object["text"] + const message_direction = message_object["direction"] + const delivery_status = message_object["delivery_status"] + const message_sender_contact_method = message_object["sender_contact_method"] + + var classes = [ + "message", + `message_${message_direction}`, + `message_type_${message_type}` + ] + + var type = "" + var message_div = document.createElement("div") + message_div.setAttribute("id", `message_${message_id}`) + message_div.setAttribute("class", classes.join(" ")) + + // Build message for each types. + if(!use_qt) { + // Add sender images if necessary (like if the interaction doesn't take the whole width) + const need_sender = (message_type === "data_transfer" || message_type === "text") + if (need_sender) { + var message_sender_image = document.createElement("span") + message_sender_image.setAttribute("class", `sender_image sender_image_${message_sender_contact_method}`) + message_div.appendChild(message_sender_image) + } + } + + + // Build main content + if (message_type === "data_transfer") { + if (isImage(message_text) && delivery_status === "finished" && displayLinksEnabled) { + var errorHandler = function () { + var wrapper = message_div.querySelector(".internal_mes_wrapper") + var message_wrapper = message_div.querySelector(".message_wrapper") + if (message_wrapper) { + message_wrapper.parentNode.removeChild(message_wrapper) + } + + var media_wrapper = message_div.querySelector(".media_wrapper") + if (media_wrapper) { + media_wrapper.parentNode.removeChild(media_wrapper) + } + + var new_interaction = fileInteraction(message_id, message_direction) + var new_message_wrapper = new_interaction.querySelector(".message_wrapper") + wrapper.prepend(new_message_wrapper) + updateFileInteraction(message_div, message_object, true) + } + message_div.append(mediaInteraction(message_id, message_direction, message_text, null, errorHandler)) + message_div.querySelector("img").id = message_id + message_div.querySelector("img").msg_obj = message_object + } else { + message_div.append(fileInteraction(message_id, message_direction)) + } + } else if (message_type === "text") { + // TODO add the possibility to update messages (remove and rebuild) + const htmlText = getMessageHtml(message_text) + if (displayLinksEnabled) { + const parser = new DOMParser() + const DOMMsg = parser.parseFromString(htmlText, "text/xml") + const links = DOMMsg.querySelectorAll("a") + if (DOMMsg.childNodes.length && links.length) { + var isTextToShow = (DOMMsg.childNodes[0].childNodes.length > 1) + const ytid = (isVideo(message_text)) ? youtube_id(message_text) : "" + if (!isTextToShow && (ytid || isImage(message_text))) { + type = "media" + message_div.append(mediaInteraction(message_id, message_direction, message_text, ytid)) + } + } + } + if (type !== "media") { + type = "text" + message_div.append(textInteraction(message_id, message_direction, htmlText)) + } + } else if (message_type === "call" || message_type === "contact") { + message_div.append(actionInteraction()) + } else { + const temp = document.createElement("div") + temp.innerText = message_type + message_div.appendChild(temp) + } + + var message_dropdown = buildMessageDropdown(message_id) + if (message_type !== "call" && message_type !== "contact") { + message_div.appendChild(message_dropdown) + } else { + var wrapper = message_div.querySelector(".message_wrapper") + wrapper.insertBefore(message_dropdown, wrapper.firstChild) + } + + if(use_qt) { + // Add sender images if necessary (like if the interaction doesn't take the whole width) + addSenderImage(message_div, message_type, message_sender_contact_method) + } + + return message_div +} + +function addSenderImage(message_div, message_type, message_sender_contact_method) { + const need_sender = (message_type === "data_transfer" || message_type === "text") + if (need_sender) { + var sender_image_cell = message_div.querySelector(".sender_image_cell") + if (sender_image_cell) { + var message_sender_image = document.createElement("span") + var cssSafeStr = message_sender_contact_method.replace(/@/g, "_").replace(/\./g, "_"); + message_sender_image.setAttribute("class", `sender_image sender_image_${cssSafeStr}`) + sender_image_cell.appendChild(message_sender_image) + } else { + console.warn("can't find sender_image_cell"); + } + } +} + + +/** + * Build a timestamp for passed message object + * @param message_object to treat + */ +function buildNewTimestamp(message_object) { + const message_type = message_object["type"] + const message_direction = message_object["direction"] + const message_timestamp = message_object["timestamp"] + + const formattedTimestamp = getMessageTimestampText(message_timestamp, true) + const date_elt = document.createElement("div") + + date_elt.innerText = formattedTimestamp + var typeIsCallOrContact = (message_type === "call" || message_type === "contact") + var timestamp_div_classes = ["timestamp", typeIsCallOrContact ? "timestamp_action" : `timestamp_${message_direction}`] + date_elt.setAttribute("class", timestamp_div_classes.join(" ")) + date_elt.setAttribute("message_timestamp", message_timestamp) + + return date_elt +} + +/** + * Add a message to the conversation. + * @param message_object to treat + * @param new_message if it's a new message or if we need to update + * @param insert_after if we want the message at the end or the top of the conversation + * @param messages_div + */ +function addOrUpdateMessage(message_object, new_message, insert_after = true, messages_div) { + const message_id = message_object["id"] + const message_type = message_object["type"] + const message_direction = message_object["direction"] + const delivery_status = message_object["delivery_status"] + + var message_div = messages_div.querySelector("#message_" + message_id) + if (new_message) { + message_div = buildNewMessage(message_object) + + /* Show timestamp if either: + - message has type call or contact + - or most recently added timestamp in this set is different + - or message is the first message in this set */ + + var date_elt = buildNewTimestamp(message_object) + var timestamp = messages_div.querySelector(".timestamp") + + if (message_type === "call" || message_type === "contact") { + message_div.querySelector(".message_wrapper").appendChild(date_elt) + } else if (insert_after || !timestamp || timestamp.className !== date_elt.className + || timestamp.innerHTML !== date_elt.innerHTML) { + if (use_qt) { + message_div.querySelector(".timestamp_cell").appendChild(date_elt) + if (message_direction === "out") + message_div.querySelector(".timestamp_cell").setAttribute("class", "timestamp_cell_out") + } else { + message_div.querySelector(".internal_mes_wrapper").appendChild(date_elt) + } + } + + var isGenerated = message_type === "call" || message_type === "contact" + if (isGenerated) { + message_div.classList.add("generated_message") + } + + if (insert_after) { + var previousMessage = messages_div.lastChild + messages_div.appendChild(message_div) + computeSequencing(previousMessage, message_div, null, insert_after) + if (previousMessage) { + previousMessage.classList.remove("last_message") + } + message_div.classList.add("last_message") + + /* When inserting at the bottom we should also check that the + previously sent message does not have the same timestamp. + If it's the case, remove it.*/ + if (message_div.previousSibling) { + var previous_timestamp = message_div.previousSibling.querySelector(".timestamp") + if (previous_timestamp && + previous_timestamp.className === date_elt.className && + previous_timestamp.innerHTML === date_elt.innerHTML && + !message_div.previousSibling.classList.contains("last_of_sequence")) { + previous_timestamp.parentNode.removeChild(previous_timestamp) + } + } + } else { + var nextMessage = messages_div.firstChild + messages_div.prepend(message_div) + computeSequencing(message_div, nextMessage, null, insert_after) + } + } + + if (isErrorStatus(delivery_status) && message_direction === "out") { + const dropdown = messages_div.querySelector(`.dropdown_${message_id}`) + if (!dropdown.querySelector(".retry")) { + const retry = document.createElement("div") + retry.setAttribute("class", "retry") + if (use_qt) { + retry.innerHTML = "Retry" + } else { + retry.innerHTML = i18n.gettext("Retry") + } + retry.msg_id = message_id + retry.onclick = function () { + if (use_qt) { + window.jsbridge.retryInteraction(`${this.msg_id}`) + } else { + window.prompt(`RETRY_INTERACTION:${this.msg_id}`) + } + } + dropdown.insertBefore(retry, message_div.querySelector(".delete")) + } + } + + // Update informations if needed + if (message_type === "data_transfer") + updateFileInteraction(message_div, message_object) + if (message_type === "text" && message_direction === "out") + // Modify sent status if necessary + updateTextInteraction(message_div, delivery_status) + if (message_type === "call") + updateCallInteraction(message_div, message_object) + if (message_type === "contact") + updateContactInteraction(message_div, message_object) + + // Clean timestamps + updateTimestamps(messages_div) +} + +function getNextInteraction(interaction, includeLazyLoadedBlock = true) { + var nextInteraction = interaction.nextSibling + if (!nextInteraction && includeLazyLoadedBlock) { + var nextBlock = interaction.parentNode.nextElementSibling + if (nextBlock) { + nextInteraction = nextBlock.firstElementChild + } + } + return nextInteraction +} + +function getPreviousInteraction(interaction, includeLazyLoadedBlock = true) { + var previousInteraction = interaction.previousSibling + if (!previousInteraction && includeLazyLoadedBlock) { + var previousBlock = interaction.parentNode.previousElementSibling + if (previousBlock) { + previousInteraction = previousBlock.lastElementChild + } + } + return previousInteraction +} + +function isSequenceBreak(firstMessage, secondMessage, insert_after = true) { + if (!firstMessage || !secondMessage) { + return false + } + var first_message_direction = firstMessage.classList.contains("message_out") ? "out" : "in" + var second_message_direction = secondMessage.classList.contains("message_out") ? "out" : "in" + if (second_message_direction != first_message_direction) { + return true + } + var firstMessageIsGenerated = firstMessage.classList.contains("generated_message") + var secondMessageIsGenerated = secondMessage.classList.contains("generated_message") + if (firstMessageIsGenerated != secondMessageIsGenerated) { + return true + } + if (insert_after) { + const internal_message_wrapper = firstMessage.querySelector(".internal_mes_wrapper") + if (internal_message_wrapper) { + const firstTimestamp = internal_message_wrapper.querySelector(".timestamp") + return !!(firstTimestamp) && firstTimestamp.innerHTML !== "just now" + } + return false + } else { + const internal_message_wrapper = firstMessage.querySelector(".internal_mes_wrapper") + if (internal_message_wrapper) { + return !!(internal_message_wrapper.querySelector(".timestamp")) + } + return false + } +} + +function updateSequencing(firstMessage, secondMessage) { + if (firstMessage) { + if (secondMessage) { + var sequence_break = isSequenceBreak(firstMessage, secondMessage, false) + if (sequence_break) { + if (firstMessage.classList.contains("middle_of_sequence")) { + firstMessage.classList.remove("middle_of_sequence") + firstMessage.classList.add("last_of_sequence") + } else if (firstMessage.classList.contains("first_of_sequence")) { + firstMessage.classList.remove("first_of_sequence") + firstMessage.classList.add("single_message") + } + if (secondMessage.classList.contains("middle_of_sequence")) { + secondMessage.classList.remove("middle_of_sequence") + secondMessage.classList.add("first_of_sequence") + } else if (secondMessage.classList.contains("last_of_sequence")) { + secondMessage.classList.remove("last_of_sequence") + secondMessage.classList.add("single_message") + } + } else { + if (firstMessage.classList.contains("last_of_sequence")) { + firstMessage.classList.remove("last_of_sequence") + firstMessage.classList.add("middle_of_sequence") + } else if (firstMessage.classList.contains("single_message")) { + firstMessage.classList.remove("single_message") + firstMessage.classList.add("first_of_sequence") + } + if (secondMessage.classList.contains("first_of_sequence")) { + secondMessage.classList.remove("first_of_sequence") + secondMessage.classList.add("middle_of_sequence") + } else if (secondMessage.classList.contains("single_message")) { + secondMessage.classList.remove("single_message") + secondMessage.classList.add("last_of_sequence") + } + } + } else { + // this is the last interaction of the conversation + if (firstMessage.classList.contains("first_of_sequence")) { + firstMessage.classList.remove("first_of_sequence") + firstMessage.classList.add("single_message") + } else if (firstMessage.classList.contains("middle_of_sequence")) { + firstMessage.classList.remove("middle_of_sequence") + firstMessage.classList.add("last_of_sequence") + } + } + } else if (secondMessage) { + // this is the first interaction of the conversation + if (secondMessage.classList.contains("middle_of_sequence")) { + secondMessage.classList.remove("middle_of_sequence") + secondMessage.classList.add("first_of_sequence") + } else if (secondMessage.classList.contains("last_of_sequence")) { + secondMessage.classList.remove("last_of_sequence") + secondMessage.classList.add("single_message") + } + } +} + +function computeSequencing(firstMessage, secondMessage, lazyLoadingBlock, insert_after = true) { + if (insert_after) { + if (secondMessage) { + var secondMessageIsGenerated = secondMessage.classList.contains("generated_message") + if (firstMessage && !secondMessageIsGenerated) { + var firstMessageIsGenerated = firstMessage.classList.contains("generated_message") + var sequence_break = isSequenceBreak(firstMessage, secondMessage) + if (sequence_break) { + secondMessage.classList.add("single_message") + } else { + if (firstMessage.classList.contains("single_message")) { + firstMessage.classList.remove("single_message") + firstMessage.classList.add("first_of_sequence") + } else if (firstMessage.classList.contains("last_of_sequence")) { + firstMessage.classList.remove("last_of_sequence") + firstMessage.classList.add("middle_of_sequence") + } + if (firstMessageIsGenerated) { + secondMessage.classList.add("single_message") + } else { + secondMessage.classList.add("last_of_sequence") + } + } + } else if (!secondMessageIsGenerated) { + secondMessage.classList.add("single_message") + } + } + } else if (firstMessage) { + var firstMessageIsGenerated = firstMessage.classList.contains("generated_message") + if (secondMessage && !firstMessageIsGenerated) { + var secondMessageIsGenerated = secondMessage.classList.contains("generated_message") + var sequence_break = isSequenceBreak(firstMessage, secondMessage, false) + if (sequence_break) { + firstMessage.classList.add("single_message") + } else { + if (secondMessage.classList.contains("single_message")) { + secondMessage.classList.remove("single_message") + secondMessage.classList.add("last_of_sequence") + } else if (secondMessage.classList.contains("first_of_sequence")) { + secondMessage.classList.remove("first_of_sequence") + secondMessage.classList.add("middle_of_sequence") + } + if (secondMessageIsGenerated) { + firstMessage.classList.add("single_message") + } else { + firstMessage.classList.add("first_of_sequence") + } + } + } else if (!firstMessageIsGenerated) { + firstMessage.classList.add("single_message") + } + } +} + +/** + * Wrapper for addOrUpdateMessage. + * + * Add or update a message and make sure the scrollbar position + * is refreshed correctly + * + * @param message_object message to be added + */ +/* exported addMessage */ +function addMessage(message_object) { + if (!messages.lastChild) { + var block_wrapper = document.createElement("div") + messages.append(block_wrapper) + } + + exec_keeping_scroll_position(addOrUpdateMessage, [message_object, true, undefined, messages.lastChild]) +} + +/** + * Update a message that was previously added with addMessage and + * make sure the scrollbar position is refreshed correctly + * + * @param message_object message to be updated + */ +/* exported updateMessage */ +function updateMessage(message_object) { + var message_div = messages.querySelector("#message_" + message_object["id"]) + exec_keeping_scroll_position(addOrUpdateMessage, [message_object, false, undefined, message_div.parentNode]) +} + +/** + * Called whenever an image has finished loading. Check lazy loading status + * once all images have finished loading. + */ +function on_image_load_finished() { + imagesLoadingCounter-- + + if (!imagesLoadingCounter) { + /* This code is executed once all images have been loaded. */ + check_lazy_loading() + } +} + +/** + * Make sure at least initialScrollBufferFactor screens of messages are + * available in the DOM. + */ +function check_lazy_loading() { + if (!canLazyLoad) + return + if (messages.scrollHeight < initialScrollBufferFactor * messages.clientHeight + && historyBufferIndex !== historyBuffer.length) { + /* Not enough messages loaded, print a new batch. Enable isInitialLoading + as reloading a single batch might not be sufficient to fulfill our + criteria (we want to be called back again to check on that) */ + isInitialLoading = true + printHistoryPart(messages, 0) + isInitialLoading = false + } +} + +/** + * Display 'scrollBuffer' messages from history in passed div (reverse order). + * + * @param messages_div that should be modified + * @param setMessages if enabled, #messages will be set to the resulting messages + * div after being modified. If #messages already exists it will + * be removed and replaced by the new div. + * @param fixedAt if setMessages is enabled, maintain scrollbar at the specified + * position (otherwise modifying #messages would result in + * changing the position of the scrollbar) + */ +function printHistoryPart(messages_div, fixedAt) { + if (historyBufferIndex === historyBuffer.length) { + return + } + /* If first element is a spinner, remove it */ + if (messages_div.firstChild && messages_div.firstChild.id === "lazyloading-icon") { + messages_div.removeChild(messages_div.firstChild) + } + + /* Elements are appended to a wrapper div. This div has no style + properties, it allows us to add all messages at once to the main + messages div. */ + var block_wrapper = document.createElement("div") + + messages_div.prepend(block_wrapper) + + for (var i = 0; i < scrollBuffer && historyBufferIndex < historyBuffer.length; ++historyBufferIndex && ++i) { + // TODO on-screen messages should be removed from the buffer + addOrUpdateMessage(historyBuffer[historyBuffer.length - 1 - historyBufferIndex], true, false, block_wrapper) + } + + var lastMessage = block_wrapper.lastChild + + updateSequencing(lastMessage, getNextInteraction(lastMessage)) + + var absoluteLastMessage = messages_div.lastChild.lastChild + if (absoluteLastMessage) { + absoluteLastMessage.classList.add("last_message") + } + + /* Add ellipsis (...) at the top if there are still messages to load */ + if (historyBufferIndex < historyBuffer.length) { + var llicon = document.createElement("span") + llicon.id = "lazyloading-icon" + llicon.innerHTML = "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"#888888\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path d=\"M0 0h24v24H0z\" fill=\"none\"/><path d=\"M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z\"/></svg>" + messages_div.prepend(llicon) + } + + if (fixedAt !== undefined) { + /* update scrollbar position to take text-message -related + scrollHeight changes in account (not necessary to wait + for DOM redisplay in this case). Changes due to image + messages are handled in their onLoad callbacks. */ + back_to_scroll(fixedAt) + /* schedule a scrollbar position update for changes which + are neither handled by the previous call nor by onLoad + callbacks. This call is necessary but not sufficient, + dropping the previous call would result in visual + glitches during initial load. */ + setTimeout(function () {back_to_scroll(fixedAt)}, 0) + } + + if (!imagesLoadingCounter) { + setTimeout(check_lazy_loading, 0) + } +} + +function hideMessagesDiv() +{ + if (!messages.classList.contains("fade")) { + messages.classList.add("fade") + } +} + +function showMessagesDiv() +{ + if (messages.classList.contains("fade")) { + messages.classList.remove("fade") + } +} + +/** + * Set history buffer, initialize messages div and display a first batch + * of messages. + * + * Make sure that enough messages are displayed to fill initialScrollBufferFactor + * screens of messages (if enough messages are present in the conversation) + * + * @param messages_array should contain history to be printed + */ +/* exported printHistory */ +function printHistory(messages_array) +{ + historyBuffer = messages_array + historyBufferIndex = 0 + + isInitialLoading = true + printHistoryPart(messages, 0) + isInitialLoading = false + + canLazyLoad = true + + if (use_qt) { + window.jsbridge.emitMessagesLoaded() + } else { + window.prompt("MESSAGES_LOADED") + } +} + +/** + * Set the image for a given sender + * set_sender_image object should contain the following keys: + * - sender: the name of the sender + * - sender_image: base64 png encoding of the sender image + * + * @param set_sender_image_object sender image object as previously described + */ +/* exported setSenderImage */ +function setSenderImage(set_sender_image_object) +{ + if (use_qt) { + var sender_contact_method = set_sender_image_object["sender_contact_method"].replace(/@/g, "_").replace(/\./g, "_"), + sender_image = set_sender_image_object["sender_image"], + sender_image_id = "sender_image_" + sender_contact_method, + invite_sender_image_id = "invite_sender_image_" + sender_contact_method, + currentSenderImage = document.getElementById(sender_image_id), // Remove the currently set sender image + style, invite_style + + } else { + var sender_contact_method = set_sender_image_object["sender_contact_method"], + sender_image = set_sender_image_object["sender_image"], + sender_image_id = "sender_image_" + sender_contact_method, + invite_sender_image_id = "invite_sender_image_" + sender_contact_method, + currentSenderImage = document.getElementById(sender_image_id), // Remove the currently set sender image + style, invite_style + } + + if (currentSenderImage) { + currentSenderImage.parentNode.removeChild(currentSenderImage) + } + + // Create a new style element + style = document.createElement("style") + + style.type = "text/css" + style.id = sender_image_id + style.innerHTML = "." + sender_image_id + " {content: url(data:image/png;base64," + sender_image + ");height: 2.25em;width: 2.25em;}" + document.head.appendChild(style) + + invite_style = document.createElement("style") + + invite_style.type = "text/css" + invite_style.id = invite_sender_image_id + invite_style.innerHTML = "." + invite_sender_image_id + " {content: url(data:image/png;base64," + sender_image + ");height: 48px;width: 48px;}" + document.head.appendChild(invite_style) +} + +/** + * Copy Mouse Selected Text and return it + */ +function copy_text_selected() { + var selObj = document.getSelection() + var selectedText = selObj.toString() + return selectedText +} + +/** + * Check if text is selected by mouse + */ +function isTextSelected() { + + var selObj = document.getSelection() + var selectedText = selObj.toString() + + if (selectedText.length != 0) { + return true + } + return false +} + +/** + * add file (local file) to message area + */ +function addFile_path(path, name, size) { + var html = "<div class='file_wrapper' data-path='" + path + "'>" + + "<svg class='svg-icon' viewBox='0 0 20 20'>" + + "<path fill = 'none' d = 'M17.222,5.041l-4.443-4.414c-0.152-0.151-0.356-0.235-0.571-0.235h-8.86c-0.444,0-0.807,0.361-0.807,0.808v17.602c0,0.448,0.363,0.808,0.807,0.808h13.303c0.448,0,0.808-0.36,0.808-0.808V5.615C17.459,5.399,17.373,5.192,17.222,5.041zM15.843,17.993H4.157V2.007h7.72l3.966,3.942V17.993z' ></path>" + + "<path fill='none' d='M5.112,7.3c0,0.446,0.363,0.808,0.808,0.808h8.077c0.445,0,0.808-0.361,0.808-0.808c0-0.447-0.363-0.808-0.808-0.808H5.92C5.475,6.492,5.112,6.853,5.112,7.3z'></path>" + + "<path fill='none' d='M5.92,5.331h4.342c0.445,0,0.808-0.361,0.808-0.808c0-0.446-0.363-0.808-0.808-0.808H5.92c-0.444,0-0.808,0.361-0.808,0.808C5.112,4.97,5.475,5.331,5.92,5.331z'></path>" + + "<path fill='none' d='M13.997,9.218H5.92c-0.444,0-0.808,0.361-0.808,0.808c0,0.446,0.363,0.808,0.808,0.808h8.077c0.445,0,0.808-0.361,0.808-0.808C14.805,9.58,14.442,9.218,13.997,9.218z'></path>" + + "<path fill='none' d='M13.997,11.944H5.92c-0.444,0-0.808,0.361-0.808,0.808c0,0.446,0.363,0.808,0.808,0.808h8.077c0.445,0,0.808-0.361,0.808-0.808C14.805,12.306,14.442,11.944,13.997,11.944z'></path>" + + "<path fill='none' d='M13.997,14.67H5.92c-0.444,0-0.808,0.361-0.808,0.808c0,0.447,0.363,0.808,0.808,0.808h8.077c0.445,0,0.808-0.361,0.808-0.808C14.805,15.032,14.442,14.67,13.997,14.67z'></path>" + + "</svg >" + + "<div class='fileinfo'>" + + "<p>" + name + "</p>" + + "<p>" + size + "</p>" + + "</div >" + + "<button class='btn' onclick='remove(this)'>X</button>" + + "</div >" + // At first, visiblity can empty + if (sendContainer.style.visibility.length == 0 || sendContainer.style.visibility == "hidden") { + grow_send_container() + sendContainer.style.visibility = "visible" + } + //add html here since display is set to flex, image will change accordingly + sendContainer.innerHTML += html +} + +/** + * add image (base64 array) to message area + */ +function addImage_base64(base64) { + + var html = "<div class='img_wrapper'>" + + "<img src='data:image/png;base64," + base64 + "'/>" + + "<button class='btn' onclick='remove(this)'>X</button>" + + "</div >" + // At first, visiblity can empty + if (sendContainer.style.visibility.length == 0 || sendContainer.style.visibility == "hidden") { + grow_send_container() + sendContainer.style.visibility = "visible" + } + //add html here since display is set to flex, image will change accordingly + sendContainer.innerHTML += html +} + +/** + * add image (image path) to message area + */ +function addImage_path(path) { + + var html = "<div class='img_wrapper'>" + + "<img src='" + path +"'/>" + + "<button class='btn' onclick='remove(this)'>X</button>" + + "</div >" + // At first, visiblity can empty + if (sendContainer.style.visibility.length == 0 || sendContainer.style.visibility == "hidden") { + grow_send_container() + sendContainer.style.visibility = "visible" + } + //add html here since display is set to flex, image will change accordingly + sendContainer.innerHTML += html +} + +/** + * This function adjusts the body paddings so that that the file_image_send_container doesn't + * overlap messages when it grows. + */ +/* exported grow_send_container */ +function grow_send_container() { + exec_keeping_scroll_position(function () { + var msgbar_size = window.getComputedStyle(document.body).getPropertyValue("--messagebar-size"); + document.body.style.paddingBottom = (parseInt(msgbar_size) + 158).toString() + "px"; + //6em + }, []) +} + +/** + * This function adjusts the body paddings so that that the file_image_send_container will hide + * and recover padding bottom + */ +/* exported grow_send_container */ +function reduce_send_container() { + exec_keeping_scroll_position(function () { + document.body.style.paddingBottom = (parseInt(document.body.style.paddingBottom) - 158).toString() + "px"; + //6em + }, []) +} + +// Remove current cancel button division and hide the sendContainer +function remove(e) { + e.parentNode.parentNode.removeChild(e.parentNode) + if (sendContainer.innerHTML.length == 0) { + reduce_send_container() + sendContainer.style.visibility = "hidden" + } +} + + +// It's called in qt qwebengine +function pasteKeyDetected(e) { + e.preventDefault() + window.jsbridge.emitPasteKeyDetected() +} + +// Set the curser to a target position +function setCaretPosition(elem, caretPos) { + var range + + if (elem.createTextRange) { + range = elem.createTextRange() + range.move("character", caretPos) + range.select() + } else { + elem.focus() + if (elem.selectionStart !== undefined) { + elem.setSelectionRange(caretPos, caretPos) + } + } +} + +function replaceText(text) { + var input = messageBarInput + var currentContent = input.value + var start = input.selectionStart + var end = input.selectionEnd + var output = [currentContent.slice(0, start), text, currentContent.slice(end)].join("") + input.value = output + setCaretPosition(input, start + text.length) +} diff --git a/src/web-chatview/jed.js b/src/web-chatview/jed.js new file mode 100644 index 0000000000000000000000000000000000000000..bda163beff3232c68093b675eed9f18ffe15f265 --- /dev/null +++ b/src/web-chatview/jed.js @@ -0,0 +1,1033 @@ +/** + * @preserve jed.js https://github.com/SlexAxton/Jed + */ +/* +----------- +A gettext compatible i18n library for modern JavaScript Applications + +by Alex Sexton - AlexSexton [at] gmail - @SlexAxton + +MIT License + +A jQuery Foundation project - requires CLA to contribute - +https://contribute.jquery.org/CLA/ + + + +Jed offers the entire applicable GNU gettext spec'd set of +functions, but also offers some nicer wrappers around them. +The api for gettext was written for a language with no function +overloading, so Jed allows a little more of that. + +Many thanks to Joshua I. Miller - unrtst@cpan.org - who wrote +gettext.js back in 2008. I was able to vet a lot of my ideas +against his. I also made sure Jed passed against his tests +in order to offer easy upgrades -- jsgettext.berlios.de +*/ +(function (root, undef) { + + // Set up some underscore-style functions, if you already have + // underscore, feel free to delete this section, and use it + // directly, however, the amount of functions used doesn't + // warrant having underscore as a full dependency. + // Underscore 1.3.0 was used to port and is licensed + // under the MIT License by Jeremy Ashkenas. + var ArrayProto = Array.prototype, + ObjProto = Object.prototype, + slice = ArrayProto.slice, + hasOwnProp = ObjProto.hasOwnProperty, + nativeForEach = ArrayProto.forEach, + breaker = {}; + + // We're not using the OOP style _ so we don't need the + // extra level of indirection. This still means that you + // sub out for real `_` though. + var _ = { + forEach : function( obj, iterator, context ) { + var i, l, key; + if ( obj === null ) { + return; + } + + if ( nativeForEach && obj.forEach === nativeForEach ) { + obj.forEach( iterator, context ); + } + else if ( obj.length === +obj.length ) { + for ( i = 0, l = obj.length; i < l; i++ ) { + if ( i in obj && iterator.call( context, obj[i], i, obj ) === breaker ) { + return; + } + } + } + else { + for ( key in obj) { + if ( hasOwnProp.call( obj, key ) ) { + if ( iterator.call (context, obj[key], key, obj ) === breaker ) { + return; + } + } + } + } + }, + extend : function( obj ) { + this.forEach( slice.call( arguments, 1 ), function ( source ) { + for ( var prop in source ) { + obj[prop] = source[prop]; + } + }); + return obj; + } + }; + // END Miniature underscore impl + + // Jed is a constructor function + var Jed = function ( options ) { + // Some minimal defaults + this.defaults = { + "locale_data" : { + "messages" : { + "" : { + "domain" : "messages", + "lang" : "en", + "plural_forms" : "nplurals=2; plural=(n != 1);" + } + // There are no default keys, though + } + }, + // The default domain if one is missing + "domain" : "messages", + // enable debug mode to log untranslated strings to the console + "debug" : false + }; + + // Mix in the sent options with the default options + this.options = _.extend( {}, this.defaults, options ); + this.textdomain( this.options.domain ); + + if ( options.domain && ! this.options.locale_data[ this.options.domain ] ) { + throw new Error('Text domain set to non-existent domain: `' + options.domain + '`'); + } + }; + + // The gettext spec sets this character as the default + // delimiter for context lookups. + // e.g.: context\u0004key + // If your translation company uses something different, + // just change this at any time and it will use that instead. + Jed.context_delimiter = String.fromCharCode( 4 ); + + function getPluralFormFunc ( plural_form_string ) { + return Jed.PF.compile( plural_form_string || "nplurals=2; plural=(n != 1);"); + } + + function Chain( key, i18n ){ + this._key = key; + this._i18n = i18n; + } + + // Create a chainable api for adding args prettily + _.extend( Chain.prototype, { + onDomain : function ( domain ) { + this._domain = domain; + return this; + }, + withContext : function ( context ) { + this._context = context; + return this; + }, + ifPlural : function ( num, pkey ) { + this._val = num; + this._pkey = pkey; + return this; + }, + fetch : function ( sArr ) { + if ( {}.toString.call( sArr ) != '[object Array]' ) { + sArr = [].slice.call(arguments, 0); + } + return ( sArr && sArr.length ? Jed.sprintf : function(x){ return x; } )( + this._i18n.dcnpgettext(this._domain, this._context, this._key, this._pkey, this._val), + sArr + ); + } + }); + + // Add functions to the Jed prototype. + // These will be the functions on the object that's returned + // from creating a `new Jed()` + // These seem redundant, but they gzip pretty well. + _.extend( Jed.prototype, { + // The sexier api start point + translate : function ( key ) { + return new Chain( key, this ); + }, + + textdomain : function ( domain ) { + if ( ! domain ) { + return this._textdomain; + } + this._textdomain = domain; + }, + + gettext : function ( key ) { + return this.dcnpgettext.call( this, undef, undef, key ); + }, + + dgettext : function ( domain, key ) { + return this.dcnpgettext.call( this, domain, undef, key ); + }, + + dcgettext : function ( domain , key /*, category */ ) { + // Ignores the category anyways + return this.dcnpgettext.call( this, domain, undef, key ); + }, + + ngettext : function ( skey, pkey, val ) { + return this.dcnpgettext.call( this, undef, undef, skey, pkey, val ); + }, + + dngettext : function ( domain, skey, pkey, val ) { + return this.dcnpgettext.call( this, domain, undef, skey, pkey, val ); + }, + + dcngettext : function ( domain, skey, pkey, val/*, category */) { + return this.dcnpgettext.call( this, domain, undef, skey, pkey, val ); + }, + + pgettext : function ( context, key ) { + return this.dcnpgettext.call( this, undef, context, key ); + }, + + dpgettext : function ( domain, context, key ) { + return this.dcnpgettext.call( this, domain, context, key ); + }, + + dcpgettext : function ( domain, context, key/*, category */) { + return this.dcnpgettext.call( this, domain, context, key ); + }, + + npgettext : function ( context, skey, pkey, val ) { + return this.dcnpgettext.call( this, undef, context, skey, pkey, val ); + }, + + dnpgettext : function ( domain, context, skey, pkey, val ) { + return this.dcnpgettext.call( this, domain, context, skey, pkey, val ); + }, + + // The most fully qualified gettext function. It has every option. + // Since it has every option, we can use it from every other method. + // This is the bread and butter. + // Technically there should be one more argument in this function for 'Category', + // but since we never use it, we might as well not waste the bytes to define it. + dcnpgettext : function ( domain, context, singular_key, plural_key, val ) { + // Set some defaults + + plural_key = plural_key || singular_key; + + // Use the global domain default if one + // isn't explicitly passed in + domain = domain || this._textdomain; + + var fallback; + + // Handle special cases + + // No options found + if ( ! this.options ) { + // There's likely something wrong, but we'll return the correct key for english + // We do this by instantiating a brand new Jed instance with the default set + // for everything that could be broken. + fallback = new Jed(); + return fallback.dcnpgettext.call( fallback, undefined, undefined, singular_key, plural_key, val ); + } + + // No translation data provided + if ( ! this.options.locale_data ) { + throw new Error('No locale data provided.'); + } + + if ( ! this.options.locale_data[ domain ] ) { + throw new Error('Domain `' + domain + '` was not found.'); + } + + if ( ! this.options.locale_data[ domain ][ "" ] ) { + throw new Error('No locale meta information provided.'); + } + + // Make sure we have a truthy key. Otherwise we might start looking + // into the empty string key, which is the options for the locale + // data. + if ( ! singular_key ) { + throw new Error('No translation key found.'); + } + + var key = context ? context + Jed.context_delimiter + singular_key : singular_key, + locale_data = this.options.locale_data, + dict = locale_data[ domain ], + defaultConf = (locale_data.messages || this.defaults.locale_data.messages)[""], + pluralForms = dict[""].plural_forms || dict[""]["Plural-Forms"] || dict[""]["plural-forms"] || defaultConf.plural_forms || defaultConf["Plural-Forms"] || defaultConf["plural-forms"], + val_list, + res; + + var val_idx; + if (val === undefined) { + // No value passed in; assume singular key lookup. + val_idx = 0; + + } else { + // Value has been passed in; use plural-forms calculations. + + // Handle invalid numbers, but try casting strings for good measure + if ( typeof val != 'number' ) { + val = parseInt( val, 10 ); + + if ( isNaN( val ) ) { + throw new Error('The number that was passed in is not a number.'); + } + } + + val_idx = getPluralFormFunc(pluralForms)(val); + } + + // Throw an error if a domain isn't found + if ( ! dict ) { + throw new Error('No domain named `' + domain + '` could be found.'); + } + + val_list = dict[ key ]; + + // If there is no match, then revert back to + // english style singular/plural with the keys passed in. + if ( ! val_list || val_idx > val_list.length ) { + if (this.options.missing_key_callback) { + this.options.missing_key_callback(key, domain); + } + res = [ singular_key, plural_key ]; + + // collect untranslated strings + if (this.options.debug===true) { + console.log(res[ getPluralFormFunc(pluralForms)( val ) ]); + } + return res[ getPluralFormFunc()( val ) ]; + } + + res = val_list[ val_idx ]; + + // This includes empty strings on purpose + if ( ! res ) { + res = [ singular_key, plural_key ]; + return res[ getPluralFormFunc()( val ) ]; + } + return res; + } + }); + + + // We add in sprintf capabilities for post translation value interolation + // This is not internally used, so you can remove it if you have this + // available somewhere else, or want to use a different system. + + // We _slightly_ modify the normal sprintf behavior to more gracefully handle + // undefined values. + + /** + sprintf() for JavaScript 0.7-beta1 + http://www.diveintojavascript.com/projects/javascript-sprintf + + Copyright (c) Alexandru Marasteanu <alexaholic [at) gmail (dot] com> + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of sprintf() for JavaScript nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL Alexandru Marasteanu BE LIABLE FOR ANY + DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + var sprintf = (function() { + function get_type(variable) { + return Object.prototype.toString.call(variable).slice(8, -1).toLowerCase(); + } + function str_repeat(input, multiplier) { + for (var output = []; multiplier > 0; output[--multiplier] = input) {/* do nothing */} + return output.join(''); + } + + var str_format = function() { + if (!str_format.cache.hasOwnProperty(arguments[0])) { + str_format.cache[arguments[0]] = str_format.parse(arguments[0]); + } + return str_format.format.call(null, str_format.cache[arguments[0]], arguments); + }; + + str_format.format = function(parse_tree, argv) { + var cursor = 1, tree_length = parse_tree.length, node_type = '', arg, output = [], i, k, match, pad, pad_character, pad_length; + for (i = 0; i < tree_length; i++) { + node_type = get_type(parse_tree[i]); + if (node_type === 'string') { + output.push(parse_tree[i]); + } + else if (node_type === 'array') { + match = parse_tree[i]; // convenience purposes only + if (match[2]) { // keyword argument + arg = argv[cursor]; + for (k = 0; k < match[2].length; k++) { + if (!arg.hasOwnProperty(match[2][k])) { + throw(sprintf('[sprintf] property "%s" does not exist', match[2][k])); + } + arg = arg[match[2][k]]; + } + } + else if (match[1]) { // positional argument (explicit) + arg = argv[match[1]]; + } + else { // positional argument (implicit) + arg = argv[cursor++]; + } + + if (/[^s]/.test(match[8]) && (get_type(arg) != 'number')) { + throw(sprintf('[sprintf] expecting number but found %s', get_type(arg))); + } + + // Jed EDIT + if ( typeof arg == 'undefined' || arg === null ) { + arg = ''; + } + // Jed EDIT + + switch (match[8]) { + case 'b': arg = arg.toString(2); break; + case 'c': arg = String.fromCharCode(arg); break; + case 'd': arg = parseInt(arg, 10); break; + case 'e': arg = match[7] ? arg.toExponential(match[7]) : arg.toExponential(); break; + case 'f': arg = match[7] ? parseFloat(arg).toFixed(match[7]) : parseFloat(arg); break; + case 'o': arg = arg.toString(8); break; + case 's': arg = ((arg = String(arg)) && match[7] ? arg.substring(0, match[7]) : arg); break; + case 'u': arg = Math.abs(arg); break; + case 'x': arg = arg.toString(16); break; + case 'X': arg = arg.toString(16).toUpperCase(); break; + } + arg = (/[def]/.test(match[8]) && match[3] && arg >= 0 ? '+'+ arg : arg); + pad_character = match[4] ? match[4] == '0' ? '0' : match[4].charAt(1) : ' '; + pad_length = match[6] - String(arg).length; + pad = match[6] ? str_repeat(pad_character, pad_length) : ''; + output.push(match[5] ? arg + pad : pad + arg); + } + } + return output.join(''); + }; + + str_format.cache = {}; + + str_format.parse = function(fmt) { + var _fmt = fmt, match = [], parse_tree = [], arg_names = 0; + while (_fmt) { + if ((match = /^[^\x25]+/.exec(_fmt)) !== null) { + parse_tree.push(match[0]); + } + else if ((match = /^\x25{2}/.exec(_fmt)) !== null) { + parse_tree.push('%'); + } + else if ((match = /^\x25(?:([1-9]\d*)\$|\(([^\)]+)\))?(\+)?(0|'[^$])?(-)?(\d+)?(?:\.(\d+))?([b-fosuxX])/.exec(_fmt)) !== null) { + if (match[2]) { + arg_names |= 1; + var field_list = [], replacement_field = match[2], field_match = []; + if ((field_match = /^([a-z_][a-z_\d]*)/i.exec(replacement_field)) !== null) { + field_list.push(field_match[1]); + while ((replacement_field = replacement_field.substring(field_match[0].length)) !== '') { + if ((field_match = /^\.([a-z_][a-z_\d]*)/i.exec(replacement_field)) !== null) { + field_list.push(field_match[1]); + } + else if ((field_match = /^\[(\d+)\]/.exec(replacement_field)) !== null) { + field_list.push(field_match[1]); + } + else { + throw('[sprintf] huh?'); + } + } + } + else { + throw('[sprintf] huh?'); + } + match[2] = field_list; + } + else { + arg_names |= 2; + } + if (arg_names === 3) { + throw('[sprintf] mixing positional and named placeholders is not (yet) supported'); + } + parse_tree.push(match); + } + else { + throw('[sprintf] huh?'); + } + _fmt = _fmt.substring(match[0].length); + } + return parse_tree; + }; + + return str_format; + })(); + + var vsprintf = function(fmt, argv) { + argv.unshift(fmt); + return sprintf.apply(null, argv); + }; + + Jed.parse_plural = function ( plural_forms, n ) { + plural_forms = plural_forms.replace(/n/g, n); + return Jed.parse_expression(plural_forms); + }; + + Jed.sprintf = function ( fmt, args ) { + if ( {}.toString.call( args ) == '[object Array]' ) { + return vsprintf( fmt, [].slice.call(args) ); + } + return sprintf.apply(this, [].slice.call(arguments) ); + }; + + Jed.prototype.sprintf = function () { + return Jed.sprintf.apply(this, arguments); + }; + // END sprintf Implementation + + // Start the Plural forms section + // This is a full plural form expression parser. It is used to avoid + // running 'eval' or 'new Function' directly against the plural + // forms. + // + // This can be important if you get translations done through a 3rd + // party vendor. I encourage you to use this instead, however, I + // also will provide a 'precompiler' that you can use at build time + // to output valid/safe function representations of the plural form + // expressions. This means you can build this code out for the most + // part. + Jed.PF = {}; + + Jed.PF.parse = function ( p ) { + var plural_str = Jed.PF.extractPluralExpr( p ); + return Jed.PF.parser.parse.call(Jed.PF.parser, plural_str); + }; + + Jed.PF.compile = function ( p ) { + // Handle trues and falses as 0 and 1 + function imply( val ) { + return (val === true ? 1 : val ? val : 0); + } + + var ast = Jed.PF.parse( p ); + return function ( n ) { + return imply( Jed.PF.interpreter( ast )( n ) ); + }; + }; + + Jed.PF.interpreter = function ( ast ) { + return function ( n ) { + var res; + switch ( ast.type ) { + case 'GROUP': + return Jed.PF.interpreter( ast.expr )( n ); + case 'TERNARY': + if ( Jed.PF.interpreter( ast.expr )( n ) ) { + return Jed.PF.interpreter( ast.truthy )( n ); + } + return Jed.PF.interpreter( ast.falsey )( n ); + case 'OR': + return Jed.PF.interpreter( ast.left )( n ) || Jed.PF.interpreter( ast.right )( n ); + case 'AND': + return Jed.PF.interpreter( ast.left )( n ) && Jed.PF.interpreter( ast.right )( n ); + case 'LT': + return Jed.PF.interpreter( ast.left )( n ) < Jed.PF.interpreter( ast.right )( n ); + case 'GT': + return Jed.PF.interpreter( ast.left )( n ) > Jed.PF.interpreter( ast.right )( n ); + case 'LTE': + return Jed.PF.interpreter( ast.left )( n ) <= Jed.PF.interpreter( ast.right )( n ); + case 'GTE': + return Jed.PF.interpreter( ast.left )( n ) >= Jed.PF.interpreter( ast.right )( n ); + case 'EQ': + return Jed.PF.interpreter( ast.left )( n ) == Jed.PF.interpreter( ast.right )( n ); + case 'NEQ': + return Jed.PF.interpreter( ast.left )( n ) != Jed.PF.interpreter( ast.right )( n ); + case 'MOD': + return Jed.PF.interpreter( ast.left )( n ) % Jed.PF.interpreter( ast.right )( n ); + case 'VAR': + return n; + case 'NUM': + return ast.val; + default: + throw new Error("Invalid Token found."); + } + }; + }; + + Jed.PF.regexps = { + TRIM_BEG: /^\s\s*/, + TRIM_END: /\s\s*$/, + HAS_SEMICOLON: /;\s*$/, + NPLURALS: /nplurals\=(\d+);/, + PLURAL: /plural\=(.*);/ + }; + + Jed.PF.extractPluralExpr = function ( p ) { + // trim first + p = p.replace(Jed.PF.regexps.TRIM_BEG, '').replace(Jed.PF.regexps.TRIM_END, ''); + + if (! Jed.PF.regexps.HAS_SEMICOLON.test(p)) { + p = p.concat(';'); + } + + var nplurals_matches = p.match( Jed.PF.regexps.NPLURALS ), + res = {}, + plural_matches; + + // Find the nplurals number + if ( nplurals_matches.length > 1 ) { + res.nplurals = nplurals_matches[1]; + } + else { + throw new Error('nplurals not found in plural_forms string: ' + p ); + } + + // remove that data to get to the formula + p = p.replace( Jed.PF.regexps.NPLURALS, "" ); + plural_matches = p.match( Jed.PF.regexps.PLURAL ); + + if (!( plural_matches && plural_matches.length > 1 ) ) { + throw new Error('`plural` expression not found: ' + p); + } + return plural_matches[ 1 ]; + }; + + /* Jison generated parser */ + Jed.PF.parser = (function(){ + +var parser = {trace: function trace() { }, +yy: {}, +symbols_: {"error":2,"expressions":3,"e":4,"EOF":5,"?":6,":":7,"||":8,"&&":9,"<":10,"<=":11,">":12,">=":13,"!=":14,"==":15,"%":16,"(":17,")":18,"n":19,"NUMBER":20,"$accept":0,"$end":1}, +terminals_: {2:"error",5:"EOF",6:"?",7:":",8:"||",9:"&&",10:"<",11:"<=",12:">",13:">=",14:"!=",15:"==",16:"%",17:"(",18:")",19:"n",20:"NUMBER"}, +productions_: [0,[3,2],[4,5],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,3],[4,1],[4,1]], +performAction: function anonymous(yytext,yyleng,yylineno,yy,yystate,$$,_$) { + +var $0 = $$.length - 1; +switch (yystate) { +case 1: return { type : 'GROUP', expr: $$[$0-1] }; +break; +case 2:this.$ = { type: 'TERNARY', expr: $$[$0-4], truthy : $$[$0-2], falsey: $$[$0] }; +break; +case 3:this.$ = { type: "OR", left: $$[$0-2], right: $$[$0] }; +break; +case 4:this.$ = { type: "AND", left: $$[$0-2], right: $$[$0] }; +break; +case 5:this.$ = { type: 'LT', left: $$[$0-2], right: $$[$0] }; +break; +case 6:this.$ = { type: 'LTE', left: $$[$0-2], right: $$[$0] }; +break; +case 7:this.$ = { type: 'GT', left: $$[$0-2], right: $$[$0] }; +break; +case 8:this.$ = { type: 'GTE', left: $$[$0-2], right: $$[$0] }; +break; +case 9:this.$ = { type: 'NEQ', left: $$[$0-2], right: $$[$0] }; +break; +case 10:this.$ = { type: 'EQ', left: $$[$0-2], right: $$[$0] }; +break; +case 11:this.$ = { type: 'MOD', left: $$[$0-2], right: $$[$0] }; +break; +case 12:this.$ = { type: 'GROUP', expr: $$[$0-1] }; +break; +case 13:this.$ = { type: 'VAR' }; +break; +case 14:this.$ = { type: 'NUM', val: Number(yytext) }; +break; +} +}, +table: [{3:1,4:2,17:[1,3],19:[1,4],20:[1,5]},{1:[3]},{5:[1,6],6:[1,7],8:[1,8],9:[1,9],10:[1,10],11:[1,11],12:[1,12],13:[1,13],14:[1,14],15:[1,15],16:[1,16]},{4:17,17:[1,3],19:[1,4],20:[1,5]},{5:[2,13],6:[2,13],7:[2,13],8:[2,13],9:[2,13],10:[2,13],11:[2,13],12:[2,13],13:[2,13],14:[2,13],15:[2,13],16:[2,13],18:[2,13]},{5:[2,14],6:[2,14],7:[2,14],8:[2,14],9:[2,14],10:[2,14],11:[2,14],12:[2,14],13:[2,14],14:[2,14],15:[2,14],16:[2,14],18:[2,14]},{1:[2,1]},{4:18,17:[1,3],19:[1,4],20:[1,5]},{4:19,17:[1,3],19:[1,4],20:[1,5]},{4:20,17:[1,3],19:[1,4],20:[1,5]},{4:21,17:[1,3],19:[1,4],20:[1,5]},{4:22,17:[1,3],19:[1,4],20:[1,5]},{4:23,17:[1,3],19:[1,4],20:[1,5]},{4:24,17:[1,3],19:[1,4],20:[1,5]},{4:25,17:[1,3],19:[1,4],20:[1,5]},{4:26,17:[1,3],19:[1,4],20:[1,5]},{4:27,17:[1,3],19:[1,4],20:[1,5]},{6:[1,7],8:[1,8],9:[1,9],10:[1,10],11:[1,11],12:[1,12],13:[1,13],14:[1,14],15:[1,15],16:[1,16],18:[1,28]},{6:[1,7],7:[1,29],8:[1,8],9:[1,9],10:[1,10],11:[1,11],12:[1,12],13:[1,13],14:[1,14],15:[1,15],16:[1,16]},{5:[2,3],6:[2,3],7:[2,3],8:[2,3],9:[1,9],10:[1,10],11:[1,11],12:[1,12],13:[1,13],14:[1,14],15:[1,15],16:[1,16],18:[2,3]},{5:[2,4],6:[2,4],7:[2,4],8:[2,4],9:[2,4],10:[1,10],11:[1,11],12:[1,12],13:[1,13],14:[1,14],15:[1,15],16:[1,16],18:[2,4]},{5:[2,5],6:[2,5],7:[2,5],8:[2,5],9:[2,5],10:[2,5],11:[2,5],12:[2,5],13:[2,5],14:[2,5],15:[2,5],16:[1,16],18:[2,5]},{5:[2,6],6:[2,6],7:[2,6],8:[2,6],9:[2,6],10:[2,6],11:[2,6],12:[2,6],13:[2,6],14:[2,6],15:[2,6],16:[1,16],18:[2,6]},{5:[2,7],6:[2,7],7:[2,7],8:[2,7],9:[2,7],10:[2,7],11:[2,7],12:[2,7],13:[2,7],14:[2,7],15:[2,7],16:[1,16],18:[2,7]},{5:[2,8],6:[2,8],7:[2,8],8:[2,8],9:[2,8],10:[2,8],11:[2,8],12:[2,8],13:[2,8],14:[2,8],15:[2,8],16:[1,16],18:[2,8]},{5:[2,9],6:[2,9],7:[2,9],8:[2,9],9:[2,9],10:[2,9],11:[2,9],12:[2,9],13:[2,9],14:[2,9],15:[2,9],16:[1,16],18:[2,9]},{5:[2,10],6:[2,10],7:[2,10],8:[2,10],9:[2,10],10:[2,10],11:[2,10],12:[2,10],13:[2,10],14:[2,10],15:[2,10],16:[1,16],18:[2,10]},{5:[2,11],6:[2,11],7:[2,11],8:[2,11],9:[2,11],10:[2,11],11:[2,11],12:[2,11],13:[2,11],14:[2,11],15:[2,11],16:[2,11],18:[2,11]},{5:[2,12],6:[2,12],7:[2,12],8:[2,12],9:[2,12],10:[2,12],11:[2,12],12:[2,12],13:[2,12],14:[2,12],15:[2,12],16:[2,12],18:[2,12]},{4:30,17:[1,3],19:[1,4],20:[1,5]},{5:[2,2],6:[1,7],7:[2,2],8:[1,8],9:[1,9],10:[1,10],11:[1,11],12:[1,12],13:[1,13],14:[1,14],15:[1,15],16:[1,16],18:[2,2]}], +defaultActions: {6:[2,1]}, +parseError: function parseError(str, hash) { + throw new Error(str); +}, +parse: function parse(input) { + var self = this, + stack = [0], + vstack = [null], // semantic value stack + lstack = [], // location stack + table = this.table, + yytext = '', + yylineno = 0, + yyleng = 0, + recovering = 0, + TERROR = 2, + EOF = 1; + + //this.reductionCount = this.shiftCount = 0; + + this.lexer.setInput(input); + this.lexer.yy = this.yy; + this.yy.lexer = this.lexer; + if (typeof this.lexer.yylloc == 'undefined') + this.lexer.yylloc = {}; + var yyloc = this.lexer.yylloc; + lstack.push(yyloc); + + if (typeof this.yy.parseError === 'function') + this.parseError = this.yy.parseError; + + function popStack (n) { + stack.length = stack.length - 2*n; + vstack.length = vstack.length - n; + lstack.length = lstack.length - n; + } + + function lex() { + var token; + token = self.lexer.lex() || 1; // $end = 1 + // if token isn't its numeric value, convert + if (typeof token !== 'number') { + token = self.symbols_[token] || token; + } + return token; + } + + var symbol, preErrorSymbol, state, action, a, r, yyval={},p,len,newState, expected; + while (true) { + // retreive state number from top of stack + state = stack[stack.length-1]; + + // use default actions if available + if (this.defaultActions[state]) { + action = this.defaultActions[state]; + } else { + if (symbol == null) + symbol = lex(); + // read action for current state and first input + action = table[state] && table[state][symbol]; + } + + // handle parse error + _handle_error: + if (typeof action === 'undefined' || !action.length || !action[0]) { + + if (!recovering) { + // Report error + expected = []; + for (p in table[state]) if (this.terminals_[p] && p > 2) { + expected.push("'"+this.terminals_[p]+"'"); + } + var errStr = ''; + if (this.lexer.showPosition) { + errStr = 'Parse error on line '+(yylineno+1)+":\n"+this.lexer.showPosition()+"\nExpecting "+expected.join(', ') + ", got '" + this.terminals_[symbol]+ "'"; + } else { + errStr = 'Parse error on line '+(yylineno+1)+": Unexpected " + + (symbol == 1 /*EOF*/ ? "end of input" : + ("'"+(this.terminals_[symbol] || symbol)+"'")); + } + this.parseError(errStr, + {text: this.lexer.match, token: this.terminals_[symbol] || symbol, line: this.lexer.yylineno, loc: yyloc, expected: expected}); + } + + // just recovered from another error + if (recovering == 3) { + if (symbol == EOF) { + throw new Error(errStr || 'Parsing halted.'); + } + + // discard current lookahead and grab another + yyleng = this.lexer.yyleng; + yytext = this.lexer.yytext; + yylineno = this.lexer.yylineno; + yyloc = this.lexer.yylloc; + symbol = lex(); + } + + // try to recover from error + while (1) { + // check for error recovery rule in this state + if ((TERROR.toString()) in table[state]) { + break; + } + if (state == 0) { + throw new Error(errStr || 'Parsing halted.'); + } + popStack(1); + state = stack[stack.length-1]; + } + + preErrorSymbol = symbol; // save the lookahead token + symbol = TERROR; // insert generic error symbol as new lookahead + state = stack[stack.length-1]; + action = table[state] && table[state][TERROR]; + recovering = 3; // allow 3 real symbols to be shifted before reporting a new error + } + + // this shouldn't happen, unless resolve defaults are off + if (action[0] instanceof Array && action.length > 1) { + throw new Error('Parse Error: multiple actions possible at state: '+state+', token: '+symbol); + } + + switch (action[0]) { + + case 1: // shift + //this.shiftCount++; + + stack.push(symbol); + vstack.push(this.lexer.yytext); + lstack.push(this.lexer.yylloc); + stack.push(action[1]); // push state + symbol = null; + if (!preErrorSymbol) { // normal execution/no error + yyleng = this.lexer.yyleng; + yytext = this.lexer.yytext; + yylineno = this.lexer.yylineno; + yyloc = this.lexer.yylloc; + if (recovering > 0) + recovering--; + } else { // error just occurred, resume old lookahead f/ before error + symbol = preErrorSymbol; + preErrorSymbol = null; + } + break; + + case 2: // reduce + //this.reductionCount++; + + len = this.productions_[action[1]][1]; + + // perform semantic action + yyval.$ = vstack[vstack.length-len]; // default to $$ = $1 + // default location, uses first token for firsts, last for lasts + yyval._$ = { + first_line: lstack[lstack.length-(len||1)].first_line, + last_line: lstack[lstack.length-1].last_line, + first_column: lstack[lstack.length-(len||1)].first_column, + last_column: lstack[lstack.length-1].last_column + }; + r = this.performAction.call(yyval, yytext, yyleng, yylineno, this.yy, action[1], vstack, lstack); + + if (typeof r !== 'undefined') { + return r; + } + + // pop off stack + if (len) { + stack = stack.slice(0,-1*len*2); + vstack = vstack.slice(0, -1*len); + lstack = lstack.slice(0, -1*len); + } + + stack.push(this.productions_[action[1]][0]); // push nonterminal (reduce) + vstack.push(yyval.$); + lstack.push(yyval._$); + // goto new state = table[STATE][NONTERMINAL] + newState = table[stack[stack.length-2]][stack[stack.length-1]]; + stack.push(newState); + break; + + case 3: // accept + return true; + } + + } + + return true; +}};/* Jison generated lexer */ +var lexer = (function(){ + +var lexer = ({EOF:1, +parseError:function parseError(str, hash) { + if (this.yy.parseError) { + this.yy.parseError(str, hash); + } else { + throw new Error(str); + } + }, +setInput:function (input) { + this._input = input; + this._more = this._less = this.done = false; + this.yylineno = this.yyleng = 0; + this.yytext = this.matched = this.match = ''; + this.conditionStack = ['INITIAL']; + this.yylloc = {first_line:1,first_column:0,last_line:1,last_column:0}; + return this; + }, +input:function () { + var ch = this._input[0]; + this.yytext+=ch; + this.yyleng++; + this.match+=ch; + this.matched+=ch; + var lines = ch.match(/\n/); + if (lines) this.yylineno++; + this._input = this._input.slice(1); + return ch; + }, +unput:function (ch) { + this._input = ch + this._input; + return this; + }, +more:function () { + this._more = true; + return this; + }, +pastInput:function () { + var past = this.matched.substr(0, this.matched.length - this.match.length); + return (past.length > 20 ? '...':'') + past.substr(-20).replace(/\n/g, ""); + }, +upcomingInput:function () { + var next = this.match; + if (next.length < 20) { + next += this._input.substr(0, 20-next.length); + } + return (next.substr(0,20)+(next.length > 20 ? '...':'')).replace(/\n/g, ""); + }, +showPosition:function () { + var pre = this.pastInput(); + var c = new Array(pre.length + 1).join("-"); + return pre + this.upcomingInput() + "\n" + c+"^"; + }, +next:function () { + if (this.done) { + return this.EOF; + } + if (!this._input) this.done = true; + + var token, + match, + col, + lines; + if (!this._more) { + this.yytext = ''; + this.match = ''; + } + var rules = this._currentRules(); + for (var i=0;i < rules.length; i++) { + match = this._input.match(this.rules[rules[i]]); + if (match) { + lines = match[0].match(/\n.*/g); + if (lines) this.yylineno += lines.length; + this.yylloc = {first_line: this.yylloc.last_line, + last_line: this.yylineno+1, + first_column: this.yylloc.last_column, + last_column: lines ? lines[lines.length-1].length-1 : this.yylloc.last_column + match[0].length} + this.yytext += match[0]; + this.match += match[0]; + this.matches = match; + this.yyleng = this.yytext.length; + this._more = false; + this._input = this._input.slice(match[0].length); + this.matched += match[0]; + token = this.performAction.call(this, this.yy, this, rules[i],this.conditionStack[this.conditionStack.length-1]); + if (token) return token; + else return; + } + } + if (this._input === "") { + return this.EOF; + } else { + this.parseError('Lexical error on line '+(this.yylineno+1)+'. Unrecognized text.\n'+this.showPosition(), + {text: "", token: null, line: this.yylineno}); + } + }, +lex:function lex() { + var r = this.next(); + if (typeof r !== 'undefined') { + return r; + } else { + return this.lex(); + } + }, +begin:function begin(condition) { + this.conditionStack.push(condition); + }, +popState:function popState() { + return this.conditionStack.pop(); + }, +_currentRules:function _currentRules() { + return this.conditions[this.conditionStack[this.conditionStack.length-1]].rules; + }, +topState:function () { + return this.conditionStack[this.conditionStack.length-2]; + }, +pushState:function begin(condition) { + this.begin(condition); + }}); +lexer.performAction = function anonymous(yy,yy_,$avoiding_name_collisions,YY_START) { + +var YYSTATE=YY_START; +switch($avoiding_name_collisions) { +case 0:/* skip whitespace */ +break; +case 1:return 20 +break; +case 2:return 19 +break; +case 3:return 8 +break; +case 4:return 9 +break; +case 5:return 6 +break; +case 6:return 7 +break; +case 7:return 11 +break; +case 8:return 13 +break; +case 9:return 10 +break; +case 10:return 12 +break; +case 11:return 14 +break; +case 12:return 15 +break; +case 13:return 16 +break; +case 14:return 17 +break; +case 15:return 18 +break; +case 16:return 5 +break; +case 17:return 'INVALID' +break; +} +}; +lexer.rules = [/^\s+/,/^[0-9]+(\.[0-9]+)?\b/,/^n\b/,/^\|\|/,/^&&/,/^\?/,/^:/,/^<=/,/^>=/,/^</,/^>/,/^!=/,/^==/,/^%/,/^\(/,/^\)/,/^$/,/^./]; +lexer.conditions = {"INITIAL":{"rules":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17],"inclusive":true}};return lexer;})() +parser.lexer = lexer; +return parser; +})(); +// End parser + + // Handle node, amd, and global systems + if (typeof exports !== 'undefined') { + if (typeof module !== 'undefined' && module.exports) { + exports = module.exports = Jed; + } + exports.Jed = Jed; + } + else { + if (typeof define === 'function' && define.amd) { + define(function() { + return Jed; + }); + } + // Leak a global regardless of module system + root['Jed'] = Jed; + } + +})(this); diff --git a/src/web-chatview/linkify-html.js b/src/web-chatview/linkify-html.js new file mode 100644 index 0000000000000000000000000000000000000000..1e5090c9e6856b3b4a2688345e33b244aee63e25 --- /dev/null +++ b/src/web-chatview/linkify-html.js @@ -0,0 +1,827 @@ +/* + * Copyright (c) 2016 SoapBox Innovations Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. +*/ + +'use strict'; + +;(function (window, linkify) { + var linkifyHtml = function (linkify) { + 'use strict'; + + var HTML5NamedCharRefs = {}; + + function EntityParser(named) { + this.named = named; + } + + var HEXCHARCODE = /^#[xX]([A-Fa-f0-9]+)$/; + var CHARCODE = /^#([0-9]+)$/; + var NAMED = /^([A-Za-z0-9]+)$/; + + EntityParser.prototype.parse = function (entity) { + if (!entity) { + return; + } + var matches = entity.match(HEXCHARCODE); + if (matches) { + return '&#x' + matches[1] + ';'; + } + matches = entity.match(CHARCODE); + if (matches) { + return '&#' + matches[1] + ';'; + } + matches = entity.match(NAMED); + if (matches) { + return '&' + matches[1] + ';'; + } + }; + + var WSP = /[\t\n\f ]/; + var ALPHA = /[A-Za-z]/; + var CRLF = /\r\n?/g; + + function isSpace(char) { + return WSP.test(char); + } + + function isAlpha(char) { + return ALPHA.test(char); + } + + function preprocessInput(input) { + return input.replace(CRLF, "\n"); + } + + function EventedTokenizer(delegate, entityParser) { + this.delegate = delegate; + this.entityParser = entityParser; + + this.state = null; + this.input = null; + + this.index = -1; + this.line = -1; + this.column = -1; + this.tagLine = -1; + this.tagColumn = -1; + + this.reset(); + } + + EventedTokenizer.prototype = { + reset: function reset() { + this.state = 'beforeData'; + this.input = ''; + + this.index = 0; + this.line = 1; + this.column = 0; + + this.tagLine = -1; + this.tagColumn = -1; + + this.delegate.reset(); + }, + + tokenize: function tokenize(input) { + this.reset(); + this.tokenizePart(input); + this.tokenizeEOF(); + }, + + tokenizePart: function tokenizePart(input) { + this.input += preprocessInput(input); + + while (this.index < this.input.length) { + this.states[this.state].call(this); + } + }, + + tokenizeEOF: function tokenizeEOF() { + this.flushData(); + }, + + flushData: function flushData() { + if (this.state === 'data') { + this.delegate.finishData(); + this.state = 'beforeData'; + } + }, + + peek: function peek() { + return this.input.charAt(this.index); + }, + + consume: function consume() { + var char = this.peek(); + + this.index++; + + if (char === "\n") { + this.line++; + this.column = 0; + } else { + this.column++; + } + + return char; + }, + + consumeCharRef: function consumeCharRef() { + var endIndex = this.input.indexOf(';', this.index); + if (endIndex === -1) { + return; + } + var entity = this.input.slice(this.index, endIndex); + var chars = this.entityParser.parse(entity); + if (chars) { + this.index = endIndex + 1; + return chars; + } + }, + + markTagStart: function markTagStart() { + this.tagLine = this.line; + this.tagColumn = this.column; + }, + + states: { + beforeData: function beforeData() { + var char = this.peek(); + + if (char === "<") { + this.state = 'tagOpen'; + this.markTagStart(); + this.consume(); + } else { + this.state = 'data'; + this.delegate.beginData(); + } + }, + + data: function data() { + var char = this.peek(); + + if (char === "<") { + this.delegate.finishData(); + this.state = 'tagOpen'; + this.markTagStart(); + this.consume(); + } else if (char === "&") { + this.consume(); + this.delegate.appendToData(this.consumeCharRef() || "&"); + } else { + this.consume(); + this.delegate.appendToData(char); + } + }, + + tagOpen: function tagOpen() { + var char = this.consume(); + + if (char === "!") { + this.state = 'markupDeclaration'; + } else if (char === "/") { + this.state = 'endTagOpen'; + } else if (isAlpha(char)) { + this.state = 'tagName'; + this.delegate.beginStartTag(); + this.delegate.appendToTagName(char.toLowerCase()); + } + }, + + markupDeclaration: function markupDeclaration() { + var char = this.consume(); + + if (char === "-" && this.input.charAt(this.index) === "-") { + this.index++; + this.state = 'commentStart'; + this.delegate.beginComment(); + } + }, + + commentStart: function commentStart() { + var char = this.consume(); + + if (char === "-") { + this.state = 'commentStartDash'; + } else if (char === ">") { + this.delegate.finishComment(); + this.state = 'beforeData'; + } else { + this.delegate.appendToCommentData(char); + this.state = 'comment'; + } + }, + + commentStartDash: function commentStartDash() { + var char = this.consume(); + + if (char === "-") { + this.state = 'commentEnd'; + } else if (char === ">") { + this.delegate.finishComment(); + this.state = 'beforeData'; + } else { + this.delegate.appendToCommentData("-"); + this.state = 'comment'; + } + }, + + comment: function comment() { + var char = this.consume(); + + if (char === "-") { + this.state = 'commentEndDash'; + } else { + this.delegate.appendToCommentData(char); + } + }, + + commentEndDash: function commentEndDash() { + var char = this.consume(); + + if (char === "-") { + this.state = 'commentEnd'; + } else { + this.delegate.appendToCommentData("-" + char); + this.state = 'comment'; + } + }, + + commentEnd: function commentEnd() { + var char = this.consume(); + + if (char === ">") { + this.delegate.finishComment(); + this.state = 'beforeData'; + } else { + this.delegate.appendToCommentData("--" + char); + this.state = 'comment'; + } + }, + + tagName: function tagName() { + var char = this.consume(); + + if (isSpace(char)) { + this.state = 'beforeAttributeName'; + } else if (char === "/") { + this.state = 'selfClosingStartTag'; + } else if (char === ">") { + this.delegate.finishTag(); + this.state = 'beforeData'; + } else { + this.delegate.appendToTagName(char); + } + }, + + beforeAttributeName: function beforeAttributeName() { + var char = this.consume(); + + if (isSpace(char)) { + return; + } else if (char === "/") { + this.state = 'selfClosingStartTag'; + } else if (char === ">") { + this.delegate.finishTag(); + this.state = 'beforeData'; + } else { + this.state = 'attributeName'; + this.delegate.beginAttribute(); + this.delegate.appendToAttributeName(char); + } + }, + + attributeName: function attributeName() { + var char = this.consume(); + + if (isSpace(char)) { + this.state = 'afterAttributeName'; + } else if (char === "/") { + this.delegate.beginAttributeValue(false); + this.delegate.finishAttributeValue(); + this.state = 'selfClosingStartTag'; + } else if (char === "=") { + this.state = 'beforeAttributeValue'; + } else if (char === ">") { + this.delegate.beginAttributeValue(false); + this.delegate.finishAttributeValue(); + this.delegate.finishTag(); + this.state = 'beforeData'; + } else { + this.delegate.appendToAttributeName(char); + } + }, + + afterAttributeName: function afterAttributeName() { + var char = this.consume(); + + if (isSpace(char)) { + return; + } else if (char === "/") { + this.delegate.beginAttributeValue(false); + this.delegate.finishAttributeValue(); + this.state = 'selfClosingStartTag'; + } else if (char === "=") { + this.state = 'beforeAttributeValue'; + } else if (char === ">") { + this.delegate.beginAttributeValue(false); + this.delegate.finishAttributeValue(); + this.delegate.finishTag(); + this.state = 'beforeData'; + } else { + this.delegate.beginAttributeValue(false); + this.delegate.finishAttributeValue(); + this.state = 'attributeName'; + this.delegate.beginAttribute(); + this.delegate.appendToAttributeName(char); + } + }, + + beforeAttributeValue: function beforeAttributeValue() { + var char = this.consume(); + + if (isSpace(char)) {} else if (char === '"') { + this.state = 'attributeValueDoubleQuoted'; + this.delegate.beginAttributeValue(true); + } else if (char === "'") { + this.state = 'attributeValueSingleQuoted'; + this.delegate.beginAttributeValue(true); + } else if (char === ">") { + this.delegate.beginAttributeValue(false); + this.delegate.finishAttributeValue(); + this.delegate.finishTag(); + this.state = 'beforeData'; + } else { + this.state = 'attributeValueUnquoted'; + this.delegate.beginAttributeValue(false); + this.delegate.appendToAttributeValue(char); + } + }, + + attributeValueDoubleQuoted: function attributeValueDoubleQuoted() { + var char = this.consume(); + + if (char === '"') { + this.delegate.finishAttributeValue(); + this.state = 'afterAttributeValueQuoted'; + } else if (char === "&") { + this.delegate.appendToAttributeValue(this.consumeCharRef('"') || "&"); + } else { + this.delegate.appendToAttributeValue(char); + } + }, + + attributeValueSingleQuoted: function attributeValueSingleQuoted() { + var char = this.consume(); + + if (char === "'") { + this.delegate.finishAttributeValue(); + this.state = 'afterAttributeValueQuoted'; + } else if (char === "&") { + this.delegate.appendToAttributeValue(this.consumeCharRef("'") || "&"); + } else { + this.delegate.appendToAttributeValue(char); + } + }, + + attributeValueUnquoted: function attributeValueUnquoted() { + var char = this.consume(); + + if (isSpace(char)) { + this.delegate.finishAttributeValue(); + this.state = 'beforeAttributeName'; + } else if (char === "&") { + this.delegate.appendToAttributeValue(this.consumeCharRef(">") || "&"); + } else if (char === ">") { + this.delegate.finishAttributeValue(); + this.delegate.finishTag(); + this.state = 'beforeData'; + } else { + this.delegate.appendToAttributeValue(char); + } + }, + + afterAttributeValueQuoted: function afterAttributeValueQuoted() { + var char = this.peek(); + + if (isSpace(char)) { + this.consume(); + this.state = 'beforeAttributeName'; + } else if (char === "/") { + this.consume(); + this.state = 'selfClosingStartTag'; + } else if (char === ">") { + this.consume(); + this.delegate.finishTag(); + this.state = 'beforeData'; + } else { + this.state = 'beforeAttributeName'; + } + }, + + selfClosingStartTag: function selfClosingStartTag() { + var char = this.peek(); + + if (char === ">") { + this.consume(); + this.delegate.markTagAsSelfClosing(); + this.delegate.finishTag(); + this.state = 'beforeData'; + } else { + this.state = 'beforeAttributeName'; + } + }, + + endTagOpen: function endTagOpen() { + var char = this.consume(); + + if (isAlpha(char)) { + this.state = 'tagName'; + this.delegate.beginEndTag(); + this.delegate.appendToTagName(char.toLowerCase()); + } + } + } + }; + + function Tokenizer(entityParser, options) { + this.token = null; + this.startLine = 1; + this.startColumn = 0; + this.options = options || {}; + this.tokenizer = new EventedTokenizer(this, entityParser); + } + + Tokenizer.prototype = { + tokenize: function tokenize(input) { + this.tokens = []; + this.tokenizer.tokenize(input); + return this.tokens; + }, + + tokenizePart: function tokenizePart(input) { + this.tokens = []; + this.tokenizer.tokenizePart(input); + return this.tokens; + }, + + tokenizeEOF: function tokenizeEOF() { + this.tokens = []; + this.tokenizer.tokenizeEOF(); + return this.tokens[0]; + }, + + reset: function reset() { + this.token = null; + this.startLine = 1; + this.startColumn = 0; + }, + + addLocInfo: function addLocInfo() { + if (this.options.loc) { + this.token.loc = { + start: { + line: this.startLine, + column: this.startColumn + }, + end: { + line: this.tokenizer.line, + column: this.tokenizer.column + } + }; + } + this.startLine = this.tokenizer.line; + this.startColumn = this.tokenizer.column; + }, + + // Data + + beginData: function beginData() { + this.token = { + type: 'Chars', + chars: '' + }; + this.tokens.push(this.token); + }, + + appendToData: function appendToData(char) { + this.token.chars += char; + }, + + finishData: function finishData() { + this.addLocInfo(); + }, + + // Comment + + beginComment: function beginComment() { + this.token = { + type: 'Comment', + chars: '' + }; + this.tokens.push(this.token); + }, + + appendToCommentData: function appendToCommentData(char) { + this.token.chars += char; + }, + + finishComment: function finishComment() { + this.addLocInfo(); + }, + + // Tags - basic + + beginStartTag: function beginStartTag() { + this.token = { + type: 'StartTag', + tagName: '', + attributes: [], + selfClosing: false + }; + this.tokens.push(this.token); + }, + + beginEndTag: function beginEndTag() { + this.token = { + type: 'EndTag', + tagName: '' + }; + this.tokens.push(this.token); + }, + + finishTag: function finishTag() { + this.addLocInfo(); + }, + + markTagAsSelfClosing: function markTagAsSelfClosing() { + this.token.selfClosing = true; + }, + + // Tags - name + + appendToTagName: function appendToTagName(char) { + this.token.tagName += char; + }, + + // Tags - attributes + + beginAttribute: function beginAttribute() { + this._currentAttribute = ["", "", null]; + this.token.attributes.push(this._currentAttribute); + }, + + appendToAttributeName: function appendToAttributeName(char) { + this._currentAttribute[0] += char; + }, + + beginAttributeValue: function beginAttributeValue(isQuoted) { + this._currentAttribute[2] = isQuoted; + }, + + appendToAttributeValue: function appendToAttributeValue(char) { + this._currentAttribute[1] = this._currentAttribute[1] || ""; + this._currentAttribute[1] += char; + }, + + finishAttributeValue: function finishAttributeValue() {} + }; + + function tokenize(input, options) { + var tokenizer = new Tokenizer(new EntityParser(HTML5NamedCharRefs), options); + return tokenizer.tokenize(input); + } + + var HTML5Tokenizer = { + HTML5NamedCharRefs: HTML5NamedCharRefs, + EntityParser: EntityParser, + EventedTokenizer: EventedTokenizer, + Tokenizer: Tokenizer, + tokenize: tokenize + }; + + var options = linkify.options; + var Options = options.Options; + + + var StartTag = 'StartTag'; + var EndTag = 'EndTag'; + var Chars = 'Chars'; + var Comment = 'Comment'; + + /** + `tokens` and `token` in this section refer to tokens generated by the HTML + parser. + */ + function linkifyHtml(str) { + var opts = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; + + var tokens = HTML5Tokenizer.tokenize(str); + var linkifiedTokens = []; + var linkified = []; + var i; + + opts = new Options(opts); + + // Linkify the tokens given by the parser + for (i = 0; i < tokens.length; i++) { + var token = tokens[i]; + + if (token.type === StartTag) { + linkifiedTokens.push(token); + + // Ignore all the contents of ignored tags + var tagName = token.tagName.toUpperCase(); + var isIgnored = tagName === 'A' || options.contains(opts.ignoreTags, tagName); + if (!isIgnored) { + continue; + } + + var preskipLen = linkifiedTokens.length; + skipTagTokens(tagName, tokens, ++i, linkifiedTokens); + i += linkifiedTokens.length - preskipLen - 1; + continue; + } else if (token.type !== Chars) { + // Skip this token, it's not important + linkifiedTokens.push(token); + continue; + } + + // Valid text token, linkify it! + var linkifedChars = linkifyChars(token.chars, opts); + linkifiedTokens.push.apply(linkifiedTokens, linkifedChars); + } + + // Convert the tokens back into a string + for (i = 0; i < linkifiedTokens.length; i++) { + var _token = linkifiedTokens[i]; + switch (_token.type) { + case StartTag: + var link = '<' + _token.tagName; + if (_token.attributes.length > 0) { + var attrs = attrsToStrings(_token.attributes); + link += ' ' + attrs.join(' '); + } + link += '>'; + linkified.push(link); + break; + case EndTag: + linkified.push('</' + _token.tagName + '>'); + break; + case Chars: + linkified.push(escapeText(_token.chars)); + break; + case Comment: + linkified.push('<!--' + escapeText(_token.chars) + '-->'); + break; + } + } + + return linkified.join(''); + } + + /** + `tokens` and `token` in this section referes to tokens returned by + `linkify.tokenize`. `linkified` will contain HTML Parser-style tokens + */ + function linkifyChars(str, opts) { + var tokens = linkify.tokenize(str); + var result = []; + + for (var i = 0; i < tokens.length; i++) { + var token = tokens[i]; + + if (token.type === 'nl' && opts.nl2br) { + result.push({ + type: StartTag, + tagName: 'br', + attributes: [], + selfClosing: true + }); + continue; + } else if (!token.isLink || !opts.check(token)) { + result.push({ type: Chars, chars: token.toString() }); + continue; + } + + var _opts$resolve = opts.resolve(token); + + var href = _opts$resolve.href; + var formatted = _opts$resolve.formatted; + var formattedHref = _opts$resolve.formattedHref; + var tagName = _opts$resolve.tagName; + var className = _opts$resolve.className; + var target = _opts$resolve.target; + var attributes = _opts$resolve.attributes; + + // Build up attributes + + var attributeArray = [['href', formattedHref]]; + + if (className) { + attributeArray.push(['class', className]); + } + + if (target) { + attributeArray.push(['target', target]); + } + + for (var attr in attributes) { + attributeArray.push([attr, attributes[attr]]); + } + + // Add the required tokens + result.push({ + type: StartTag, + tagName: tagName, + attributes: attributeArray, + selfClosing: false + }); + result.push({ type: Chars, chars: formatted }); + result.push({ type: EndTag, tagName: tagName }); + } + + return result; + } + + /** + Returns a list of tokens skipped until the closing tag of tagName. + + * `tagName` is the closing tag which will prompt us to stop skipping + * `tokens` is the array of tokens generated by HTML5Tokenizer which + * `i` is the index immediately after the opening tag to skip + * `skippedTokens` is an array which skipped tokens are being pushed into + + Caveats + + * Assumes that i is the first token after the given opening tagName + * The closing tag will be skipped, but nothing after it + * Will track whether there is a nested tag of the same type + */ + function skipTagTokens(tagName, tokens, i, skippedTokens) { + + // number of tokens of this type on the [fictional] stack + var stackCount = 1; + + while (i < tokens.length && stackCount > 0) { + var token = tokens[i]; + if (token.type === StartTag && token.tagName.toUpperCase() === tagName) { + // Nested tag of the same type, "add to stack" + stackCount++; + } else if (token.type === EndTag && token.tagName.toUpperCase() === tagName) { + // Closing tag + stackCount--; + } + skippedTokens.push(token); + i++; + } + + // Note that if stackCount > 0 here, the HTML is probably invalid + return skippedTokens; + } + + function escapeText(text) { + // Not required, HTML tokenizer ensures this occurs properly + return text; + } + + function escapeAttr(attr) { + return attr.replace(/"/g, '"'); + } + + function attrsToStrings(attrs) { + var attrStrs = []; + for (var i = 0; i < attrs.length; i++) { + var _attrs$i = attrs[i]; + var name = _attrs$i[0]; + var value = _attrs$i[1]; + + attrStrs.push(name + '="' + escapeAttr(value) + '"'); + } + return attrStrs; + } + + return linkifyHtml; + }(linkify); + window.linkifyHtml = linkifyHtml; +})(window, linkify); diff --git a/src/web-chatview/linkify-string.js b/src/web-chatview/linkify-string.js new file mode 100644 index 0000000000000000000000000000000000000000..699a94d1f47ac7fbc6d1c3dfbd51cc553d12feed --- /dev/null +++ b/src/web-chatview/linkify-string.js @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2016 SoapBox Innovations Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. +*/ + +'use strict'; + +;(function (window, linkify) { + var linkifyString = function (linkify) { + 'use strict'; + + /** + Convert strings of text into linkable HTML text + */ + + var tokenize = linkify.tokenize; + var options = linkify.options; + var Options = options.Options; + + + function escapeText(text) { + return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); + } + + function escapeAttr(href) { + return href.replace(/"/g, '"'); + } + + function attributesToString(attributes) { + if (!attributes) { + return ''; + } + var result = []; + + for (var attr in attributes) { + var val = attributes[attr] + ''; + result.push(attr + '="' + escapeAttr(val) + '"'); + } + return result.join(' '); + } + + function linkifyStr(str) { + var opts = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; + + opts = new Options(opts); + + var tokens = tokenize(str); + var result = []; + + for (var i = 0; i < tokens.length; i++) { + var token = tokens[i]; + + if (token.type === 'nl' && opts.nl2br) { + result.push('<br>\n'); + continue; + } else if (!token.isLink || !opts.check(token)) { + result.push(escapeText(token.toString())); + continue; + } + + var _opts$resolve = opts.resolve(token); + + var formatted = _opts$resolve.formatted; + var formattedHref = _opts$resolve.formattedHref; + var tagName = _opts$resolve.tagName; + var className = _opts$resolve.className; + var target = _opts$resolve.target; + var attributes = _opts$resolve.attributes; + + + var link = '<' + tagName + ' href="' + escapeAttr(formattedHref) + '"'; + + if (className) { + link += ' class="' + escapeAttr(className) + '"'; + } + + if (target) { + link += ' target="' + escapeAttr(target) + '"'; + } + + if (attributes) { + link += ' ' + attributesToString(attributes); + } + + link += '>' + escapeText(formatted) + '</' + tagName + '>'; + result.push(link); + } + + return result.join(''); + } + + if (!String.prototype.linkify) { + String.prototype.linkify = function (opts) { + return linkifyStr(this, opts); + }; + } + + return linkifyStr; + }(linkify); + window.linkifyStr = linkifyString; +})(window, linkify); diff --git a/src/web-chatview/linkify.js b/src/web-chatview/linkify.js new file mode 100644 index 0000000000000000000000000000000000000000..15dc03b1a2e936cfcbafa02788722dc382106da0 --- /dev/null +++ b/src/web-chatview/linkify.js @@ -0,0 +1,1271 @@ +/* + * Copyright (c) 2016 SoapBox Innovations Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. +*/ + +;(function () { +'use strict'; + +var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol ? "symbol" : typeof obj; }; + +(function (exports) { + 'use strict'; + + function inherits(parent, child) { + var props = arguments.length <= 2 || arguments[2] === undefined ? {} : arguments[2]; + + var extended = Object.create(parent.prototype); + for (var p in props) { + extended[p] = props[p]; + } + extended.constructor = child; + child.prototype = extended; + return child; + } + + var defaults = { + defaultProtocol: 'http', + events: null, + format: noop, + formatHref: noop, + nl2br: false, + tagName: 'a', + target: typeToTarget, + validate: true, + ignoreTags: [], + attributes: null, + className: 'linkified' }; + + function Options(opts) { + opts = opts || {}; + + this.defaultProtocol = opts.defaultProtocol || defaults.defaultProtocol; + this.events = opts.events || defaults.events; + this.format = opts.format || defaults.format; + this.formatHref = opts.formatHref || defaults.formatHref; + this.nl2br = opts.nl2br || defaults.nl2br; + this.tagName = opts.tagName || defaults.tagName; + this.target = opts.target || defaults.target; + this.validate = opts.validate || defaults.validate; + this.ignoreTags = []; + + // linkAttributes and linkClass is deprecated + this.attributes = opts.attributes || opts.linkAttributes || defaults.attributes; + this.className = opts.className || opts.linkClass || defaults.className; + + // Make all tags names upper case + + var ignoredTags = opts.ignoreTags || defaults.ignoreTags; + for (var i = 0; i < ignoredTags.length; i++) { + this.ignoreTags.push(ignoredTags[i].toUpperCase()); + } + } + + Options.prototype = { + /** + * Given the token, return all options for how it should be displayed + */ + resolve: function resolve(token) { + var href = token.toHref(this.defaultProtocol); + return { + formatted: this.get('format', token.toString(), token), + formattedHref: this.get('formatHref', href, token), + tagName: this.get('tagName', href, token), + className: this.get('className', href, token), + target: this.get('target', href, token), + events: this.getObject('events', href, token), + attributes: this.getObject('attributes', href, token) + }; + }, + + + /** + * Returns true or false based on whether a token should be displayed as a + * link based on the user options. By default, + */ + check: function check(token) { + return this.get('validate', token.toString(), token); + }, + + + // Private methods + + /** + * Resolve an option's value based on the value of the option and the given + * params. + * @param [String] key Name of option to use + * @param operator will be passed to the target option if it's method + * @param [MultiToken] token The token from linkify.tokenize + */ + get: function get(key, operator, token) { + var option = this[key]; + + if (!option) { + return option; + } + + switch (typeof option === 'undefined' ? 'undefined' : _typeof(option)) { + case 'function': + return option(operator, token.type); + case 'object': + var optionValue = option[token.type] || defaults[key]; + return typeof optionValue === 'function' ? optionValue(operator, token.type) : optionValue; + } + + return option; + }, + getObject: function getObject(key, operator, token) { + var option = this[key]; + return typeof option === 'function' ? option(operator, token.type) : option; + } + }; + + /** + * Quick indexOf replacement for checking the ignoreTags option + */ + function contains(arr, value) { + for (var i = 0; i < arr.length; i++) { + if (arr[i] === value) { + return true; + } + } + return false; + } + + function noop(val) { + return val; + } + + function typeToTarget(href, type) { + return type === 'url' ? '_blank' : null; + } + + var options = Object.freeze({ + defaults: defaults, + Options: Options, + contains: contains + }); + + function createStateClass() { + return function (tClass) { + this.j = []; + this.T = tClass || null; + }; + } + + /** + A simple state machine that can emit token classes + + The `j` property in this class refers to state jumps. It's a + multidimensional array where for each element: + + * index [0] is a symbol or class of symbols to transition to. + * index [1] is a State instance which matches + + The type of symbol will depend on the target implementation for this class. + In Linkify, we have a two-stage scanner. Each stage uses this state machine + but with a slighly different (polymorphic) implementation. + + The `T` property refers to the token class. + + TODO: Can the `on` and `next` methods be combined? + + @class BaseState + */ + var BaseState = createStateClass(); + BaseState.prototype = { + defaultTransition: false, + + /** + @method constructor + @param {Class} tClass Pass in the kind of token to emit if there are + no jumps after this state and the state is accepting. + */ + + /** + On the given symbol(s), this machine should go to the given state + @method on + @param {Array|Mixed} symbol + @param {BaseState} state Note that the type of this state should be the + same as the current instance (i.e., don't pass in a different + subclass) + */ + on: function on(symbol, state) { + if (symbol instanceof Array) { + for (var i = 0; i < symbol.length; i++) { + this.j.push([symbol[i], state]); + } + return this; + } + this.j.push([symbol, state]); + return this; + }, + + + /** + Given the next item, returns next state for that item + @method next + @param {Mixed} item Should be an instance of the symbols handled by + this particular machine. + @return {State} state Returns false if no jumps are available + */ + next: function next(item) { + for (var i = 0; i < this.j.length; i++) { + var jump = this.j[i]; + var symbol = jump[0]; // Next item to check for + var state = jump[1]; // State to jump to if items match + + // compare item with symbol + if (this.test(item, symbol)) { + return state; + } + } + + // Nowhere left to jump! + return this.defaultTransition; + }, + + + /** + Does this state accept? + `true` only of `this.T` exists + @method accepts + @return {Boolean} + */ + accepts: function accepts() { + return !!this.T; + }, + + + /** + Determine whether a given item "symbolizes" the symbol, where symbol is + a class of items handled by this state machine. + This method should be overriden in extended classes. + @method test + @param {Mixed} item Does this item match the given symbol? + @param {Mixed} symbol + @return {Boolean} + */ + test: function test(item, symbol) { + return item === symbol; + }, + + + /** + Emit the token for this State (just return it in this case) + If this emits a token, this instance is an accepting state + @method emit + @return {Class} T + */ + emit: function emit() { + return this.T; + } + }; + + /** + State machine for string-based input + + @class CharacterState + @extends BaseState + */ + var CharacterState = inherits(BaseState, createStateClass(), { + /** + Does the given character match the given character or regular + expression? + @method test + @param {String} char + @param {String|RegExp} charOrRegExp + @return {Boolean} + */ + test: function test(character, charOrRegExp) { + return character === charOrRegExp || charOrRegExp instanceof RegExp && charOrRegExp.test(character); + } + }); + + /** + State machine for input in the form of TextTokens + + @class TokenState + @extends BaseState + */ + var State = inherits(BaseState, createStateClass(), { + + /** + * Similar to `on`, but returns the state the results in the transition from + * the given item + * @method jump + * @param {Mixed} item + * @param {Token} [token] + * @return state + */ + jump: function jump(token) { + var tClass = arguments.length <= 1 || arguments[1] === undefined ? null : arguments[1]; + + var state = this.next(new token('')); // dummy temp token + if (state === this.defaultTransition) { + // Make a new state! + state = new this.constructor(tClass); + this.on(token, state); + } else if (tClass) { + state.T = tClass; + } + return state; + }, + + + /** + Is the given token an instance of the given token class? + @method test + @param {TextToken} token + @param {Class} tokenClass + @return {Boolean} + */ + test: function test(token, tokenClass) { + return token instanceof tokenClass; + } + }); + + /** + Given a non-empty target string, generates states (if required) for each + consecutive substring of characters in str starting from the beginning of + the string. The final state will have a special value, as specified in + options. All other "in between" substrings will have a default end state. + + This turns the state machine into a Trie-like data structure (rather than a + intelligently-designed DFA). + + Note that I haven't really tried these with any strings other than + DOMAIN. + + @param {String} str + @param {CharacterState} start State to jump from the first character + @param {Class} endToken Token class to emit when the given string has been + matched and no more jumps exist. + @param {Class} defaultToken "Filler token", or which token type to emit when + we don't have a full match + @return {Array} list of newly-created states + */ + function stateify(str, start, endToken, defaultToken) { + var i = 0, + len = str.length, + state = start, + newStates = [], + nextState = void 0; + + // Find the next state without a jump to the next character + while (i < len && (nextState = state.next(str[i]))) { + state = nextState; + i++; + } + + if (i >= len) { + return []; + } // no new tokens were added + + while (i < len - 1) { + nextState = new CharacterState(defaultToken); + newStates.push(nextState); + state.on(str[i], nextState); + state = nextState; + i++; + } + + nextState = new CharacterState(endToken); + newStates.push(nextState); + state.on(str[len - 1], nextState); + + return newStates; + } + + function createTokenClass() { + return function (value) { + if (value) { + this.v = value; + } + }; + } + + /****************************************************************************** + Text Tokens + Tokens composed of strings + ******************************************************************************/ + + /** + Abstract class used for manufacturing text tokens. + Pass in the value this token represents + + @class TextToken + @abstract + */ + var TextToken = createTokenClass(); + TextToken.prototype = { + toString: function toString() { + return this.v + ''; + } + }; + + function inheritsToken(value) { + var props = value ? { v: value } : {}; + return inherits(TextToken, createTokenClass(), props); + } + + /** + A valid domain token + @class DOMAIN + @extends TextToken + */ + var DOMAIN = inheritsToken(); + + /** + @class AT + @extends TextToken + */ + var AT = inheritsToken('@'); + + /** + Represents a single colon `:` character + + @class COLON + @extends TextToken + */ + var COLON = inheritsToken(':'); + + /** + @class DOT + @extends TextToken + */ + var DOT = inheritsToken('.'); + + /** + A character class that can surround the URL, but which the URL cannot begin + or end with. Does not include certain English punctuation like parentheses. + + @class PUNCTUATION + @extends TextToken + */ + var PUNCTUATION = inheritsToken(); + + /** + The word localhost (by itself) + @class LOCALHOST + @extends TextToken + */ + var LOCALHOST = inheritsToken(); + + /** + Newline token + @class NL + @extends TextToken + */ + var TNL = inheritsToken('\n'); + + /** + @class NUM + @extends TextToken + */ + var NUM = inheritsToken(); + + /** + @class PLUS + @extends TextToken + */ + var PLUS = inheritsToken('+'); + + /** + @class POUND + @extends TextToken + */ + var POUND = inheritsToken('#'); + + /** + Represents a web URL protocol. Supported types include + + * `http:` + * `https:` + * `ftp:` + * `ftps:` + * There's Another super weird one + + @class PROTOCOL + @extends TextToken + */ + var PROTOCOL = inheritsToken(); + + /** + @class QUERY + @extends TextToken + */ + var QUERY = inheritsToken('?'); + + /** + @class SLASH + @extends TextToken + */ + var SLASH = inheritsToken('/'); + + /** + @class UNDERSCORE + @extends TextToken + */ + var UNDERSCORE = inheritsToken('_'); + + /** + One ore more non-whitespace symbol. + @class SYM + @extends TextToken + */ + var SYM = inheritsToken(); + + /** + @class TLD + @extends TextToken + */ + var TLD = inheritsToken(); + + /** + Represents a string of consecutive whitespace characters + + @class WS + @extends TextToken + */ + var WS = inheritsToken(); + + /** + Opening/closing bracket classes + */ + + var OPENBRACE = inheritsToken('{'); + var OPENBRACKET = inheritsToken('['); + var OPENANGLEBRACKET = inheritsToken('<'); + var OPENPAREN = inheritsToken('('); + var CLOSEBRACE = inheritsToken('}'); + var CLOSEBRACKET = inheritsToken(']'); + var CLOSEANGLEBRACKET = inheritsToken('>'); + var CLOSEPAREN = inheritsToken(')'); + + var TOKENS = Object.freeze({ + Base: TextToken, + DOMAIN: DOMAIN, + AT: AT, + COLON: COLON, + DOT: DOT, + PUNCTUATION: PUNCTUATION, + LOCALHOST: LOCALHOST, + NL: TNL, + NUM: NUM, + PLUS: PLUS, + POUND: POUND, + QUERY: QUERY, + PROTOCOL: PROTOCOL, + SLASH: SLASH, + UNDERSCORE: UNDERSCORE, + SYM: SYM, + TLD: TLD, + WS: WS, + OPENBRACE: OPENBRACE, + OPENBRACKET: OPENBRACKET, + OPENANGLEBRACKET: OPENANGLEBRACKET, + OPENPAREN: OPENPAREN, + CLOSEBRACE: CLOSEBRACE, + CLOSEBRACKET: CLOSEBRACKET, + CLOSEANGLEBRACKET: CLOSEANGLEBRACKET, + CLOSEPAREN: CLOSEPAREN + }); + + /** + The scanner provides an interface that takes a string of text as input, and + outputs an array of tokens instances that can be used for easy URL parsing. + + @module linkify + @submodule scanner + @main scanner + */ + + var tlds = 'aaa|aarp|abb|abbott|abogado|ac|academy|accenture|accountant|accountants|aco|active|actor|ad|adac|ads|adult|ae|aeg|aero|af|afl|ag|agency|ai|aig|airforce|airtel|al|alibaba|alipay|allfinanz|alsace|am|amica|amsterdam|an|analytics|android|ao|apartments|app|apple|aq|aquarelle|ar|aramco|archi|army|arpa|arte|as|asia|associates|at|attorney|au|auction|audi|audio|author|auto|autos|avianca|aw|ax|axa|az|azure|ba|baidu|band|bank|bar|barcelona|barclaycard|barclays|bargains|bauhaus|bayern|bb|bbc|bbva|bcg|bcn|bd|be|beats|beer|bentley|berlin|best|bet|bf|bg|bh|bharti|bi|bible|bid|bike|bing|bingo|bio|biz|bj|black|blackfriday|bloomberg|blue|bm|bms|bmw|bn|bnl|bnpparibas|bo|boats|boehringer|bom|bond|boo|book|boots|bosch|bostik|bot|boutique|br|bradesco|bridgestone|broadway|broker|brother|brussels|bs|bt|budapest|bugatti|build|builders|business|buy|buzz|bv|bw|by|bz|bzh|ca|cab|cafe|cal|call|camera|camp|cancerresearch|canon|capetown|capital|car|caravan|cards|care|career|careers|cars|cartier|casa|cash|casino|cat|catering|cba|cbn|cc|cd|ceb|center|ceo|cern|cf|cfa|cfd|cg|ch|chanel|channel|chase|chat|cheap|chloe|christmas|chrome|church|ci|cipriani|circle|cisco|citic|city|cityeats|ck|cl|claims|cleaning|click|clinic|clinique|clothing|cloud|club|clubmed|cm|cn|co|coach|codes|coffee|college|cologne|com|commbank|community|company|compare|computer|comsec|condos|construction|consulting|contact|contractors|cooking|cool|coop|corsica|country|coupon|coupons|courses|cr|credit|creditcard|creditunion|cricket|crown|crs|cruises|csc|cu|cuisinella|cv|cw|cx|cy|cymru|cyou|cz|dabur|dad|dance|date|dating|datsun|day|dclk|de|dealer|deals|degree|delivery|dell|deloitte|delta|democrat|dental|dentist|desi|design|dev|diamonds|diet|digital|direct|directory|discount|dj|dk|dm|dnp|do|docs|dog|doha|domains|download|drive|dubai|durban|dvag|dz|earth|eat|ec|edeka|edu|education|ee|eg|email|emerck|energy|engineer|engineering|enterprises|epson|equipment|er|erni|es|esq|estate|et|eu|eurovision|eus|events|everbank|exchange|expert|exposed|express|fage|fail|fairwinds|faith|family|fan|fans|farm|fashion|fast|feedback|ferrero|fi|film|final|finance|financial|firestone|firmdale|fish|fishing|fit|fitness|fj|fk|flickr|flights|florist|flowers|flsmidth|fly|fm|fo|foo|football|ford|forex|forsale|forum|foundation|fox|fr|fresenius|frl|frogans|frontier|fund|furniture|futbol|fyi|ga|gal|gallery|gallup|game|garden|gb|gbiz|gd|gdn|ge|gea|gent|genting|gf|gg|ggee|gh|gi|gift|gifts|gives|giving|gl|glass|gle|global|globo|gm|gmail|gmbh|gmo|gmx|gn|gold|goldpoint|golf|goo|goog|google|gop|got|gov|gp|gq|gr|grainger|graphics|gratis|green|gripe|group|gs|gt|gu|gucci|guge|guide|guitars|guru|gw|gy|hamburg|hangout|haus|hdfcbank|health|healthcare|help|helsinki|here|hermes|hiphop|hitachi|hiv|hk|hm|hn|hockey|holdings|holiday|homedepot|homes|honda|horse|host|hosting|hoteles|hotmail|house|how|hr|hsbc|ht|hu|hyundai|ibm|icbc|ice|icu|id|ie|ifm|iinet|il|im|immo|immobilien|in|industries|infiniti|info|ing|ink|institute|insurance|insure|int|international|investments|io|ipiranga|iq|ir|irish|is|iselect|ist|istanbul|it|itau|iwc|jaguar|java|jcb|je|jetzt|jewelry|jlc|jll|jm|jmp|jo|jobs|joburg|jot|joy|jp|jpmorgan|jprs|juegos|kaufen|kddi|ke|kerryhotels|kerrylogistics|kerryproperties|kfh|kg|kh|ki|kia|kim|kinder|kitchen|kiwi|km|kn|koeln|komatsu|kp|kpn|kr|krd|kred|kuokgroup|kw|ky|kyoto|kz|la|lacaixa|lamborghini|lamer|lancaster|land|landrover|lanxess|lasalle|lat|latrobe|law|lawyer|lb|lc|lds|lease|leclerc|legal|lexus|lgbt|li|liaison|lidl|life|lifeinsurance|lifestyle|lighting|like|limited|limo|lincoln|linde|link|live|living|lixil|lk|loan|loans|local|locus|lol|london|lotte|lotto|love|lr|ls|lt|ltd|ltda|lu|lupin|luxe|luxury|lv|ly|ma|madrid|maif|maison|makeup|man|management|mango|market|marketing|markets|marriott|mba|mc|md|me|med|media|meet|melbourne|meme|memorial|men|menu|meo|mg|mh|miami|microsoft|mil|mini|mk|ml|mm|mma|mn|mo|mobi|mobily|moda|moe|moi|mom|monash|money|montblanc|mormon|mortgage|moscow|motorcycles|mov|movie|movistar|mp|mq|mr|ms|mt|mtn|mtpc|mtr|mu|museum|mutuelle|mv|mw|mx|my|mz|na|nadex|nagoya|name|natura|navy|nc|ne|nec|net|netbank|network|neustar|new|news|nexus|nf|ng|ngo|nhk|ni|nico|nikon|ninja|nissan|nl|no|nokia|norton|nowruz|np|nr|nra|nrw|ntt|nu|nyc|nz|obi|office|okinawa|om|omega|one|ong|onl|online|ooo|oracle|orange|org|organic|origins|osaka|otsuka|ovh|pa|page|pamperedchef|panerai|paris|pars|partners|parts|party|passagens|pe|pet|pf|pg|ph|pharmacy|philips|photo|photography|photos|physio|piaget|pics|pictet|pictures|pid|pin|ping|pink|pizza|pk|pl|place|play|playstation|plumbing|plus|pm|pn|pohl|poker|porn|post|pr|praxi|press|pro|prod|productions|prof|promo|properties|property|protection|ps|pt|pub|pw|pwc|py|qa|qpon|quebec|quest|racing|re|read|realtor|realty|recipes|red|redstone|redumbrella|rehab|reise|reisen|reit|ren|rent|rentals|repair|report|republican|rest|restaurant|review|reviews|rexroth|rich|ricoh|rio|rip|ro|rocher|rocks|rodeo|room|rs|rsvp|ru|ruhr|run|rw|rwe|ryukyu|sa|saarland|safe|safety|sakura|sale|salon|samsung|sandvik|sandvikcoromant|sanofi|sap|sapo|sarl|sas|saxo|sb|sbs|sc|sca|scb|schaeffler|schmidt|scholarships|school|schule|schwarz|science|scor|scot|sd|se|seat|security|seek|select|sener|services|seven|sew|sex|sexy|sfr|sg|sh|sharp|shell|shia|shiksha|shoes|show|shriram|si|singles|site|sj|sk|ski|skin|sky|skype|sl|sm|smile|sn|sncf|so|soccer|social|softbank|software|sohu|solar|solutions|song|sony|soy|space|spiegel|spot|spreadbetting|sr|srl|st|stada|star|starhub|statefarm|statoil|stc|stcgroup|stockholm|storage|store|studio|study|style|su|sucks|supplies|supply|support|surf|surgery|suzuki|sv|swatch|swiss|sx|sy|sydney|symantec|systems|sz|tab|taipei|taobao|tatamotors|tatar|tattoo|tax|taxi|tc|tci|td|team|tech|technology|tel|telecity|telefonica|temasek|tennis|tf|tg|th|thd|theater|theatre|tickets|tienda|tiffany|tips|tires|tirol|tj|tk|tl|tm|tmall|tn|to|today|tokyo|tools|top|toray|toshiba|total|tours|town|toyota|toys|tp|tr|trade|trading|training|travel|travelers|travelersinsurance|trust|trv|tt|tube|tui|tunes|tushu|tv|tvs|tw|tz|ua|ubs|ug|uk|unicom|university|uno|uol|us|uy|uz|va|vacations|vana|vc|ve|vegas|ventures|verisign|versicherung|vet|vg|vi|viajes|video|viking|villas|vin|vip|virgin|vision|vista|vistaprint|viva|vlaanderen|vn|vodka|volkswagen|vote|voting|voto|voyage|vu|vuelos|wales|walter|wang|wanggou|watch|watches|weather|weatherchannel|webcam|weber|website|wed|wedding|weir|wf|whoswho|wien|wiki|williamhill|win|windows|wine|wme|wolterskluwer|work|works|world|ws|wtc|wtf|xbox|xerox|xin|xperia|xxx|xyz|yachts|yahoo|yamaxun|yandex|ye|yodobashi|yoga|yokohama|youtube|yt|za|zara|zero|zip|zm|zone|zuerich|zw'.split('|'); // macro, see gulpfile.js + + var NUMBERS = '0123456789'.split(''); + var ALPHANUM = '0123456789abcdefghijklmnopqrstuvwxyz'.split(''); + var WHITESPACE = [' ', '\f', '\r', '\t', '\v', ' ', ' ', '']; // excluding line breaks + + var domainStates = []; // states that jump to DOMAIN on /[a-z0-9]/ + var makeState = function makeState(tokenClass) { + return new CharacterState(tokenClass); + }; + + // Frequently used states + var S_START = makeState(); + var S_NUM = makeState(NUM); + var S_DOMAIN = makeState(DOMAIN); + var S_DOMAIN_HYPHEN = makeState(); // domain followed by 1 or more hyphen characters + var S_WS = makeState(WS); + + // States for special URL symbols + S_START.on('@', makeState(AT)).on('.', makeState(DOT)).on('+', makeState(PLUS)).on('#', makeState(POUND)).on('?', makeState(QUERY)).on('/', makeState(SLASH)).on('_', makeState(UNDERSCORE)).on(':', makeState(COLON)).on('{', makeState(OPENBRACE)).on('[', makeState(OPENBRACKET)).on('<', makeState(OPENANGLEBRACKET)).on('(', makeState(OPENPAREN)).on('}', makeState(CLOSEBRACE)).on(']', makeState(CLOSEBRACKET)).on('>', makeState(CLOSEANGLEBRACKET)).on(')', makeState(CLOSEPAREN)).on([',', ';', '!', '"', '\''], makeState(PUNCTUATION)); + + // Whitespace jumps + // Tokens of only non-newline whitespace are arbitrarily long + S_START.on('\n', makeState(TNL)).on(WHITESPACE, S_WS); + + // If any whitespace except newline, more whitespace! + S_WS.on(WHITESPACE, S_WS); + + // Generates states for top-level domains + // Note that this is most accurate when tlds are in alphabetical order + for (var i = 0; i < tlds.length; i++) { + var newStates = stateify(tlds[i], S_START, TLD, DOMAIN); + domainStates.push.apply(domainStates, newStates); + } + + // Collect the states generated by different protocls + var partialProtocolFileStates = stateify('file', S_START, DOMAIN, DOMAIN); + var partialProtocolFtpStates = stateify('ftp', S_START, DOMAIN, DOMAIN); + var partialProtocolHttpStates = stateify('http', S_START, DOMAIN, DOMAIN); + + // Add the states to the array of DOMAINeric states + domainStates.push.apply(domainStates, partialProtocolFileStates); + domainStates.push.apply(domainStates, partialProtocolFtpStates); + domainStates.push.apply(domainStates, partialProtocolHttpStates); + + // Protocol states + var S_PROTOCOL_FILE = partialProtocolFileStates.pop(); + var S_PROTOCOL_FTP = partialProtocolFtpStates.pop(); + var S_PROTOCOL_HTTP = partialProtocolHttpStates.pop(); + var S_PROTOCOL_SECURE = makeState(DOMAIN); + var S_FULL_PROTOCOL = makeState(PROTOCOL); // Full protocol ends with COLON + + // Secure protocols (end with 's') + S_PROTOCOL_FTP.on('s', S_PROTOCOL_SECURE).on(':', S_FULL_PROTOCOL); + + S_PROTOCOL_HTTP.on('s', S_PROTOCOL_SECURE).on(':', S_FULL_PROTOCOL); + + domainStates.push(S_PROTOCOL_SECURE); + + // Become protocol tokens after a COLON + S_PROTOCOL_FILE.on(':', S_FULL_PROTOCOL); + S_PROTOCOL_SECURE.on(':', S_FULL_PROTOCOL); + + // Localhost + var partialLocalhostStates = stateify('localhost', S_START, LOCALHOST, DOMAIN); + domainStates.push.apply(domainStates, partialLocalhostStates); + + // Everything else + // DOMAINs make more DOMAINs + // Number and character transitions + S_START.on(NUMBERS, S_NUM); + S_NUM.on('-', S_DOMAIN_HYPHEN).on(NUMBERS, S_NUM).on(ALPHANUM, S_DOMAIN); // number becomes DOMAIN + + S_DOMAIN.on('-', S_DOMAIN_HYPHEN).on(ALPHANUM, S_DOMAIN); + + // All the generated states should have a jump to DOMAIN + for (var _i = 0; _i < domainStates.length; _i++) { + domainStates[_i].on('-', S_DOMAIN_HYPHEN).on(ALPHANUM, S_DOMAIN); + } + + S_DOMAIN_HYPHEN.on('-', S_DOMAIN_HYPHEN).on(NUMBERS, S_DOMAIN).on(ALPHANUM, S_DOMAIN); + + // Set default transition + S_START.defaultTransition = makeState(SYM); + + /** + Given a string, returns an array of TOKEN instances representing the + composition of that string. + + @method run + @param {String} str Input string to scan + @return {Array} Array of TOKEN instances + */ + var run = function run(str) { + + // The state machine only looks at lowercase strings. + // This selective `toLowerCase` is used because lowercasing the entire + // string causes the length and character position to vary in some in some + // non-English strings. This happens only on V8-based runtimes. + var lowerStr = str.replace(/[A-Z]/g, function (c) { + return c.toLowerCase(); + }); + var len = str.length; + var tokens = []; // return value + + var cursor = 0; + + // Tokenize the string + while (cursor < len) { + var state = S_START; + var secondState = null; + var nextState = null; + var tokenLength = 0; + var latestAccepting = null; + var sinceAccepts = -1; + + while (cursor < len && (nextState = state.next(lowerStr[cursor]))) { + secondState = null; + state = nextState; + + // Keep track of the latest accepting state + if (state.accepts()) { + sinceAccepts = 0; + latestAccepting = state; + } else if (sinceAccepts >= 0) { + sinceAccepts++; + } + + tokenLength++; + cursor++; + } + + if (sinceAccepts < 0) { + continue; + } // Should never happen + + // Roll back to the latest accepting state + cursor -= sinceAccepts; + tokenLength -= sinceAccepts; + + // Get the class for the new token + var TOKEN = latestAccepting.emit(); // Current token class + + // No more jumps, just make a new token + tokens.push(new TOKEN(str.substr(cursor - tokenLength, tokenLength))); + } + + return tokens; + }; + + var start = S_START; + + var scanner = Object.freeze({ + State: CharacterState, + TOKENS: TOKENS, + run: run, + start: start + }); + + /****************************************************************************** + Multi-Tokens + Tokens composed of arrays of TextTokens + ******************************************************************************/ + + // Is the given token a valid domain token? + // Should nums be included here? + function isDomainToken(token) { + return token instanceof DOMAIN || token instanceof TLD; + } + + /** + Abstract class used for manufacturing tokens of text tokens. That is rather + than the value for a token being a small string of text, it's value an array + of text tokens. + + Used for grouping together URLs, emails, hashtags, and other potential + creations. + + @class MultiToken + @abstract + */ + var MultiToken = createTokenClass(); + + MultiToken.prototype = { + /** + String representing the type for this token + @property type + @default 'TOKEN' + */ + type: 'token', + + /** + Is this multitoken a link? + @property isLink + @default false + */ + isLink: false, + + /** + Return the string this token represents. + @method toString + @return {String} + */ + toString: function toString() { + var result = []; + for (var _i2 = 0; _i2 < this.v.length; _i2++) { + result.push(this.v[_i2].toString()); + } + return result.join(''); + }, + + + /** + What should the value for this token be in the `href` HTML attribute? + Returns the `.toString` value by default. + @method toHref + @return {String} + */ + toHref: function toHref() { + return this.toString(); + }, + + + /** + Returns a hash of relevant values for this token, which includes keys + * type - Kind of token ('url', 'email', etc.) + * value - Original text + * href - The value that should be added to the anchor tag's href + attribute + @method toObject + @param {String} [protocol] `'http'` by default + @return {Object} + */ + toObject: function toObject() { + var protocol = arguments.length <= 0 || arguments[0] === undefined ? 'http' : arguments[0]; + + return { + type: this.type, + value: this.toString(), + href: this.toHref(protocol) + }; + } + }; + + /** + Represents a list of tokens making up a valid email address + @class EMAIL + @extends MultiToken + */ + var EMAIL = inherits(MultiToken, createTokenClass(), { + type: 'email', + isLink: true, + toHref: function toHref() { + return 'mailto:' + this.toString(); + } + }); + + /** + Represents some plain text + @class TEXT + @extends MultiToken + */ + var TEXT = inherits(MultiToken, createTokenClass(), { type: 'text' }); + + /** + Multi-linebreak token - represents a line break + @class NL + @extends MultiToken + */ + var NL = inherits(MultiToken, createTokenClass(), { type: 'nl' }); + + /** + Represents a list of tokens making up a valid URL + @class URL + @extends MultiToken + */ + var URL = inherits(MultiToken, createTokenClass(), { + type: 'url', + isLink: true, + + /** + Lowercases relevant parts of the domain and adds the protocol if + required. Note that this will not escape unsafe HTML characters in the + URL. + @method href + @param {String} protocol + @return {String} + */ + toHref: function toHref() { + var protocol = arguments.length <= 0 || arguments[0] === undefined ? 'http' : arguments[0]; + + var hasProtocol = false; + var hasSlashSlash = false; + var tokens = this.v; + var result = []; + var i = 0; + + // Make the first part of the domain lowercase + // Lowercase protocol + while (tokens[i] instanceof PROTOCOL) { + hasProtocol = true; + result.push(tokens[i].toString().toLowerCase()); + i++; + } + + // Skip slash-slash + while (tokens[i] instanceof SLASH) { + hasSlashSlash = true; + result.push(tokens[i].toString()); + i++; + } + + // Lowercase all other characters in the domain + while (isDomainToken(tokens[i])) { + result.push(tokens[i].toString().toLowerCase()); + i++; + } + + // Leave all other characters as they were written + for (; i < tokens.length; i++) { + result.push(tokens[i].toString()); + } + + result = result.join(''); + + if (!(hasProtocol || hasSlashSlash)) { + result = protocol + '://' + result; + } + + return result; + }, + hasProtocol: function hasProtocol() { + return this.v[0] instanceof PROTOCOL; + } + }); + + var TOKENS$1 = Object.freeze({ + Base: MultiToken, + EMAIL: EMAIL, + NL: NL, + TEXT: TEXT, + URL: URL + }); + + /** + Not exactly parser, more like the second-stage scanner (although we can + theoretically hotswap the code here with a real parser in the future... but + for a little URL-finding utility abstract syntax trees may be a little + overkill). + + URL format: http://en.wikipedia.org/wiki/URI_scheme + Email format: http://en.wikipedia.org/wiki/Email_address (links to RFC in + reference) + + @module linkify + @submodule parser + @main parser + */ + + var makeState$1 = function makeState$1(tokenClass) { + return new State(tokenClass); + }; + + // The universal starting state. + var S_START$1 = makeState$1(); + + // Intermediate states for URLs. Note that domains that begin with a protocol + // are treated slighly differently from those that don't. + var S_PROTOCOL = makeState$1(); // e.g., 'http:' + var S_PROTOCOL_SLASH = makeState$1(); // e.g., '/', 'http:/'' + var S_PROTOCOL_SLASH_SLASH = makeState$1(); // e.g., '//', 'http://' + var S_DOMAIN$1 = makeState$1(); // parsed string ends with a potential domain name (A) + var S_DOMAIN_DOT = makeState$1(); // (A) domain followed by DOT + var S_TLD = makeState$1(URL); // (A) Simplest possible URL with no query string + var S_TLD_COLON = makeState$1(); // (A) URL followed by colon (potential port number here) + var S_TLD_PORT = makeState$1(URL); // TLD followed by a port number + var S_URL = makeState$1(URL); // Long URL with optional port and maybe query string + var S_URL_NON_ACCEPTING = makeState$1(); // URL followed by some symbols (will not be part of the final URL) + var S_URL_OPENBRACE = makeState$1(); // URL followed by { + var S_URL_OPENBRACKET = makeState$1(); // URL followed by [ + var S_URL_OPENANGLEBRACKET = makeState$1(); // URL followed by < + var S_URL_OPENPAREN = makeState$1(); // URL followed by ( + var S_URL_OPENBRACE_Q = makeState$1(URL); // URL followed by { and some symbols that the URL can end it + var S_URL_OPENBRACKET_Q = makeState$1(URL); // URL followed by [ and some symbols that the URL can end it + var S_URL_OPENANGLEBRACKET_Q = makeState$1(URL); // URL followed by < and some symbols that the URL can end it + var S_URL_OPENPAREN_Q = makeState$1(URL); // URL followed by ( and some symbols that the URL can end it + var S_URL_OPENBRACE_SYMS = makeState$1(); // S_URL_OPENBRACE_Q followed by some symbols it cannot end it + var S_URL_OPENBRACKET_SYMS = makeState$1(); // S_URL_OPENBRACKET_Q followed by some symbols it cannot end it + var S_URL_OPENANGLEBRACKET_SYMS = makeState$1(); // S_URL_OPENANGLEBRACKET_Q followed by some symbols it cannot end it + var S_URL_OPENPAREN_SYMS = makeState$1(); // S_URL_OPENPAREN_Q followed by some symbols it cannot end it + var S_EMAIL_DOMAIN = makeState$1(); // parsed string starts with local email info + @ with a potential domain name (C) + var S_EMAIL_DOMAIN_DOT = makeState$1(); // (C) domain followed by DOT + var S_EMAIL = makeState$1(EMAIL); // (C) Possible email address (could have more tlds) + var S_EMAIL_COLON = makeState$1(); // (C) URL followed by colon (potential port number here) + var S_EMAIL_PORT = makeState$1(EMAIL); // (C) Email address with a port + var S_LOCALPART = makeState$1(); // Local part of the email address + var S_LOCALPART_AT = makeState$1(); // Local part of the email address plus @ + var S_LOCALPART_DOT = makeState$1(); // Local part of the email address plus '.' (localpart cannot end in .) + var S_NL = makeState$1(NL); // single new line + + // Make path from start to protocol (with '//') + S_START$1.on(TNL, S_NL).on(PROTOCOL, S_PROTOCOL).on(SLASH, S_PROTOCOL_SLASH); + + S_PROTOCOL.on(SLASH, S_PROTOCOL_SLASH); + S_PROTOCOL_SLASH.on(SLASH, S_PROTOCOL_SLASH_SLASH); + + // The very first potential domain name + S_START$1.on(TLD, S_DOMAIN$1).on(DOMAIN, S_DOMAIN$1).on(LOCALHOST, S_TLD).on(NUM, S_DOMAIN$1); + + // Force URL for anything sane followed by protocol + S_PROTOCOL_SLASH_SLASH.on(TLD, S_URL).on(DOMAIN, S_URL).on(NUM, S_URL).on(LOCALHOST, S_URL); + + // Account for dots and hyphens + // hyphens are usually parts of domain names + S_DOMAIN$1.on(DOT, S_DOMAIN_DOT); + S_EMAIL_DOMAIN.on(DOT, S_EMAIL_DOMAIN_DOT); + + // Hyphen can jump back to a domain name + + // After the first domain and a dot, we can find either a URL or another domain + S_DOMAIN_DOT.on(TLD, S_TLD).on(DOMAIN, S_DOMAIN$1).on(NUM, S_DOMAIN$1).on(LOCALHOST, S_DOMAIN$1); + + S_EMAIL_DOMAIN_DOT.on(TLD, S_EMAIL).on(DOMAIN, S_EMAIL_DOMAIN).on(NUM, S_EMAIL_DOMAIN).on(LOCALHOST, S_EMAIL_DOMAIN); + + // S_TLD accepts! But the URL could be longer, try to find a match greedily + // The `run` function should be able to "rollback" to the accepting state + S_TLD.on(DOT, S_DOMAIN_DOT); + S_EMAIL.on(DOT, S_EMAIL_DOMAIN_DOT); + + // Become real URLs after `SLASH` or `COLON NUM SLASH` + // Here PSS and non-PSS converge + S_TLD.on(COLON, S_TLD_COLON).on(SLASH, S_URL); + S_TLD_COLON.on(NUM, S_TLD_PORT); + S_TLD_PORT.on(SLASH, S_URL); + S_EMAIL.on(COLON, S_EMAIL_COLON); + S_EMAIL_COLON.on(NUM, S_EMAIL_PORT); + + // Types of characters the URL can definitely end in + var qsAccepting = [DOMAIN, AT, LOCALHOST, NUM, PLUS, POUND, PROTOCOL, SLASH, TLD, UNDERSCORE, SYM]; + + // Types of tokens that can follow a URL and be part of the query string + // but cannot be the very last characters + // Characters that cannot appear in the URL at all should be excluded + var qsNonAccepting = [COLON, DOT, QUERY, PUNCTUATION, CLOSEBRACE, CLOSEBRACKET, CLOSEANGLEBRACKET, CLOSEPAREN, OPENBRACE, OPENBRACKET, OPENANGLEBRACKET, OPENPAREN]; + + // These states are responsible primarily for determining whether or not to + // include the final round bracket. + + // URL, followed by an opening bracket + S_URL.on(OPENBRACE, S_URL_OPENBRACE).on(OPENBRACKET, S_URL_OPENBRACKET).on(OPENANGLEBRACKET, S_URL_OPENANGLEBRACKET).on(OPENPAREN, S_URL_OPENPAREN); + + // URL with extra symbols at the end, followed by an opening bracket + S_URL_NON_ACCEPTING.on(OPENBRACE, S_URL_OPENBRACE).on(OPENBRACKET, S_URL_OPENBRACKET).on(OPENANGLEBRACKET, S_URL_OPENANGLEBRACKET).on(OPENPAREN, S_URL_OPENPAREN); + + // Closing bracket component. This character WILL be included in the URL + S_URL_OPENBRACE.on(CLOSEBRACE, S_URL); + S_URL_OPENBRACKET.on(CLOSEBRACKET, S_URL); + S_URL_OPENANGLEBRACKET.on(CLOSEANGLEBRACKET, S_URL); + S_URL_OPENPAREN.on(CLOSEPAREN, S_URL); + S_URL_OPENBRACE_Q.on(CLOSEBRACE, S_URL); + S_URL_OPENBRACKET_Q.on(CLOSEBRACKET, S_URL); + S_URL_OPENANGLEBRACKET_Q.on(CLOSEANGLEBRACKET, S_URL); + S_URL_OPENPAREN_Q.on(CLOSEPAREN, S_URL); + S_URL_OPENBRACE_SYMS.on(CLOSEBRACE, S_URL); + S_URL_OPENBRACKET_SYMS.on(CLOSEBRACKET, S_URL); + S_URL_OPENANGLEBRACKET_SYMS.on(CLOSEANGLEBRACKET, S_URL); + S_URL_OPENPAREN_SYMS.on(CLOSEPAREN, S_URL); + + // URL that beings with an opening bracket, followed by a symbols. + // Note that the final state can still be `S_URL_OPENBRACE_Q` (if the URL only + // has a single opening bracket for some reason). + S_URL_OPENBRACE.on(qsAccepting, S_URL_OPENBRACE_Q); + S_URL_OPENBRACKET.on(qsAccepting, S_URL_OPENBRACKET_Q); + S_URL_OPENANGLEBRACKET.on(qsAccepting, S_URL_OPENANGLEBRACKET_Q); + S_URL_OPENPAREN.on(qsAccepting, S_URL_OPENPAREN_Q); + S_URL_OPENBRACE.on(qsNonAccepting, S_URL_OPENBRACE_SYMS); + S_URL_OPENBRACKET.on(qsNonAccepting, S_URL_OPENBRACKET_SYMS); + S_URL_OPENANGLEBRACKET.on(qsNonAccepting, S_URL_OPENANGLEBRACKET_SYMS); + S_URL_OPENPAREN.on(qsNonAccepting, S_URL_OPENPAREN_SYMS); + + // URL that begins with an opening bracket, followed by some symbols + S_URL_OPENBRACE_Q.on(qsAccepting, S_URL_OPENBRACE_Q); + S_URL_OPENBRACKET_Q.on(qsAccepting, S_URL_OPENBRACKET_Q); + S_URL_OPENANGLEBRACKET_Q.on(qsAccepting, S_URL_OPENANGLEBRACKET_Q); + S_URL_OPENPAREN_Q.on(qsAccepting, S_URL_OPENPAREN_Q); + S_URL_OPENBRACE_Q.on(qsNonAccepting, S_URL_OPENBRACE_Q); + S_URL_OPENBRACKET_Q.on(qsNonAccepting, S_URL_OPENBRACKET_Q); + S_URL_OPENANGLEBRACKET_Q.on(qsNonAccepting, S_URL_OPENANGLEBRACKET_Q); + S_URL_OPENPAREN_Q.on(qsNonAccepting, S_URL_OPENPAREN_Q); + + S_URL_OPENBRACE_SYMS.on(qsAccepting, S_URL_OPENBRACE_Q); + S_URL_OPENBRACKET_SYMS.on(qsAccepting, S_URL_OPENBRACKET_Q); + S_URL_OPENANGLEBRACKET_SYMS.on(qsAccepting, S_URL_OPENANGLEBRACKET_Q); + S_URL_OPENPAREN_SYMS.on(qsAccepting, S_URL_OPENPAREN_Q); + S_URL_OPENBRACE_SYMS.on(qsNonAccepting, S_URL_OPENBRACE_SYMS); + S_URL_OPENBRACKET_SYMS.on(qsNonAccepting, S_URL_OPENBRACKET_SYMS); + S_URL_OPENANGLEBRACKET_SYMS.on(qsNonAccepting, S_URL_OPENANGLEBRACKET_SYMS); + S_URL_OPENPAREN_SYMS.on(qsNonAccepting, S_URL_OPENPAREN_SYMS); + + // Account for the query string + S_URL.on(qsAccepting, S_URL); + S_URL_NON_ACCEPTING.on(qsAccepting, S_URL); + + S_URL.on(qsNonAccepting, S_URL_NON_ACCEPTING); + S_URL_NON_ACCEPTING.on(qsNonAccepting, S_URL_NON_ACCEPTING); + + // Email address-specific state definitions + // Note: We are not allowing '/' in email addresses since this would interfere + // with real URLs + + // Tokens allowed in the localpart of the email + var localpartAccepting = [DOMAIN, NUM, PLUS, POUND, QUERY, UNDERSCORE, SYM, TLD]; + + // Some of the tokens in `localpartAccepting` are already accounted for here and + // will not be overwritten (don't worry) + S_DOMAIN$1.on(localpartAccepting, S_LOCALPART).on(AT, S_LOCALPART_AT); + S_TLD.on(localpartAccepting, S_LOCALPART).on(AT, S_LOCALPART_AT); + S_DOMAIN_DOT.on(localpartAccepting, S_LOCALPART); + + // Okay we're on a localpart. Now what? + // TODO: IP addresses and what if the email starts with numbers? + S_LOCALPART.on(localpartAccepting, S_LOCALPART).on(AT, S_LOCALPART_AT) // close to an email address now + .on(DOT, S_LOCALPART_DOT); + S_LOCALPART_DOT.on(localpartAccepting, S_LOCALPART); + S_LOCALPART_AT.on(TLD, S_EMAIL_DOMAIN).on(DOMAIN, S_EMAIL_DOMAIN).on(LOCALHOST, S_EMAIL); + // States following `@` defined above + + var run$1 = function run$1(tokens) { + var len = tokens.length; + var cursor = 0; + var multis = []; + var textTokens = []; + + while (cursor < len) { + var state = S_START$1; + var secondState = null; + var nextState = null; + var multiLength = 0; + var latestAccepting = null; + var sinceAccepts = -1; + + while (cursor < len && !(secondState = state.next(tokens[cursor]))) { + // Starting tokens with nowhere to jump to. + // Consider these to be just plain text + textTokens.push(tokens[cursor++]); + } + + while (cursor < len && (nextState = secondState || state.next(tokens[cursor]))) { + + // Get the next state + secondState = null; + state = nextState; + + // Keep track of the latest accepting state + if (state.accepts()) { + sinceAccepts = 0; + latestAccepting = state; + } else if (sinceAccepts >= 0) { + sinceAccepts++; + } + + cursor++; + multiLength++; + } + + if (sinceAccepts < 0) { + + // No accepting state was found, part of a regular text token + // Add all the tokens we looked at to the text tokens array + for (var _i3 = cursor - multiLength; _i3 < cursor; _i3++) { + textTokens.push(tokens[_i3]); + } + } else { + + // Accepting state! + + // First close off the textTokens (if available) + if (textTokens.length > 0) { + multis.push(new TEXT(textTokens)); + textTokens = []; + } + + // Roll back to the latest accepting state + cursor -= sinceAccepts; + multiLength -= sinceAccepts; + + // Create a new multitoken + var MULTI = latestAccepting.emit(); + multis.push(new MULTI(tokens.slice(cursor - multiLength, cursor))); + } + } + + // Finally close off the textTokens (if available) + if (textTokens.length > 0) { + multis.push(new TEXT(textTokens)); + } + + return multis; + }; + + var parser = Object.freeze({ + State: State, + TOKENS: TOKENS$1, + run: run$1, + start: S_START$1 + }); + + if (!Array.isArray) { + Array.isArray = function (arg) { + return Object.prototype.toString.call(arg) === '[object Array]'; + }; + } + + /** + Converts a string into tokens that represent linkable and non-linkable bits + @method tokenize + @param {String} str + @return {Array} tokens + */ + var tokenize = function tokenize(str) { + return run$1(run(str)); + }; + + /** + Returns a list of linkable items in the given string. + */ + var find = function find(str) { + var type = arguments.length <= 1 || arguments[1] === undefined ? null : arguments[1]; + + var tokens = tokenize(str); + var filtered = []; + + for (var i = 0; i < tokens.length; i++) { + var token = tokens[i]; + if (token.isLink && (!type || token.type === type)) { + filtered.push(token.toObject()); + } + } + + return filtered; + }; + + /** + Is the given string valid linkable text of some sort + Note that this does not trim the text for you. + + Optionally pass in a second `type` param, which is the type of link to test + for. + + For example, + + test(str, 'email'); + + Will return `true` if str is a valid email. + */ + var test = function test(str) { + var type = arguments.length <= 1 || arguments[1] === undefined ? null : arguments[1]; + + var tokens = tokenize(str); + return tokens.length === 1 && tokens[0].isLink && (!type || tokens[0].type === type); + }; + + exports.find = find; + exports.inherits = inherits; + exports.options = options; + exports.parser = parser; + exports.scanner = scanner; + exports.test = test; + exports.tokenize = tokenize; +})(window.linkify = window.linkify || {}); +})(); diff --git a/src/web-chatview/qwebchannel.js b/src/web-chatview/qwebchannel.js new file mode 100644 index 0000000000000000000000000000000000000000..abf8461be4f726e3b6ea4b8a239f507b05f61c31 --- /dev/null +++ b/src/web-chatview/qwebchannel.js @@ -0,0 +1,427 @@ +/**************************************************************************** +** +** Copyright (C) 2016 The Qt Company Ltd. +** Copyright (C) 2016 Klarälvdalens Datakonsult AB, a KDAB Group company, info@kdab.com, author Milian Wolff <milian.wolff@kdab.com> +** Contact: https://www.qt.io/licensing/ +** +** This file is part of the QtWebChannel module of the Qt Toolkit. +** +** $QT_BEGIN_LICENSE:LGPL$ +** Commercial License Usage +** Licensees holding valid commercial Qt licenses may use this file in +** accordance with the commercial license agreement provided with the +** Software or, alternatively, in accordance with the terms contained in +** a written agreement between you and The Qt Company. For licensing terms +** and conditions see https://www.qt.io/terms-conditions. For further +** information use the contact form at https://www.qt.io/contact-us. +** +** GNU Lesser General Public License Usage +** Alternatively, this file may be used under the terms of the GNU Lesser +** General Public License version 3 as published by the Free Software +** Foundation and appearing in the file LICENSE.LGPL3 included in the +** packaging of this file. Please review the following information to +** ensure the GNU Lesser General Public License version 3 requirements +** will be met: https://www.gnu.org/licenses/lgpl-3.0.html. +** +** GNU General Public License Usage +** Alternatively, this file may be used under the terms of the GNU +** General Public License version 2.0 or (at your option) the GNU General +** Public license version 3 or any later version approved by the KDE Free +** Qt Foundation. The licenses are as published by the Free Software +** Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3 +** included in the packaging of this file. Please review the following +** information to ensure the GNU General Public License requirements will +** be met: https://www.gnu.org/licenses/gpl-2.0.html and +** https://www.gnu.org/licenses/gpl-3.0.html. +** +** $QT_END_LICENSE$ +** +****************************************************************************/ + +"use strict"; + +var QWebChannelMessageTypes = { + signal: 1, + propertyUpdate: 2, + init: 3, + idle: 4, + debug: 5, + invokeMethod: 6, + connectToSignal: 7, + disconnectFromSignal: 8, + setProperty: 9, + response: 10, +}; + +var QWebChannel = function(transport, initCallback) +{ + if (typeof transport !== "object" || typeof transport.send !== "function") { + console.error("The QWebChannel expects a transport object with a send function and onmessage callback property." + + " Given is: transport: " + typeof(transport) + ", transport.send: " + typeof(transport.send)); + return; + } + + var channel = this; + this.transport = transport; + + this.send = function(data) + { + if (typeof(data) !== "string") { + data = JSON.stringify(data); + } + channel.transport.send(data); + } + + this.transport.onmessage = function(message) + { + var data = message.data; + if (typeof data === "string") { + data = JSON.parse(data); + } + switch (data.type) { + case QWebChannelMessageTypes.signal: + channel.handleSignal(data); + break; + case QWebChannelMessageTypes.response: + channel.handleResponse(data); + break; + case QWebChannelMessageTypes.propertyUpdate: + channel.handlePropertyUpdate(data); + break; + default: + console.error("invalid message received:", message.data); + break; + } + } + + this.execCallbacks = {}; + this.execId = 0; + this.exec = function(data, callback) + { + if (!callback) { + // if no callback is given, send directly + channel.send(data); + return; + } + if (channel.execId === Number.MAX_VALUE) { + // wrap + channel.execId = Number.MIN_VALUE; + } + if (data.hasOwnProperty("id")) { + console.error("Cannot exec message with property id: " + JSON.stringify(data)); + return; + } + data.id = channel.execId++; + channel.execCallbacks[data.id] = callback; + channel.send(data); + }; + + this.objects = {}; + + this.handleSignal = function(message) + { + var object = channel.objects[message.object]; + if (object) { + object.signalEmitted(message.signal, message.args); + } else { + console.warn("Unhandled signal: " + message.object + "::" + message.signal); + } + } + + this.handleResponse = function(message) + { + if (!message.hasOwnProperty("id")) { + console.error("Invalid response message received: ", JSON.stringify(message)); + return; + } + channel.execCallbacks[message.id](message.data); + delete channel.execCallbacks[message.id]; + } + + this.handlePropertyUpdate = function(message) + { + for (var i in message.data) { + var data = message.data[i]; + var object = channel.objects[data.object]; + if (object) { + object.propertyUpdate(data.signals, data.properties); + } else { + console.warn("Unhandled property update: " + data.object + "::" + data.signal); + } + } + channel.exec({type: QWebChannelMessageTypes.idle}); + } + + this.debug = function(message) + { + channel.send({type: QWebChannelMessageTypes.debug, data: message}); + }; + + channel.exec({type: QWebChannelMessageTypes.init}, function(data) { + for (var objectName in data) { + var object = new QObject(objectName, data[objectName], channel); + } + // now unwrap properties, which might reference other registered objects + for (var objectName in channel.objects) { + channel.objects[objectName].unwrapProperties(); + } + if (initCallback) { + initCallback(channel); + } + channel.exec({type: QWebChannelMessageTypes.idle}); + }); +}; + +function QObject(name, data, webChannel) +{ + this.__id__ = name; + webChannel.objects[name] = this; + + // List of callbacks that get invoked upon signal emission + this.__objectSignals__ = {}; + + // Cache of all properties, updated when a notify signal is emitted + this.__propertyCache__ = {}; + + var object = this; + + // ---------------------------------------------------------------------- + + this.unwrapQObject = function(response) + { + if (response instanceof Array) { + // support list of objects + var ret = new Array(response.length); + for (var i = 0; i < response.length; ++i) { + ret[i] = object.unwrapQObject(response[i]); + } + return ret; + } + if (!response + || !response["__QObject*__"] + || response.id === undefined) { + return response; + } + + var objectId = response.id; + if (webChannel.objects[objectId]) + return webChannel.objects[objectId]; + + if (!response.data) { + console.error("Cannot unwrap unknown QObject " + objectId + " without data."); + return; + } + + var qObject = new QObject( objectId, response.data, webChannel ); + qObject.destroyed.connect(function() { + if (webChannel.objects[objectId] === qObject) { + delete webChannel.objects[objectId]; + // reset the now deleted QObject to an empty {} object + // just assigning {} though would not have the desired effect, but the + // below also ensures all external references will see the empty map + // NOTE: this detour is necessary to workaround QTBUG-40021 + var propertyNames = []; + for (var propertyName in qObject) { + propertyNames.push(propertyName); + } + for (var idx in propertyNames) { + delete qObject[propertyNames[idx]]; + } + } + }); + // here we are already initialized, and thus must directly unwrap the properties + qObject.unwrapProperties(); + return qObject; + } + + this.unwrapProperties = function() + { + for (var propertyIdx in object.__propertyCache__) { + object.__propertyCache__[propertyIdx] = object.unwrapQObject(object.__propertyCache__[propertyIdx]); + } + } + + function addSignal(signalData, isPropertyNotifySignal) + { + var signalName = signalData[0]; + var signalIndex = signalData[1]; + object[signalName] = { + connect: function(callback) { + if (typeof(callback) !== "function") { + console.error("Bad callback given to connect to signal " + signalName); + return; + } + + object.__objectSignals__[signalIndex] = object.__objectSignals__[signalIndex] || []; + object.__objectSignals__[signalIndex].push(callback); + + if (!isPropertyNotifySignal && signalName !== "destroyed") { + // only required for "pure" signals, handled separately for properties in propertyUpdate + // also note that we always get notified about the destroyed signal + webChannel.exec({ + type: QWebChannelMessageTypes.connectToSignal, + object: object.__id__, + signal: signalIndex + }); + } + }, + disconnect: function(callback) { + if (typeof(callback) !== "function") { + console.error("Bad callback given to disconnect from signal " + signalName); + return; + } + object.__objectSignals__[signalIndex] = object.__objectSignals__[signalIndex] || []; + var idx = object.__objectSignals__[signalIndex].indexOf(callback); + if (idx === -1) { + console.error("Cannot find connection of signal " + signalName + " to " + callback.name); + return; + } + object.__objectSignals__[signalIndex].splice(idx, 1); + if (!isPropertyNotifySignal && object.__objectSignals__[signalIndex].length === 0) { + // only required for "pure" signals, handled separately for properties in propertyUpdate + webChannel.exec({ + type: QWebChannelMessageTypes.disconnectFromSignal, + object: object.__id__, + signal: signalIndex + }); + } + } + }; + } + + /** + * Invokes all callbacks for the given signalname. Also works for property notify callbacks. + */ + function invokeSignalCallbacks(signalName, signalArgs) + { + var connections = object.__objectSignals__[signalName]; + if (connections) { + connections.forEach(function(callback) { + callback.apply(callback, signalArgs); + }); + } + } + + this.propertyUpdate = function(signals, propertyMap) + { + // update property cache + for (var propertyIndex in propertyMap) { + var propertyValue = propertyMap[propertyIndex]; + object.__propertyCache__[propertyIndex] = propertyValue; + } + + for (var signalName in signals) { + // Invoke all callbacks, as signalEmitted() does not. This ensures the + // property cache is updated before the callbacks are invoked. + invokeSignalCallbacks(signalName, signals[signalName]); + } + } + + this.signalEmitted = function(signalName, signalArgs) + { + invokeSignalCallbacks(signalName, this.unwrapQObject(signalArgs)); + } + + function addMethod(methodData) + { + var methodName = methodData[0]; + var methodIdx = methodData[1]; + object[methodName] = function() { + var args = []; + var callback; + for (var i = 0; i < arguments.length; ++i) { + var argument = arguments[i]; + if (typeof argument === "function") + callback = argument; + else if (argument instanceof QObject && webChannel.objects[argument.__id__] !== undefined) + args.push({ + "id": argument.__id__ + }); + else + args.push(argument); + } + + webChannel.exec({ + "type": QWebChannelMessageTypes.invokeMethod, + "object": object.__id__, + "method": methodIdx, + "args": args + }, function(response) { + if (response !== undefined) { + var result = object.unwrapQObject(response); + if (callback) { + (callback)(result); + } + } + }); + }; + } + + function bindGetterSetter(propertyInfo) + { + var propertyIndex = propertyInfo[0]; + var propertyName = propertyInfo[1]; + var notifySignalData = propertyInfo[2]; + // initialize property cache with current value + // NOTE: if this is an object, it is not directly unwrapped as it might + // reference other QObject that we do not know yet + object.__propertyCache__[propertyIndex] = propertyInfo[3]; + + if (notifySignalData) { + if (notifySignalData[0] === 1) { + // signal name is optimized away, reconstruct the actual name + notifySignalData[0] = propertyName + "Changed"; + } + addSignal(notifySignalData, true); + } + + Object.defineProperty(object, propertyName, { + configurable: true, + get: function () { + var propertyValue = object.__propertyCache__[propertyIndex]; + if (propertyValue === undefined) { + // This shouldn't happen + console.warn("Undefined value in property cache for property \"" + propertyName + "\" in object " + object.__id__); + } + + return propertyValue; + }, + set: function(value) { + if (value === undefined) { + console.warn("Property setter for " + propertyName + " called with undefined value!"); + return; + } + object.__propertyCache__[propertyIndex] = value; + var valueToSend = value; + if (valueToSend instanceof QObject && webChannel.objects[valueToSend.__id__] !== undefined) + valueToSend = { "id": valueToSend.__id__ }; + webChannel.exec({ + "type": QWebChannelMessageTypes.setProperty, + "object": object.__id__, + "property": propertyIndex, + "value": valueToSend + }); + } + }); + + } + + // ---------------------------------------------------------------------- + + data.methods.forEach(addMethod); + + data.properties.forEach(bindGetterSetter); + + data.signals.forEach(function(signal) { addSignal(signal, false); }); + + for (var name in data.enums) { + object[name] = data.enums[name]; + } +} + +//required for use with nodejs +if (typeof module === 'object') { + module.exports = { + QWebChannel: QWebChannel + }; +} diff --git a/src/web-chatview/web.gresource.xml b/src/web-chatview/web.gresource.xml new file mode 100644 index 0000000000000000000000000000000000000000..de86990317a2fd73592ee6d16669e82ddd298fd8 --- /dev/null +++ b/src/web-chatview/web.gresource.xml @@ -0,0 +1,20 @@ + <?xml version="1.0" encoding="UTF-8"?> + <gresources> + <gresource prefix="/net/jami/JamiGnome"> + <!-- HTML --> + <file>chatview.html</file> + + <!-- JavaScript --> + <file>chatview.js</file> + <file>linkify.js</file> + <file>linkify-string.js</file> + <file>linkify-html.js</file> + <file>jed.js</file> + + <!-- CSS --> + <file>chatview.css</file> + <file>chatview-gnome.css</file> + + <!-- Locale --> + </gresource> + </gresources>