##// END OF EJS Templates
Refactored thread update code. Use indexOf() instead of contains() for strings...
neko259 -
r1085:f30638e5 default
parent child Browse files
Show More
@@ -1,88 +1,88 b''
1 /*
1 /*
2 @licstart The following is the entire license notice for the
2 @licstart The following is the entire license notice for the
3 JavaScript code in this page.
3 JavaScript code in this page.
4
4
5
5
6 Copyright (C) 2013 neko259
6 Copyright (C) 2013 neko259
7
7
8 The JavaScript code in this page is free software: you can
8 The JavaScript code in this page is free software: you can
9 redistribute it and/or modify it under the terms of the GNU
9 redistribute it and/or modify it under the terms of the GNU
10 General Public License (GNU GPL) as published by the Free Software
10 General Public License (GNU GPL) as published by the Free Software
11 Foundation, either version 3 of the License, or (at your option)
11 Foundation, either version 3 of the License, or (at your option)
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
13 without even the implied warranty of MERCHANTABILITY or FITNESS
13 without even the implied warranty of MERCHANTABILITY or FITNESS
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
15
15
16 As additional permission under GNU GPL version 3 section 7, you
16 As additional permission under GNU GPL version 3 section 7, you
17 may distribute non-source (e.g., minimized or compacted) forms of
17 may distribute non-source (e.g., minimized or compacted) forms of
18 that code without the copy of the GNU GPL normally required by
18 that code without the copy of the GNU GPL normally required by
19 section 4, provided you include this license notice and a URL
19 section 4, provided you include this license notice and a URL
20 through which recipients can access the Corresponding Source.
20 through which recipients can access the Corresponding Source.
21
21
22 @licend The above is the entire license notice
22 @licend The above is the entire license notice
23 for the JavaScript code in this page.
23 for the JavaScript code in this page.
24 */
24 */
25
25
26 var $html = $("html, body");
26 var $html = $("html, body");
27
27
28 function moveCaretToEnd(el) {
28 function moveCaretToEnd(el) {
29 if (typeof el.selectionStart == "number") {
29 if (typeof el.selectionStart == "number") {
30 el.selectionStart = el.selectionEnd = el.value.length;
30 el.selectionStart = el.selectionEnd = el.value.length;
31 } else if (typeof el.createTextRange != "undefined") {
31 } else if (typeof el.createTextRange != "undefined") {
32 el.focus();
32 el.focus();
33 var range = el.createTextRange();
33 var range = el.createTextRange();
34 range.collapse(false);
34 range.collapse(false);
35 range.select();
35 range.select();
36 }
36 }
37 }
37 }
38
38
39 function getForm() {
39 function getForm() {
40 return $('.post-form-w');
40 return $('.post-form-w');
41 }
41 }
42
42
43 function resetFormPosition() {
43 function resetFormPosition() {
44 var form = getForm();
44 var form = getForm();
45 form.insertAfter($('.thread'));
45 form.insertAfter($('.thread'));
46 }
46 }
47
47
48 function addQuickReply(postId) {
48 function addQuickReply(postId) {
49 var postLinkRaw = '[post]' + postId + '[/post]'
49 var postLinkRaw = '[post]' + postId + '[/post]'
50
50
51 var textToAdd = '';
51 var textToAdd = '';
52 var blockToInsert = null;
52 var blockToInsert = null;
53
53
54 var textAreaJq = $('textarea');
54 var textAreaJq = $('textarea');
55
55
56 if (postId != null) {
56 if (postId != null) {
57 var post = $('#' + postId);
57 var post = $('#' + postId);
58
58
59 // If this is not OP, add reflink to the post. If there already is
59 // If this is not OP, add reflink to the post. If there already is
60 // the same reflink, don't add it again.
60 // the same reflink, don't add it again.
61 if (!post.is(':first-child') && !textAreaJq.val().contains(postLinkRaw)) {
61 if (!post.is(':first-child') && textAreaJq.val().indexOf(postLinkRaw) < 0) {
62 textToAdd += postLinkRaw + '\n';
62 textToAdd += postLinkRaw + '\n';
63 }
63 }
64
64
65 blockToInsert = post;
65 blockToInsert = post;
66 } else {
66 } else {
67 blockToInsert = $('.thread');
67 blockToInsert = $('.thread');
68 }
68 }
69
69
70 var selection = window.getSelection().toString();
70 var selection = window.getSelection().toString();
71 if (selection.length > 0) {
71 if (selection.length > 0) {
72 textToAdd += '[quote]' + selection + '[/quote]\n';
72 textToAdd += '[quote]' + selection + '[/quote]\n';
73 }
73 }
74
74
75 textAreaJq.val(textAreaJq.val()+ textToAdd);
75 textAreaJq.val(textAreaJq.val()+ textToAdd);
76
76
77 var form = getForm();
77 var form = getForm();
78 form.insertAfter(blockToInsert);
78 form.insertAfter(blockToInsert);
79 form.show();
79 form.show();
80
80
81 textAreaJq.focus();
81 textAreaJq.focus();
82 var textarea = document.getElementsByTagName('textarea')[0];
82 var textarea = document.getElementsByTagName('textarea')[0];
83 moveCaretToEnd(textarea);
83 moveCaretToEnd(textarea);
84 }
84 }
85
85
86 function scrollToBottom() {
86 function scrollToBottom() {
87 $html.animate({scrollTop: $html.height()}, "fast");
87 $html.animate({scrollTop: $html.height()}, "fast");
88 }
88 }
@@ -1,336 +1,336 b''
1 /*
1 /*
2 @licstart The following is the entire license notice for the
2 @licstart The following is the entire license notice for the
3 JavaScript code in this page.
3 JavaScript code in this page.
4
4
5
5
6 Copyright (C) 2013-2014 neko259
6 Copyright (C) 2013-2014 neko259
7
7
8 The JavaScript code in this page is free software: you can
8 The JavaScript code in this page is free software: you can
9 redistribute it and/or modify it under the terms of the GNU
9 redistribute it and/or modify it under the terms of the GNU
10 General Public License (GNU GPL) as published by the Free Software
10 General Public License (GNU GPL) as published by the Free Software
11 Foundation, either version 3 of the License, or (at your option)
11 Foundation, either version 3 of the License, or (at your option)
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
12 any later version. The code is distributed WITHOUT ANY WARRANTY;
13 without even the implied warranty of MERCHANTABILITY or FITNESS
13 without even the implied warranty of MERCHANTABILITY or FITNESS
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
14 FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
15
15
16 As additional permission under GNU GPL version 3 section 7, you
16 As additional permission under GNU GPL version 3 section 7, you
17 may distribute non-source (e.g., minimized or compacted) forms of
17 may distribute non-source (e.g., minimized or compacted) forms of
18 that code without the copy of the GNU GPL normally required by
18 that code without the copy of the GNU GPL normally required by
19 section 4, provided you include this license notice and a URL
19 section 4, provided you include this license notice and a URL
20 through which recipients can access the Corresponding Source.
20 through which recipients can access the Corresponding Source.
21
21
22 @licend The above is the entire license notice
22 @licend The above is the entire license notice
23 for the JavaScript code in this page.
23 for the JavaScript code in this page.
24 */
24 */
25
25
26 var CLASS_POST = '.post'
27
26 var wsUser = '';
28 var wsUser = '';
27
29
28 var unreadPosts = 0;
30 var unreadPosts = 0;
29 var documentOriginalTitle = '';
31 var documentOriginalTitle = '';
30
32
31 // Thread ID does not change, can be stored one time
33 // Thread ID does not change, can be stored one time
32 var threadId = $('div.thread').children('.post').first().attr('id');
34 var threadId = $('div.thread').children(CLASS_POST).first().attr('id');
33
35
34 /**
36 /**
35 * Connect to websocket server and subscribe to thread updates. On any update we
37 * Connect to websocket server and subscribe to thread updates. On any update we
36 * request a thread diff.
38 * request a thread diff.
37 *
39 *
38 * @returns {boolean} true if connected, false otherwise
40 * @returns {boolean} true if connected, false otherwise
39 */
41 */
40 function connectWebsocket() {
42 function connectWebsocket() {
41 var metapanel = $('.metapanel')[0];
43 var metapanel = $('.metapanel')[0];
42
44
43 var wsHost = metapanel.getAttribute('data-ws-host');
45 var wsHost = metapanel.getAttribute('data-ws-host');
44 var wsPort = metapanel.getAttribute('data-ws-port');
46 var wsPort = metapanel.getAttribute('data-ws-port');
45
47
46 if (wsHost.length > 0 && wsPort.length > 0)
48 if (wsHost.length > 0 && wsPort.length > 0)
47 var centrifuge = new Centrifuge({
49 var centrifuge = new Centrifuge({
48 "url": 'ws://' + wsHost + ':' + wsPort + "/connection/websocket",
50 "url": 'ws://' + wsHost + ':' + wsPort + "/connection/websocket",
49 "project": metapanel.getAttribute('data-ws-project'),
51 "project": metapanel.getAttribute('data-ws-project'),
50 "user": wsUser,
52 "user": wsUser,
51 "timestamp": metapanel.getAttribute('data-last-update'),
53 "timestamp": metapanel.getAttribute('data-last-update'),
52 "token": metapanel.getAttribute('data-ws-token'),
54 "token": metapanel.getAttribute('data-ws-token'),
53 "debug": false
55 "debug": false
54 });
56 });
55
57
56 centrifuge.on('error', function(error_message) {
58 centrifuge.on('error', function(error_message) {
57 console.log("Error connecting to websocket server.");
59 console.log("Error connecting to websocket server.");
58 return false;
60 return false;
59 });
61 });
60
62
61 centrifuge.on('connect', function() {
63 centrifuge.on('connect', function() {
62 var channelName = 'thread:' + threadId;
64 var channelName = 'thread:' + threadId;
63 centrifuge.subscribe(channelName, function(message) {
65 centrifuge.subscribe(channelName, function(message) {
64 getThreadDiff();
66 getThreadDiff();
65 });
67 });
66
68
67 // For the case we closed the browser and missed some updates
69 // For the case we closed the browser and missed some updates
68 getThreadDiff();
70 getThreadDiff();
69 $('#autoupdate').hide();
71 $('#autoupdate').hide();
70 });
72 });
71
73
72 centrifuge.connect();
74 centrifuge.connect();
73
75
74 return true;
76 return true;
75 }
77 }
76
78
77 /**
79 /**
78 * Get diff of the posts from the current thread timestamp.
80 * Get diff of the posts from the current thread timestamp.
79 * This is required if the browser was closed and some post updates were
81 * This is required if the browser was closed and some post updates were
80 * missed.
82 * missed.
81 */
83 */
82 function getThreadDiff() {
84 function getThreadDiff() {
83 var lastUpdateTime = $('.metapanel').attr('data-last-update');
85 var lastUpdateTime = $('.metapanel').attr('data-last-update');
84
86
85 var diffUrl = '/api/diff_thread/' + threadId + '/' + lastUpdateTime + '/';
87 var diffUrl = '/api/diff_thread/' + threadId + '/' + lastUpdateTime + '/';
86
88
87 $.getJSON(diffUrl)
89 $.getJSON(diffUrl)
88 .success(function(data) {
90 .success(function(data) {
89 var addedPosts = data.added;
91 var addedPosts = data.added;
90 var hasMetaUpdates = false;
92 var hasMetaUpdates = false;
91
93
92 for (var i = 0; i < addedPosts.length; i++) {
94 for (var i = 0; i < addedPosts.length; i++) {
93 var postText = addedPosts[i];
95 var postText = addedPosts[i];
94 var post = $(postText);
96 var post = $(postText);
95
97
96 updatePost(post);
98 updatePost(post);
97 hasMetaUpdates = true;
99 hasMetaUpdates = true;
98 }
100 }
99
101
102 var addedPostsCount = addedPosts.length;
103 updateBumplimitProgress(addedPostsCount);
104 showNewPostsTitle(addedPostsCount);
105
100 var updatedPosts = data.updated;
106 var updatedPosts = data.updated;
101
107
102 for (var i = 0; i < updatedPosts.length; i++) {
108 for (var i = 0; i < updatedPosts.length; i++) {
103 var postText = updatedPosts[i];
109 var postText = updatedPosts[i];
104 var post = $(postText);
110 var post = $(postText);
105
111
106 updatePost(post);
112 updatePost(post);
107 hasMetaUpdates = true;
113 hasMetaUpdates = true;
108 }
114 }
109
115
110 if (hasMetaUpdates) {
116 if (hasMetaUpdates) {
111 updateMetadataPanel();
117 updateMetadataPanel();
112 }
118 }
113
119
114 // TODO Process removed posts if any
120 // TODO Process removed posts if any
115 $('.metapanel').attr('data-last-update', data.last_update);
121 $('.metapanel').attr('data-last-update', data.last_update);
116 })
122 })
117 }
123 }
118
124
119 /**
125 /**
120 * Add or update the post on html page.
126 * Add or update the post on html page.
121 */
127 */
122 function updatePost(postHtml) {
128 function updatePost(postHtml) {
123 // This needs to be set on start because the page is scrolled after posts
129 // This needs to be set on start because the page is scrolled after posts
124 // are added or updated
130 // are added or updated
125 var bottom = isPageBottom();
131 var bottom = isPageBottom();
126
132
127 var post = $(postHtml);
133 var post = $(postHtml);
128
134
129 var threadBlock = $('div.thread');
135 var threadBlock = $('div.thread');
130
136
131 var postId = post.attr('id');
137 var postId = post.attr('id');
132
138
133 // If the post already exists, replace it. Otherwise add as a new one.
139 // If the post already exists, replace it. Otherwise add as a new one.
134 var existingPosts = threadBlock.children('.post[id=' + postId + ']');
140 var existingPosts = threadBlock.children('.post[id=' + postId + ']');
135
141
136 if (existingPosts.size() > 0) {
142 if (existingPosts.size() > 0) {
137 existingPosts.replaceWith(post);
143 existingPosts.replaceWith(post);
138 } else {
144 } else {
139 var threadPosts = threadBlock.children('.post');
145 post.appendTo(threadBlock);
140 var lastPost = threadPosts.last();
141
142 post.appendTo(lastPost.parent());
143
144 updateBumplimitProgress(1);
145 showNewPostsTitle(1);
146
146
147 if (bottom) {
147 if (bottom) {
148 scrollToBottom();
148 scrollToBottom();
149 }
149 }
150 }
150 }
151
151
152 processNewPost(post);
152 processNewPost(post);
153 }
153 }
154
154
155 /**
155 /**
156 * Initiate a blinking animation on a node to show it was updated.
156 * Initiate a blinking animation on a node to show it was updated.
157 */
157 */
158 function blink(node) {
158 function blink(node) {
159 var blinkCount = 2;
159 var blinkCount = 2;
160
160
161 var nodeToAnimate = node;
161 var nodeToAnimate = node;
162 for (var i = 0; i < blinkCount; i++) {
162 for (var i = 0; i < blinkCount; i++) {
163 nodeToAnimate = nodeToAnimate.fadeTo('fast', 0.5).fadeTo('fast', 1.0);
163 nodeToAnimate = nodeToAnimate.fadeTo('fast', 0.5).fadeTo('fast', 1.0);
164 }
164 }
165 }
165 }
166
166
167 function isPageBottom() {
167 function isPageBottom() {
168 var scroll = $(window).scrollTop() / ($(document).height()
168 var scroll = $(window).scrollTop() / ($(document).height()
169 - $(window).height());
169 - $(window).height());
170
170
171 return scroll == 1
171 return scroll == 1
172 }
172 }
173
173
174 function initAutoupdate() {
174 function initAutoupdate() {
175 return connectWebsocket();
175 return connectWebsocket();
176 }
176 }
177
177
178 function getReplyCount() {
178 function getReplyCount() {
179 return $('.thread').children('.post').length
179 return $('.thread').children(CLASS_POST).length
180 }
180 }
181
181
182 function getImageCount() {
182 function getImageCount() {
183 return $('.thread').find('img').length
183 return $('.thread').find('img').length
184 }
184 }
185
185
186 /**
186 /**
187 * Update post count, images count and last update time in the metadata
187 * Update post count, images count and last update time in the metadata
188 * panel.
188 * panel.
189 */
189 */
190 function updateMetadataPanel() {
190 function updateMetadataPanel() {
191 var replyCountField = $('#reply-count');
191 var replyCountField = $('#reply-count');
192 var imageCountField = $('#image-count');
192 var imageCountField = $('#image-count');
193
193
194 replyCountField.text(getReplyCount());
194 replyCountField.text(getReplyCount());
195 imageCountField.text(getImageCount());
195 imageCountField.text(getImageCount());
196
196
197 var lastUpdate = $('.post:last').children('.post-info').first()
197 var lastUpdate = $('.post:last').children('.post-info').first()
198 .children('.pub_time').first().html();
198 .children('.pub_time').first().html();
199 if (lastUpdate !== '') {
199 if (lastUpdate !== '') {
200 var lastUpdateField = $('#last-update');
200 var lastUpdateField = $('#last-update');
201 lastUpdateField.html(lastUpdate);
201 lastUpdateField.html(lastUpdate);
202 blink(lastUpdateField);
202 blink(lastUpdateField);
203 }
203 }
204
204
205 blink(replyCountField);
205 blink(replyCountField);
206 blink(imageCountField);
206 blink(imageCountField);
207 }
207 }
208
208
209 /**
209 /**
210 * Update bumplimit progress bar
210 * Update bumplimit progress bar
211 */
211 */
212 function updateBumplimitProgress(postDelta) {
212 function updateBumplimitProgress(postDelta) {
213 var progressBar = $('#bumplimit_progress');
213 var progressBar = $('#bumplimit_progress');
214 if (progressBar) {
214 if (progressBar) {
215 var postsToLimitElement = $('#left_to_limit');
215 var postsToLimitElement = $('#left_to_limit');
216
216
217 var oldPostsToLimit = parseInt(postsToLimitElement.text());
217 var oldPostsToLimit = parseInt(postsToLimitElement.text());
218 var postCount = getReplyCount();
218 var postCount = getReplyCount();
219 var bumplimit = postCount - postDelta + oldPostsToLimit;
219 var bumplimit = postCount - postDelta + oldPostsToLimit;
220
220
221 var newPostsToLimit = bumplimit - postCount;
221 var newPostsToLimit = bumplimit - postCount;
222 if (newPostsToLimit <= 0) {
222 if (newPostsToLimit <= 0) {
223 $('.bar-bg').remove();
223 $('.bar-bg').remove();
224 } else {
224 } else {
225 postsToLimitElement.text(newPostsToLimit);
225 postsToLimitElement.text(newPostsToLimit);
226 progressBar.width((100 - postCount / bumplimit * 100.0) + '%');
226 progressBar.width((100 - postCount / bumplimit * 100.0) + '%');
227 }
227 }
228 }
228 }
229 }
229 }
230
230
231 /**
231 /**
232 * Show 'new posts' text in the title if the document is not visible to a user
232 * Show 'new posts' text in the title if the document is not visible to a user
233 */
233 */
234 function showNewPostsTitle(newPostCount) {
234 function showNewPostsTitle(newPostCount) {
235 if (document.hidden) {
235 if (document.hidden) {
236 if (documentOriginalTitle === '') {
236 if (documentOriginalTitle === '') {
237 documentOriginalTitle = document.title;
237 documentOriginalTitle = document.title;
238 }
238 }
239 unreadPosts = unreadPosts + newPostCount;
239 unreadPosts = unreadPosts + newPostCount;
240 document.title = '[' + unreadPosts + '] ' + documentOriginalTitle;
240 document.title = '[' + unreadPosts + '] ' + documentOriginalTitle;
241
241
242 document.addEventListener('visibilitychange', function() {
242 document.addEventListener('visibilitychange', function() {
243 if (documentOriginalTitle !== '') {
243 if (documentOriginalTitle !== '') {
244 document.title = documentOriginalTitle;
244 document.title = documentOriginalTitle;
245 documentOriginalTitle = '';
245 documentOriginalTitle = '';
246 unreadPosts = 0;
246 unreadPosts = 0;
247 }
247 }
248
248
249 document.removeEventListener('visibilitychange', null);
249 document.removeEventListener('visibilitychange', null);
250 });
250 });
251 }
251 }
252 }
252 }
253
253
254 /**
254 /**
255 * Clear all entered values in the form fields
255 * Clear all entered values in the form fields
256 */
256 */
257 function resetForm(form) {
257 function resetForm(form) {
258 form.find('input:text, input:password, input:file, select, textarea').val('');
258 form.find('input:text, input:password, input:file, select, textarea').val('');
259 form.find('input:radio, input:checkbox')
259 form.find('input:radio, input:checkbox')
260 .removeAttr('checked').removeAttr('selected');
260 .removeAttr('checked').removeAttr('selected');
261 $('.file_wrap').find('.file-thumb').remove();
261 $('.file_wrap').find('.file-thumb').remove();
262 }
262 }
263
263
264 /**
264 /**
265 * When the form is posted, this method will be run as a callback
265 * When the form is posted, this method will be run as a callback
266 */
266 */
267 function updateOnPost(response, statusText, xhr, form) {
267 function updateOnPost(response, statusText, xhr, form) {
268 var json = $.parseJSON(response);
268 var json = $.parseJSON(response);
269 var status = json.status;
269 var status = json.status;
270
270
271 showAsErrors(form, '');
271 showAsErrors(form, '');
272
272
273 if (status === 'ok') {
273 if (status === 'ok') {
274 resetFormPosition();
274 resetFormPosition();
275 resetForm(form);
275 resetForm(form);
276 getThreadDiff();
276 getThreadDiff();
277 scrollToBottom();
277 scrollToBottom();
278 } else {
278 } else {
279 var errors = json.errors;
279 var errors = json.errors;
280 for (var i = 0; i < errors.length; i++) {
280 for (var i = 0; i < errors.length; i++) {
281 var fieldErrors = errors[i];
281 var fieldErrors = errors[i];
282
282
283 var error = fieldErrors.errors;
283 var error = fieldErrors.errors;
284
284
285 showAsErrors(form, error);
285 showAsErrors(form, error);
286 }
286 }
287 }
287 }
288 }
288 }
289
289
290 /**
290 /**
291 * Show text in the errors row of the form.
291 * Show text in the errors row of the form.
292 * @param form
292 * @param form
293 * @param text
293 * @param text
294 */
294 */
295 function showAsErrors(form, text) {
295 function showAsErrors(form, text) {
296 form.children('.form-errors').remove();
296 form.children('.form-errors').remove();
297
297
298 if (text.length > 0) {
298 if (text.length > 0) {
299 var errorList = $('<div class="form-errors">' + text + '<div>');
299 var errorList = $('<div class="form-errors">' + text + '<div>');
300 errorList.appendTo(form);
300 errorList.appendTo(form);
301 }
301 }
302 }
302 }
303
303
304 /**
304 /**
305 * Run js methods that are usually run on the document, on the new post
305 * Run js methods that are usually run on the document, on the new post
306 */
306 */
307 function processNewPost(post) {
307 function processNewPost(post) {
308 addRefLinkPreview(post[0]);
308 addRefLinkPreview(post[0]);
309 highlightCode(post);
309 highlightCode(post);
310 blink(post);
310 blink(post);
311 }
311 }
312
312
313 $(document).ready(function(){
313 $(document).ready(function(){
314 if (initAutoupdate()) {
314 if (initAutoupdate()) {
315 // Post form data over AJAX
315 // Post form data over AJAX
316 var threadId = $('div.thread').children('.post').first().attr('id');
316 var threadId = $('div.thread').children('.post').first().attr('id');
317
317
318 var form = $('#form');
318 var form = $('#form');
319
319
320 if (form.length > 0) {
320 if (form.length > 0) {
321 var options = {
321 var options = {
322 beforeSubmit: function(arr, $form, options) {
322 beforeSubmit: function(arr, $form, options) {
323 showAsErrors($('form'), gettext('Sending message...'));
323 showAsErrors($('form'), gettext('Sending message...'));
324 },
324 },
325 success: updateOnPost,
325 success: updateOnPost,
326 url: '/api/add_post/' + threadId + '/'
326 url: '/api/add_post/' + threadId + '/'
327 };
327 };
328
328
329 form.ajaxForm(options);
329 form.ajaxForm(options);
330
330
331 resetForm(form);
331 resetForm(form);
332 }
332 }
333 }
333 }
334
334
335 $('#autoupdate').click(getThreadDiff);
335 $('#autoupdate').click(getThreadDiff);
336 });
336 });
General Comments 0
You need to be logged in to leave comments. Login now