├── .tx └── config ├── LICENSE.txt ├── README.md ├── assets ├── javascripts │ └── discourse │ │ ├── components │ │ ├── docked-composer.js.es6 │ │ ├── docked-editor.js.es6 │ │ ├── docked-expanding-textarea.js.es6 │ │ ├── docked-post-content.js.es6 │ │ ├── docked-post.js.es6 │ │ └── docked-upload.js.es6 │ │ ├── connectors │ │ ├── above-footer │ │ │ ├── messages-container.hbs │ │ │ └── messages-container.js.es6 │ │ └── user-preferences-interface │ │ │ └── show-quick-messages.js.es6 │ │ ├── initializers │ │ └── quick-messages-initializer.js.es6 │ │ ├── lib │ │ ├── docked-composer.js.es6 │ │ ├── docked-screen-track.js.es6 │ │ └── user-messages.js.es6 │ │ ├── pre-initializers │ │ └── quick-messages-pre-initializer.js.es6 │ │ ├── templates │ │ ├── components │ │ │ ├── docked-composer.hbs │ │ │ ├── docked-editor.hbs │ │ │ ├── docked-post.hbs │ │ │ └── docked-upload.hbs │ │ └── connectors │ │ │ └── user-preferences-interface │ │ │ └── show-quick-messages.hbs │ │ └── widgets │ │ ├── docked-post.js.es6 │ │ ├── docked-small-action.js.es6 │ │ ├── message-item.js.es6 │ │ ├── message-list.js.es6 │ │ └── message-menu.js.es6 └── stylesheets │ ├── colors.scss │ ├── common │ ├── quick_composer.scss │ └── quick_menu.scss │ └── mobile │ └── quick_mobile.scss ├── config ├── locales │ ├── client.en.yml │ ├── client.es.yml │ ├── client.fi.yml │ ├── client.fr.yml │ ├── client.pl.yml │ ├── client.pt.yml │ ├── server.en.yml │ ├── server.es.yml │ ├── server.fi.yml │ ├── server.fr.yml │ └── server.pl.yml └── settings.yml ├── lib └── setting_quick_messages_badge.rb └── plugin.rb /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | 4 | [discourse-quick-messages.client-en-yml] 5 | file_filter = config/locales/client..yml 6 | minimum_perc = 0 7 | source_file = config/locales/client.en.yml 8 | source_lang = en 9 | type = YML 10 | 11 | [discourse-quick-messages.server-en-yml] 12 | file_filter = config/locales/server..yml 13 | source_file = config/locales/server.en.yml 14 | source_lang = en 15 | type = YML 16 | 17 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # discourse-quick-messages 2 | 3 | Adds a dropdown in the header that shows you your latest private messages and a 'chat' experience for private messages similar to Facebook Messages or Google Hangouts. [Read more about this plugin on Discourse Meta](https://meta.discourse.org/t/quick-messages-plugin/39188). 4 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/docked-composer.js.es6: -------------------------------------------------------------------------------- 1 | import { default as discourseComputed, on, observes } from 'discourse-common/utils/decorators'; 2 | import { headerHeight } from 'discourse/components/site-header'; 3 | import { getCurrentUserMessages } from '../lib/user-messages'; 4 | import { emojiUnescape } from 'discourse/lib/text'; 5 | import { dockedScreenTrack } from '../lib/docked-screen-track'; 6 | import { deepEqual } from "discourse-common/lib/object"; 7 | import DiscourseURL from 'discourse/lib/url'; 8 | import { getUsernames, formatUsernames } from '../lib/docked-composer'; 9 | import { popupAjaxError } from 'discourse/lib/ajax-error'; 10 | import Component from '@ember/component'; 11 | import { or, equal, alias } from "@ember/object/computed"; 12 | import { throttle, scheduleOnce, next } from "@ember/runloop"; 13 | import { Promise } from "rsvp"; 14 | import { set } from "@ember/object"; 15 | import I18n from "I18n"; 16 | 17 | const _create_serializer = { 18 | raw: 'reply', 19 | title: 'title', 20 | topic_id: 'topic.id', 21 | target_recipients: 'targetUsernames', 22 | }; 23 | 24 | const START_EVENTS = "touchstart mousedown"; 25 | const DRAG_EVENTS = "touchmove mousemove"; 26 | const END_EVENTS = "touchend mouseup"; 27 | 28 | const MIN_COMPOSER_SIZE = 240; 29 | const THROTTLE_RATE = 20; 30 | 31 | function mouseYPos(e) { 32 | return e.clientY || (e.touches && e.touches[0] && e.touches[0].clientY); 33 | } 34 | 35 | export default Component.extend({ 36 | tagName: "div", 37 | classNameBindings: [':docked-composer', 'composeState', "integratedCompose", "firstPost:new"], 38 | disableSubmit: or("loading", "uploading"), 39 | composerOpen: equal('composeState', 'open'), 40 | postStream: alias('topic.postStream'), 41 | loading: true, 42 | composeState: null, 43 | targetUsernames: null, 44 | firstPost: false, 45 | archetypeId: 'private_message', 46 | reply: '', 47 | topic: null, 48 | emojiPickerOpen: false, 49 | hiddenUsernames: false, 50 | mobileKeyboard: false, 51 | 52 | // display 53 | 54 | @on('didInsertElement') 55 | _setupDisplay() { 56 | const integratedCompose = this.get('integratedCompose'); 57 | this.set('composeState', 'open'); 58 | 59 | if (!this.site.mobileView && !integratedCompose) { 60 | this.setupComposerResizeEvents(); 61 | } 62 | }, 63 | 64 | setupComposerResizeEvents() { 65 | const $composer = $(this.element); 66 | const $grippie = $composer.find(".docked-composer-header"); 67 | const $document = $(document); 68 | let origComposerSize = 0; 69 | let lastMousePos = 0; 70 | 71 | const performDrag = event => { 72 | $composer.trigger("div-resizing"); 73 | $composer.addClass("clear-transitions"); 74 | const currentMousePos = mouseYPos(event); 75 | let size = origComposerSize + (lastMousePos - currentMousePos); 76 | 77 | const winHeight = $(window).height(); 78 | size = Math.min(size, winHeight - headerHeight()); 79 | size = Math.max(size, MIN_COMPOSER_SIZE); 80 | const sizePx = `${size}px`; 81 | this.movePanels(sizePx); 82 | $composer.height(sizePx); 83 | }; 84 | 85 | const throttledPerformDrag = (event => { 86 | event.preventDefault(); 87 | throttle(this, performDrag, event, THROTTLE_RATE); 88 | }).bind(this); 89 | 90 | const endDrag = () => { 91 | $document.off(DRAG_EVENTS, throttledPerformDrag); 92 | $document.off(END_EVENTS, endDrag); 93 | $composer.removeClass("clear-transitions"); 94 | $composer.focus(); 95 | }; 96 | 97 | $grippie.on(START_EVENTS, event => { 98 | event.preventDefault(); 99 | origComposerSize = $composer.height(); 100 | lastMousePos = mouseYPos(event); 101 | $document.on(DRAG_EVENTS, throttledPerformDrag); 102 | $document.on(END_EVENTS, endDrag); 103 | }); 104 | }, 105 | 106 | @on('didInsertElement') 107 | @observes('index') 108 | _arrangeComposers() { 109 | if (!this.get('singleWindow')) { 110 | scheduleOnce('afterRender', () => { 111 | const index = this.get('index'); 112 | let right = this.site.mobileView ? 0 : 340 * index + 100; 113 | $(this.element).css('right', right); 114 | }); 115 | } 116 | }, 117 | 118 | @observes('composeState') 119 | _resize() { 120 | const h = $(this.element) ? $(this.element).height() : 0; 121 | this.movePanels(h + "px"); 122 | }, 123 | 124 | @observes('composerOpen') 125 | stopBodyScrollingOnMobile() { 126 | if (this.site.mobileView) { 127 | const composerOpen = this.get('composerOpen'); 128 | const $body = $('body, html'); 129 | 130 | if (composerOpen) { 131 | $body.scrollTop(0); 132 | $body.addClass('noscroll'); 133 | } else { 134 | $body.removeClass('noscroll'); 135 | } 136 | } 137 | }, 138 | 139 | movePanels(sizePx) { 140 | $('#main-outlet').css('padding-bottom', sizePx); 141 | }, 142 | 143 | @discourseComputed('targetUsernames', 'missingReplyCharacters') 144 | cantSubmitPost() { 145 | if (this.get('replyLength') < 1) return true; 146 | return this.get('targetUsernames') && (this.get('targetUsernames').trim() + ',').indexOf(',') === 0; 147 | }, 148 | 149 | @discourseComputed('reply') 150 | replyLength() { 151 | let reply = this.get('reply') || ""; 152 | return reply.replace(/\s+/img, " ").trim().length; 153 | }, 154 | 155 | @discourseComputed('index', 'singleWindow') 156 | editorTabIndex(index, singleWindow) { 157 | return singleWindow ? null : index + 1; 158 | }, 159 | 160 | @discourseComputed 161 | spinnerSize() { 162 | return this.site.mobileView ? 'large' : 'small'; 163 | }, 164 | 165 | @observes('emojiPickerOpen') 166 | setupEmojiPickerCss() { 167 | const emojiPickerOpen = this.get('emojiPickerOpen'); 168 | if (emojiPickerOpen) { 169 | $(this.element).css('z-index', 100); 170 | 171 | next(() => { 172 | let css = { visibility: 'visible' }; 173 | 174 | if (this.site.mobileView) { 175 | const editorHeight = $(this.element).find('.docked-editor').height(); 176 | css['left'] = 5; 177 | css['right'] = 5; 178 | css['bottom'] = editorHeight + 5; 179 | } else { 180 | const composerWidth = 300; 181 | const emojiModalWidth = 400; 182 | const composerOffset = $(this.element).offset(); 183 | const composerLeftOffset = composerOffset.left; 184 | 185 | css['bottom'] = 20; 186 | 187 | if (composerLeftOffset > emojiModalWidth) { 188 | css['left'] = composerLeftOffset - emojiModalWidth; 189 | } else if (($(window).width() - (composerLeftOffset - composerWidth)) > emojiModalWidth) { 190 | css['left'] = composerLeftOffset + composerWidth; 191 | } else { 192 | css['left'] = composerLeftOffset; 193 | css['bottom'] = $(this.element).height(); 194 | } 195 | } 196 | 197 | $('.emoji-picker').css(css); 198 | }); 199 | } else { 200 | $('.emoji-picker').css('visibility', 'hidden'); 201 | $(this.element).css('z-index', 0); 202 | } 203 | }, 204 | 205 | click() { 206 | const state = this.get('composeState'); 207 | if (state === 'minimized') { 208 | this.open(); 209 | } 210 | }, 211 | 212 | closeAutocomplete() { 213 | $(this.element).find('.d-editor-input').autocomplete({ cancel: true }); 214 | }, 215 | 216 | keyDown(e) { 217 | const enter = e.which === 13; 218 | const shift = e.shiftKey; 219 | const escape = e.which === 27; 220 | 221 | if (escape) { 222 | this.toggle(); 223 | return false; 224 | } 225 | 226 | if (enter && shift) { 227 | let reply = this.get('reply'); 228 | reply += '\n'; 229 | this.set('reply', reply); 230 | return false; 231 | } else if (enter) { 232 | this.save(); 233 | return false; 234 | } 235 | }, 236 | 237 | open() { 238 | const $element = $(this.element); 239 | const height = this.site.mobileView ? $(window).height() : 400; 240 | this.set('composeState', 'open'); 241 | 242 | if ($element) { 243 | scheduleOnce('afterRender', () => { 244 | $element.find(".d-editor-input").one('focus', () => { 245 | $element.find(".d-editor-input").blur(); 246 | }); 247 | }); 248 | $element.animate({ height }, 200, () => { 249 | this.afterStreamRender(); 250 | }); 251 | } 252 | }, 253 | 254 | collapse() { 255 | const $element = $(this.element); 256 | const height = this.site.mobileView ? 50 : 40; 257 | this.set('composeState', 'minimized'); 258 | 259 | if ($element) { 260 | $element.animate({ height }, 200); 261 | } 262 | }, 263 | 264 | close() { 265 | const $element = $(this.element); 266 | this.set('composeState', 'closed'); 267 | 268 | if ($element) { 269 | $element.animate({ height: 0 }, 200, () => { 270 | this.sendAction('removeDocked', this.get('index')); 271 | }); 272 | } 273 | }, 274 | 275 | cancel() { 276 | const self = this; 277 | return new Promise(function (resolve) { 278 | if (self.get('reply')) { 279 | bootbox.confirm(I18n.t("post.abandon.confirm"), I18n.t("post.abandon.no_value"), 280 | I18n.t("post.abandon.yes_value"), function(result) { 281 | if (result) { 282 | self.close(); 283 | resolve(); 284 | } 285 | }); 286 | } else { 287 | self.close(); 288 | resolve(); 289 | } 290 | }); 291 | }, 292 | 293 | toggle() { 294 | this.closeAutocomplete(); 295 | switch (this.get('composeState')) { 296 | case 'open': 297 | this.collapse(); 298 | break; 299 | case 'minimized': 300 | this.open(); 301 | break; 302 | } 303 | return false; 304 | }, 305 | 306 | @discourseComputed('composeState') 307 | togglerIcon(composeState) { 308 | return composeState === 'minimized' ? 'angle-up' : 'angle-down'; 309 | }, 310 | 311 | @discourseComputed('composeState') 312 | togglerTitle(composeState) { 313 | return composeState === 'minimized' ? 'composer.toggler.maximize' : 'composer.toggler.minimize'; 314 | }, 315 | 316 | scrollPoststream() { 317 | const $element = $(this.element); 318 | const $container = $element.find('.docked-composer-posts'); 319 | const $stream = $element.find('.docked-post-stream'); 320 | const streamHeight = $stream.height(); 321 | let self = this; 322 | 323 | // ensure stream is scrolled after images are loaded 324 | $element.find('.docked-post-stream img:not(.avatar)').each(function() { 325 | if ($(this).height() === 0) { 326 | $(this).on("load", function() { 327 | if (this.complete) self.scrollPoststream(); 328 | }); 329 | } 330 | }); 331 | 332 | $container.scrollTop(streamHeight); 333 | }, 334 | 335 | // actions 336 | 337 | actions: { 338 | save() { 339 | this.save(); 340 | }, 341 | 342 | cancel() { 343 | this.cancel(); 344 | }, 345 | 346 | toggle() { 347 | this.toggle(); 348 | }, 349 | 350 | openTopic() { 351 | const topicUrl = this.get('topic.url'); 352 | this.cancel(); 353 | DiscourseURL.routeTo(topicUrl); 354 | }, 355 | 356 | showUsernames() { 357 | this.toggleProperty('showUsernames'); 358 | }, 359 | 360 | toggleEmojiPicker(state = null) { 361 | if (state !== null) { 362 | this.set('emojiPickerOpen', state); 363 | } else { 364 | this.toggleProperty('emojiPickerOpen'); 365 | } 366 | }, 367 | 368 | scrollPoststream() { 369 | this.scrollPoststream(); 370 | }, 371 | 372 | back() { 373 | this.back(); 374 | } 375 | }, 376 | 377 | // topic and posts 378 | 379 | @on('init') 380 | setTopic() { 381 | const id = this.get('id'); 382 | if (id === 'new') { 383 | this.setProperties({ 384 | firstPost: true, 385 | loading: false 386 | }); 387 | return false; 388 | } 389 | this.set('topic', this.getTopic(id)); 390 | this.subscribeToTopic(); 391 | 392 | if (!this.get('singleWindow')) { 393 | this.appEvents.on('composer:opened', () => this.collapse()); 394 | } 395 | }, 396 | 397 | getTopic(id) { 398 | return this.store.createRecord('topic', { id }); 399 | }, 400 | 401 | subscribeToTopic() { 402 | const topic = this.get('topic'); 403 | if (!topic) { return; } 404 | 405 | const postStream = topic.get('postStream'); 406 | const row = { 407 | topic_id: topic.id, 408 | highest_post_number: topic.highest_post_number, 409 | last_read_post_number: Math.min(topic.highest_post_number, topic.last_read_post_number), 410 | created_at: topic.created_at, 411 | category_id: topic.category_id, 412 | notification_level: topic.notification_level 413 | }; 414 | 415 | this.topicTrackingState.loadStates([row]); 416 | 417 | this.messageBus.subscribe("/topic/" + topic.id, data => { 418 | if (data.type === "created") { 419 | postStream.triggerNewPostInStream(data.id).then(() => this.afterStreamRender()); 420 | if (this.get('currentUser.id') !== data.user_id) { 421 | Discourse.incrementBackgroundContextCount(); 422 | } 423 | } 424 | }); 425 | }, 426 | 427 | @observes('topic.postStream.loadedAllPosts') 428 | afterStreamRender() { 429 | const $element = $(this.element); 430 | const postStream = this.get('postStream'); 431 | 432 | if (postStream) { 433 | const nearPost = this.get("topic.highest_post_number"); 434 | 435 | postStream.refresh({ nearPost }).then(() => { 436 | if (this._state !== 'destroying') { 437 | this.set('loading', false); 438 | 439 | scheduleOnce('afterRender', () => { 440 | if ($element) { 441 | this.scrollPoststream(); 442 | dockedScreenTrack(this, this.get('topic')); 443 | } 444 | }); 445 | } 446 | }); 447 | } 448 | }, 449 | 450 | @discourseComputed('topic.details.loaded') 451 | otherUsernames(loaded) { 452 | if (loaded) { 453 | const usernames = getUsernames(this.get('topic.details.allowed_users')); 454 | usernames.splice(usernames.indexOf(this.get('currentUser.username')), 1); 455 | return formatUsernames(usernames); 456 | } 457 | return ''; 458 | }, 459 | 460 | @on('didInsertElement') 461 | @observes('otherUsernames') 462 | handleLongUsernames() { 463 | const $element = $(this.element); 464 | 465 | if (this.get('otherUsernames')) { 466 | scheduleOnce('afterRender', this, () => { 467 | const usernamesWidth = $element.find(".docked-usernames").width(); 468 | const wrapperWidth = $element.find('.docked-usernames-wrapper').width(); 469 | 470 | if (usernamesWidth > wrapperWidth) { 471 | this.set("hiddenUsernames", true); 472 | } 473 | }); 474 | } 475 | }, 476 | 477 | @observes('targetUsernames') 478 | createOrContinue() { 479 | const currentUsername = this.get('currentUser.username'); 480 | const integratedCompose = this.get('integratedCompose'); 481 | let existingId = null; 482 | let targetUsernames = this.get('targetUsernames').split(','); 483 | targetUsernames.push(currentUsername); 484 | 485 | getCurrentUserMessages(this).then((result) => { 486 | result.forEach((message) => { 487 | let usernames = getUsernames(message.participants); 488 | if (usernames.indexOf(currentUsername) === -1) { 489 | usernames.push(currentUsername); 490 | } 491 | 492 | if (deepEqual([...usernames].sort(), [...targetUsernames].sort())) { 493 | existingId = message.id; 494 | } 495 | }); 496 | 497 | if (existingId) { 498 | const docked = this.get('docked'); 499 | if (docked && docked.indexOf(existingId) > -1) { 500 | this.set('disableEditor', true); 501 | } else { 502 | this.setProperties({ 503 | 'topic': this.getTopic(existingId), 504 | 'disableEditor': false 505 | }); 506 | this.subscribeToTopic(); 507 | } 508 | } else { 509 | this.setProperties({ 510 | 'id': 'new', 511 | 'topic': null, 512 | 'title': formatUsernames(targetUsernames), 513 | 'disableEditor': false 514 | }); 515 | } 516 | }); 517 | }, 518 | 519 | save() { 520 | if (this.get('cantSubmitPost')) return; 521 | 522 | const postStream = this.get('postStream'); 523 | const user = this.get('currentUser'); 524 | 525 | let createdPost = this.store.createRecord('post', { 526 | cooked: emojiUnescape(this.get('reply')), 527 | yours: true 528 | }); 529 | let postOpts = { 530 | custom_fields: { 'quick_message': true } 531 | }; 532 | 533 | this.serialize(_create_serializer, postOpts); 534 | 535 | postOpts.archetype = postOpts.topic_id ? 'regular' : 'private_message'; 536 | 537 | this.set('reply', ''); 538 | 539 | let state = ''; 540 | if (postStream) { 541 | state = postStream.stagePost(createdPost, user); 542 | this.set('firstPost', false); 543 | } 544 | if (state === 'staged') this.afterStreamRender(); 545 | 546 | const self = this; 547 | const id = this.get('id'); 548 | createdPost.save(postOpts).then((result) => { 549 | if (postStream) { 550 | user.set('reply_count', user.get('reply_count') + 1); 551 | postStream.commitPost(createdPost); 552 | } 553 | 554 | if (!id || id === 'new') { 555 | self.set('firstPost', false); 556 | self.sendAction('updateId', self.get('index'), result.responseJson.post.topic_id); 557 | 558 | if (id === 'new') { 559 | user.set('topic_count', user.get('topic_count') + 1); 560 | } 561 | } 562 | }).catch(popupAjaxError); 563 | }, 564 | 565 | serialize(serializer, dest) { 566 | dest = dest || {}; 567 | Object.keys(serializer).forEach(f => { 568 | const val = this.get(serializer[f]); 569 | if (typeof val !== 'undefined') { 570 | set(dest, f, val); 571 | } 572 | }); 573 | return dest; 574 | } 575 | }); 576 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/docked-editor.js.es6: -------------------------------------------------------------------------------- 1 | import DEditor from 'discourse/components/d-editor'; 2 | import userSearch from 'discourse/lib/user-search'; 3 | import { findRawTemplate } from 'discourse-common/lib/raw-templates'; 4 | import { on } from 'discourse-common/utils/decorators'; 5 | import { isAppleDevice } from 'discourse/lib/utilities'; 6 | import { calcHeightWithKeyboard } from '../lib/docked-composer'; 7 | import { scheduleOnce, next } from "@ember/runloop"; 8 | 9 | export default DEditor.extend({ 10 | classNames: ['docked-editor'], 11 | dockedUpload: false, 12 | 13 | @on('didInsertElement') 14 | setupDocked() { 15 | const $element = $(this.element); 16 | const $editorInput = $element.find('.d-editor-input'); 17 | this._applyMentionAutocomplete($editorInput); 18 | }, 19 | 20 | _applyMentionAutocomplete($editorInput) { 21 | const topicId = this.get('topic.id'); 22 | $editorInput.autocomplete({ 23 | template: findRawTemplate('user-selector-autocomplete'), 24 | dataSource: term => userSearch({ term, topicId, includeGroups: true }), 25 | key: "@", 26 | transformComplete: v => v.username || v.name 27 | }); 28 | }, 29 | 30 | handleIOSPositioning(type) { 31 | const isIOS = isAppleDevice(); 32 | if (isIOS) { 33 | const $composer = $('.docked-composer'); 34 | let style = {}; 35 | 36 | if (type === 'blur') { 37 | style['height'] = '100%'; 38 | } else { 39 | const $body = $('body,html'); 40 | const heightWithKeyboard = calcHeightWithKeyboard(); 41 | style['height'] = heightWithKeyboard; 42 | $body.scrollTop(0); 43 | } 44 | 45 | $composer.css(style); 46 | 47 | this.sendAction('scrollPoststream'); 48 | } 49 | }, 50 | 51 | actions: { 52 | uploadDone(upload) { 53 | const text = `![${upload.original_filename}](${upload.url})`; 54 | this._addText(this._getSelected(), text); 55 | scheduleOnce('afterRender', () => $('.d-editor-input').blur()); 56 | }, 57 | 58 | openEmojiPicker() { 59 | this.sendAction('toggleEmojiPicker'); 60 | }, 61 | 62 | emojiSelected(code){ 63 | this._super(code); 64 | this.sendAction('toggleEmojiPicker'); 65 | scheduleOnce('afterRender', () => $('.d-editor-input').click()); 66 | }, 67 | 68 | focusChange(state) { 69 | this.handleIOSPositioning(state); 70 | if (state === 'focus') { 71 | this.sendAction('toggleEmojiPicker', false); 72 | } 73 | scheduleOnce('afterRender', () => { 74 | if (this._state === 'destroying') return; 75 | this.set('focusState', state); 76 | }); 77 | }, 78 | 79 | // focus/blur events in textarea prevent normal clicks on buttons from working the first time 80 | buttonMousedown(e) { 81 | if (this.site.mobileView && this.get('focusState') === 'focus') { 82 | next(() => { 83 | const $element = $(this.element); 84 | const $target = $(e.target); 85 | if ($target.hasClass('qm-upload-picture')) { 86 | $element.find('.docked-upload input').click(); 87 | }; 88 | if ($target.hasClass('qm-emoji')) { 89 | this.sendAction('toggleEmojiPicker'); 90 | } 91 | }); 92 | } 93 | } 94 | } 95 | }); 96 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/docked-expanding-textarea.js.es6: -------------------------------------------------------------------------------- 1 | import { on, observes } from 'discourse-common/utils/decorators'; 2 | import ExpandingTextArea from 'discourse/components/expanding-text-area'; 3 | import { scheduleOnce, bind } from "@ember/runloop"; 4 | 5 | export default ExpandingTextArea.extend({ 6 | attributeBindings: ['disabled', 'rows'], 7 | rows: 1, 8 | 9 | @on('didInsertElement') 10 | setupListners() { 11 | const focus = () => this.sendAction('focusChange', 'focus'); 12 | const blur = () => this.sendAction('focusChange', 'blur'); 13 | this.setProperties({ focus, blur }); 14 | 15 | scheduleOnce('afterRender', () => { 16 | $(this.element).one('click', bind(this, focus)); 17 | $(this.element).on('focus', bind(this, focus)); 18 | $(this.element).on('blur', bind(this, blur)); 19 | 20 | // for iOS 21 | $(document).on('visibilitychange', bind(this, blur)); 22 | }); 23 | }, 24 | 25 | @on('willDestroyElement') 26 | destroyListners() { 27 | $(this.element).off('focus', bind(this, this.get('focus'))); 28 | $(this.element).off('blur', bind(this, this.get('blur'))); 29 | $(document).off('visibilitychange', bind(this, this.get('blur'))); 30 | }, 31 | 32 | @observes('value') 33 | _updateAutosize() { 34 | scheduleOnce('afterRender', () => { 35 | const evt = document.createEvent('Event'); 36 | evt.initEvent('autosize:update', true, false); 37 | this.element.dispatchEvent(evt); 38 | }); 39 | } 40 | }); 41 | 42 | // necessary because the expanding textarea in core doesnt apply autosize after render 43 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/docked-post-content.js.es6: -------------------------------------------------------------------------------- 1 | import { observes } from 'discourse-common/utils/decorators'; 2 | import MountWidget from 'discourse/components/mount-widget'; 3 | 4 | export default MountWidget.extend({ 5 | widget: 'docked-post', 6 | 7 | @observes('post.cooked') 8 | _rerender() { 9 | this.queueRerender(); 10 | }, 11 | 12 | buildArgs() { 13 | return { 14 | post: this.get('post') 15 | }; 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/docked-post.js.es6: -------------------------------------------------------------------------------- 1 | import { default as discourseComputed } from 'discourse-common/utils/decorators'; 2 | import Component from "@ember/component"; 3 | 4 | export default Component.extend({ 5 | tagName: "div", 6 | classNames: 'docked-post', 7 | 8 | @discourseComputed('post.yours') 9 | contentClass(yours) { 10 | return yours ? 'yours' : ''; 11 | }, 12 | 13 | @discourseComputed('onlineService.users.@each', 'post.user_id') 14 | avatarClasses(onlineUsers, userId) { 15 | let classes = 'docked-avatar'; 16 | 17 | const onlineService = this.get('onlineService'); 18 | if (onlineService && onlineService.isUserOnline(userId)) { 19 | classes += ' user-online'; 20 | } 21 | 22 | return classes; 23 | }, 24 | 25 | @discourseComputed('post.post_type') 26 | isSmallAction(type) { 27 | const postTypes = this.site.post_types; 28 | return type === postTypes.small_action; 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/docked-upload.js.es6: -------------------------------------------------------------------------------- 1 | import UploadMixin from "discourse/mixins/upload"; 2 | import Component from "@ember/component"; 3 | 4 | export default Component.extend(UploadMixin, { 5 | tagName: 'button', 6 | classNames: 'docked-upload btn btn-small', 7 | attributeBindings: ['uploading:disabled'], 8 | type: 'PUT', 9 | 10 | input() { 11 | return $(this.element).find('input'); 12 | }, 13 | 14 | click(e) { 15 | if (!$(e.target).is('input')) { 16 | this.input().trigger('click'); 17 | } 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/above-footer/messages-container.hbs: -------------------------------------------------------------------------------- 1 | {{#each docked as |id index|}} 2 | {{docked-composer id=id 3 | docked=docked 4 | maxIndex=maxIndex 5 | index=index 6 | removeDocked="removeDocked" 7 | updateId="updateId" 8 | singleWindow=singleWindow}} 9 | {{/each}} 10 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/above-footer/messages-container.js.es6: -------------------------------------------------------------------------------- 1 | import { getOwner } from 'discourse-common/lib/get-owner'; 2 | 3 | export default { 4 | setupComponent(args, component) { 5 | const appController = getOwner(this).lookup('controller:application'); 6 | const docked = appController.get('docked'); 7 | const maxIndex = appController.get('maxIndex'); 8 | const singleWindow = component.get('mobileView'); 9 | 10 | component.setProperties({ 11 | docked, 12 | maxIndex, 13 | singleWindow 14 | }); 15 | }, 16 | 17 | actions: { 18 | removeDocked(index) { 19 | const appController = getOwner(this).lookup('controller:application'); 20 | appController.send('removeDocked', index); 21 | }, 22 | 23 | updateId(index, id) { 24 | const appController = getOwner(this).lookup('controller:application'); 25 | appController.send('updateId', index, id); 26 | } 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/user-preferences-interface/show-quick-messages.js.es6: -------------------------------------------------------------------------------- 1 | export default { 2 | shouldRender(args, component) { 3 | return Discourse.SiteSettings.quick_message_enabled && 4 | Discourse.SiteSettings.quick_message_user_preference && 5 | args.model.quick_messages_access; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/initializers/quick-messages-initializer.js.es6: -------------------------------------------------------------------------------- 1 | import { default as discourseComputed, observes } from 'discourse-common/utils/decorators'; 2 | import { withPluginApi } from 'discourse/lib/plugin-api'; 3 | import DiscourseURL from 'discourse/lib/url'; 4 | import { inject as service } from "@ember/service"; 5 | 6 | export default { 7 | name: 'quick-messages-initializer', 8 | initialize(container){ 9 | const siteSettings = container.lookup("site-settings:main"); 10 | const currentUser = container.lookup('current-user:main'); 11 | const appEvents = container.lookup("service:app-events"); 12 | const site = container.lookup("site:main"); 13 | const bus = container.lookup("message-bus:main"); 14 | 15 | if (currentUser) { 16 | bus.subscribe(`/notification/${currentUser.get("id")}`, data => { 17 | if (data.unread_private_messages) { 18 | const oldUnreadPMs = currentUser.get("unread_private_messages"); 19 | currentUser.set('unread_private_messages', data.unread_private_messages); 20 | if (oldUnreadPMs !== data.unread_private_messages) { 21 | appEvents.trigger("notifications:changed"); 22 | } 23 | } 24 | }); 25 | } 26 | 27 | withPluginApi('0.8.12', api => { 28 | if (currentUser && currentUser.show_quick_messages && !siteSettings.quick_message_integrated) { 29 | api.decorateWidget('header-icons:before', function(helper) { 30 | const headerState = helper.widget.parentWidget.state; 31 | 32 | let contents = []; 33 | if (currentUser && (!site.mobileView || siteSettings.quick_message_mobile)) { 34 | const unread = currentUser.get('unread_private_messages'); 35 | contents.push( 36 | helper.attach('header-dropdown', { 37 | title: 'user.private_messages', 38 | icon: siteSettings.quick_message_icon, 39 | iconId: 'toggle-messages-menu', 40 | active: headerState.messagesVisible, 41 | action: 'toggleMessages', 42 | contents() { 43 | if (unread) { 44 | return this.attach('link', { 45 | action: 'toggleMessages', 46 | className: 'badge-notification unread-private-message-notifications', 47 | rawLabel: `${unread}` 48 | }); 49 | } 50 | } 51 | }) 52 | ); 53 | } 54 | return contents; 55 | }); 56 | 57 | api.addHeaderPanel('messages-menu', 'messagesVisible', function(attrs, state) { 58 | return {}; 59 | }); 60 | 61 | api.attachWidgetAction('header', 'toggleMessages', function() { 62 | this.state.messagesVisible = !this.state.messagesVisible; 63 | }); 64 | 65 | api.attachWidgetAction('header', 'messagesClicked', function() { 66 | this.linkClickedEvent(); 67 | this.state.messagesVisible = false; 68 | }); 69 | 70 | if (siteSettings.whos_online_enabled) { 71 | api.modifyClass('component:docked-post', { 72 | onlineService: service('online-service') 73 | }); 74 | } 75 | 76 | } 77 | 78 | if (siteSettings.quick_message_enabled) { 79 | api.modifyClass('controller:preferences/interface', { 80 | @discourseComputed('makeThemeDefault') 81 | saveAttrNames() { 82 | const attrs = this._super(...arguments); 83 | if (!attrs.includes("custom_fields")) attrs.push("custom_fields"); 84 | return attrs; 85 | }, 86 | 87 | @observes('saved') 88 | _updateShowQuickMessages() { 89 | const saved = this.get("saved"); 90 | 91 | if (saved && currentUser && this.get("model.id") === currentUser.get("id")) { 92 | currentUser.set("quick_messages_pref", this.get("model.custom_fields.quick_messages_pref")); 93 | } 94 | } 95 | }); 96 | } 97 | 98 | }); 99 | } 100 | }; 101 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/lib/docked-composer.js.es6: -------------------------------------------------------------------------------- 1 | const getUsernames = function(participants) { 2 | let usernames = []; 3 | participants.forEach((participant) => { 4 | let username = participant.user ? participant.user.username : participant.username; 5 | usernames.push(username); 6 | }); 7 | return usernames; 8 | }; 9 | 10 | const formatUsernames = function(usernames) { 11 | let formatted = ''; 12 | let length = usernames.length; 13 | usernames.forEach((username, i) => { 14 | formatted += username; 15 | if (i < length - 1) { 16 | formatted += i === (length - 2) ? ' & ' : ', '; 17 | } 18 | }); 19 | return formatted; 20 | }; 21 | 22 | // based on discourse/lib/safari-hacks calcHeight 23 | 24 | const calcHeightWithKeyboard = function() { 25 | 26 | // estimate 270 px for keyboard 27 | let withoutKeyboard = window.innerHeight - 270; 28 | const min = 270; 29 | 30 | // iPhone shrinks header and removes footer controls ( back / forward nav ) 31 | // at 39px we are at the largest viewport 32 | const portrait = window.innerHeight > window.innerWidth; 33 | const smallViewport = ((portrait ? window.screen.height : window.screen.width) - window.innerHeight) > 40; 34 | 35 | if (portrait) { 36 | 37 | // iPhone SE, it is super small so just 38 | // have a bit of crop 39 | if (window.screen.height === 568) { 40 | withoutKeyboard = 270; 41 | } 42 | 43 | // iPhone 6/7/8 44 | if (window.screen.height === 667) { 45 | withoutKeyboard = smallViewport ? 295 : 325; 46 | } 47 | 48 | // iPhone 6/7/8 plus 49 | if (window.screen.height === 736) { 50 | withoutKeyboard = smallViewport ? 353 : 383; 51 | } 52 | 53 | // iPhone X 54 | if (window.screen.height === 812) { 55 | withoutKeyboard = smallViewport ? 340 : 370; 56 | } 57 | 58 | // iPad can use innerHeight cause it renders nothing in the footer 59 | if (window.innerHeight > 920) { 60 | withoutKeyboard -= 45; 61 | } 62 | 63 | } else { 64 | 65 | // landscape 66 | // iPad, we have a bigger keyboard 67 | if (window.innerHeight > 665) { 68 | withoutKeyboard -= 128; 69 | } 70 | } 71 | 72 | // iPad portrait also has a bigger keyboard 73 | return Math.max(withoutKeyboard, min); 74 | }; 75 | 76 | export { getUsernames, formatUsernames, calcHeightWithKeyboard }; 77 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/lib/docked-screen-track.js.es6: -------------------------------------------------------------------------------- 1 | import { ajax } from 'discourse/lib/ajax'; 2 | 3 | let dockedScreenTrack = function(context, topic) { 4 | if (!topic) { return; } 5 | 6 | const highest = topic.highest_post_number; 7 | const lastRead = Math.min(highest, topic.last_read_post_number); 8 | 9 | context.topicTrackingState.updateSeen(topic.id, highest); 10 | 11 | let newTimings = {}; 12 | if (lastRead === highest) { 13 | newTimings[highest] = 3000; 14 | } else { 15 | for (let p = lastRead + 1; p <= highest; p++) { 16 | newTimings[p] = 3000; 17 | } 18 | } 19 | 20 | ajax('/topics/timings', { 21 | data: { 22 | timings: newTimings, 23 | topic_time: 3000, 24 | topic_id: topic.id 25 | }, 26 | cache: false, 27 | type: 'POST', 28 | headers: { 29 | 'X-SILENCE-LOGGER': 'true' 30 | } 31 | }); 32 | }; 33 | 34 | export { dockedScreenTrack }; 35 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/lib/user-messages.js.es6: -------------------------------------------------------------------------------- 1 | let getCurrentUserMessages = function(context) { 2 | const store = context.store, 3 | username = context.currentUser.get('username'); 4 | 5 | return store.findFiltered("topicList", {filter: "topics/private-messages/" + username}).then((result) => { 6 | let inbox = result.topics, 7 | inboxIds = result.topics.map(function(topic) {return topic.id;}); 8 | 9 | return store.findFiltered("topicList", {filter: "topics/private-messages-sent/" + username}).then((result) => { 10 | let sentOnly = result.topics.filter(function(topic) {return inboxIds.indexOf(topic.id) === -1;}), 11 | messages = inbox.concat(sentOnly); 12 | 13 | messages.sort(function(a, b) { 14 | a = new Date(a.last_posted_at); 15 | b = new Date(b.last_posted_at); 16 | return a > b ? -1 : a < b ? 1 : 0; 17 | }); 18 | 19 | messages = messages.filter(function(m) { 20 | return m.subtype === 'user_to_user'; 21 | }); 22 | 23 | return messages; 24 | }).catch(() => { 25 | console.log('getting sent messages failed'); 26 | return []; 27 | }); 28 | }).catch(() => { 29 | console.log('getting inbox failed'); 30 | return []; 31 | }); 32 | }; 33 | 34 | export { getCurrentUserMessages }; 35 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/pre-initializers/quick-messages-pre-initializer.js.es6: -------------------------------------------------------------------------------- 1 | import { default as discourseComputed, on } from 'discourse-common/utils/decorators'; 2 | import { withPluginApi } from 'discourse/lib/plugin-api'; 3 | import { bind } from "@ember/runloop"; 4 | import { A } from "@ember/array"; 5 | 6 | export default { 7 | name: 'quick-messages-pre-initializer', 8 | initialize(container) { 9 | const currentUser = container.lookup('current-user:main'); 10 | 11 | if (currentUser && currentUser.show_quick_messages) { 12 | 13 | withPluginApi('0.8.12', api => { 14 | api.modifyClass('controller:application', { 15 | docked: A(), 16 | 17 | @on('didInsertElement') 18 | _setupQuickMessages() { 19 | $(window).on('resize', bind(this, this.maxIndex)); 20 | }, 21 | 22 | @on('willDestroyElement') 23 | _teardownQuickMessages() { 24 | $(window).off('resize', bind(this, this.maxIndex)); 25 | }, 26 | 27 | @discourseComputed() 28 | maxIndex() { 29 | return this.site.mobileView ? 1 : Math.floor(($(window).width() - 390) / 340); 30 | }, 31 | 32 | actions: { 33 | addToDocked(id) { 34 | id = id ? id : 'new'; 35 | let docked = this.get('docked'); 36 | 37 | if (docked.includes(id)) return; 38 | 39 | let max = this.get('maxIndex'); 40 | if (docked.length >= max) { 41 | docked.replace(0, 1, id); 42 | } else { 43 | docked.pushObject(id); 44 | } 45 | }, 46 | 47 | removeDocked(index) { 48 | this.get('docked').removeAt(index); 49 | }, 50 | 51 | updateId(index, id) { 52 | const docked = this.get('docked'); 53 | docked.replace(index, 1, [id]); 54 | } 55 | } 56 | }); 57 | }); 58 | } 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/templates/components/docked-composer.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{#if firstPost}} 3 | {{user-selector topicId=topic.id 4 | excludeCurrentUser="true" 5 | id="private-message-users" 6 | includeMentionableGroups="true" 7 | placeholderKey="composer.users_placeholder" 8 | tabindex="1" 9 | usernames=targetUsernames}} 10 | {{else}} 11 |
12 | {{#if hiddenUsernames}} 13 | 14 | {{otherUsernames}} 15 | 16 | {{else}} 17 | {{otherUsernames}} 18 | {{/if}} 19 |
20 | {{#if showUsernames}} 21 |
22 | {{otherUsernames}} 23 | 24 | {{d-icon 'times'}} 25 | 26 |
27 | {{/if}} 28 | {{/if}} 29 | 46 |
47 | {{#if composerOpen}} 48 |
49 | {{#if disableEditor}} 50 |
51 | {{i18n 'composer.quick_duplicate'}} 52 |
53 | {{else}} 54 | {{#if loading}} 55 | {{loading-spinner size=spinnerSize}} 56 | {{else}} 57 |
58 | {{#each postStream.posts as |post|}} 59 | {{docked-post post=post}} 60 | {{/each}} 61 |
62 | {{/if}} 63 | {{/if}} 64 |
65 | 66 | {{docked-editor tabindex=editorTabIndex 67 | value=reply 68 | composer=this 69 | placeholder="composer.quick_placeholder" 70 | disabled=disableEditor 71 | toggleEmojiPicker='toggleEmojiPicker' 72 | emojiPickerOpen=emojiPickerOpen 73 | scrollPoststream='scrollPoststream'}} 74 | {{/if}} 75 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/templates/components/docked-editor.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{d-button action='openEmojiPicker' class='btn qm-emoji' icon='far-smile' mouseDown=(action 'buttonMousedown')}} 4 | {{docked-upload uploadDone=(action 'uploadDone') mouseDown=(action 'buttonMousedown')}} 5 |
6 |
7 | {{docked-expanding-textarea tabindex=null 8 | value=value 9 | class="d-editor-input" 10 | placeholder=placeholderTranslated 11 | disabled=disabled 12 | scrollPoststream='scrollPoststream' 13 | focusChange='focusChange'}} 14 | {{popup-input-tip validation=validation}} 15 |
16 | 17 | {{emoji-picker active=emojiPickerOpen automaticPositioning=false emojiSelected=(action 'emojiSelected')}} 18 |
19 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/templates/components/docked-post.hbs: -------------------------------------------------------------------------------- 1 | {{#if isSmallAction}} 2 | {{mount-widget widget='docked-small-action' args=post}} 3 | {{else}} 4 |
5 | {{#unless post.yours}} 6 |
7 | {{avatar post avatarTemplatePath="avatar_template" usernamePath="username" imageSize="small"}} 8 |
9 | {{/unless}} 10 |
11 |
12 | {{docked-post-content post=post}} 13 |
14 | {{/if}} 15 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/templates/components/docked-upload.hbs: -------------------------------------------------------------------------------- 1 | {{#if uploading}} 2 |
3 | {{uploadProgress}}% 4 |
5 | {{else}} 6 | {{d-icon "far-image" class='qm-upload-picture'}} 7 | 8 | {{/if}} 9 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/templates/connectors/user-preferences-interface/show-quick-messages.hbs: -------------------------------------------------------------------------------- 1 |
2 | 6 |
-------------------------------------------------------------------------------- /assets/javascripts/discourse/widgets/docked-post.js.es6: -------------------------------------------------------------------------------- 1 | import { createWidget } from 'discourse/widgets/widget'; 2 | import PostCooked from 'discourse/widgets/post-cooked'; 3 | import DecoratorHelper from 'discourse/widgets/decorator-helper'; 4 | import { longDate } from 'discourse/lib/formatter'; 5 | 6 | class QuickPostCooked extends PostCooked { 7 | init() { 8 | const $html = $(`
${this.attrs.cooked}
`); 9 | this._insertQuoteControls($html); 10 | this._showLinkCounts($html); 11 | this._fixImageSizes($html); 12 | this._applySearchHighlight($html); 13 | return $html[0]; 14 | } 15 | 16 | update(prev) { 17 | return this.init(); 18 | } 19 | } 20 | 21 | export default createWidget('docked-post', { 22 | buildAttributes(attrs) { 23 | return { 24 | title: longDate(attrs.post.created_at) 25 | } 26 | }, 27 | 28 | html(attrs) { 29 | const post = attrs.post; 30 | return new QuickPostCooked(post, new DecoratorHelper(this)); 31 | } 32 | }); 33 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/widgets/docked-small-action.js.es6: -------------------------------------------------------------------------------- 1 | import { createWidget } from 'discourse/widgets/widget'; 2 | import RawHtml from 'discourse/widgets/raw-html'; 3 | import { iconNode } from 'discourse-common/lib/icon-library'; 4 | import { autoUpdatingRelativeAge } from 'discourse/lib/formatter'; 5 | import { userPath } from 'discourse/lib/url'; 6 | import { h } from 'virtual-dom'; 7 | import I18n from "I18n"; 8 | 9 | const actionDescriptionHtml = function(actionCode, createdAt, username) { 10 | const dt = new Date(createdAt); 11 | const when = autoUpdatingRelativeAge(dt, { format: 'tiny', addAgo: true }); 12 | 13 | var who = ""; 14 | if (username) { 15 | if (actionCode === "invited_group" || actionCode === "removed_group") { 16 | who = `@${username}`; 17 | } else { 18 | who = `@${username}`; 19 | } 20 | } 21 | return I18n.t(`action_codes.${actionCode}`, { who, when }).htmlSafe(); 22 | }; 23 | 24 | const icons = { 25 | 'closed.enabled': 'lock', 26 | 'closed.disabled': 'unlock-alt', 27 | 'autoclosed.enabled': 'lock', 28 | 'autoclosed.disabled': 'unlock-alt', 29 | 'archived.enabled': 'folder', 30 | 'archived.disabled': 'folder-open', 31 | 'pinned.enabled': 'thumb-tack', 32 | 'pinned.disabled': 'thumb-tack unpinned', 33 | 'pinned_globally.enabled': 'thumb-tack', 34 | 'pinned_globally.disabled': 'thumb-tack unpinned', 35 | 'banner.enabled': 'thumb-tack', 36 | 'banner.disabled': 'thumb-tack unpinned', 37 | 'visible.enabled': 'eye', 38 | 'visible.disabled': 'eye-slash', 39 | 'split_topic': 'sign-out', 40 | 'invited_user': 'plus-circle', 41 | 'invited_group': 'plus-circle', 42 | 'user_left': 'minus-circle', 43 | 'removed_user': 'minus-circle', 44 | 'removed_group': 'minus-circle', 45 | 'public_topic': 'comment', 46 | 'private_topic': 'envelope' 47 | }; 48 | 49 | export default createWidget('docked-small-action', { 50 | tagName: 'div.docked-small-action', 51 | 52 | html(attrs) { 53 | const contents = []; 54 | 55 | contents.push(h('div.icon', iconNode(icons[attrs.action_code] || 'exclamation'))); 56 | 57 | contents.push(h('span', h('a.mention', { href: userPath(attrs.username) }, `@${attrs.username}`))); 58 | 59 | const description = actionDescriptionHtml(attrs.action_code, attrs.created_at, attrs.action_code_who); 60 | contents.push(new RawHtml({ html: `${description}` })); 61 | 62 | if (attrs.cooked) { 63 | contents.push(new RawHtml({ html: `
${attrs.cooked}
` })); 64 | } 65 | 66 | return contents; 67 | } 68 | }); 69 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/widgets/message-item.js.es6: -------------------------------------------------------------------------------- 1 | import { createWidget } from 'discourse/widgets/widget'; 2 | import { h } from 'virtual-dom'; 3 | import { avatarImg } from 'discourse/widgets/post'; 4 | import I18n from "I18n"; 5 | 6 | createWidget('message-item', { 7 | tagName: 'li.message-item', 8 | 9 | buildClasses(attrs) { 10 | const classNames = []; 11 | if (attrs.get('unread')) { classNames.push('unread'); } 12 | return classNames; 13 | }, 14 | 15 | html(attrs) { 16 | const participants = attrs.participants.map(p => { 17 | return avatarImg('small', { template: p.user.avatar_template, 18 | username: p.user.username }); 19 | }); 20 | 21 | let contents = [ 22 | h('ul', h('li', participants)), 23 | attrs.get('excerpt') 24 | ]; 25 | 26 | if (attrs.get('unread')) { 27 | contents.push(h('div', {className: 'new-count'}, `${attrs.get('newCount')} ${I18n.t(`new_item`)}` )); 28 | } 29 | 30 | return h('a', h('div.item-contents', contents)); 31 | }, 32 | 33 | click() { 34 | this.attrs.set('unread', false); 35 | const id = this.attrs.id; 36 | this.sendWidgetAction('addToDocked', id); 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/widgets/message-list.js.es6: -------------------------------------------------------------------------------- 1 | import { createWidget } from 'discourse/widgets/widget'; 2 | import { getCurrentUserMessages } from '../lib/user-messages'; 3 | import { h } from 'virtual-dom'; 4 | import RawHtml from 'discourse/widgets/raw-html'; 5 | import { emojiUnescape } from 'discourse/lib/text'; 6 | import I18n from "I18n"; 7 | 8 | export default createWidget('message-list', { 9 | tagName: 'div.message-list', 10 | buildKey: () => 'message-list', 11 | 12 | defaultState() { 13 | return { 14 | messages: null, 15 | loading: false 16 | }; 17 | }, 18 | 19 | messagesChanged() { 20 | this.refreshMessages(this.state); 21 | }, 22 | 23 | refreshMessages(state) { 24 | if (this.loading) { return; } 25 | state.loading = true; 26 | getCurrentUserMessages(this).then((result) => { 27 | if (result.length) { 28 | let messages = result.slice(0,7); 29 | 30 | messages.forEach((m) => { 31 | if (m.last_read_post_number < m.highest_post_number) { 32 | m.set('unread', true); 33 | m.set('newCount', m.highest_post_number - m.last_read_post_number); 34 | } 35 | if (m.message_excerpt) { 36 | let excerpt = new RawHtml({ 37 | html: `
${emojiUnescape(m.message_excerpt)}
` 38 | }); 39 | m.set('excerpt', excerpt); 40 | } 41 | state.messages = messages; 42 | }); 43 | } else { 44 | state.messages = 'empty'; 45 | } 46 | state.loading = false; 47 | this.scheduleRerender(); 48 | }); 49 | }, 50 | 51 | html(attrs, state) { 52 | if (!state.messages) { 53 | this.refreshMessages(state); 54 | } 55 | const result = []; 56 | if (state.loading) { 57 | result.push(h('div.spinner-container', h('div.spinner'))); 58 | } else if (state.messages !== 'empty') { 59 | const messageItems = state.messages.map(m => this.attach('message-item', m)); 60 | result.push(h('ul', [messageItems])); 61 | } else { 62 | result.push(h('div.no-messages', I18n.t(`user.no_quick_messages`))); 63 | } 64 | return result; 65 | } 66 | }); 67 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/widgets/message-menu.js.es6: -------------------------------------------------------------------------------- 1 | import { createWidget } from 'discourse/widgets/widget'; 2 | import { h } from 'virtual-dom'; 3 | import DiscourseURL from 'discourse/lib/url'; 4 | import { later } from "@ember/runloop"; 5 | import { userPath } from "discourse/lib/url"; 6 | 7 | export default createWidget('messages-menu', { 8 | tagName: 'div.messages-menu', 9 | panelContents() { 10 | return [ 11 | this.attach('message-list'), 12 | h('div.menu-footer', [ 13 | h('hr'), 14 | h('ul.menu-links', [ 15 | h('li.all-messages', this.attach('link', 16 | {label: 'show_more', 17 | action: 'goToMessages'})), 18 | h('li.new-message', this.attach('link', 19 | {icon: 'envelope', 20 | label: 'user.new_private_message', 21 | action: 'addToDocked'})) 22 | ] 23 | )])]; 24 | }, 25 | 26 | html(attrs) { 27 | const { notMenu } = attrs; 28 | let contents = []; 29 | 30 | if (notMenu) { 31 | contents.push(this.panelContents()); 32 | } else { 33 | contents.push(this.attach('menu-panel', { contents: () => this.panelContents() })); 34 | } 35 | 36 | return contents; 37 | }, 38 | 39 | addToDocked(id) { 40 | const appController = this.register.lookup('controller:application'); 41 | appController.send('addToDocked', id); 42 | this.sendWidgetAction('messagesClicked'); 43 | }, 44 | 45 | goToMessages() { 46 | const username = this.get("currentUser.username_lower"); 47 | DiscourseURL.routeTo(userPath(`${username}/messages`)); 48 | this.sendWidgetAction('messagesClicked'); 49 | }, 50 | 51 | clickOutsideMobile(e) { 52 | const $centeredElement = $(document.elementFromPoint(e.clientX, e.clientY)); 53 | if ( 54 | $centeredElement.parents(".panel").length && 55 | !$centeredElement.hasClass("header-cloak") 56 | ) { 57 | this.sendWidgetAction("toggleMessages"); 58 | } else { 59 | const $window = $(window); 60 | const windowWidth = parseInt($window.width(), 10); 61 | const $panel = $(".menu-panel"); 62 | $panel.addClass("animate"); 63 | const panelOffsetDirection = this.site.mobileView ? "left" : "right"; 64 | $panel.css(panelOffsetDirection, -windowWidth); 65 | const $headerCloak = $(".header-cloak"); 66 | $headerCloak.addClass("animate"); 67 | $headerCloak.css("opacity", 0); 68 | later(() => this.sendWidgetAction("toggleMessages"), 200); 69 | } 70 | }, 71 | 72 | clickOutside(e) { 73 | if (this.site.mobileView) { 74 | this.clickOutsideMobile(e); 75 | } else { 76 | this.sendWidgetAction("toggleMessages"); 77 | } 78 | } 79 | }); 80 | -------------------------------------------------------------------------------- /assets/stylesheets/colors.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --quick-msg-primary-low: #{dark-light-diff($primary, $secondary, 88%, -55%)}; 3 | --quick-msg-icon: #{dark-light-choose(scale-color($primary, $lightness: 30%), scale-color($secondary, $lightness: 70%))}; 4 | } -------------------------------------------------------------------------------- /assets/stylesheets/common/quick_composer.scss: -------------------------------------------------------------------------------- 1 | .messages-container { 2 | z-index: 998; 3 | position: fixed; 4 | } 5 | 6 | .docked-composer { 7 | position: fixed; 8 | bottom: 0; 9 | width: 300px; 10 | background-color: var(--secondary, $secondary); 11 | box-shadow: shadow("menu-panel"); 12 | border: 1px solid var(--primary-low, $primary-low); 13 | height: 400px; 14 | display: flex; 15 | flex-direction: column; 16 | 17 | &.minimized { 18 | height: auto; 19 | position: fixed; 20 | display: block; 21 | 22 | .docked-post-stream, 23 | .grippie { 24 | display: none; 25 | } 26 | 27 | .docked-composer-header { 28 | height: 40px; 29 | line-height: 30px; 30 | margin-bottom: 0; 31 | cursor: pointer; 32 | box-shadow: 0 -2px 4px -1px rgba(0,0,0,0.25); 33 | } 34 | } 35 | 36 | &.closed { 37 | height: auto; 38 | } 39 | 40 | &#new .docked-composer-header { 41 | height: auto; 42 | } 43 | 44 | .grippie { 45 | background-color: var(--primary-low, $primary-low); 46 | 47 | &:before { 48 | border-top: 3px double var(--primary, $primary); 49 | } 50 | } 51 | 52 | .docked-composer-posts { 53 | flex: 1; 54 | width: 100%; 55 | overflow-x: hidden; 56 | overflow-y: scroll; 57 | margin-bottom: 2px; 58 | 59 | .spinner { 60 | position: absolute; 61 | top: calc(50% - 40px); 62 | left: calc(50% - 10px); 63 | } 64 | } 65 | 66 | &.integrated-compose { 67 | .docked-composer-header { 68 | padding: 0; 69 | position: relative; 70 | 71 | .controls { 72 | position: absolute; 73 | z-index: 9999; 74 | margin: 0 10px 0 0; 75 | line-height: 30px; 76 | } 77 | 78 | .docked-usernames-wrapper { 79 | text-align: center; 80 | } 81 | } 82 | 83 | &.new { 84 | .docked-composer-header { 85 | padding: 0; 86 | 87 | .controls { 88 | position: relative; 89 | float: left; 90 | } 91 | 92 | .ac-wrap { 93 | margin: 0 5px 0 0; 94 | } 95 | } 96 | } 97 | } 98 | } 99 | 100 | .quick-messages-header { 101 | width: inherit; 102 | height: 30px; 103 | line-height: 25px; 104 | margin-bottom: 2px; 105 | box-sizing: border-box; 106 | background-color: var(--primary-low, $primary-low); 107 | box-shadow: shadow("header"); 108 | z-index: 100; 109 | } 110 | 111 | .integrated-compose-header { 112 | @extend .quick-messages-header; 113 | position: relative; 114 | 115 | a.back { 116 | line-height: 30px; 117 | margin: 0 5px; 118 | position: absolute; 119 | left: 0; 120 | top: 2px; 121 | } 122 | 123 | .title { 124 | padding-top: 2px; 125 | line-height: 30px; 126 | text-align: center; 127 | overflow: hidden; 128 | } 129 | } 130 | 131 | .docked-composer-header { 132 | @extend .quick-messages-header; 133 | padding: 6px 12px; 134 | display: flex; 135 | justify-content: space-between; 136 | align-items: center; 137 | min-height: 40px; 138 | 139 | .controls { 140 | margin-left: 5px; 141 | float: right; 142 | display: flex; 143 | align-items: center; 144 | 145 | > a { 146 | margin: 0 5px; 147 | display: inline-block; 148 | vertical-align: middle; 149 | 150 | &:last-of-type { 151 | margin-right: 0; 152 | } 153 | } 154 | 155 | .toggler { 156 | padding: 0; 157 | font-size: 19px; 158 | color: var(--primary-medium, $primary-medium); 159 | font-weight: 900; 160 | } 161 | 162 | .cancel { 163 | font-size: 15px; 164 | color: var(--primary-medium, $primary-medium); 165 | z-index: 20; 166 | } 167 | 168 | .topic-link { 169 | padding: 0; 170 | color: var(--primary-medium, $primary-medium); 171 | font-weight: 900; 172 | } 173 | } 174 | 175 | .ac-wrap { 176 | width: initial !important; 177 | background-color: var(--primary-very-low, $primary-very-low); 178 | } 179 | 180 | .docked-usernames-wrapper { 181 | position: relative; 182 | z-index: 1; 183 | overflow: hidden; 184 | text-overflow: ellipsis; 185 | line-height: 30px; 186 | 187 | .docked-usernames { 188 | white-space: nowrap; 189 | display: inline-block; 190 | color: var(--primary, $primary); 191 | } 192 | } 193 | 194 | .docked-usernames-toggle { 195 | padding: 0 5px; 196 | } 197 | 198 | .extra-usernames { 199 | position: absolute; 200 | max-width: 200px; 201 | z-index: 100; 202 | padding: 10px; 203 | box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.25); 204 | background-color: var(--quick-msg-primary-low, dark-light-diff($primary, $secondary, 88%, -55%)); 205 | 206 | .cancel { 207 | float: right; 208 | } 209 | } 210 | } 211 | 212 | .docked-post-stream { 213 | padding: 10px; 214 | 215 | .docked-post { 216 | width: 100%; 217 | display: inline-block; 218 | padding: 5px 0; 219 | color: #333; 220 | 221 | .docked-post-user { 222 | display: inline-block; 223 | vertical-align: top; 224 | width: 11%; 225 | } 226 | 227 | .docked-post-content { 228 | display: inline-block; 229 | word-wrap: break-word; 230 | vertical-align: middle; 231 | max-width: 80%; 232 | 233 | &.yours { 234 | float: right; 235 | background-color: var(--tertiary-low, $tertiary-low); 236 | padding: 3px 6px; 237 | border-radius: 6px; 238 | display: block; 239 | } 240 | 241 | .cooked { 242 | color: var(--primary-medium, $primary-medium); 243 | } 244 | 245 | p { 246 | margin: 0; 247 | color: var(--primary, $primary); 248 | } 249 | 250 | img { 251 | max-width: 100%; 252 | height: auto; 253 | } 254 | } 255 | } 256 | } 257 | 258 | .docked-editor { 259 | flex-grow: 0; 260 | box-shadow: 0 -2px 4px -1px rgba(0,0,0,0.25); 261 | 262 | .docked-editor-container { 263 | border: none; 264 | bottom: 0; 265 | width: 300px; 266 | left: 0; 267 | right: 0; 268 | padding: 0; 269 | background-color: var(--secondary, $secondary); 270 | position: relative; 271 | } 272 | 273 | .docked-editor-textarea-wrapper { 274 | padding: 0; 275 | width: 100%; 276 | height: 100%; 277 | border: none; 278 | 279 | .d-editor-input { 280 | border-bottom: none; 281 | border-left: none; 282 | border-right: none; 283 | margin-bottom: 30px; 284 | box-shadow: none; 285 | border-radius: 0; 286 | max-height: calc(100% - 35px); 287 | } 288 | } 289 | 290 | .button-bar { 291 | position: absolute; 292 | bottom: 10px; 293 | left: 5px; 294 | margin: 0; 295 | top: initial; 296 | width: auto; 297 | border: none; 298 | z-index: 99; 299 | display: flex; 300 | align-items: center; 301 | 302 | .btn { 303 | padding: 3px 6px; 304 | background-color: var(--secondary, $secondary); 305 | 306 | .fa { 307 | line-height: 18px; 308 | margin-right: 0; 309 | } 310 | 311 | &:hover { 312 | background-color: transparent; 313 | 314 | .fa { 315 | color: var(--primary, $primary); 316 | } 317 | } 318 | } 319 | } 320 | 321 | .d-editor-preview-header, 322 | .d-editor-preview-wrapper { 323 | display: none; 324 | } 325 | 326 | .emoji-picker { 327 | visibility: hidden; 328 | box-shadow: shadow('menu-panel'); 329 | } 330 | } 331 | 332 | .docked-upload { 333 | display: inline-block; 334 | position: relative; 335 | 336 | .docked-uploading { 337 | display: inline-block; 338 | } 339 | 340 | input { 341 | position: absolute; 342 | display: none; 343 | top: -100px; 344 | } 345 | } 346 | 347 | .noscroll { 348 | height: 100%; 349 | overflow: auto; 350 | } 351 | 352 | .docked-small-action { 353 | border-top: 1px solid var(--primary-low, $primary-low); 354 | border-bottom: 1px solid var(--primary-low, $primary-low); 355 | padding: 10px 0; 356 | display: flex; 357 | flex-wrap: wrap; 358 | align-items: center; 359 | 360 | .icon, &> span:not(:last-of-type) { 361 | margin-right: 6px; 362 | } 363 | } 364 | 365 | .docked-avatar { 366 | position: relative; 367 | } 368 | 369 | html.whos-online-glow { 370 | .docked-avatar.user-online { 371 | @extend .topic-avatar.user-online !optional; 372 | } 373 | } 374 | 375 | html.whos-online-ring { 376 | .docked-avatar.user-online { 377 | @extend .topic-avatar.user-online !optional; 378 | } 379 | } 380 | 381 | html.whos-online-flair { 382 | .docked-avatar.user-online::before { 383 | @extend .whos-online-flair-prototype !optional; 384 | top: -3px; 385 | left: -2px; 386 | height: 6px !important; 387 | width: 6px !important; 388 | } 389 | } 390 | -------------------------------------------------------------------------------- /assets/stylesheets/common/quick_menu.scss: -------------------------------------------------------------------------------- 1 | #toggle-messages-menu { 2 | position: relative; 3 | 4 | .unread-private-message-notifications { 5 | top: -2px; 6 | right: -2px; 7 | left: initial; 8 | background-color: var(--tertiary, $tertiary); 9 | } 10 | } 11 | 12 | .messages-menu { 13 | width: 0; 14 | } 15 | 16 | .messages-menu .panel-body-contents { 17 | overflow: hidden; 18 | 19 | .spinner { 20 | position: absolute; 21 | left: 150px; 22 | top: 8px; 23 | width: 15px; 24 | height: 15px; 25 | border-width: 2px; 26 | margin: 0 auto; 27 | } 28 | } 29 | 30 | .message-list { 31 | text-align: left; 32 | min-height: 35px; 33 | } 34 | 35 | .no-messages { 36 | text-align: center; 37 | padding: 10px 0; 38 | } 39 | 40 | .message-item { 41 | padding: 0 2px; 42 | font-size: 14px; 43 | list-style: none; 44 | margin: 0; 45 | line-height: 17px; 46 | display: inline-block; 47 | padding: 5px; 48 | width: 97%; 49 | 50 | .item-contents { 51 | color: var(--primary, $primary); 52 | cursor: pointer; 53 | display: flex; 54 | 55 | div.badge-notification { 56 | position: relative; 57 | top: 4px; 58 | display: initial; 59 | background-color: var(--success, $success); 60 | height: 12px; 61 | width: 8px; 62 | } 63 | } 64 | 65 | .message-avatar { 66 | margin-right: 3px; 67 | } 68 | 69 | .message-excerpt { 70 | flex: 1; 71 | padding: 0 8px; 72 | line-height: 25px; 73 | white-space: nowrap; 74 | word-break: break-all; 75 | text-overflow: ellipsis; 76 | overflow: hidden; 77 | 78 | i.fa { 79 | font-size: 18px; 80 | width: 18px; 81 | height: 18px; 82 | line-height: 18px; 83 | padding: 0; 84 | vertical-align: middle; 85 | } 86 | } 87 | 88 | .new-count { 89 | line-height: 25px; 90 | } 91 | 92 | &:hover { 93 | background-color: var(--highlight-medium, $highlight-medium); 94 | } 95 | 96 | span { 97 | color: $primary; 98 | } 99 | 100 | .fa { 101 | padding-right: 6px; 102 | 103 | } 104 | 105 | .icon { 106 | color: var(--quick-msg-icon, dark-light-choose(scale-color($primary, $lightness: 30%), scale-color($secondary, $lightness: 70%))); 107 | } 108 | 109 | .read { 110 | background-color: var(--secondary, $secondary); 111 | } 112 | 113 | .none { 114 | padding-top: 5px; 115 | } 116 | } 117 | 118 | .message-item.unread .item-contents { 119 | color: #0088cc; 120 | } 121 | 122 | .menu-links { 123 | a { 124 | line-height: 26px; 125 | } 126 | 127 | .all-messages { 128 | float: left; 129 | text-align: left; 130 | } 131 | 132 | .new-message { 133 | float: right; 134 | text-align: right; 135 | 136 | .widget-link { 137 | display: flex; 138 | align-items: center; 139 | 140 | svg.d-icon { 141 | margin-right: 5px; 142 | margin-bottom: 2px; 143 | vertical-align: middle; 144 | display: inline-block; 145 | } 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /assets/stylesheets/mobile/quick_mobile.scss: -------------------------------------------------------------------------------- 1 | .d-header-icons .icon { 2 | width: 10vw; 3 | max-width: 2.2857em; 4 | } 5 | 6 | .messages-container { 7 | z-index: 1100; 8 | } 9 | 10 | .docked-composer { 11 | width: 100%; 12 | height: 100%; 13 | 14 | &.open { 15 | top: 0; 16 | } 17 | 18 | .docked-composer-header, &.minimized .docked-composer-header { 19 | height: 50px; 20 | line-height: 50px; 21 | 22 | .controls > a { 23 | margin: 0 10px; 24 | } 25 | 26 | a.cancel { 27 | font-size: 23px; 28 | margin-bottom: 3px; 29 | } 30 | 31 | a.toggler { 32 | font-size: 30px; 33 | margin-bottom: 5px; 34 | } 35 | 36 | a.topic-link { 37 | font-size: 20px; 38 | } 39 | } 40 | 41 | .docked-composer-header .ac-wrap { 42 | margin: 9px 0; 43 | line-height: initial; 44 | } 45 | 46 | .docked-usernames-wrapper { 47 | line-height: 50px; 48 | } 49 | 50 | .docked-usernames { 51 | font-size: 1.1em; 52 | } 53 | 54 | .docked-editor { 55 | left: 0; 56 | right: 0; 57 | margin-bottom: 2px; 58 | } 59 | 60 | .docked-editor-container { 61 | width: 100%; 62 | } 63 | 64 | .docked-editor-textarea-wrapper { 65 | overflow: hidden; 66 | width: initial; 67 | line-height: 0; 68 | 69 | .d-editor-input { 70 | margin-bottom: 0; 71 | min-height: 50px; 72 | line-height: 25px; 73 | } 74 | 75 | .autocomplete { 76 | left: 15px !important; 77 | bottom: 40px !important; 78 | top: initial !important; 79 | } 80 | } 81 | 82 | .button-bar { 83 | float: right; 84 | position: initial; 85 | line-height: 50px; 86 | padding-right: 5px; 87 | 88 | .btn { 89 | padding: 6px 10px; 90 | font-size: 1.4em; 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /config/locales/client.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | js: 3 | composer: 4 | quick_placeholder: "Type a message." 5 | quick_duplicate: "Your thread with this user is open in another message window." 6 | open_message_topic: "Navigate to the message topic." 7 | toggler: 8 | minimize: "Minimize" 9 | maximize: "Maximiize" 10 | user: 11 | no_quick_messages: "No messages yet." 12 | user_preferences: 13 | quick_messages_preference: "Enable messages menu in header." 14 | -------------------------------------------------------------------------------- /config/locales/client.es.yml: -------------------------------------------------------------------------------- 1 | es: 2 | js: 3 | composer: 4 | quick_placeholder: "Escribe un mensaje." 5 | quick_duplicate: "Tu conversación con este usuario está abierta en otra ventana." 6 | open_message_topic: "Navegar al tema de mensajes." 7 | toggler: 8 | minimize: "Minimizar" 9 | maximize: "Maximizar" 10 | user: 11 | no_quick_messages: "Ningún mensaje todavía." 12 | user_preferences: 13 | quick_messages_preference: "Activar mensajes en el menú de cabecera." 14 | -------------------------------------------------------------------------------- /config/locales/client.fi.yml: -------------------------------------------------------------------------------- 1 | fi: 2 | js: 3 | composer: 4 | quick_placeholder: "Kirjoita viesti." 5 | user: 6 | no_quick_messages: "Ei viestejä toistaiseksi." 7 | -------------------------------------------------------------------------------- /config/locales/client.fr.yml: -------------------------------------------------------------------------------- 1 | fr: 2 | js: 3 | composer: 4 | quick_placeholder: "Saisir un message." 5 | quick_duplicate: "Votre conversation avec cette personne est ouverte dans une autre fenêtre de messages." 6 | open_message_topic: "Naviguer vers le sujet du message." 7 | user: 8 | no_quick_messages: "Pas encore de message." 9 | user_preferences: 10 | quick_messages_preference: "Activer le menu messages dans l'en-tête." 11 | -------------------------------------------------------------------------------- /config/locales/client.pl.yml: -------------------------------------------------------------------------------- 1 | pl: 2 | js: 3 | composer: 4 | quick_placeholder: "Wpisz wiadomość." 5 | quick_duplicate: "Twój wątek z tym użytkownikiem otwarty jest w innym oknie wiadomośći." 6 | open_message_topic: "Przejdź do tematu wiadomości." 7 | toggler: 8 | minimize: "Minimalizuj" 9 | maximize: "Maksymalizuj" 10 | user: 11 | no_quick_messages: "Brak wiadomości." 12 | user_preferences: 13 | quick_messages_preference: "Włącz menu wiadomości w nagłówku." 14 | -------------------------------------------------------------------------------- /config/locales/client.pt.yml: -------------------------------------------------------------------------------- 1 | pt: 2 | js: 3 | composer: 4 | quick_placeholder: "Escreva uma mensagem." 5 | -------------------------------------------------------------------------------- /config/locales/server.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | site_settings: 3 | quick_message_enabled: "Enable Quick Messages." 4 | quick_message_user_preference: "Messages menu is enabled according to user interface setting 'Enable messages menu in header'." 5 | quick_message_required_badge: "Require a badge to access Quick Messages, for example require 'Certified' to reward users who completed the new user tutorial" 6 | quick_message_rate_limit_create: "After posting, users must wait (n) seconds before creating another quick message." 7 | quick_message_min_post_length: "Minimum allowed quick message length in characters." 8 | quick_message_icon: "Font Awesome name of Quick Messages icon. Font Awesome Cheatsheet." 9 | quick_message_mobile: "Enable Quick Messages on mobile." 10 | quick_message_integrated: "Enable integrated Quick Messages (only turn this on if you know what it does)." 11 | -------------------------------------------------------------------------------- /config/locales/server.es.yml: -------------------------------------------------------------------------------- 1 | es: 2 | site_settings: 3 | quick_message_enabled: "Activar Mensajes Rápidos." 4 | quick_message_user_preference: "El menú de mensajes estará activado conforme al ajuste de interfaz del usuario 'Activar mensajes en el menú de cabecera'." 5 | quick_message_required_badge: "Requerir una medalla para acceder a los mensajes rápidos, como por ejemplo la de 'Certificado' para premiar a los que completen el tutorial de inicio" 6 | quick_message_rate_limit_create: "Después de publicar, los usuarios deben esperar (n) segundos antes de crear otro mensaje rápido." 7 | quick_message_min_post_length: "Número mínimo de caracteres en los mensajes rápidos." 8 | quick_message_icon: "Nombre del icono de Font Awesome de los mensajes rápidos. Lista de Font Awesome." 9 | quick_message_mobile: "Habilitar Mensajes Rápidos en mobile." 10 | quick_message_integrated: "Habilitar la integración Mensajes Rápidos (solo activar ésto si sabes lo que hace)." 11 | -------------------------------------------------------------------------------- /config/locales/server.fi.yml: -------------------------------------------------------------------------------- 1 | fi: 2 | site_settings: 3 | quick_message_enabled: "Ota käyttöön pikaviestit." 4 | quick_message_rate_limit_create: "Lähettämisen jälkeen käyttäjän täytyy odottaa (n) sekuntia ennen uuden lähettämistä." 5 | quick_message_min_post_length: "Vähimmäismerkkimäärä pikaviestissä." 6 | -------------------------------------------------------------------------------- /config/locales/server.fr.yml: -------------------------------------------------------------------------------- 1 | fr: 2 | site_settings: 3 | quick_message_enabled: "Activer les messages rapides." 4 | quick_message_user_preference: "Le menu Messages est activé en fonction du paramètre d'interface utilisateur \"Activer le menu des messages dans l'en-tête\"." 5 | quick_message_required_badge: "Exiger un badge pour accéder aux messages rapides, par exemple requérir 'Certifié' pour récompenser les utilisateurs ayant terminé le nouveau tutoriel utilisateur" 6 | quick_message_rate_limit_create: "Après la publication, les utilisateurs doivent attendre (n) secondes avant de créer un autre message rapide." 7 | quick_message_min_post_length: "Longueur minimale autorisée des messages rapides en caractères." 8 | quick_message_icon: "Police Nom génial de l'icône Quick Messages. Police Awesome Cheatsheet." 9 | -------------------------------------------------------------------------------- /config/locales/server.pl.yml: -------------------------------------------------------------------------------- 1 | pl: 2 | site_settings: 3 | quick_message_enabled: "Włacz szybkie wiadomości." 4 | quick_message_user_preference: "Menu Wiadomości jest włączone zgodnie z ustawieniem interfejsu użytkownika „Włącz menu wiadomości w nagłówku'." 5 | quick_message_required_badge: "Wymagaj plakietki, aby uzyskać dostęp do szybkich wiadomości, na przykład wymagaj „Certyfikowanego”, aby nagradzać użytkowników, którzy ukończyli nowy samouczek użytkownika" 6 | quick_message_rate_limit_create: "Po wysłaniu, użytkownicy muszą czekać (n) sekund zanim utworzą kolejną szybką wiadomość." 7 | quick_message_min_post_length: "Minimalna dopuszczalna długość znaków szybkiej wiadomości." 8 | quick_message_icon: "Nazwa ikonki Font Awesome dla ikony wiadomości w nagłówku. Font Awesome Cheatsheet." 9 | quick_message_mobile: "Włącz szybkie wiadomośći na urządzeniach mobilnych." 10 | quick_message_integrated: "Włącz wbudowane szybkie wiadomości (włącz to tylko, jeśli wiesz, co to robi)." 11 | -------------------------------------------------------------------------------- /config/settings.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | quick_message_enabled: 3 | default: true 4 | client: true 5 | quick_message_user_preference: 6 | default: false 7 | client: true 8 | quick_message_rate_limit_create: 9 | default: 1 10 | client: true 11 | quick_message_min_post_length: 12 | default: 1 13 | client: true 14 | quick_message_icon: 15 | default: 'envelope' 16 | client: true 17 | quick_message_required_badge: 18 | default: 0 19 | client: true 20 | enum: SettingQuickMessagesBadge 21 | quick_message_mobile: 22 | default: true 23 | client: true 24 | quick_message_integrated: 25 | default: false 26 | client: true 27 | -------------------------------------------------------------------------------- /lib/setting_quick_messages_badge.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'enum_site_setting' 2 | 3 | # Adds an Enum type used in the Admin to select a badge by name. 4 | # Examples see: https://github.com/discourse/discourse/blob/master/config/site_settings.yml 5 | 6 | class SettingQuickMessagesBadge < EnumSiteSetting 7 | def self.valid_value?(any) 8 | true 9 | end 10 | 11 | def self.values 12 | @values ||= begin 13 | @values = Badge.where(enabled: true).map{ |b| { name: b.name, value: b.id } } 14 | @values.unshift(name: 'None', value: 0) 15 | end 16 | end 17 | 18 | def self.translate_names? 19 | false 20 | end 21 | end -------------------------------------------------------------------------------- /plugin.rb: -------------------------------------------------------------------------------- 1 | # name: discourse-quick-messages 2 | # about: A Discourse plugin that adds a menu and a chat-like compose for private messages 3 | # version: 0.1 4 | # authors: Angus McLeod 5 | # url: https://github.com/angusmcleod/discourse-quick-messages 6 | 7 | register_asset 'stylesheets/common/quick_menu.scss' 8 | register_asset 'stylesheets/common/quick_composer.scss' 9 | register_asset 'stylesheets/mobile/quick_mobile.scss', :mobile 10 | register_asset "stylesheets/colors.scss", :color_definitions 11 | require_relative 'lib/setting_quick_messages_badge' 12 | 13 | if respond_to?(:register_svg_icon) 14 | register_svg_icon "angle-up" 15 | register_svg_icon "angle-down" 16 | register_svg_icon "external-link" 17 | register_svg_icon "times" 18 | end 19 | 20 | enabled_site_setting :quick_message_enabled 21 | 22 | after_initialize do 23 | 24 | Post.register_custom_field_type('quick_message', :boolean) 25 | PostRevisor.track_topic_field(:custom_fields) 26 | 27 | DiscoursePluginRegistry.serialized_current_user_fields << "show_quick_messages" 28 | DiscoursePluginRegistry.serialized_current_user_fields << "quick_messages_access" 29 | User.register_custom_field_type("show_quick_messages", :boolean) 30 | User.register_custom_field_type("quick_messages_access", :boolean) 31 | add_to_serializer(:current_user, :show_quick_messages) { object.show_quick_messages } 32 | add_to_serializer(:current_user, :quick_messages_access) { object.quick_messages_access } 33 | register_editable_user_custom_field :show_quick_messages if defined? register_editable_user_custom_field 34 | register_editable_user_custom_field :quick_messages_access if defined? register_editable_user_custom_field 35 | 36 | SiteSetting.class_eval do 37 | def self.min_personal_message_post_length 38 | quick_message_min_post_length 39 | end 40 | 41 | def self.personal_message_post_length 42 | quick_message_min_post_length..max_post_length 43 | end 44 | end 45 | 46 | (defined?(PostValidator) == 'constant' ? PostValidator : Validators::PostValidator).class_eval do 47 | def private_message?(post) 48 | post.topic.try(:private_message?) || post.custom_fields['quick_message'] 49 | end 50 | end 51 | 52 | Post.class_eval do 53 | def default_rate_limiter 54 | return @rate_limiter if @rate_limiter.present? 55 | if custom_fields["quick_message"] || archetype == Archetype.private_message 56 | limit_key = "create_quick_message" 57 | max_setting = SiteSetting.send("quick_message_rate_limit_create") 58 | else 59 | limit_key = "create_#{self.class.name.underscore}" 60 | max_setting = if user && user.new_user? && SiteSetting.has_setting?("rate_limit_new_user_#{limit_key}") 61 | SiteSetting.send("rate_limit_new_user_#{limit_key}") 62 | else 63 | SiteSetting.send("rate_limit_#{limit_key}") 64 | end 65 | end 66 | @rate_limiter = RateLimiter.new(user, limit_key, 1, max_setting) 67 | end 68 | 69 | def limit_posts_per_day 70 | unless custom_fields["quick_message"] || archetype == Archetype.private_message 71 | if user && user.new_user_posting_on_first_day? && post_number && post_number > 1 72 | RateLimiter.new(user, "first-day-replies-per-day", SiteSetting.max_replies_in_first_day, 1.day.to_i) 73 | end 74 | end 75 | end 76 | end 77 | 78 | class ::User 79 | def show_quick_messages 80 | return false unless SiteSetting.quick_message_enabled 81 | return false unless quick_messages_access 82 | !SiteSetting.quick_message_user_preference || ActiveModel::Type::Boolean.new.cast(custom_fields['show_quick_messages']) 83 | end 84 | 85 | def quick_messages_access 86 | SiteSetting.quick_message_required_badge == 0 || self.badge_ids.include?(SiteSetting.quick_message_required_badge) 87 | end 88 | end 89 | 90 | module UserUnreadPrivateMessagesExtension 91 | def unread_private_messages 92 | if self.show_quick_messages 93 | @unread_pms ||= 94 | begin 95 | # perf critical, much more efficient than AR 96 | sql = <<~SQL 97 | SELECT COUNT(*) 98 | FROM notifications n 99 | LEFT JOIN topics t ON t.id = n.topic_id 100 | WHERE t.deleted_at IS NULL 101 | AND t.subtype = :subtype 102 | AND n.notification_type = :type 103 | AND n.user_id = :user_id 104 | AND NOT read 105 | SQL 106 | 107 | DB.query_single(sql, 108 | user_id: id, 109 | subtype: TopicSubtype.user_to_user, 110 | type: Notification.types[:private_message] 111 | )[0].to_i 112 | end 113 | else 114 | super 115 | end 116 | end 117 | end 118 | 119 | require_dependency 'user' 120 | class ::User 121 | prepend UserUnreadPrivateMessagesExtension 122 | end 123 | 124 | require_dependency 'topic_list_item_serializer' 125 | class ::TopicListItemSerializer 126 | attributes :message_excerpt, :subtype 127 | 128 | def message_excerpt 129 | if object.custom_fields["quick_message"] || object.archetype == Archetype.private_message 130 | raw = Post.where(topic_id: object.id, post_number: object.highest_post_number).pluck('raw')[0] 131 | raw.gsub!(/(\!\[)(.*?)\)/, "") 132 | raw.truncate(150) 133 | else 134 | return false 135 | end 136 | end 137 | 138 | def include_message_excerpt? 139 | !!message_excerpt 140 | end 141 | 142 | def subtype 143 | object.subtype 144 | end 145 | end 146 | 147 | TopicList.preloaded_custom_fields << "quick_message" if TopicList.respond_to? :preloaded_custom_fields 148 | end 149 | --------------------------------------------------------------------------------