[Gekidou] - Channel Intro (#5846)

* Channel Intro

* Move avatar margins to post component per feedback review

* Channel intro redesign

* Fix preferences unit test

* Change group intro sizes

* Add Bot tag to DM Intro if they have it

* fix channel isTablet layout on split screen

* update snapshot
This commit is contained in:
Elias Nahum
2021-12-21 17:44:00 +02:00
committed by GitHub
parent 675d8495b3
commit 7e6248dfb3
43 changed files with 2202 additions and 364 deletions

View File

@@ -44,7 +44,7 @@ export const switchToChannel = async (serverUrl: string, channelId: string, team
}
if (system.currentChannelId !== channelId) {
const history = await addChannelToTeamHistory(operator, system.currentTeamId, channelId, true);
const history = await addChannelToTeamHistory(operator, channel.teamId || system.currentTeamId, channelId, true);
models.push(...history);
}

View File

@@ -9,8 +9,8 @@ import {General} from '@constants';
import DatabaseManager from '@database/manager';
import {privateChannelJoinPrompt} from '@helpers/api/channel';
import NetworkManager from '@init/network_manager';
import {prepareMyChannelsForTeam, queryMyChannel} from '@queries/servers/channel';
import {queryCommonSystemValues} from '@queries/servers/system';
import {prepareMyChannelsForTeam, queryChannelById, queryMyChannel} from '@queries/servers/channel';
import {queryCommonSystemValues, queryCurrentUserId} from '@queries/servers/system';
import {prepareMyTeams, queryMyTeamById, queryTeamById, queryTeamByName} from '@queries/servers/team';
import MyChannelModel from '@typings/database/models/servers/my_channel';
import MyTeamModel from '@typings/database/models/servers/my_team';
@@ -47,13 +47,23 @@ export const addMembersToChannel = async (serverUrl: string, channelId: string,
try {
const promises = userIds.map((id) => client.addToChannel(id, channelId, postRootId));
const channelMemberships: ChannelMembership[] = await Promise.all(promises);
await fetchUsersByIds(serverUrl, userIds, false);
const {users} = await fetchUsersByIds(serverUrl, userIds, true);
if (!fetchOnly) {
await operator.handleChannelMembership({
const modelPromises: Array<Promise<Model[]>> = [];
if (users) {
modelPromises.push(operator.handleUsers({
users,
prepareRecordsOnly: true,
}));
}
modelPromises.push(operator.handleChannelMembership({
channelMemberships,
prepareRecordsOnly: false,
});
prepareRecordsOnly: true,
}));
const models = await Promise.all(modelPromises);
await operator.batchRecords(models.flat());
}
return {channelMemberships};
} catch (error) {
@@ -87,6 +97,53 @@ export const fetchChannelByName = async (serverUrl: string, teamId: string, chan
}
};
export const fetchChannelCreator = 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 currentUserId = await queryCurrentUserId(operator.database);
const channel = await queryChannelById(operator.database, channelId);
if (channel && channel.creatorId) {
const user = await client.getUser(channel.creatorId);
if (!fetchOnly) {
const modelPromises: Array<Promise<Model[]>> = [];
if (user.id !== currentUserId) {
modelPromises.push(operator.handleUsers({
users: [user],
prepareRecordsOnly: true,
}));
}
modelPromises.push(operator.handleChannelMembership({
channelMemberships: [{channel_id: channelId, user_id: channel.creatorId}],
prepareRecordsOnly: true,
}));
const models = await Promise.all(modelPromises);
await operator.batchRecords(models.flat());
}
return {user};
}
return {user: undefined};
} 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 {

View File

@@ -1,8 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Preferences} from '@constants';
import DatabaseManager from '@database/manager';
import NetworkManager from '@init/network_manager';
import {queryCurrentUserId} from '@queries/servers/system';
import {forceLogoutIfNecessary} from './session';
@@ -25,9 +27,10 @@ export const fetchMyPreferences = async (serverUrl: string, fetchOnly = false):
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
operator.handlePreferences({
await operator.handlePreferences({
prepareRecordsOnly: false,
preferences,
sync: true,
});
}
}
@@ -38,3 +41,39 @@ export const fetchMyPreferences = async (serverUrl: string, fetchOnly = false):
return {error};
}
};
export const saveFavoriteChannel = async (serverUrl: string, channelId: string, isFavorite: boolean) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
// Todo: @shaz I think you'll need to add the category handler here so that the channel is added/removed from the favorites category
const userId = await queryCurrentUserId(operator.database);
const favPref: PreferenceType = {
category: Preferences.CATEGORY_FAVORITE_CHANNEL,
name: channelId,
user_id: userId,
value: String(isFavorite),
};
const preferences = [favPref];
client.savePreferences(userId, preferences);
await operator.handlePreferences({
preferences,
prepareRecordsOnly: false,
});
return {preferences};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};

View File

@@ -90,7 +90,7 @@ export const fetchConfigAndLicense = async (serverUrl: string, fetchOnly = false
}
if (systems.length) {
operator.handleSystem({systems, prepareRecordsOnly: false}).
await operator.handleSystem({systems, prepareRecordsOnly: false}).
catch((error) => {
// eslint-disable-next-line no-console
console.log('An error ocurred while saving config & license', error);

View File

@@ -48,7 +48,7 @@ export const fetchMe = async (serverUrl: string, fetchOnly = false): Promise<MyU
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
operator.handleUsers({users: [user], prepareRecordsOnly: false});
await operator.handleUsers({users: [user], prepareRecordsOnly: false});
}
}
@@ -70,20 +70,32 @@ export const fetchProfilesInChannel = async (serverUrl: string, channelId: strin
try {
const users = await client.getProfilesInChannel(channelId);
const uniqueUsers = Array.from(new Set(users));
const filteredUsers = uniqueUsers.filter((u) => u.id !== excludeUserId);
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
const prepare = prepareUsers(operator, uniqueUsers.filter((u) => u.id !== excludeUserId));
if (operator && filteredUsers.length) {
const modelPromises: Array<Promise<Model[]>> = [];
const membership = filteredUsers.map((u) => ({
channel_id: channelId,
user_id: u.id,
}));
modelPromises.push(operator.handleChannelMembership({
channelMemberships: membership,
prepareRecordsOnly: true,
}));
const prepare = prepareUsers(operator, filteredUsers);
if (prepare) {
const models = await prepare;
if (models.length) {
await operator.batchRecords(models);
}
modelPromises.push(prepare);
}
if (modelPromises.length) {
const models = await Promise.all(modelPromises);
await operator.batchRecords(models.flat());
}
}
}
return {channelId, users: uniqueUsers};
return {channelId, users: filteredUsers};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {channelId, error};
@@ -98,18 +110,29 @@ export const fetchProfilesPerChannels = async (serverUrl: string, channelIds: st
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {
const modelPromises: Array<Promise<Model[]>> = [];
const users = new Set<UserProfile>();
const memberships: Array<{channel_id: string; user_id: string}> = [];
for (const item of data) {
if (item.users?.length) {
item.users.forEach(users.add, users);
item.users.forEach((u) => {
users.add(u);
memberships.push({channel_id: item.channelId, user_id: u.id});
});
}
}
modelPromises.push(operator.handleChannelMembership({
channelMemberships: memberships,
prepareRecordsOnly: true,
}));
const prepare = prepareUsers(operator, Array.from(users).filter((u) => u.id !== excludeUserId));
if (prepare) {
const models = await prepare;
if (models.length) {
await operator.batchRecords(models);
}
modelPromises.push(prepare);
}
if (modelPromises.length) {
const models = await Promise.all(modelPromises);
await operator.batchRecords(models.flat());
}
}
}

View File

@@ -73,8 +73,8 @@ async function doReconnect(serverUrl: string) {
const lastDisconnectedAt = await queryWebSocketLastDisconnected(database.database);
// TODO consider fetch only and batch all the results.
fetchMe(serverUrl);
fetchMyPreferences(serverUrl);
await fetchMe(serverUrl);
await fetchMyPreferences(serverUrl);
const {config} = await fetchConfigAndLicense(serverUrl);
const {memberships: teamMemberships, error: teamMembershipsError} = await fetchMyTeams(serverUrl);
const {currentChannelId, currentUserId, currentTeamId, license} = system;
@@ -82,7 +82,7 @@ async function doReconnect(serverUrl: string) {
let channelMemberships: ChannelMembership[] | undefined;
if (currentTeamMembership) {
const {memberships, channels, error} = await fetchMyChannelsForTeam(serverUrl, currentTeamMembership.team_id, true, lastDisconnectedAt, true);
const {memberships, channels, error} = await fetchMyChannelsForTeam(serverUrl, currentTeamMembership.team_id, true, lastDisconnectedAt);
if (error) {
DeviceEventEmitter.emit(Events.TEAM_LOAD_ERROR, serverUrl, error);
return;
@@ -92,7 +92,7 @@ async function doReconnect(serverUrl: string) {
const teammateDisplayNameSetting = getTeammateNameDisplaySetting(preferences || [], system.config, license);
const directChannels = channels?.filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL);
if (directChannels?.length) {
await fetchMissingSidebarInfo(serverUrl, directChannels, currentUser?.locale, teammateDisplayNameSetting, currentUserId, true);
await fetchMissingSidebarInfo(serverUrl, directChannels, currentUser?.locale, teammateDisplayNameSetting, currentUserId);
}
const modelPromises: Array<Promise<Model[]>> = [];

View File

@@ -5,7 +5,7 @@ exports[`components/channel_list should match snapshot 1`] = `
animatedStyle={
Object {
"value": Object {
"maxWidth": undefined,
"maxWidth": "100%",
},
}
}
@@ -14,7 +14,7 @@ exports[`components/channel_list should match snapshot 1`] = `
Object {
"backgroundColor": "#1e325c",
"flex": 1,
"maxWidth": undefined,
"maxWidth": "100%",
"paddingHorizontal": 20,
"paddingVertical": 10,
}

View File

@@ -50,12 +50,12 @@ const ChannelList = ({iconPad, isTablet, teamsCount}: ChannelListProps) => {
const tabletStyle = useAnimatedStyle(() => {
if (!isTablet) {
return {
maxWidth: undefined,
maxWidth: '100%',
};
}
return {maxWidth: withTiming(tabletWidth.value, {duration: 350})};
}, []);
}, [isTablet]);
useEffect(() => {
if (isTablet) {
@@ -64,7 +64,6 @@ const ChannelList = ({iconPad, isTablet, teamsCount}: ChannelListProps) => {
}, [isTablet, teamsCount]);
const [showCats, setShowCats] = useState<boolean>(true);
return (
<Animated.View style={[styles.container, tabletStyle]}>
<TouchableOpacity onPress={() => setShowCats(!showCats)}>

View File

@@ -4,23 +4,17 @@
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React, {ReactElement, useCallback} from 'react';
import {AppStateStatus, DeviceEventEmitter, FlatList, Platform, RefreshControl, StyleSheet, ViewToken} from 'react-native';
import {AppStateStatus} from 'react-native';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
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 {Preferences} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useTheme} from '@context/theme';
import {getPreferenceAsBool} from '@helpers/api/preference';
import {emptyFunction} from '@utils/general';
import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList} from '@utils/post_list';
import {getTimezone} from '@utils/user';
import PostList from './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';
@@ -29,42 +23,6 @@ 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';
type RefreshProps = {
children: ReactElement;
enabled: boolean;
onRefresh: () => void;
refreshing: boolean;
}
type Props = {
currentTimezone: string | null;
currentUsername: string;
isTimezoneEnabled: boolean;
lastViewedAt: number;
posts: PostModel[];
shouldShowJoinLeaveMessages: boolean;
testID: string;
}
type ViewableItemsChanged = {
viewableItems: ViewToken[];
changed: ViewToken[];
}
const style = StyleSheet.create({
container: {
flex: 1,
scaleY: -1,
},
scale: {
...Platform.select({
android: {
scaleY: -1,
},
}),
},
});
const {SERVER: {MY_CHANNEL, POST, POSTS_IN_CHANNEL, PREFERENCE, SYSTEM, USER}} = MM_TABLES;
export const VIEWABILITY_CONFIG = {
@@ -72,161 +30,7 @@ export const VIEWABILITY_CONFIG = {
minimumViewTime: 100,
};
const PostListRefreshControl = ({children, enabled, onRefresh, refreshing}: RefreshProps) => {
const props = {
onRefresh,
refreshing,
};
if (Platform.OS === 'android') {
return (
<RefreshControl
{...props}
enabled={enabled}
style={style.container}
>
{children}
</RefreshControl>
);
}
const refreshControl = <RefreshControl {...props}/>;
return React.cloneElement(
children,
{refreshControl, inverted: true},
);
};
const PostList = ({currentTimezone, currentUsername, isTimezoneEnabled, lastViewedAt, posts, shouldShowJoinLeaveMessages, testID}: Props) => {
const theme = useTheme();
const orderedPosts = preparePostList(posts, lastViewedAt, true, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, currentTimezone, false);
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);
}, []);
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={style.scale}
/>
);
} else if (isDateLine(item)) {
return (
<DateSeparator
date={getDateForDateLine(item)}
theme={theme}
style={style.scale}
timezone={isTimezoneEnabled ? currentTimezone : null}
/>
);
}
if (isCombinedUserActivityPost(item)) {
const postProps = {
currentUsername,
postId: item,
style: Platform.OS === 'ios' ? style.scale : style.container,
testID: `${testID}.combined_user_activity`,
showJoinLeave: shouldShowJoinLeaveMessages,
theme,
};
return (<CombinedUserActivity {...postProps}/>);
}
}
let previousPost: PostModel|undefined;
let nextPost: PostModel|undefined;
if (index < posts.length - 1) {
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 = {
highlightPinnedOrFlagged: true,
location: 'Channel',
nextPost,
previousPost,
shouldRenderReplyButton: true,
};
return (
<Post
key={item.id}
post={item}
style={style.scale}
testID={`${testID}.post`}
{...postProps}
/>
);
}, [orderedPosts, theme]);
return (
<PostListRefreshControl
enabled={false}
refreshing={false}
onRefresh={emptyFunction}
>
<FlatList
data={orderedPosts}
renderItem={renderItem}
keyboardDismissMode='interactive'
keyboardShouldPersistTaps='handled'
keyExtractor={(item) => (typeof item === 'string' ? item : item.id)}
style={{flex: 1}}
contentContainerStyle={{paddingTop: 5}}
initialNumToRender={10}
maxToRenderPerBatch={10}
removeClippedSubviews={true}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={VIEWABILITY_CONFIG}
scrollEventThrottle={60}
/>
</PostListRefreshControl>
);
};
const withPosts = withObservables(['channelId', 'forceQueryAfterAppState'], ({database, channelId}: {channelId: string; forceQueryAfterAppState: AppStateStatus} & WithDatabaseArgs) => {
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)),
);
@@ -269,4 +73,4 @@ const withPosts = withObservables(['channelId', 'forceQueryAfterAppState'], ({da
};
});
export default withDatabase(withPosts(PostList));
export default withDatabase(enhanced(PostList));

View File

@@ -61,7 +61,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
consecutivePostContainer: {
marginBottom: 10,
marginRight: 10,
marginLeft: 47,
marginLeft: 27,
marginTop: 10,
},
container: {flexDirection: 'row'},
@@ -76,6 +76,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
overflow: 'hidden',
flex: 1,
},
profilePictureContainer: {
marginBottom: 5,
marginRight: 10,
marginTop: 10,
},
replyBar: {
backgroundColor: theme.centerChannelColor,
opacity: 0.1,
@@ -89,7 +94,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
rightColumn: {
flex: 1,
flexDirection: 'column',
marginRight: 12,
},
rightColumnPadding: {paddingBottom: 3},
};
@@ -186,15 +190,18 @@ const Post = ({
consecutiveStyle = styles.consective;
postAvatar = <View style={styles.consecutivePostContainer}/>;
} else {
postAvatar = isAutoResponder ? (
<SystemAvatar theme={theme}/>
) : (
<Avatar
isAutoReponse={isAutoResponder}
isSystemPost={isSystemPost}
pendingPostStyle={pendingPostStyle}
post={post}
/>
postAvatar = (
<View style={[styles.profilePictureContainer, pendingPostStyle]}>
{isAutoResponder ? (
<SystemAvatar theme={theme}/>
) : (
<Avatar
isAutoReponse={isAutoResponder}
isSystemPost={isSystemPost}
post={post}
/>
)}
</View>
);
if (isSystemPost && !isAutoResponder) {

View File

@@ -18,8 +18,10 @@ exports[`renderSystemMessage uses renderer for Channel Display Name update 1`] =
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"lineHeight": 20,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
@@ -37,8 +39,10 @@ exports[`renderSystemMessage uses renderer for Channel Display Name update 1`] =
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"lineHeight": 20,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="markdown_text"
@@ -67,8 +71,10 @@ exports[`renderSystemMessage uses renderer for Channel Header update 1`] = `
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"lineHeight": 20,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
@@ -86,8 +92,10 @@ exports[`renderSystemMessage uses renderer for Channel Header update 1`] = `
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"lineHeight": 20,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="markdown_text"
@@ -103,8 +111,10 @@ exports[`renderSystemMessage uses renderer for Channel Purpose update 1`] = `
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"lineHeight": 20,
"fontWeight": "400",
"lineHeight": 24,
}
}
>
@@ -130,8 +140,10 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 1
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"lineHeight": 20,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
@@ -149,8 +161,10 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 1
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"lineHeight": 20,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="markdown_text"
@@ -179,8 +193,10 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 2
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"lineHeight": 20,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
@@ -198,8 +214,10 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 2
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"lineHeight": 20,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="markdown_text"
@@ -211,8 +229,10 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 2
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"lineHeight": 20,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
@@ -247,8 +267,10 @@ exports[`renderSystemMessage uses renderer for OLD archived channel without a us
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"lineHeight": 20,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="markdown_text"
@@ -277,8 +299,10 @@ exports[`renderSystemMessage uses renderer for archived channel 1`] = `
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"lineHeight": 20,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
@@ -296,8 +320,10 @@ exports[`renderSystemMessage uses renderer for archived channel 1`] = `
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"lineHeight": 20,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="markdown_text"
@@ -326,8 +352,10 @@ exports[`renderSystemMessage uses renderer for unarchived channel 1`] = `
Array [
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"lineHeight": 20,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"opacity": 1,
@@ -345,8 +373,10 @@ exports[`renderSystemMessage uses renderer for unarchived channel 1`] = `
style={
Object {
"color": "rgba(63,67,80,0.6)",
"fontFamily": "OpenSans",
"fontSize": 16,
"lineHeight": 20,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="markdown_text"

View File

@@ -11,6 +11,7 @@ import {useTheme} from '@context/theme';
import {t} from '@i18n';
import {getMarkdownTextStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
@@ -46,8 +47,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
systemMessage: {
color: changeOpacity(theme.centerChannelColor, 0.6),
fontSize: 16,
lineHeight: 20,
...typography('Body', 200, 'Regular'),
},
};
});

View File

@@ -0,0 +1,233 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactElement, useCallback, useEffect, useRef} from 'react';
import {DeviceEventEmitter, FlatList, Platform, RefreshControl, StyleProp, StyleSheet, ViewStyle, ViewToken} from 'react-native';
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 {useTheme} from '@context/theme';
import {emptyFunction} from '@utils/general';
import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList} from '@utils/post_list';
import type PostModel from '@typings/database/models/servers/post';
type RefreshProps = {
children: ReactElement;
enabled: boolean;
onRefresh: () => void;
refreshing: boolean;
}
type Props = {
channelId: string;
contentContainerStyle?: StyleProp<ViewStyle>;
currentTimezone: string | null;
currentUsername: string;
isTimezoneEnabled: boolean;
lastViewedAt: number;
posts: PostModel[];
shouldShowJoinLeaveMessages: boolean;
footer?: ReactElement;
testID: string;
}
type ViewableItemsChanged = {
viewableItems: ViewToken[];
changed: ViewToken[];
}
const style = StyleSheet.create({
container: {
flex: 1,
scaleY: -1,
},
scale: {
...Platform.select({
android: {
scaleY: -1,
},
}),
},
});
export const VIEWABILITY_CONFIG = {
itemVisiblePercentThreshold: 1,
minimumViewTime: 100,
};
const keyExtractor = (item: string | PostModel) => (typeof item === 'string' ? item : item.id);
const styles = StyleSheet.create({
flex: {
flex: 1,
},
content: {
marginHorizontal: 20,
},
});
const PostListRefreshControl = ({children, enabled, onRefresh, refreshing}: RefreshProps) => {
const props = {
onRefresh,
refreshing,
};
if (Platform.OS === 'android') {
return (
<RefreshControl
{...props}
enabled={enabled}
style={style.container}
>
{children}
</RefreshControl>
);
}
const refreshControl = <RefreshControl {...props}/>;
return React.cloneElement(
children,
{refreshControl, inverted: true},
);
};
const PostList = ({channelId, contentContainerStyle, currentTimezone, currentUsername, footer, isTimezoneEnabled, lastViewedAt, posts, shouldShowJoinLeaveMessages, testID}: Props) => {
const listRef = useRef<FlatList>(null);
const theme = useTheme();
const orderedPosts = preparePostList(posts, lastViewedAt, true, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, currentTimezone, false);
useEffect(() => {
listRef.current?.scrollToOffset({offset: 0, animated: false});
}, [channelId, listRef.current]);
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);
}, []);
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={style.scale}
/>
);
} else if (isDateLine(item)) {
return (
<DateSeparator
date={getDateForDateLine(item)}
theme={theme}
style={style.scale}
timezone={isTimezoneEnabled ? currentTimezone : null}
/>
);
}
if (isCombinedUserActivityPost(item)) {
const postProps = {
currentUsername,
postId: item,
style: Platform.OS === 'ios' ? style.scale : style.container,
testID: `${testID}.combined_user_activity`,
showJoinLeave: shouldShowJoinLeaveMessages,
theme,
};
return (<CombinedUserActivity {...postProps}/>);
}
}
let previousPost: PostModel|undefined;
let nextPost: PostModel|undefined;
if (index < posts.length - 1) {
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 = {
highlightPinnedOrFlagged: true,
location: 'Channel',
nextPost,
previousPost,
shouldRenderReplyButton: true,
};
return (
<Post
key={item.id}
post={item}
style={style.scale}
testID={`${testID}.post`}
{...postProps}
/>
);
}, [orderedPosts, theme]);
return (
<PostListRefreshControl
enabled={false}
refreshing={false}
onRefresh={emptyFunction}
>
<FlatList
contentContainerStyle={[styles.content, contentContainerStyle]}
data={orderedPosts}
keyboardDismissMode='interactive'
keyboardShouldPersistTaps='handled'
keyExtractor={keyExtractor}
initialNumToRender={10}
ListFooterComponent={footer}
maxToRenderPerBatch={10}
onViewableItemsChanged={onViewableItemsChanged}
ref={listRef}
renderItem={renderItem}
removeClippedSubviews={true}
scrollEventThrottle={60}
style={styles.flex}
viewabilityConfig={VIEWABILITY_CONFIG}
/>
</PostListRefreshControl>
);
};
export default PostList;

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, View, ViewStyle} from 'react-native';
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
@@ -16,14 +16,15 @@ type TagProps = {
show?: boolean;
style?: StyleProp<ViewStyle>;
testID?: string;
textStyle?: StyleProp<TextStyle>;
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
alignSelf: 'center',
backgroundColor: changeOpacity(theme.centerChannelColor, 0.15),
borderRadius: 2,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
borderRadius: 4,
marginRight: 2,
marginBottom: 1,
marginLeft: 2,
@@ -68,7 +69,7 @@ export function GuestTag(props: Omit<TagProps, 'id' | 'defaultMessage'>) {
);
}
const Tag = ({id, defaultMessage, inTitle, show = true, style, testID}: TagProps) => {
const Tag = ({id, defaultMessage, inTitle, show = true, style, testID, textStyle}: TagProps) => {
const theme = useTheme();
if (!show) {
@@ -82,7 +83,7 @@ const Tag = ({id, defaultMessage, inTitle, show = true, style, testID}: TagProps
<FormattedText
id={id}
defaultMessage={defaultMessage}
style={[styles.text, inTitle ? styles.title : null]}
style={[styles.text, inTitle ? styles.title : null, textStyle]}
testID={testID}
/>
</View>

View File

@@ -6,6 +6,9 @@ export const EMOJI_PICKER = 'AddReaction';
export const APP_FORM = 'AppForm';
export const BOTTOM_SHEET = 'BottomSheet';
export const CHANNEL = 'Channel';
export const CHANNEL_ADD_PEOPLE = 'ChannelAddPeople';
export const CHANNEL_DETAILS = 'ChannelDetails';
export const CHANNEL_EDIT = 'ChannelEdit';
export const CUSTOM_STATUS_CLEAR_AFTER = 'CustomStatusClearAfter';
export const CUSTOM_STATUS = 'CustomStatus';
export const FORGOT_PASSWORD = 'ForgotPassword';
@@ -21,6 +24,7 @@ export const SERVER = 'Server';
export const SETTINGS_SIDEBAR = 'SettingsSidebar';
export const SSO = 'SSO';
export const THREAD = 'Thread';
export const USER_PROFILE = 'UserProfile';
export const MENTIONS = 'Mentions';
export default {
@@ -29,6 +33,9 @@ export default {
APP_FORM,
BOTTOM_SHEET,
CHANNEL,
CHANNEL_ADD_PEOPLE,
CHANNEL_EDIT,
CHANNEL_DETAILS,
CUSTOM_STATUS_CLEAR_AFTER,
CUSTOM_STATUS,
FORGOT_PASSWORD,
@@ -44,5 +51,6 @@ export default {
SETTINGS_SIDEBAR,
SSO,
THREAD,
USER_PROFILE,
MENTIONS,
};

View File

@@ -151,9 +151,8 @@ describe('*** Operator: User Handlers tests ***', () => {
expect(spyOnHandleRecords).toHaveBeenCalledWith({
fieldName: 'user_id',
createOrUpdateRawValues: preferences,
deleteRawValues: [],
tableName: 'Preference',
prepareRecordsOnly: false,
prepareRecordsOnly: true,
findMatchingRecordBy: isRecordPreferenceEqualToRaw,
transformer: transformPreferenceRecord,
});
@@ -163,6 +162,7 @@ describe('*** Operator: User Handlers tests ***', () => {
expect.assertions(2);
const channelMemberships: ChannelMembership[] = [
{
id: '17bfnb1uwb8epewp4q3x3rx9go-9ciscaqbrpd6d8s68k76xb9bte',
channel_id: '17bfnb1uwb8epewp4q3x3rx9go',
user_id: '9ciscaqbrpd6d8s68k76xb9bte',
roles: 'wqyby5r5pinxxdqhoaomtacdhc',
@@ -181,6 +181,7 @@ describe('*** Operator: User Handlers tests ***', () => {
scheme_admin: false,
},
{
id: '1yw6gxfr4bn1jbyp9nr7d53yew-9ciscaqbrpd6d8s68k76xb9bte',
channel_id: '1yw6gxfr4bn1jbyp9nr7d53yew',
user_id: '9ciscaqbrpd6d8s68k76xb9bte',
roles: 'channel_user',

View File

@@ -59,7 +59,12 @@ const UserHandler = (superclass: any) => class extends superclass {
);
}
const createOrUpdateRawValues = getUniqueRawsBy({raws: channelMemberships, key: 'channel_id'});
const memberships: ChannelMember[] = channelMemberships.map((m) => ({
id: `${m.channel_id}-${m.user_id}`,
...m,
}));
const createOrUpdateRawValues = getUniqueRawsBy({raws: memberships, key: 'id'});
return this.handleRecords({
fieldName: 'user_id',
@@ -87,33 +92,36 @@ const UserHandler = (superclass: any) => class extends superclass {
}
// WE NEED TO SYNC THE PREFS FROM WHAT WE GOT AND WHAT WE HAVE
const deleteRawValues: PreferenceType[] = [];
const deleteValues: PreferenceModel[] = [];
if (sync) {
const stored = await this.database.get(PREFERENCE).fetch() as PreferenceModel[];
const stored = await this.database.get(PREFERENCE).query().fetch() as PreferenceModel[];
for (const pref of stored) {
const exists = preferences.findIndex((p) => p.category === pref.category && p.name === pref.name) > -1;
if (!exists) {
deleteRawValues.push({
category: pref.category,
name: pref.name,
user_id: pref.userId,
value: pref.value,
});
pref.prepareDestroyPermanently();
deleteValues.push(pref);
}
}
}
const createOrUpdateRawValues = getUniqueRawsBy({raws: preferences, key: 'name'});
return this.handleRecords({
const records: PreferenceModel[] = await this.handleRecords({
fieldName: 'user_id',
findMatchingRecordBy: isRecordPreferenceEqualToRaw,
transformer: transformPreferenceRecord,
prepareRecordsOnly,
createOrUpdateRawValues,
deleteRawValues,
prepareRecordsOnly: true,
createOrUpdateRawValues: preferences,
tableName: PREFERENCE,
});
if (deleteValues.length) {
records.push(...deleteValues);
}
if (records.length && !prepareRecordsOnly) {
await this.batchRecords(records);
}
return records;
};
/**

View File

@@ -13,7 +13,7 @@ export const DEFAULT_LOCALE = deviceLocale;
function loadTranslation(locale?: string) {
try {
let translations;
let translations: Record<string, string>;
let momentData;
switch (locale) {
@@ -184,7 +184,7 @@ export function getLocalizedMessage(lang: string, id: string, defaultMessage?: s
const locale = getLocaleFromLanguage(lang);
const translations = getTranslations(locale);
return translations[id] || defaultMessage;
return translations[id] || defaultMessage || '';
}
export function t(v: string): string {

View File

@@ -140,6 +140,15 @@ export const queryMyChannel = async (database: Database, channelId: string) => {
}
};
export const queryChannelById = async (database: Database, channelId: string) => {
try {
const channel = await database.get<ChannelModel>(CHANNEL).find(channelId);
return channel;
} catch {
return undefined;
}
};
export const queryChannelByName = async (database: Database, channelName: string) => {
try {
const channels = await database.get(CHANNEL).query(Q.where('name', channelName)).fetch() as ChannelModel[];

View File

@@ -57,7 +57,7 @@ export const prepareModels = async ({operator, initialTeamId, removeTeams, remov
}
if (prefData?.preferences?.length) {
const prefModel = prepareMyPreferences(operator, prefData.preferences);
const prefModel = prepareMyPreferences(operator, prefData.preferences, true);
if (prefModel) {
modelPromises.push(prefModel);
}

View File

@@ -11,11 +11,12 @@ import {queryCurrentTeamId} from './system';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type PreferenceModel from '@typings/database/models/servers/preference';
export const prepareMyPreferences = (operator: ServerDataOperator, preferences: PreferenceType[]) => {
export const prepareMyPreferences = (operator: ServerDataOperator, preferences: PreferenceType[], sync = false) => {
try {
return operator.handlePreferences({
prepareRecordsOnly: true,
preferences,
sync,
});
} catch {
return undefined;

View File

@@ -3,25 +3,22 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React, {useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import React, {useEffect, useState} from 'react';
import {SafeAreaView} from 'react-native-safe-area-context';
import {map} from 'rxjs/operators';
import {logout} from '@actions/remote/session';
import {fetchPostsForChannel} from '@actions/remote/post';
import PostList from '@components/post_list';
import ServerVersion from '@components/server_version';
import {Screens, Database} from '@constants';
import {Database} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useAppState} from '@hooks/device';
import {goToScreen} from '@screens/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
import ChannelNavBar from './channel_nav_bar';
import FailedChannels from './failed_channels';
import FailedTeams from './failed_teams';
import Intro from './intro';
import type {WithDatabaseArgs} from '@typings/database/database';
import type SystemModel from '@typings/database/models/servers/system';
@@ -30,7 +27,6 @@ import type {LaunchProps} from '@typings/launch';
type ChannelProps = LaunchProps & {
currentChannelId: string;
currentTeamId: string;
time?: number;
};
const {MM_TABLES, SYSTEM_IDENTIFIERS} = Database;
@@ -51,85 +47,49 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
},
}));
const Channel = ({currentChannelId, currentTeamId, time}: ChannelProps) => {
// TODO: If we have LaunchProps, ensure we load the correct channel/post/modal.
// TODO: If LaunchProps.error is true, use the LaunchProps.launchType to determine which
// error message to display. For example:
// if (props.launchError) {
// let erroMessage;
// if (props.launchType === LaunchType.DeepLink) {
// errorMessage = intl.formatMessage({id: 'mobile.launchError.deepLink', defaultMessage: 'Did not find a server for this deep link'});
// } else if (props.launchType === LaunchType.Notification) {
// errorMessage = intl.formatMessage({id: 'mobile.launchError.notification', defaultMessage: 'Did not find a server for this notification'});
// }
// }
//todo: https://mattermost.atlassian.net/browse/MM-37266
const theme = useTheme();
const intl = useIntl();
const styles = getStyleSheet(theme);
const Channel = ({currentChannelId, currentTeamId}: ChannelProps) => {
const appState = useAppState();
const [loading, setLoading] = useState(false);
const serverUrl = useServerUrl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const doLogout = () => {
logout(serverUrl!);
};
useEffect(() => {
setLoading(true);
fetchPostsForChannel(serverUrl, currentChannelId).then(() => {
setLoading(false);
});
}, [currentChannelId]);
const goToAbout = () => {
const title = intl.formatMessage({id: 'about.title', defaultMessage: 'About {appTitle}'}, {appTitle: 'Mattermost'});
goToScreen(Screens.ABOUT, title);
};
if (!currentTeamId) {
return <FailedTeams/>;
}
const renderComponent = useMemo(() => {
if (!currentTeamId) {
return <FailedTeams/>;
}
if (!currentChannelId) {
return <FailedChannels teamId={currentTeamId}/>;
}
return (
<>
<ChannelNavBar
channelId={currentChannelId}
onPress={() => null}
/>
<PostList
channelId={currentChannelId}
testID='channel.post_list'
forceQueryAfterAppState={appState}
/>
<View style={styles.sectionContainer}>
<Text
onPress={doLogout}
style={styles.sectionTitle}
>
{`Loaded in: ${time || 0}ms. Logout from ${serverUrl}`}
</Text>
</View>
<View style={styles.sectionContainer}>
<Text
onPress={goToAbout}
style={styles.sectionTitle}
>
{'Go to About Screen'}
</Text>
</View>
</>
);
}, [currentTeamId, currentChannelId, theme, appState]);
if (!currentChannelId) {
return <FailedChannels teamId={currentTeamId}/>;
}
return (
<SafeAreaView
style={styles.flex}
mode='margin'
edges={['left', 'right', 'bottom']}
edges={['left', 'right']}
>
<ServerVersion/>
{renderComponent}
<ChannelNavBar
channelId={currentChannelId}
onPress={() => null}
/>
<PostList
channelId={currentChannelId}
footer={(
<Intro
channelId={currentChannelId}
loading={loading}
/>
)}
forceQueryAfterAppState={appState}
testID='channel.post_list'
/>
</SafeAreaView>
);
};

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,86 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {ActivityIndicator, Platform, StyleSheet, View} from 'react-native';
import {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,
overflow: 'hidden',
...Platform.select({
android: {
scaleY: -1,
},
}),
},
});
const Intro = ({channel, loading = false, roles}: Props) => {
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]);
if (loading) {
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

@@ -9,7 +9,6 @@
"about.teamEditionSt": "All your team communication in one place, instantly searchable and accessible anywhere.",
"about.teamEditiont0": "Team Edition",
"about.teamEditiont1": "Enterprise Edition",
"about.title": "About {appTitle}",
"account.logout": "Log out",
"account.logout_from": "Log out of {serverName}",
"account.saved_messages": "Saved Messages",
@@ -109,6 +108,19 @@
"failed_action.fetch_teams": "An error ocurred while loading the teams of this server",
"failed_action.something_wrong": "Something went wrong",
"failed_action.try_again": "Try again",
"intro.add_people": "Add People",
"intro.channel_details": "Details",
"intro.created_by": "created by {creator} on {date}.",
"intro.direct_message": "This is the start of your conversation with {teammate}. Messages and files shared here are not shown to anyone else.",
"intro.favorite": "Favorite",
"intro.group_message": "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.",
"intro.private_channel": "Private Channel",
"intro.public_channel": "Public Channel",
"intro.set_header": "Set Header",
"intro.townsquare": "Welcome to {name}. Everyone automatically becomes a member of this channel when they join the team.",
"intro.welcome": "Welcome to {displayName} channel.",
"intro.welcome.private": "Only invited members can see messages posted in this private channel.",
"intro.welcome.public": "Add some more team members to the channel or start a conversation below.",
"last_users_message.added_to_channel.type": "were **added to the channel** by {actor}.",
"last_users_message.added_to_team.type": "were **added to the team** by {actor}.",
"last_users_message.first": "{firstUser} and ",
@@ -299,6 +311,8 @@
"screen.mentions.subtitle": "Messages you've been mentioned in",
"screen.mentions.title": "Recent Mentions",
"screen.search.placeholder": "Search messages & files",
"screens.channel_details": "Channel Details",
"screens.channel_edit": "Edit Channel",
"search_bar.search": "Search",
"status_dropdown.set_away": "Away",
"status_dropdown.set_dnd": "Do Not Disturb",

View File

@@ -41,6 +41,11 @@ type ChannelWithTeamData = Channel & {
team_name: string;
team_update_at: number;
}
type ChannelMember = {
id?: string;
channel_id: string;
user_id: string;
}
type ChannelMembership = {
id?: string;
channel_id: string;

View File

@@ -222,7 +222,7 @@ export type HandleGroupArgs = PrepareOnly & {
};
export type HandleChannelMembershipArgs = PrepareOnly & {
channelMemberships: ChannelMembership[];
channelMemberships: ChannelMember[];
};
export type HandleGroupMembershipArgs = PrepareOnly & {

View File

@@ -97,6 +97,7 @@ type RawValue =
| AppInfo
| Channel
| ChannelInfo
| ChannelMember
| ChannelMembership
| CustomEmoji
| Draft