Merge pull request #4662 from NodeBB/topic-watching

topic watching
v1.18.x
Barış Soner Uşaklı 9 years ago
commit ca835e35ba

@ -13,6 +13,10 @@
"share_this_category": "Share this category",
"watch": "Watch",
"ignore": "Ignore",
"watching": "Watching",
"ignoring": "Ignoring",
"watching.description": "Show topics in unread",
"ignoring.description": "Do not show topics in unread",
"watch.message": "You are now watching updates from this category",
"ignore.message": "You are now ignoring updates from this category",

@ -38,6 +38,7 @@
"following_topic.message": "You will now be receiving notifications when somebody posts to this topic.",
"not_following_topic.message": "You will no longer receive notifications from this topic.",
"ignoring_topic.message": "You will no longer see this topic in the unread topics list. You will be notified when you are mentioned or your post is up voted.",
"login_to_subscribe": "Please register or log in in order to subscribe to this topic.",
@ -50,6 +51,12 @@
"watch.title": "Be notified of new replies in this topic",
"unwatch.title": "Stop watching this topic",
"share_this_post": "Share this Post",
"watching": "Watching",
"not-watching": "Not Watching",
"ignoring": "Ignoring",
"watching.description": "Notify me of new replies.<br/>Show topic in unread.",
"not-watching.description": "Do not notify me of new replies.<br/>Show topic in unread if category is not ignored.",
"ignoring.description": "Do not notify me of new replies.<br/>Do not show topic in unread.",
"thread_tools.title": "Topic Tools",
"thread_tools.markAsUnreadForAll": "Mark Unread",

@ -21,6 +21,12 @@
overflow-x: hidden;
}
.topic-watch-dropdown {
.help-text {
margin-left: 20px;
}
}
.category-list {
padding: 0;

@ -62,17 +62,20 @@ define('forum/category', [
};
function handleIgnoreWatch(cid) {
$('.watch, .ignore').on('click', function() {
$('[component="category/watching"], [component="category/ignoring"]').on('click', function() {
var $this = $(this);
var command = $this.hasClass('watch') ? 'watch' : 'ignore';
var command = $this.attr('component') === 'category/watching' ? 'watch' : 'ignore';
socket.emit('categories.' + command, cid, function(err) {
if (err) {
return app.alertError(err.message);
}
$('.watch').toggleClass('hidden', command === 'watch');
$('.ignore').toggleClass('hidden', command === 'ignore');
$('[component="category/watching/menu"]').toggleClass('hidden', command !== 'watch');
$('[component="category/watching/check"]').toggleClass('fa-check', command === 'watch');
$('[component="category/ignoring/menu"]').toggleClass('hidden', command !== 'ignore');
$('[component="category/ignoring/check"]').toggleClass('fa-check', command === 'ignore');
app.alertSuccess('[[category:' + command + '.message]]');
});

@ -83,12 +83,18 @@ define('forum/topic/threadTools', [
deletePosts.init();
fork.init();
components.get('topic').on('click', '[component="topic/follow"], [component="topic/unfollow"]', follow);
components.get('topic/follow').off('click').on('click', follow);
components.get('topic/unfollow').off('click').on('click', follow);
$('.topic').on('click', '[component="topic/following"]', function() {
changeWatching('follow');
});
$('.topic').on('click', '[component="topic/not-following"]', function() {
changeWatching('unfollow');
});
$('.topic').on('click', '[component="topic/ignoring"]', function() {
changeWatching('ignore');
});
function follow() {
socket.emit('topics.toggleFollow', tid, function(err, state) {
function changeWatching(type) {
socket.emit('topics.changeWatching', {tid: tid, type: type}, function(err) {
if (err) {
return app.alert({
type: 'danger',
@ -98,12 +104,19 @@ define('forum/topic/threadTools', [
timeout: 5000
});
}
setFollowState(state);
var message = '';
if (type === 'follow') {
message = '[[topic:following_topic.message]]';
} else if (type === 'unfollow') {
message = '[[topic:not_following_topic.message]]';
} else if (type === 'ignore') {
message = '[[topic:ignoring_topic.message]]';
}
setFollowState(type);
app.alert({
alert_id: 'follow_thread',
message: state ? '[[topic:following_topic.message]]' : '[[topic:not_following_topic.message]]',
message: message,
type: 'success',
timeout: 5000
});
@ -195,8 +208,17 @@ define('forum/topic/threadTools', [
};
function setFollowState(state) {
components.get('topic/follow').toggleClass('hidden', state);
components.get('topic/unfollow').toggleClass('hidden', !state);
var menu = components.get('topic/following/menu');
menu.toggleClass('hidden', state !== 'follow');
components.get('topic/following/check').toggleClass('fa-check', state === 'follow');
menu = components.get('topic/not-following/menu');
menu.toggleClass('hidden', state !== 'unfollow');
components.get('topic/not-following/check').toggleClass('fa-check', state === 'unfollow');
menu = components.get('topic/ignoring/menu');
menu.toggleClass('hidden', state !== 'ignore' );
components.get('topic/ignoring/check').toggleClass('fa-check', state === 'ignore');
}

@ -305,4 +305,23 @@ var privileges = require('./privileges');
return tree;
};
Categories.getIgnorers = function(cid, start, stop, callback) {
db.getSortedSetRevRange('cid:' + cid + ':ignorers', start, stop, callback);
};
Categories.filterIgnoringUids = function(cid, uids, callback) {
async.waterfall([
function (next){
db.sortedSetScores('cid:' + cid + ':ignorers', uids, next);
},
function (scores, next) {
var readingUids = uids.filter(function(uid, index) {
return uid && !!scores[index];
});
next(null, readingUids);
}
], callback);
};
}(exports));

@ -38,6 +38,7 @@ module.exports = function(Categories) {
'cid:' + cid + ':tids:posts',
'cid:' + cid + ':pids',
'cid:' + cid + ':read_by_uid',
'cid:' + cid + ':ignorers',
'cid:' + cid + ':children',
'category:' + cid
], next);

@ -4,6 +4,7 @@ var async = require('async');
var winston = require('winston');
var S = require('string');
var db = require('../database');
var websockets = require('./index');
var user = require('../user');
var posts = require('../posts');
@ -27,6 +28,9 @@ SocketHelpers.notifyNew = function(uid, type, result) {
function(uids, next) {
privileges.topics.filterUids('read', result.posts[0].topic.tid, uids, next);
},
function(uids, next) {
filterTidCidIgnorers(uids, result.posts[0].topic.tid, result.posts[0].topic.cid, next);
},
function(uids, next) {
plugins.fireHook('filter:sockets.sendNewPostToUids', {uidsTo: uids, uidFrom: uid, type: type}, next);
}
@ -48,6 +52,31 @@ SocketHelpers.notifyNew = function(uid, type, result) {
});
};
function filterTidCidIgnorers(uids, tid, cid, callback) {
async.waterfall([
function (next) {
async.parallel({
topicFollowed: function(next) {
db.isSetMembers('tid:' + tid + ':followers', uids, next);
},
topicIgnored: function(next) {
db.isSetMembers('tid:' + tid + ':ignorers', uids, next);
},
categoryIgnored: function(next) {
db.sortedSetScores('cid:' + cid + ':ignorers', uids, next);
}
}, next);
},
function (results, next) {
uids = uids.filter(function(uid, index) {
return results.topicFollowed[index] ||
(!results.topicFollowed[index] && !results.topicIgnored[index] && !results.categoryIgnored[index]);
});
next(null, uids);
}
], callback);
}
SocketHelpers.sendNotificationToPostOwner = function(pid, fromuid, notification) {
if (!pid || !fromuid || !notification) {
return;

@ -72,8 +72,15 @@ SocketTopics.createTopicFromPosts = function(socket, data, callback) {
topics.createTopicFromPosts(socket.uid, data.title, data.pids, data.fromTid, callback);
};
SocketTopics.toggleFollow = function(socket, tid, callback) {
followCommand(topics.toggleFollow, socket, tid, callback);
SocketTopics.changeWatching = function(socket, data, callback) {
if (!data.tid || !data.type) {
return callback(new Error('[[error:invalid-data]]'));
}
var commands = ['follow', 'unfollow', 'ignore'];
if (commands.indexOf(data.type) === -1) {
return callback(new Error('[[error:invalid-command]]'));
}
followCommand(topics[data.type], socket, data.tid, callback);
};
SocketTopics.follow = function(socket, tid, callback) {

@ -131,6 +131,9 @@ var social = require('./social');
hasRead: function(next) {
Topics.hasReadTopics(tids, uid, next);
},
isIgnored: function(next) {
Topics.isIgnoring(tids, uid, next);
},
bookmarks: function(next) {
Topics.getUserBookmarks(tids, uid, next);
},
@ -157,7 +160,8 @@ var social = require('./social');
topics[i].pinned = parseInt(topics[i].pinned, 10) === 1;
topics[i].locked = parseInt(topics[i].locked, 10) === 1;
topics[i].deleted = parseInt(topics[i].deleted, 10) === 1;
topics[i].unread = !results.hasRead[i];
topics[i].ignored = results.isIgnored[i];
topics[i].unread = !results.hasRead[i] && !results.isIgnored[i];
topics[i].bookmark = results.bookmarks[i];
topics[i].unreplied = !topics[i].teaser;
}
@ -184,6 +188,7 @@ var social = require('./social');
threadTools: async.apply(plugins.fireHook, 'filter:topic.thread_tools', {topic: topicData, uid: uid, tools: []}),
tags: async.apply(Topics.getTopicTagsObjects, topicData.tid),
isFollowing: async.apply(Topics.isFollowing, [topicData.tid], uid),
isIgnoring: async.apply(Topics.isIgnoring, [topicData.tid], uid),
bookmark: async.apply(Topics.getUserBookmark, topicData.tid, uid),
postSharing: async.apply(social.getActivePostSharing)
}, next);
@ -194,6 +199,8 @@ var social = require('./social');
topicData.thread_tools = results.threadTools.tools;
topicData.tags = results.tags;
topicData.isFollowing = results.isFollowing[0];
topicData.isNotFollowing = !results.isFollowing[0] && !results.isIgnoring[0];
topicData.isIgnoring = results.isIgnoring[0];
topicData.bookmark = results.bookmark;
topicData.postSharing = results.postSharing;

@ -113,6 +113,7 @@ module.exports = function(Topics) {
function(next) {
db.deleteAll([
'tid:' + tid + ':followers',
'tid:' + tid + ':ignorers',
'tid:' + tid + ':posts',
'tid:' + tid + ':posts:votes',
'tid:' + tid + ':bookmarks',

@ -56,12 +56,12 @@ module.exports = function(Topics) {
if (!exists) {
return next(new Error('[[error:no-topic]]'));
}
db.setAdd('tid:' + tid + ':followers', uid, next);
follow(tid, uid, next);
},
async.apply(plugins.fireHook, 'action:topic.follow', { uid: uid, tid: tid }),
function (next) {
db.sortedSetAdd('uid:' + uid + ':followed_tids', Date.now(), tid, next);
}
unignore(tid, uid, next);
},
async.apply(plugins.fireHook, 'action:topic.follow', {uid: uid, tid: tid})
], callback);
};
@ -75,15 +75,78 @@ module.exports = function(Topics) {
if (!exists) {
return next(new Error('[[error:no-topic]]'));
}
db.setRemove('tid:' + tid + ':followers', uid, next);
unfollow(tid, uid, next);
},
function (next) {
unignore(tid, uid, next);
},
async.apply(plugins.fireHook, 'action:topic.unfollow', {uid: uid, tid: tid}),
], callback);
};
Topics.ignore = function(tid, uid, callback) {
callback = callback || function() {};
async.waterfall([
function (next) {
db.sortedSetRemove('uid:' + uid + ':followed_tids', tid, next);
Topics.exists(tid, next);
},
function (exists, next) {
if (!exists) {
return next(new Error('[[error:no-topic]]'));
}
ignore(tid, uid, next);
},
function (next) {
unfollow(tid, uid, next);
},
async.apply(plugins.fireHook, 'action:topic.ignore', {uid: uid, tid: tid})
], callback);
};
function follow(tid, uid, callback) {
async.waterfall([
function (next) {
db.setAdd('tid:' + tid + ':followers', uid, next);
},
function (next) {
db.sortedSetAdd('uid:' + uid + ':followed_tids', Date.now(), tid, next);
}
], callback);
}
function unfollow(tid, uid, callback) {
async.waterfall([
function (next) {
db.setRemove('tid:' + tid + ':followers', uid, next);
},
function (next) {
db.sortedSetRemove('uid:' + uid + ':followed_tids', tid, next);
}
], callback);
}
function ignore(tid, uid, callback) {
async.waterfall([
function (next) {
db.setAdd('tid:' + tid + ':ignorers', uid, next);
},
function(next) {
db.sortedSetAdd('uid:' + uid + ':ignored_tids', Date.now(), tid, next);
}
], callback);
}
function unignore(tid, uid, callback) {
async.waterfall([
function (next) {
db.setRemove('tid:' + tid + ':ignorers', uid, next);
},
function(next) {
db.sortedSetRemove('uid:' + uid + ':ignored_tids', tid, next);
}
], callback);
}
Topics.isFollowing = function(tids, uid, callback) {
if (!Array.isArray(tids)) {
return callback();
@ -97,10 +160,41 @@ module.exports = function(Topics) {
db.isMemberOfSets(keys, uid, callback);
};
Topics.isIgnoring = function(tids, uid, callback) {
if (!Array.isArray(tids)) {
return callback();
}
if (!parseInt(uid, 10)) {
return callback(null, tids.map(function() { return false; }));
}
var keys = tids.map(function(tid) {
return 'tid:' + tid + ':ignorers';
});
db.isMemberOfSets(keys, uid, callback);
};
Topics.getFollowers = function(tid, callback) {
db.getSetMembers('tid:' + tid + ':followers', callback);
};
Topics.getIgnorers = function(tid, callback) {
db.getSetMembers('tid:' + tid + ':ignorers', callback);
};
Topics.filterIgnoringUids = function(tid, uids, callback) {
async.waterfall([
function (next){
db.isSetMembers('tid:' + tid + ':ignorers', uids, next);
},
function (isMembers, next){
var readingUids = uids.filter(function(uid, index) {
return uid && isMembers[index];
});
next(null, readingUids);
}
], callback);
};
Topics.notifyFollowers = function(postData, exceptUid, callback) {
callback = callback || function() {};
var followers;

@ -87,6 +87,9 @@ module.exports = function(Topics) {
}
user.getIgnoredCategories(uid, next);
},
ignoredTids: function(next) {
user.getIgnoredTids(uid, 0, -1, next);
},
recentTids: function(next) {
db.getSortedSetRevRangeByScoreWithScores('topics:recent', 0, -1, '+inf', cutoff, next);
},
@ -116,6 +119,9 @@ module.exports = function(Topics) {
});
var tids = results.recentTids.filter(function(recentTopic) {
if (results.ignoredTids.indexOf(recentTopic.value.toString()) !== -1) {
return false;
}
switch (filter) {
case 'new':
return !userRead[recentTopic.value];
@ -138,7 +144,7 @@ module.exports = function(Topics) {
tids = tids.slice(0, 200);
filterTopics(uid, tids, cid, ignoredCids, next);
filterTopics(uid, tids, cid, ignoredCids, filter, next);
}
], callback);
};
@ -155,7 +161,7 @@ module.exports = function(Topics) {
});
}
function filterTopics(uid, tids, cid, ignoredCids, callback) {
function filterTopics(uid, tids, cid, ignoredCids, filter, callback) {
if (!Array.isArray(ignoredCids) || !tids.length) {
return callback(null, tids);
}
@ -165,11 +171,24 @@ module.exports = function(Topics) {
privileges.topics.filterTids('read', tids, uid, next);
},
function(tids, next) {
async.parallel({
topics: function(next) {
Topics.getTopicsFields(tids, ['tid', 'cid'], next);
},
function(topics, next) {
tids = topics.filter(function(topic) {
return topic && topic.cid && ignoredCids.indexOf(topic.cid.toString()) === -1 && (!cid || parseInt(cid, 10) === parseInt(topic.cid, 10));
isTopicsFollowed: function(next) {
if (filter === 'watched' || filter === 'new') {
return next(null, []);
}
db.sortedSetScores('uid:' + uid + ':followed_tids', tids, next);
}
}, next);
},
function(results, next) {
var topics = results.topics;
tids = topics.filter(function(topic, index) {
return topic && topic.cid &&
(!!results.isTopicsFollowed[index] || ignoredCids.indexOf(topic.cid.toString()) === -1) &&
(!cid || parseInt(cid, 10) === parseInt(topic.cid, 10));
}).map(function(topic) {
return topic.tid;
});

@ -1,12 +1,12 @@
'use strict';
var async = require('async'),
var async = require('async');
plugins = require('./plugins'),
db = require('./database'),
topics = require('./topics'),
privileges = require('./privileges'),
utils = require('../public/src/utils');
var plugins = require('./plugins');
var db = require('./database');
var topics = require('./topics');
var privileges = require('./privileges');
var utils = require('../public/src/utils');
(function(User) {
@ -19,6 +19,7 @@ var async = require('async'),
require('./user/auth')(User);
require('./user/create')(User);
require('./user/posts')(User);
require('./user/topics')(User);
require('./user/categories')(User);
require('./user/follow')(User);
require('./user/profile')(User);

@ -45,6 +45,9 @@ module.exports = function(User) {
return next(new Error('[[error:no-category]]'));
}
db.sortedSetAdd('uid:' + uid + ':ignored:cids', Date.now(), cid, next);
},
function (next) {
db.sortedSetAdd('cid:' + cid + ':ignorers', Date.now(), uid, next);
}
], callback);
};
@ -63,6 +66,9 @@ module.exports = function(User) {
return next(new Error('[[error:no-category]]'));
}
db.sortedSetRemove('uid:' + uid + ':ignored:cids', cid, next);
},
function (next) {
db.sortedSetRemove('cid:' + cid + ':ignorers', uid, next);
}
], callback);
};

@ -102,8 +102,12 @@ module.exports = function(User) {
},
function(next) {
var keys = [
'uid:' + uid + ':notifications:read', 'uid:' + uid + ':notifications:unread',
'uid:' + uid + ':favourites', 'uid:' + uid + ':followed_tids', 'user:' + uid + ':settings',
'uid:' + uid + ':notifications:read',
'uid:' + uid + ':notifications:unread',
'uid:' + uid + ':favourites',
'uid:' + uid + ':followed_tids',
'uid:' + uid + ':ignored_tids',
'user:' + uid + ':settings',
'uid:' + uid + ':topics', 'uid:' + uid + ':posts',
'uid:' + uid + ':chats', 'uid:' + uid + ':chats:unread',
'uid:' + uid + ':chat:rooms', 'uid:' + uid + ':chat:rooms:unread',

@ -1,9 +1,9 @@
'use strict';
var async = require('async'),
db = require('../database'),
meta = require('../meta'),
privileges = require('../privileges');
var async = require('async');
var db = require('../database');
var meta = require('../meta');
var privileges = require('../privileges');
module.exports = function(User) {
@ -83,13 +83,6 @@ module.exports = function(User) {
db.sortedSetAdd('uid:' + uid + ':posts', timestamp, pid, callback);
};
User.addTopicIdToUser = function(uid, tid, timestamp, callback) {
async.parallel([
async.apply(db.sortedSetAdd, 'uid:' + uid + ':topics', timestamp, tid),
async.apply(User.incrementUserFieldBy, uid, 'topiccount', 1)
], callback);
};
User.incrementUserPostCountBy = function(uid, value, callback) {
callback = callback || function() {};
User.incrementUserFieldBy(uid, 'postcount', value, function(err, newpostcount) {

@ -0,0 +1,19 @@
'use strict';
var async = require('async');
var db = require('../database');
module.exports = function(User) {
User.getIgnoredTids = function(uid, start, stop, callback) {
db.getSortedSetRevRange('uid:' + uid + ':ignored_tids', start, stop, callback);
};
User.addTopicIdToUser = function(uid, tid, timestamp, callback) {
async.parallel([
async.apply(db.sortedSetAdd, 'uid:' + uid + ':topics', timestamp, tid),
async.apply(User.incrementUserFieldBy, uid, 'topiccount', 1)
], callback);
};
};

@ -57,28 +57,28 @@ describe('Topic\'s', function() {
});
it('should fail to create new topic with invalid user id', function(done) {
topics.post({uid: null, title: topic.title, content: topic.content, cid: topic.categoryId}, function(err, result) {
topics.post({uid: null, title: topic.title, content: topic.content, cid: topic.categoryId}, function(err) {
assert.equal(err.message, '[[error:no-privileges]]');
done();
});
});
it('should fail to create new topic with empty title', function(done) {
topics.post({uid: topic.userId, title: '', content: topic.content, cid: topic.categoryId}, function(err, result) {
topics.post({uid: topic.userId, title: '', content: topic.content, cid: topic.categoryId}, function(err) {
assert.ok(err);
done();
});
});
it('should fail to create new topic with empty content', function(done) {
topics.post({uid: topic.userId, title: topic.title, content: '', cid: topic.categoryId}, function(err, result) {
topics.post({uid: topic.userId, title: topic.title, content: '', cid: topic.categoryId}, function(err) {
assert.ok(err);
done();
});
});
it('should fail to create new topic with non-existant category id', function(done) {
topics.post({uid: topic.userId, title: topic.title, content: topic.content, cid: 99}, function(err, result) {
topics.post({uid: topic.userId, title: topic.title, content: topic.content, cid: 99}, function(err) {
assert.equal(err.message, '[[error:no-category]]', 'received no error');
done();
});
@ -107,21 +107,21 @@ describe('Topic\'s', function() {
});
it('should fail to create new reply with invalid user id', function(done) {
topics.reply({uid: null, content: 'test post', tid: newTopic.tid}, function(err, result) {
topics.reply({uid: null, content: 'test post', tid: newTopic.tid}, function(err) {
assert.equal(err.message, '[[error:no-privileges]]');
done();
});
});
it('should fail to create new reply with empty content', function(done) {
topics.reply({uid: topic.userId, content: '', tid: newTopic.tid}, function(err, result) {
topics.reply({uid: topic.userId, content: '', tid: newTopic.tid}, function(err) {
assert.ok(err);
done();
});
});
it('should fail to create new reply with invalid topic id', function(done) {
topics.reply({uid: null, content: 'test post', tid: 99}, function(err, result) {
topics.reply({uid: null, content: 'test post', tid: 99}, function(err) {
assert.equal(err.message, '[[error:no-topic]]');
done();
});
@ -189,10 +189,113 @@ describe('Topic\'s', function() {
});
});
describe('.ignore', function(){
var newTid;
var uid;
var newTopic;
before(function(done){
uid = topic.userId;
async.waterfall([
function(done){
topics.post({uid: topic.userId, title: 'Topic to be ignored', content: 'Just ignore me, please!', cid: topic.categoryId}, function(err, result) {
newTopic = result.topicData;
newTid = newTopic.tid;
done();
});
},
function(done){
topics.markUnread( newTid, uid, done );
}
],done);
});
it('should not appear in the unread list', function(done){
async.waterfall([
function(done){
topics.ignore( newTid, uid, done );
},
function(done){
topics.getUnreadTopics(0, uid, 0, -1, '', done );
},
function(results, done){
var topics = results.topics;
var tids = topics.map( function(topic){ return topic.tid; } );
assert.equal(tids.indexOf(newTid), -1, 'The topic appeared in the unread list.');
done();
}
], done);
});
it('should not appear as unread in the recent list', function(done){
async.waterfall([
function(done){
topics.ignore( newTid, uid, done );
},
function(done){
topics.getLatestTopics( uid, 0, -1, 'year', done );
},
function(results, done){
var topics = results.topics;
var topic;
var i;
for(i = 0; i < topics.length; ++i){
if( topics[i].tid == newTid ){
assert.equal(false, topics[i].unread, 'ignored topic was marked as unread in recent list');
return done();
}
}
assert.ok(topic, 'topic didn\'t appear in the recent list');
done();
}
], done);
});
it('should appear as unread again when marked as reading', function(done){
async.waterfall([
function(done){
topics.ignore( newTid, uid, done );
},
function(done){
topics.follow( newTid, uid, done );
},
function(done){
topics.getUnreadTopics(0, uid, 0, -1, '', done );
},
function(results, done){
var topics = results.topics;
var tids = topics.map( function(topic){ return topic.tid; } );
assert.notEqual(tids.indexOf(newTid), -1, 'The topic did not appear in the unread list.');
done();
}
], done);
});
it('should appear as unread again when marked as following', function(done){
async.waterfall([
function(done){
topics.ignore( newTid, uid, done );
},
function(done){
topics.follow( newTid, uid, done );
},
function(done){
topics.getUnreadTopics(0, uid, 0, -1, '', done );
},
function(results, done){
var topics = results.topics;
var tids = topics.map( function(topic){ return topic.tid; } );
assert.notEqual(tids.indexOf(newTid), -1, 'The topic did not appear in the unread list.');
done();
}
], done);
});
});
describe('.fork', function(){
var newTopic;
var replies = new Array();
var replies = [];
var topicPids;
var originalBookmark = 5;
function postReply( next ){

Loading…
Cancel
Save