forked from Ivasoft/mattermost-mobile
[Gekidou] post list (#5893)
This commit is contained in:
@@ -7,7 +7,7 @@ import {DeviceEventEmitter} from 'react-native';
|
||||
import {Navigation as NavigationConstants, Screens} from '@constants';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {prepareDeleteChannel, queryAllMyChannelIds, queryChannelsById, queryMyChannel} from '@queries/servers/channel';
|
||||
import {prepareCommonSystemValues, PrepareCommonSystemValuesArgs, queryCommonSystemValues, queryCurrentTeamId} from '@queries/servers/system';
|
||||
import {prepareCommonSystemValues, PrepareCommonSystemValuesArgs, queryCommonSystemValues, queryCurrentTeamId, setCurrentChannelId} from '@queries/servers/system';
|
||||
import {addChannelToTeamHistory, addTeamToTeamHistory, removeChannelFromTeamHistory} from '@queries/servers/team';
|
||||
import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation';
|
||||
import {isTablet} from '@utils/helpers';
|
||||
@@ -31,6 +31,11 @@ export const switchToChannel = async (serverUrl: string, channelId: string, team
|
||||
const {operator} = DatabaseManager.serverDatabases[serverUrl];
|
||||
const models = [];
|
||||
const commonValues: PrepareCommonSystemValuesArgs = {currentChannelId: channelId};
|
||||
if (isTabletDevice) {
|
||||
// On tablet, the channel is being rendered, by setting the channel to empty first we speed up
|
||||
// the switch by ~3x
|
||||
await setCurrentChannelId(operator, '');
|
||||
}
|
||||
|
||||
if (teamId && system.currentTeamId !== teamId) {
|
||||
commonValues.currentTeamId = teamId;
|
||||
@@ -168,3 +173,26 @@ export const markChannelAsViewed = async (serverUrl: string, channelId: string,
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export const resetMessageCount = async (serverUrl: string, channelId: string) => {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
return {error: `${serverUrl} database not found`};
|
||||
}
|
||||
|
||||
const member = await queryMyChannel(operator.database, channelId);
|
||||
if (!member) {
|
||||
return {error: 'not a member'};
|
||||
}
|
||||
|
||||
try {
|
||||
member.prepareUpdate((m) => {
|
||||
m.messageCount = 0;
|
||||
});
|
||||
await operator.batchRecords([member]);
|
||||
|
||||
return member;
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -132,7 +132,7 @@ export const selectAttachmentMenuAction = (serverUrl: string, postId: string, ac
|
||||
return postActionWithCookie(serverUrl, postId, actionId, '', selectedOption);
|
||||
};
|
||||
|
||||
export const processPostsFetched = async (serverUrl: string, actionType: string, data: {order: string[]; posts: Post[]; prev_post_id?: string}, fetchOnly = false) => {
|
||||
export const processPostsFetched = async (serverUrl: string, actionType: string, data: PostResponse, fetchOnly = false) => {
|
||||
const order = data.order;
|
||||
const posts = Object.values(data.posts) as Post[];
|
||||
const previousPostId = data.prev_post_id;
|
||||
|
||||
@@ -1,59 +1,12 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {fetchMyChannelsForTeam} from '@actions/remote/channel';
|
||||
import {fetchPostsForChannel, fetchPostsForUnreadChannels} from '@actions/remote/post';
|
||||
import {fetchAllTeams} from '@actions/remote/team';
|
||||
import Events from '@constants/events';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {prepareCommonSystemValues, queryCurrentTeamId} from '@queries/servers/system';
|
||||
import {prepareDeleteTeam, queryMyTeamById, removeTeamFromTeamHistory, queryLastChannelFromTeam, addTeamToTeamHistory} from '@queries/servers/team';
|
||||
import {isTablet} from '@utils/helpers';
|
||||
import {prepareDeleteTeam, queryMyTeamById, removeTeamFromTeamHistory} from '@queries/servers/team';
|
||||
|
||||
import type TeamModel from '@typings/database/models/servers/team';
|
||||
|
||||
export const handleTeamChange = async (serverUrl: string, teamId: string) => {
|
||||
const {operator, database} = DatabaseManager.serverDatabases[serverUrl];
|
||||
const currentTeamId = await queryCurrentTeamId(database);
|
||||
|
||||
if (currentTeamId === teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let channelId = '';
|
||||
if (await isTablet()) {
|
||||
channelId = await queryLastChannelFromTeam(database, teamId);
|
||||
if (channelId) {
|
||||
fetchPostsForChannel(serverUrl, channelId);
|
||||
}
|
||||
}
|
||||
const models = [];
|
||||
const system = await prepareCommonSystemValues(operator, {currentChannelId: channelId, currentTeamId: teamId});
|
||||
if (system?.length) {
|
||||
models.push(...system);
|
||||
}
|
||||
const history = await addTeamToTeamHistory(operator, teamId, true);
|
||||
if (history.length) {
|
||||
models.push(...history);
|
||||
}
|
||||
|
||||
if (models.length) {
|
||||
operator.batchRecords(models);
|
||||
}
|
||||
|
||||
const {channels, memberships, error} = await fetchMyChannelsForTeam(serverUrl, teamId);
|
||||
if (error) {
|
||||
DeviceEventEmitter.emit(Events.TEAM_LOAD_ERROR, serverUrl, error);
|
||||
}
|
||||
|
||||
if (channels?.length && memberships?.length) {
|
||||
fetchPostsForUnreadChannels(serverUrl, channels, memberships, channelId);
|
||||
}
|
||||
};
|
||||
|
||||
export const localRemoveUserFromTeam = async (serverUrl: string, teamId: string) => {
|
||||
export const removeUserFromTeam = async (serverUrl: string, teamId: string) => {
|
||||
const serverDatabase = DatabaseManager.serverDatabases[serverUrl];
|
||||
if (!serverDatabase) {
|
||||
return;
|
||||
@@ -77,7 +30,5 @@ export const localRemoveUserFromTeam = async (serverUrl: string, teamId: string)
|
||||
console.log('FAILED TO BATCH CHANGES FOR REMOVE USER FROM TEAM');
|
||||
}
|
||||
}
|
||||
|
||||
fetchAllTeams(serverUrl);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -18,12 +18,14 @@ import TeamModel from '@typings/database/models/servers/team';
|
||||
import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url';
|
||||
import {displayGroupMessageName, displayUsername} from '@utils/user';
|
||||
|
||||
import {fetchPostsForChannel} from './post';
|
||||
import {fetchRolesIfNeeded} from './role';
|
||||
import {forceLogoutIfNecessary} from './session';
|
||||
import {addUserToTeam, fetchTeamByName, removeUserFromTeam} from './team';
|
||||
import {fetchProfilesPerChannels, fetchUsersByIds} from './user';
|
||||
|
||||
import type {Client} from '@client/rest';
|
||||
import type ChannelInfoModel from '@typings/database/models/servers/channel_info';
|
||||
|
||||
export type MyChannelsRequest = {
|
||||
channels?: Channel[];
|
||||
@@ -144,6 +146,44 @@ export const fetchChannelCreator = async (serverUrl: string, channelId: string,
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchChannelStats = async (serverUrl: string, channelId: string, fetchOnly = false) => {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
return {error: `${serverUrl} database not found`};
|
||||
}
|
||||
|
||||
let client: Client;
|
||||
try {
|
||||
client = NetworkManager.getClient(serverUrl);
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await client.getChannelStats(channelId);
|
||||
if (!fetchOnly) {
|
||||
const channel = await queryChannelById(operator.database, channelId);
|
||||
if (channel) {
|
||||
const channelInfo = await channel.info.fetch() as ChannelInfoModel;
|
||||
const channelInfos: ChannelInfo[] = [{
|
||||
guest_count: stats.guest_count,
|
||||
header: channelInfo.header,
|
||||
id: channelId,
|
||||
member_count: stats.member_count,
|
||||
pinned_post_count: stats.pinnedpost_count,
|
||||
purpose: channelInfo.purpose,
|
||||
}];
|
||||
await operator.handleChannelInfo({channelInfos, prepareRecordsOnly: false});
|
||||
}
|
||||
}
|
||||
|
||||
return {stats};
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchMyChannelsForTeam = async (serverUrl: string, teamId: string, includeDeleted = true, since = 0, fetchOnly = false, excludeDirect = false): Promise<MyChannelsRequest> => {
|
||||
let client: Client;
|
||||
try {
|
||||
@@ -503,7 +543,7 @@ export const switchToChannelByName = async (serverUrl: string, channelName: stri
|
||||
}
|
||||
|
||||
if (teamId && channelId) {
|
||||
await switchToChannel(serverUrl, channelId, teamId);
|
||||
await switchToChannelById(serverUrl, channelId, teamId);
|
||||
}
|
||||
|
||||
if (roles.length) {
|
||||
@@ -516,3 +556,17 @@ export const switchToChannelByName = async (serverUrl: string, channelName: stri
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export const switchToChannelById = async (serverUrl: string, channelId: string, teamId?: string) => {
|
||||
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
||||
if (!database) {
|
||||
return {error: `${serverUrl} database not found`};
|
||||
}
|
||||
|
||||
fetchPostsForChannel(serverUrl, channelId);
|
||||
await switchToChannel(serverUrl, channelId, teamId);
|
||||
markChannelAsRead(serverUrl, channelId);
|
||||
fetchChannelStats(serverUrl, channelId);
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {switchToChannelById} from '@actions/remote/channel';
|
||||
import {fetchRoles} from '@actions/remote/role';
|
||||
import {fetchConfigAndLicense} from '@actions/remote/systems';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {queryChannelsById, queryDefaultChannelForTeam} from '@queries/servers/channel';
|
||||
import {prepareModels} from '@queries/servers/entry';
|
||||
import {prepareCommonSystemValues, queryCommonSystemValues, queryCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system';
|
||||
import {prepareCommonSystemValues, queryCommonSystemValues, queryCurrentChannelId, queryCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system';
|
||||
import {deleteMyTeams, queryTeamsById} from '@queries/servers/team';
|
||||
import {queryCurrentUser} from '@queries/servers/user';
|
||||
import {deleteV1Data} from '@utils/file';
|
||||
@@ -21,6 +22,7 @@ export const appEntry = async (serverUrl: string) => {
|
||||
}
|
||||
const {database} = operator;
|
||||
|
||||
const tabletDevice = await isTablet();
|
||||
const currentTeamId = await queryCurrentTeamId(database);
|
||||
const fetchedData = await fetchAppEntryData(serverUrl, currentTeamId);
|
||||
const fetchedError = (fetchedData as AppEntryError).error;
|
||||
@@ -31,15 +33,31 @@ export const appEntry = async (serverUrl: string) => {
|
||||
|
||||
const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds} = fetchedData as AppEntryData;
|
||||
|
||||
if (initialTeamId !== currentTeamId) {
|
||||
if (initialTeamId === currentTeamId) {
|
||||
let cId = await queryCurrentChannelId(database);
|
||||
if (tabletDevice) {
|
||||
if (!cId) {
|
||||
const channel = await queryDefaultChannelForTeam(database, initialTeamId);
|
||||
if (channel) {
|
||||
cId = channel.id;
|
||||
}
|
||||
}
|
||||
|
||||
switchToChannelById(serverUrl, cId, initialTeamId);
|
||||
}
|
||||
} else {
|
||||
// Immediately set the new team as the current team in the database so that the UI
|
||||
// renders the correct team.
|
||||
let channelId = '';
|
||||
if ((await isTablet())) {
|
||||
if (tabletDevice) {
|
||||
const channel = await queryDefaultChannelForTeam(database, initialTeamId);
|
||||
channelId = channel?.id || '';
|
||||
}
|
||||
setCurrentTeamAndChannelId(operator, initialTeamId, channelId);
|
||||
if (channelId) {
|
||||
switchToChannelById(serverUrl, channelId, initialTeamId);
|
||||
} else {
|
||||
setCurrentTeamAndChannelId(operator, initialTeamId, channelId);
|
||||
}
|
||||
}
|
||||
|
||||
let removeTeams;
|
||||
|
||||
@@ -81,7 +81,7 @@ export const fetchAppEntryData = async (serverUrl: string, initialTeamId: string
|
||||
removeTeamIds,
|
||||
};
|
||||
|
||||
if (teamData.teams?.length === 0) {
|
||||
if (teamData.teams?.length === 0 && !teamData.error) {
|
||||
// User is no longer a member of any team
|
||||
const myTeams = await queryMyTeams(database);
|
||||
removeTeamIds.push(...(myTeams?.map((myTeam) => myTeam.id) || []));
|
||||
@@ -95,7 +95,7 @@ export const fetchAppEntryData = async (serverUrl: string, initialTeamId: string
|
||||
|
||||
const inTeam = teamData.teams?.find((t) => t.id === initialTeamId);
|
||||
const chError = chData?.error as ClientError | undefined;
|
||||
if (!inTeam || chError?.status_code === 403) {
|
||||
if ((!inTeam && !teamData.error) || chError?.status_code === 403) {
|
||||
// User is no longer a member of the current team
|
||||
if (!removeTeamIds.includes(initialTeamId)) {
|
||||
removeTeamIds.push(initialTeamId);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
//
|
||||
import Model from '@nozbe/watermelondb/Model';
|
||||
|
||||
import {DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {processPostsFetched} from '@actions/local/post';
|
||||
import {ActionType, General} from '@constants';
|
||||
import {ActionType, Events, General} from '@constants';
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {getNeededAtMentionedUsernames} from '@helpers/api/user';
|
||||
@@ -17,6 +18,7 @@ import {queryAllUsers} from '@queries/servers/user';
|
||||
import {forceLogoutIfNecessary} from './session';
|
||||
|
||||
import type {Client} from '@client/rest';
|
||||
import type Model from '@nozbe/watermelondb/Model';
|
||||
|
||||
type PostsRequest = {
|
||||
error?: unknown;
|
||||
@@ -64,19 +66,38 @@ export const fetchPostsForChannel = async (serverUrl: string, channelId: string)
|
||||
}
|
||||
|
||||
if (data.posts?.length && data.order?.length) {
|
||||
const models: Model[] = [];
|
||||
try {
|
||||
await fetchPostAuthors(serverUrl, data.posts, false);
|
||||
const {authors} = await fetchPostAuthors(serverUrl, data.posts, true);
|
||||
if (authors?.length) {
|
||||
const users = await operator.handleUsers({
|
||||
users: authors,
|
||||
prepareRecordsOnly: true,
|
||||
});
|
||||
if (users.length) {
|
||||
models.push(...users);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('FETCH AUTHORS ERROR', error);
|
||||
}
|
||||
|
||||
operator.handlePosts({
|
||||
const postModels = await operator.handlePosts({
|
||||
actionType,
|
||||
order: data.order,
|
||||
posts: data.posts,
|
||||
previousPostId: data.previousPostId,
|
||||
prepareRecordsOnly: true,
|
||||
});
|
||||
|
||||
if (postModels.length) {
|
||||
models.push(...postModels);
|
||||
}
|
||||
|
||||
if (models.length) {
|
||||
await operator.batchRecords(models);
|
||||
}
|
||||
}
|
||||
|
||||
return {posts: data.posts};
|
||||
@@ -119,6 +140,65 @@ export const fetchPosts = async (serverUrl: string, channelId: string, page = 0,
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchPostsBefore = async (serverUrl: string, channelId: string, postId: string, perPage = General.POST_CHUNK_SIZE, fetchOnly = false) => {
|
||||
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
|
||||
if (!operator) {
|
||||
return {error: `${serverUrl} database not found`};
|
||||
}
|
||||
|
||||
let client: Client;
|
||||
try {
|
||||
client = NetworkManager.getClient(serverUrl);
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
const activeServerUrl = await DatabaseManager.getActiveServerUrl();
|
||||
|
||||
try {
|
||||
if (activeServerUrl === serverUrl) {
|
||||
DeviceEventEmitter.emit(Events.LOADING_CHANNEL_POSTS, true);
|
||||
}
|
||||
const data = await client.getPostsBefore(channelId, postId, 0, perPage);
|
||||
const result = await processPostsFetched(serverUrl, ActionType.POSTS.RECEIVED_BEFORE, data, true);
|
||||
|
||||
if (activeServerUrl === serverUrl) {
|
||||
DeviceEventEmitter.emit(Events.LOADING_CHANNEL_POSTS, false);
|
||||
}
|
||||
|
||||
if (result.posts.length && !fetchOnly) {
|
||||
try {
|
||||
const models = await operator.handlePosts({
|
||||
actionType: ActionType.POSTS.RECEIVED_BEFORE,
|
||||
...result,
|
||||
prepareRecordsOnly: true,
|
||||
});
|
||||
const {authors} = await fetchPostAuthors(serverUrl, result.posts, true);
|
||||
if (authors?.length) {
|
||||
const userModels = await operator.handleUsers({
|
||||
users: authors,
|
||||
prepareRecordsOnly: true,
|
||||
});
|
||||
models.push(...userModels);
|
||||
}
|
||||
|
||||
await operator.batchRecords(models);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('FETCH AUTHORS ERROR', error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
|
||||
if (activeServerUrl === serverUrl) {
|
||||
DeviceEventEmitter.emit(Events.LOADING_CHANNEL_POSTS, true);
|
||||
}
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchPostsSince = async (serverUrl: string, channelId: string, since: number, fetchOnly = false): Promise<PostsRequest> => {
|
||||
let client: Client;
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import Model from '@nozbe/watermelondb/Model';
|
||||
|
||||
import {processPostsFetched} from '@actions/local/post';
|
||||
import {prepareMissingChannelsForAllTeams} from '@app/queries/servers/channel';
|
||||
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
@@ -14,6 +12,7 @@ import {fetchPostAuthors, getMissingChannelsFromPosts} from './post';
|
||||
import {forceLogoutIfNecessary} from './session';
|
||||
|
||||
import type {Client} from '@client/rest';
|
||||
import type Model from '@nozbe/watermelondb/Model';
|
||||
|
||||
type PostSearchRequest = {
|
||||
error?: unknown;
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Model} from '@nozbe/watermelondb';
|
||||
import {DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {localRemoveUserFromTeam} from '@actions/local/team';
|
||||
import {removeUserFromTeam as localRemoveUserFromTeam} from '@actions/local/team';
|
||||
import {Events} from '@constants';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import NetworkManager from '@init/network_manager';
|
||||
import {prepareMyChannelsForTeam, queryDefaultChannelForTeam} from '@queries/servers/channel';
|
||||
import {queryWebSocketLastDisconnected} from '@queries/servers/system';
|
||||
import {prepareDeleteTeam, prepareMyTeams, queryTeamsById, syncTeamTable} from '@queries/servers/team';
|
||||
import {prepareCommonSystemValues, queryCurrentTeamId, queryWebSocketLastDisconnected} from '@queries/servers/system';
|
||||
import {addTeamToTeamHistory, prepareDeleteTeam, prepareMyTeams, queryLastChannelFromTeam, queryTeamsById, syncTeamTable} from '@queries/servers/team';
|
||||
import {isTablet} from '@utils/helpers';
|
||||
|
||||
import {fetchMyChannelsForTeam} from './channel';
|
||||
import {fetchMyChannelsForTeam, switchToChannelById} from './channel';
|
||||
import {fetchPostsForChannel, fetchPostsForUnreadChannels} from './post';
|
||||
import {fetchRolesIfNeeded} from './role';
|
||||
import {forceLogoutIfNecessary} from './session';
|
||||
@@ -250,6 +252,7 @@ export const removeUserFromTeam = async (serverUrl: string, teamId: string, user
|
||||
|
||||
if (!fetchOnly) {
|
||||
localRemoveUserFromTeam(serverUrl, teamId);
|
||||
fetchAllTeams(serverUrl);
|
||||
}
|
||||
|
||||
return {error: undefined};
|
||||
@@ -258,3 +261,44 @@ export const removeUserFromTeam = async (serverUrl: string, teamId: string, user
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
|
||||
export const handleTeamChange = async (serverUrl: string, teamId: string) => {
|
||||
const {operator, database} = DatabaseManager.serverDatabases[serverUrl];
|
||||
const currentTeamId = await queryCurrentTeamId(database);
|
||||
|
||||
if (currentTeamId === teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let channelId = '';
|
||||
if (await isTablet()) {
|
||||
channelId = await queryLastChannelFromTeam(database, teamId);
|
||||
if (channelId) {
|
||||
await switchToChannelById(serverUrl, channelId, teamId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const models = [];
|
||||
const system = await prepareCommonSystemValues(operator, {currentChannelId: channelId, currentTeamId: teamId});
|
||||
if (system?.length) {
|
||||
models.push(...system);
|
||||
}
|
||||
const history = await addTeamToTeamHistory(operator, teamId, true);
|
||||
if (history.length) {
|
||||
models.push(...history);
|
||||
}
|
||||
|
||||
if (models.length) {
|
||||
await operator.batchRecords(models);
|
||||
}
|
||||
|
||||
const {channels, memberships, error} = await fetchMyChannelsForTeam(serverUrl, teamId);
|
||||
if (error) {
|
||||
DeviceEventEmitter.emit(Events.TEAM_LOAD_ERROR, serverUrl, error);
|
||||
}
|
||||
|
||||
if (channels?.length && memberships?.length) {
|
||||
fetchPostsForUnreadChannels(serverUrl, channels, memberships, channelId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
import {DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {handleTeamChange, localRemoveUserFromTeam} from '@actions/local/team';
|
||||
import {removeUserFromTeam} from '@actions/local/team';
|
||||
import {fetchAllTeams, handleTeamChange} from '@actions/remote/team';
|
||||
import {updateUsersNoLongerVisible} from '@actions/remote/user';
|
||||
import Events from '@constants/events';
|
||||
import DatabaseManager from '@database/manager';
|
||||
@@ -27,7 +28,8 @@ export async function handleLeaveTeamEvent(serverUrl: string, msg: any) {
|
||||
}
|
||||
|
||||
if (user.id === msg.data.user_id) {
|
||||
localRemoveUserFromTeam(serverUrl, msg.data.team_id);
|
||||
await removeUserFromTeam(serverUrl, msg.data.team_id);
|
||||
fetchAllTeams(serverUrl);
|
||||
|
||||
if (isGuest(user.roles)) {
|
||||
updateUsersNoLongerVisible(serverUrl);
|
||||
|
||||
@@ -12,10 +12,10 @@ export interface ClientPostsMix {
|
||||
patchPost: (postPatch: Partial<Post> & {id: string}) => Promise<Post>;
|
||||
deletePost: (postId: string) => Promise<any>;
|
||||
getPostThread: (postId: string) => Promise<any>;
|
||||
getPosts: (channelId: string, page?: number, perPage?: number) => Promise<any>;
|
||||
getPostsSince: (channelId: string, since: number) => Promise<any>;
|
||||
getPostsBefore: (channelId: string, postId: string, page?: number, perPage?: number) => Promise<any>;
|
||||
getPostsAfter: (channelId: string, postId: string, page?: number, perPage?: number) => Promise<any>;
|
||||
getPosts: (channelId: string, page?: number, perPage?: number) => Promise<PostResponse>;
|
||||
getPostsSince: (channelId: string, since: number) => Promise<PostResponse>;
|
||||
getPostsBefore: (channelId: string, postId: string, page?: number, perPage?: number) => Promise<PostResponse>;
|
||||
getPostsAfter: (channelId: string, postId: string, page?: number, perPage?: number) => Promise<PostResponse>;
|
||||
getFileInfosForPost: (postId: string) => Promise<FileInfo[]>;
|
||||
getFlaggedPosts: (userId: string, channelId?: string, teamId?: string, page?: number, perPage?: number) => Promise<any>;
|
||||
getPinnedPosts: (channelId: string) => Promise<any>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Category List Component should match snapshot 1`] = `
|
||||
exports[`Category List Component should match snapshot 1`] = `
|
||||
<RCTScrollView
|
||||
ListHeaderComponent={[Function]}
|
||||
data={
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
|
||||
import {renderWithEverything} from '@test/intl-test-helper';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import Category from './index';
|
||||
|
||||
import type Database from '@nozbe/watermelondb/Database';
|
||||
|
||||
const channels: TempoChannel[] = [
|
||||
{id: '1', name: 'Just a channel'},
|
||||
{id: '2', name: 'Highlighted!!!', highlight: true},
|
||||
@@ -17,10 +20,19 @@ const categories: TempoCategory[] = [
|
||||
{id: '2', title: 'Another cat', channels},
|
||||
];
|
||||
|
||||
test('Category List Component should match snapshot', () => {
|
||||
const {toJSON} = renderWithIntlAndTheme(
|
||||
<Category categories={categories}/>,
|
||||
);
|
||||
describe('Category List Component ', () => {
|
||||
let database: Database | undefined;
|
||||
beforeAll(async () => {
|
||||
const server = await TestHelper.setupServerDatabase();
|
||||
database = server.database;
|
||||
});
|
||||
|
||||
expect(toJSON()).toMatchSnapshot();
|
||||
test('should match snapshot', () => {
|
||||
const {toJSON} = renderWithEverything(
|
||||
<Category categories={categories}/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
expect(toJSON()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,14 +3,26 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
|
||||
import {renderWithEverything} from '@test/intl-test-helper';
|
||||
import TestHelper from '@test/test_helper';
|
||||
|
||||
import Threads from './index';
|
||||
|
||||
test('Threads Component should match snapshot', () => {
|
||||
const {toJSON} = renderWithIntlAndTheme(
|
||||
<Threads/>,
|
||||
);
|
||||
import type Database from '@nozbe/watermelondb/Database';
|
||||
|
||||
expect(toJSON()).toMatchSnapshot();
|
||||
describe('Threads Component', () => {
|
||||
let database: Database | undefined;
|
||||
beforeAll(async () => {
|
||||
const server = await TestHelper.setupServerDatabase();
|
||||
database = server.database;
|
||||
});
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const {toJSON} = renderWithEverything(
|
||||
<Threads/>,
|
||||
{database},
|
||||
);
|
||||
|
||||
expect(toJSON()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Q} from '@nozbe/watermelondb';
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import React from 'react';
|
||||
import {StyleSheet, Text, View} from 'react-native';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {switchToChannelById} from '@actions/remote/channel';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {Screens} from '@constants';
|
||||
import {General} from '@constants';
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {goToScreen} from '@screens/navigation';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type SystemModel from '@typings/database/models/servers/system';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
container: {
|
||||
display: 'flex',
|
||||
@@ -30,18 +41,19 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
|
||||
const textStyle = StyleSheet.create([typography('Body', 200, 'SemiBold')]);
|
||||
|
||||
const ThreadsButton = () => {
|
||||
const ThreadsButton = ({channelId}: {channelId?: string}) => {
|
||||
const theme = useTheme();
|
||||
const serverUrl = useServerUrl();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
/*
|
||||
* @to-do:
|
||||
* - Check if there are threads, else return null
|
||||
* - Change to button, navigate to threads view
|
||||
* - Check if there are threads, else return null (think of doing this before mounting this component)
|
||||
* - Change to button, navigate to threads view instead of the current team Town Square
|
||||
* - Add right-side number badge
|
||||
*/
|
||||
return (
|
||||
<TouchableWithFeedback onPress={() => goToScreen(Screens.CHANNEL, 'Channel', {}, {topBar: {visible: false}})} >
|
||||
<TouchableWithFeedback onPress={() => (channelId ? switchToChannelById(serverUrl, channelId) : true)} >
|
||||
<View style={styles.container}>
|
||||
<CompassIcon
|
||||
name='message-text-outline'
|
||||
@@ -53,4 +65,19 @@ const ThreadsButton = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ThreadsButton;
|
||||
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
const currentTeamId = database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID);
|
||||
const channelId = currentTeamId.pipe(
|
||||
switchMap((model) => database.get<ChannelModel>(MM_TABLES.SERVER.CHANNEL).query(
|
||||
Q.where('team_id', model.value),
|
||||
Q.where('name', General.DEFAULT_CHANNEL),
|
||||
).observe().pipe(
|
||||
// eslint-disable-next-line max-nested-callbacks
|
||||
switchMap((channels) => (channels.length ? of$(channels[0].id) : of$(undefined))),
|
||||
)),
|
||||
);
|
||||
|
||||
return {channelId};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(ThreadsButton));
|
||||
|
||||
@@ -11,7 +11,7 @@ type FormattedDateProps = TextProps & {
|
||||
value: number | string | Date;
|
||||
}
|
||||
|
||||
const FormattedDate = ({format = 'ddd, MMM DD, YYYY', timezone, value, ...props}: FormattedDateProps) => {
|
||||
const FormattedDate = ({format = 'MMM DD, YYYY', timezone, value, ...props}: FormattedDateProps) => {
|
||||
let formattedDate = moment(value).format(format);
|
||||
if (timezone) {
|
||||
let zone = timezone as string;
|
||||
|
||||
@@ -70,7 +70,7 @@ const JumboEmoji = ({baseTextStyle, isEdited, value}: JumboEmojiProps) => {
|
||||
};
|
||||
|
||||
const renderText = ({literal}: {literal: string}) => {
|
||||
return <Text style={baseTextStyle}>{literal}</Text>;
|
||||
return renderEmoji({emojiName: literal, literal, context: []});
|
||||
};
|
||||
|
||||
const renderNewLine = () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
hasSearch: boolean;
|
||||
isLargeTitle: boolean;
|
||||
largeHeight: number;
|
||||
scrollValue: Animated.SharedValue<number>;
|
||||
scrollValue?: Animated.SharedValue<number>;
|
||||
theme: Theme;
|
||||
top: number;
|
||||
}
|
||||
@@ -44,14 +44,14 @@ const NavigationHeaderContext = ({
|
||||
|
||||
const marginTop = useAnimatedStyle(() => {
|
||||
const normal = defaultHeight + top;
|
||||
const calculated = -(top + scrollValue.value);
|
||||
const calculated = -(top + (scrollValue?.value || 0));
|
||||
const searchHeight = hasSearch ? defaultHeight + 9 : 0;
|
||||
if (!isLargeTitle) {
|
||||
return {marginTop: Math.max((normal + calculated), normal)};
|
||||
}
|
||||
|
||||
return {marginTop: Math.max((-scrollValue.value + largeHeight + searchHeight), normal)};
|
||||
}, [defaultHeight, largeHeight, isLargeTitle, hasSearch, top]);
|
||||
return {marginTop: Math.max((-(scrollValue?.value || 0) + largeHeight + searchHeight), normal)};
|
||||
}, [defaultHeight, largeHeight, isLargeTitle, hasSearch]);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, marginTop]}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo} from 'react';
|
||||
import {Platform, Text} from 'react-native';
|
||||
import {Platform, Text, View} from 'react-native';
|
||||
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
@@ -27,10 +27,12 @@ type Props = {
|
||||
largeHeight: number;
|
||||
leftComponent?: React.ReactElement;
|
||||
onBackPress?: () => void;
|
||||
onTitlePress?: () => void;
|
||||
rightButtons?: HeaderRightButton[];
|
||||
scrollValue: Animated.SharedValue<number>;
|
||||
scrollValue?: Animated.SharedValue<number>;
|
||||
showBackButton?: boolean;
|
||||
subtitle?: string;
|
||||
subtitleCompanion?: React.ReactElement;
|
||||
theme: Theme;
|
||||
title?: string;
|
||||
top: number;
|
||||
@@ -45,6 +47,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
paddingHorizontal: 16,
|
||||
zIndex: 10,
|
||||
},
|
||||
subtitleContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
subtitle: {
|
||||
color: changeOpacity(theme.sidebarHeaderTextColor, 0.72),
|
||||
fontFamily: 'OpenSans',
|
||||
@@ -89,10 +94,12 @@ const Header = ({
|
||||
largeHeight,
|
||||
leftComponent,
|
||||
onBackPress,
|
||||
onTitlePress,
|
||||
rightButtons,
|
||||
scrollValue,
|
||||
showBackButton = true,
|
||||
subtitle,
|
||||
subtitleCompanion,
|
||||
theme,
|
||||
title,
|
||||
top,
|
||||
@@ -109,15 +116,15 @@ const Header = ({
|
||||
}
|
||||
|
||||
const barHeight = Platform.OS === 'ios' ? (largeHeight - defaultHeight - (top / 2)) : largeHeight - defaultHeight;
|
||||
const val = (top + scrollValue.value);
|
||||
const val = (top + (scrollValue?.value ?? 0));
|
||||
return {
|
||||
opacity: val >= barHeight ? withTiming(1, {duration: 250}) : 0,
|
||||
};
|
||||
}, [defaultHeight, largeHeight, top, isLargeTitle, hasSearch]);
|
||||
}, [defaultHeight, largeHeight, isLargeTitle, hasSearch]);
|
||||
|
||||
const containerStyle = useMemo(() => {
|
||||
return [styles.container, {height: defaultHeight + top, paddingTop: top}];
|
||||
}, [top, defaultHeight, theme]);
|
||||
}, [defaultHeight, theme]);
|
||||
|
||||
const additionalTitleStyle = useMemo(() => ({
|
||||
marginLeft: Platform.select({android: showBackButton && !leftComponent ? 20 : 0}),
|
||||
@@ -144,26 +151,37 @@ const Header = ({
|
||||
{leftComponent}
|
||||
</Animated.View>
|
||||
<Animated.View style={[styles.titleContainer, additionalTitleStyle]}>
|
||||
{!hasSearch &&
|
||||
<Animated.Text
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={1}
|
||||
style={[styles.title, opacity]}
|
||||
testID='navigation.header.title'
|
||||
<TouchableWithFeedback
|
||||
disabled={!onTitlePress}
|
||||
onPress={onTitlePress}
|
||||
type='opacity'
|
||||
>
|
||||
{title}
|
||||
</Animated.Text>
|
||||
}
|
||||
{!isLargeTitle &&
|
||||
<Text
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={1}
|
||||
style={styles.subtitle}
|
||||
testID='navigation.header.subtitle'
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
}
|
||||
<>
|
||||
{!hasSearch &&
|
||||
<Animated.Text
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={1}
|
||||
style={[styles.title, opacity]}
|
||||
testID='navigation.header.title'
|
||||
>
|
||||
{title}
|
||||
</Animated.Text>
|
||||
}
|
||||
{!isLargeTitle &&
|
||||
<View style={styles.subtitleContainer}>
|
||||
<Text
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={1}
|
||||
style={styles.subtitle}
|
||||
testID='navigation.header.subtitle'
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
{subtitleCompanion}
|
||||
</View>
|
||||
}
|
||||
</>
|
||||
</TouchableWithFeedback>
|
||||
</Animated.View>
|
||||
<Animated.View style={styles.rightContainer}>
|
||||
{Boolean(rightButtons?.length) &&
|
||||
|
||||
@@ -24,11 +24,13 @@ type Props = SearchProps & {
|
||||
isLargeTitle?: boolean;
|
||||
leftComponent?: React.ReactElement;
|
||||
onBackPress?: () => void;
|
||||
onTitlePress?: () => void;
|
||||
rightButtons?: HeaderRightButton[];
|
||||
scrollValue: Animated.SharedValue<number>;
|
||||
scrollValue?: Animated.SharedValue<number>;
|
||||
showBackButton?: boolean;
|
||||
showHeaderInContext?: boolean;
|
||||
subtitle?: string;
|
||||
subtitleCompanion?: React.ReactElement;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
@@ -47,11 +49,13 @@ const NavigationHeader = ({
|
||||
isLargeTitle = false,
|
||||
leftComponent,
|
||||
onBackPress,
|
||||
onTitlePress,
|
||||
rightButtons,
|
||||
scrollValue,
|
||||
showBackButton,
|
||||
showHeaderInContext = true,
|
||||
subtitle,
|
||||
subtitleCompanion,
|
||||
title = '',
|
||||
...searchProps
|
||||
}: Props) => {
|
||||
@@ -62,9 +66,9 @@ const NavigationHeader = ({
|
||||
const {largeHeight, defaultHeight} = useHeaderHeight(isLargeTitle, Boolean(subtitle), hasSearch);
|
||||
const containerHeight = useAnimatedStyle(() => {
|
||||
const normal = defaultHeight + insets.top;
|
||||
const calculated = -(insets.top + scrollValue.value);
|
||||
const calculated = -(insets.top + (scrollValue?.value || 0));
|
||||
return {height: Math.max((normal + calculated), normal)};
|
||||
}, [defaultHeight, insets.top]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -76,10 +80,12 @@ const NavigationHeader = ({
|
||||
largeHeight={largeHeight}
|
||||
leftComponent={leftComponent}
|
||||
onBackPress={onBackPress}
|
||||
onTitlePress={onTitlePress}
|
||||
rightButtons={rightButtons}
|
||||
scrollValue={scrollValue}
|
||||
showBackButton={showBackButton}
|
||||
subtitle={subtitle}
|
||||
subtitleCompanion={subtitleCompanion}
|
||||
theme={theme}
|
||||
title={title}
|
||||
top={insets.top}
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
defaultHeight: number;
|
||||
hasSearch: boolean;
|
||||
largeHeight: number;
|
||||
scrollValue: Animated.SharedValue<number>;
|
||||
scrollValue?: Animated.SharedValue<number>;
|
||||
subtitle?: string;
|
||||
theme: Theme;
|
||||
title: string;
|
||||
@@ -48,7 +48,7 @@ const NavigationHeaderLargeTitle = ({
|
||||
|
||||
const transform = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{translateY: -(top + scrollValue.value)}],
|
||||
transform: [{translateY: -(top + (scrollValue?.value || 0))}],
|
||||
};
|
||||
}, [top]);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ type Props = SearchProps & {
|
||||
defaultHeight: number;
|
||||
forwardedRef?: React.RefObject<ScrollView | FlatList | SectionList>;
|
||||
largeHeight: number;
|
||||
scrollValue: Animated.SharedValue<number>;
|
||||
scrollValue?: Animated.SharedValue<number>;
|
||||
theme: Theme;
|
||||
top: number;
|
||||
}
|
||||
@@ -44,13 +44,13 @@ const NavigationSearch = ({
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
const searchTop = useAnimatedStyle(() => {
|
||||
return {marginTop: Math.max((-scrollValue.value + largeHeight), top)};
|
||||
return {marginTop: Math.max((-(scrollValue?.value || 0) + largeHeight), top)};
|
||||
}, [defaultHeight, largeHeight, top]);
|
||||
|
||||
const onFocus = useCallback((e) => {
|
||||
const searchInset = isTablet ? TABLET_HEADER_SEARCH_INSET : IOS_HEADER_SEARCH_INSET;
|
||||
const offset = Platform.select({android: largeHeight + ANDROID_HEADER_SEARCH_INSET, default: defaultHeight + searchInset});
|
||||
if (forwardedRef?.current && Math.abs(scrollValue.value) <= top) {
|
||||
if (forwardedRef?.current && Math.abs((scrollValue?.value || 0)) <= top) {
|
||||
if ((forwardedRef.current as ScrollView).scrollTo) {
|
||||
(forwardedRef.current as ScrollView).scrollTo({y: offset, animated: true});
|
||||
} else {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
type Props = {
|
||||
defaultHeight: number;
|
||||
largeHeight: number;
|
||||
scrollValue: Animated.SharedValue<number>;
|
||||
scrollValue?: Animated.SharedValue<number>;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ const NavigationHeaderSearchContext = ({
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
const marginTop = useAnimatedStyle(() => {
|
||||
return {marginTop: (-scrollValue.value + largeHeight + defaultHeight) - ANDROID_HEADER_SEARCH_INSET};
|
||||
return {marginTop: (-(scrollValue?.value || 0) + largeHeight + defaultHeight) - ANDROID_HEADER_SEARCH_INSET};
|
||||
}, [defaultHeight, largeHeight]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -46,11 +46,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
},
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 20,
|
||||
marginTop: 10,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
marginRight: 12,
|
||||
marginLeft: 12,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -83,7 +85,7 @@ const CombinedUserActivity = ({
|
||||
const you = intl.formatMessage({id: 'combined_system_message.you', defaultMessage: 'You'});
|
||||
const usernames = userIds.reduce((acc: string[], id: string) => {
|
||||
if (id !== currentUserId && id !== currentUsername) {
|
||||
const name = usernamesById[id];
|
||||
const name = usernamesById[id] ?? Object.values(usernamesById).find((n) => n === id);
|
||||
acc.push(name ? `@${name}` : someone);
|
||||
}
|
||||
return acc;
|
||||
|
||||
50
app/components/post_list/config.ts
Normal file
50
app/components/post_list/config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {INDICATOR_BAR_HEIGHT} from '@constants/view';
|
||||
|
||||
const HIDDEN_TOP = -400;
|
||||
const MAX_INPUT = 1;
|
||||
const MIN_INPUT = 0;
|
||||
const SHOWN_TOP = 0;
|
||||
const INDICATOR_BAR_FACTOR = Math.abs(INDICATOR_BAR_HEIGHT / (HIDDEN_TOP - SHOWN_TOP));
|
||||
|
||||
export const INITIAL_BATCH_TO_RENDER = 10;
|
||||
export const SCROLL_POSITION_CONFIG = {
|
||||
|
||||
// To avoid scrolling the list when new messages arrives
|
||||
// if the user is not at the bottom
|
||||
minIndexForVisible: 0,
|
||||
|
||||
// If the user is at the bottom or 60px from the bottom
|
||||
// auto scroll show the new message
|
||||
autoscrollToTopThreshold: 60,
|
||||
};
|
||||
export const VIEWABILITY_CONFIG = {
|
||||
itemVisiblePercentThreshold: 50,
|
||||
minimumViewTime: 100,
|
||||
};
|
||||
|
||||
export const MORE_MESSAGES = {
|
||||
CANCEL_TIMER_DELAY: 400,
|
||||
HIDDEN_TOP,
|
||||
INDICATOR_BAR_FACTOR,
|
||||
MAX_INPUT,
|
||||
MIN_INPUT,
|
||||
SHOWN_TOP,
|
||||
TOP_INTERPOL_CONFIG: {
|
||||
inputRange: [
|
||||
MIN_INPUT,
|
||||
MIN_INPUT + INDICATOR_BAR_FACTOR,
|
||||
MAX_INPUT - INDICATOR_BAR_FACTOR,
|
||||
MAX_INPUT,
|
||||
],
|
||||
outputRange: [
|
||||
HIDDEN_TOP - INDICATOR_BAR_HEIGHT,
|
||||
HIDDEN_TOP,
|
||||
SHOWN_TOP,
|
||||
SHOWN_TOP + INDICATOR_BAR_HEIGHT,
|
||||
],
|
||||
extrapolate: 'clamp',
|
||||
},
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import {StyleProp, View, ViewStyle} from 'react-native';
|
||||
import FormattedDate from '@components/formatted_date';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
type DateSeparatorProps = {
|
||||
date: number | Date;
|
||||
@@ -21,6 +22,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
marginVertical: 8,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
line: {
|
||||
flex: 1,
|
||||
@@ -30,9 +32,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
},
|
||||
date: {
|
||||
color: theme.centerChannelColor,
|
||||
fontFamily: 'OpenSans-Semibold',
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
marginHorizontal: 4,
|
||||
...typography('Body', 75, 'SemiBold'),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -41,6 +42,10 @@ export function isSameDay(a: Date, b: Date) {
|
||||
return a.getDate() === b.getDate() && a.getMonth() === b.getMonth() && a.getFullYear() === b.getFullYear();
|
||||
}
|
||||
|
||||
export function isSameYear(a: Date, b: Date) {
|
||||
return a.getFullYear() === b.getFullYear();
|
||||
}
|
||||
|
||||
export function isToday(date: Date) {
|
||||
const now = new Date();
|
||||
|
||||
@@ -76,9 +81,12 @@ const RecentDate = (props: DateSeparatorProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
const format = isSameYear(when, new Date()) ? 'MMM DD' : 'MMM DD, YYYY';
|
||||
|
||||
return (
|
||||
<FormattedDate
|
||||
{...otherProps}
|
||||
format={format}
|
||||
value={date}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,76 +1,343 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Q} from '@nozbe/watermelondb';
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {AppStateStatus} from 'react-native';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
import {FlatList} from '@stream-io/flat-list-mvcp';
|
||||
import React, {ReactElement, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {DeviceEventEmitter, NativeScrollEvent, NativeSyntheticEvent, Platform, StyleProp, StyleSheet, ViewStyle, ViewToken} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
import {Preferences} from '@constants';
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {getPreferenceAsBool} from '@helpers/api/preference';
|
||||
import {getTimezone} from '@utils/user';
|
||||
import {fetchPosts} from '@actions/remote/post';
|
||||
import CombinedUserActivity from '@components/post_list/combined_user_activity';
|
||||
import DateSeparator from '@components/post_list/date_separator';
|
||||
import NewMessagesLine from '@components/post_list/new_message_line';
|
||||
import Post from '@components/post_list/post';
|
||||
import {Screens} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList, START_OF_NEW_MESSAGES} from '@utils/post_list';
|
||||
|
||||
import PostList from './post_list';
|
||||
import {INITIAL_BATCH_TO_RENDER, SCROLL_POSITION_CONFIG, VIEWABILITY_CONFIG} from './config';
|
||||
import MoreMessages from './more_messages';
|
||||
import PostListRefreshControl from './refresh_control';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type MyChannelModel from '@typings/database/models/servers/my_channel';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel';
|
||||
import type PreferenceModel from '@typings/database/models/servers/preference';
|
||||
import type SystemModel from '@typings/database/models/servers/system';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
const {SERVER: {MY_CHANNEL, POST, POSTS_IN_CHANNEL, PREFERENCE, SYSTEM, USER}} = MM_TABLES;
|
||||
type Props = {
|
||||
channelId: string;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
currentTimezone: string | null;
|
||||
currentUsername: string;
|
||||
highlightPinnedOrSaved?: boolean;
|
||||
isTimezoneEnabled: boolean;
|
||||
lastViewedAt: number;
|
||||
location: string;
|
||||
nativeID: string;
|
||||
onEndReached?: () => void;
|
||||
posts: PostModel[];
|
||||
rootId?: string;
|
||||
shouldRenderReplyButton?: boolean;
|
||||
shouldShowJoinLeaveMessages: boolean;
|
||||
showMoreMessages?: boolean;
|
||||
showNewMessageLine?: boolean;
|
||||
footer?: ReactElement;
|
||||
testID: string;
|
||||
}
|
||||
|
||||
export const VIEWABILITY_CONFIG = {
|
||||
itemVisiblePercentThreshold: 1,
|
||||
minimumViewTime: 100,
|
||||
type ViewableItemsChanged = {
|
||||
viewableItems: ViewToken[];
|
||||
changed: ViewToken[];
|
||||
}
|
||||
|
||||
type onScrollEndIndexListenerEvent = (endIndex: number) => void;
|
||||
type ViewableItemsChangedListenerEvent = (viewableItms: ViewToken[]) => void;
|
||||
|
||||
type ScrollIndexFailed = {
|
||||
index: number;
|
||||
highestMeasuredFrameIndex: number;
|
||||
averageItemLength: number;
|
||||
};
|
||||
|
||||
const enhanced = withObservables(['channelId', 'forceQueryAfterAppState'], ({database, channelId}: {channelId: string; forceQueryAfterAppState: AppStateStatus} & WithDatabaseArgs) => {
|
||||
const currentUser = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
|
||||
switchMap((currentUserId) => database.get<UserModel>(USER).findAndObserve(currentUserId.value)),
|
||||
);
|
||||
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
|
||||
const keyExtractor = (item: string | PostModel) => (typeof item === 'string' ? item : item.id);
|
||||
|
||||
return {
|
||||
currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user.timezone))))),
|
||||
currentUsername: currentUser.pipe((switchMap((user) => of$(user.username)))),
|
||||
isTimezoneEnabled: database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
|
||||
switchMap((config) => of$(config.value.ExperimentalTimezone === 'true')),
|
||||
),
|
||||
lastViewedAt: database.get<MyChannelModel>(MY_CHANNEL).findAndObserve(channelId).pipe(
|
||||
switchMap((myChannel) => of$(myChannel.viewedAt)),
|
||||
),
|
||||
posts: database.get<PostsInChannelModel>(POSTS_IN_CHANNEL).query(
|
||||
Q.where('channel_id', channelId),
|
||||
Q.sortBy('latest', Q.desc),
|
||||
).observeWithColumns(['earliest', 'latest']).pipe(
|
||||
switchMap((postsInChannel) => {
|
||||
if (!postsInChannel.length) {
|
||||
return of$([]);
|
||||
}
|
||||
|
||||
const {earliest, latest} = postsInChannel[0];
|
||||
return database.get<PostModel>(POST).query(
|
||||
Q.and(
|
||||
Q.where('delete_at', 0),
|
||||
Q.where('channel_id', channelId),
|
||||
Q.where('create_at', Q.between(earliest, latest)),
|
||||
),
|
||||
Q.sortBy('create_at', Q.desc),
|
||||
).observe();
|
||||
}),
|
||||
),
|
||||
shouldShowJoinLeaveMessages: database.get<PreferenceModel>(PREFERENCE).query(
|
||||
Q.where('category', Preferences.CATEGORY_ADVANCED_SETTINGS),
|
||||
Q.where('name', Preferences.ADVANCED_FILTER_JOIN_LEAVE),
|
||||
).observe().pipe(
|
||||
switchMap((preferences) => of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE, true))),
|
||||
),
|
||||
};
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
scaleY: -1,
|
||||
},
|
||||
scale: {
|
||||
...Platform.select({
|
||||
android: {
|
||||
scaleY: -1,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(PostList));
|
||||
const PostList = ({
|
||||
channelId,
|
||||
contentContainerStyle,
|
||||
currentTimezone,
|
||||
currentUsername,
|
||||
footer,
|
||||
highlightPinnedOrSaved = true,
|
||||
isTimezoneEnabled,
|
||||
lastViewedAt,
|
||||
location,
|
||||
nativeID,
|
||||
onEndReached,
|
||||
posts,
|
||||
rootId,
|
||||
shouldRenderReplyButton = true,
|
||||
shouldShowJoinLeaveMessages,
|
||||
showMoreMessages,
|
||||
showNewMessageLine = true,
|
||||
testID,
|
||||
}: Props) => {
|
||||
const listRef = useRef<FlatList>(null);
|
||||
const onScrollEndIndexListener = useRef<onScrollEndIndexListenerEvent>();
|
||||
const onViewableItemsChangedListener = useRef<ViewableItemsChangedListenerEvent>();
|
||||
const [offsetY, setOffsetY] = useState(0);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const theme = useTheme();
|
||||
const serverUrl = useServerUrl();
|
||||
const orderedPosts = useMemo(() => {
|
||||
return preparePostList(posts, lastViewedAt, showNewMessageLine, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, currentTimezone, location === Screens.THREAD);
|
||||
}, [posts, lastViewedAt, showNewMessageLine, currentTimezone, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, location]);
|
||||
|
||||
const initialIndex = useMemo(() => {
|
||||
return orderedPosts.indexOf(START_OF_NEW_MESSAGES);
|
||||
}, [orderedPosts]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false});
|
||||
}, [channelId, listRef.current]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollToBottom = (screen: string) => {
|
||||
if (screen === location) {
|
||||
const scrollToBottomTimer = setTimeout(() => {
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: true});
|
||||
clearTimeout(scrollToBottomTimer);
|
||||
}, 400);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollBottomListener = DeviceEventEmitter.addListener('scroll-to-bottom', scrollToBottom);
|
||||
|
||||
return () => {
|
||||
scrollBottomListener.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
if (location === Screens.CHANNEL && channelId) {
|
||||
await fetchPosts(serverUrl, channelId);
|
||||
} else if (location === Screens.THREAD && rootId) {
|
||||
// await getPostThread(rootId);
|
||||
}
|
||||
setRefreshing(false);
|
||||
}, [channelId, location, rootId]);
|
||||
|
||||
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (Platform.OS === 'android') {
|
||||
const {y} = event.nativeEvent.contentOffset;
|
||||
if (y === 0) {
|
||||
setOffsetY(y);
|
||||
} else if (offsetY === 0 && y !== 0) {
|
||||
setOffsetY(y);
|
||||
}
|
||||
}
|
||||
}, [offsetY]);
|
||||
|
||||
const onScrollToIndexFailed = useCallback((info: ScrollIndexFailed) => {
|
||||
const index = Math.min(info.highestMeasuredFrameIndex, info.index);
|
||||
if (onScrollEndIndexListener.current) {
|
||||
onScrollEndIndexListener.current(index);
|
||||
}
|
||||
scrollToIndex(index);
|
||||
}, []);
|
||||
|
||||
const onViewableItemsChanged = useCallback(({viewableItems}: ViewableItemsChanged) => {
|
||||
if (!viewableItems.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewableItemsMap = viewableItems.reduce((acc: Record<string, boolean>, {item, isViewable}) => {
|
||||
if (isViewable) {
|
||||
acc[item.id] = true;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
DeviceEventEmitter.emit('scrolled', viewableItemsMap);
|
||||
|
||||
if (onViewableItemsChangedListener.current) {
|
||||
onViewableItemsChangedListener.current(viewableItems);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const registerScrollEndIndexListener = useCallback((listener) => {
|
||||
onScrollEndIndexListener.current = listener;
|
||||
const removeListener = () => {
|
||||
onScrollEndIndexListener.current = undefined;
|
||||
};
|
||||
|
||||
return removeListener;
|
||||
}, []);
|
||||
|
||||
const registerViewableItemsListener = useCallback((listener) => {
|
||||
onViewableItemsChangedListener.current = listener;
|
||||
const removeListener = () => {
|
||||
onViewableItemsChangedListener.current = undefined;
|
||||
};
|
||||
|
||||
return removeListener;
|
||||
}, []);
|
||||
|
||||
const renderItem = useCallback(({item, index}) => {
|
||||
if (typeof item === 'string') {
|
||||
if (isStartOfNewMessages(item)) {
|
||||
// postIds includes a date item after the new message indicator so 2
|
||||
// needs to be added to the index for the length check to be correct.
|
||||
const moreNewMessages = orderedPosts.length === index + 2;
|
||||
|
||||
// The date line and new message line each count for a line. So the
|
||||
// goal of this is to check for the 3rd previous, which for the start
|
||||
// of a thread would be null as it doesn't exist.
|
||||
const checkForPostId = index < orderedPosts.length - 3;
|
||||
|
||||
return (
|
||||
<NewMessagesLine
|
||||
theme={theme}
|
||||
moreMessages={moreNewMessages && checkForPostId}
|
||||
testID={`${testID}.new_messages_line`}
|
||||
style={styles.scale}
|
||||
/>
|
||||
);
|
||||
} else if (isDateLine(item)) {
|
||||
return (
|
||||
<DateSeparator
|
||||
date={getDateForDateLine(item)}
|
||||
theme={theme}
|
||||
style={styles.scale}
|
||||
timezone={isTimezoneEnabled ? currentTimezone : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCombinedUserActivityPost(item)) {
|
||||
const postProps = {
|
||||
currentUsername,
|
||||
postId: item,
|
||||
style: Platform.OS === 'ios' ? styles.scale : styles.container,
|
||||
testID: `${testID}.combined_user_activity`,
|
||||
showJoinLeave: shouldShowJoinLeaveMessages,
|
||||
theme,
|
||||
};
|
||||
|
||||
return (<CombinedUserActivity {...postProps}/>);
|
||||
}
|
||||
}
|
||||
|
||||
let previousPost: PostModel|undefined;
|
||||
let nextPost: PostModel|undefined;
|
||||
const prev = orderedPosts.slice(index + 1).find((v) => typeof v !== 'string');
|
||||
if (prev) {
|
||||
previousPost = prev as PostModel;
|
||||
}
|
||||
|
||||
if (index > 0) {
|
||||
const next = orderedPosts.slice(0, index);
|
||||
for (let i = next.length - 1; i >= 0; i--) {
|
||||
const v = next[i];
|
||||
if (typeof v !== 'string') {
|
||||
nextPost = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const postProps = {
|
||||
highlightPinnedOrSaved,
|
||||
location,
|
||||
nextPost,
|
||||
previousPost,
|
||||
shouldRenderReplyButton,
|
||||
};
|
||||
|
||||
return (
|
||||
<Post
|
||||
key={item.id}
|
||||
post={item}
|
||||
style={styles.scale}
|
||||
testID={`${testID}.post`}
|
||||
{...postProps}
|
||||
/>
|
||||
);
|
||||
}, [currentTimezone, highlightPinnedOrSaved, isTimezoneEnabled, orderedPosts, shouldRenderReplyButton, theme]);
|
||||
|
||||
const scrollToIndex = useCallback((index: number, animated = true) => {
|
||||
listRef.current?.scrollToIndex({
|
||||
animated,
|
||||
index,
|
||||
viewOffset: 0,
|
||||
viewPosition: 1, // 0 is at bottom
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PostListRefreshControl
|
||||
enabled={offsetY === 0}
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
style={styles.container}
|
||||
>
|
||||
<AnimatedFlatList
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
data={orderedPosts}
|
||||
keyboardDismissMode='interactive'
|
||||
keyboardShouldPersistTaps='handled'
|
||||
keyExtractor={keyExtractor}
|
||||
initialNumToRender={INITIAL_BATCH_TO_RENDER + 5}
|
||||
listKey={`postList-${channelId}`}
|
||||
ListFooterComponent={footer}
|
||||
maintainVisibleContentPosition={SCROLL_POSITION_CONFIG}
|
||||
maxToRenderPerBatch={10}
|
||||
nativeID={nativeID}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={2}
|
||||
onScroll={onScroll}
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
onViewableItemsChanged={onViewableItemsChanged}
|
||||
ref={listRef}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={renderItem}
|
||||
scrollEventThrottle={60}
|
||||
style={styles.flex}
|
||||
viewabilityConfig={VIEWABILITY_CONFIG}
|
||||
testID={testID}
|
||||
/>
|
||||
</PostListRefreshControl>
|
||||
{showMoreMessages &&
|
||||
<MoreMessages
|
||||
channelId={channelId}
|
||||
newMessageLineIndex={initialIndex}
|
||||
posts={orderedPosts}
|
||||
registerScrollEndIndexListener={registerScrollEndIndexListener}
|
||||
registerViewableItemsListener={registerViewableItemsListener}
|
||||
scrollToIndex={scrollToIndex}
|
||||
theme={theme}
|
||||
testID={`${testID}.more_messages_button`}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostList;
|
||||
|
||||
30
app/components/post_list/more_messages/index.ts
Normal file
30
app/components/post_list/more_messages/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {Database} from '@constants';
|
||||
|
||||
import MoreMessages from './more_messages';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type MyChannelModel from '@typings/database/models/servers/my_channel';
|
||||
|
||||
const {MM_TABLES} = Database;
|
||||
const {SERVER: {MY_CHANNEL}} = MM_TABLES;
|
||||
|
||||
const enhanced = withObservables(['channelId'], ({channelId, database}: {channelId: string} & WithDatabaseArgs) => {
|
||||
const myChannel = database.get<MyChannelModel>(MY_CHANNEL).findAndObserve(channelId);
|
||||
const isManualUnread = myChannel.pipe(switchMap((ch) => of$(ch.manuallyUnread)));
|
||||
const unreadCount = myChannel.pipe(switchMap((ch) => of$(ch.messageCount)));
|
||||
|
||||
return {
|
||||
isManualUnread,
|
||||
unreadCount,
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(MoreMessages));
|
||||
269
app/components/post_list/more_messages/more_messages.tsx
Normal file
269
app/components/post_list/more_messages/more_messages.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {ActivityIndicator, DeviceEventEmitter, View, ViewToken} from 'react-native';
|
||||
import Animated, {interpolate, useAnimatedStyle, useSharedValue, withSpring} from 'react-native-reanimated';
|
||||
|
||||
import {resetMessageCount} from '@actions/local/channel';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {Events} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {makeStyleSheetFromTheme, hexToHue} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
isManualUnread: boolean;
|
||||
newMessageLineIndex: number;
|
||||
posts: Array<string | PostModel>;
|
||||
registerScrollEndIndexListener: (fn: (endIndex: number) => void) => () => void;
|
||||
registerViewableItemsListener: (fn: (viewableItems: ViewToken[]) => void) => () => void;
|
||||
scrollToIndex: (index: number, animated?: boolean) => void;
|
||||
unreadCount: number;
|
||||
theme: Theme;
|
||||
testID: string;
|
||||
}
|
||||
|
||||
const HIDDEN_TOP = -60;
|
||||
const SHOWN_TOP = 0;
|
||||
const MIN_INPUT = 0;
|
||||
const MAX_INPUT = 1;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
animatedContainer: {
|
||||
position: 'absolute',
|
||||
margin: 8,
|
||||
backgroundColor: theme.buttonBg,
|
||||
},
|
||||
cancelContainer: {
|
||||
alignItems: 'center',
|
||||
width: 32,
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-evenly',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 12,
|
||||
width: '100%',
|
||||
height: 42,
|
||||
shadowColor: theme.centerChannelColor,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 6,
|
||||
},
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
roundBorder: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 18,
|
||||
color: theme.buttonColor,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
iconContainer: {
|
||||
top: 2,
|
||||
width: 22,
|
||||
},
|
||||
pressContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
textContainer: {
|
||||
paddingLeft: 4,
|
||||
},
|
||||
text: {
|
||||
color: theme.buttonColor,
|
||||
...typography('Body', 200, 'SemiBold'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const MoreMessages = ({
|
||||
channelId,
|
||||
isManualUnread,
|
||||
newMessageLineIndex,
|
||||
posts,
|
||||
registerViewableItemsListener,
|
||||
registerScrollEndIndexListener,
|
||||
scrollToIndex,
|
||||
unreadCount,
|
||||
testID,
|
||||
theme,
|
||||
}: Props) => {
|
||||
const serverUrl = useServerUrl();
|
||||
const pressed = useRef(false);
|
||||
const resetting = useRef(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [remaining, setRemaining] = useState(0);
|
||||
const underlayColor = useMemo(() => `hsl(${hexToHue(theme.buttonBg)}, 50%, 38%)`, [theme]);
|
||||
const top = useSharedValue(0);
|
||||
const BARS_FACTOR = Math.abs((1) / (HIDDEN_TOP - SHOWN_TOP));
|
||||
const styles = getStyleSheet(theme);
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{
|
||||
translateY: withSpring(interpolate(
|
||||
top.value,
|
||||
[
|
||||
MIN_INPUT,
|
||||
MIN_INPUT + BARS_FACTOR,
|
||||
MAX_INPUT - BARS_FACTOR,
|
||||
MAX_INPUT,
|
||||
],
|
||||
[
|
||||
HIDDEN_TOP,
|
||||
HIDDEN_TOP,
|
||||
SHOWN_TOP,
|
||||
SHOWN_TOP,
|
||||
],
|
||||
Animated.Extrapolate.CLAMP,
|
||||
), {damping: 15}),
|
||||
}],
|
||||
}), []);
|
||||
|
||||
const resetCount = async () => {
|
||||
if (resetting.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetting.current = true;
|
||||
await resetMessageCount(serverUrl, channelId);
|
||||
resetting.current = false;
|
||||
};
|
||||
|
||||
const onViewableItemsChanged = (viewableItems: ViewToken[]) => {
|
||||
pressed.current = false;
|
||||
|
||||
if (newMessageLineIndex <= 0 || viewableItems.length === 0 || isManualUnread || resetting.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastViewableIndex = viewableItems.filter((v) => v.isViewable)[viewableItems.length - 1]?.index || 0;
|
||||
const nextViewableIndex = lastViewableIndex + 1;
|
||||
if (viewableItems[0].index === 0 && nextViewableIndex > newMessageLineIndex) {
|
||||
// Auto scroll if the first post is viewable and
|
||||
// * the new message line is viewable OR
|
||||
// * the new message line will be the first next viewable item
|
||||
scrollToIndex(newMessageLineIndex, true);
|
||||
resetCount();
|
||||
top.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const readCount = posts.slice(0, lastViewableIndex).filter((v) => typeof v !== 'string').length;
|
||||
const totalUnread = unreadCount - readCount;
|
||||
if (lastViewableIndex >= newMessageLineIndex) {
|
||||
resetCount();
|
||||
top.value = 0;
|
||||
} else if (totalUnread > 0) {
|
||||
setRemaining(totalUnread);
|
||||
top.value = 1;
|
||||
}
|
||||
};
|
||||
|
||||
const onScrollEndIndex = () => {
|
||||
pressed.current = false;
|
||||
};
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
pressed.current = true;
|
||||
top.value = 0;
|
||||
resetMessageCount(serverUrl, channelId);
|
||||
pressed.current = false;
|
||||
}, [serverUrl, channelId]);
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
if (pressed.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
pressed.current = true;
|
||||
scrollToIndex(newMessageLineIndex, true);
|
||||
}, [newMessageLineIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = DeviceEventEmitter.addListener(Events.LOADING_CHANNEL_POSTS, (value: boolean) => {
|
||||
setLoading(value);
|
||||
});
|
||||
|
||||
return () => listener.remove();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unregister = registerScrollEndIndexListener(onScrollEndIndex);
|
||||
|
||||
return () => unregister();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unregister = registerViewableItemsListener(onViewableItemsChanged);
|
||||
|
||||
return () => unregister();
|
||||
}, [channelId, unreadCount, newMessageLineIndex, posts]);
|
||||
|
||||
useEffect(() => {
|
||||
resetting.current = false;
|
||||
}, [channelId]);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.animatedContainer, styles.roundBorder, animatedStyle]}>
|
||||
<View style={styles.container}>
|
||||
<TouchableWithFeedback
|
||||
type={'opacity'}
|
||||
onPress={onPress}
|
||||
underlayColor={underlayColor}
|
||||
style={styles.pressContainer}
|
||||
testID={testID}
|
||||
>
|
||||
<>
|
||||
<View style={styles.iconContainer}>
|
||||
{loading &&
|
||||
<ActivityIndicator
|
||||
animating={true}
|
||||
size='small'
|
||||
color={theme.buttonColor}
|
||||
/>
|
||||
}
|
||||
{!loading &&
|
||||
<CompassIcon
|
||||
name='arrow-up'
|
||||
style={styles.icon}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<FormattedText
|
||||
id='more_messages.text'
|
||||
defaultMessage='{count} new {count, plural, one {message} other {messages}}'
|
||||
style={styles.text}
|
||||
values={{count: remaining}}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
</TouchableWithFeedback>
|
||||
<TouchableWithFeedback
|
||||
type='opacity'
|
||||
onPress={onCancel}
|
||||
>
|
||||
<View style={styles.cancelContainer}>
|
||||
<CompassIcon
|
||||
name='close'
|
||||
style={styles.icon}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MoreMessages;
|
||||
@@ -4,6 +4,7 @@
|
||||
import React from 'react';
|
||||
import {StyleProp, View, ViewStyle} from 'react-native';
|
||||
|
||||
import {typography} from '@app/utils/typography';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
@@ -14,6 +15,30 @@ type NewMessagesLineProps = {
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
height: 28,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
textContainer: {
|
||||
marginHorizontal: 15,
|
||||
},
|
||||
line: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: theme.newMessageSeparator,
|
||||
},
|
||||
text: {
|
||||
color: theme.newMessageSeparator,
|
||||
marginHorizontal: 4,
|
||||
...typography('Body', 75, 'SemiBold'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function NewMessagesLine({moreMessages, style, testID, theme}: NewMessagesLineProps) {
|
||||
const styles = getStyleFromTheme(theme);
|
||||
|
||||
@@ -48,27 +73,4 @@ function NewMessagesLine({moreMessages, style, testID, theme}: NewMessagesLinePr
|
||||
);
|
||||
}
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
height: 28,
|
||||
},
|
||||
textContainer: {
|
||||
marginHorizontal: 15,
|
||||
},
|
||||
line: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: theme.newMessageSeparator,
|
||||
},
|
||||
text: {
|
||||
lineHeight: 16,
|
||||
fontSize: 12,
|
||||
color: theme.newMessageSeparator,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default NewMessagesLine;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import React, {ReactNode, useRef} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Keyboard, Platform, StyleProp, StyleSheet, View, ViewStyle} from 'react-native';
|
||||
import {Keyboard, Platform, StyleSheet, View} from 'react-native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
@@ -34,7 +34,6 @@ type AvatarProps = {
|
||||
enablePostIconOverride?: boolean;
|
||||
isAutoReponse: boolean;
|
||||
isSystemPost: boolean;
|
||||
pendingPostStyle?: StyleProp<ViewStyle>;
|
||||
post: PostModel;
|
||||
}
|
||||
|
||||
@@ -42,14 +41,9 @@ const style = StyleSheet.create({
|
||||
buffer: {
|
||||
marginRight: Platform.select({android: 2, ios: 3}),
|
||||
},
|
||||
profilePictureContainer: {
|
||||
marginBottom: 5,
|
||||
marginRight: 10,
|
||||
marginTop: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const Avatar = ({author, enablePostIconOverride, isAutoReponse, isSystemPost, pendingPostStyle, post}: AvatarProps) => {
|
||||
const Avatar = ({author, enablePostIconOverride, isAutoReponse, isSystemPost, post}: AvatarProps) => {
|
||||
const closeButton = useRef<ImageSource>();
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
@@ -96,19 +90,17 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, isSystemPost, pe
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[style.profilePictureContainer, pendingPostStyle]}>
|
||||
<View
|
||||
style={[{
|
||||
borderRadius,
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: frameSize,
|
||||
width: frameSize,
|
||||
}, style.buffer]}
|
||||
>
|
||||
{iconComponent}
|
||||
</View>
|
||||
<View
|
||||
style={[{
|
||||
borderRadius,
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: frameSize,
|
||||
width: frameSize,
|
||||
}, style.buffer]}
|
||||
>
|
||||
{iconComponent}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -157,11 +149,7 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, isSystemPost, pe
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[style.profilePictureContainer, pendingPostStyle]}>
|
||||
{component}
|
||||
</View>
|
||||
);
|
||||
return component;
|
||||
};
|
||||
|
||||
const withPost = withObservables(['post'], ({database, post}: {post: PostModel} & WithDatabaseArgs) => {
|
||||
|
||||
@@ -140,7 +140,7 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId,
|
||||
theme={theme}
|
||||
isSingleImage={singleImage}
|
||||
nonVisibleImagesCount={nonVisibleImagesCount}
|
||||
wrapperWidth={getViewPortWidth(isReplyPost, isTablet)}
|
||||
wrapperWidth={getViewPortWidth(isReplyPost, isTablet) - 15}
|
||||
inViewPort={inViewPort}
|
||||
/>
|
||||
</View>
|
||||
@@ -154,7 +154,7 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId,
|
||||
}
|
||||
|
||||
const visibleImages = imageAttachments.slice(0, MAX_VISIBLE_ROW_IMAGES);
|
||||
const portraitPostWidth = getViewPortWidth(isReplyPost, isTablet);
|
||||
const portraitPostWidth = getViewPortWidth(isReplyPost, isTablet) - 15;
|
||||
|
||||
let nonVisibleImagesCount;
|
||||
if (imageAttachments.length > MAX_VISIBLE_ROW_IMAGES) {
|
||||
|
||||
@@ -41,8 +41,7 @@ type BodyProps = {
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
messageBody: {
|
||||
paddingBottom: 2,
|
||||
paddingTop: 2,
|
||||
paddingVertical: 2,
|
||||
flex: 1,
|
||||
},
|
||||
messageContainer: {width: '100%'},
|
||||
|
||||
@@ -11,6 +11,7 @@ import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {showModal} from '@screens/navigation';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import type {ImageSource} from 'react-native-vector-icons/Icon';
|
||||
|
||||
@@ -28,10 +29,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
displayName: {
|
||||
color: theme.centerChannelColor,
|
||||
fontFamily: 'OpenSans-Semibold',
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
flexGrow: 1,
|
||||
...typography('Body', 200, 'SemiBold'),
|
||||
},
|
||||
displayNameContainer: {
|
||||
maxWidth: '60%',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import React from 'react';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import {typography} from '@app/utils/typography';
|
||||
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
|
||||
import FormattedTime from '@components/formatted_time';
|
||||
import {CHANNEL, THREAD} from '@constants/screens';
|
||||
@@ -55,11 +56,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
},
|
||||
time: {
|
||||
color: theme.centerChannelColor,
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
marginTop: 5,
|
||||
opacity: 0.5,
|
||||
flex: 1,
|
||||
...typography('Body', 75, 'Regular'),
|
||||
},
|
||||
customStatusEmoji: {
|
||||
color: theme.centerChannelColor,
|
||||
|
||||
@@ -70,6 +70,17 @@ async function shouldHighlightReplyBar(currentUser: UserModel, post: PostModel,
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isFirstReply(post: PostModel, previousPost?: PostModel) {
|
||||
if (post.rootId) {
|
||||
if (previousPost) {
|
||||
return post.rootId !== previousPost.id && post.rootId !== previousPost.rootId;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({
|
||||
featureFlagAppsEnabled: database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
|
||||
switchMap((cfg) => of$(cfg.value.FeatureFlagAppsEnabled)),
|
||||
@@ -82,17 +93,12 @@ const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({
|
||||
const withPost = withObservables(
|
||||
['currentUser', 'post', 'previousPost', 'nextPost'],
|
||||
({featureFlagAppsEnabled, currentUser, database, post, previousPost, nextPost}: PropsInput) => {
|
||||
let isFirstReply = of$(true);
|
||||
let isJumboEmoji = of$(false);
|
||||
let isLastReply = of$(true);
|
||||
let isPostAddChannelMember = of$(false);
|
||||
const isOwner = currentUser.id === post.userId;
|
||||
const author = post.author.observe();
|
||||
const canDelete = from$(hasPermissionForPost(post, currentUser, isOwner ? Permissions.DELETE_POST : Permissions.DELETE_OTHERS_POSTS, false));
|
||||
const isConsecutivePost = post.author.observe().pipe(switchMap(
|
||||
(user: UserModel) => {
|
||||
return of$(Boolean(post && previousPost && !user.isBot && post.rootId && areConsecutivePosts(post, previousPost)));
|
||||
},
|
||||
));
|
||||
const isEphemeral = of$(isPostEphemeral(post));
|
||||
const isFlagged = database.get<PreferenceModel>(PREFERENCE).query(
|
||||
Q.where('category', Preferences.CATEGORY_FLAGGED_POST),
|
||||
@@ -114,7 +120,6 @@ const withPost = withObservables(
|
||||
let differentThreadSequence = true;
|
||||
if (post.rootId) {
|
||||
differentThreadSequence = previousPost?.rootId ? previousPost?.rootId !== post.rootId : previousPost?.id !== post.rootId;
|
||||
isFirstReply = of$(differentThreadSequence || (previousPost?.id === post.rootId || previousPost?.rootId === post.rootId));
|
||||
isLastReply = of$(!(nextPost?.rootId === post.rootId));
|
||||
}
|
||||
|
||||
@@ -125,6 +130,10 @@ const withPost = withObservables(
|
||||
),
|
||||
);
|
||||
}
|
||||
const hasReplies = from$(post.hasReplies());
|
||||
const isConsecutivePost = author.pipe(
|
||||
switchMap((user) => of$(Boolean(post && previousPost && !user.isBot && areConsecutivePosts(post, previousPost)))),
|
||||
);
|
||||
|
||||
const partialConfig: Partial<ClientConfig> = {
|
||||
FeatureFlagAppsEnabled: featureFlagAppsEnabled,
|
||||
@@ -133,13 +142,13 @@ const withPost = withObservables(
|
||||
return {
|
||||
appsEnabled: of$(appsEnabled(partialConfig)),
|
||||
canDelete,
|
||||
currentUser,
|
||||
differentThreadSequence: of$(differentThreadSequence),
|
||||
files: post.files.observe(),
|
||||
hasReplies,
|
||||
highlightReplyBar,
|
||||
isConsecutivePost,
|
||||
isEphemeral,
|
||||
isFirstReply,
|
||||
isFirstReply: of$(isFirstReply(post, previousPost)),
|
||||
isFlagged,
|
||||
isJumboEmoji,
|
||||
isLastReply,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {ReactNode, useRef} from 'react';
|
||||
import React, {ReactNode, useMemo, useRef} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {DeviceEventEmitter, Keyboard, StyleProp, View, ViewStyle} from 'react-native';
|
||||
import {DeviceEventEmitter, Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native';
|
||||
|
||||
import {showPermalink} from '@actions/local/permalink';
|
||||
import {removePost} from '@actions/local/post';
|
||||
@@ -34,8 +34,9 @@ type PostProps = {
|
||||
currentUser: UserModel;
|
||||
differentThreadSequence: boolean;
|
||||
files: FileModel[];
|
||||
hasReplies: boolean;
|
||||
highlight?: boolean;
|
||||
highlightPinnedOrFlagged?: boolean;
|
||||
highlightPinnedOrSaved?: boolean;
|
||||
highlightReplyBar: boolean;
|
||||
isConsecutivePost?: boolean;
|
||||
isEphemeral: boolean;
|
||||
@@ -46,6 +47,7 @@ type PostProps = {
|
||||
isPostAddChannelMember: boolean;
|
||||
location: string;
|
||||
post: PostModel;
|
||||
previousPost?: PostModel;
|
||||
reactionsCount: number;
|
||||
shouldRenderReplyButton?: boolean;
|
||||
showAddReaction?: boolean;
|
||||
@@ -61,7 +63,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
consecutivePostContainer: {
|
||||
marginBottom: 10,
|
||||
marginRight: 10,
|
||||
marginLeft: 27,
|
||||
marginLeft: Platform.select({ios: 35, android: 34}),
|
||||
marginTop: 10,
|
||||
},
|
||||
container: {flexDirection: 'row'},
|
||||
@@ -70,27 +72,20 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
backgroundColor: theme.mentionHighlightBg,
|
||||
opacity: 1,
|
||||
},
|
||||
highlightPinnedOrFlagged: {backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.2)},
|
||||
highlightPinnedOrSaved: {
|
||||
backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.2),
|
||||
},
|
||||
pendingPost: {opacity: 0.5},
|
||||
postStyle: {
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
profilePictureContainer: {
|
||||
marginBottom: 5,
|
||||
marginRight: 10,
|
||||
marginTop: 10,
|
||||
},
|
||||
replyBar: {
|
||||
backgroundColor: theme.centerChannelColor,
|
||||
opacity: 0.1,
|
||||
marginLeft: 1,
|
||||
marginRight: 7,
|
||||
width: 3,
|
||||
flexBasis: 3,
|
||||
},
|
||||
replyBarFirst: {paddingTop: 10},
|
||||
replyBarLast: {paddingBottom: 10},
|
||||
rightColumn: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
@@ -100,10 +95,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
});
|
||||
|
||||
const Post = ({
|
||||
appsEnabled, canDelete, currentUser, differentThreadSequence, files, highlight, highlightPinnedOrFlagged = true, highlightReplyBar,
|
||||
appsEnabled, canDelete, currentUser, differentThreadSequence, files, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar,
|
||||
isConsecutivePost, isEphemeral, isFirstReply, isFlagged, isJumboEmoji, isLastReply, isPostAddChannelMember,
|
||||
location, post, reactionsCount, shouldRenderReplyButton, skipFlaggedHeader, skipPinnedHeader, showAddReaction = true, style,
|
||||
testID,
|
||||
testID, previousPost,
|
||||
}: PostProps) => {
|
||||
const pressDetected = useRef(false);
|
||||
const intl = useIntl();
|
||||
@@ -114,6 +109,17 @@ const Post = ({
|
||||
const isPendingOrFailed = isPostPendingOrFailed(post);
|
||||
const isSystemPost = isSystemMessage(post);
|
||||
const isWebHook = isFromWebhook(post);
|
||||
const hasSameRoot = useMemo(() => {
|
||||
if (isFirstReply) {
|
||||
return false;
|
||||
} else if (!post.rootId && !previousPost?.rootId && isConsecutivePost) {
|
||||
return true;
|
||||
} else if (post.rootId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [isConsecutivePost, post, previousPost, isFirstReply]);
|
||||
|
||||
const handlePress = preventDoubleTap(() => {
|
||||
pressDetected.current = true;
|
||||
@@ -179,14 +185,15 @@ const Post = ({
|
||||
let highlightedStyle: StyleProp<ViewStyle>;
|
||||
if (highlight) {
|
||||
highlightedStyle = styles.highlight;
|
||||
} else if ((highlightFlagged || hightlightPinned) && highlightPinnedOrFlagged) {
|
||||
highlightedStyle = styles.highlightPinnedOrFlagged;
|
||||
} else if ((highlightFlagged || hightlightPinned) && highlightPinnedOrSaved) {
|
||||
highlightedStyle = styles.highlightPinnedOrSaved;
|
||||
}
|
||||
|
||||
let header: ReactNode;
|
||||
let postAvatar: ReactNode;
|
||||
let consecutiveStyle: StyleProp<ViewStyle>;
|
||||
if (isConsecutivePost) {
|
||||
const sameSecuence = hasReplies ? (hasReplies && post.rootId) : !post.rootId;
|
||||
if (hasSameRoot && isConsecutivePost && sameSecuence) {
|
||||
consecutiveStyle = styles.consective;
|
||||
postAvatar = <View style={styles.consecutivePostContainer}/>;
|
||||
} else {
|
||||
|
||||
@@ -24,7 +24,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
height: 15,
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
marginTop: 10,
|
||||
},
|
||||
@@ -89,7 +88,7 @@ const PreHeader = ({isConsecutivePost, isFlagged, isPinned, skipFlaggedHeader, s
|
||||
<View style={style.iconsContainer}>
|
||||
{isPinned && !skipPinnedHeader &&
|
||||
<CompassIcon
|
||||
name='pin-outline'
|
||||
name='pin'
|
||||
size={14}
|
||||
style={style.icon}
|
||||
/>
|
||||
@@ -99,7 +98,7 @@ const PreHeader = ({isConsecutivePost, isFlagged, isPinned, skipFlaggedHeader, s
|
||||
}
|
||||
{isFlagged && !skipFlaggedHeader &&
|
||||
<CompassIcon
|
||||
name='bookmark-outline'
|
||||
name='bookmark'
|
||||
size={14}
|
||||
style={style.icon}
|
||||
/>
|
||||
|
||||
@@ -257,14 +257,25 @@ const systemMessageRenderers = {
|
||||
export const SystemMessage = ({post, author}: SystemMessageProps) => {
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
const renderer = systemMessageRenderers[post.type];
|
||||
if (!renderer) {
|
||||
return null;
|
||||
}
|
||||
const style = getStyleSheet(theme);
|
||||
const textStyles = getMarkdownTextStyles(theme);
|
||||
const styles = {messageStyle: style.systemMessage, textStyles};
|
||||
|
||||
const renderer = systemMessageRenderers[post.type];
|
||||
if (!renderer) {
|
||||
return (
|
||||
<Markdown
|
||||
baseTextStyle={styles.messageStyle}
|
||||
disableGallery={true}
|
||||
textStyles={styles.textStyles}
|
||||
value={post.message}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return renderer({post, author, styles, intl, theme});
|
||||
};
|
||||
|
||||
export default React.memo(SystemMessage);
|
||||
export default SystemMessage;
|
||||
|
||||
41
app/components/post_list/refresh_control.tsx
Normal file
41
app/components/post_list/refresh_control.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Platform, RefreshControl, StyleProp, ViewStyle} from 'react-native';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactElement;
|
||||
enabled: boolean;
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const PostListRefreshControl = ({children, enabled, onRefresh, refreshing, style}: Props) => {
|
||||
const props = {
|
||||
onRefresh,
|
||||
refreshing,
|
||||
};
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
return (
|
||||
<RefreshControl
|
||||
{...props}
|
||||
enabled={enabled}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</RefreshControl>
|
||||
);
|
||||
}
|
||||
|
||||
const refreshControl = <RefreshControl {...props}/>;
|
||||
|
||||
return React.cloneElement(
|
||||
children,
|
||||
{refreshControl, inverted: true},
|
||||
);
|
||||
};
|
||||
|
||||
export default PostListRefreshControl;
|
||||
@@ -4,7 +4,7 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
// We disable the prop types check here as forwardRef & typescript has a bug
|
||||
|
||||
import React, {forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react';
|
||||
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {ActivityIndicatorProps, Platform, StyleProp, TextInput, TextInputProps, TextStyle, TouchableOpacityProps, ViewStyle} from 'react-native';
|
||||
import {SearchBar} from 'react-native-elements';
|
||||
@@ -73,7 +73,7 @@ const Search = forwardRef<SearchRef, SearchProps>((props: SearchProps, ref) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const searchRef = useRef<TextInput>(null);
|
||||
const [value, setValue] = useState(props.value || '');
|
||||
const [value, setValue] = useState(props.defaultValue || props.value || '');
|
||||
const searchClearButtonTestID = `${props.testID}.search.clear.button`;
|
||||
const searchCancelButtonTestID = `${props.testID}.search.cancel.button`;
|
||||
const searchInputTestID = `${props.testID}.search.input`;
|
||||
@@ -100,6 +100,10 @@ const Search = forwardRef<SearchRef, SearchProps>((props: SearchProps, ref) => {
|
||||
},
|
||||
}), [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(props.defaultValue || value || '');
|
||||
}, [props.defaultValue]);
|
||||
|
||||
const clearIcon = useMemo(() => {
|
||||
return (
|
||||
<CompassIcon
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {View as ViewConstants} from '@constants';
|
||||
@@ -11,23 +10,13 @@ type Props = {
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
profilePictureContainer: {
|
||||
marginBottom: 5,
|
||||
marginRight: 10,
|
||||
marginTop: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const SystemAvatar = ({theme}: Props) => {
|
||||
return (
|
||||
<View style={styles.profilePictureContainer}>
|
||||
<CompassIcon
|
||||
name='mattermost'
|
||||
color={theme.centerChannelColor}
|
||||
size={ViewConstants.PROFILE_PICTURE_SIZE}
|
||||
/>
|
||||
</View>
|
||||
<CompassIcon
|
||||
name='mattermost'
|
||||
color={theme.centerChannelColor}
|
||||
size={ViewConstants.PROFILE_PICTURE_SIZE}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {Preferences} from '@constants';
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {getPreferenceAsBool} from '@helpers/api/preference';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
import {getUserTimezone} from '@utils/user';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
@@ -35,15 +36,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
displayName: {
|
||||
color: theme.centerChannelColor,
|
||||
fontSize: 15,
|
||||
fontFamily: 'OpenSans-Semibold',
|
||||
flexGrow: 1,
|
||||
paddingVertical: 2,
|
||||
...typography('Body', 200, 'SemiBold'),
|
||||
},
|
||||
displayNameContainer: {
|
||||
maxWidth: '60%',
|
||||
marginRight: 5,
|
||||
marginBottom: 3,
|
||||
},
|
||||
header: {
|
||||
flex: 1,
|
||||
@@ -52,10 +50,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
},
|
||||
time: {
|
||||
color: theme.centerChannelColor,
|
||||
fontSize: 12,
|
||||
marginTop: 5,
|
||||
opacity: 0.5,
|
||||
flex: 1,
|
||||
...typography('Body', 75, 'Regular'),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -105,4 +103,4 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(React.memo(SystemHeader)));
|
||||
export default withDatabase(enhanced(SystemHeader));
|
||||
|
||||
@@ -54,7 +54,7 @@ const withTeams = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
|
||||
return ts.sort((a, b) => {
|
||||
if ((indexes[a.id] != null) || (indexes[b.id] != null)) {
|
||||
return (indexes[a.id] ?? -1) - (indexes[b.id] ?? -1);
|
||||
return (indexes[a.id] ?? tids.length) - (indexes[b.id] ?? tids.length);
|
||||
}
|
||||
return (originalIndexes[a.id] - originalIndexes[b.id]);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import React from 'react';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import {handleTeamChange} from '@actions/local/team';
|
||||
import {handleTeamChange} from '@actions/remote/team';
|
||||
import Badge from '@components/badge';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {useServerUrl} from '@context/server';
|
||||
@@ -22,6 +22,41 @@ type Props = {
|
||||
currentTeamId: string;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
height: 54,
|
||||
width: 54,
|
||||
flex: 0,
|
||||
padding: 3,
|
||||
borderRadius: 10,
|
||||
marginVertical: 3,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
containerSelected: {
|
||||
borderWidth: 3,
|
||||
borderRadius: 12,
|
||||
borderColor: theme.sidebarTextActiveBorder,
|
||||
},
|
||||
unread: {
|
||||
left: 40,
|
||||
top: 3,
|
||||
},
|
||||
mentionsOneDigit: {
|
||||
top: 1,
|
||||
left: 28,
|
||||
},
|
||||
mentionsTwoDigits: {
|
||||
top: 1,
|
||||
left: 26,
|
||||
},
|
||||
mentionsThreeDigits: {
|
||||
top: 1,
|
||||
left: 23,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default function TeamItem({team, hasUnreads, mentionCount, currentTeamId}: Props) {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
@@ -64,45 +99,10 @@ export default function TeamItem({team, hasUnreads, mentionCount, currentTeamId}
|
||||
</View>
|
||||
<Badge
|
||||
borderColor={theme.sidebarTeamBarBg}
|
||||
visible={hasBadge}
|
||||
visible={hasBadge && !selected}
|
||||
style={badgeStyle}
|
||||
value={value}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
height: 54,
|
||||
width: 54,
|
||||
flex: 0,
|
||||
padding: 3,
|
||||
borderRadius: 10,
|
||||
marginVertical: 3,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
containerSelected: {
|
||||
borderWidth: 3,
|
||||
borderRadius: 12,
|
||||
borderColor: theme.sidebarTextActiveBorder,
|
||||
},
|
||||
unread: {
|
||||
left: 40,
|
||||
top: 3,
|
||||
},
|
||||
mentionsOneDigit: {
|
||||
top: 1,
|
||||
left: 28,
|
||||
},
|
||||
mentionsTwoDigits: {
|
||||
top: 1,
|
||||
left: 26,
|
||||
},
|
||||
mentionsThreeDigits: {
|
||||
top: 1,
|
||||
left: 23,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export default keyMirror({
|
||||
CONFIG_CHANGED: null,
|
||||
LEAVE_CHANNEL: null,
|
||||
LEAVE_TEAM: null,
|
||||
LOADING_CHANNEL_POSTS: null,
|
||||
NOTIFICATION_ERROR: null,
|
||||
SERVER_LOGOUT: null,
|
||||
SERVER_VERSION_CHANGED: null,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import keyMirror from '@utils/key_mirror';
|
||||
|
||||
const Navigation = keyMirror({
|
||||
NAVIGATE_TO_TAB: null,
|
||||
NAVIGATION_CLOSE_MODAL: null,
|
||||
NAVIGATION_HOME: null,
|
||||
NAVIGATION_SHOW_OVERLAY: null,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export const ABOUT = 'About';
|
||||
export const ACCOUNT = 'Account';
|
||||
export const EMOJI_PICKER = 'AddReaction';
|
||||
export const APP_FORM = 'AppForm';
|
||||
export const BOTTOM_SHEET = 'BottomSheet';
|
||||
@@ -17,7 +18,7 @@ export const HOME = 'Home';
|
||||
export const INTEGRATION_SELECTOR = 'IntegrationSelector';
|
||||
export const IN_APP_NOTIFICATION = 'InAppNotification';
|
||||
export const LOGIN = 'Login';
|
||||
export const MAIN_SIDEBAR = 'MainSidebar';
|
||||
export const MENTIONS = 'Mentions';
|
||||
export const MFA = 'MFA';
|
||||
export const PERMALINK = 'Permalink';
|
||||
export const SEARCH = 'Search';
|
||||
@@ -26,10 +27,10 @@ export const SETTINGS_SIDEBAR = 'SettingsSidebar';
|
||||
export const SSO = 'SSO';
|
||||
export const THREAD = 'Thread';
|
||||
export const USER_PROFILE = 'UserProfile';
|
||||
export const MENTIONS = 'Mentions';
|
||||
|
||||
export default {
|
||||
ABOUT,
|
||||
ACCOUNT,
|
||||
EMOJI_PICKER,
|
||||
APP_FORM,
|
||||
BOTTOM_SHEET,
|
||||
@@ -45,7 +46,7 @@ export default {
|
||||
INTEGRATION_SELECTOR,
|
||||
IN_APP_NOTIFICATION,
|
||||
LOGIN,
|
||||
MAIN_SIDEBAR,
|
||||
MENTIONS,
|
||||
MFA,
|
||||
PERMALINK,
|
||||
SEARCH,
|
||||
@@ -54,5 +55,4 @@ export default {
|
||||
SSO,
|
||||
THREAD,
|
||||
USER_PROFILE,
|
||||
MENTIONS,
|
||||
};
|
||||
|
||||
@@ -7,9 +7,11 @@ export const BOTTOM_TAB_ICON_SIZE = 31.2;
|
||||
export const PROFILE_PICTURE_SIZE = 32;
|
||||
export const PROFILE_PICTURE_EMOJI_SIZE = 28;
|
||||
export const SEARCH_INPUT_HEIGHT = Platform.select({android: 40, ios: 36})!;
|
||||
export const TABLET_SIDEBAR_WIDTH = 320;
|
||||
|
||||
export const TEAM_SIDEBAR_WIDTH = 72;
|
||||
export const TABLET_HEADER_HEIGHT = 44;
|
||||
export const TABLET_SIDEBAR_WIDTH = 320;
|
||||
|
||||
export const IOS_DEFAULT_HEADER_HEIGHT = 50;
|
||||
export const ANDROID_DEFAULT_HEADER_HEIGHT = 56;
|
||||
export const LARGE_HEADER_TITLE = 60;
|
||||
@@ -19,6 +21,8 @@ export const IOS_HEADER_SEARCH_INSET = 20;
|
||||
export const TABLET_HEADER_SEARCH_INSET = 28;
|
||||
export const ANDROID_HEADER_SEARCH_INSET = 11;
|
||||
|
||||
export const INDICATOR_BAR_HEIGHT = 38;
|
||||
|
||||
export default {
|
||||
BOTTOM_TAB_ICON_SIZE,
|
||||
PROFILE_PICTURE_SIZE,
|
||||
@@ -37,4 +41,5 @@ export default {
|
||||
IOS_HEADER_SEARCH_INSET,
|
||||
TABLET_HEADER_SEARCH_INSET,
|
||||
ANDROID_HEADER_SEARCH_INSET,
|
||||
INDICATOR_BAR_HEIGHT,
|
||||
};
|
||||
|
||||
@@ -124,4 +124,17 @@ export default class PostModel extends Model {
|
||||
).destroyAllPermanently();
|
||||
super.destroyPermanently();
|
||||
}
|
||||
|
||||
async hasReplies() {
|
||||
if (!this.rootId) {
|
||||
return (await this.postsInThread.fetch()).length > 0;
|
||||
}
|
||||
|
||||
const root = await this.root.fetch();
|
||||
if (root.length) {
|
||||
return (await root[0].postsInThread.fetch()).length > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,49 @@ const PostsInChannelHandler = (superclass: any) => class extends superclass {
|
||||
};
|
||||
|
||||
handleReceivedPostsInChannelBefore = async (posts: Post[], prepareRecordsOnly = false): Promise<PostsInChannelModel[]> => {
|
||||
throw new Error(`handleReceivedPostsInChannelBefore Not implemented yet. posts count${posts.length} prepareRecordsOnly=${prepareRecordsOnly}`);
|
||||
if (!posts.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const {firstPost} = getPostListEdges(posts);
|
||||
|
||||
// Channel Id for this chain of posts
|
||||
const channelId = firstPost.channel_id;
|
||||
|
||||
// Find smallest 'create_at' value in chain
|
||||
const earliest = firstPost.create_at;
|
||||
|
||||
// Find the records in the PostsInChannel table that have a matching channel_id
|
||||
const chunks = (await this.database.get(POSTS_IN_CHANNEL).query(
|
||||
Q.where('channel_id', channelId),
|
||||
Q.sortBy('latest', Q.desc),
|
||||
).fetch()) as PostsInChannelModel[];
|
||||
|
||||
if (chunks.length === 0) {
|
||||
// No chunks found, previous posts in this block not found
|
||||
return [];
|
||||
}
|
||||
|
||||
let targetChunk = chunks[0];
|
||||
if (targetChunk) {
|
||||
// If the chunk was found, Update the chunk and return
|
||||
if (prepareRecordsOnly) {
|
||||
targetChunk.prepareUpdate((record) => {
|
||||
record.earliest = Math.min(record.earliest, earliest);
|
||||
});
|
||||
return [targetChunk];
|
||||
}
|
||||
|
||||
targetChunk = await this.database.write(async () => {
|
||||
return targetChunk!.update((record) => {
|
||||
record.earliest = Math.min(record.earliest, earliest);
|
||||
});
|
||||
});
|
||||
|
||||
return [targetChunk!];
|
||||
}
|
||||
|
||||
return targetChunk;
|
||||
};
|
||||
|
||||
handleReceivedPostsInChannelAfter = async (posts: Post[], prepareRecordsOnly = false): Promise<PostsInChannelModel[]> => {
|
||||
|
||||
@@ -129,6 +129,8 @@ export const useCollapsibleHeader = <T>(isLargeTitle: boolean, hasSubtitle: bool
|
||||
}
|
||||
|
||||
return {
|
||||
defaultHeight,
|
||||
largeHeight,
|
||||
scrollPaddingTop: (isLargeTitle ? largeHeight : defaultHeight) + searchPadding,
|
||||
scrollRef: animatedRef as unknown as React.RefObject<T>,
|
||||
scrollValue,
|
||||
|
||||
144
app/screens/channel/channel.tsx
Normal file
144
app/screens/channel/channel.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {DeviceEventEmitter, Keyboard, Platform, View} from 'react-native';
|
||||
import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import NavigationHeader from '@components/navigation_header';
|
||||
import {Navigation} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useAppState, useIsTablet} from '@hooks/device';
|
||||
import {useDefaultHeaderHeight} from '@hooks/header';
|
||||
import {popTopScreen} from '@screens/navigation';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import ChannelPostList from './channel_post_list';
|
||||
import FailedChannels from './failed_channels';
|
||||
import FailedTeams from './failed_teams';
|
||||
import OtherMentionsBadge from './other_mentions_badge';
|
||||
|
||||
import type {HeaderRightButton} from '@components/navigation_header/header';
|
||||
|
||||
type ChannelProps = {
|
||||
channelId: string;
|
||||
componentId?: string;
|
||||
displayName: string;
|
||||
isOwnDirectMessage: boolean;
|
||||
memberCount: number;
|
||||
name: string;
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
const edges: Edge[] = ['left', 'right'];
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
sectionContainer: {
|
||||
marginTop: 10,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontFamily: 'OpenSans-Semibold',
|
||||
color: theme.centerChannelColor,
|
||||
},
|
||||
}));
|
||||
|
||||
const Channel = ({channelId, componentId, displayName, isOwnDirectMessage, memberCount, name, teamId}: ChannelProps) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const appState = useAppState();
|
||||
const isTablet = useIsTablet();
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const defaultHeight = useDefaultHeaderHeight();
|
||||
const rightButtons: HeaderRightButton[] = useMemo(() => ([{
|
||||
iconName: 'magnify',
|
||||
onPress: () => {
|
||||
DeviceEventEmitter.emit(Navigation.NAVIGATE_TO_TAB, {screen: 'Search', params: {searchTerm: `in: ${name}`}});
|
||||
if (!isTablet) {
|
||||
popTopScreen(componentId);
|
||||
}
|
||||
},
|
||||
}, {
|
||||
iconName: Platform.select({android: 'dots-vertical', default: 'dots-horizontal'}),
|
||||
onPress: () => true,
|
||||
buttonType: 'opacity',
|
||||
}]), [channelId, isTablet, name]);
|
||||
|
||||
const leftComponent = useMemo(() => {
|
||||
if (isTablet || !channelId || !teamId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (<OtherMentionsBadge channelId={channelId}/>);
|
||||
}, [isTablet, channelId, teamId]);
|
||||
|
||||
const subtitleCompanion = useMemo(() => (
|
||||
<CompassIcon
|
||||
color={changeOpacity(theme.sidebarHeaderTextColor, 0.72)}
|
||||
name='chevron-right'
|
||||
size={14}
|
||||
/>
|
||||
), []);
|
||||
|
||||
const onBackPress = useCallback(() => {
|
||||
Keyboard.dismiss();
|
||||
popTopScreen(componentId);
|
||||
}, []);
|
||||
|
||||
const onTitlePress = useCallback(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Title Press go to Channel Info', displayName);
|
||||
}, [channelId]);
|
||||
|
||||
if (!teamId) {
|
||||
return <FailedTeams/>;
|
||||
}
|
||||
|
||||
if (!channelId) {
|
||||
return <FailedChannels teamId={teamId}/>;
|
||||
}
|
||||
|
||||
let title = displayName;
|
||||
if (isOwnDirectMessage) {
|
||||
title = formatMessage({id: 'channel_header.directchannel.you', defaultMessage: '{displayName} (you)'}, {displayName});
|
||||
}
|
||||
|
||||
const marginTop = defaultHeight + (isTablet ? insets.top : 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SafeAreaView
|
||||
style={styles.flex}
|
||||
mode='margin'
|
||||
edges={edges}
|
||||
>
|
||||
<NavigationHeader
|
||||
isLargeTitle={false}
|
||||
leftComponent={leftComponent}
|
||||
onBackPress={onBackPress}
|
||||
onTitlePress={onTitlePress}
|
||||
rightButtons={rightButtons}
|
||||
showBackButton={!isTablet}
|
||||
subtitle={formatMessage({id: 'channel', defaultMessage: '{count, plural, one {# member} other {# members}}'}, {count: memberCount})}
|
||||
subtitleCompanion={subtitleCompanion}
|
||||
title={title}
|
||||
/>
|
||||
<View style={[styles.flex, {marginTop}]}>
|
||||
<ChannelPostList
|
||||
channelId={channelId}
|
||||
forceQueryAfterAppState={appState}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Channel;
|
||||
@@ -1,57 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Text} from 'react-native';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {General} from '@constants';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
type ChannelDisplayNameProps = {
|
||||
channelType: string;
|
||||
currentUserId: string;
|
||||
displayName: string;
|
||||
teammateId?: string;
|
||||
theme: Theme;
|
||||
};
|
||||
|
||||
const ChannelDisplayName = ({channelType, currentUserId, displayName, teammateId, theme}: ChannelDisplayNameProps) => {
|
||||
const style = getStyle(theme);
|
||||
let isSelfDMChannel = false;
|
||||
if (channelType === General.DM_CHANNEL && teammateId) {
|
||||
isSelfDMChannel = currentUserId === teammateId;
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={1}
|
||||
style={style.text}
|
||||
testID='channel.nav_bar.title'
|
||||
>
|
||||
{isSelfDMChannel ? (
|
||||
<FormattedText
|
||||
id={'channel_header.directchannel.you'}
|
||||
defaultMessage={'{displayname} (you)'}
|
||||
values={{displayname: displayName}}
|
||||
/>) : displayName
|
||||
}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyle = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
text: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
fontSize: 18,
|
||||
fontFamily: 'OpenSans-Semibold',
|
||||
textAlign: 'center',
|
||||
flex: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default ChannelDisplayName;
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {General} from '@constants';
|
||||
import {t} from '@i18n';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
type ChannelGuestLabelProps = {
|
||||
channelType: string;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
const ChannelGuestLabel = ({channelType, theme}: ChannelGuestLabelProps) => {
|
||||
const style = getStyle(theme);
|
||||
|
||||
let messageId;
|
||||
let defaultMessage;
|
||||
|
||||
switch (channelType) {
|
||||
case General.DM_CHANNEL: {
|
||||
messageId = t('channel.isGuest');
|
||||
defaultMessage = 'This person is a guest';
|
||||
break;
|
||||
}
|
||||
case General.GM_CHANNEL: {
|
||||
messageId = t('channel.hasGuests');
|
||||
defaultMessage = 'This group message has guests';
|
||||
break;
|
||||
}
|
||||
default : {
|
||||
messageId = t('channel.channelHasGuests');
|
||||
defaultMessage = 'This channel has guests';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={style.guestsWrapper}>
|
||||
<FormattedText
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
id={messageId}
|
||||
defaultMessage={defaultMessage}
|
||||
style={style.guestsText}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyle = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
guestsWrapper: {
|
||||
alignItems: 'flex-start',
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
top: -1,
|
||||
width: '90%',
|
||||
},
|
||||
guestsText: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
fontSize: 14,
|
||||
opacity: 0.6,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default ChannelGuestLabel;
|
||||
@@ -1,202 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import React from 'react';
|
||||
import {TouchableOpacity, View} from 'react-native';
|
||||
import {of as of$, Observable} from 'rxjs';
|
||||
import {map, switchMap} from 'rxjs/operators';
|
||||
|
||||
import ChannelIcon from '@components/channel_icon';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {General} from '@constants';
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {getUserIdFromChannelName, isGuest as isTeammateGuest} from '@utils/user';
|
||||
|
||||
import ChannelDisplayName from './channel_display_name';
|
||||
import ChannelGuestLabel from './channel_guest_label';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type ChannelInfoModel from '@typings/database/models/servers/channel_info';
|
||||
import type MyChannelSettingsModel from '@typings/database/models/servers/my_channel_settings';
|
||||
import type SystemModel from '@typings/database/models/servers/system';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
const {SERVER: {SYSTEM, USER}} = MM_TABLES;
|
||||
|
||||
type WithChannelArgs = WithDatabaseArgs & {
|
||||
channel: ChannelModel;
|
||||
}
|
||||
|
||||
type ChannelTitleProps = {
|
||||
canHaveSubtitle: boolean;
|
||||
channel: ChannelModel;
|
||||
currentUserId: string;
|
||||
channelInfo: ChannelInfoModel;
|
||||
channelSettings: MyChannelSettingsModel;
|
||||
onPress: () => void;
|
||||
teammate?: UserModel;
|
||||
};
|
||||
|
||||
const ChannelTitle = ({
|
||||
canHaveSubtitle,
|
||||
channel,
|
||||
channelInfo,
|
||||
channelSettings,
|
||||
currentUserId,
|
||||
onPress,
|
||||
teammate,
|
||||
}: ChannelTitleProps) => {
|
||||
const theme = useTheme();
|
||||
const style = getStyle(theme);
|
||||
const channelType = channel.type;
|
||||
const isArchived = channel.deleteAt !== 0;
|
||||
const isChannelMuted = channelSettings.notifyProps?.mark_unread === 'mention';
|
||||
const isChannelShared = channel.shared;
|
||||
const hasGuests = channelInfo.guestCount > 0;
|
||||
const teammateRoles = teammate?.roles ?? '';
|
||||
const isGuest = channelType === General.DM_CHANNEL && isTeammateGuest(teammateRoles);
|
||||
|
||||
const showGuestLabel = (canHaveSubtitle && ((isGuest && hasGuests) || (channelType === General.DM_CHANNEL && isGuest)));
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
testID={'channel.title.button'}
|
||||
style={style.container}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={style.wrapper}>
|
||||
{isArchived && (
|
||||
<CompassIcon
|
||||
name='archive-outline'
|
||||
style={[style.archiveIcon]}
|
||||
/>
|
||||
)}
|
||||
<ChannelDisplayName
|
||||
channelType={channelType}
|
||||
currentUserId={currentUserId}
|
||||
displayName={channel.displayName}
|
||||
teammateId={teammate?.id}
|
||||
theme={theme}
|
||||
/>
|
||||
{isChannelShared && (
|
||||
<ChannelIcon
|
||||
isActive={true}
|
||||
isArchived={false}
|
||||
size={18}
|
||||
shared={isChannelShared}
|
||||
style={style.channelIconContainer}
|
||||
type={channelType}
|
||||
/>
|
||||
)}
|
||||
<CompassIcon
|
||||
style={style.icon}
|
||||
size={24}
|
||||
name='chevron-down'
|
||||
/>
|
||||
{isChannelMuted && (
|
||||
<CompassIcon
|
||||
style={[style.icon, style.muted]}
|
||||
size={24}
|
||||
name='bell-off-outline'
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
{showGuestLabel && (
|
||||
<ChannelGuestLabel
|
||||
channelType={channelType}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyle = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
wrapper: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
top: -1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
width: '90%',
|
||||
},
|
||||
icon: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
marginHorizontal: 1,
|
||||
},
|
||||
emoji: {
|
||||
marginHorizontal: 5,
|
||||
},
|
||||
text: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
fontSize: 18,
|
||||
fontFamily: 'OpenSans-Semibold',
|
||||
textAlign: 'center',
|
||||
flex: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
channelIconContainer: {
|
||||
marginLeft: 3,
|
||||
marginRight: 0,
|
||||
},
|
||||
muted: {
|
||||
marginTop: 1,
|
||||
opacity: 0.6,
|
||||
marginLeft: 0,
|
||||
},
|
||||
archiveIcon: {
|
||||
fontSize: 17,
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
paddingRight: 7,
|
||||
},
|
||||
guestsWrapper: {
|
||||
alignItems: 'flex-start',
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
top: -1,
|
||||
width: '90%',
|
||||
},
|
||||
guestsText: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
fontSize: 14,
|
||||
opacity: 0.6,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const enhanced = withObservables(['channel'], ({channel, database}: WithChannelArgs) => {
|
||||
const currentUserId = database.collections.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
|
||||
map(({value}: {value: string}) => value),
|
||||
);
|
||||
let teammate: Observable<UserModel | undefined> = of$(undefined);
|
||||
if (channel.type === General.DM_CHANNEL && channel.displayName) {
|
||||
teammate = currentUserId.pipe(
|
||||
switchMap((id) => {
|
||||
const teammateId = getUserIdFromChannelName(id, channel.name);
|
||||
if (teammateId) {
|
||||
return database.get<UserModel>(USER).findAndObserve(teammateId);
|
||||
}
|
||||
return of$(undefined);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
channelInfo: channel.info.observe(),
|
||||
channelSettings: channel.settings.observe(),
|
||||
currentUserId,
|
||||
teammate,
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(ChannelTitle));
|
||||
@@ -1,69 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import React from 'react';
|
||||
import {Platform} from 'react-native';
|
||||
import {SafeAreaView} from 'react-native-safe-area-context';
|
||||
|
||||
import {MM_TABLES} from '@constants/database';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import ChannelTitle from './channel_title';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
|
||||
type ChannelNavBar = {
|
||||
channel: ChannelModel;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
// Todo: Create common NavBar: See Gekidou & Mobile v2 task Board
|
||||
const ChannelNavBar = ({channel, onPress}: ChannelNavBar) => {
|
||||
const theme = useTheme();
|
||||
const style = getStyleFromTheme(theme);
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
edges={['top', 'left', 'right']}
|
||||
mode='padding'
|
||||
style={style.header}
|
||||
>
|
||||
<ChannelTitle
|
||||
channel={channel}
|
||||
onPress={onPress}
|
||||
canHaveSubtitle={true}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
header: {
|
||||
backgroundColor: theme.sidebarBg,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
width: '100%',
|
||||
...Platform.select({
|
||||
android: {
|
||||
elevation: 10,
|
||||
height: 56,
|
||||
},
|
||||
ios: {
|
||||
zIndex: 10,
|
||||
height: 88,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const withChannel = withObservables(['channelId'], ({channelId, database}: {channelId: string } & WithDatabaseArgs) => ({
|
||||
channel: database.get(MM_TABLES.SERVER.CHANNEL).findAndObserve(channelId),
|
||||
}));
|
||||
|
||||
export default withDatabase(withChannel(ChannelNavBar));
|
||||
69
app/screens/channel/channel_post_list/channel_post_list.tsx
Normal file
69
app/screens/channel/channel_post_list/channel_post_list.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useMemo, useRef} from 'react';
|
||||
import {StyleProp, ViewStyle} from 'react-native';
|
||||
|
||||
import {fetchPostsBefore} from '@actions/remote/post';
|
||||
import PostList from '@components/post_list';
|
||||
import {Screens} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {debounce} from '@helpers/api/general';
|
||||
import {sortPostsByNewest} from '@utils/post';
|
||||
|
||||
import Intro from './intro';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
currentTimezone: string | null;
|
||||
currentUsername: string;
|
||||
isTimezoneEnabled: boolean;
|
||||
lastViewedAt: number;
|
||||
posts: PostModel[];
|
||||
shouldShowJoinLeaveMessages: boolean;
|
||||
}
|
||||
|
||||
const ChannelPostList = ({channelId, contentContainerStyle, currentTimezone, currentUsername, isTimezoneEnabled, lastViewedAt, posts, shouldShowJoinLeaveMessages}: Props) => {
|
||||
const serverUrl = useServerUrl();
|
||||
const canLoadPosts = useRef(true);
|
||||
const fetchingPosts = useRef(false);
|
||||
|
||||
const onEndReached = useCallback(debounce(async () => {
|
||||
if (!fetchingPosts.current && canLoadPosts.current && posts.length) {
|
||||
fetchingPosts.current = true;
|
||||
const lastPost = sortPostsByNewest(posts)[0];
|
||||
const result = await fetchPostsBefore(serverUrl, channelId, lastPost.id);
|
||||
canLoadPosts.current = ((result as ProcessedPosts).posts?.length ?? 1) > 0;
|
||||
fetchingPosts.current = false;
|
||||
}
|
||||
}, 500), [channelId, posts]);
|
||||
|
||||
const intro = useMemo(() => (
|
||||
<Intro channelId={channelId}/>
|
||||
), [channelId]);
|
||||
|
||||
return (
|
||||
<PostList
|
||||
channelId={channelId}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
currentTimezone={currentTimezone}
|
||||
currentUsername={currentUsername}
|
||||
isTimezoneEnabled={isTimezoneEnabled}
|
||||
footer={intro}
|
||||
lastViewedAt={lastViewedAt}
|
||||
location={Screens.CHANNEL}
|
||||
nativeID={`${Screens.CHANNEL}-${channelId}`}
|
||||
onEndReached={onEndReached}
|
||||
posts={posts}
|
||||
shouldShowJoinLeaveMessages={shouldShowJoinLeaveMessages}
|
||||
showMoreMessages={true}
|
||||
testID='channel.post_list'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelPostList;
|
||||
|
||||
71
app/screens/channel/channel_post_list/index.ts
Normal file
71
app/screens/channel/channel_post_list/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Q} from '@nozbe/watermelondb';
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {AppStateStatus} from 'react-native';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {Preferences} from '@constants';
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {getPreferenceAsBool} from '@helpers/api/preference';
|
||||
import {getTimezone} from '@utils/user';
|
||||
|
||||
import ChannelPostList from './channel_post_list';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type MyChannelModel from '@typings/database/models/servers/my_channel';
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel';
|
||||
import type PreferenceModel from '@typings/database/models/servers/preference';
|
||||
import type SystemModel from '@typings/database/models/servers/system';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
const {SERVER: {MY_CHANNEL, POST, POSTS_IN_CHANNEL, PREFERENCE, SYSTEM, USER}} = MM_TABLES;
|
||||
|
||||
const enhanced = withObservables(['channelId', 'forceQueryAfterAppState'], ({database, channelId}: {channelId: string; forceQueryAfterAppState: AppStateStatus} & WithDatabaseArgs) => {
|
||||
const currentUser = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
|
||||
switchMap((currentUserId) => database.get<UserModel>(USER).findAndObserve(currentUserId.value)),
|
||||
);
|
||||
|
||||
return {
|
||||
currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user.timezone))))),
|
||||
currentUsername: currentUser.pipe((switchMap((user) => of$(user.username)))),
|
||||
isTimezoneEnabled: database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
|
||||
switchMap((config) => of$(config.value.ExperimentalTimezone === 'true')),
|
||||
),
|
||||
lastViewedAt: database.get<MyChannelModel>(MY_CHANNEL).findAndObserve(channelId).pipe(
|
||||
switchMap((myChannel) => of$(myChannel.viewedAt)),
|
||||
),
|
||||
posts: database.get<PostsInChannelModel>(POSTS_IN_CHANNEL).query(
|
||||
Q.where('channel_id', channelId),
|
||||
Q.sortBy('latest', Q.desc),
|
||||
).observeWithColumns(['earliest', 'latest']).pipe(
|
||||
switchMap((postsInChannel) => {
|
||||
if (!postsInChannel.length) {
|
||||
return of$([]);
|
||||
}
|
||||
|
||||
const {earliest, latest} = postsInChannel[0];
|
||||
return database.get<PostModel>(POST).query(
|
||||
Q.and(
|
||||
Q.where('delete_at', 0),
|
||||
Q.where('channel_id', channelId),
|
||||
Q.where('create_at', Q.between(earliest, latest)),
|
||||
),
|
||||
Q.sortBy('create_at', Q.desc),
|
||||
).observe();
|
||||
}),
|
||||
),
|
||||
shouldShowJoinLeaveMessages: database.get<PreferenceModel>(PREFERENCE).query(
|
||||
Q.where('category', Preferences.CATEGORY_ADVANCED_SETTINGS),
|
||||
Q.where('name', Preferences.ADVANCED_FILTER_JOIN_LEAVE),
|
||||
).observe().pipe(
|
||||
switchMap((preferences) => of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE, true))),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(ChannelPostList));
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useMemo} from 'react';
|
||||
import {Text, View} from 'react-native';
|
||||
|
||||
import {fetchProfilesInChannel} from '@actions/remote/user';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {BotTag} from '@components/tag';
|
||||
import {General} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import IntroOptions from '../options';
|
||||
|
||||
import Group from './group';
|
||||
import Member from './member';
|
||||
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership';
|
||||
|
||||
type Props = {
|
||||
channel: ChannelModel;
|
||||
currentUserId: string;
|
||||
isBot: boolean;
|
||||
members?: ChannelMembershipModel[];
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
botContainer: {
|
||||
alignSelf: 'flex-end',
|
||||
bottom: 7.5,
|
||||
height: 20,
|
||||
marginBottom: 0,
|
||||
marginLeft: 4,
|
||||
paddingVertical: 0,
|
||||
},
|
||||
botText: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
message: {
|
||||
color: theme.centerChannelColor,
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
...typography('Body', 200, 'Regular'),
|
||||
},
|
||||
profilesContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
color: theme.centerChannelColor,
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
...typography('Heading', 700, 'SemiBold'),
|
||||
},
|
||||
titleGroup: {
|
||||
...typography('Heading', 600, 'SemiBold'),
|
||||
},
|
||||
}));
|
||||
|
||||
const DirectChannel = ({channel, currentUserId, isBot, members, theme}: Props) => {
|
||||
const serverUrl = useServerUrl();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
useEffect(() => {
|
||||
const channelMembers = members?.filter((m) => m.userId !== currentUserId);
|
||||
if (!channelMembers?.length) {
|
||||
fetchProfilesInChannel(serverUrl, channel.id, currentUserId, false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const message = useMemo(() => {
|
||||
if (channel.type === General.DM_CHANNEL) {
|
||||
return (
|
||||
<FormattedText
|
||||
defaultMessage={'This is the start of your conversation with {teammate}. Messages and files shared here are not shown to anyone else.'}
|
||||
id='intro.direct_message'
|
||||
style={styles.message}
|
||||
values={{teammate: channel.displayName}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormattedText
|
||||
defaultMessage={'This is the start of your conversation with this group. Messages and files shared here are not shown to anyone else outside of the group.'}
|
||||
id='intro.group_message'
|
||||
style={styles.message}
|
||||
/>
|
||||
);
|
||||
}, [channel.displayName, theme]);
|
||||
|
||||
const profiles = useMemo(() => {
|
||||
const channelMembers = members?.filter((m) => m.userId !== currentUserId);
|
||||
if (!channelMembers?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (channel.type === General.DM_CHANNEL) {
|
||||
return (
|
||||
<Member
|
||||
containerStyle={{height: 96}}
|
||||
member={channelMembers[0]}
|
||||
size={96}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group
|
||||
theme={theme}
|
||||
userIds={channelMembers.map((cm) => cm.userId)}
|
||||
/>
|
||||
);
|
||||
}, [members, theme]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.profilesContainer}>
|
||||
{profiles}
|
||||
</View>
|
||||
<View style={{flexDirection: 'row'}}>
|
||||
<Text style={[styles.title, channel.type === General.GM_CHANNEL ? styles.titleGroup : undefined]}>
|
||||
{channel.displayName}
|
||||
</Text>
|
||||
{isBot &&
|
||||
<BotTag
|
||||
style={styles.botContainer}
|
||||
textStyle={styles.botText}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
{message}
|
||||
<IntroOptions
|
||||
channelId={channel.id}
|
||||
header={true}
|
||||
favorite={true}
|
||||
people={false}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DirectChannel;
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {chunk} from 'lodash';
|
||||
import React from 'react';
|
||||
import {View} from 'react-native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
|
||||
import {useServerUrl} from '@context/server';
|
||||
import NetworkManager from '@init/network_manager';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import type {Client} from '@client/rest';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
type Props = {
|
||||
theme: Theme;
|
||||
users: UserModel[];
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
marginBottom: 12,
|
||||
},
|
||||
profile: {
|
||||
borderColor: theme.centerChannelBg,
|
||||
borderRadius: 36,
|
||||
borderWidth: 2,
|
||||
height: 72,
|
||||
width: 72,
|
||||
},
|
||||
}));
|
||||
|
||||
const Group = ({theme, users}: Props) => {
|
||||
const serverUrl = useServerUrl();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
let client: Client | undefined;
|
||||
|
||||
try {
|
||||
client = NetworkManager.getClient(serverUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rows = chunk(users, 5);
|
||||
const groups = rows.map((c, k) => {
|
||||
const group = c.map((u, i) => {
|
||||
const pictureUrl = client!.getProfilePictureUrl(u.id, u.lastPictureUpdate);
|
||||
return (
|
||||
<FastImage
|
||||
key={pictureUrl + i.toString()}
|
||||
style={[styles.profile, {transform: [{translateX: -(i * 24)}]}]}
|
||||
source={{uri: `${serverUrl}${pictureUrl}`}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<View
|
||||
key={'group_avatar' + k.toString()}
|
||||
style={[styles.container, {left: (c.length - 1) * 12}]}
|
||||
>
|
||||
{group}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Group;
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Q} from '@nozbe/watermelondb';
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
|
||||
import {MM_TABLES} from '@constants/database';
|
||||
|
||||
import Group from './group';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
const {SERVER: {USER}} = MM_TABLES;
|
||||
|
||||
const enhanced = withObservables([], ({userIds, database}: {userIds: string[]} & WithDatabaseArgs) => ({
|
||||
users: database.get<UserModel>(USER).query(Q.where('id', Q.oneOf(userIds))).observeWithColumns(['last_picture_update']),
|
||||
}));
|
||||
|
||||
export default withDatabase(enhanced(Group));
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {catchError, switchMap} from 'rxjs/operators';
|
||||
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@app/constants/database';
|
||||
import {General} from '@constants';
|
||||
import {getUserIdFromChannelName} from '@utils/user';
|
||||
|
||||
import DirectChannel from './direct_channel';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type SystemModel from '@typings/database/models/servers/system';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
const enhanced = withObservables([], ({channel, database}: {channel: ChannelModel} & WithDatabaseArgs) => {
|
||||
const currentUserId = database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(switchMap(({value}) => of$(value)));
|
||||
const members = channel.members.observe();
|
||||
let isBot = of$(false);
|
||||
|
||||
if (channel.type === General.DM_CHANNEL) {
|
||||
isBot = currentUserId.pipe(
|
||||
switchMap((userId: string) => {
|
||||
const otherUserId = getUserIdFromChannelName(userId, channel.name);
|
||||
return database.get<UserModel>(MM_TABLES.SERVER.USER).findAndObserve(otherUserId).pipe(
|
||||
// eslint-disable-next-line max-nested-callbacks
|
||||
switchMap((user) => of$(user.isBot)), // eslint-disable-next-line max-nested-callbacks
|
||||
catchError(() => of$(false)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
currentUserId,
|
||||
isBot,
|
||||
members,
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(DirectChannel));
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
|
||||
import Member from './member';
|
||||
|
||||
import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership';
|
||||
|
||||
const enhanced = withObservables([], ({member}: {member: ChannelMembershipModel}) => ({
|
||||
user: member.memberUser.observe(),
|
||||
}));
|
||||
|
||||
export default withDatabase(enhanced(Member));
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {StyleProp, StyleSheet, ViewStyle} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import ProfilePicture from '@components/profile_picture';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {Screens} from '@constants';
|
||||
import {showModal} from '@screens/navigation';
|
||||
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
type Props = {
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
size?: number;
|
||||
showStatus?: boolean;
|
||||
theme: Theme;
|
||||
user: UserModel;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
profile: {
|
||||
height: 67,
|
||||
marginBottom: 12,
|
||||
marginRight: 12,
|
||||
},
|
||||
});
|
||||
|
||||
const Member = ({containerStyle, size = 72, showStatus = true, theme, user}: Props) => {
|
||||
const intl = useIntl();
|
||||
const onPress = useCallback(() => {
|
||||
const screen = Screens.USER_PROFILE;
|
||||
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
|
||||
const passProps = {
|
||||
userId: user.id,
|
||||
};
|
||||
|
||||
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
|
||||
|
||||
const options = {
|
||||
topBar: {
|
||||
leftButtons: [{
|
||||
id: 'close-user-profile',
|
||||
icon: closeButton,
|
||||
testID: 'close.settings.button',
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
showModal(screen, title, passProps, options);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={onPress}
|
||||
style={[styles.profile, containerStyle]}
|
||||
type='opacity'
|
||||
>
|
||||
<ProfilePicture
|
||||
author={user}
|
||||
size={size}
|
||||
iconSize={48}
|
||||
showStatus={showStatus}
|
||||
statusSize={24}
|
||||
testID='channel_intro.profile_picture'
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
};
|
||||
|
||||
export default Member;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
43
app/screens/channel/channel_post_list/intro/index.ts
Normal file
43
app/screens/channel/channel_post_list/intro/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Q} from '@nozbe/watermelondb';
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {combineLatest} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@app/constants/database';
|
||||
|
||||
import Intro from './intro';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type MyChannelModel from '@typings/database/models/servers/my_channel';
|
||||
import type RoleModel from '@typings/database/models/servers/role';
|
||||
import type SystemModel from '@typings/database/models/servers/system';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
const {SERVER: {CHANNEL, MY_CHANNEL, ROLE, SYSTEM, USER}} = MM_TABLES;
|
||||
|
||||
const enhanced = withObservables(['channelId'], ({channelId, database}: {channelId: string} & WithDatabaseArgs) => {
|
||||
const channel = database.get<ChannelModel>(CHANNEL).findAndObserve(channelId);
|
||||
const myChannel = database.get<MyChannelModel>(MY_CHANNEL).findAndObserve(channelId);
|
||||
const me = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
|
||||
switchMap(({value}) => database.get<UserModel>(USER).findAndObserve(value)),
|
||||
);
|
||||
|
||||
const roles = combineLatest([me, myChannel]).pipe(
|
||||
switchMap(([{roles: userRoles}, {roles: memberRoles}]) => {
|
||||
const combinedRoles = userRoles.split(' ').concat(memberRoles.split(' '));
|
||||
return database.get<RoleModel>(ROLE).query(Q.where('name', Q.oneOf(combinedRoles))).observe();
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
channel,
|
||||
roles,
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(Intro));
|
||||
96
app/screens/channel/channel_post_list/intro/intro.tsx
Normal file
96
app/screens/channel/channel_post_list/intro/intro.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useMemo, useState} from 'react';
|
||||
import {ActivityIndicator, DeviceEventEmitter, Platform, StyleSheet, View} from 'react-native';
|
||||
|
||||
import {Events, General} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
|
||||
import DirectChannel from './direct_channel';
|
||||
import PublicOrPrivateChannel from './public_or_private_channel';
|
||||
import TownSquare from './townsquare';
|
||||
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type RoleModel from '@typings/database/models/servers/role';
|
||||
|
||||
type Props = {
|
||||
channel: ChannelModel;
|
||||
loading?: boolean;
|
||||
roles: RoleModel[];
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginVertical: 12,
|
||||
paddingTop: 50,
|
||||
overflow: 'hidden',
|
||||
...Platform.select({
|
||||
android: {
|
||||
scaleY: -1,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const Intro = ({channel, loading = false, roles}: Props) => {
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const theme = useTheme();
|
||||
const element = useMemo(() => {
|
||||
if (channel.type === General.OPEN_CHANNEL && channel.name === General.DEFAULT_CHANNEL) {
|
||||
return (
|
||||
<TownSquare
|
||||
channelId={channel.id}
|
||||
displayName={channel.displayName}
|
||||
roles={roles}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (channel.type) {
|
||||
case General.OPEN_CHANNEL:
|
||||
case General.PRIVATE_CHANNEL:
|
||||
return (
|
||||
<PublicOrPrivateChannel
|
||||
channel={channel}
|
||||
roles={roles}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<DirectChannel
|
||||
channel={channel}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [channel, roles, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = DeviceEventEmitter.addListener(Events.LOADING_CHANNEL_POSTS, (value: boolean) => {
|
||||
setFetching(value);
|
||||
});
|
||||
|
||||
return () => listener.remove();
|
||||
}, []);
|
||||
|
||||
if (loading || fetching) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
size='small'
|
||||
color={theme.centerChannelColor}
|
||||
style={styles.container}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{element}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Intro;
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import {saveFavoriteChannel} from '@actions/remote/preference';
|
||||
import {useServerUrl} from '@context/server';
|
||||
|
||||
import OptionItem from '../item';
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
isFavorite: boolean;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
const IntroFavorite = ({channelId, isFavorite, theme}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const serverUrl = useServerUrl();
|
||||
|
||||
const toggleFavorite = useCallback(() => {
|
||||
saveFavoriteChannel(serverUrl, channelId, !isFavorite);
|
||||
}, [channelId, isFavorite]);
|
||||
|
||||
return (
|
||||
<OptionItem
|
||||
applyMargin={true}
|
||||
color={isFavorite ? theme.buttonBg : undefined}
|
||||
iconName={isFavorite ? 'star' : 'star-outline'}
|
||||
label={formatMessage({id: 'intro.favorite', defaultMessage: 'Favorite'})}
|
||||
onPress={toggleFavorite}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntroFavorite;
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Q} from '@nozbe/watermelondb';
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {MM_TABLES} from '@app/constants/database';
|
||||
import {Preferences} from '@constants';
|
||||
|
||||
import FavoriteItem from './favorite';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type PreferenceModel from '@typings/database/models/servers/preference';
|
||||
|
||||
const enhanced = withObservables([], ({channelId, database}: {channelId: string} & WithDatabaseArgs) => ({
|
||||
isFavorite: database.get<PreferenceModel>(MM_TABLES.SERVER.PREFERENCE).query(
|
||||
Q.where('category', Preferences.CATEGORY_FAVORITE_CHANNEL),
|
||||
Q.where('name', channelId),
|
||||
).observeWithColumns(['value']).pipe(
|
||||
switchMap((prefs) => {
|
||||
return prefs.length ? of$(prefs[0].value === 'true') : of$(false);
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
export default withDatabase(enhanced(FavoriteItem));
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
|
||||
import {Screens} from '@constants';
|
||||
import {showModal} from '@screens/navigation';
|
||||
|
||||
import IntroFavorite from './favorite';
|
||||
import OptionItem from './item';
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
header?: boolean;
|
||||
favorite?: boolean;
|
||||
people?: boolean;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
marginBottom: 8,
|
||||
marginTop: 28,
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
const IntroOptions = ({channelId, header, favorite, people, theme}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
const onAddPeople = useCallback(() => {
|
||||
const title = formatMessage({id: 'intro.add_people', defaultMessage: 'Add People'});
|
||||
showModal(Screens.CHANNEL_ADD_PEOPLE, title, {channelId});
|
||||
}, []);
|
||||
|
||||
const onSetHeader = useCallback(() => {
|
||||
const title = formatMessage({id: 'screens.channel_edit', defaultMessage: 'Edit Channel'});
|
||||
showModal(Screens.CHANNEL_EDIT, title, {channelId});
|
||||
}, []);
|
||||
|
||||
const onDetails = useCallback(() => {
|
||||
const title = formatMessage({id: 'screens.channel_details', defaultMessage: 'Channel Details'});
|
||||
showModal(Screens.CHANNEL_DETAILS, title, {channelId});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{people &&
|
||||
<OptionItem
|
||||
applyMargin={true}
|
||||
iconName='account-plus-outline'
|
||||
label={formatMessage({id: 'intro.add_people', defaultMessage: 'Add People'})}
|
||||
onPress={onAddPeople}
|
||||
theme={theme}
|
||||
/>
|
||||
}
|
||||
{header &&
|
||||
<OptionItem
|
||||
applyMargin={true}
|
||||
iconName='pencil-outline'
|
||||
label={formatMessage({id: 'intro.set_header', defaultMessage: 'Set Header'})}
|
||||
onPress={onSetHeader}
|
||||
theme={theme}
|
||||
/>
|
||||
}
|
||||
{favorite &&
|
||||
<IntroFavorite
|
||||
channelId={channelId}
|
||||
theme={theme}
|
||||
/>
|
||||
}
|
||||
<OptionItem
|
||||
iconName='information-outline'
|
||||
label={formatMessage({id: 'intro.channel_details', defaultMessage: 'Details'})}
|
||||
onPress={onDetails}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntroOptions;
|
||||
79
app/screens/channel/channel_post_list/intro/options/item.tsx
Normal file
79
app/screens/channel/channel_post_list/intro/options/item.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {Pressable, PressableStateCallbackType, Text} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
type Props = {
|
||||
applyMargin?: boolean;
|
||||
color?: string;
|
||||
iconName: string;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
|
||||
borderRadius: 4,
|
||||
height: 70,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
width: 112,
|
||||
},
|
||||
containerPressed: {
|
||||
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
|
||||
},
|
||||
label: {
|
||||
marginTop: 6,
|
||||
...typography('Body', 50, 'SemiBold'),
|
||||
},
|
||||
margin: {
|
||||
marginRight: 8,
|
||||
},
|
||||
}));
|
||||
|
||||
const IntroItem = ({applyMargin, color, iconName, label, onPress, theme}: Props) => {
|
||||
const styles = getStyleSheet(theme);
|
||||
const pressedStyle = useCallback(({pressed}: PressableStateCallbackType) => {
|
||||
const style = [styles.container];
|
||||
if (pressed) {
|
||||
style.push(styles.containerPressed);
|
||||
}
|
||||
|
||||
if (applyMargin) {
|
||||
style.push(styles.margin);
|
||||
}
|
||||
|
||||
return style;
|
||||
}, [applyMargin, theme]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={pressedStyle}
|
||||
>
|
||||
{({pressed}) => (
|
||||
<>
|
||||
<CompassIcon
|
||||
name={iconName}
|
||||
color={pressed ? theme.linkColor : color || changeOpacity(theme.centerChannelColor, 0.56)}
|
||||
size={24}
|
||||
/>
|
||||
<Text style={[styles.label, {color: pressed ? theme.linkColor : color || changeOpacity(theme.centerChannelColor, 0.56)}]}>
|
||||
{label}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntroItem;
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Q} from '@nozbe/watermelondb';
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {combineLatest, of as of$} from 'rxjs';
|
||||
import {map, switchMap} from 'rxjs/operators';
|
||||
|
||||
import {Preferences} from '@constants';
|
||||
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
|
||||
import {displayUsername} from '@utils/user';
|
||||
|
||||
import PublicOrPrivateChannel from './public_or_private_channel';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type PreferenceModel from '@typings/database/models/servers/preference';
|
||||
import type SystemModel from '@typings/database/models/servers/system';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
const {SERVER: {PREFERENCE, SYSTEM, USER}} = MM_TABLES;
|
||||
|
||||
const enhanced = withObservables([], ({channel, database}: {channel: ChannelModel} & WithDatabaseArgs) => {
|
||||
let creator;
|
||||
if (channel.creatorId) {
|
||||
const config = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(switchMap(({value}) => of$(value as ClientConfig)));
|
||||
const license = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe(switchMap(({value}) => of$(value as ClientLicense)));
|
||||
const preferences = database.get<PreferenceModel>(PREFERENCE).query(Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS)).observe();
|
||||
const me = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
|
||||
switchMap(({value}) => database.get<UserModel>(USER).findAndObserve(value)),
|
||||
);
|
||||
|
||||
const profile = channel.creator.observe();
|
||||
const teammateNameDisplay = combineLatest([preferences, config, license]).pipe(
|
||||
map(([prefs, cfg, lcs]) => getTeammateNameDisplaySetting(prefs, cfg, lcs)),
|
||||
);
|
||||
creator = combineLatest([profile, teammateNameDisplay, me]).pipe(
|
||||
map(([user, displaySetting, currentUser]) => (user ? displayUsername(user as UserModel, currentUser.locale, displaySetting, true) : '')),
|
||||
);
|
||||
} else {
|
||||
creator = of$(undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
creator,
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(PublicOrPrivateChannel));
|
||||
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Text, View} from 'react-native';
|
||||
|
||||
import {fetchChannelCreator} from '@actions/remote/channel';
|
||||
import CompassIcon from '@app/components/compass_icon';
|
||||
import {General, Permissions} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {t} from '@i18n';
|
||||
import {hasPermission} from '@utils/role';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import PrivateChannel from '../illustration/private';
|
||||
import PublicChannel from '../illustration/public';
|
||||
import IntroOptions from '../options';
|
||||
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type RoleModel from '@typings/database/models/servers/role';
|
||||
|
||||
type Props = {
|
||||
channel: ChannelModel;
|
||||
creator?: string;
|
||||
roles: RoleModel[];
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
created: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||
...typography('Body', 50, 'Regular'),
|
||||
},
|
||||
icon: {
|
||||
marginRight: 5,
|
||||
},
|
||||
message: {
|
||||
color: theme.centerChannelColor,
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
...typography('Body', 200, 'Regular'),
|
||||
},
|
||||
title: {
|
||||
color: theme.centerChannelColor,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
...typography('Heading', 700, 'SemiBold'),
|
||||
},
|
||||
}));
|
||||
|
||||
const PublicOrPrivateChannel = ({channel, creator, roles, theme}: Props) => {
|
||||
const intl = useIntl();
|
||||
const serverUrl = useServerUrl();
|
||||
const styles = getStyleSheet(theme);
|
||||
const illustration = useMemo(() => {
|
||||
if (channel.type === General.OPEN_CHANNEL) {
|
||||
return <PublicChannel theme={theme}/>;
|
||||
}
|
||||
|
||||
return <PrivateChannel theme={theme}/>;
|
||||
}, [channel.type, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!creator && channel.creatorId) {
|
||||
fetchChannelCreator(serverUrl, channel.id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const canManagePeople = useMemo(() => {
|
||||
const permission = channel.type === General.OPEN_CHANNEL ? Permissions.MANAGE_PUBLIC_CHANNEL_MEMBERS : Permissions.MANAGE_PRIVATE_CHANNEL_MEMBERS;
|
||||
return hasPermission(roles, permission, false);
|
||||
}, [channel.type, roles]);
|
||||
|
||||
const canSetHeader = useMemo(() => {
|
||||
const permission = channel.type === General.OPEN_CHANNEL ? Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES : Permissions.MANAGE_PRIVATE_CHANNEL_PROPERTIES;
|
||||
return hasPermission(roles, permission, false);
|
||||
}, [channel.type, roles]);
|
||||
|
||||
const createdBy = useMemo(() => {
|
||||
const id = channel.type === General.OPEN_CHANNEL ? t('intro.public_channel') : t('intro.private_channel');
|
||||
const defaultMessage = channel.type === General.OPEN_CHANNEL ? 'Public Channel' : 'Private Channel';
|
||||
const channelType = `${intl.formatMessage({id, defaultMessage})} `;
|
||||
|
||||
const date = intl.formatDate(channel.createAt, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
const by = intl.formatMessage({id: 'intro.created_by', defaultMessage: 'created by {creator} on {date}.'}, {
|
||||
creator,
|
||||
date,
|
||||
});
|
||||
|
||||
return `${channelType} ${by}`;
|
||||
}, [channel.type, creator, theme]);
|
||||
|
||||
const message = useMemo(() => {
|
||||
const id = channel.type === General.OPEN_CHANNEL ? t('intro.welcome.public') : t('intro.welcome.private');
|
||||
const msg = channel.type === General.OPEN_CHANNEL ? 'Add some more team members to the channel or start a conversation below.' : 'Only invited members can see messages posted in this private channel.';
|
||||
const mainMessage = intl.formatMessage({
|
||||
id: 'intro.welcome',
|
||||
defaultMessage: 'Welcome to {displayName} channel.',
|
||||
}, {displayName: channel.displayName});
|
||||
|
||||
const suffix = intl.formatMessage({id, defaultMessage: msg});
|
||||
|
||||
return `${mainMessage} ${suffix}`;
|
||||
}, [channel.displayName, channel.type, theme]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{illustration}
|
||||
<Text style={styles.title}>
|
||||
{channel.displayName}
|
||||
</Text>
|
||||
<View style={{flexDirection: 'row'}}>
|
||||
<CompassIcon
|
||||
name={channel.type === General.OPEN_CHANNEL ? 'globe' : 'lock'}
|
||||
size={14.4}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||
style={styles.icon}
|
||||
/>
|
||||
<Text style={styles.created}>
|
||||
{createdBy}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.message}>
|
||||
{message}
|
||||
</Text>
|
||||
<IntroOptions
|
||||
channelId={channel.id}
|
||||
header={canSetHeader}
|
||||
people={canManagePeople}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublicOrPrivateChannel;
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Text, View} from 'react-native';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {Permissions} from '@constants';
|
||||
import {hasPermission} from '@utils/role';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import PublicChannel from '../illustration/public';
|
||||
import IntroOptions from '../options';
|
||||
|
||||
import type RoleModel from '@typings/database/models/servers/role';
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
displayName: string;
|
||||
roles: RoleModel[];
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
message: {
|
||||
color: theme.centerChannelColor,
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
...typography('Body', 200, 'Regular'),
|
||||
width: '100%',
|
||||
},
|
||||
title: {
|
||||
color: theme.centerChannelColor,
|
||||
marginTop: 16,
|
||||
...typography('Heading', 700, 'SemiBold'),
|
||||
},
|
||||
}));
|
||||
|
||||
const TownSquare = ({channelId, displayName, roles, theme}: Props) => {
|
||||
const styles = getStyleSheet(theme);
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<PublicChannel theme={theme}/>
|
||||
<Text style={styles.title}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<FormattedText
|
||||
defaultMessage='Welcome to {name}. Everyone automatically becomes a member of this channel when they join the team.'
|
||||
id='intro.townsquare'
|
||||
style={styles.message}
|
||||
values={{name: displayName}}
|
||||
/>
|
||||
<IntroOptions
|
||||
channelId={channelId}
|
||||
header={hasPermission(roles, Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES, false)}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default TownSquare;
|
||||
@@ -1,106 +1,99 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Q} from '@nozbe/watermelondb';
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {SafeAreaView} from 'react-native-safe-area-context';
|
||||
import {map} from 'rxjs/operators';
|
||||
import {combineLatest, of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {fetchPostsForChannel} from '@actions/remote/post';
|
||||
import PostList from '@components/post_list';
|
||||
import {Database} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useAppState} from '@hooks/device';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {getUserIdFromChannelName} from '@app/utils/user';
|
||||
import {Database, General} from '@constants';
|
||||
|
||||
import ChannelNavBar from './channel_nav_bar';
|
||||
import FailedChannels from './failed_channels';
|
||||
import FailedTeams from './failed_teams';
|
||||
import Intro from './intro';
|
||||
import Channel from './channel';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type ChannelModel from '@typings/database/models/servers/channel';
|
||||
import type ChannelInfoModel from '@typings/database/models/servers/channel_info';
|
||||
import type SystemModel from '@typings/database/models/servers/system';
|
||||
import type {LaunchProps} from '@typings/launch';
|
||||
|
||||
type ChannelProps = LaunchProps & {
|
||||
currentChannelId: string;
|
||||
currentTeamId: string;
|
||||
};
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
const {MM_TABLES, SYSTEM_IDENTIFIERS} = Database;
|
||||
const {SERVER: {SYSTEM}} = MM_TABLES;
|
||||
const {SERVER: {CHANNEL, CHANNEL_INFO, SYSTEM, USER}} = MM_TABLES;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
sectionContainer: {
|
||||
marginTop: 10,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontFamily: 'OpenSans-Semibold',
|
||||
color: theme.centerChannelColor,
|
||||
},
|
||||
}));
|
||||
|
||||
const Channel = ({currentChannelId, currentTeamId}: ChannelProps) => {
|
||||
const appState = useAppState();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const serverUrl = useServerUrl();
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchPostsForChannel(serverUrl, currentChannelId).then(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [currentChannelId]);
|
||||
|
||||
if (!currentTeamId) {
|
||||
return <FailedTeams/>;
|
||||
}
|
||||
|
||||
if (!currentChannelId) {
|
||||
return <FailedChannels teamId={currentTeamId}/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={styles.flex}
|
||||
mode='margin'
|
||||
edges={['left', 'right']}
|
||||
>
|
||||
<ChannelNavBar
|
||||
channelId={currentChannelId}
|
||||
onPress={() => null}
|
||||
/>
|
||||
<PostList
|
||||
channelId={currentChannelId}
|
||||
footer={(
|
||||
<Intro
|
||||
channelId={currentChannelId}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
forceQueryAfterAppState={appState}
|
||||
testID='channel.post_list'
|
||||
/>
|
||||
</SafeAreaView>
|
||||
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
const currentUserId = database.collections.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
|
||||
switchMap(({value}: {value: string}) => of$(value)),
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
|
||||
currentChannelId: database.collections.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID).pipe(
|
||||
map(({value}: {value: string}) => value),
|
||||
),
|
||||
currentTeamId: database.collections.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe(
|
||||
map(({value}: {value: string}) => value),
|
||||
),
|
||||
}));
|
||||
const channelId = database.collections.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID).pipe(
|
||||
switchMap(({value}: {value: string}) => of$(value)),
|
||||
);
|
||||
const teamId = database.collections.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe(
|
||||
switchMap(({value}: {value: string}) => of$(value)),
|
||||
);
|
||||
|
||||
const channel = channelId.pipe(
|
||||
switchMap((id) => database.get<ChannelModel>(CHANNEL).query(Q.where('id', id)).observe().pipe(
|
||||
// eslint-disable-next-line max-nested-callbacks
|
||||
switchMap((channels) => {
|
||||
if (channels.length) {
|
||||
return channels[0].observe();
|
||||
}
|
||||
|
||||
return of$(null);
|
||||
}),
|
||||
)),
|
||||
);
|
||||
|
||||
const channelInfo = channelId.pipe(
|
||||
switchMap((id) => database.get<ChannelInfoModel>(CHANNEL_INFO).query(Q.where('id', id)).observe().pipe(
|
||||
// eslint-disable-next-line max-nested-callbacks
|
||||
switchMap((infos) => {
|
||||
if (infos.length) {
|
||||
return infos[0].observe();
|
||||
}
|
||||
|
||||
return of$(null);
|
||||
}),
|
||||
)),
|
||||
);
|
||||
|
||||
const isOwnDirectMessage = combineLatest([currentUserId, channel]).pipe(
|
||||
switchMap(([userId, ch]) => {
|
||||
if (ch?.type === General.DM_CHANNEL) {
|
||||
const teammateId = getUserIdFromChannelName(userId, ch.name);
|
||||
return of$(userId === teammateId);
|
||||
}
|
||||
|
||||
return of$(false);
|
||||
}),
|
||||
);
|
||||
|
||||
const displayName = channel.pipe(switchMap((c) => of$(c?.displayName)));
|
||||
const name = combineLatest([currentUserId, channel]).pipe(switchMap(([userId, c]) => {
|
||||
if (c?.type === General.DM_CHANNEL) {
|
||||
const teammateId = getUserIdFromChannelName(userId, c.name);
|
||||
return database.get<UserModel>(USER).findAndObserve(teammateId).pipe(
|
||||
// eslint-disable-next-line max-nested-callbacks
|
||||
switchMap((u) => of$(`@${u.username}`)),
|
||||
);
|
||||
} else if (c?.type === General.GM_CHANNEL) {
|
||||
return of$(`@${c.name}`);
|
||||
}
|
||||
|
||||
return of$(c?.name);
|
||||
}));
|
||||
const memberCount = channelInfo.pipe(switchMap((ci) => of$(ci?.memberCount || 0)));
|
||||
|
||||
return {
|
||||
channelId,
|
||||
displayName,
|
||||
isOwnDirectMessage,
|
||||
memberCount,
|
||||
name,
|
||||
teamId,
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(Channel));
|
||||
|
||||
@@ -55,23 +55,32 @@ const IntroItem = ({applyMargin, color, iconName, label, onPress, theme}: Props)
|
||||
return style;
|
||||
}, [applyMargin, theme]);
|
||||
|
||||
const renderPressableChildren = ({pressed}: PressableStateCallbackType) => {
|
||||
let pressedColor = color || changeOpacity(theme.centerChannelColor, 0.56);
|
||||
if (pressed) {
|
||||
pressedColor = theme.linkColor;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CompassIcon
|
||||
name={iconName}
|
||||
color={pressedColor}
|
||||
size={24}
|
||||
/>
|
||||
<Text style={[styles.label, {color: pressedColor}]}>
|
||||
{label}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={pressedStyle}
|
||||
>
|
||||
{({pressed}) => (
|
||||
<>
|
||||
<CompassIcon
|
||||
name={iconName}
|
||||
color={pressed ? theme.linkColor : color || changeOpacity(theme.centerChannelColor, 0.56)}
|
||||
size={24}
|
||||
/>
|
||||
<Text style={[styles.label, {color: pressed ? theme.linkColor : color || changeOpacity(theme.centerChannelColor, 0.56)}]}>
|
||||
{label}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{renderPressableChildren}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
120
app/screens/channel/other_mentions_badge/index.tsx
Normal file
120
app/screens/channel/other_mentions_badge/index.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Q} from '@nozbe/watermelondb';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
|
||||
import Badge from '@components/badge';
|
||||
import {MM_TABLES} from '@constants/database';
|
||||
import DatabaseManager from '@database/manager';
|
||||
|
||||
import type ServersModel from '@typings/database/models/app/servers';
|
||||
import type MyChannelModel from '@typings/database/models/servers/my_channel';
|
||||
import type {Subscription} from 'rxjs';
|
||||
|
||||
type UnreadSubscription = {
|
||||
mentions: number;
|
||||
subscription?: Subscription;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
left: 2,
|
||||
position: 'relative',
|
||||
top: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const {SERVERS} = MM_TABLES.APP;
|
||||
const {CHANNEL, MY_CHANNEL} = MM_TABLES.SERVER;
|
||||
const subscriptions: Map<string, UnreadSubscription> = new Map();
|
||||
|
||||
const OtherMentionsBadge = ({channelId}: Props) => {
|
||||
const db = DatabaseManager.appDatabase?.database;
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const updateCount = () => {
|
||||
let mentions = 0;
|
||||
subscriptions.forEach((value) => {
|
||||
mentions += value.mentions;
|
||||
});
|
||||
setCount(mentions);
|
||||
};
|
||||
|
||||
const unreadsSubscription = (serverUrl: string, myChannels: MyChannelModel[]) => {
|
||||
const unreads = subscriptions.get(serverUrl);
|
||||
if (unreads) {
|
||||
let mentions = 0;
|
||||
myChannels.forEach((myChannel) => {
|
||||
if (channelId !== myChannel.id) {
|
||||
mentions += myChannel.mentionsCount;
|
||||
}
|
||||
});
|
||||
|
||||
unreads.mentions = mentions;
|
||||
subscriptions.set(serverUrl, unreads);
|
||||
updateCount();
|
||||
}
|
||||
};
|
||||
|
||||
const serversObserver = async (servers: ServersModel[]) => {
|
||||
servers.forEach((server) => {
|
||||
const serverUrl = server.url;
|
||||
if (server.lastActiveAt) {
|
||||
const sdb = DatabaseManager.serverDatabases[serverUrl];
|
||||
if (sdb?.database) {
|
||||
if (!subscriptions.has(serverUrl)) {
|
||||
const unreads: UnreadSubscription = {
|
||||
mentions: 0,
|
||||
};
|
||||
subscriptions.set(serverUrl, unreads);
|
||||
unreads.subscription = sdb.database.
|
||||
get(MY_CHANNEL).
|
||||
query(Q.on(CHANNEL, Q.where('delete_at', Q.eq(0)))).
|
||||
observeWithColumns(['mentions_count']).
|
||||
subscribe(unreadsSubscription.bind(undefined, serverUrl));
|
||||
}
|
||||
}
|
||||
|
||||
// subscribe and listen for mentions
|
||||
} else if (subscriptions.has(serverUrl)) {
|
||||
// logout from server, remove the subscription
|
||||
subscriptions.delete(serverUrl);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = db?.
|
||||
get(SERVERS).
|
||||
query().
|
||||
observeWithColumns(['last_active_at']).
|
||||
subscribe(serversObserver);
|
||||
|
||||
return () => {
|
||||
subscription?.unsubscribe();
|
||||
subscriptions.forEach((unreads) => {
|
||||
unreads.subscription?.unsubscribe();
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Badge
|
||||
type='Small'
|
||||
visible={count > 0}
|
||||
value={count}
|
||||
style={styles.badge}
|
||||
borderColor='transparent'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default OtherMentionsBadge;
|
||||
@@ -4,9 +4,8 @@
|
||||
import {useManagedConfig} from '@mattermost/react-native-emm';
|
||||
import {useIsFocused, useRoute} from '@react-navigation/native';
|
||||
import React from 'react';
|
||||
import {View} from 'react-native';
|
||||
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
|
||||
import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import ChannelList from '@components/channel_list';
|
||||
import TeamSidebar from '@components/team_sidebar';
|
||||
@@ -16,13 +15,12 @@ import Channel from '@screens/channel';
|
||||
import ServerIcon from '@screens/home/channel_list/server_icon/server_icon';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import type {LaunchProps} from '@typings/launch';
|
||||
|
||||
type ChannelProps = LaunchProps & {
|
||||
type ChannelProps = {
|
||||
teamsCount: number;
|
||||
time?: number;
|
||||
};
|
||||
|
||||
const edges: Edge[] = ['bottom', 'left', 'right'];
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
flex: {
|
||||
flex: 1,
|
||||
@@ -71,12 +69,16 @@ const ChannelListScreen = (props: ChannelProps) => {
|
||||
};
|
||||
}, [isFocused, params]);
|
||||
|
||||
const top = useAnimatedStyle(() => {
|
||||
return {height: insets.top, backgroundColor: theme.sidebarBg};
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Boolean(insets.top) && <View style={{height: insets.top, backgroundColor: theme.sidebarBg}}/>}
|
||||
{<Animated.View style={top}/>}
|
||||
<SafeAreaView
|
||||
style={styles.content}
|
||||
edges={['bottom', 'left', 'right']}
|
||||
edges={edges}
|
||||
>
|
||||
{canAddOtherServers && <ServerIcon/>}
|
||||
<Animated.View
|
||||
@@ -92,7 +94,7 @@ const ChannelListScreen = (props: ChannelProps) => {
|
||||
teamsCount={props.teamsCount}
|
||||
/>
|
||||
{isTablet &&
|
||||
<Channel {...props}/>
|
||||
<Channel/>
|
||||
}
|
||||
</Animated.View>
|
||||
</SafeAreaView>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {useIntl} from 'react-intl';
|
||||
import {DeviceEventEmitter, Platform} from 'react-native';
|
||||
import {enableScreens} from 'react-native-screens';
|
||||
|
||||
import {Events} from '@constants';
|
||||
import {Events, Screens} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {notificationError} from '@utils/notification';
|
||||
|
||||
@@ -66,22 +66,22 @@ export default function HomeScreen(props: HomeProps) {
|
||||
/>)}
|
||||
>
|
||||
<Tab.Screen
|
||||
name='Home'
|
||||
name={Screens.HOME}
|
||||
options={{title: 'Channel', unmountOnBlur: false}}
|
||||
>
|
||||
{() => <ChannelList {...props}/>}
|
||||
</Tab.Screen>
|
||||
<Tab.Screen
|
||||
name='Search'
|
||||
name={Screens.SEARCH}
|
||||
component={Search}
|
||||
options={{unmountOnBlur: false}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name='Mentions'
|
||||
name={Screens.MENTIONS}
|
||||
component={RecentMentions}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name='Account'
|
||||
name={Screens.ACCOUNT}
|
||||
component={Account}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
|
||||
@@ -16,7 +16,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
marginTop: 8,
|
||||
marginVertical: 8,
|
||||
},
|
||||
channel: {
|
||||
...typography('Body', 75, 'SemiBold'),
|
||||
|
||||
@@ -32,14 +32,14 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
paddingBottom: 8,
|
||||
},
|
||||
rightColumn: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
marginRight: 12,
|
||||
},
|
||||
message: {
|
||||
flex: 1,
|
||||
},
|
||||
profilePictureContainer: {
|
||||
marginBottom: 5,
|
||||
marginRight: 10,
|
||||
marginTop: 10,
|
||||
},
|
||||
});
|
||||
|
||||
function Mention({post, currentUser}: Props) {
|
||||
@@ -50,14 +50,18 @@ function Mention({post, currentUser}: Props) {
|
||||
const isWebHook = isFromWebhook(post);
|
||||
const isEdited = postEdited(post);
|
||||
|
||||
const postAvatar = isAutoResponder ? (
|
||||
<SystemAvatar theme={theme}/>
|
||||
) : (
|
||||
<Avatar
|
||||
isAutoReponse={isAutoResponder}
|
||||
isSystemPost={isSystemPost}
|
||||
post={post}
|
||||
/>
|
||||
const postAvatar = (
|
||||
<View style={[styles.profilePictureContainer]}>
|
||||
{isAutoResponder ? (
|
||||
<SystemAvatar theme={theme}/>
|
||||
) : (
|
||||
<Avatar
|
||||
isAutoReponse={isAutoResponder}
|
||||
isSystemPost={isSystemPost}
|
||||
post={post}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
const header = isSystemPost && !isAutoResponder ? (
|
||||
@@ -85,7 +89,7 @@ function Mention({post, currentUser}: Props) {
|
||||
<ChannelInfo post={post}/>
|
||||
<View style={styles.content}>
|
||||
{postAvatar}
|
||||
<View style={styles.rightColumn}>
|
||||
<View>
|
||||
{header}
|
||||
<View style={styles.message}>
|
||||
<Message
|
||||
|
||||
@@ -24,6 +24,7 @@ const SearchScreen = () => {
|
||||
const intl = useIntl();
|
||||
const searchScreenIndex = 1;
|
||||
const stateIndex = nav.getState().index;
|
||||
const {searchTerm} = nav.getState().routes[stateIndex].params;
|
||||
|
||||
const animated = useAnimatedStyle(() => {
|
||||
if (isFocused) {
|
||||
@@ -113,6 +114,7 @@ const SearchScreen = () => {
|
||||
}}
|
||||
blurOnSubmit={true}
|
||||
placeholder={intl.formatMessage({id: 'screen.search.placeholder', defaultMessage: 'Search messages & files'})}
|
||||
defaultValue={searchTerm}
|
||||
/>
|
||||
<SafeAreaView
|
||||
style={{flex: 1}}
|
||||
|
||||
@@ -82,6 +82,31 @@ function TabBar({state, descriptors, navigation, theme}: BottomTabBarProps & {th
|
||||
return () => listner.remove();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const listner = DeviceEventEmitter.addListener(NavigationConstants.NAVIGATE_TO_TAB, ({screen, params = {}}: {screen: string; params: any}) => {
|
||||
const lastTab = state.history[state.history.length - 1];
|
||||
// eslint-disable-next-line max-nested-callbacks
|
||||
const routeIndex = state.routes.findIndex((r) => r.name === screen);
|
||||
const route = state.routes[routeIndex];
|
||||
// eslint-disable-next-line max-nested-callbacks
|
||||
const lastIndex = state.routes.findIndex((r) => r.key === lastTab.key);
|
||||
const direction = lastIndex < routeIndex ? 'right' : 'left';
|
||||
const event = navigation.emit({
|
||||
type: 'tabPress',
|
||||
target: screen,
|
||||
canPreventDefault: true,
|
||||
});
|
||||
|
||||
if (!event.defaultPrevented) {
|
||||
// The `merge: true` option makes sure that the params inside the tab screen are preserved
|
||||
navigation.navigate({params: {direction, ...params}, name: route.name, merge: false});
|
||||
EphemeralStore.setVisibleTap(route.name);
|
||||
}
|
||||
});
|
||||
|
||||
return () => listner.remove();
|
||||
}, [state]);
|
||||
|
||||
const transform = useAnimatedStyle(() => {
|
||||
const translateX = withTiming(state.index * tabWidth, {duration: 150});
|
||||
return {
|
||||
@@ -140,7 +165,7 @@ function TabBar({state, descriptors, navigation, theme}: BottomTabBarProps & {th
|
||||
|
||||
if (!isFocused && !event.defaultPrevented) {
|
||||
// The `merge: true` option makes sure that the params inside the tab screen are preserved
|
||||
navigation.navigate({params: {...route.params, direction}, name: route.name, merge: true});
|
||||
navigation.navigate({params: {direction}, name: route.name, merge: false});
|
||||
EphemeralStore.setVisibleTap(route.name);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,7 +92,7 @@ Appearance.addChangeListener(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function getThemeFromState() {
|
||||
function getThemeFromState(): Theme {
|
||||
if (EphemeralStore.theme) {
|
||||
return EphemeralStore.theme;
|
||||
}
|
||||
@@ -241,9 +241,10 @@ export function resetToTeams(name: string, title: string, passProps = {}, option
|
||||
|
||||
export function goToScreen(name: string, title: string, passProps = {}, options = {}) {
|
||||
const theme = getThemeFromState();
|
||||
const isDark = tinyColor(theme.sidebarBg).isDark();
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
DeviceEventEmitter.emit('tabBarVisible', false);
|
||||
const defaultOptions = {
|
||||
const defaultOptions: Options = {
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
@@ -252,6 +253,10 @@ export function goToScreen(name: string, title: string, passProps = {}, options
|
||||
left: {enabled: false},
|
||||
right: {enabled: false},
|
||||
},
|
||||
statusBar: {
|
||||
backgroundColor: null,
|
||||
style: isDark ? 'light' : 'dark',
|
||||
},
|
||||
topBar: {
|
||||
animate: true,
|
||||
visible: true,
|
||||
|
||||
@@ -192,7 +192,6 @@ const ServerForm = ({
|
||||
autoCapitalize={'none'}
|
||||
enablesReturnKeyAutomatically={true}
|
||||
error={displayNameError}
|
||||
keyboardType='url'
|
||||
label={formatMessage({
|
||||
id: 'mobile.components.select_server_view.displayName',
|
||||
defaultMessage: 'Display Name',
|
||||
|
||||
@@ -32,7 +32,7 @@ const RE_EMOTICON: Record<string, RegExp> = {
|
||||
broken_heart: /(^|\s)(<\/3|</3)(?=$|\s)/g, // </3
|
||||
};
|
||||
|
||||
const MAX_JUMBO_EMOJIS = 4;
|
||||
const MAX_JUMBO_EMOJIS = 8;
|
||||
|
||||
function isEmoticon(text: string) {
|
||||
for (const emoticon of Object.keys(RE_EMOTICON)) {
|
||||
|
||||
@@ -19,12 +19,12 @@ export function areConsecutivePosts(post: PostModel, previousPost: PostModel) {
|
||||
const isFromSameUser = previousPost.userId === post.userId;
|
||||
const isNotSystemMessage = !isSystemMessage(post) && !isSystemMessage(previousPost);
|
||||
const isInTimeframe = (post.createAt - previousPost.createAt) <= Post.POST_COLLAPSE_TIMEOUT;
|
||||
const isSameThread = (previousPost.rootId === post.rootId || previousPost.id === post.rootId);
|
||||
|
||||
// Were the last post and this post made by the same user within some time?
|
||||
consecutive = previousPost && (isFromSameUser || isInTimeframe) && !postFromWebhook &&
|
||||
!prevPostFromWebhook && isNotSystemMessage && isSameThread;
|
||||
consecutive = previousPost && isFromSameUser && isInTimeframe && !postFromWebhook &&
|
||||
!prevPostFromWebhook && isNotSystemMessage;
|
||||
}
|
||||
|
||||
return consecutive;
|
||||
}
|
||||
|
||||
@@ -73,3 +73,13 @@ export const getMentionKeysForPost = (user: UserModel, post: PostModel, groups:
|
||||
|
||||
return keys;
|
||||
};
|
||||
|
||||
export const sortPostsByNewest = (posts: PostModel[]) => {
|
||||
return posts.sort((a, b) => {
|
||||
if (a.createAt > b.createAt) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return -1;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -93,6 +93,7 @@ export function concatStyles<T>(...styles: T[]) {
|
||||
}
|
||||
|
||||
export function setNavigatorStyles(componentId: string, theme: Theme, additionalOptions: Options = {}, statusBarColor?: string) {
|
||||
const isDark = tinyColor(statusBarColor || theme.sidebarBg).isDark();
|
||||
const options: Options = {
|
||||
topBar: {
|
||||
title: {
|
||||
@@ -106,6 +107,7 @@ export function setNavigatorStyles(componentId: string, theme: Theme, additional
|
||||
},
|
||||
statusBar: {
|
||||
backgroundColor: theme.sidebarBg,
|
||||
style: isDark ? 'light' : 'dark',
|
||||
},
|
||||
layout: {
|
||||
componentBackgroundColor: theme.centerChannelBg,
|
||||
@@ -117,7 +119,6 @@ export function setNavigatorStyles(componentId: string, theme: Theme, additional
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
};
|
||||
}
|
||||
const isDark = tinyColor(statusBarColor || theme.sidebarBg).isDark();
|
||||
StatusBar.setBarStyle(isDark ? 'light-content' : 'dark-content');
|
||||
|
||||
const mergeOptions = {
|
||||
|
||||
@@ -24,12 +24,10 @@
|
||||
"apps.error.responses.unexpected_error": "Received an unexpected error.",
|
||||
"apps.error.responses.unknown_type": "App response type not supported. Response type: {type}.",
|
||||
"apps.error.unknown": "Unknown error occurred.",
|
||||
"channel": "{count, plural, one {# member} other {# members}}",
|
||||
"channel_header.directchannel.you": "{displayname} (you)",
|
||||
"channel_loader.someone": "Someone",
|
||||
"channel_modal.optional": "(optional)",
|
||||
"channel.channelHasGuests": "This channel has guests",
|
||||
"channel.hasGuests": "This group message has guests",
|
||||
"channel.isGuest": "This person is a guest",
|
||||
"combined_system_message.added_to_channel.many_expanded": "{users} and {lastUser} were **added to the channel** by {actor}.",
|
||||
"combined_system_message.added_to_channel.one": "{firstUser} **added to the channel** by {actor}.",
|
||||
"combined_system_message.added_to_channel.one_you": "You were **added to the channel** by {actor}.",
|
||||
@@ -283,6 +281,7 @@
|
||||
"modal.manual_status.auto_responder.message_dnd": "Would you like to switch your status to \"Do Not Disturb\" and disable Automatic Replies?",
|
||||
"modal.manual_status.auto_responder.message_offline": "Would you like to switch your status to \"Offline\" and disable Automatic Replies?",
|
||||
"modal.manual_status.auto_responder.message_online": "Would you like to switch your status to \"Online\" and disable Automatic Replies?",
|
||||
"more_messages.text": "{count} new {count, plural, one {message} other {messages}}",
|
||||
"notification.message_not_found": "Message not found",
|
||||
"notification.not_channel_member": "This message belongs to a channel where you are not a member.",
|
||||
"notification.not_team_member": "This message belongs to a team where you are not a member.",
|
||||
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -32,6 +32,7 @@
|
||||
"@react-navigation/native": "6.0.6",
|
||||
"@rudderstack/rudder-sdk-react-native": "1.1.1",
|
||||
"@sentry/react-native": "3.2.10",
|
||||
"@stream-io/flat-list-mvcp": "0.10.1",
|
||||
"@types/mime-db": "1.43.1",
|
||||
"commonmark": "0.30.0",
|
||||
"commonmark-react-renderer": "4.3.5",
|
||||
@@ -5272,6 +5273,18 @@
|
||||
"@sinonjs/commons": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stream-io/flat-list-mvcp": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@stream-io/flat-list-mvcp/-/flat-list-mvcp-0.10.1.tgz",
|
||||
"integrity": "sha512-/snvyGqEO/7WKrcFOUxh1s1GPfYaUOwr7wyWgZogOUrGXE75zzEvOe39mooMoCJ8G92govZoAO5LCkftXQUoAQ==",
|
||||
"dependencies": {
|
||||
"lodash.debounce": "^4.0.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.0.0.tgz",
|
||||
@@ -27840,6 +27853,14 @@
|
||||
"@sinonjs/commons": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"@stream-io/flat-list-mvcp": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@stream-io/flat-list-mvcp/-/flat-list-mvcp-0.10.1.tgz",
|
||||
"integrity": "sha512-/snvyGqEO/7WKrcFOUxh1s1GPfYaUOwr7wyWgZogOUrGXE75zzEvOe39mooMoCJ8G92govZoAO5LCkftXQUoAQ==",
|
||||
"requires": {
|
||||
"lodash.debounce": "^4.0.8"
|
||||
}
|
||||
},
|
||||
"@svgr/babel-plugin-add-jsx-attribute": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.0.0.tgz",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@react-navigation/native": "6.0.6",
|
||||
"@rudderstack/rudder-sdk-react-native": "1.1.1",
|
||||
"@sentry/react-native": "3.2.10",
|
||||
"@stream-io/flat-list-mvcp": "0.10.1",
|
||||
"@types/mime-db": "1.43.1",
|
||||
"commonmark": "0.30.0",
|
||||
"commonmark-react-renderer": "4.3.5",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
diff --git a/node_modules/react-native/Libraries/Components/ScrollView/ScrollView.js b/node_modules/react-native/Libraries/Components/ScrollView/ScrollView.js
|
||||
index 8eca425..f0df04f 100644
|
||||
index e497288..5465e97 100644
|
||||
--- a/node_modules/react-native/Libraries/Components/ScrollView/ScrollView.js
|
||||
+++ b/node_modules/react-native/Libraries/Components/ScrollView/ScrollView.js
|
||||
@@ -1768,9 +1768,15 @@ class ScrollView extends React.Component<Props, State> {
|
||||
@@ -1776,9 +1776,15 @@ class ScrollView extends React.Component<Props, State> {
|
||||
// Note: we should split props.style on the inner and outer props
|
||||
// however, the ScrollView still needs the baseStyle to be scrollable
|
||||
const {outer, inner} = splitLayoutProps(flattenStyle(props.style));
|
||||
@@ -20,7 +20,7 @@ index 8eca425..f0df04f 100644
|
||||
{...props}
|
||||
style={StyleSheet.compose(baseStyle, inner)}
|
||||
diff --git a/node_modules/react-native/Libraries/Lists/VirtualizedList.js b/node_modules/react-native/Libraries/Lists/VirtualizedList.js
|
||||
index a7c1567..1531a45 100644
|
||||
index 2648cc3..eee7c9a 100644
|
||||
--- a/node_modules/react-native/Libraries/Lists/VirtualizedList.js
|
||||
+++ b/node_modules/react-native/Libraries/Lists/VirtualizedList.js
|
||||
@@ -16,6 +16,7 @@ const ScrollView = require('../Components/ScrollView/ScrollView');
|
||||
@@ -31,7 +31,158 @@ index a7c1567..1531a45 100644
|
||||
|
||||
const flattenStyle = require('../StyleSheet/flattenStyle');
|
||||
const infoLog = require('../Utilities/infoLog');
|
||||
@@ -2122,7 +2123,14 @@ function describeNestedLists(childList: {
|
||||
@@ -514,29 +515,29 @@ class VirtualizedList extends React.PureComponent<Props, State> {
|
||||
* Param `animated` (`true` by default) defines whether the list
|
||||
* should do an animation while scrolling.
|
||||
*/
|
||||
- scrollToOffset(params: {animated?: ?boolean, offset: number, ...}) {
|
||||
- const {animated, offset} = params;
|
||||
+scrollToOffset(params: {animated?: ?boolean, offset: number, ...}) {
|
||||
+ const {animated, offset} = params;
|
||||
|
||||
- if (this._scrollRef == null) {
|
||||
- return;
|
||||
- }
|
||||
-
|
||||
- if (this._scrollRef.scrollTo == null) {
|
||||
- console.warn(
|
||||
- 'No scrollTo method provided. This may be because you have two nested ' +
|
||||
- 'VirtualizedLists with the same orientation, or because you are ' +
|
||||
- 'using a custom component that does not implement scrollTo.',
|
||||
- );
|
||||
- return;
|
||||
- }
|
||||
+ if (this._scrollRef == null) {
|
||||
+ return;
|
||||
+ }
|
||||
|
||||
- this._scrollRef.scrollTo(
|
||||
- horizontalOrDefault(this.props.horizontal)
|
||||
- ? {x: offset, animated}
|
||||
- : {y: offset, animated},
|
||||
+ if (this._scrollRef.scrollTo == null) {
|
||||
+ console.warn(
|
||||
+ 'No scrollTo method provided. This may be because you have two nested ' +
|
||||
+ 'VirtualizedLists with the same orientation, or because you are ' +
|
||||
+ 'using a custom component that does not implement scrollTo.',
|
||||
);
|
||||
+ return;
|
||||
}
|
||||
|
||||
+ this._scrollRef.scrollTo(
|
||||
+ horizontalOrDefault(this.props.horizontal)
|
||||
+ ? {x: offset, animated}
|
||||
+ : {y: offset, animated},
|
||||
+ );
|
||||
+}
|
||||
+
|
||||
recordInteraction() {
|
||||
this._nestedChildLists.forEach(childList => {
|
||||
childList.ref && childList.ref.recordInteraction();
|
||||
@@ -1221,6 +1222,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
|
||||
_totalCellsMeasured = 0;
|
||||
_updateCellsToRenderBatcher: Batchinator;
|
||||
_viewabilityTuples: Array<ViewabilityHelperCallbackTuple> = [];
|
||||
+ _hasDoneFirstScroll = false;
|
||||
|
||||
_captureScrollRef = ref => {
|
||||
this._scrollRef = ref;
|
||||
@@ -1495,31 +1497,40 @@ class VirtualizedList extends React.PureComponent<Props, State> {
|
||||
return !horizontalOrDefault(this.props.horizontal) ? metrics.y : metrics.x;
|
||||
}
|
||||
|
||||
- _maybeCallOnEndReached() {
|
||||
- const {
|
||||
- data,
|
||||
- getItemCount,
|
||||
- onEndReached,
|
||||
- onEndReachedThreshold,
|
||||
- } = this.props;
|
||||
- const {contentLength, visibleLength, offset} = this._scrollMetrics;
|
||||
- const distanceFromEnd = contentLength - visibleLength - offset;
|
||||
- const threshold =
|
||||
- onEndReachedThreshold != null ? onEndReachedThreshold * visibleLength : 2;
|
||||
+ _maybeCallOnEndReached(hasShrunkContentLength: boolean = false) {
|
||||
+ const {onEndReached, onEndReachedThreshold} = this.props;
|
||||
+ if (!onEndReached) {
|
||||
+ return;
|
||||
+ }
|
||||
+ const {contentLength, visibleLength, offset, dOffset} = this._scrollMetrics;
|
||||
+ // If this is just after the initial rendering
|
||||
if (
|
||||
- onEndReached &&
|
||||
- this.state.last === getItemCount(data) - 1 &&
|
||||
- distanceFromEnd < threshold &&
|
||||
- this._scrollMetrics.contentLength !== this._sentEndForContentLength
|
||||
+ !hasShrunkContentLength &&
|
||||
+ !this._hasDoneFirstScroll &&
|
||||
+ offset === 0
|
||||
) {
|
||||
- // Only call onEndReached once for a given content length
|
||||
- this._sentEndForContentLength = this._scrollMetrics.contentLength;
|
||||
- onEndReached({distanceFromEnd});
|
||||
- } else if (distanceFromEnd > threshold) {
|
||||
- // If the user scrolls away from the end and back again cause
|
||||
- // an onEndReached to be triggered again
|
||||
- this._sentEndForContentLength = 0;
|
||||
+ return;
|
||||
+ }
|
||||
+ // If scrolled up in the vertical list
|
||||
+ if (dOffset < 0) {
|
||||
+ return;
|
||||
+ }
|
||||
+ // If contentLength has not changed
|
||||
+ if (contentLength === this._sentEndForContentLength) {
|
||||
+ return;
|
||||
+ }
|
||||
+ const distanceFromEnd = contentLength - visibleLength - offset;
|
||||
+ // If the distance is so farther than the area shown on the screen
|
||||
+ if (distanceFromEnd >= visibleLength * 1.5) {
|
||||
+ return;
|
||||
+ }
|
||||
+ // $FlowFixMe
|
||||
+ const minimumDistanceFromEnd = onEndReachedThreshold !== null ? onEndReachedThreshold * visibleLength : 2;
|
||||
+ if (distanceFromEnd >= minimumDistanceFromEnd) {
|
||||
+ return;
|
||||
}
|
||||
+ this._sentEndForContentLength = contentLength;
|
||||
+ onEndReached({distanceFromEnd});
|
||||
}
|
||||
|
||||
_onContentSizeChange = (width: number, height: number) => {
|
||||
@@ -1541,9 +1552,21 @@ class VirtualizedList extends React.PureComponent<Props, State> {
|
||||
if (this.props.onContentSizeChange) {
|
||||
this.props.onContentSizeChange(width, height);
|
||||
}
|
||||
- this._scrollMetrics.contentLength = this._selectLength({height, width});
|
||||
+ const {contentLength: currentContentLength} = this._scrollMetrics;
|
||||
+ const contentLength = this._selectLength({height, width});
|
||||
+ this._scrollMetrics.contentLength = contentLength;
|
||||
this._scheduleCellsToRenderUpdate();
|
||||
- this._maybeCallOnEndReached();
|
||||
+ const hasShrunkContentLength =
|
||||
+ currentContentLength > 0 &&
|
||||
+ contentLength > 0 &&
|
||||
+ contentLength < currentContentLength;
|
||||
+ if (
|
||||
+ hasShrunkContentLength &&
|
||||
+ this._sentEndForContentLength >= contentLength
|
||||
+ ) {
|
||||
+ this._sentEndForContentLength = 0;
|
||||
+ }
|
||||
+ this._maybeCallOnEndReached(hasShrunkContentLength);
|
||||
};
|
||||
|
||||
/* Translates metrics from a scroll event in a parent VirtualizedList into
|
||||
@@ -1631,6 +1654,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
|
||||
if (!this.props) {
|
||||
return;
|
||||
}
|
||||
+ this._hasDoneFirstScroll = true;
|
||||
this._maybeCallOnEndReached();
|
||||
if (velocity !== 0) {
|
||||
this._fillRateHelper.activate();
|
||||
@@ -2119,7 +2143,14 @@ function describeNestedLists(childList: {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
verticallyInverted: {
|
||||
@@ -48,7 +199,7 @@ index a7c1567..1531a45 100644
|
||||
horizontallyInverted: {
|
||||
transform: [{scaleX: -1}],
|
||||
diff --git a/node_modules/react-native/react.gradle b/node_modules/react-native/react.gradle
|
||||
index 84b1f60..0ffc592 100644
|
||||
index ff46476..90e66db 100644
|
||||
--- a/node_modules/react-native/react.gradle
|
||||
+++ b/node_modules/react-native/react.gradle
|
||||
@@ -151,7 +151,7 @@ afterEvaluate {
|
||||
@@ -78,7 +229,7 @@ index 84b1f60..0ffc592 100644
|
||||
}
|
||||
|
||||
// Expose a minimal interface on the application variant and the task itself:
|
||||
@@ -321,7 +321,7 @@ afterEvaluate {
|
||||
@@ -328,7 +328,7 @@ afterEvaluate {
|
||||
// This should really be done by packaging all Hermes related libs into
|
||||
// two separate HermesDebug and HermesRelease AARs, but until then we'll
|
||||
// kludge it by deleting the .so files out of the /transforms/ directory.
|
||||
|
||||
1
types/api/channels.d.ts
vendored
1
types/api/channels.d.ts
vendored
@@ -3,6 +3,7 @@
|
||||
type ChannelType = 'O' | 'P' | 'D' | 'G';
|
||||
type ChannelStats = {
|
||||
channel_id: string;
|
||||
guest_count: number;
|
||||
member_count: number;
|
||||
pinnedpost_count: number;
|
||||
};
|
||||
|
||||
48
types/api/posts.d.ts
vendored
48
types/api/posts.d.ts
vendored
@@ -69,53 +69,23 @@ type Post = {
|
||||
participants: null|string[];
|
||||
};
|
||||
|
||||
type PostWithFormatData = Post & {
|
||||
isFirstReply: boolean;
|
||||
isLastReply: boolean;
|
||||
previousPostIsComment: boolean;
|
||||
commentedOnPost?: Post;
|
||||
consecutivePostByUser: boolean;
|
||||
replyCount: number;
|
||||
isCommentMention: boolean;
|
||||
highlight: boolean;
|
||||
};
|
||||
|
||||
type PostOrderBlock = {
|
||||
order: string[];
|
||||
recent?: boolean;
|
||||
oldest?: boolean;
|
||||
};
|
||||
|
||||
type MessageHistory = {
|
||||
messages: string[];
|
||||
index: {
|
||||
post: number;
|
||||
comment: number;
|
||||
};
|
||||
};
|
||||
|
||||
type PostsState = {
|
||||
posts: IDMappedObjects<Post>;
|
||||
postsInChannel: Dictionary<PostOrderBlock[]>;
|
||||
postsInThread: RelationOneToMany<Post, Post>;
|
||||
reactions: RelationOneToOne<Post, Dictionary<Reaction>>;
|
||||
openGraph: RelationOneToOne<Post, any>;
|
||||
pendingPostIds: string[];
|
||||
selectedPostId: string;
|
||||
currentFocusedPostId: string;
|
||||
messagesHistory: MessageHistory;
|
||||
expandedURLs: Dictionary<string>;
|
||||
};
|
||||
|
||||
type PostProps = {
|
||||
disable_group_highlight?: boolean;
|
||||
mentionHighlightDisabled: boolean;
|
||||
};
|
||||
|
||||
type PostResponse = PostOrderBlock & {
|
||||
type PostResponse = {
|
||||
order: string[];
|
||||
posts: IDMappedObjects<Post>;
|
||||
prev_post_id?: string;
|
||||
};
|
||||
|
||||
type ProcessedPosts = {
|
||||
order: string[];
|
||||
posts: Post[];
|
||||
previousPostId?: string;
|
||||
}
|
||||
|
||||
type MessageAttachment = {
|
||||
id: number;
|
||||
fallback: string;
|
||||
|
||||
3
types/database/models/servers/post.d.ts
vendored
3
types/database/models/servers/post.d.ts
vendored
@@ -78,4 +78,7 @@ export default class PostModel extends Model {
|
||||
|
||||
/** channel: The channel which is presenting this Post */
|
||||
channel: Relation<ChannelModel>;
|
||||
|
||||
/** hasReplies: Async function to determine if the post is part of a thread */
|
||||
hasReplies: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user