[Gekidou] post list (#5893)

This commit is contained in:
Elias Nahum
2022-01-17 07:06:26 -03:00
committed by GitHub
parent 2686633608
commit b8b51296c0
97 changed files with 3787 additions and 951 deletions

View File

@@ -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};
}
};

View File

@@ -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;

View File

@@ -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);
}
};

View File

@@ -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 {};
};

View File

@@ -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;

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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);
}
};

View File

@@ -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);

View File

@@ -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>;

View File

@@ -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={

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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));

View File

@@ -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;

View File

@@ -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 = () => {

View File

@@ -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]}>

View File

@@ -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) &&

View File

@@ -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}

View File

@@ -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]);

View File

@@ -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 {

View File

@@ -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 (

View File

@@ -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;

View 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',
},
};

View File

@@ -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}
/>
);

View File

@@ -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;

View 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));

View 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;

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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) {

View File

@@ -41,8 +41,7 @@ type BodyProps = {
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
messageBody: {
paddingBottom: 2,
paddingTop: 2,
paddingVertical: 2,
flex: 1,
},
messageContainer: {width: '100%'},

View File

@@ -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%',

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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}
/>

View File

@@ -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;

View 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;

View File

@@ -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

View File

@@ -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}
/>
);
};

View File

@@ -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));

View File

@@ -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]);
});

View File

@@ -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,
},
};
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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;
}
}

View File

@@ -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[]> => {

View File

@@ -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,

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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));

View File

@@ -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));

View 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;

View 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));

View File

@@ -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;

View File

@@ -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;

View File

@@ -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));

View File

@@ -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));

View File

@@ -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));

View File

@@ -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

View 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));

View 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;

View File

@@ -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;

View File

@@ -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));

View File

@@ -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;

View 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;

View File

@@ -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));

View File

@@ -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;

View File

@@ -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;

View File

@@ -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));

View File

@@ -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>
);
};

View 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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -16,7 +16,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flex: 1,
flexDirection: 'row',
marginTop: 8,
marginVertical: 8,
},
channel: {
...typography('Body', 75, 'SemiBold'),

View File

@@ -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

View File

@@ -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}}

View File

@@ -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);
}
};

View File

@@ -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,

View File

@@ -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',

View File

@@ -32,7 +32,7 @@ const RE_EMOTICON: Record<string, RegExp> = {
broken_heart: /(^|\s)(<\/3|&lt;&#x2F;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)) {

View File

@@ -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;
});
};

View File

@@ -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 = {

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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.

View File

@@ -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
View File

@@ -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;

View File

@@ -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>;
}