* Apps bindings support (#5012) * Add redux related information * Add binding loading on channel refresh * Add channel header and post option bindings * Fix test * Minor fixes * Fix snapshots * Handle errors and show bindings only on main channel * Update Expand Levels keys and values to match latest changes * Add NAVIGATE call response handling. * Add more isolation to apps related code * Add defaults to send ephemeral * Update variable naming * Rename shouldProcessApps by a more meaningful appsEnabled * Embedded forms (#5169) * Add redux related information * Add binding loading on channel refresh * Add channel header and post option bindings * Fix test * Minor fixes * Fix snapshots * Handle errors and show bindings only on main channel * Update Expand Levels keys and values to match latest changes * Add NAVIGATE call response handling. * Add more isolation to apps related code * Add Embedded Forms * Fix snapshots * Add Embedded Forms * Improve naming, change the root element to be a binding and improve binding handling * Get post down on the buttons, remove unneeded variables and minor fixes from the review * Allow undefined bindings to fillBindingsInformation and add logging for error. * Address review feedback * Add App Forms (#5177) * Add App Forms * Address feedback and self review * Add dynamic select * Fixes and improvements * Add the ability to refresh on submit. * Use AppFormValue instead of redoing the type * Address feedback * [MM-31508] Rename URL to Path in Call (#5186) * Add user agent to call context (#5193) * Remove unneeded state reference (#5196) * Change user agent query on fetch bindings (#5201) * Add refresh websocket event to refetch bindings (#5194) * Add refresh websocket event to refetch bindings * Add missing changes * Declare socket event constant and separate the handler to a different function * Add binding validation on binding fetch (#5200) * Apps commands (#5107) * Add redux related information * Add binding loading on channel refresh * Add channel header and post option bindings * Fix test * Minor fixes * Fix snapshots * apps modals draft * Handle errors and show bindings only on main channel * Update Expand Levels keys and values to match latest changes * Add NAVIGATE call response handling. * Add more isolation to apps related code * reuse command parser throughout slash_suggestion component's lifecycle * fix prop and lint * using alert to show error message. need another way * duplicate import * types * types * types * rename file * copy webapp parser and test * dependencies moved. tests pass * move app command parser into its own folder * converted to typescript, all tests are passing * automated and manual tests work * lint * lint * remove fall throughs with blocks * types * doAppCall type * extract displayError to deps file * test types * lint * fix tests * unused import * PR feedback * fix imports and show errors * types * remove execute suggestion for mobile * watch feature flag * fix tests * change form text arugment behavior to show user input and not hint * return call response error in doAppCall * update tests to remove hint from text field suggestions * lint * use new base command structure * fix tests * wrap appsEnabled * typescript actions/command.ts * update app constants * PR feedback * fix error handling from doAppCall action * Use App CallRequest structure (#5212) * error handling * move test files * remove unused import Co-authored-by: Daniel Espino García <larkox@gmail.com> * Add feature flag (#5207) * Add feature flag * Simplify return * Add localization, call validation and use call type on subpath (#5221) * Add localization, call validation and use call type on subpath * Add more localization to the parser and bring fixes from webapp * Fix ephemerals and channel header / post options crashes * fix app command parser deps and alert messages * Improve suggestion handling * Fix test * Fix lint * Return errors as error * Address PR feedback * return error property * fix error string wordings Co-authored-by: Michael Kochell <6913320+mickmister@users.noreply.github.com> * migrate app selector to AutocompleteSelector * add if statement to avoid missing prop * add tests for dynamic values * clean up * remove old files Co-authored-by: Daniel Espino García <larkox@gmail.com> Co-authored-by: Ben Schumacher <ben.schumacher@mattermost.com>
506 lines
16 KiB
JavaScript
506 lines
16 KiB
JavaScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {batchActions} from 'redux-batched-actions';
|
|
|
|
import {UserTypes} from '@mm-redux/action_types';
|
|
import {
|
|
doPostAction,
|
|
getNeededAtMentionedUsernames,
|
|
receivedNewPost,
|
|
receivedPost,
|
|
receivedPosts,
|
|
receivedPostsBefore,
|
|
receivedPostsInChannel,
|
|
receivedPostsSince,
|
|
receivedPostsInThread,
|
|
} from '@mm-redux/actions/posts';
|
|
import {Client4} from '@mm-redux/client';
|
|
import {Posts} from '@mm-redux/constants';
|
|
import {getPost as selectPost, getPostIdsInChannel} from '@mm-redux/selectors/entities/posts';
|
|
import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels';
|
|
import {removeUserFromList} from '@mm-redux/utils/user_utils';
|
|
import {isUnreadChannel, isArchivedChannel} from '@mm-redux/utils/channel_utils';
|
|
|
|
import {ViewTypes} from '@constants';
|
|
import {generateId} from '@utils/file';
|
|
import {getChannelSinceValue} from '@utils/channels';
|
|
|
|
import {getEmojisInPosts} from './emoji';
|
|
|
|
export function sendEphemeralPost(message, channelId = '', parentId = '') {
|
|
return async (dispatch, getState) => {
|
|
const timestamp = Date.now();
|
|
const post = {
|
|
id: generateId(),
|
|
user_id: '0',
|
|
channel_id: channelId || getCurrentChannelId(getState()),
|
|
message,
|
|
type: Posts.POST_TYPES.EPHEMERAL,
|
|
create_at: timestamp,
|
|
update_at: timestamp,
|
|
root_id: parentId,
|
|
parent_id: parentId,
|
|
props: {},
|
|
};
|
|
|
|
dispatch(receivedNewPost(post));
|
|
|
|
return {};
|
|
};
|
|
}
|
|
|
|
export function sendAddToChannelEphemeralPost(user, addedUsername, message, channelId, postRootId = '') {
|
|
return async (dispatch) => {
|
|
const timestamp = Date.now();
|
|
const post = {
|
|
id: generateId(),
|
|
user_id: user.id,
|
|
channel_id: channelId,
|
|
message,
|
|
type: Posts.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL,
|
|
create_at: timestamp,
|
|
update_at: timestamp,
|
|
root_id: postRootId,
|
|
parent_id: postRootId,
|
|
props: {
|
|
username: user.username,
|
|
addedUsername,
|
|
},
|
|
};
|
|
|
|
dispatch(receivedNewPost(post));
|
|
};
|
|
}
|
|
|
|
export function setAutocompleteSelector(dataSource, onSelect, options, getDynamicOptions) {
|
|
return {
|
|
type: ViewTypes.SELECTED_ACTION_MENU,
|
|
data: {
|
|
dataSource,
|
|
onSelect,
|
|
options,
|
|
getDynamicOptions,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function selectAttachmentMenuAction(postId, actionId, text, value) {
|
|
return (dispatch) => {
|
|
dispatch({
|
|
type: ViewTypes.SUBMIT_ATTACHMENT_MENU_ACTION,
|
|
postId,
|
|
data: {
|
|
[actionId]: {
|
|
text,
|
|
value,
|
|
},
|
|
},
|
|
});
|
|
|
|
dispatch(doPostAction(postId, actionId, value));
|
|
};
|
|
}
|
|
|
|
export function getPosts(channelId, page = 0, perPage = Posts.POST_CHUNK_SIZE) {
|
|
return async (dispatch, getState) => {
|
|
try {
|
|
const state = getState();
|
|
const {postsInChannel} = state.entities.posts;
|
|
const postForChannel = postsInChannel[channelId];
|
|
const data = await Client4.getPosts(channelId, page, perPage);
|
|
const posts = Object.values(data.posts);
|
|
const actions = [{
|
|
type: ViewTypes.SET_CHANNEL_RETRY_FAILED,
|
|
failed: false,
|
|
}];
|
|
|
|
if (posts?.length) {
|
|
actions.push(receivedPosts(data));
|
|
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
|
if (additional.data.length) {
|
|
actions.push(...additional.data);
|
|
}
|
|
}
|
|
|
|
if (posts?.length || !postForChannel) {
|
|
actions.push(receivedPostsInChannel(data, channelId, page === 0, data.prev_post_id === ''));
|
|
}
|
|
|
|
dispatch(batchActions(actions, 'BATCH_GET_POSTS'));
|
|
|
|
return {data};
|
|
} catch (error) {
|
|
return {error};
|
|
}
|
|
};
|
|
}
|
|
|
|
export function getPost(postId) {
|
|
return async (dispatch) => {
|
|
try {
|
|
const data = await Client4.getPost(postId);
|
|
|
|
if (data) {
|
|
const actions = [
|
|
receivedPost(data),
|
|
];
|
|
|
|
const additional = await dispatch(getPostsAdditionalDataBatch([data]));
|
|
if (additional.data.length) {
|
|
actions.push(...additional.data);
|
|
}
|
|
|
|
dispatch(batchActions(actions, 'BATCH_GET_POST'));
|
|
}
|
|
|
|
return {data};
|
|
} catch (error) {
|
|
return {error};
|
|
}
|
|
};
|
|
}
|
|
|
|
export function getPostsSince(channelId, since) {
|
|
return async (dispatch) => {
|
|
try {
|
|
const data = await Client4.getPostsSince(channelId, since);
|
|
const posts = Object.values(data.posts);
|
|
|
|
if (posts?.length) {
|
|
const actions = [
|
|
receivedPosts(data),
|
|
receivedPostsSince(data, channelId),
|
|
];
|
|
|
|
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
|
if (additional.data.length) {
|
|
actions.push(...additional.data);
|
|
}
|
|
|
|
dispatch(batchActions(actions, 'BATCH_GET_POSTS_SINCE'));
|
|
}
|
|
|
|
return {data};
|
|
} catch (error) {
|
|
return {error};
|
|
}
|
|
};
|
|
}
|
|
|
|
export function getPostsBefore(channelId, postId, page = 0, perPage = Posts.POST_CHUNK_SIZE) {
|
|
return async (dispatch) => {
|
|
try {
|
|
const data = await Client4.getPostsBefore(channelId, postId, page, perPage);
|
|
const posts = Object.values(data.posts);
|
|
|
|
if (posts?.length) {
|
|
const actions = [
|
|
receivedPosts(data),
|
|
receivedPostsBefore(data, channelId, postId, data.prev_post_id === ''),
|
|
];
|
|
|
|
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
|
if (additional.data.length) {
|
|
actions.push(...additional.data);
|
|
}
|
|
|
|
dispatch(batchActions(actions, 'BATCH_GET_POSTS_BEFORE'));
|
|
}
|
|
|
|
return {data};
|
|
} catch (error) {
|
|
return {error};
|
|
}
|
|
};
|
|
}
|
|
|
|
export function getPostThread(rootId, skipDispatch = false) {
|
|
return async (dispatch) => {
|
|
try {
|
|
const data = await Client4.getPostThread(rootId);
|
|
const posts = Object.values(data.posts);
|
|
|
|
if (posts.length) {
|
|
const actions = [
|
|
receivedPosts(data),
|
|
receivedPostsInThread(data, rootId),
|
|
];
|
|
|
|
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
|
if (additional.data.length) {
|
|
actions.push(...additional.data);
|
|
}
|
|
|
|
if (skipDispatch) {
|
|
return {data: actions};
|
|
}
|
|
|
|
dispatch(batchActions(actions, 'BATCH_GET_POSTS_THREAD'));
|
|
}
|
|
|
|
return {data};
|
|
} catch (error) {
|
|
return {error};
|
|
}
|
|
};
|
|
}
|
|
|
|
export function getPostsAround(channelId, postId, perPage = Posts.POST_CHUNK_SIZE / 2) {
|
|
return async (dispatch) => {
|
|
try {
|
|
const [before, thread, after] = await Promise.all([
|
|
Client4.getPostsBefore(channelId, postId, 0, perPage),
|
|
Client4.getPostThread(postId),
|
|
Client4.getPostsAfter(channelId, postId, 0, perPage),
|
|
]);
|
|
|
|
const data = {
|
|
posts: {
|
|
...after.posts,
|
|
...thread.posts,
|
|
...before.posts,
|
|
},
|
|
order: [ // Remember that the order is newest posts first
|
|
...after.order,
|
|
postId,
|
|
...before.order,
|
|
],
|
|
next_post_id: after.next_post_id,
|
|
prev_post_id: before.prev_post_id,
|
|
};
|
|
|
|
const posts = Object.values(data.posts);
|
|
|
|
if (posts?.length) {
|
|
const actions = [
|
|
receivedPosts(data),
|
|
receivedPostsInChannel(data, channelId, after.next_post_id === '', before.prev_post_id === ''),
|
|
];
|
|
|
|
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
|
if (additional.data.length) {
|
|
actions.push(...additional.data);
|
|
}
|
|
|
|
dispatch(batchActions(actions, 'BATCH_GET_POSTS_AROUND'));
|
|
}
|
|
|
|
return {data};
|
|
} catch (error) {
|
|
return {error};
|
|
}
|
|
};
|
|
}
|
|
|
|
export function handleNewPostBatch(WebSocketMessage) {
|
|
return async (dispatch, getState) => {
|
|
const state = getState();
|
|
const post = JSON.parse(WebSocketMessage.data.post);
|
|
const actions = [receivedNewPost(post)];
|
|
|
|
// If we don't have the thread for this post, fetch it from the server
|
|
// and include the actions in the batch
|
|
if (post.root_id) {
|
|
const rootPost = selectPost(state, post.root_id);
|
|
|
|
if (!rootPost) {
|
|
const thread = await dispatch(getPostThread(post.root_id, true));
|
|
if (thread.actions?.length) {
|
|
actions.push(...thread.actions);
|
|
}
|
|
}
|
|
}
|
|
|
|
const additional = await dispatch(getPostsAdditionalDataBatch([post]));
|
|
if (additional.data.length) {
|
|
actions.push(...additional.data);
|
|
}
|
|
|
|
return actions;
|
|
};
|
|
}
|
|
|
|
export function getPostsAdditionalDataBatch(posts = []) {
|
|
return async (dispatch, getState) => {
|
|
const data = [];
|
|
|
|
if (!posts.length) {
|
|
return {data};
|
|
}
|
|
|
|
// Custom Emojis used in the posts
|
|
// Do not wait for this as they need to be loaded one by one
|
|
dispatch(getEmojisInPosts(posts));
|
|
|
|
try {
|
|
const state = getState();
|
|
const promises = [];
|
|
const promiseTrace = [];
|
|
const extra = userMetadataToLoadFromPosts(state, posts);
|
|
|
|
if (extra?.userIds.length) {
|
|
promises.push(Client4.getProfilesByIds(extra.userIds));
|
|
promiseTrace.push('ids');
|
|
}
|
|
|
|
if (extra?.usernames.length) {
|
|
promises.push(Client4.getProfilesByUsernames(extra.usernames));
|
|
promiseTrace.push('usernames');
|
|
}
|
|
|
|
if (extra?.statuses.length) {
|
|
promises.push(Client4.getStatusesByIds(extra.statuses));
|
|
promiseTrace.push('statuses');
|
|
}
|
|
|
|
if (promises.length) {
|
|
const result = await Promise.all(promises);
|
|
result.forEach((p, index) => {
|
|
if (p.length) {
|
|
const type = promiseTrace[index];
|
|
switch (type) {
|
|
case 'statuses':
|
|
data.push({
|
|
type: UserTypes.RECEIVED_STATUSES,
|
|
data: p,
|
|
});
|
|
break;
|
|
default: {
|
|
const {currentUserId} = state.entities.users;
|
|
|
|
removeUserFromList(currentUserId, p);
|
|
data.push({
|
|
type: UserTypes.RECEIVED_PROFILES_LIST,
|
|
data: p,
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} catch (error) {
|
|
// do nothing
|
|
}
|
|
|
|
return {data};
|
|
};
|
|
}
|
|
|
|
function userMetadataToLoadFromPosts(state, posts = []) {
|
|
const {currentUserId, profiles, statuses} = state.entities.users;
|
|
|
|
// Profiles of users mentioned in the posts
|
|
const usernamesToLoad = getNeededAtMentionedUsernames(state, posts);
|
|
|
|
// Statuses and profiles of the users who made the posts
|
|
const userIdsToLoad = new Set();
|
|
const statusesToLoad = new Set();
|
|
|
|
posts.forEach((post) => {
|
|
const userId = post.user_id;
|
|
|
|
if (!statuses[userId]) {
|
|
statusesToLoad.add(userId);
|
|
}
|
|
|
|
if (userId === currentUserId) {
|
|
return;
|
|
}
|
|
|
|
if (!profiles[userId]) {
|
|
userIdsToLoad.add(userId);
|
|
}
|
|
});
|
|
|
|
return {
|
|
usernames: Array.from(usernamesToLoad),
|
|
userIds: Array.from(userIdsToLoad),
|
|
statuses: Array.from(statusesToLoad),
|
|
};
|
|
}
|
|
|
|
export function loadUnreadChannelPosts(channels, channelMembers) {
|
|
return async (dispatch, getState) => {
|
|
const state = getState();
|
|
const currentChannelId = getCurrentChannelId(state);
|
|
|
|
const promises = [];
|
|
const promiseTrace = [];
|
|
|
|
const channelMembersByChannel = {};
|
|
channelMembers.forEach((member) => {
|
|
channelMembersByChannel[member.channel_id] = member;
|
|
});
|
|
|
|
channels.forEach((channel) => {
|
|
if (channel.id === currentChannelId || isArchivedChannel(channel)) {
|
|
return;
|
|
}
|
|
|
|
const isUnread = isUnreadChannel(channelMembersByChannel, channel);
|
|
if (!isUnread) {
|
|
return;
|
|
}
|
|
|
|
const postIds = getPostIdsInChannel(state, channel.id);
|
|
|
|
let promise;
|
|
const trace = {
|
|
channelId: channel.id,
|
|
since: false,
|
|
};
|
|
if (!postIds || !postIds.length) {
|
|
// Get the first page of posts if it appears we haven't gotten it yet, like the webapp
|
|
promise = Client4.getPosts(channel.id);
|
|
} else {
|
|
const since = getChannelSinceValue(state, channel.id, postIds);
|
|
promise = Client4.getPostsSince(channel.id, since);
|
|
trace.since = since;
|
|
}
|
|
|
|
promises.push(promise);
|
|
promiseTrace.push(trace);
|
|
});
|
|
|
|
let posts = [];
|
|
const actions = [];
|
|
if (promises.length) {
|
|
const results = await Promise.all(promises);
|
|
results.forEach((data, index) => {
|
|
const channelPosts = Object.values(data.posts);
|
|
if (channelPosts.length) {
|
|
posts = posts.concat(channelPosts);
|
|
|
|
const trace = promiseTrace[index];
|
|
if (trace.since) {
|
|
actions.push(receivedPostsSince(data, trace.channelId));
|
|
} else {
|
|
actions.push(receivedPostsInChannel(data, trace.channelId, true, data.prev_post_id === ''));
|
|
}
|
|
|
|
actions.push({
|
|
type: ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME,
|
|
channelId: trace.channelId,
|
|
time: Date.now(),
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
console.log(`Fetched ${posts.length} posts from ${promises.length} unread channels`); //eslint-disable-line no-console
|
|
if (posts.length) {
|
|
// receivedPosts should be the first action dispatched as
|
|
// receivedPostsSince and receivedPostsInChannel reducers are
|
|
// dependent on it.
|
|
actions.unshift(receivedPosts({posts}));
|
|
const additional = await dispatch(getPostsAdditionalDataBatch(posts));
|
|
if (additional.data.length) {
|
|
actions.push(...additional.data);
|
|
}
|
|
|
|
dispatch(batchActions(actions));
|
|
}
|
|
};
|
|
}
|