Plugin tool icons on control_input
[KiwiIRC.git] / client / assets / dev / view.js
1 /*jslint white:true, regexp: true, nomen: true, devel: true, undef: true, browser: true, continue: true, sloppy: true, forin: true, newcap: true, plusplus: true, maxerr: 50, indent: 4 */
2 /*global kiwi */
3
4 _kiwi.view.MemberList = Backbone.View.extend({
5 tagName: "ul",
6 events: {
7 "click .nick": "nickClick"
8 },
9 initialize: function (options) {
10 this.model.bind('all', this.render, this);
11 $(this.el).appendTo('#memberlists');
12 },
13 render: function () {
14 var $this = $(this.el);
15 $this.empty();
16 this.model.forEach(function (member) {
17 var prefix_css_class = (member.get('modes') || []).join(' ');
18 $('<li class="mode ' + prefix_css_class + '"><a class="nick"><span class="prefix">' + member.get("prefix") + '</span>' + member.get("nick") + '</a></li>')
19 .appendTo($this)
20 .data('member', member);
21 });
22 },
23 nickClick: function (x) {
24 var $target = $(x.currentTarget).parent('li'),
25 member = $target.data('member'),
26 userbox;
27
28 // If the userbox already exists here, hide it
29 if ($target.find('.userbox').length > 0) {
30 $('.userbox', this.$el).remove();
31 return;
32 }
33
34 userbox = new _kiwi.view.UserBox();
35 userbox.member = member;
36
37 // Remove any existing userboxes
38 $('.userbox', this.$el).remove();
39
40 $target.append(userbox.$el);
41 },
42 show: function () {
43 $('#memberlists').children().removeClass('active');
44 $(this.el).addClass('active');
45 }
46 });
47
48
49
50 _kiwi.view.UserBox = Backbone.View.extend({
51 events: {
52 'click .query': 'queryClick',
53 'click .info': 'infoClick',
54 'click .slap': 'slapClick'
55 },
56
57 initialize: function () {
58 this.$el = $($('#tmpl_userbox').html());
59 },
60
61 queryClick: function (event) {
62 var panel = new _kiwi.model.Query({name: this.member.get('nick')});
63 _kiwi.app.panels.add(panel);
64 panel.view.show();
65 },
66
67 infoClick: function (event) {
68 _kiwi.app.controlbox.processInput('/whois ' + this.member.get('nick'));
69 },
70
71 slapClick: function (event) {
72 _kiwi.app.controlbox.processInput('/slap ' + this.member.get('nick'));
73 }
74 });
75
76 _kiwi.view.NickChangeBox = Backbone.View.extend({
77 events: {
78 'submit': 'changeNick',
79 'click .cancel': 'close'
80 },
81
82 initialize: function () {
83 this.$el = $($('#tmpl_nickchange').html());
84 },
85
86 render: function () {
87 // Add the UI component and give it focus
88 _kiwi.app.controlbox.$el.prepend(this.$el);
89 this.$el.find('input').focus();
90
91 this.$el.css('bottom', _kiwi.app.controlbox.$el.outerHeight(true));
92 },
93
94 close: function () {
95 this.$el.remove();
96
97 },
98
99 changeNick: function (event) {
100 var that = this;
101 _kiwi.gateway.changeNick(this.$el.find('input').val(), function (err, val) {
102 that.close();
103 });
104 return false;
105 }
106 });
107
108 _kiwi.view.ServerSelect = function () {
109 // Are currently showing all the controlls or just a nick_change box?
110 var state = 'all';
111
112 var model = Backbone.View.extend({
113 events: {
114 'submit form': 'submitForm',
115 'click .show_more': 'showMore',
116 'change .have_pass input': 'showPass'
117 },
118
119 initialize: function () {
120 var that = this;
121
122 this.$el = $($('#tmpl_server_select').html());
123
124 // Remove the 'more' link if the server has disabled server changing
125 if (_kiwi.app.server_settings && _kiwi.app.server_settings.connection) {
126 if (!_kiwi.app.server_settings.connection.allow_change) {
127 this.$el.find('.show_more').remove();
128 this.$el.addClass('single_server');
129 }
130 }
131
132
133 _kiwi.gateway.bind('onconnect', this.networkConnected, this);
134 _kiwi.gateway.bind('connecting', this.networkConnecting, this);
135
136 _kiwi.gateway.bind('onirc_error', function (data) {
137 $('button', this.$el).attr('disabled', null);
138
139 if (data.error == 'nickname_in_use') {
140 this.setStatus('Nickname already taken');
141 this.show('nick_change');
142 }
143
144 if (data.error == 'password_mismatch') {
145 this.setStatus('Incorrect Password');
146 this.show('nick_change');
147 that.$el.find('.password').select();
148 }
149 }, this);
150 },
151
152 submitForm: function (event) {
153 event.preventDefault();
154
155 // Make sure a nick is chosen
156 if (!$('input.nick', this.$el).val().trim()) {
157 this.setStatus('Select a nickname first!');
158 $('input.nick', this.$el).select();
159 return;
160 }
161
162 if (state === 'nick_change') {
163 this.submitNickChange(event);
164 } else {
165 this.submitLogin(event);
166 }
167
168 $('button', this.$el).attr('disabled', 1);
169 return;
170 },
171
172 submitLogin: function (event) {
173 // If submitting is disabled, don't do anything
174 if ($('button', this.$el).attr('disabled')) return;
175
176 var values = {
177 nick: $('input.nick', this.$el).val(),
178 server: $('input.server', this.$el).val(),
179 port: $('input.port', this.$el).val(),
180 ssl: $('input.ssl', this.$el).prop('checked'),
181 password: $('input.password', this.$el).val(),
182 channel: $('input.channel', this.$el).val(),
183 channel_key: $('input.channel_key', this.$el).val()
184 };
185
186 this.trigger('server_connect', values);
187 },
188
189 submitNickChange: function (event) {
190 _kiwi.gateway.changeNick($('input.nick', this.$el).val());
191 this.networkConnecting();
192 },
193
194 showPass: function (event) {
195 if (this.$el.find('tr.have_pass input').is(':checked')) {
196 this.$el.find('tr.pass').show().find('input').focus();
197 } else {
198 this.$el.find('tr.pass').hide().find('input').val('');
199 }
200 },
201
202 showMore: function (event) {
203 $('.more', this.$el).slideDown('fast');
204 $('input.server', this.$el).select();
205 },
206
207 populateFields: function (defaults) {
208 var nick, server, port, channel, channel_key, ssl, password;
209
210 defaults = defaults || {};
211
212 nick = defaults.nick || '';
213 server = defaults.server || '';
214 port = defaults.port || 6667;
215 ssl = defaults.ssl || 0;
216 password = defaults.password || '';
217 channel = defaults.channel || '';
218 channel_key = defaults.channel_key || '';
219
220 $('input.nick', this.$el).val(nick);
221 $('input.server', this.$el).val(server);
222 $('input.port', this.$el).val(port);
223 $('input.ssl', this.$el).prop('checked', ssl);
224 $('input.password', this.$el).val(password);
225 $('input.channel', this.$el).val(channel);
226 $('input.channel_key', this.$el).val(channel_key);
227 },
228
229 hide: function () {
230 this.$el.slideUp();
231 },
232
233 show: function (new_state) {
234 new_state = new_state || 'all';
235
236 this.$el.show();
237
238 if (new_state === 'all') {
239 $('.show_more', this.$el).show();
240
241 } else if (new_state === 'more') {
242 $('.more', this.$el).slideDown('fast');
243
244 } else if (new_state === 'nick_change') {
245 $('.more', this.$el).hide();
246 $('.show_more', this.$el).hide();
247 $('input.nick', this.$el).select();
248 }
249
250 state = new_state;
251 },
252
253 setStatus: function (text, class_name) {
254 $('.status', this.$el)
255 .text(text)
256 .attr('class', 'status')
257 .addClass(class_name||'')
258 .show();
259 },
260 clearStatus: function () {
261 $('.status', this.$el).hide();
262 },
263
264 networkConnected: function (event) {
265 this.setStatus('Connected :)', 'ok');
266 $('form', this.$el).hide();
267 },
268
269 networkConnecting: function (event) {
270 this.setStatus('Connecting..', 'ok');
271 },
272
273 showError: function (event) {
274 this.setStatus('Error connecting', 'error');
275 $('button', this.$el).attr('disabled', null);
276 this.show();
277 }
278 });
279
280
281 return new model(arguments);
282 };
283
284
285 _kiwi.view.Panel = Backbone.View.extend({
286 tagName: "div",
287 className: "messages",
288 events: {
289 "click .chan": "chanClick",
290 'click .media .open': 'mediaClick',
291 'mouseenter .msg .nick': 'msgEnter',
292 'mouseleave .msg .nick': 'msgLeave'
293 },
294
295 initialize: function (options) {
296 this.initializePanel(options);
297 },
298
299 initializePanel: function (options) {
300 this.$el.css('display', 'none');
301 options = options || {};
302
303 // Containing element for this panel
304 if (options.container) {
305 this.$container = $(options.container);
306 } else {
307 this.$container = $('#panels .container1');
308 }
309
310 this.$el.appendTo(this.$container);
311
312 this.alert_level = 0;
313
314 this.model.bind('msg', this.newMsg, this);
315 this.msg_count = 0;
316
317 this.model.set({"view": this}, {"silent": true});
318 },
319
320 render: function () {
321 var that = this;
322
323 this.$el.empty();
324 _.each(this.model.get('scrollback'), function (msg) {
325 that.newMsg(msg);
326 });
327 },
328
329 newMsg: function (msg) {
330 var re, line_msg, $this = this.$el,
331 nick_colour_hex, nick_hex, is_highlight, msg_css_classes = '';
332
333 // Nick highlight detecting
334 if ((new RegExp('\\b' + _kiwi.gateway.get('nick') + '\\b', 'i')).test(msg.msg)) {
335 is_highlight = true;
336 msg_css_classes += ' highlight';
337 }
338
339 // Escape any HTML that may be in here
340 msg.msg = $('<div />').text(msg.msg).html();
341
342 // Make the channels clickable
343 re = new RegExp('(?:^|\\s)([' + _kiwi.gateway.get('channel_prefix') + '][^ ,.\\007]+)', 'g');
344 msg.msg = msg.msg.replace(re, function (match) {
345 return '<a class="chan" data-channel="' + match.trim() + '">' + match + '</a>';
346 });
347
348
349 // Parse any links found
350 msg.msg = msg.msg.replace(/(([A-Za-z0-9\-]+\:\/\/)|(www\.))([\w.\-]+)([a-zA-Z]{2,6})(:[0-9]+)?(\/[\w#!:.?$'()[\]*,;~+=&%@!\-\/]*)?/gi, function (url) {
351 var nice = url,
352 extra_html = '';
353
354 // Add the http if no protoocol was found
355 if (url.match(/^www\./)) {
356 url = 'http://' + url;
357 }
358
359 // Shorten the displayed URL if it's going to be too long
360 if (nice.length > 100) {
361 nice = nice.substr(0, 100) + '...';
362 }
363
364 // Get any media HTML if supported
365 extra_html = _kiwi.view.MediaMessage.buildHtml(url);
366
367 // Make the link clickable
368 return '<a class="link_ext" target="_blank" rel="nofollow" href="' + url + '">' + nice + '</a> ' + extra_html;
369 });
370
371
372 // Convert IRC formatting into HTML formatting
373 msg.msg = formatIRCMsg(msg.msg);
374
375
376 // Add some colours to the nick (Method based on IRSSIs nickcolor.pl)
377 nick_colour_hex = (function (nick) {
378 var nick_int = 0, rgb;
379
380 _.map(nick.split(''), function (i) { nick_int += i.charCodeAt(0); });
381 rgb = hsl2rgb(nick_int % 255, 70, 35);
382 rgb = rgb[2] | (rgb[1] << 8) | (rgb[0] << 16);
383
384 return '#' + rgb.toString(16);
385 })(msg.nick);
386
387 msg.nick_style = 'color:' + nick_colour_hex + ';';
388
389 // Generate a hex string from the nick to be used as a CSS class name
390 nick_hex = msg.nick_css_class = '';
391 if (msg.nick) {
392 _.map(msg.nick.split(''), function (char) {
393 nick_hex += char.charCodeAt(0).toString(16);
394 });
395 msg_css_classes += ' nick_' + nick_hex;
396 }
397
398 // Build up and add the line
399 msg.msg_css_classes = msg_css_classes;
400 line_msg = '<div class="msg <%= type %> <%= msg_css_classes %>"><div class="time"><%- time %></div><div class="nick" style="<%= nick_style %>"><%- nick %></div><div class="text" style="<%= style %>"><%= msg %> </div></div>';
401 $this.append(_.template(line_msg, msg));
402
403 // Activity/alerts based on the type of new message
404 if (msg.type.match(/^action /)) {
405 this.alert('action');
406
407 } else if (is_highlight) {
408 _kiwi.app.view.alertWindow('* People are talking!');
409 this.alert('highlight');
410
411 } else {
412 // If this is the active panel, send an alert out
413 if (this.model.isActive()) {
414 _kiwi.app.view.alertWindow('* People are talking!');
415 }
416 this.alert('activity');
417 }
418
419 // Update the activity counters
420 (function () {
421 // Only inrement the counters if we're not the active panel
422 if (this.model.isActive()) return;
423
424 var $act = this.model.tab.find('.activity');
425 $act.text((parseInt($act.text(), 10) || 0) + 1);
426 if ($act.text() === '0') {
427 $act.addClass('zero');
428 } else {
429 $act.removeClass('zero');
430 }
431 }).apply(this);
432
433 this.scrollToBottom();
434
435 // Make sure our DOM isn't getting too large (Acts as scrollback)
436 this.msg_count++;
437 if (this.msg_count > (parseInt(_kiwi.global.settings.get('scrollback'), 10) || 250)) {
438 $('.msg:first', this.$el).remove();
439 this.msg_count--;
440 }
441 },
442 chanClick: function (event) {
443 if (event.target) {
444 _kiwi.gateway.join($(event.target).data('channel'));
445 } else {
446 // IE...
447 _kiwi.gateway.join($(event.srcElement).data('channel'));
448 }
449 },
450
451 mediaClick: function (event) {
452 var $media = $(event.target).parents('.media');
453 var media_message;
454
455 if ($media.data('media')) {
456 media_message = $media.data('media');
457 } else {
458 media_message = new _kiwi.view.MediaMessage({el: $media[0]});
459 $media.data('media', media_message);
460 }
461
462 $media.data('media', media_message);
463
464 media_message.open();
465 },
466
467 // Cursor hovers over a message
468 msgEnter: function (event) {
469 var nick_class;
470
471 // Find a valid class that this element has
472 _.each($(event.currentTarget).parent('.msg').attr('class').split(' '), function (css_class) {
473 if (css_class.match(/^nick_[a-z0-9]+/i)) {
474 nick_class = css_class;
475 }
476 });
477
478 // If no class was found..
479 if (!nick_class) return;
480
481 $('.'+nick_class).addClass('global_nick_highlight');
482 },
483
484 // Cursor leaves message
485 msgLeave: function (event) {
486 var nick_class;
487
488 // Find a valid class that this element has
489 _.each($(event.currentTarget).parent('.msg').attr('class').split(' '), function (css_class) {
490 if (css_class.match(/^nick_[a-z0-9]+/i)) {
491 nick_class = css_class;
492 }
493 });
494
495 // If no class was found..
496 if (!nick_class) return;
497
498 $('.'+nick_class).removeClass('global_nick_highlight');
499 },
500
501 show: function () {
502 var $this = this.$el;
503
504 // Hide all other panels and show this one
505 this.$container.children().css('display', 'none');
506 $this.css('display', 'block');
507
508 // Show this panels memberlist
509 var members = this.model.get("members");
510 if (members) {
511 $('#memberlists').removeClass('disabled');
512 members.view.show();
513 } else {
514 // Memberlist not found for this panel, hide any active ones
515 $('#memberlists').addClass('disabled').children().removeClass('active');
516 }
517
518 _kiwi.app.view.doLayout();
519
520 // Remove any alerts and activity counters for this panel
521 this.alert('none');
522 this.model.tab.find('.activity').text('0').addClass('zero');
523
524 this.trigger('active', this.model);
525 _kiwi.app.panels.trigger('active', this.model, _kiwi.app.panels.active);
526
527 this.scrollToBottom(true);
528 },
529
530
531 alert: function (level) {
532 // No need to highlight if this si the active panel
533 if (this.model == _kiwi.app.panels.active) return;
534
535 var types, type_idx;
536 types = ['none', 'action', 'activity', 'highlight'];
537
538 // Default alert level
539 level = level || 'none';
540
541 // If this alert level does not exist, assume clearing current level
542 type_idx = _.indexOf(types, level);
543 if (!type_idx) {
544 level = 'none';
545 type_idx = 0;
546 }
547
548 // Only 'upgrade' the alert. Never down (unless clearing)
549 if (type_idx !== 0 && type_idx <= this.alert_level) {
550 return;
551 }
552
553 // Clear any existing levels
554 this.model.tab.removeClass(function (i, css) {
555 return (css.match(/\balert_\S+/g) || []).join(' ');
556 });
557
558 // Add the new level if there is one
559 if (level !== 'none') {
560 this.model.tab.addClass('alert_' + level);
561 }
562
563 this.alert_level = type_idx;
564 },
565
566
567 // Scroll to the bottom of the panel
568 scrollToBottom: function (force_down) {
569 // If this isn't the active panel, don't scroll
570 if (this.model !== _kiwi.app.panels.active) return;
571
572 // Don't scroll down if we're scrolled up the panel a little
573 if (force_down || this.$container.scrollTop() + this.$container.height() > this.$el.outerHeight() - 150) {
574 this.$container[0].scrollTop = this.$container[0].scrollHeight;
575 }
576 }
577 });
578
579 _kiwi.view.Applet = _kiwi.view.Panel.extend({
580 className: 'applet',
581 initialize: function (options) {
582 this.initializePanel(options);
583 }
584 });
585
586 _kiwi.view.Channel = _kiwi.view.Panel.extend({
587 initialize: function (options) {
588 this.initializePanel(options);
589 this.model.bind('change:topic', this.topic, this);
590
591 // Only show the loader if this is a channel (ie. not a query)
592 if (this.model.isChannel()) {
593 this.$el.append('<div class="initial_loader" style="margin:1em;text-align:center;">Joining channel.. <span class="loader"></span></div>');
594 }
595 },
596
597 // Override the existing newMsg() method to remove the joining channel loader
598 newMsg: function () {
599 this.$el.find('.initial_loader').slideUp(function () {
600 $(this).remove();
601 });
602
603 return this.constructor.__super__.newMsg.apply(this, arguments);
604 },
605
606 topic: function (topic) {
607 if (typeof topic !== 'string' || !topic) {
608 topic = this.model.get("topic");
609 }
610
611 this.model.addMsg('', '== Topic for ' + this.model.get('name') + ' is: ' + topic, 'topic');
612
613 // If this is the active channel then update the topic bar
614 if (_kiwi.app.panels.active === this) {
615 _kiwi.app.topicbar.setCurrentTopic(this.model.get("topic"));
616 }
617 }
618 });
619
620 // Model for this = _kiwi.model.PanelList
621 _kiwi.view.Tabs = Backbone.View.extend({
622 events: {
623 'click li': 'tabClick',
624 'click li .part': 'partClick'
625 },
626
627 initialize: function () {
628 this.model.on("add", this.panelAdded, this);
629 this.model.on("remove", this.panelRemoved, this);
630 this.model.on("reset", this.render, this);
631
632 this.model.on('active', this.panelActive, this);
633
634 this.tabs_applets = $('ul.applets', this.$el);
635 this.tabs_msg = $('ul.channels', this.$el);
636
637 _kiwi.gateway.on('change:name', function (gateway, new_val) {
638 $('span', this.model.server.tab).text(new_val);
639 }, this);
640 },
641 render: function () {
642 var that = this;
643
644 this.tabs_msg.empty();
645
646 // Add the server tab first
647 this.model.server.tab
648 .data('panel_id', this.model.server.cid)
649 .appendTo(this.tabs_msg);
650
651 // Go through each panel adding its tab
652 this.model.forEach(function (panel) {
653 // If this is the server panel, ignore as it's already added
654 if (panel == that.model.server) return;
655
656 panel.tab
657 .data('panel_id', panel.cid)
658 .appendTo(panel.isApplet() ? this.tabs_applets : this.tabs_msg);
659 });
660
661 _kiwi.app.view.doLayout();
662 },
663
664 updateTabTitle: function (panel, new_title) {
665 $('span', panel.tab).text(new_title);
666 },
667
668 panelAdded: function (panel) {
669 // Add a tab to the panel
670 panel.tab = $('<li><span>' + (panel.get('title') || panel.get('name')) + '</span><div class="activity"></div></li>');
671
672 if (panel.isServer()) {
673 panel.tab.addClass('server');
674 }
675
676 panel.tab.data('panel_id', panel.cid)
677 .appendTo(panel.isApplet() ? this.tabs_applets : this.tabs_msg);
678
679 panel.bind('change:title', this.updateTabTitle);
680 _kiwi.app.view.doLayout();
681 },
682 panelRemoved: function (panel) {
683 panel.tab.remove();
684 delete panel.tab;
685
686 _kiwi.app.view.doLayout();
687 },
688
689 panelActive: function (panel, previously_active_panel) {
690 // Remove any existing tabs or part images
691 $('.part', this.$el).remove();
692 this.tabs_applets.children().removeClass('active');
693 this.tabs_msg.children().removeClass('active');
694
695 panel.tab.addClass('active');
696
697 // Only show the part image on non-server tabs
698 if (!panel.isServer()) {
699 panel.tab.append('<span class="part"></span>');
700 }
701 },
702
703 tabClick: function (e) {
704 var tab = $(e.currentTarget);
705
706 var panel = this.model.getByCid(tab.data('panel_id'));
707 if (!panel) {
708 // A panel wasn't found for this tab... wadda fuck
709 return;
710 }
711
712 panel.view.show();
713 },
714
715 partClick: function (e) {
716 var tab = $(e.currentTarget).parent();
717 var panel = this.model.getByCid(tab.data('panel_id'));
718
719 // Only need to part if it's a channel
720 // If the nicklist is empty, we haven't joined the channel as yet
721 if (panel.isChannel() && panel.get('members').models.length > 0) {
722 _kiwi.gateway.part(panel.get('name'));
723 } else {
724 panel.close();
725 }
726 },
727
728 next: function () {
729 var next = _kiwi.app.panels.active.tab.next();
730 if (!next.length) next = $('li:first', this.tabs_msgs);
731
732 next.click();
733 },
734 prev: function () {
735 var prev = _kiwi.app.panels.active.tab.prev();
736 if (!prev.length) prev = $('li:last', this.tabs_msgs);
737
738 prev.click();
739 }
740 });
741
742
743
744 _kiwi.view.TopicBar = Backbone.View.extend({
745 events: {
746 'keydown div': 'process'
747 },
748
749 initialize: function () {
750 _kiwi.app.panels.bind('active', function (active_panel) {
751 // If it's a channel topic, update and make editable
752 if (active_panel.isChannel()) {
753 this.setCurrentTopic(active_panel.get('topic') || '');
754 this.$el.find('div').attr('contentEditable', true);
755
756 } else {
757 // Not a channel topic.. clear and make uneditable
758 this.$el.find('div').attr('contentEditable', false)
759 .text('');
760 }
761 }, this);
762 },
763
764 process: function (ev) {
765 var inp = $(ev.currentTarget),
766 inp_val = inp.text();
767
768 // Only allow topic editing if this is a channel panel
769 if (!_kiwi.app.panels.active.isChannel()) {
770 return false;
771 }
772
773 // If hit return key, update the current topic
774 if (ev.keyCode === 13) {
775 _kiwi.gateway.topic(_kiwi.app.panels.active.get('name'), inp_val);
776 return false;
777 }
778 },
779
780 setCurrentTopic: function (new_topic) {
781 new_topic = new_topic || '';
782
783 // We only want a plain text version
784 $('div', this.$el).html(formatIRCMsg(_.escape(new_topic)));
785 }
786 });
787
788
789
790 _kiwi.view.ControlBox = Backbone.View.extend({
791 events: {
792 'keydown .inp': 'process',
793 'click .nick': 'showNickChange'
794 },
795
796 initialize: function () {
797 var that = this;
798
799 this.buffer = []; // Stores previously run commands
800 this.buffer_pos = 0; // The current position in the buffer
801
802 this.preprocessor = new InputPreProcessor();
803 this.preprocessor.recursive_depth = 5;
804
805 // Hold tab autocomplete data
806 this.tabcomplete = {active: false, data: [], prefix: ''};
807
808 _kiwi.gateway.bind('change:nick', function () {
809 $('.nick', that.$el).text(this.get('nick'));
810 });
811 },
812
813 showNickChange: function (ev) {
814 (new _kiwi.view.NickChangeBox()).render();
815 },
816
817 process: function (ev) {
818 var that = this,
819 inp = $(ev.currentTarget),
820 inp_val = inp.val(),
821 meta;
822
823 if (navigator.appVersion.indexOf("Mac") !== -1) {
824 meta = ev.metaKey;
825 } else {
826 meta = ev.altKey;
827 }
828
829 // If not a tab key, reset the tabcomplete data
830 if (this.tabcomplete.active && ev.keyCode !== 9) {
831 this.tabcomplete.active = false;
832 this.tabcomplete.data = [];
833 this.tabcomplete.prefix = '';
834 }
835
836 switch (true) {
837 case (ev.keyCode === 13): // return
838 inp_val = inp_val.trim();
839
840 if (inp_val) {
841 $.each(inp_val.split('\n'), function (idx, line) {
842 that.processInput(line);
843 });
844
845 this.buffer.push(inp_val);
846 this.buffer_pos = this.buffer.length;
847 }
848
849 inp.val('');
850 return false;
851
852 break;
853
854 case (ev.keyCode === 38): // up
855 if (this.buffer_pos > 0) {
856 this.buffer_pos--;
857 inp.val(this.buffer[this.buffer_pos]);
858 }
859 break;
860
861 case (ev.keyCode === 40): // down
862 if (this.buffer_pos < this.buffer.length) {
863 this.buffer_pos++;
864 inp.val(this.buffer[this.buffer_pos]);
865 }
866 break;
867
868 case (ev.keyCode === 219 && meta): // [ + meta
869 _kiwi.app.panels.view.prev();
870 return false;
871
872 case (ev.keyCode === 221 && meta): // ] + meta
873 _kiwi.app.panels.view.next();
874 return false;
875
876 case (ev.keyCode === 9): // tab
877 this.tabcomplete.active = true;
878 if (_.isEqual(this.tabcomplete.data, [])) {
879 // Get possible autocompletions
880 var ac_data = [];
881 $.each(_kiwi.app.panels.active.get('members').models, function (i, member) {
882 if (!member) return;
883 ac_data.push(member.get('nick'));
884 });
885 ac_data = _.sortBy(ac_data, function (nick) {
886 return nick;
887 });
888 this.tabcomplete.data = ac_data;
889 }
890
891 if (inp_val[inp[0].selectionStart - 1] === ' ') {
892 return false;
893 }
894
895 (function () {
896 var tokens, // Words before the cursor position
897 val, // New value being built up
898 p1, // Position in the value just before the nick
899 newnick, // New nick to be displayed (cycles through)
900 range, // TextRange for setting new text cursor position
901 nick, // Current nick in the value
902 trailing = ': '; // Text to be inserted after a tabbed nick
903
904 tokens = inp_val.substring(0, inp[0].selectionStart).split(' ');
905 if (tokens[tokens.length-1] == ':')
906 tokens.pop();
907
908 nick = tokens[tokens.length - 1];
909
910 if (this.tabcomplete.prefix === '') {
911 this.tabcomplete.prefix = nick;
912 }
913
914 this.tabcomplete.data = _.select(this.tabcomplete.data, function (n) {
915 return (n.toLowerCase().indexOf(that.tabcomplete.prefix.toLowerCase()) === 0);
916 });
917
918 if (this.tabcomplete.data.length > 0) {
919 // Get the current value before cursor position
920 p1 = inp[0].selectionStart - (nick.length);
921 val = inp_val.substr(0, p1);
922
923 // Include the current selected nick
924 newnick = this.tabcomplete.data.shift();
925 this.tabcomplete.data.push(newnick);
926 val += newnick;
927
928 if (inp_val.substr(inp[0].selectionStart, 2) !== trailing)
929 val += trailing;
930
931 // Now include the rest of the current value
932 val += inp_val.substr(inp[0].selectionStart);
933
934 inp.val(val);
935
936 // Move the cursor position to the end of the nick
937 if (inp[0].setSelectionRange) {
938 inp[0].setSelectionRange(p1 + newnick.length + trailing.length, p1 + newnick.length + trailing.length);
939 } else if (inp[0].createTextRange) { // not sure if this bit is actually needed....
940 range = inp[0].createTextRange();
941 range.collapse(true);
942 range.moveEnd('character', p1 + newnick.length + trailing.length);
943 range.moveStart('character', p1 + newnick.length + trailing.length);
944 range.select();
945 }
946 }
947 }).apply(this);
948 return false;
949 }
950 },
951
952
953 processInput: function (command_raw) {
954 var command, params,
955 pre_processed;
956
957 // The default command
958 if (command_raw[0] !== '/' || command_raw.substr(0, 2) === '//') {
959 // Remove any slash escaping at the start (ie. //)
960 command_raw = command_raw.replace(/^\/\//, '/');
961
962 // Prepend the default command
963 command_raw = '/msg ' + _kiwi.app.panels.active.get('name') + ' ' + command_raw;
964 }
965
966 // Process the raw command for any aliases
967 this.preprocessor.vars.server = _kiwi.gateway.get('name');
968 this.preprocessor.vars.channel = _kiwi.app.panels.active.get('name');
969 this.preprocessor.vars.destination = this.preprocessor.vars.channel;
970 command_raw = this.preprocessor.process(command_raw);
971
972 // Extract the command and parameters
973 params = command_raw.split(' ');
974 if (params[0][0] === '/') {
975 command = params[0].substr(1).toLowerCase();
976 params = params.splice(1, params.length - 1);
977 } else {
978 // Default command
979 command = 'msg';
980 params.unshift(_kiwi.app.panels.active.get('name'));
981 }
982
983 // Trigger the command events
984 this.trigger('command', {command: command, params: params});
985 this.trigger('command:' + command, {command: command, params: params});
986
987 // If we didn't have any listeners for this event, fire a special case
988 // TODO: This feels dirty. Should this really be done..?
989 if (!this._callbacks['command:' + command]) {
990 this.trigger('unknown_command', {command: command, params: params});
991 }
992 }
993 });
994
995
996
997
998 _kiwi.view.StatusMessage = Backbone.View.extend({
999 initialize: function () {
1000 this.$el.hide();
1001
1002 // Timer for hiding the message after X seconds
1003 this.tmr = null;
1004 },
1005
1006 text: function (text, opt) {
1007 // Defaults
1008 opt = opt || {};
1009 opt.type = opt.type || '';
1010 opt.timeout = opt.timeout || 5000;
1011
1012 this.$el.text(text).attr('class', opt.type);
1013 this.$el.slideDown($.proxy(_kiwi.app.view.doLayout, this));
1014
1015 if (opt.timeout) this.doTimeout(opt.timeout);
1016 },
1017
1018 html: function (html, opt) {
1019 // Defaults
1020 opt = opt || {};
1021 opt.type = opt.type || '';
1022 opt.timeout = opt.timeout || 5000;
1023
1024 this.$el.html(text).attr('class', opt.type);
1025 this.$el.slideDown(_kiwi.app.view.doLayout);
1026
1027 if (opt.timeout) this.doTimeout(opt.timeout);
1028 },
1029
1030 hide: function () {
1031 this.$el.slideUp($.proxy(_kiwi.app.view.doLayout, this));
1032 },
1033
1034 doTimeout: function (length) {
1035 if (this.tmr) clearTimeout(this.tmr);
1036 var that = this;
1037 this.tmr = setTimeout(function () { that.hide(); }, length);
1038 }
1039 });
1040
1041
1042
1043
1044 _kiwi.view.ResizeHandler = Backbone.View.extend({
1045 events: {
1046 'mousedown': 'startDrag',
1047 'mouseup': 'stopDrag'
1048 },
1049
1050 initialize: function () {
1051 this.dragging = false;
1052 this.starting_width = {};
1053
1054 $(window).on('mousemove', $.proxy(this.onDrag, this));
1055 },
1056
1057 startDrag: function (event) {
1058 this.dragging = true;
1059 },
1060
1061 stopDrag: function (event) {
1062 this.dragging = false;
1063 },
1064
1065 onDrag: function (event) {
1066 if (!this.dragging) return;
1067
1068 this.$el.css('left', event.clientX - (this.$el.outerWidth(true) / 2));
1069 $('#memberlists').css('width', this.$el.parent().width() - (this.$el.position().left + this.$el.outerWidth()));
1070 _kiwi.app.view.doLayout();
1071 }
1072 });
1073
1074
1075
1076 _kiwi.view.AppToolbar = Backbone.View.extend({
1077 events: {
1078 'click .settings': 'clickSettings'
1079 },
1080
1081 initialize: function () {
1082 },
1083
1084 clickSettings: function (event) {
1085 _kiwi.app.controlbox.processInput('/settings');
1086 }
1087 });
1088
1089
1090
1091 _kiwi.view.Application = Backbone.View.extend({
1092 initialize: function () {
1093 var that = this;
1094
1095 $(window).resize(function() { that.doLayout.apply(that); });
1096 $('#toolbar').resize(function() { that.doLayout.apply(that); });
1097 $('#controlbox').resize(function() { that.doLayout.apply(that); });
1098
1099 // Change the theme when the config is changed
1100 _kiwi.global.settings.on('change:theme', this.updateTheme, this);
1101 this.updateTheme(getQueryVariable('theme'));
1102
1103 _kiwi.global.settings.on('change:channel_list_style', this.setTabLayout, this);
1104 this.setTabLayout(_kiwi.global.settings.get('channel_list_style'));
1105
1106 _kiwi.global.settings.on('change:show_timestamps', this.displayTimestamps, this);
1107 this.displayTimestamps(_kiwi.global.settings.get('show_timestamps'));
1108
1109 this.doLayout();
1110
1111 $(document).keydown(this.setKeyFocus);
1112
1113 // Confirmation require to leave the page
1114 window.onbeforeunload = function () {
1115 if (_kiwi.gateway.isConnected()) {
1116 return 'This will close all KiwiIRC conversations. Are you sure you want to close this window?';
1117 }
1118 };
1119 },
1120
1121
1122
1123 updateTheme: function (theme_name) {
1124 // If called by the settings callback, get the correct new_value
1125 if (theme_name === _kiwi.global.settings) {
1126 theme_name = arguments[1];
1127 }
1128
1129 // If we have no theme specified, get it from the settings
1130 if (!theme_name) theme_name = _kiwi.global.settings.get('theme');
1131
1132 // Clear any current theme
1133 this.$el.removeClass(function (i, css) {
1134 return (css.match(/\btheme_\S+/g) || []).join(' ');
1135 });
1136
1137 // Apply the new theme
1138 this.$el.addClass('theme_' + (theme_name || 'relaxed'));
1139 },
1140
1141
1142 setTabLayout: function (layout_style) {
1143 // If called by the settings callback, get the correct new_value
1144 if (layout_style === _kiwi.global.settings) {
1145 layout_style = arguments[1];
1146 }
1147
1148 if (layout_style == 'list') {
1149 this.$el.addClass('chanlist_treeview');
1150 } else {
1151 this.$el.removeClass('chanlist_treeview');
1152 }
1153
1154 this.doLayout();
1155 },
1156
1157
1158 displayTimestamps: function (show_timestamps) {
1159 // If called by the settings callback, get the correct new_value
1160 if (show_timestamps === _kiwi.global.settings) {
1161 show_timestamps = arguments[1];
1162 }
1163
1164 if (show_timestamps) {
1165 this.$el.addClass('timestamps');
1166 } else {
1167 this.$el.removeClass('timestamps');
1168 }
1169 },
1170
1171
1172 // Globally shift focus to the command input box on a keypress
1173 setKeyFocus: function (ev) {
1174 // If we're copying text, don't shift focus
1175 if (ev.ctrlKey || ev.altKey || ev.metaKey) {
1176 return;
1177 }
1178
1179 // If we're typing into an input box somewhere, ignore
1180 if ((ev.target.tagName.toLowerCase() === 'input') || (ev.target.tagName.toLowerCase() === 'textarea') || $(ev.target).attr('contenteditable')) {
1181 return;
1182 }
1183
1184 $('#controlbox .inp').focus();
1185 },
1186
1187
1188 doLayout: function () {
1189 var el_kiwi = this.$el;
1190 var el_panels = $('#kiwi #panels');
1191 var el_memberlists = $('#kiwi #memberlists');
1192 var el_toolbar = $('#kiwi #toolbar');
1193 var el_controlbox = $('#kiwi #controlbox');
1194 var el_resize_handle = $('#kiwi #memberlists_resize_handle');
1195
1196 var css_heights = {
1197 top: el_toolbar.outerHeight(true),
1198 bottom: el_controlbox.outerHeight(true)
1199 };
1200
1201
1202 // If any elements are not visible, full size the panals instead
1203 if (!el_toolbar.is(':visible')) {
1204 css_heights.top = 0;
1205 }
1206
1207 if (!el_controlbox.is(':visible')) {
1208 css_heights.bottom = 0;
1209 }
1210
1211 // Apply the CSS sizes
1212 el_panels.css(css_heights);
1213 el_memberlists.css(css_heights);
1214 el_resize_handle.css(css_heights);
1215
1216 // If we have channel tabs on the side, adjust the height
1217 if (el_kiwi.hasClass('chanlist_treeview')) {
1218 $('#tabs', el_kiwi).css(css_heights);
1219 }
1220
1221 // Determine if we have a narrow window (mobile/tablet/or even small desktop window)
1222 if (el_kiwi.outerWidth() < 400) {
1223 el_kiwi.addClass('narrow');
1224 } else {
1225 el_kiwi.removeClass('narrow');
1226 }
1227
1228 // Set the panels width depending on the memberlist visibility
1229 if (el_memberlists.css('display') != 'none') {
1230 // Panels to the side of the memberlist
1231 el_panels.css('right', el_memberlists.outerWidth(true));
1232 // The resize handle sits overlapping the panels and memberlist
1233 el_resize_handle.css('left', el_memberlists.position().left - (el_resize_handle.outerWidth(true) / 2));
1234 } else {
1235 // Memberlist is hidden so panels to the right edge
1236 el_panels.css('right', 0);
1237 // And move the handle just out of sight to the right
1238 el_resize_handle.css('left', el_panels.outerWidth(true));
1239 }
1240
1241 var input_wrap_width = parseInt($('#kiwi #controlbox .input_tools').outerWidth());
1242 el_controlbox.find('.input_wrap').css('right', input_wrap_width + 7);
1243 },
1244
1245
1246 alertWindow: function (title) {
1247 if (!this.alertWindowTimer) {
1248 this.alertWindowTimer = new (function () {
1249 var that = this;
1250 var tmr;
1251 var has_focus = true;
1252 var state = 0;
1253 var default_title = 'Kiwi IRC';
1254 var title = 'Kiwi IRC';
1255
1256 this.setTitle = function (new_title) {
1257 new_title = new_title || default_title;
1258 window.document.title = new_title;
1259 return new_title;
1260 };
1261
1262 this.start = function (new_title) {
1263 // Don't alert if we already have focus
1264 if (has_focus) return;
1265
1266 title = new_title;
1267 if (tmr) return;
1268 tmr = setInterval(this.update, 1000);
1269 };
1270
1271 this.stop = function () {
1272 // Stop the timer and clear the title
1273 if (tmr) clearInterval(tmr);
1274 tmr = null;
1275 this.setTitle();
1276
1277 // Some browsers don't always update the last title correctly
1278 // Wait a few seconds and then reset
1279 setTimeout(this.reset, 2000);
1280 };
1281
1282 this.reset = function () {
1283 if (tmr) return;
1284 that.setTitle();
1285 };
1286
1287
1288 this.update = function () {
1289 if (state === 0) {
1290 that.setTitle(title);
1291 state = 1;
1292 } else {
1293 that.setTitle();
1294 state = 0;
1295 }
1296 };
1297
1298 $(window).focus(function (event) {
1299 has_focus = true;
1300 that.stop();
1301
1302 // Some browsers don't always update the last title correctly
1303 // Wait a few seconds and then reset
1304 setTimeout(that.reset, 2000);
1305 });
1306
1307 $(window).blur(function (event) {
1308 has_focus = false;
1309 });
1310 })();
1311 }
1312
1313 this.alertWindowTimer.start(title);
1314 },
1315
1316
1317 barsHide: function (instant) {
1318 var that = this;
1319
1320 if (!instant) {
1321 $('#toolbar').slideUp({queue: false, duration: 400, step: $.proxy(this.doLayout, this)});
1322 $('#controlbox').slideUp({queue: false, duration: 400, step: $.proxy(this.doLayout, this)});
1323 } else {
1324 $('#toolbar').slideUp(0);
1325 $('#controlbox').slideUp(0);
1326 this.doLayout();
1327 }
1328 },
1329
1330 barsShow: function (instant) {
1331 var that = this;
1332
1333 if (!instant) {
1334 $('#toolbar').slideDown({queue: false, duration: 400, step: $.proxy(this.doLayout, this)});
1335 $('#controlbox').slideDown({queue: false, duration: 400, step: $.proxy(this.doLayout, this)});
1336 } else {
1337 $('#toolbar').slideDown(0);
1338 $('#controlbox').slideDown(0);
1339 this.doLayout();
1340 }
1341 }
1342 });
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352 _kiwi.view.MediaMessage = Backbone.View.extend({
1353 events: {
1354 'click .media_close': 'close'
1355 },
1356
1357 initialize: function () {
1358 // Get the URL from the data
1359 this.url = this.$el.data('url');
1360 },
1361
1362 // Close the media content and remove it from display
1363 close: function () {
1364 var that = this;
1365 this.$content.slideUp('fast', function () {
1366 that.$content.remove();
1367 });
1368 },
1369
1370 // Open the media content within its wrapper
1371 open: function () {
1372 // Create the content div if we haven't already
1373 if (!this.$content) {
1374 this.$content = $('<div class="media_content"><a class="media_close"><i class="icon-chevron-up"></i> Close media</a><br /><div class="content"></div></div>');
1375 this.$content.find('.content').append(this.mediaTypes[this.$el.data('type')].apply(this, []) || 'Not found :(');
1376 }
1377
1378 // Now show the content if not already
1379 if (!this.$content.is(':visible')) {
1380 // Hide it first so the slideDown always plays
1381 this.$content.hide();
1382
1383 // Add the media content and slide it into view
1384 this.$el.append(this.$content);
1385 this.$content.slideDown();
1386 }
1387 },
1388
1389
1390
1391 // Generate the media content for each recognised type
1392 mediaTypes: {
1393 twitter: function () {
1394 var tweet_id = this.$el.data('tweetid');
1395 var that = this;
1396
1397 $.getJSON('https://api.twitter.com/1/statuses/oembed.json?id=' + tweet_id + '&callback=?', function (data) {
1398 that.$content.find('.content').html(data.html);
1399 });
1400
1401 return $('<div>Loading tweet..</div>');
1402 },
1403
1404
1405 image: function () {
1406 return $('<a href="' + this.url + '" target="_blank"><img height="100" src="' + this.url + '" /></a>');
1407 },
1408
1409
1410 reddit: function () {
1411 var that = this;
1412 var matches = (/reddit\.com\/r\/([a-zA-Z0-9_\-]+)\/comments\/([a-z0-9]+)\/([^\/]+)?/gi).exec(this.url);
1413
1414 $.getJSON('http://www.' + matches[0] + '.json?jsonp=?', function (data) {
1415 console.log('Loaded reddit data', data);
1416 var post = data[0].data.children[0].data;
1417 var thumb = '';
1418
1419 // Show a thumbnail if there is one
1420 if (post.thumbnail) {
1421 //post.thumbnail = 'http://www.eurotunnel.com/uploadedImages/commercial/back-steps-icon-arrow.png';
1422
1423 // Hide the thumbnail if an over_18 image
1424 if (post.over_18) {
1425 thumb = '<span class="thumbnail_nsfw" onclick="$(this).find(\'p\').remove(); $(this).find(\'img\').css(\'visibility\', \'visible\');">';
1426 thumb += '<p style="font-size:0.9em;line-height:1.2em;cursor:pointer;">Show<br />NSFW</p>';
1427 thumb += '<img src="' + post.thumbnail + '" class="thumbnail" style="visibility:hidden;" />';
1428 thumb += '</span>';
1429 } else {
1430 thumb = '<img src="' + post.thumbnail + '" class="thumbnail" />';
1431 }
1432 }
1433
1434 // Build the template string up
1435 var tmpl = '<div>' + thumb + '<b><%- title %></b><br />Posted by <%- author %>. &nbsp;&nbsp; ';
1436 tmpl += '<i class="icon-arrow-up"></i> <%- ups %> &nbsp;&nbsp; <i class="icon-arrow-down"></i> <%- downs %><br />';
1437 tmpl += '<%- num_comments %> comments made. <a href="http://www.reddit.com<%- permalink %>">View post</a></div>';
1438
1439 that.$content.find('.content').html(_.template(tmpl, post));
1440 });
1441
1442 return $('<div>Loading Reddit thread..</div>');
1443 }
1444 }
1445
1446 }, {
1447
1448 // Build the closed media HTML from a URL
1449 buildHtml: function (url) {
1450 var html = '', matches;
1451
1452 // Is it an image?
1453 if (url.match(/(\.jpe?g|\.gif|\.bmp|\.png)\??$/i)) {
1454 html += '<span class="media image" data-type="image" data-url="' + url + '" title="Open Image"><a class="open"><i class="icon-chevron-right"></i></a></span>';
1455 }
1456
1457 // Is it a tweet?
1458 matches = (/https?:\/\/twitter.com\/([a-zA-Z0-9_]+)\/status\/([0-9]+)/ig).exec(url);
1459 if (matches) {
1460 html += '<span class="media twitter" data-type="twitter" data-url="' + url + '" data-tweetid="' + matches[2] + '" title="Show tweet information"><a class="open"><i class="icon-chevron-right"></i></a></span>';
1461 }
1462
1463 // Is reddit?
1464 matches = (/reddit\.com\/r\/([a-zA-Z0-9_\-]+)\/comments\/([a-z0-9]+)\/([^\/]+)?/gi).exec(url);
1465 if (matches) {
1466 html += '<span class="media reddit" data-type="reddit" data-url="' + url + '" title="Reddit thread"><a class="open"><i class="icon-chevron-right"></i></a></span>';
1467 }
1468
1469 return html;
1470 }
1471 });