conversations_new.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. /*
  2. * Copyright (C) 2013 - present Instructure, Inc.
  3. *
  4. * This file is part of Canvas.
  5. *
  6. * Canvas is free software: you can redistribute it and/or modify it under
  7. * the terms of the GNU Affero General Public License as published by the Free
  8. * Software Foundation, version 3 of the License.
  9. *
  10. * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
  11. * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
  12. * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
  13. * details.
  14. *
  15. * You should have received a copy of the GNU Affero General Public License along
  16. * with this program. If not, see <http://www.gnu.org/licenses/>.
  17. */
  18. import I18n from 'i18n!conversations'
  19. import $ from 'jquery'
  20. import _ from 'underscore'
  21. import Backbone from 'Backbone'
  22. import MessageCollection from 'compiled/collections/MessageCollection'
  23. import MessageListView from 'compiled/views/conversations/MessageListView'
  24. import MessageDetailView from 'compiled/views/conversations/MessageDetailView'
  25. import MessageFormDialog from 'compiled/views/conversations/MessageFormDialog'
  26. import SubmissionCommentFormDialog from 'compiled/views/conversations/SubmissionCommentFormDialog'
  27. import InboxHeaderView from 'compiled/views/conversations/InboxHeaderView'
  28. import deparam from 'compiled/util/deparam'
  29. import CourseCollection from 'compiled/collections/CourseCollection'
  30. import FavoriteCourseCollection from 'compiled/collections/FavoriteCourseCollection'
  31. import GroupCollection from 'compiled/collections/GroupCollection'
  32. import 'compiled/behaviors/unread_conversations'
  33. import 'jquery.disableWhileLoading'
  34. const ConversationsRouter = Backbone.Router.extend({
  35. routes: {
  36. '': 'index',
  37. 'filter=:state': 'filter'
  38. },
  39. sendingCount: 0,
  40. initialize () {
  41. ['onSelected', 'selectConversation', 'onSubmissionReply', 'onReply', 'onReplyAll', 'onArchive',
  42. 'onDelete', 'onCompose', 'onMarkUnread', 'onMarkRead', 'onForward', 'onStarToggle', 'onFilter',
  43. 'onCourse', '_replyFromRemote', '_initViews', 'onSubmit', 'onAddMessage', 'onSubmissionAddMessage',
  44. 'onSearch', 'onKeyDown'].forEach(method => this[method] = this[method].bind(this))
  45. const dfd = this._initCollections()
  46. this._initViews()
  47. this._attachEvents()
  48. if (this._isRemoteLaunch()) return dfd.then(this._replyFromRemote)
  49. },
  50. // Public: Pull a value from the query string.
  51. //
  52. // name - The name of the query string param.
  53. //
  54. // Returns a string value or null.
  55. param (name) {
  56. const regex = new RegExp(`${name}=([^&]+)`)
  57. const value = window.location.search.match(regex)
  58. if (value) return decodeURIComponent(value[1])
  59. return null
  60. },
  61. // Internal: Perform a batch update of all selected messages.
  62. //
  63. // event - The event to batch (e.g. 'star' or 'destroy').
  64. // fn - A function called with each selected message. Used for side-effecting.
  65. //
  66. // Returns an array of impacted message IDs.
  67. batchUpdate (event, fn = $.noop) {
  68. const messages = _.map(this.list.selectedMessages, (message) => {
  69. fn.call(this, message)
  70. return message.get('id')
  71. })
  72. $.ajaxJSON('/api/v1/conversations', 'PUT', {
  73. 'conversation_ids[]': messages,
  74. event
  75. })
  76. if (event === 'destroy') this.list.selectedMessages = []
  77. if (event === 'archive' && this.filters.type !== 'sent') this.list.selectedMessages = []
  78. if (event === 'mark_as_read' && this.filters.type === 'archived') this.list.selectedMessages = []
  79. if (event === 'unstar' && this.filters.type === 'starred') this.list.selectedMessages = []
  80. return messages
  81. },
  82. lastFetch: null,
  83. onSelected (model) {
  84. if (this.lastFetch) this.lastFetch.abort()
  85. this.header.onModelChange(null, this.model)
  86. this.detail.onModelChange(null, this.model)
  87. this.model = model
  88. const messages = this.list.selectedMessages
  89. if (messages.length === 0) {
  90. delete this.detail.model
  91. return this.detail.render()
  92. } else if (messages.length > 1) {
  93. delete this.detail.model
  94. messages[0].set('canArchive', this.filters.type !== 'sent')
  95. this.detail.onModelChange(messages[0], null)
  96. this.detail.render({batch: true})
  97. this.header.onModelChange(messages[0], null)
  98. this.header.toggleReplyBtn(true)
  99. this.header.toggleReplyAllBtn(true)
  100. this.header.hideForwardBtn(true)
  101. return
  102. } else {
  103. model = this.list.selectedMessage()
  104. if (model.get('messages')) {
  105. this.selectConversation(model)
  106. } else {
  107. this.lastFetch = model.fetch({
  108. data: {
  109. include_participant_contexts: false,
  110. include_private_conversation_enrollments: false
  111. },
  112. success: this.selectConversation
  113. })
  114. this.detail.$el.disableWhileLoading(this.lastFetch)
  115. }
  116. }
  117. },
  118. selectConversation (model) {
  119. if (model) model.set('canArchive', this.filters.type !== 'sent')
  120. this.header.onModelChange(model, null)
  121. this.detail.onModelChange(model, null)
  122. this.detail.render()
  123. },
  124. onSubmissionReply () {
  125. this.submissionReply.show(this.detail.model, {trigger: $('#submission-reply-btn')})
  126. },
  127. onReply (message, trigger) {
  128. if (this.detail.model.get('for_submission')) {
  129. this.onSubmissionReply()
  130. } else {
  131. this._delegateReply(message, 'reply', trigger)
  132. }
  133. },
  134. onReplyAll (message, trigger) {
  135. this._delegateReply(message, 'replyAll', trigger)
  136. },
  137. _delegateReply (message, type, trigger) {
  138. this.compose.show(this.detail.model, {to: type, trigger, message})
  139. },
  140. onArchive (focusNext, trigger) {
  141. const action = this.list.selectedMessage().get('workflow_state') === 'archived' ? 'mark_as_read' : 'archive'
  142. const confirmMessage = action === 'archive'
  143. ? I18n.t({
  144. one: 'Are you sure you want to archive your copy of this conversation?',
  145. other: 'Are you sure you want to archive your copies of these conversations?'
  146. }, {count: this.list.selectedMessages.length})
  147. : I18n.t({
  148. one: 'Are you sure you want to unarchive this conversation?',
  149. other: 'Are you sure you want to unarchive these conversations?'
  150. }, {count: this.list.selectedMessages.length})
  151. if (!confirm(confirmMessage)) { // eslint-disable-line no-alert
  152. $(trigger).focus()
  153. return
  154. }
  155. const messages = this.batchUpdate(action, function (m) {
  156. const newState = action === 'mark_as_read' ? 'read' : 'archived'
  157. m.set('workflow_state', newState)
  158. this.header.onArchivedStateChange(m)
  159. })
  160. if (_.include(['inbox', 'archived'], this.filters.type)) {
  161. this.list.collection.remove(messages)
  162. this.selectConversation(null)
  163. }
  164. let $focusNext = $(focusNext)
  165. if ($focusNext.length === 0) {
  166. $focusNext = $('#compose-message-recipients')
  167. }
  168. $focusNext.focus()
  169. },
  170. onDelete (focusNext, trigger) {
  171. const confirmMsg = I18n.t({
  172. one: 'Are you sure you want to delete your copy of this conversation? This action cannot be undone.',
  173. other: 'Are you sure you want to delete your copy of these conversations? This action cannot be undone.'
  174. }, {count: this.list.selectedMessages.length})
  175. if (!confirm(confirmMsg)) {
  176. $(trigger).focus()
  177. return
  178. }
  179. const delmsg = I18n.t({
  180. one: 'Message Deleted!',
  181. other: 'Messages Deleted!'
  182. }, {count: this.list.selectedMessages.length})
  183. const messages = this.batchUpdate('destroy')
  184. delete this.detail.model
  185. this.list.collection.remove(messages)
  186. this.header.updateUi(null)
  187. $.flashMessage(delmsg)
  188. this.detail.render()
  189. let $focusNext = $(focusNext)
  190. if ($focusNext.length === 0) {
  191. $focusNext = $('#compose-message-recipients')
  192. }
  193. $focusNext.focus()
  194. },
  195. onCompose (e) {
  196. this.compose.show(null, {trigger: '#compose-btn'})
  197. },
  198. index () {
  199. return this.filter('')
  200. },
  201. filter (state) {
  202. const filters = this.filters = deparam(state)
  203. this.header.displayState(filters)
  204. this.selectConversation(null)
  205. this.list.selectedMessages = []
  206. this.list.collection.reset()
  207. if (filters.type === 'submission_comments') {
  208. _.each(
  209. ['scope', 'filter', 'filter_mode', 'include_private_conversation_enrollments'],
  210. this.list.collection.deleteParam,
  211. this.list.collection
  212. )
  213. this.list.collection.url = '/api/v1/users/self/activity_stream'
  214. this.list.collection.setParam('asset_type', 'Submission')
  215. if (filters.course) {
  216. this.list.collection.setParam('context_code', filters.course)
  217. } else {
  218. this.list.collection.deleteParam('context_code')
  219. }
  220. } else {
  221. _.each(
  222. ['context_code', 'asset_type', 'submission_user_id'],
  223. this.list.collection.deleteParam,
  224. this.list.collection
  225. )
  226. this.list.collection.url = '/api/v1/conversations'
  227. this.list.collection.setParam('scope', filters.type)
  228. this.list.collection.setParam('filter', this._currentFilter())
  229. this.list.collection.setParam('filter_mode', 'and')
  230. this.list.collection.setParam('include_private_conversation_enrollments', false)
  231. }
  232. this.list.collection.fetch()
  233. this.compose.setDefaultCourse(filters.course)
  234. },
  235. onMarkUnread () {
  236. return this.batchUpdate('mark_as_unread', m => m.toggleReadState(false))
  237. },
  238. onMarkRead () {
  239. return this.batchUpdate('mark_as_read', m => m.toggleReadState(true))
  240. },
  241. onForward (message, trigger) {
  242. let model
  243. if (message) {
  244. model = this.detail.model.clone()
  245. model.handleMessages()
  246. model.set('messages', _.filter(model.get('messages'), m =>
  247. m.id === message.id ||
  248. (_.include(m.participating_user_ids, message.author_id) && m.created_at < message.created_at)
  249. ))
  250. } else {
  251. model = this.detail.model
  252. }
  253. this.compose.show(model, {to: 'forward', trigger})
  254. },
  255. onStarToggle () {
  256. const event = this.list.selectedMessage().get('starred') ? 'unstar' : 'star'
  257. const messages = this.batchUpdate(event, m => m.toggleStarred(event === 'star'))
  258. if (this.filters.type === 'starred') {
  259. if (event === 'unstar') this.selectConversation(null)
  260. return this.list.collection.remove(messages)
  261. }
  262. },
  263. onFilter (filters) {
  264. // Update the hash. Replace if there isn't already a hash - we're in the
  265. // process of loading the page if so, and we wouldn't want to create a
  266. // spurious history entry by not doing so.
  267. const existingHash = window.location.hash && window.location.hash.substring(1)
  268. return this.navigate(`filter=${$.param(filters)}`, {trigger: true, replace: !existingHash})
  269. },
  270. onCourse (course) {
  271. return this.list.updateCourse(course)
  272. },
  273. // Internal: Determine if a reply was launched from another URL.
  274. //
  275. // Returns a boolean.
  276. _isRemoteLaunch () {
  277. return !!window.location.search.match(/user_id/)
  278. },
  279. // Internal: Open and populate the new message dialog from a remote launch.
  280. //
  281. // Returns nothing.
  282. _replyFromRemote () {
  283. this.compose.show(null, {
  284. user: {
  285. id: this.param('user_id'),
  286. name: this.param('user_name')
  287. },
  288. context: this.param('context_id'),
  289. remoteLaunch: true
  290. })
  291. },
  292. _initCollections () {
  293. const gc = new GroupCollection()
  294. gc.setParam('include[]', 'can_message')
  295. this.courses = {
  296. favorites: new FavoriteCourseCollection(),
  297. all: new CourseCollection(),
  298. groups: gc
  299. }
  300. return this.courses.favorites.fetch()
  301. },
  302. _initViews () {
  303. this._initListView()
  304. this._initDetailView()
  305. this._initHeaderView()
  306. this._initComposeDialog()
  307. this._initSubmissionCommentReplyDialog()
  308. },
  309. _attachEvents () {
  310. this.list.collection.on('change:selected', this.onSelected)
  311. this.header.on('compose', this.onCompose)
  312. this.header.on('reply', this.onReply)
  313. this.header.on('reply-all', this.onReplyAll)
  314. this.header.on('archive', this.onArchive)
  315. this.header.on('delete', this.onDelete)
  316. this.header.on('filter', this.onFilter)
  317. this.header.on('course', this.onCourse)
  318. this.header.on('mark-unread', this.onMarkUnread)
  319. this.header.on('mark-read', this.onMarkRead)
  320. this.header.on('forward', this.onForward)
  321. this.header.on('star-toggle', this.onStarToggle)
  322. this.header.on('search', this.onSearch)
  323. this.header.on('submission-reply', this.onReply)
  324. this.compose.on('close', this.onCloseCompose)
  325. this.compose.on('addMessage', this.onAddMessage)
  326. this.compose.on('addMessage', this.list.updateMessage)
  327. this.compose.on('newConversations', this.onNewConversations)
  328. this.compose.on('submitting', this.onSubmit)
  329. this.submissionReply.on('addMessage', this.onSubmissionAddMessage)
  330. this.submissionReply.on('submitting', this.onSubmit)
  331. this.detail.on('reply', this.onReply)
  332. this.detail.on('reply-all', this.onReplyAll)
  333. this.detail.on('forward', this.onForward)
  334. this.detail.on('star-toggle', this.onStarToggle)
  335. this.detail.on('delete', this.onDelete)
  336. this.detail.on('archive', this.onArchive)
  337. $(document).ready(this.onPageLoad)
  338. $(window).keydown(this.onKeyDown)
  339. },
  340. onPageLoad (e) {
  341. $('#main').css({display: 'block'})
  342. },
  343. onSubmit (dfd) {
  344. this._incrementSending(1)
  345. return dfd.always(() => this._incrementSending(-1))
  346. },
  347. onAddMessage (message, conversation) {
  348. const model = this.list.collection.get(conversation.id)
  349. if (model && model.get('messages')) {
  350. message.context_name = model.messageCollection.last().get('context_name')
  351. model.get('messages').unshift(message)
  352. model.trigger('change:messages')
  353. if (model === this.detail.model) {
  354. return this.detail.render()
  355. }
  356. }
  357. },
  358. onSubmissionAddMessage (message, submission) {
  359. const model = this.list.collection.findWhere({submission_id: submission.id})
  360. if (model && model.get('messages')) {
  361. model.get('messages').unshift(message)
  362. model.trigger('change:messages')
  363. if (model === this.detail.model) {
  364. return this.detail.render()
  365. }
  366. }
  367. },
  368. onNewConversations (conversations) {},
  369. _incrementSending (increment) {
  370. this.sendingCount += increment
  371. return this.header.toggleSending(this.sendingCount > 0)
  372. },
  373. _currentFilter () {
  374. let filter = this.searchTokens || []
  375. if (this.filters.course) filter = filter.concat(this.filters.course)
  376. return filter
  377. },
  378. onSearch (tokens) {
  379. this.list.collection.reset()
  380. this.searchTokens = tokens.length ? tokens : null
  381. if (this.filters.type === 'submission_comments') {
  382. let match
  383. if (this.searchTokens && (match = this.searchTokens[0].match(/^user_(\d+)$/))) {
  384. this.list.collection.setParam('submission_user_id', match[1])
  385. } else {
  386. this.list.collection.deleteParam('submission_user_id')
  387. }
  388. } else {
  389. this.list.collection.setParam('filter', this._currentFilter())
  390. }
  391. delete this.detail.model
  392. this.list.selectedMessages = []
  393. this.detail.render()
  394. return this.list.collection.fetch()
  395. },
  396. _initListView () {
  397. this.list = new MessageListView({
  398. collection: new MessageCollection(),
  399. el: $('.message-list'),
  400. scrollContainer: $('.message-list-scroller'),
  401. buffer: 50
  402. })
  403. this.list.render()
  404. },
  405. _initDetailView () {
  406. this.detail = new MessageDetailView({el: $('.message-detail')})
  407. this.detail.render()
  408. },
  409. _initHeaderView () {
  410. this.header = new InboxHeaderView({el: $('header.panel'), courses: this.courses})
  411. this.header.render()
  412. },
  413. _initComposeDialog () {
  414. this.compose = new MessageFormDialog({
  415. courses: this.courses,
  416. folderId: ENV.CONVERSATIONS.ATTACHMENTS_FOLDER_ID,
  417. account_context_code: ENV.CONVERSATIONS.ACCOUNT_CONTEXT_CODE
  418. })
  419. },
  420. _initSubmissionCommentReplyDialog () {
  421. this.submissionReply = new SubmissionCommentFormDialog()
  422. },
  423. onKeyDown (e) {
  424. const nodeName = e.target.nodeName.toLowerCase()
  425. if (nodeName === 'input' || nodeName === 'textarea') return
  426. const ctrl = e.ctrlKey || e.metaKey
  427. if ((e.which === 65) && ctrl) { // ctrl-a
  428. e.preventDefault()
  429. this.list.selectAll()
  430. }
  431. }
  432. })
  433. window.conversationsRouter = new ConversationsRouter()
  434. Backbone.history.start()