Skip to content

Commit

Permalink
MM-11504: Add unread API actions and selectors for bi-directional scr…
Browse files Browse the repository at this point in the history
…olling (mattermost#873)

* Add getUnreadPosts (mattermost#574)
* Add getUnreadPostsAction
* Add getUnreadPosts API to client4
* Add selector makeGetPostsChunkAroundPost
* Add util func getPostsChunkInChannelAroundTime
* Mark recent true for chunks when dispatching based on post.next_post_id
  for unread API, posts after API
* Keep empty blocks if there will be no blocks after merging
* Change limit for onload unreads
* Add a new util func getUnreadPostsChunk
  • Loading branch information
sudheerDev authored Jul 9, 2019
1 parent fa133c6 commit e04d312
Show file tree
Hide file tree
Showing 7 changed files with 486 additions and 28 deletions.
34 changes: 31 additions & 3 deletions src/actions/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,13 @@ export function receivedPosts(posts) {
}

// receivedPostsAfter should be dispatched when receiving an ordered list of posts that come before a given post.
export function receivedPostsAfter(posts, channelId, afterPostId) {
export function receivedPostsAfter(posts, channelId, afterPostId, recent = false) {
return {
type: PostTypes.RECEIVED_POSTS_AFTER,
channelId,
data: posts,
afterPostId,
recent,
};
}

Expand Down Expand Up @@ -620,6 +621,33 @@ export function getPosts(channelId, page = 0, perPage = Posts.POST_CHUNK_SIZE) {
};
}

export function getPostsUnread(channelId) {
return async (dispatch, getState) => {
const userId = getCurrentUserId(getState());
let posts;
try {
posts = await Client4.getPostsUnread(channelId, userId);
getProfilesAndStatusesForPosts(posts.posts, dispatch, getState);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}

dispatch(batchActions([
receivedPosts(posts),
receivedPostsInChannel(posts, channelId, posts.next_post_id === ''),
]));
dispatch({
type: PostTypes.RECEIVED_POSTS,
data: posts,
channelId,
});

return {data: posts};
};
}

export function getPostsSince(channelId, since) {
return async (dispatch, getState) => {
let posts;
Expand Down Expand Up @@ -679,7 +707,7 @@ export function getPostsAfter(channelId, postId, page = 0, perPage = Posts.POST_

dispatch(batchActions([
receivedPosts(posts),
receivedPostsAfter(posts, channelId, postId),
receivedPostsAfter(posts, channelId, postId, posts.next_post_id === ''),
]));

return {data: posts};
Expand Down Expand Up @@ -722,7 +750,7 @@ export function getPostsAround(channelId, postId, perPage = Posts.POST_CHUNK_SIZ

dispatch(batchActions([
receivedPosts(posts),
receivedPostsInChannel(posts, channelId),
receivedPostsInChannel(posts, channelId, after.posts.next_post_id === ''),
]));

return {data: posts};
Expand Down
73 changes: 73 additions & 0 deletions src/actions/posts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,31 @@ describe('Actions.Posts', () => {
assert.ok(!reactions[post1.id]);
});

it('getPostsUnread', async () => {
const {dispatch, getState} = store;
const channelId = TestHelper.basicChannel.id;
const post = TestHelper.fakePostWithId(channelId);
const userId = getState().entities.users.currentUserId;
const response = {
posts: {
[post.id]: post,
},
order: [post.id],
next_post_id: '',
prev_post_id: '',
};

nock(Client4.getUsersRoute()).
get(`/${userId}/channels/${channelId}/posts/unread`).
query(true).
reply(200, response);

await Actions.getPostsUnread(channelId)(dispatch, getState);
const {posts} = getState().entities.posts;

assert.ok(posts[post.id]);
});

it('getPostThread', async () => {
const channelId = TestHelper.basicChannel.id;
const post = {id: TestHelper.generateId(), channel_id: channelId, message: ''};
Expand Down Expand Up @@ -897,6 +922,54 @@ describe('Actions.Posts', () => {
});
});

it('getPostsAfter with empty next_post_id', async () => {
const channelId = 'channel1';

const post1 = {id: 'post1', channel_id: channelId, create_at: 1001, message: ''};
const post2 = {id: 'post2', channel_id: channelId, root_id: 'post1', create_at: 1002, message: ''};
const post3 = {id: 'post3', channel_id: channelId, create_at: 1003, message: ''};

store = await configureStore({
entities: {
posts: {
posts: {
post1,
},
postsInChannel: {
channel1: [
{order: ['post1'], recent: false},
],
},
},
},
});

const postList = {
order: [post3.id, post2.id],
posts: {
post2,
post3,
},
next_post_id: '',
};

nock(Client4.getChannelsRoute()).
get(`/${channelId}/posts`).
query(true).
reply(200, postList);

const result = await store.dispatch(Actions.getPostsAfter(channelId, 'post1', 0, 10));

expect(result).toEqual({data: postList});

const state = store.getState();

expect(state.entities.posts.posts).toEqual({post1, post2, post3});
expect(state.entities.posts.postsInChannel.channel1).toEqual([
{order: ['post3', 'post2', 'post1'], recent: true},
]);
});

it('getPostsAround', async () => {
const postId = 'post3';
const channelId = 'channel1';
Expand Down
7 changes: 7 additions & 0 deletions src/client/client4.js
Original file line number Diff line number Diff line change
Expand Up @@ -1548,6 +1548,13 @@ export default class Client4 {
);
};

getPostsUnread = async (channelId, userId, limitAfter = 30, limitBefore = 30) => {
return this.doFetch(
`${this.getUserRoute(userId)}/channels/${channelId}/posts/unread${buildQueryString({limit_after: limitAfter, limit_before: limitBefore})}`,
{method: 'get'}
);
};

getPostsSince = async (channelId, since) => {
return this.doFetch(
`${this.getChannelRoute(channelId)}/posts${buildQueryString({since})}`,
Expand Down
13 changes: 7 additions & 6 deletions src/reducers/entities/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -281,11 +281,6 @@ export function postsInChannel(state = {}, action, prevPosts, nextPosts) {
}

const recentBlockIndex = postsForChannel.findIndex((block) => block.recent);
if (recentBlockIndex === -1 && postsForChannel.length > 0) {
// Don't save newly created posts until the most recent posts for the channel have been loaded, unless
// the channel is completely empty
return state;
}

let nextRecentBlock;
if (recentBlockIndex === -1) {
Expand Down Expand Up @@ -444,7 +439,7 @@ export function postsInChannel(state = {}, action, prevPosts, nextPosts) {
// Add a new block including the previous post and then have mergePostBlocks sort out any overlap or duplicates
const newBlock = {
order: [...order, afterPostId],
recent: false,
recent: action.recent,
};

let nextPostsForChannel = [...postsForChannel, newBlock];
Expand Down Expand Up @@ -671,6 +666,12 @@ export function mergePostBlocks(blocks, posts) {
// Remove any blocks that may have become empty by removing posts
nextBlocks = removeEmptyPostBlocks(blocks);

// If a channel does not have any posts(Experimental feature where join and leave messages don't exist)
// return the previous state i.e an empty block
if (!nextBlocks.length) {
return blocks;
}

// Sort blocks so that the most recent one comes first
nextBlocks.sort((a, b) => {
const aStartsAt = posts[a.order[0]].create_at;
Expand Down
15 changes: 11 additions & 4 deletions src/reducers/entities/posts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -610,10 +610,9 @@ describe('postsInChannel', () => {
data: {id: 'post1', channel_id: 'channel1'},
});

expect(nextState).toBe(state);
expect(nextState).toEqual({
channel1: [
{order: ['post2', 'post3'], recent: false},
{order: ['post2', 'post3'], recent: false}, {order: ['post1'], recent: true},
],
});
});
Expand Down Expand Up @@ -885,7 +884,10 @@ describe('postsInChannel', () => {

expect(nextState).not.toBe(state);
expect(nextState).toEqual({
channel1: [],
channel1: [{
order: [],
recent: true,
}],
});
});

Expand Down Expand Up @@ -1144,7 +1146,10 @@ describe('postsInChannel', () => {

expect(nextState).not.toBe(state);
expect(nextState).toEqual({
channel1: [],
channel1: [{
order: [],
recent: false,
}],
});
});

Expand Down Expand Up @@ -1598,6 +1603,7 @@ describe('postsInChannel', () => {
order: ['post1', 'post2'],
},
afterPostId: 'post3',
recent: false,
}, null, nextPosts);

expect(nextState).not.toBe(state);
Expand Down Expand Up @@ -1630,6 +1636,7 @@ describe('postsInChannel', () => {
order: ['post1', 'post2'],
},
afterPostId: 'post3',
recent: false,
}, null, nextPosts);

expect(nextState).not.toBe(state);
Expand Down
85 changes: 70 additions & 15 deletions src/selectors/entities/posts.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,19 +98,16 @@ export function makeGetPostIdsForThread(): (GlobalState, $ID<Post>) => Array<$ID
);
}

export function makeGetPostIdsAroundPost(): (GlobalState, $ID<Post>, $ID<Channel>, {postsBeforeCount: number, postsAfterCount: number}) => ?Array<$ID<Post>> {
export function makeGetPostsChunkAroundPost(): (GlobalState, $ID<Post>, $ID<Channel>) => Object {
return createIdsSelector(
(state: GlobalState, postId, channelId) => state.entities.posts.postsInChannel[channelId],
(state: GlobalState, postId) => postId,
(state: GlobalState, postId, channelId, options) => options && options.postsBeforeCount,
(state: GlobalState, postId, channelId, options) => options && options.postsAfterCount,
(postsForChannel, postId, postsBeforeCount = Posts.POST_CHUNK_SIZE / 2, postsAfterCount = Posts.POST_CHUNK_SIZE / 2) => {
(postsForChannel, postId) => {
if (!postsForChannel) {
return null;
}

let postIds: ?Array<$ID<Post>> = null;
let postIndex = -1;
let postChunk = null;

for (const block of postsForChannel) {
const index = block.order.indexOf(postId);
Expand All @@ -119,17 +116,32 @@ export function makeGetPostIdsAroundPost(): (GlobalState, $ID<Post>, $ID<Channel
continue;
}

postIds = block.order;
postIndex = index;
postChunk = block;
}

if (postIndex === -1 || !postIds) {
return postChunk;
}
);
}

export function makeGetPostIdsAroundPost(): (GlobalState, $ID<Post>, $ID<Channel>, {postsBeforeCount: number, postsAfterCount: number}) => ?Array<$ID<Post>> {
const getPostsChunkAroundPost = makeGetPostsChunkAroundPost();
return createIdsSelector(
(state: GlobalState, postId, channelId) => getPostsChunkAroundPost(state, postId, channelId),
(state: GlobalState, postId) => postId,
(state: GlobalState, postId, channelId, options) => options && options.postsBeforeCount,
(state: GlobalState, postId, channelId, options) => options && options.postsAfterCount,
(postsChunk, postId, postsBeforeCount = Posts.POST_CHUNK_SIZE / 2, postsAfterCount = Posts.POST_CHUNK_SIZE / 2) => {
if (!postsChunk || !postsChunk.order) {
return null;
}

const postIds = postsChunk.order;
const index = postIds.indexOf(postId);

// Remember that posts that come after the post have a smaller index
const minPostIndex = postsAfterCount === -1 ? 0 : Math.max(postIndex - postsAfterCount, 0);
const maxPostIndex = postsBeforeCount === -1 ? postIds.length : Math.min(postIndex + postsBeforeCount + 1, postIds.length); // Needs the extra 1 to include the focused post
const minPostIndex = postsAfterCount === -1 ? 0 : Math.max(index - postsAfterCount, 0);
const maxPostIndex = postsBeforeCount === -1 ? postIds.length : Math.min(index + postsBeforeCount + 1, postIds.length); // Needs the extra 1 to include the focused post

return postIds.slice(minPostIndex, maxPostIndex);
}
Expand Down Expand Up @@ -493,22 +505,65 @@ export const getCurrentUsersLatestPost: (GlobalState, $ID<Post>) => ?PostWithFor
}
);

// getPostIdsInChannel returns the IDs of posts loaded at the bottom of the given channel. It does not include older
// posts such as those loaded by viewing a thread or a permalink.
export function getPostIdsInChannel(state: GlobalState, channelId: $ID<Channel>): ?Array<$ID<Post>> {
export function getRecentPostsChunkInChannel(state: GlobalState, channelId: $ID<Channel>): Object {
const postsForChannel = state.entities.posts.postsInChannel[channelId];
if (!postsForChannel) {
return null;
}

const recentBlock = postsForChannel.find((block) => block.recent);
return postsForChannel.find((block) => block.recent);
}

// getPostIdsInChannel returns the IDs of posts loaded at the bottom of the given channel. It does not include older
// posts such as those loaded by viewing a thread or a permalink.
export function getPostIdsInChannel(state: GlobalState, channelId: $ID<Channel>): ?Array<$ID<Post>> {
const recentBlock = getRecentPostsChunkInChannel(state, channelId);

if (!recentBlock) {
return null;
}

return recentBlock.order;
}

export function getPostsChunkInChannelAroundTime(state: GlobalState, channelId: $ID<Channel>, timeStamp: number): ?Object {
const postsEntity = state.entities.posts;
const postsForChannel = postsEntity.postsInChannel[channelId];
const posts = postsEntity.posts;
if (!postsForChannel) {
return null;
}

const blockAroundTimestamp = postsForChannel.find((block) => {
const {order} = block;
const recentPostInBlock = posts[order[0]];
const oldestPostInBlock = posts[order[order.length - 1]];
if (recentPostInBlock && oldestPostInBlock) {
return (recentPostInBlock.create_at >= timeStamp && oldestPostInBlock.create_at <= timeStamp);
}
return false;
});

return blockAroundTimestamp;
}

export function getUnreadPostsChunk(state: GlobalState, channelId: $ID<Channel>, timeStamp: number): ?Object {
const postsEntity = state.entities.posts;
const posts = postsEntity.posts;
const recentChunk = getRecentPostsChunkInChannel(state, channelId);
if (recentChunk && recentChunk.order.length) {
const {order} = recentChunk;
const oldestPostInBlock = posts[order[order.length - 1]];

// check for only oldest posts because this can be higher than the latest post if the last post is edited
if (oldestPostInBlock.create_at <= timeStamp) {
return recentChunk;
}
}

return getPostsChunkInChannelAroundTime(state, channelId, timeStamp);
}

export const isPostIdSending = (state: GlobalState, postId: $ID<Post>): boolean =>
state.entities.posts.pendingPostIds.some((sendingPostId) => sendingPostId === postId);

Expand Down
Loading

0 comments on commit e04d312

Please sign in to comment.