PageRenderTime 34ms CodeModel.GetById 19ms RepoModel.GetById 1ms app.codeStats 0ms

/tai/service/static/js/chat.js

https://bitbucket.org/nakamura/tai
JavaScript | 1485 lines | 1240 code | 201 blank | 44 comment | 118 complexity | c60762145599ae246f3046132c524ea3 MD5 | raw file
  1. /* jshint supernew: true */
  2. // TODO: periodically send NAMES message to update user list
  3. if (!window.console) window.console = {};
  4. if (!window.console.log) window.console.log = function() {};
  5. var connection = new function() {
  6. var self = this;
  7. _.extend(this, {
  8. socket: null,
  9. onopen: function() {},
  10. onerror: function() {},
  11. onclose: function() {},
  12. onmessage: function() {},
  13. initialReconnectTimeout: 5 * 1000,
  14. reconnectTimeout: null,
  15. start: function() {
  16. self.socket = new SockJS(config.sockURL);
  17. self.socket.onopen = function() {
  18. console.log('Connected.');
  19. self.reconnectTimeout = null;
  20. var message = {};
  21. message[config.sessionKeyName] = getCookie(config.sessionKeyName);
  22. self.socket.send(JSON.stringify(message));
  23. if (self.onopen) {
  24. self.onopen();
  25. }
  26. };
  27. self.socket.onerror = function() {
  28. console.log('Failed to connect server.');
  29. if (self.onerror) {
  30. self.onerror();
  31. }
  32. };
  33. self.socket.onclose = function() {
  34. if (!self.reconnectTimeout) {
  35. self.reconnectTimeout = self.initialReconnectTimeout;
  36. } else {
  37. self.reconnectTimeout = _.min([self.reconnectTimeout * 2, 60 * 1000]);
  38. }
  39. console.log('Server connection closed. Reconnecting in ' + self.reconnectTimeout / 1000 + ' seconds ...');
  40. if (self.onclose) {
  41. self.onclose();
  42. }
  43. window.setTimeout(self.start, self.reconnectTimeout);
  44. };
  45. self.socket.onmessage = function(event) {
  46. console.log(event.data);
  47. var message = event.data;
  48. if (_.isString(message)) {
  49. message = JSON.parse(message);
  50. }
  51. if (self.onmessage) {
  52. self.onmessage(message);
  53. }
  54. };
  55. },
  56. stop: function() {
  57. if (self.socket) {
  58. self.socket.close();
  59. self.socket = null;
  60. }
  61. },
  62. send: function (command, params) {
  63. self.socket.send(JSON.stringify({command: command, params: params}));
  64. },
  65. sendMessage: function (message) {
  66. self.send(message.command, message.params);
  67. }
  68. });
  69. };
  70. var notifier = new function() {
  71. // display `*` on window title, if window don't have focus
  72. // also use webkitNotifications if exists
  73. var self = {
  74. originalTitle: undefined,
  75. windowHasFocus: true,
  76. notify: function(icon, title, body) {
  77. if (window.webkitNotifications && window.webkitNotifications.checkPermission() === 0) {
  78. var n = window.webkitNotifications.createNotification(icon, title, body);
  79. n.onclick = function() {
  80. // TODO: focus browser window
  81. // close notification
  82. n.cancel();
  83. };
  84. n.show();
  85. }
  86. if (!self.windowHasFocus && self.originalTitle === undefined) {
  87. self.originalTitle = document.title;
  88. document.title = '* ' + document.title;
  89. }
  90. },
  91. resetTitle: function() {
  92. if (self.originalTitle !== undefined) {
  93. document.title = self.originalTitle;
  94. self.originalTitle = undefined;
  95. }
  96. }
  97. };
  98. $(window).on('focus', function() {
  99. self.windowHasFocus = true;
  100. self.resetTitle();
  101. });
  102. $(window).on('blur', function() {
  103. self.windowHasFocus = false;
  104. });
  105. return self;
  106. };
  107. // Models
  108. // ======
  109. // IRC Message model
  110. var Message = Backbone.Model.extend({
  111. // XXX: I want to these methods acting as a property
  112. getNickname: function() {
  113. // FIXME: if this message is from server, prefix doesn't contain "!"
  114. return this.get('prefix').split('!')[0];
  115. },
  116. getTarget: function() {
  117. switch(this.get('command')) {
  118. case 'JOIN':
  119. case 'NOTICE':
  120. case 'PART':
  121. case 'PRIVMSG':
  122. case 'TOPIC':
  123. case 'RPL_QUERY':
  124. return this.get('params')[0];
  125. case 'RPL_NAMREPLY':
  126. case 'RPL_TOPIC':
  127. return this.get('params')[1];
  128. default:
  129. console.log('call getTarget for unknown command:', this.get('command'));
  130. return null;
  131. }
  132. },
  133. set: function() {
  134. var ret = Backbone.Model.prototype.set.apply(this, arguments);
  135. // as we don't have database to save it,
  136. // trigger change event at here.
  137. // XXX: may need to pass more arguments
  138. this.trigger('change');
  139. return ret;
  140. }
  141. });
  142. var MessageCollection = Backbone.Collection.extend({
  143. model: Message,
  144. comparator: function(message) {
  145. return message.get('id');
  146. }
  147. });
  148. var ChannelMap = Backbone.Model.extend({
  149. unread: function() {
  150. return sum(_.invoke(this.toJSON(), 'get', 'unread'));
  151. },
  152. set: function(key, val, options) {
  153. if (key === null) {
  154. return this;
  155. }
  156. var attrs;
  157. if (typeof key === 'object') {
  158. attrs = key;
  159. options = val;
  160. } else {
  161. (attrs = {})[key] = val;
  162. }
  163. var self = this;
  164. // proxy events from channels
  165. _.each(attrs, function(channel, name) {
  166. var current = self.get(name);
  167. if (current) {
  168. current.off(null, null, self);
  169. }
  170. channel.on('all', function(eventName) {
  171. self.trigger(eventName, channel);
  172. }, self);
  173. });
  174. return Backbone.Model.prototype.set.apply(this, arguments);
  175. },
  176. unset: function(attr, options) {
  177. var current = this.get(attr);
  178. if (current) {
  179. current.off(null, null, this);
  180. }
  181. return Backbone.Model.prototype.unset.apply(this, attr, options);
  182. }
  183. });
  184. // IRC Channel model
  185. var Channel = Backbone.Model.extend({
  186. defaults: {
  187. name: null,
  188. topic: null,
  189. isJoined: false,
  190. unread: 0,
  191. userNames: [],
  192. messages: null
  193. },
  194. initialize: function() {
  195. if (!this.get('messages')) {
  196. this.set('messages', new MessageCollection());
  197. }
  198. },
  199. // send QUERY message to the server
  200. query: function(options) {
  201. if (!this.get('isJoined')) {
  202. console.log("you can't query to not joined channel", this.get('name'));
  203. return false;
  204. }
  205. connection.send('QUERY', [this.get('name'), JSON.stringify(options)]);
  206. },
  207. isChannel: function() {
  208. return this.get('name').match(/^[#&]/);
  209. },
  210. isUser: function() {
  211. return !this.isChannel();
  212. },
  213. addNames: function(names) {
  214. var currentNames = this.get('userNames');
  215. var newNames = _.union(currentNames, names);
  216. newNames.sort();
  217. if (newNames != currentNames) {
  218. this.set('userNames', newNames);
  219. }
  220. },
  221. addName: function(name) {
  222. this.addNames([name]);
  223. },
  224. removeName: function(name) {
  225. var names = this.get('userNames');
  226. if (_.contains(names, name)) {
  227. names.splice(_.indexOf(names, name), 1);
  228. // because we modify names array in place,
  229. // we need to trigger `change` event manually
  230. this.trigger('change:userNames', this);
  231. }
  232. }
  233. });
  234. var ApplicationState = Backbone.Model.extend({
  235. defaults: {
  236. currentChannel: null
  237. }
  238. });
  239. var Bookmarks = Backbone.Model.extend({
  240. initialize: function() {
  241. this.on('change', function() {
  242. _.each(this.changed, function(value, key) {
  243. connection.send('BOOKMARK', [key, value]);
  244. });
  245. });
  246. },
  247. get: function(attr) {
  248. var value = Backbone.Model.prototype.get.call(this, attr);
  249. if (value === undefined) {
  250. // because message ID start from 0,
  251. // -1 is the value always smaller than message ID.
  252. value = -1;
  253. }
  254. return value;
  255. },
  256. validate: function(attrs) {
  257. var keys = _.keys(attrs);
  258. for (i=0; i<keys.length; i++) {
  259. var key = keys[i];
  260. var value = attrs[key];
  261. if (! _.isNumber(value)) {
  262. var error = "only numbers are allowed, but you pass " + key + ": " + value;
  263. console.log(error);
  264. return error;
  265. }
  266. }
  267. },
  268. _validate: function(attrs, options) {
  269. // because I don't call `save` method,
  270. // validate attrs on `set` using `validate` option
  271. options = _.extend({validate: true}, options);
  272. return Backbone.Model.prototype._validate.call(this, attrs, options);
  273. }
  274. });
  275. // Views
  276. // =====
  277. var ChannelListView = Backbone.View.extend({
  278. template: Mustache.compile($('#template-channel-name').html()),
  279. listEl: '#channel-list',
  280. unreadIcon: '#channels-have-unreads',
  281. defaults: {
  282. 'activeChannel': null
  283. },
  284. initialize: function() {
  285. this.$listEl = this.$(this.listEl);
  286. this.$unreadIcon = $(this.unreadIcon);
  287. },
  288. render: function() {
  289. this.stopListening();
  290. this.$listEl.empty();
  291. this.listenTo(this.model, 'change', function(model) {
  292. if (model === this.model) {
  293. this.render();
  294. }
  295. });
  296. var sortedChannels = _.sortBy(this.model.toJSON(), function(c) { c.get('name'); });
  297. _.each(sortedChannels, function(channel) {
  298. var div = $('<div/>').html(this.template(channel.toJSON()));
  299. this.$listEl.append(div);
  300. div.addClass('channel-name-line');
  301. div.on('click', function() {
  302. var channelName = this.getElementsByClassName('channel-name')[0].name;
  303. window.location.hash = '#channel/' + channelName;
  304. return false;
  305. });
  306. if (channel.get('name') == this.activeChannel) {
  307. div.addClass('active');
  308. }
  309. div.find('.part-btn').tooltip({
  310. title: tr['click to part from this channel'],
  311. placement: 'right'
  312. });
  313. div.find('.part-link').on('click', function() {
  314. connection.send('PART', [channel.get('name')]);
  315. return false;
  316. });
  317. this.listenTo(channel, 'change', function() {
  318. div.html(this.template(channel.toJSON()));
  319. if (channel.get('name') == this.activeChannel) {
  320. div.addClass('active');
  321. }
  322. div.find('.part-btn').tooltip({
  323. title: tr['click to part from this channel'],
  324. placement: 'right'
  325. });
  326. div.find('.part-link').on('click', function() {
  327. connection.send('PART', [channel.get('name')]);
  328. return false;
  329. });
  330. this.updateUnreadIcon();
  331. });
  332. }, this);
  333. this.updateUnreadIcon();
  334. return this;
  335. },
  336. updateUnreadIcon: function() {
  337. if (this.model.unread()) {
  338. this.$unreadIcon.show();
  339. } else {
  340. this.$unreadIcon.hide();
  341. }
  342. },
  343. setActiveChannel: function(channelName) {
  344. var self = this;
  345. this.$listEl.find('.channel-name').each(function() {
  346. if (this.name == channelName) {
  347. this.parentNode.className = "channel-name-line active";
  348. self.scrollChannelIntoViewIfNeeded();
  349. } else {
  350. this.parentNode.className = "channel-name-line";
  351. }
  352. });
  353. this.activeChannel = channelName;
  354. },
  355. isActive: function() {
  356. return this.$el.hasClass('active');
  357. },
  358. _getSelected: function() {
  359. var selected = this.$el.find('.channel-name-line.selected');
  360. if (selected.length) {
  361. return selected;
  362. }
  363. var active = this.$el.find('.channel-name-line.active');
  364. if (active.length) {
  365. return active;
  366. }
  367. return null;
  368. },
  369. selectNext: function() {
  370. var selected = this._getSelected();
  371. var next;
  372. if (selected) {
  373. next = selected.next();
  374. } else {
  375. selected = next = this.$el.find('.channel-name-line').first();
  376. }
  377. if (next.length) {
  378. selected.removeClass('selected');
  379. next.addClass('selected');
  380. this.scrollChannelIntoViewIfNeeded(next[0]);
  381. }
  382. },
  383. selectPrev: function() {
  384. var selected = this._getSelected();
  385. var prev;
  386. if (selected) {
  387. prev = selected.prev();
  388. } else {
  389. selected = prev = this.$el.find('.channel-name-line').last();
  390. }
  391. if (prev.length) {
  392. selected.removeClass('selected');
  393. prev.addClass('selected');
  394. this.scrollChannelIntoViewIfNeeded(prev[0]);
  395. }
  396. },
  397. scrollChannelIntoViewIfNeeded: function(elem) {
  398. if (elem === undefined) {
  399. var $elem = this._getSelected();
  400. if ($elem === null) {
  401. return;
  402. }
  403. elem = $elem[0];
  404. }
  405. var parent = $('.tab-content')[0];
  406. var overTop = elem.offsetTop - parent.offsetTop < parent.scrollTop;
  407. var overBottom = (elem.offsetTop - parent.offsetTop + elem.clientHeight) > (parent.scrollTop + parent.clientHeight);
  408. var alignWithTop = overTop && !overBottom;
  409. if (overTop || overBottom) {
  410. elem.scrollIntoView(alignWithTop);
  411. }
  412. },
  413. openSelected: function() {
  414. var selected = this._getSelected();
  415. if (selected.length) {
  416. var channelName = selected.find('.channel-name').attr('name');
  417. window.location.hash = 'channel/' + channelName;
  418. }
  419. }
  420. });
  421. var UserListView = ChannelListView.extend({
  422. template: Mustache.compile($('#template-user-name').html()),
  423. listEl: '#user-list',
  424. unreadIcon: '#users-have-unreads'
  425. });
  426. var ChannelView = Backbone.View.extend({
  427. events: {
  428. 'submit #message-form': 'submitMessage',
  429. 'keydown #message-form': 'messageFormKeyPressed',
  430. 'click #upload-button': 'toggleUploadForm',
  431. 'submit #paste-form': 'submitPaste',
  432. 'click #edit-topic-icon': 'editTopic',
  433. 'click #save-topic-btn': 'saveTopic',
  434. 'click #cancel-edit-topic-btn': 'stopEditingTopic'
  435. },
  436. templateChannelUserName: Mustache.compile($('#template-channel-user-name').html()),
  437. templateArchiveLink: Mustache.compile($('#template-archive-link').html()),
  438. scrollStep: 100,
  439. initialize: function() {
  440. if (!this.model) {
  441. this.model = new Backbone.Model();
  442. }
  443. this.model.on('change:currentChannel', this.openChannel, this);
  444. this.bookmarks = this.attributes.bookmarks;
  445. this.bookmarkedId = -1;
  446. this.$messagesAndArchiveLinkEl = this.$('#messages-and-archive-link');
  447. this.$archiveLinkEl = this.$('#archive-link');
  448. this.$containerEl = this.$('#message-container');
  449. this.$namesEl = $('#channel-names-list');
  450. this.$channelTitle = this.$('#channel-title');
  451. this.$channelTopic = this.$('#channel-topic');
  452. this.$channelTopicContainer = this.$('#channel-topic-container');
  453. this.$channelTopicInput = this.$('#channel-topic-input');
  454. this.$messageForm = this.$('#message-form');
  455. this.$('#channel-title-container .icon-edit').tooltip({
  456. title: tr['edit topic of this channel'],
  457. placement: 'right'
  458. });
  459. var self = this;
  460. this.$('#message-text').typeahead({source: function() {
  461. var channel = self.model.get('currentChannel');
  462. if (channel) {
  463. return _.map(channel.get('userNames'), function(name) {
  464. return name + ': ';
  465. });
  466. }
  467. }});
  468. },
  469. openChannel: function() {
  470. this.stopListening();
  471. var channel = this.model.get('currentChannel');
  472. if (!channel) {
  473. this.$channelTitle.empty();
  474. this.$channelTopic.empty();
  475. this.$archiveLinkEl.empty();
  476. this.$archiveLinkEl.hide();
  477. this.$containerEl.empty();
  478. this.$namesEl.empty();
  479. this.$messageForm.children().attr('disabled', true);
  480. this.$channelTopicContainer.hide();
  481. return;
  482. }
  483. // remember bookmarked ID when channel is opened
  484. // to detect that latter received RPL_QUERY messages are unread or not
  485. this.bookmarkedId = this.bookmarks.get(channel.get('name'));
  486. this.renderTitle();
  487. this.$containerEl.empty();
  488. this.$archiveLinkEl.empty();
  489. this.$archiveLinkEl.hide();
  490. var messages = channel.get('messages');
  491. if (messages.length) {
  492. messages.each(function(message) {
  493. this.insertMessageView(new MessageView({model: message}));
  494. }, this);
  495. this.scrollToBottom();
  496. var firstId = messages.first().get('id');
  497. if (messages.length < 50 && firstId > 0) {
  498. channel.query({limit: 50, untilId: firstId, reverse: true});
  499. }
  500. this.bookmarks.set(channel.get('name'), messages.last().get('id'));
  501. } else {
  502. channel.query({limit: 50, reverse: true});
  503. }
  504. this.renderNames(channel);
  505. this.$messageForm.children().attr('disabled', false);
  506. this.$channelTopicContainer.show();
  507. channel.set('unread', 0);
  508. this.listenTo(channel, 'change:userNames', this.renderNames);
  509. this.listenTo(channel, 'change:topic', this.renderTitle);
  510. this.listenTo(messages, 'add', this.messageAdded);
  511. this.listenTo(messages, 'bulkAdd', this.messageBulkAdded);
  512. },
  513. renderTitle: function() {
  514. var channel = this.model.get('currentChannel');
  515. var title = channel.get('name');
  516. var topic = channel.get('topic');
  517. this.$channelTitle.text(title);
  518. if (topic) {
  519. topic = ' - ' + topic;
  520. }
  521. this.$channelTopic.text(topic);
  522. },
  523. renderNames: function(channel) {
  524. var names = channel.get('userNames');
  525. this.$namesEl.empty();
  526. _.each(names, function(name) {
  527. this.$namesEl.append(this.templateChannelUserName({name: name}));
  528. }, this);
  529. },
  530. messageAdded: function(message) {
  531. var doScroll = this.isBottom();
  532. var channelName = this.model.get('currentChannel').get('name');
  533. if (message.getTarget() == channelName ||
  534. (message.getTarget() == config.myNick && message.getNickname() == channelName)) {
  535. this.insertMessageView(new MessageView({model: message}));
  536. } else {
  537. console.log('ERROR: channel name mismatch: ', message.getTarget(), channelName);
  538. return;
  539. }
  540. if (doScroll) {
  541. this.scrollToBottom(true);
  542. }
  543. var messageId = message.get('id');
  544. if (this.bookmarks.get(channelName) < messageId) {
  545. this.bookmarks.set(channelName, messageId);
  546. }
  547. },
  548. messageBulkAdded: function(messages) {
  549. var doScroll = this.isBottom();
  550. var channelName = this.model.get('currentChannel').get('name');
  551. _.each(messages, function(message) {
  552. if (message.getTarget() == channelName ||
  553. (message.getTarget() == config.myNick && message.getNickname() == channelName)) {
  554. this.insertMessageView(new MessageView({model: message}));
  555. } else {
  556. console.log('ERROR: channel name mismatch: ', message.getTarget(), channelName);
  557. return;
  558. }
  559. }, this);
  560. if (doScroll) {
  561. this.scrollToBottom();
  562. }
  563. var messageId = _.max(_.pluck(messages, 'id'));
  564. if (this.bookmarks.get(channelName) < messageId) {
  565. this.bookmarks.set(channelName, messageId);
  566. }
  567. },
  568. insertMessageView: function(view) {
  569. view.render();
  570. if (view.model.get('id') > this.bookmarkedId) {
  571. view.setUnread();
  572. }
  573. var firstElem = this.$containerEl.children().first();
  574. var thisId = view.model.get('id');
  575. if (thisId === 0) {
  576. // this is first message of this channel.
  577. // no need to display link to archive page.
  578. this.$archiveLinkEl.hide();
  579. } else if (!firstElem.length || thisId < firstElem.attr('message-id')) {
  580. this.showArchiveLinkFor(this.model.get('currentChannel'), view.model);
  581. }
  582. if (!this.$containerEl.children().length) {
  583. this.$containerEl.append(view.el);
  584. return;
  585. }
  586. var lastElem = this.$containerEl.children().last();
  587. if (view.model.get('id') > lastElem.attr('message-id')) {
  588. this.$containerEl.append(view.el);
  589. } else if (view.model.get('id') < firstElem.attr('message-id')) {
  590. this.$containerEl.prepend(view.el);
  591. } else {
  592. // TODO: binary search
  593. var children = this.$containerEl.children();
  594. for (var i = 0; i < children.length; i++) {
  595. if (view.model.get('id') < children[i].getAttribute('message-id')) {
  596. view.$el.insertBefore(children[i]);
  597. return;
  598. }
  599. }
  600. }
  601. },
  602. showArchiveLinkFor: function(channel, message) {
  603. this.$archiveLinkEl.html(this.templateArchiveLink({
  604. name: encodeURIComponent(channel.get('name')),
  605. page: 1 + Math.floor(message.get('id') / initialState.messagesPerPage),
  606. id: message.get('id')
  607. }));
  608. this.$archiveLinkEl.show();
  609. },
  610. editTopic: function() {
  611. var channel = this.model.get('currentChannel');
  612. if (!channel) {
  613. console.log('no currentChannel');
  614. return false;
  615. }
  616. this.$channelTopicInput.val(channel.get('topic'));
  617. this.$channelTopicContainer.addClass('editing');
  618. this.$channelTopicInput.focus();
  619. return false;
  620. },
  621. saveTopic: function() {
  622. var channel = this.model.get('currentChannel');
  623. if (!channel) {
  624. console.log('no currentChannel');
  625. return false;
  626. }
  627. connection.send('TOPIC', [channel.get('name'), this.$channelTopicInput.val()]);
  628. this.stopEditingTopic();
  629. return false;
  630. },
  631. stopEditingTopic: function() {
  632. this.$channelTopicContainer.removeClass('editing');
  633. this.$channelTopicInput.val('');
  634. return false;
  635. },
  636. isBottom: function() {
  637. var el = this.$messagesAndArchiveLinkEl[0];
  638. return (el.scrollTop == el.scrollHeight - el.offsetHeight);
  639. },
  640. scrollToBottom: function(animate) {
  641. var el = this.$messagesAndArchiveLinkEl[0];
  642. var scrollTopMax = el.scrollHeight - el.offsetHeight;
  643. if (animate) {
  644. this.$messagesAndArchiveLinkEl.animate({
  645. scrollTop: scrollTopMax
  646. });
  647. } else {
  648. this.$messagesAndArchiveLinkEl.scrollTop(scrollTopMax);
  649. }
  650. },
  651. scrollUp: function() {
  652. this.$messagesAndArchiveLinkEl.scrollTop(
  653. this.$messagesAndArchiveLinkEl.scrollTop() - this.scrollStep
  654. );
  655. },
  656. scrollDown: function() {
  657. this.$messagesAndArchiveLinkEl.scrollTop(
  658. this.$messagesAndArchiveLinkEl.scrollTop() + this.scrollStep
  659. );
  660. },
  661. focusMessageInput: function() {
  662. this.$messageForm.find('#message-text').focus();
  663. },
  664. messageFormKeyPressed: function(event) {
  665. event = event || window.event;
  666. switch (event.keyCode) {
  667. case 27:
  668. // escape
  669. // move focus from input area
  670. this.$messageForm.find('#message-text').blur();
  671. break;
  672. case 13:
  673. // enter
  674. return this.submitMessage(event);
  675. }
  676. },
  677. submitMessage: function(event) {
  678. // TODO: try your best to return false, to avoid HTTP POSTing form
  679. var form = this.$messageForm[0];
  680. var text = form.text.value;
  681. if (text) {
  682. var channelName = this.model.get('currentChannel').get('name');
  683. var command;
  684. if (event.ctrlKey) {
  685. command = 'NOTICE';
  686. } else {
  687. command = 'PRIVMSG';
  688. }
  689. connection.send(command, [channelName, text]);
  690. form.text.value = '';
  691. }
  692. return false;
  693. },
  694. toggleUploadForm: function() {
  695. this.$('#upload-button').button('toggle');
  696. this.$('#paste-form').toggle();
  697. if (window.onresize) {
  698. window.onresize();
  699. }
  700. },
  701. submitPaste: function(event) {
  702. var form = event.target;
  703. if (!form.paste.value) {
  704. // no file selected
  705. return false;
  706. }
  707. form.channel.value = this.model.get('currentChannel').get('name');
  708. // FIXME: it doesn't work on old browsers
  709. var data = new FormData(form);
  710. var messageForm = this.$('#message-form')[0];
  711. var self = this;
  712. $.ajax({
  713. url: config.pasteURL,
  714. type: 'POST',
  715. success: function(result) {
  716. if (messageForm.text.value) {
  717. messageForm.text.value += " " + result.url;
  718. } else {
  719. messageForm.text.value = result.url;
  720. }
  721. form.paste.value = "";
  722. self.toggleUploadForm();
  723. },
  724. data: data,
  725. cache: false,
  726. contentType: false,
  727. processData: false
  728. });
  729. return false;
  730. },
  731. markAllAsRead: function() {
  732. this.$messagesAndArchiveLinkEl.find('.message-unread-bar').fadeOut();
  733. this.$messagesAndArchiveLinkEl.find('.unread').removeClass('unread');
  734. }
  735. });
  736. var MessageView = Backbone.View.extend({
  737. events: {
  738. 'mousemove': 'clearUnread',
  739. 'click .remove-icon': 'deleteThisMessage'
  740. },
  741. templatePriv: Mustache.compile($('#template-priv-message').html()),
  742. templateNotice: Mustache.compile($('#template-notice-message').html()),
  743. templateTopic: Mustache.compile($('#template-topic-message').html()),
  744. regex: RegExp('\\b[:@]?' + config.myNick + '\\b'),
  745. initialize: function() {
  746. // TODO: assert this.model is for PRIVMSG (or NOTICE)
  747. this.$el.addClass('message');
  748. this.$el.attr('message-id', this.model.get('id'));
  749. this.model.on('change', this.render, this);
  750. },
  751. render: function() {
  752. var text = _.last(this.model.get('params'));
  753. var template = null;
  754. switch (this.model.get('command')) {
  755. case 'NOTICE':
  756. template = this.templateNotice;
  757. // I don't expect break here
  758. /* jshint -W086 */
  759. case 'PRIVMSG':
  760. /* jshint +W086 */
  761. template = template || this.templatePriv;
  762. this.$el.html(template({
  763. nickname: this.model.getNickname(),
  764. datetime: showTime(this.model.get('time')),
  765. text: linkify(text)
  766. }));
  767. if (text.match(this.regex)) {
  768. this.$el.addClass('mention');
  769. }
  770. var nickname = this.model.getNickname();
  771. if (nickname == config.myNick) {
  772. this.$el.addClass('mine');
  773. }
  774. if (nickname == config.myNick && text) {
  775. this.$el.addClass('show-delete-icon');
  776. } else {
  777. this.$el.removeClass('show-delete-icon');
  778. }
  779. break;
  780. case 'TOPIC':
  781. this.$el.html(this.templateTopic({
  782. nickname: this.model.getNickname(),
  783. datetime: showTime(this.model.get('time')),
  784. topic: text
  785. }));
  786. break;
  787. }
  788. return this;
  789. },
  790. setUnread: function() {
  791. this.$('.message-unread-bar').show();
  792. this.$el.addClass('unread');
  793. },
  794. clearUnread: function() {
  795. this.$('.message-unread-bar').fadeOut();
  796. this.$el.removeClass('unread');
  797. },
  798. deleteThisMessage: function() {
  799. var channelName = this.model.getTarget();
  800. var messageId = this.model.get('id');
  801. var text = _.last(this.model.get('params'));
  802. var $dialog = $('#confirm-message-deletion');
  803. $dialog.find('.quoted-message-text').text(text);
  804. $dialog.find('.btn-danger').on('click', function() {
  805. connection.send('DELETE', [channelName, messageId]);
  806. $dialog.modal('hide');
  807. });
  808. $dialog.modal('show');
  809. // to avoid updating URL
  810. return false;
  811. }
  812. });
  813. // Utility functions
  814. // =================
  815. function getCookie(key) {
  816. var alist = [];
  817. _.each(document.cookie.split(';'), function(kv) {
  818. var kvList = kv.split('=');
  819. var k = $.trim(kvList[0]);
  820. var v = $.trim(kvList[1]);
  821. alist.push([k, v]);
  822. });
  823. for (var i in alist) {
  824. var k = alist[i][0];
  825. var v = alist[i][1];
  826. if (k == key) {
  827. return v;
  828. }
  829. }
  830. }
  831. function showTime(epocseconds) {
  832. // UTC to local time
  833. var n = new Date();
  834. var t = new Date(epocseconds * 1000 - n.getTimezoneOffset() * 60000);
  835. return t.getFullYear() + "-" + pad20(t.getMonth() + 1) + "-" + pad20(t.getDate()) + " " + pad20(t.getHours()) + ":" + pad20(t.getMinutes());
  836. }
  837. function pad(str, len, chr) {
  838. str = str.toString();
  839. if (!chr) {
  840. chr = ' ';
  841. }
  842. if (str.length >= len) {
  843. return str;
  844. } else {
  845. return pad(chr + str, len-1);
  846. }
  847. }
  848. function pad20(str) {
  849. return pad(str, 2, '0');
  850. }
  851. var _LINKABLE_RE = /(?:\b((?:([\w-]+):(\/{1,3})|www[.])(?:(?:(?:[^\s&()]|&amp;|&quot;)*(?:[^!"#$%&'()*+,.:;<=>?@\[\]^`{|}~\s]))|(?:\((?:[^\s&()]|&amp;|&quot;)*\)))+)|(#[^ \t\r\n]+))/g;
  852. function linkify(text) {
  853. var escaped = $('<p/>').text(text).html();
  854. return escaped.replace(_LINKABLE_RE, function(m) {
  855. if (m.match(/^https?:/)) {
  856. return '<a href="' + m + '" rel="noreferrer" target="_blank">' + m + '</a>';
  857. } else if (m.match(/^[#&]/)) {
  858. return '<a href="#channel/' + m + '">' + m + '</a>';
  859. }
  860. return m;
  861. });
  862. }
  863. function sum(array) {
  864. return _.reduce(array, function(acc, n) { return acc + n; });
  865. }
  866. // Router
  867. // ======
  868. var Application = Backbone.Router.extend({
  869. routes: {
  870. '': 'index',
  871. 'channel/*id': 'openChannel'
  872. },
  873. initialize: function(options) {
  874. this.state = new ApplicationState();
  875. this.channels = new ChannelMap();
  876. _.each(options.channels, function(channel) {
  877. this.channels.set(channel.name, new Channel(channel));
  878. }, this);
  879. this.channelList = new ChannelListView({el: $('#tab-list'), model: this.channels});
  880. this.channelList.render();
  881. this.users = new ChannelMap();
  882. _.each(options.users, function(user) {
  883. this.users.set(user.name, new Channel(user));
  884. }, this);
  885. this.userList = new UserListView({el: $('#tab-names'), model: this.users});
  886. this.userList.render();
  887. this.state.on('change:currentChannel', function() {
  888. var channel = this.state.get('currentChannel');
  889. var channelName;
  890. if (channel) {
  891. channelName = channel.get('name');
  892. } else {
  893. channelName = '';
  894. }
  895. this.channelList.setActiveChannel(channelName);
  896. this.userList.setActiveChannel(channelName);
  897. }, this);
  898. this.bookmarks = new Bookmarks(options.bookmarks);
  899. this.channelView = new ChannelView({el: $('#channel-view'),
  900. model: this.state,
  901. attributes: {bookmarks: this.bookmarks}});
  902. $('#join-form').on('submit', function() {
  903. if (this.channel.value) {
  904. var channelName = '#' + this.channel.value;
  905. connection.send('JOIN', [channelName]);
  906. this.channel.value = '';
  907. }
  908. return false;
  909. });
  910. var self = this;
  911. connection.onerror = function() {
  912. // TODO: show error message to user
  913. };
  914. connection.onclose = function() {
  915. // TODO: show error message to user
  916. };
  917. connection.onmessage = function(message) {
  918. self.messageReceived(new Message(message));
  919. };
  920. connection.start();
  921. // initialize desktop notification
  922. if (window.webkitNotifications && window.webkitNotifications.checkPermission() == 1) {
  923. // PERMISSION_NOT_ALLOWED(1)
  924. var $dialog = $('#configure-desktop-notification');
  925. $dialog.find('.btn-primary').on('click', function() {
  926. window.webkitNotifications.requestPermission();
  927. $dialog.modal('hide');
  928. });
  929. $dialog.modal('show');
  930. }
  931. this.enableKeyboardShortcut();
  932. },
  933. index: function() {
  934. this.state.set('currentChannel', null);
  935. connection.onopen = null;
  936. },
  937. openChannel: function(id) {
  938. var channel;
  939. if (id.match(/^[#&]/)) {
  940. channel = this.channels.get(id);
  941. if (!channel) {
  942. // XXX: channel may be created after I get LIST information
  943. console.log('unknown channel name:', id);
  944. return false;
  945. }
  946. } else {
  947. channel = this.users.get(id);
  948. if (!channel) {
  949. // XXX: channel may be created after I get NAMES information
  950. console.log('unknown user name:', id);
  951. return false;
  952. }
  953. }
  954. if (channel.isUser() || channel.get('isJoined')) {
  955. this.state.set('currentChannel', channel);
  956. connection.onopen = function() {
  957. // query messages after (re)connection
  958. var lastMessage = channel.get('messages').last();
  959. if (lastMessage) {
  960. channel.query({sinceId: lastMessage.get('id')});
  961. } else {
  962. channel.query({limit: 50, reverse: true});
  963. }
  964. };
  965. } else {
  966. // just send JOIN message.
  967. // actual channel switching will be occured
  968. // in response to the echo-backed JOIN message.
  969. connection.send('JOIN', [channel.get('name')]);
  970. }
  971. },
  972. messageReceived: function(message) {
  973. var command = message.get('command');
  974. var callback;
  975. if (command && (callback = this['receive_' + command.toUpperCase()])) {
  976. return callback.call(this, message);
  977. } else {
  978. console.log('unknown message', message);
  979. }
  980. },
  981. receive_PRIVMSG: function(message) {
  982. var target = message.getTarget();
  983. if (target == config.myNick) {
  984. // direct message
  985. target = message.getNickname();
  986. }
  987. var channel = this.getChannelOrUser(target);
  988. if (!channel) {
  989. // XXX: is this possible in normal case?
  990. console.log('unknow target:', target);
  991. return;
  992. }
  993. channel.get('messages').add(message);
  994. if (this.state.get('currentChannel') != channel) {
  995. channel.set('unread', channel.get('unread') + 1);
  996. }
  997. var nickname = message.getNickname();
  998. if (nickname != config.myNick) {
  999. notifier.notify(
  1000. config.avatarImageURL,
  1001. nickname + ' on ' + target,
  1002. _.last(message.get('params'))
  1003. );
  1004. }
  1005. },
  1006. receive_NOTICE: function(message) {
  1007. return this.receive_PRIVMSG(message);
  1008. },
  1009. receive_RPL_QUERY: function(message) {
  1010. var channel = this.getChannelOrUser(message.getTarget());
  1011. var messages = _.map(message.get('params').slice(1),
  1012. function(m) { return new Message(m); });
  1013. var collection = channel.get('messages');
  1014. collection.add(messages, {silent: true});
  1015. // FIXME: trigger with messages actually added (don't send messages already in the collection)
  1016. collection.trigger('bulkAdd', messages);
  1017. },
  1018. receive_JOIN: function(message) {
  1019. var channelName = message.getTarget();
  1020. var channel = this.getChannelOrUser(channelName);
  1021. if (message.getNickname() == config.myNick) {
  1022. if (channel) {
  1023. channel.set('isJoined', true);
  1024. } else {
  1025. channel = new Channel({
  1026. name: channelName,
  1027. topic: '',
  1028. isJoined: true,
  1029. unread: 0,
  1030. userNames: []
  1031. });
  1032. if (channel.isChannel()) {
  1033. this.channels.set(channelName, channel);
  1034. } else {
  1035. this.users.set(channelName, channel);
  1036. }
  1037. }
  1038. var hashURL = '#channel/' + channelName;
  1039. if (window.location.hash != hashURL) {
  1040. window.location.hash = hashURL;
  1041. } else {
  1042. this.openChannel(channelName);
  1043. }
  1044. } else {
  1045. if (channel) {
  1046. channel.addName(message.getNickname());
  1047. }
  1048. }
  1049. },
  1050. receive_PART: function(message) {
  1051. var channelName = message.getTarget();
  1052. var channel = this.getChannelOrUser(channelName);
  1053. if (message.getNickname() == config.myNick) {
  1054. if (channel) {
  1055. channel.set('isJoined', false);
  1056. if (this.state.get('currentChannel') == channel) {
  1057. this.state.set('currentChannel', null);
  1058. window.location.hash = '';
  1059. }
  1060. } else {
  1061. console.log('receive PART message for unknown channel:', channelName);
  1062. }
  1063. } else {
  1064. if (channel) {
  1065. channel.removeName(message.getNickname());
  1066. }
  1067. }
  1068. },
  1069. receive_TOPIC: function(message) {
  1070. var channelName = message.getTarget();
  1071. var channel = this.channels.get(channelName);
  1072. if (!channel) {
  1073. console.log('unknown channel name', channelName);
  1074. } else {
  1075. channel.set('topic', message.get('params')[1]);
  1076. channel.get('messages').add(message);
  1077. }
  1078. },
  1079. receive_RPL_NAMREPLY: function(message) {
  1080. var channelName = message.getTarget();
  1081. var channel = this.channels.get(channelName);
  1082. if (!channel) {
  1083. console.log('unknown channel name', channelName);
  1084. } else {
  1085. var names = _.last(message.get('params')).split(' ');
  1086. channel.addNames(names);
  1087. }
  1088. },
  1089. receive_RPL_ENDOFNAMES: function(message) {
  1090. // do nothing
  1091. return;
  1092. },
  1093. receive_RPL_TOPIC: function(message) {
  1094. var channelName = message.getTarget();
  1095. var channel = this.channels.get(channelName);
  1096. if (!channel) {
  1097. console.log('unknown channel name', channelName);
  1098. } else {
  1099. channel.set('topic', message.get('params')[2]);
  1100. }
  1101. },
  1102. receive_RPL_LISTSTART: function(message) {
  1103. // do nothing
  1104. return;
  1105. },
  1106. receive_RPL_LIST: function(message) {
  1107. // TODO: remove channels which do not exist anymore
  1108. var channelName = message.get('params')[1];
  1109. var channelTopic = message.get('params')[3];
  1110. if (!this.channels.get(channelName)) {
  1111. this.channels.set(channelName, new Channel({
  1112. isJoined: false,
  1113. name: channelName,
  1114. topic: channelTopic,
  1115. userNames: [],
  1116. unread: 0
  1117. }));
  1118. }
  1119. },
  1120. receive_RPL_LISTEND: function(message) {
  1121. // do nothing
  1122. return;
  1123. },
  1124. receive_RPL_DELETE: function(message) {
  1125. var channelName = message.get('params')[0];
  1126. var messageId = message.get('params')[1];
  1127. var channel = this.channels.get(channelName);
  1128. if (!channel) {
  1129. console.log('unknown channel name', channelName);
  1130. return false;
  1131. }
  1132. var deletedMessage = channel.get('messages').get(messageId);
  1133. if (deletedMessage) {
  1134. var params = deletedMessage.get('params');
  1135. params[params.length - 1] = '';
  1136. deletedMessage.set('params', params);
  1137. }
  1138. },
  1139. getChannelOrUser: function(target) {
  1140. var collection;
  1141. if (target.match(/^[#&]/)) {
  1142. collection = this.channels;
  1143. } else {
  1144. collection = this.users;
  1145. }
  1146. return collection.get(target);
  1147. },
  1148. enableKeyboardShortcut: function() {
  1149. var self = this;
  1150. Mousetrap.bind('A', function() { self.channelView.markAllAsRead(); });
  1151. Mousetrap.bind('C', function() { $('#tab-for-channel').tab('show'); });
  1152. Mousetrap.bind('N', function() {
  1153. if (self.channelList.isActive()) {
  1154. self.channelList.selectNext();
  1155. } else if (self.userList.isActive()) {
  1156. self.userList.selectNext();
  1157. }
  1158. });
  1159. Mousetrap.bind('O', function() {
  1160. if (self.channelList.isActive()) {
  1161. self.channelList.openSelected();
  1162. } else if (self.userList.isActive()) {
  1163. self.userList.openSelected();
  1164. }
  1165. });
  1166. Mousetrap.bind('P', function() {
  1167. if (self.channelList.isActive()) {
  1168. self.channelList.selectPrev();
  1169. } else if (self.userList.isActive()) {
  1170. self.userList.selectPrev();
  1171. }
  1172. });
  1173. Mousetrap.bind('U', function() { $('#tab-for-names').tab('show'); });
  1174. Mousetrap.bind('i', function() {
  1175. self.channelView.focusMessageInput();
  1176. // return false to avoid inputting "i"
  1177. return false;
  1178. });
  1179. Mousetrap.bind('j', function() { self.channelView.scrollDown(); });
  1180. Mousetrap.bind('k', function() { self.channelView.scrollUp(); });
  1181. },
  1182. disableKeyboardShortcut: function() {
  1183. Moustrap.reset();
  1184. }
  1185. });
  1186. $(document).ready(function() {
  1187. var app = window.app = new Application(initialState);
  1188. connection.onopen = function() {
  1189. // conneciton is opened. start routing
  1190. Backbone.history.start();
  1191. setInterval(function() {
  1192. // periodically send LIST command to update channel list
  1193. connection.send('LIST', []);
  1194. }, 10 * 60 * 1000);
  1195. // remove callback to avoid starting history twice
  1196. connection.onopen = null;
  1197. };
  1198. });
  1199. $(window).ready(function() {
  1200. function autoResize() {
  1201. var fizedSize = sum(_.pluck($('.fixed-size'), 'clientHeight'));
  1202. // XXX: remove magic number (120)
  1203. var appColumnHeight = window.innerHeight - fizedSize - 120;
  1204. if (appColumnHeight < 200) {
  1205. appColumnHeight = 200;
  1206. }
  1207. $('.app-column').height(appColumnHeight);
  1208. $('.app-column-small').height(appColumnHeight - 100);
  1209. if (app.channelList.isActive()) {
  1210. app.channelList.scrollChannelIntoViewIfNeeded();
  1211. } else if (app.userList.isActive()) {
  1212. app.userList.scrollChannelIntoViewIfNeeded();
  1213. }
  1214. }
  1215. window.onresize = autoResize;
  1216. autoResize();
  1217. });