[Gekidou] user profile (#6353)

* User profile

* Access User Profile from reaction list

* Fix threads participants list & open user profile on tap

* Extra bottom padding

* Profile long press tutorial

* Adjust heights

* Reduce label margin from 12 to 8
This commit is contained in:
Elias Nahum 2022-06-06 11:27:25 -04:00 committed by GitHub
parent e01d8f46c2
commit 2f07b7afc8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
75 changed files with 1428 additions and 298 deletions

View file

@ -29,3 +29,16 @@ export const storeMultiServerTutorial = async (prepareRecordsOnly = false) => {
prepareRecordsOnly,
});
};
export const storeProfileLongPressTutorial = async (prepareRecordsOnly = false) => {
const operator = DatabaseManager.appDatabase?.operator;
if (!operator) {
return {error: 'No App database found'};
}
return operator.handleGlobal({
globals: [{id: GLOBAL_IDENTIFIERS.PROFILE_LONG_PRESS_TUTORIAL, value: 'true'}],
prepareRecordsOnly,
});
};

View file

@ -5,7 +5,7 @@ import {SYSTEM_IDENTIFIERS} from '@constants/database';
import General from '@constants/general';
import DatabaseManager from '@database/manager';
import {getRecentCustomStatuses} from '@queries/servers/system';
import {getCurrentUser} from '@queries/servers/user';
import {getCurrentUser, getUserById} from '@queries/servers/user';
import {addRecentReaction} from './reactions';
@ -141,3 +141,27 @@ export const updateLocalUser = async (
return {};
};
export const storeProfile = async (serverUrl: string, profile: UserProfile) => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
try {
const {database} = operator;
const user = await getUserById(database, profile.id);
if (user) {
return {user};
}
const records = await operator.handleUsers({
users: [profile],
prepareRecordsOnly: false,
});
return {user: records[0]};
} catch (error) {
return {error};
}
};

View file

@ -797,6 +797,13 @@ export async function createDirectChannel(serverUrl: string, userId: string, dis
if (!currentUser) {
return {error: 'Cannot get the current user'};
}
const channelName = getDirectChannelName(currentUser.id, userId);
const channel = await getChannelByName(database, channelName);
if (channel) {
return {data: channel.toApi()};
}
EphemeralStore.creatingDMorGMTeammates = [userId];
const created = await client.createDirectChannel([userId, currentUser.id]);
const profiles: UserProfile[] = [];

View file

@ -822,3 +822,43 @@ export const autoUpdateTimezone = async (serverUrl: string, {deviceTimezone, use
}
return null;
};
export const fetchTeamAndChannelMembership = async (serverUrl: string, userId: string, teamId: string, channelId?: string) => {
const operator = DatabaseManager.serverDatabases[serverUrl].operator;
if (!operator) {
return {error: `No database present for ${serverUrl}`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const requests = await Promise.all([
client.getTeamMember(teamId, userId),
channelId ? client.getChannelMember(channelId, userId) : undefined,
]);
const modelPromises: Array<Promise<Model[]>> = [];
modelPromises.push(operator.handleTeamMemberships({
teamMemberships: [requests[0]],
prepareRecordsOnly: true,
}));
const channelMemberships = requests[1];
if (channelMemberships) {
modelPromises.push(operator.handleChannelMembership({
channelMemberships: [channelMemberships],
prepareRecordsOnly: true,
}));
}
const models = await Promise.all(modelPromises);
await operator.batchRecords(models.flat());
return {error: undefined};
} catch (error) {
return {error};
}
};

View file

@ -159,6 +159,11 @@ export async function handleChannelMemberUpdatedEvent(serverUrl: string, msg: an
settings: [updatedChannelMember],
prepareRecordsOnly: true,
}));
models.push(...await operator.handleChannelMembership({
channelMemberships: [updatedChannelMember],
prepareRecordsOnly: true,
}));
const rolesRequest = await fetchRolesIfNeeded(serverUrl, updatedChannelMember.roles.split(','), true);
if (rolesRequest.roles?.length) {
models.push(...await operator.handleRole({roles: rolesRequest.roles, prepareRecordsOnly: true}));

View file

@ -111,6 +111,13 @@ export async function handleTeamMemberRoleUpdatedEvent(serverUrl: string, msg: W
models.push(...myTeamRecords);
// update TeamMembership table
const teamMembership = await operator.handleTeamMemberships({
teamMemberships: [member],
prepareRecordsOnly: true,
});
models.push(...teamMembership);
await operator.batchRecords(models);
} catch {
// do nothing

View file

@ -58,7 +58,7 @@ const enhance = withObservables(['channel', 'showTeamName'], ({channel, database
let membersCount = of$(0);
if (channel.type === General.GM_CHANNEL) {
membersCount = channel.members.observeCount();
membersCount = channel.members.observeCount(false);
}
const isUnread = myChannel.pipe(

View file

@ -17,8 +17,10 @@ import type {PrimitiveType} from 'intl-messageformat';
type Props = {
baseTextStyle: StyleProp<TextStyle>;
channelId?: string;
defaultMessage: string;
id: string;
location: string;
onPostPress?: (e: GestureResponderEvent) => void;
style?: StyleProp<TextStyle>;
textStyles: {
@ -47,7 +49,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const FormattedMarkdownText = ({baseTextStyle, defaultMessage, id, onPostPress, style, textStyles, values}: Props) => {
const FormattedMarkdownText = ({baseTextStyle, channelId, defaultMessage, id, location, onPostPress, style, textStyles, values}: Props) => {
const intl = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
@ -84,8 +86,10 @@ const FormattedMarkdownText = ({baseTextStyle, defaultMessage, id, onPostPress,
const renderAtMention = ({context, mentionName}: {context: string[]; mentionName: string}) => {
return (
<AtMention
channelId={channelId}
mentionStyle={textStyles.mention}
mentionName={mentionName}
location={location}
onPostPress={onPostPress}
textStyle={[computeTextStyle(baseTextStyle, context), styles.atMentionOpacity]}
/>

View file

@ -6,25 +6,27 @@ import {Database} from '@nozbe/watermelondb';
import Clipboard from '@react-native-community/clipboard';
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {GestureResponderEvent, StyleProp, StyleSheet, Text, TextStyle, View} from 'react-native';
import {GestureResponderEvent, Keyboard, StyleProp, StyleSheet, Text, TextStyle, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import {Screens} from '@constants';
import {MM_TABLES} from '@constants/database';
import {useTheme} from '@context/theme';
import UserModel from '@database/models/server/user';
import {bottomSheet, dismissBottomSheet, showModal} from '@screens/navigation';
import {bottomSheet, dismissBottomSheet, openAsBottomSheet} from '@screens/navigation';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {displayUsername, getUsersByUsername} from '@utils/user';
import type UserModelType from '@typings/database/models/servers/user';
type AtMentionProps = {
channelId?: string;
currentUserId: string;
database: Database;
disableAtChannelMentionHighlight?: boolean;
isSearchResult?: boolean;
location: string;
mentionKeys?: Array<{key: string }>;
mentionName: string;
mentionStyle: TextStyle;
@ -41,10 +43,12 @@ const style = StyleSheet.create({
});
const AtMention = ({
channelId,
currentUserId,
database,
disableAtChannelMentionHighlight,
isSearchResult,
location,
mentionName,
mentionKeys,
mentionStyle,
@ -89,26 +93,14 @@ const AtMention = ({
return user.mentionKeys;
}, [currentUserId, mentionKeys, user]);
const goToUserProfile = () => {
const screen = 'UserProfile';
const openUserProfile = () => {
const screen = Screens.USER_PROFILE;
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
const passProps = {
userId: user.id,
};
const closeButtonId = 'close-user-profile';
const props = {closeButtonId, location, userId: user.id, channelId};
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
const options = {
topBar: {
leftButtons: [{
id: 'close-settings',
icon: closeButton,
testID: 'close.settings.button',
}],
},
};
showModal(screen, title, passProps, options);
Keyboard.dismiss();
openAsBottomSheet({screen, title, theme, closeButtonId, props});
};
const handleLongPress = useCallback(() => {
@ -196,7 +188,7 @@ const AtMention = ({
if (canPress) {
onLongPress = handleLongPress;
onPress = (isSearchResult ? onPostPress : goToUserProfile);
onPress = (isSearchResult ? onPostPress : openUserProfile);
}
if (suffix) {

View file

@ -38,6 +38,7 @@ type MarkdownProps = {
autolinkedUrlSchemes?: string[];
baseTextStyle: StyleProp<TextStyle>;
blockStyles?: MarkdownBlockStyles;
channelId?: string;
channelMentions?: ChannelMentions;
disableAtChannelMentionHighlight?: boolean;
disableAtMentions?: boolean;
@ -57,7 +58,7 @@ type MarkdownProps = {
isSearchResult?: boolean;
layoutHeight?: number;
layoutWidth?: number;
location?: string;
location: string;
mentionKeys?: UserMentionKey[];
minimumHashtagLength?: number;
onPostPress?: (event: GestureResponderEvent) => void;
@ -124,7 +125,7 @@ const computeTextStyle = (textStyles: MarkdownTextStyles, baseStyle: StyleProp<T
};
const Markdown = ({
autolinkedUrlSchemes, baseTextStyle, blockStyles, channelMentions,
autolinkedUrlSchemes, baseTextStyle, blockStyles, channelId, channelMentions,
disableAtChannelMentionHighlight, disableAtMentions, disableBlockQuote, disableChannelLink,
disableCodeBlock, disableGallery, disableHashtags, disableHeading, disableTables,
enableInlineLatex, enableLatex,
@ -146,10 +147,12 @@ const Markdown = ({
return (
<AtMention
channelId={channelId}
disableAtChannelMentionHighlight={disableAtChannelMentionHighlight}
mentionStyle={textStyles.mention}
textStyle={[computeTextStyle(textStyles, baseTextStyle, context), style.atMentionOpacity]}
isSearchResult={isSearchResult}
location={location}
mentionName={mentionName}
onPostPress={onPostPress}
mentionKeys={mentionKeys}

View file

@ -90,6 +90,7 @@ export default function Archived({
style={style.archivedText}
baseTextStyle={style.baseTextStyle}
textStyles={style.textStyles}
location=''
/>
<Button
containerStyle={style.closeButton}

View file

@ -6,14 +6,14 @@ import PasteableTextInput, {PastedFile, PasteInputRef} from '@mattermost/react-n
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {IntlShape, useIntl} from 'react-intl';
import {
Alert, AppState, AppStateStatus, EmitterSubscription, Keyboard,
Alert, AppState, AppStateStatus, DeviceEventEmitter, EmitterSubscription, Keyboard,
KeyboardTypeOptions, NativeSyntheticEvent, Platform, TextInputSelectionChangeEventData,
} from 'react-native';
import HWKeyboardEvent from 'react-native-hw-keyboard-event';
import {updateDraftMessage} from '@actions/local/draft';
import {userTyping} from '@actions/websocket/users';
import {Screens} from '@constants';
import {Events, Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
@ -266,6 +266,19 @@ export default function PostInput({
};
}, [onAppStateChange]);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.SEND_TO_POST_DRAFT, ({text, location}: {text: string; location: string}) => {
const sourceScreen = channelId && rootId ? Screens.THREAD : Screens.CHANNEL;
if (location === sourceScreen) {
const draft = value ? `${value} ${text} ` : `${text} `;
updateValue(draft);
updateCursorPosition(draft.length);
input.current?.focus();
}
});
return () => listener.remove();
}, [updateValue, value, channelId, rootId]);
useEffect(() => {
if (value !== lastNativeValue.current) {
// May change when we implement Fabric

View file

@ -24,6 +24,7 @@ type Props = {
canDelete: boolean;
currentUserId?: string;
currentUsername?: string;
location: string;
post: Post;
showJoinLeave: boolean;
testID?: string;
@ -58,7 +59,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
});
const CombinedUserActivity = ({
canDelete, currentUserId, currentUsername,
canDelete, currentUserId, currentUsername, location,
post, showJoinLeave, testID, theme, usernamesById = {}, style,
}: Props) => {
const intl = useIntl();
@ -135,7 +136,9 @@ const CombinedUserActivity = ({
return (
<LastUsers
key={postType + actorId}
channelId={post.channel_id}
actor={actor}
location={location}
postType={postType}
theme={theme}
usernames={usernames}
@ -162,8 +165,10 @@ const CombinedUserActivity = ({
const formattedMessage = intl.formatMessage(localeHolder, {firstUser, secondUser, actor});
return (
<Markdown
channelId={post.channel_id}
key={postType + actorId}
baseTextStyle={styles.baseText}
location={location}
textStyles={textStyles}
value={formattedMessage}
theme={theme}

View file

@ -15,6 +15,8 @@ import {postTypeMessages, systemMessages} from './messages';
type LastUsersProps = {
actor: string;
channelId?: string;
location: string;
postType: string;
usernames: string[];
theme: Theme;
@ -35,7 +37,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const LastUsers = ({actor, postType, theme, usernames}: LastUsersProps) => {
const LastUsers = ({actor, channelId, location, postType, theme, usernames}: LastUsersProps) => {
const [expanded, setExpanded] = useState(false);
const intl = useIntl();
const style = getStyleSheet(theme);
@ -58,6 +60,8 @@ const LastUsers = ({actor, postType, theme, usernames}: LastUsersProps) => {
return (
<Markdown
baseTextStyle={style.baseText}
channelId={channelId}
location={location}
textStyles={textStyles}
value={formattedMessage}
theme={theme}
@ -71,8 +75,10 @@ const LastUsers = ({actor, postType, theme, usernames}: LastUsersProps) => {
return (
<Text>
<FormattedMarkdownText
channelId={channelId}
id={'last_users_message.first'}
defaultMessage={'{firstUser} and '}
location={location}
values={{firstUser}}
baseTextStyle={style.baseText}
style={style.baseText}
@ -90,8 +96,10 @@ const LastUsers = ({actor, postType, theme, usernames}: LastUsersProps) => {
/>
</Text>
<FormattedMarkdownText
channelId={channelId}
id={systemMessages[postType].id}
defaultMessage={systemMessages[postType].defaultMessage}
location={location}
values={{actor}}
baseTextStyle={style.baseText}
style={style.baseText}

View file

@ -1,29 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {ReactNode, useRef} from 'react';
import React, {ReactNode} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Platform, StyleSheet, TouchableOpacity, View} from 'react-native';
import FastImage from 'react-native-fast-image';
import CompassIcon from '@components/compass_icon';
import ProfilePicture from '@components/profile_picture';
import {View as ViewConstant} from '@constants';
import {Screens, View as ViewConstant} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import NetworkManager from '@managers/network_manager';
import {showModal} from '@screens/navigation';
import {openAsBottomSheet} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import type {Client} from '@client/rest';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
import type {ImageSource} from 'react-native-vector-icons/Icon';
type AvatarProps = {
author: UserModel;
enablePostIconOverride?: boolean;
isAutoReponse: boolean;
location: string;
post: PostModel;
}
@ -33,8 +33,7 @@ const style = StyleSheet.create({
},
});
const Avatar = ({author, enablePostIconOverride, isAutoReponse, post}: AvatarProps) => {
const closeButton = useRef<ImageSource>();
const Avatar = ({author, enablePostIconOverride, isAutoReponse, location, post}: AvatarProps) => {
const intl = useIntl();
const theme = useTheme();
const serverUrl = useServerUrl();
@ -91,27 +90,21 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, post}: AvatarPro
);
}
const onViewUserProfile = preventDoubleTap(() => {
const screen = 'UserProfile';
const openUserProfile = preventDoubleTap(() => {
const screen = Screens.USER_PROFILE;
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
const passProps = {author};
if (!closeButton.current) {
closeButton.current = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
}
const options = {
topBar: {
leftButtons: [{
id: 'close-settings',
icon: closeButton.current,
testID: 'close.settings.button',
}],
},
const closeButtonId = 'close-user-profile';
const props = {
closeButtonId,
userId: author.id,
channelId: post.channelId,
location,
userIconOverride: post.props?.override_username,
usernameOverride: post.props?.override_icon_url,
};
Keyboard.dismiss();
showModal(screen, title, passProps, options);
openAsBottomSheet({screen, title, theme, closeButtonId, props});
});
let component = (
@ -126,7 +119,7 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, post}: AvatarPro
if (!fromWebHook) {
component = (
<TouchableOpacity onPress={onViewUserProfile}>
<TouchableOpacity onPress={openUserProfile}>
{component}
</TouchableOpacity>
);

View file

@ -21,6 +21,7 @@ import type UserModel from '@typings/database/models/servers/user';
type AddMembersProps = {
channelType: string | null;
currentUser: UserModel;
location: string;
post: PostModel;
theme: Theme;
}
@ -35,7 +36,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const AddMembers = ({channelType, currentUser, post, theme}: AddMembersProps) => {
const AddMembers = ({channelType, currentUser, location, post, theme}: AddMembersProps) => {
const intl = useIntl();
const styles = getStyleSheet(theme);
const textStyles = getMarkdownTextStyles(theme);
@ -83,6 +84,8 @@ const AddMembers = ({channelType, currentUser, post, theme}: AddMembersProps) =>
if (names.length === 1) {
return (
<AtMention
channelId={post.channelId}
location={location}
mentionName={names[0]}
mentionStyle={textStyles.mention}
/>
@ -110,6 +113,8 @@ const AddMembers = ({channelType, currentUser, post, theme}: AddMembersProps) =>
return (
<AtMention
key={username}
channelId={post.channelId}
location={location}
mentionStyle={textStyles.mention}
mentionName={username}
/>

View file

@ -12,6 +12,8 @@ import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
channelId: string;
location: string;
theme: Theme;
value: string;
}
@ -26,7 +28,7 @@ const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
},
}));
const EmbedText = ({theme, value}: Props) => {
const EmbedText = ({channelId, location, theme, value}: Props) => {
const [open, setOpen] = useState(false);
const [height, setHeight] = useState<number|undefined>();
const dimensions = useWindowDimensions();
@ -51,6 +53,8 @@ const EmbedText = ({theme, value}: Props) => {
<View onLayout={onLayout}>
<Markdown
baseTextStyle={style.message}
channelId={channelId}
location={location}
textStyles={textStyles}
blockStyles={blockStyles}
disableGallery={true}

View file

@ -8,6 +8,8 @@ import Markdown from '@components/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
channelId: string;
location: string;
theme: Theme;
value: string;
}
@ -32,16 +34,18 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const EmbedTitle = ({theme, value}: Props) => {
const EmbedTitle = ({channelId, location, theme, value}: Props) => {
const style = getStyleSheet(theme);
return (
<View style={style.container}>
<Markdown
channelId={channelId}
disableHashtags={true}
disableAtMentions={true}
disableChannelLink={true}
disableGallery={true}
location={location}
autolinkedUrlSchemes={[]}
mentionKeys={[]}
theme={theme}

View file

@ -16,6 +16,7 @@ import type PostModel from '@typings/database/models/servers/post';
type Props = {
embed: AppBinding;
location: string;
post: PostModel;
theme: Theme;
}
@ -37,7 +38,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const EmbeddedBinding = ({embed, post, theme}: Props) => {
const EmbeddedBinding = ({embed, location, post, theme}: Props) => {
const style = getStyleSheet(theme);
const [cleanedBindings, setCleanedBindings] = useState<AppBinding[]>([]);
@ -52,12 +53,16 @@ const EmbeddedBinding = ({embed, post, theme}: Props) => {
<View style={style.container}>
{Boolean(embed.label) &&
<EmbedTitle
channelId={post.channelId}
location={location}
theme={theme}
value={embed.label}
/>
}
{Boolean(embed.description) &&
<EmbedText
channelId={post.channelId}
location={location}
value={embed.description!}
theme={theme}
/>

View file

@ -9,11 +9,12 @@ import EmbeddedBinding from './embedded_binding';
import type PostModel from '@typings/database/models/servers/post';
type Props = {
location: string;
post: PostModel;
theme: Theme;
}
const EmbeddedBindings = ({post, theme}: Props) => {
const EmbeddedBindings = ({location, post, theme}: Props) => {
const content: React.ReactNode[] = [];
const embeds: AppBinding[] = post.props.app_bindings;
@ -21,6 +22,7 @@ const EmbeddedBindings = ({post, theme}: Props) => {
content.push(
<EmbeddedBinding
embed={embed}
location={location}
key={'binding_' + i.toString()}
post={post}
theme={theme}

View file

@ -78,6 +78,7 @@ const Content = ({isReplyPost, layoutWidth, location, post, theme}: ContentProps
return (
<MessageAttachments
attachments={post.props.attachments}
channelId={post.channelId}
layoutWidth={layoutWidth}
location={location}
metadata={post.metadata!}
@ -91,6 +92,7 @@ const Content = ({isReplyPost, layoutWidth, location, post, theme}: ContentProps
if (post.props.app_bindings?.length) {
return (
<EmbeddedBindings
location={location}
post={post}
theme={theme}
/>

View file

@ -12,7 +12,9 @@ import type {MarkdownBlockStyles, MarkdownTextStyles} from '@typings/global/mark
type Props = {
baseTextStyle: StyleProp<TextStyle>;
blockStyles?: MarkdownBlockStyles;
channelId: string;
fields: MessageAttachmentField[];
location: string;
metadata?: PostMetadata;
textStyles?: MarkdownTextStyles;
theme: Theme;
@ -44,7 +46,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const AttachmentFields = ({baseTextStyle, blockStyles, fields, metadata, textStyles, theme}: Props) => {
const AttachmentFields = ({baseTextStyle, blockStyles, channelId, fields, location, metadata, textStyles, theme}: Props) => {
const style = getStyleSheet(theme);
const fieldTables = [];
@ -92,10 +94,12 @@ const AttachmentFields = ({baseTextStyle, blockStyles, fields, metadata, textSty
>
<Markdown
baseTextStyle={baseTextStyle}
channelId={channelId}
textStyles={textStyles}
blockStyles={blockStyles}
disableGallery={true}
imagesMetadata={metadata?.images}
location={location}
theme={theme}
value={(field.value || '')}
/>

View file

@ -11,6 +11,8 @@ import type {MarkdownBlockStyles, MarkdownTextStyles} from '@typings/global/mark
type Props = {
baseTextStyle: StyleProp<TextStyle>;
blockStyles?: MarkdownBlockStyles;
channelId: string;
location: string;
metadata?: PostMetadata;
textStyles?: MarkdownTextStyles;
theme: Theme;
@ -40,10 +42,12 @@ export default function AttachmentPreText(props: Props) {
<View style={style.container}>
<Markdown
baseTextStyle={baseTextStyle}
channelId={props.channelId}
textStyles={textStyles}
blockStyles={blockStyles}
disableGallery={true}
imagesMetadata={metadata?.images}
location={props.location}
theme={props.theme}
value={value}
/>

View file

@ -14,7 +14,9 @@ import type {MarkdownBlockStyles, MarkdownTextStyles} from '@typings/global/mark
type Props = {
baseTextStyle: StyleProp<TextStyle>;
blockStyles?: MarkdownBlockStyles;
channelId: string;
hasThumbnail?: boolean;
location: string;
metadata?: PostMetadata;
textStyles?: MarkdownTextStyles;
theme: Theme;
@ -28,7 +30,7 @@ const style = StyleSheet.create({
},
});
const AttachmentText = ({baseTextStyle, blockStyles, hasThumbnail, metadata, textStyles, theme, value}: Props) => {
const AttachmentText = ({baseTextStyle, blockStyles, channelId, hasThumbnail, location, metadata, textStyles, theme, value}: Props) => {
const [open, setOpen] = useState(false);
const [height, setHeight] = useState<number|undefined>();
const dimensions = useWindowDimensions();
@ -49,6 +51,8 @@ const AttachmentText = ({baseTextStyle, blockStyles, hasThumbnail, metadata, tex
>
<View onLayout={onLayout}>
<Markdown
channelId={channelId}
location={location}
baseTextStyle={baseTextStyle}
textStyles={textStyles}
blockStyles={blockStyles}

View file

@ -10,7 +10,9 @@ import {makeStyleSheetFromTheme} from '@utils/theme';
import {tryOpenURL} from '@utils/url';
type Props = {
channelId: string;
link?: string;
location: string;
theme: Theme;
value?: string;
}
@ -33,7 +35,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const AttachmentTitle = ({link, theme, value}: Props) => {
const AttachmentTitle = ({channelId, link, location, theme, value}: Props) => {
const intl = useIntl();
const style = getStyleSheet(theme);
@ -69,6 +71,8 @@ const AttachmentTitle = ({link, theme, value}: Props) => {
} else {
title = (
<Markdown
channelId={channelId}
location={location}
isEdited={false}
isReplyPost={false}
disableHashtags={true}

View file

@ -8,6 +8,7 @@ import MessageAttachment from './message_attachment';
type Props = {
attachments: MessageAttachment[];
channelId: string;
layoutWidth?: number;
location: string;
metadata?: PostMetadata;
@ -22,13 +23,14 @@ const styles = StyleSheet.create({
},
});
const MessageAttachments = ({attachments, layoutWidth, location, metadata, postId, theme}: Props) => {
const MessageAttachments = ({attachments, channelId, layoutWidth, location, metadata, postId, theme}: Props) => {
const content: React.ReactNode[] = [];
attachments.forEach((attachment, i) => {
content.push(
<MessageAttachment
attachment={attachment}
channelId={channelId}
key={'att_' + i.toString()}
layoutWidth={layoutWidth}
location={location}

View file

@ -21,6 +21,7 @@ import AttachmentTitle from './attachment_title';
type Props = {
attachment: MessageAttachment;
channelId: string;
layoutWidth?: number;
location: string;
metadata?: PostMetadata;
@ -52,7 +53,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
export default function MessageAttachment({attachment, layoutWidth, location, metadata, postId, theme}: Props) {
export default function MessageAttachment({attachment, channelId, layoutWidth, location, metadata, postId, theme}: Props) {
const style = getStyleSheet(theme);
const blockStyles = getMarkdownBlockStyles(theme);
const textStyles = getMarkdownTextStyles(theme);
@ -71,6 +72,8 @@ export default function MessageAttachment({attachment, layoutWidth, location, me
<AttachmentPreText
baseTextStyle={style.message}
blockStyles={blockStyles}
channelId={channelId}
location={location}
metadata={metadata}
textStyles={textStyles}
theme={theme}
@ -87,6 +90,8 @@ export default function MessageAttachment({attachment, layoutWidth, location, me
}
{Boolean(attachment.title) &&
<AttachmentTitle
channelId={channelId}
location={location}
link={attachment.title_link}
theme={theme}
value={attachment.title}
@ -99,6 +104,8 @@ export default function MessageAttachment({attachment, layoutWidth, location, me
<AttachmentText
baseTextStyle={style.message}
blockStyles={blockStyles}
channelId={channelId}
location={location}
hasThumbnail={Boolean(attachment.thumb_url)}
metadata={metadata}
textStyles={textStyles}
@ -110,6 +117,8 @@ export default function MessageAttachment({attachment, layoutWidth, location, me
<AttachmentFields
baseTextStyle={style.message}
blockStyles={blockStyles}
channelId={channelId}
location={location}
fields={attachment.fields}
metadata={metadata}
textStyles={textStyles}

View file

@ -127,6 +127,7 @@ const Body = ({
} else if (isPostAddChannelMember) {
message = (
<AddMembers
location={location}
post={post}
theme={theme}
/>
@ -180,6 +181,7 @@ const Body = ({
}
{hasReactions && showAddReaction &&
<Reactions
location={location}
post={post}
theme={theme}
/>

View file

@ -85,6 +85,7 @@ const Message = ({currentUser, highlight, isEdited, isPendingOrFailed, isReplyPo
<Markdown
baseTextStyle={style.message}
blockStyles={blockStyles}
channelId={post.channelId}
channelMentions={post.props?.channel_mentions}
imagesMetadata={post.metadata?.images}
isEdited={isEdited}

View file

@ -26,6 +26,7 @@ type ReactionsProps = {
canRemoveReaction: boolean;
disabled: boolean;
currentUserId: string;
location: string;
postId: string;
reactions: ReactionModel[];
theme: Theme;
@ -59,7 +60,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled, postId, reactions, theme}: ReactionsProps) => {
const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled, location, postId, reactions, theme}: ReactionsProps) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const isTablet = useIsTablet();
@ -137,6 +138,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
const screen = Screens.REACTIONS;
const passProps = {
initialEmoji,
location,
postId,
};
@ -150,7 +152,7 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled,
showModalOverCurrentContext(screen, passProps, bottomSheetModalOptions(theme));
}
}
}, [intl, isTablet, postId, theme]);
}, [intl, isTablet, location, postId, theme]);
let addMoreReactions = null;
const {reactionsByName, highlightedReactions} = buildReactionsMap();

View file

@ -18,6 +18,8 @@ import type ThreadModel from '@typings/database/models/servers/thread';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
channelId: string;
location: string;
participants: UserModel[];
teamId?: string;
thread: ThreadModel;
@ -76,7 +78,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const Footer = ({participants, teamId, thread}: Props) => {
const Footer = ({channelId, location, participants, teamId, thread}: Props) => {
const serverUrl = useServerUrl();
const theme = useTheme();
const styles = getStyleSheet(theme);
@ -156,6 +158,8 @@ const Footer = ({participants, teamId, thread}: Props) => {
if (participantsList.length) {
userAvatarsStack = (
<UserAvatarsStack
channelId={channelId}
location={location}
style={styles.avatarsContainer}
users={participantsList}
/>

View file

@ -1,27 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useRef} from 'react';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Text, TouchableOpacity, useWindowDimensions, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {showModal} from '@screens/navigation';
import {Screens} from '@constants';
import {openAsBottomSheet} 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';
type HeaderDisplayNameProps = {
channelId: string;
commentCount: number;
displayName?: string;
isAutomation: boolean;
location: string;
rootPostAuthor?: string;
shouldRenderReplyButton?: boolean;
theme: Theme;
userIconOverride?: string;
userId: string;
usernameOverride?: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@ -49,47 +50,35 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
});
const HeaderDisplayName = ({
commentCount, displayName, isAutomation, rootPostAuthor, shouldRenderReplyButton, theme, userId,
channelId, commentCount, displayName,
location, rootPostAuthor,
shouldRenderReplyButton, theme,
userIconOverride, userId, usernameOverride,
}: HeaderDisplayNameProps) => {
const closeButton = useRef<ImageSource>();
const dimensions = useWindowDimensions();
const intl = useIntl();
const style = getStyleSheet(theme);
const onPress = useCallback(preventDoubleTap(() => {
const screen = 'UserProfile';
const screen = Screens.USER_PROFILE;
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
const passProps = {userId};
if (!closeButton.current) {
closeButton.current = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
}
const options = {
topBar: {
leftButtons: [{
id: 'close-user-profile',
icon: closeButton.current,
testID: 'close.user_profile.button',
}],
},
};
const closeButtonId = 'close-user-profile';
const props = {closeButtonId, userId, channelId, location, userIconOverride, usernameOverride};
Keyboard.dismiss();
showModal(screen, title, passProps, options);
}), []);
openAsBottomSheet({screen, title, theme, closeButtonId, props});
}), [intl.locale, channelId, userIconOverride, userId, usernameOverride, theme]);
const calcNameWidth = () => {
const isLandscape = dimensions.width > dimensions.height;
const showReply = shouldRenderReplyButton || (!rootPostAuthor && commentCount > 0);
const reduceWidth = showReply && isAutomation;
if (reduceWidth && isLandscape) {
if (showReply && isLandscape) {
return style.displayNameContainerLandscapeBotReplyWidth;
} else if (isLandscape) {
return style.displayNameContainerLandscape;
} else if (reduceWidth) {
} else if (showReply) {
return style.displayNameContainerBotReplyWidth;
}
return undefined;
@ -98,20 +87,7 @@ const HeaderDisplayName = ({
const displayNameWidth = calcNameWidth();
const displayNameStyle = [style.displayNameContainer, displayNameWidth];
if (isAutomation) {
return (
<View style={displayNameStyle}>
<Text
style={style.displayName}
ellipsizeMode={'tail'}
numberOfLines={1}
testID='post_header.display_name'
>
{displayName}
</Text>
</View>
);
} else if (displayName) {
if (displayName) {
return (
<View style={displayNameStyle}>
<TouchableOpacity onPress={onPress}>

View file

@ -28,7 +28,6 @@ type HeaderProps = {
enablePostUsernameOverride: boolean;
isAutoResponse: boolean;
isCRTEnabled?: boolean;
isCustomStatusEnabled: boolean;
isEphemeral: boolean;
isMilitaryTime: boolean;
isPendingOrFailed: boolean;
@ -72,7 +71,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const Header = (props: HeaderProps) => {
const {
author, commentCount = 0, currentUser, enablePostUsernameOverride, isAutoResponse, isCRTEnabled, isCustomStatusEnabled,
author, commentCount = 0, currentUser, enablePostUsernameOverride, isAutoResponse, isCRTEnabled,
isEphemeral, isMilitaryTime, isPendingOrFailed, isSystemPost, isTimezoneEnabled, isWebHook,
location, post, rootPostAuthor, shouldRenderReplyButton, teammateNameDisplay,
} = props;
@ -86,9 +85,8 @@ const Header = (props: HeaderProps) => {
const customStatus = getUserCustomStatus(author);
const customStatusExpired = isCustomStatusExpired(author);
const showCustomStatusEmoji = Boolean(
isCustomStatusEnabled && displayName &&
!(isSystemPost || author.isBot || isAutoResponse || isWebHook) &&
customStatus,
displayName && customStatus &&
!(isSystemPost || author.isBot || isAutoResponse || isWebHook),
);
return (
@ -96,13 +94,16 @@ const Header = (props: HeaderProps) => {
<View style={[style.container, pendingPostStyle]}>
<View style={style.wrapper}>
<HeaderDisplayName
channelId={post.channelId}
commentCount={commentCount}
displayName={displayName}
isAutomation={author.isBot || isAutoResponse || isWebHook}
location={location}
rootPostAuthor={rootAuthorDisplayName}
shouldRenderReplyButton={shouldRenderReplyButton}
theme={theme}
userIconOverride={post.props?.override_icon_url}
userId={post.userId}
usernameOverride={post.props?.override_username}
/>
{showCustomStatusEmoji && !customStatusExpired && Boolean(customStatus?.emoji) && (
<CustomStatusEmoji

View file

@ -3,15 +3,15 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {of as of$} from 'rxjs';
import {map, switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {getPreferenceAsBool, getTeammateNameDisplaySetting} from '@helpers/api/preference';
import {getPreferenceAsBool} from '@helpers/api/preference';
import {queryPostReplies} from '@queries/servers/post';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {observeConfig, observeLicense} from '@queries/servers/system';
import {isMinimumServerVersion} from '@utils/helpers';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {observeTeammateNameDisplay} from '@queries/servers/user';
import Header from './header';
@ -26,18 +26,13 @@ type HeaderInputProps = {
const withHeaderProps = withObservables(
['post', 'differentThreadSequence'],
({post, database, differentThreadSequence}: WithDatabaseArgs & HeaderInputProps) => {
const config = observeConfig(database);
const license = observeLicense(database);
const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).
observeWithColumns(['value']);
const author = post.author.observe();
const enablePostUsernameOverride = config.pipe(map((cfg) => cfg?.EnablePostUsernameOverride === 'true'));
const isTimezoneEnabled = config.pipe(map((cfg) => cfg?.ExperimentalTimezone === 'true'));
const enablePostUsernameOverride = observeConfigBooleanValue(database, 'EnablePostUsernameOverride');
const isTimezoneEnabled = observeConfigBooleanValue(database, 'ExperimentalTimezone');
const isMilitaryTime = preferences.pipe(map((prefs) => getPreferenceAsBool(prefs, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false)));
const isCustomStatusEnabled = config.pipe(map((cfg) => cfg?.EnableCustomUserStatuses === 'true' && isMinimumServerVersion(cfg.Version, 5, 36)));
const teammateNameDisplay = combineLatest([preferences, config, license]).pipe(
map(([prefs, cfg, lcs]) => getTeammateNameDisplaySetting(prefs, cfg, lcs)),
);
const teammateNameDisplay = observeTeammateNameDisplay(database);
const commentCount = queryPostReplies(database, post.rootId || post.id).observeCount();
const rootPostAuthor = differentThreadSequence ? post.root.observe().pipe(switchMap((root) => {
if (root.length) {
@ -51,7 +46,6 @@ const withHeaderProps = withObservables(
author,
commentCount,
enablePostUsernameOverride,
isCustomStatusEnabled,
isMilitaryTime,
isTimezoneEnabled,
rootPostAuthor,

View file

@ -230,6 +230,7 @@ const Post = ({
) : (
<Avatar
isAutoReponse={isAutoResponder}
location={location}
post={post}
/>
)}
@ -266,6 +267,7 @@ const Post = ({
if (isSystemPost && !isEphemeral && !isAutoResponder) {
body = (
<SystemMessage
location={location}
post={post}
/>
);
@ -297,7 +299,11 @@ const Post = ({
if (isCRTEnabled && thread) {
if (thread.replyCount > 0 || thread.isFollowing) {
footer = (
<Footer thread={thread}/>
<Footer
channelId={post.channelId}
location={location}
thread={thread}
/>
);
}
if (thread.unreadMentions || thread.unreadReplies) {

View file

@ -19,6 +19,7 @@ import type {PrimitiveType} from 'intl-messageformat';
type SystemMessageProps = {
author?: UserModel;
location: string;
post: PostModel;
}
@ -64,7 +65,7 @@ const renderUsername = (value = '') => {
return value;
};
const renderMessage = ({styles, intl, localeHolder, theme, values, skipMarkdown = false}: RenderMessageProps) => {
const renderMessage = ({location, post, styles, intl, localeHolder, theme, values, skipMarkdown = false}: RenderMessageProps) => {
const {containerStyle, messageStyle, textStyles} = styles;
if (skipMarkdown) {
@ -79,7 +80,9 @@ const renderMessage = ({styles, intl, localeHolder, theme, values, skipMarkdown
<View style={containerStyle}>
<Markdown
baseTextStyle={messageStyle}
channelId={post.channelId}
disableGallery={true}
location={location}
textStyles={textStyles}
value={intl.formatMessage(localeHolder, values)}
theme={theme}
@ -88,7 +91,7 @@ const renderMessage = ({styles, intl, localeHolder, theme, values, skipMarkdown
);
};
const renderHeaderChangeMessage = ({post, author, styles, intl, theme}: RenderersProps) => {
const renderHeaderChangeMessage = ({post, author, location, styles, intl, theme}: RenderersProps) => {
let values;
if (!author?.username) {
@ -108,7 +111,7 @@ const renderHeaderChangeMessage = ({post, author, styles, intl, theme}: Renderer
};
values = {username, oldHeader, newHeader};
return renderMessage({post, styles, intl, localeHolder, values, theme});
return renderMessage({post, styles, intl, location, localeHolder, values, theme});
}
localeHolder = {
@ -117,7 +120,7 @@ const renderHeaderChangeMessage = ({post, author, styles, intl, theme}: Renderer
};
values = {username, oldHeader, newHeader};
return renderMessage({post, styles, intl, localeHolder, values, theme});
return renderMessage({post, styles, intl, location, localeHolder, values, theme});
} else if (post.props?.old_header) {
localeHolder = {
id: t('mobile.system_message.update_channel_header_message_and_forget.removed'),
@ -125,13 +128,13 @@ const renderHeaderChangeMessage = ({post, author, styles, intl, theme}: Renderer
};
values = {username, oldHeader, newHeader};
return renderMessage({post, styles, intl, localeHolder, values, theme});
return renderMessage({post, styles, intl, location, localeHolder, values, theme});
}
return null;
};
const renderPurposeChangeMessage = ({post, author, styles, intl, theme}: RenderersProps) => {
const renderPurposeChangeMessage = ({post, author, location, styles, intl, theme}: RenderersProps) => {
let values;
if (!author?.username) {
@ -151,7 +154,7 @@ const renderPurposeChangeMessage = ({post, author, styles, intl, theme}: Rendere
};
values = {username, oldPurpose, newPurpose};
return renderMessage({post, styles, intl, localeHolder, values, skipMarkdown: true, theme});
return renderMessage({post, styles, intl, location, localeHolder, values, skipMarkdown: true, theme});
}
localeHolder = {
@ -160,7 +163,7 @@ const renderPurposeChangeMessage = ({post, author, styles, intl, theme}: Rendere
};
values = {username, oldPurpose, newPurpose};
return renderMessage({post, styles, intl, localeHolder, values, skipMarkdown: true, theme});
return renderMessage({post, styles, intl, location, localeHolder, values, skipMarkdown: true, theme});
} else if (post.props?.old_purpose) {
localeHolder = {
id: t('mobile.system_message.update_channel_purpose_message.removed'),
@ -168,13 +171,13 @@ const renderPurposeChangeMessage = ({post, author, styles, intl, theme}: Rendere
};
values = {username, oldPurpose, newPurpose};
return renderMessage({post, styles, intl, localeHolder, values, skipMarkdown: true, theme});
return renderMessage({post, styles, intl, location, localeHolder, values, skipMarkdown: true, theme});
}
return null;
};
const renderDisplayNameChangeMessage = ({post, author, styles, intl, theme}: RenderersProps) => {
const renderDisplayNameChangeMessage = ({post, author, location, styles, intl, theme}: RenderersProps) => {
const oldDisplayName = post.props?.old_displayname;
const newDisplayName = post.props?.new_displayname;
@ -189,10 +192,10 @@ const renderDisplayNameChangeMessage = ({post, author, styles, intl, theme}: Ren
};
const values = {username, oldDisplayName, newDisplayName};
return renderMessage({post, styles, intl, localeHolder, values, theme});
return renderMessage({post, styles, intl, location, localeHolder, values, theme});
};
const renderArchivedMessage = ({post, author, styles, intl, theme}: RenderersProps) => {
const renderArchivedMessage = ({post, author, location, styles, intl, theme}: RenderersProps) => {
const username = renderUsername(author?.username);
const localeHolder = {
id: t('mobile.system_message.channel_archived_message'),
@ -200,10 +203,10 @@ const renderArchivedMessage = ({post, author, styles, intl, theme}: RenderersPro
};
const values = {username};
return renderMessage({post, styles, intl, localeHolder, values, theme});
return renderMessage({post, styles, intl, location, localeHolder, values, theme});
};
const renderUnarchivedMessage = ({post, author, styles, intl, theme}: RenderersProps) => {
const renderUnarchivedMessage = ({post, author, location, styles, intl, theme}: RenderersProps) => {
if (!author?.username) {
return null;
}
@ -215,10 +218,10 @@ const renderUnarchivedMessage = ({post, author, styles, intl, theme}: RenderersP
};
const values = {username};
return renderMessage({post, styles, intl, localeHolder, values, theme});
return renderMessage({post, styles, intl, location, localeHolder, values, theme});
};
const renderAddGuestToChannelMessage = ({post, styles, intl, theme}: RenderersProps) => {
const renderAddGuestToChannelMessage = ({post, location, styles, intl, theme}: RenderersProps) => {
if (!post.props.username || !post.props.addedUsername) {
return null;
}
@ -232,10 +235,10 @@ const renderAddGuestToChannelMessage = ({post, styles, intl, theme}: RenderersPr
};
const values = {username, addedUsername};
return renderMessage({post, styles, intl, localeHolder, values, theme});
return renderMessage({post, styles, intl, location, localeHolder, values, theme});
};
const renderGuestJoinChannelMessage = ({post, styles, intl, theme}: RenderersProps) => {
const renderGuestJoinChannelMessage = ({post, styles, location, intl, theme}: RenderersProps) => {
if (!post.props.username) {
return null;
}
@ -247,7 +250,7 @@ const renderGuestJoinChannelMessage = ({post, styles, intl, theme}: RenderersPro
};
const values = {username};
return renderMessage({post, styles, intl, localeHolder, values, theme});
return renderMessage({post, styles, intl, location, localeHolder, values, theme});
};
const systemMessageRenderers = {
@ -260,7 +263,7 @@ const systemMessageRenderers = {
[Post.POST_TYPES.ADD_GUEST_TO_CHANNEL]: renderAddGuestToChannelMessage,
};
export const SystemMessage = ({post, author}: SystemMessageProps) => {
export const SystemMessage = ({post, location, author}: SystemMessageProps) => {
const intl = useIntl();
const theme = useTheme();
const style = getStyleSheet(theme);
@ -272,6 +275,8 @@ export const SystemMessage = ({post, author}: SystemMessageProps) => {
return (
<Markdown
baseTextStyle={styles.messageStyle}
channelId={post.channelId}
location={location}
disableGallery={true}
textStyles={styles.textStyles}
value={post.message}
@ -280,7 +285,7 @@ export const SystemMessage = ({post, author}: SystemMessageProps) => {
);
}
return renderer({post, author, styles, intl, theme});
return renderer({post, author, location, styles, intl, theme});
};
export default SystemMessage;

View file

@ -248,6 +248,7 @@ const PostList = ({
const postProps = {
currentUsername,
postId: item,
location,
style: Platform.OS === 'ios' ? styles.scale : styles.container,
testID: `${testID}.combined_user_activity`,
showJoinLeave: shouldShowJoinLeaveMessages,

View file

@ -54,6 +54,8 @@ function Footer({
<Markdown
baseTextStyle={style.helpText}
textStyles={textStyles}
disableAtMentions={true}
location=''
blockStyles={blockStyles}
value={disabledText}
theme={theme}
@ -66,6 +68,8 @@ function Footer({
baseTextStyle={style.helpText}
textStyles={textStyles}
blockStyles={blockStyles}
disableAtMentions={true}
location=''
value={helpText}
theme={theme}
/>
@ -77,6 +81,8 @@ function Footer({
baseTextStyle={style.errorText}
textStyles={textStyles}
blockStyles={blockStyles}
disableAtMentions={true}
location=''
value={errorText}
theme={theme}
/>

View file

@ -11,7 +11,7 @@ type Props = {
itemBounds: TutorialItemBounds;
itemBorderRadius?: number;
onDismiss: () => void;
onShow: () => void;
onShow?: () => void;
}
const TutorialHighlight = ({children, itemBounds, itemBorderRadius, onDismiss, onShow}: Props) => {

View file

@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, StyleSheet, Text, TextStyle, View, ViewStyle} from 'react-native';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import LongPressIllustration from './long_press_illustration.svg';
type Props = {
containerStyle?: StyleProp<ViewStyle>;
message: string;
style?: StyleProp<ViewStyle>;
textStyles?: StyleProp<TextStyle>;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
},
view: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
borderRadius: 8,
height: 161,
padding: 16,
width: 247,
},
text: {
...typography('Heading', 200),
color: theme.centerChannelColor,
marginTop: 8,
paddingHorizontal: 12,
textAlign: 'center',
},
}));
const TutorialSwipeLeft = ({containerStyle, message, style, textStyles}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<View
pointerEvents='none'
style={[styles.container, containerStyle]}
>
<View style={[styles.view, style]}>
<LongPressIllustration/>
<Text style={[styles.text, textStyles]}>
{message}
</Text>
</View>
</View>
);
};
export default TutorialSwipeLeft;

View file

@ -0,0 +1,22 @@
<svg width="69" height="79" viewBox="0 0 69 79" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="31" cy="22.5" r="8.5" fill="#3F4350" fill-opacity="0.24"/>
<circle cx="31" cy="22" r="21" stroke="#3F4350" stroke-opacity="0.08" stroke-width="2"/>
<circle cx="31" cy="22" r="14" stroke="#3F4350" stroke-opacity="0.16" stroke-width="2"/>
<g clip-path="url(#clip0_510_17728)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.8817 59.3289C23.782 59.1198 23.6852 58.9036 23.5896 58.68C23.1622 57.7293 22.7481 56.7384 22.3549 55.7976L22.3548 55.7974C21.6009 53.9934 20.9241 52.3738 20.3781 51.5763C20.3725 51.5692 20.3668 51.5623 20.3613 51.5556C20.3558 51.5489 20.3504 51.5424 20.345 51.536C19.9446 51.0595 19.5411 50.5806 19.1348 50.1041C18.4711 49.3623 17.7767 48.6482 17.0534 47.9636L16.9464 47.6157C16.8366 47.4976 16.7087 47.3639 16.5684 47.2171C15.3219 45.9132 13.0949 43.5837 13.8637 42.1814C14.6752 40.7014 16.8849 40.089 17.9873 40.3238C19.0896 40.5586 20.784 41.8548 20.784 41.8548L24.2798 44.9169L26.2242 47.7033L26.2246 47.704C26.8231 47.0294 27.1529 46.6265 27.1529 46.6265C27.1529 46.6265 27.6633 34.8887 27.6327 32.189C27.602 29.4893 27.5918 21.0278 27.7654 20.3337C27.9389 19.6396 28.5054 18.0627 31.0775 18.4557C33.6496 18.8486 34.0885 20.8543 34.0885 20.8543L34.3487 24.937L34.7877 38.2773C34.7877 38.2773 35.1857 34.2609 36.2881 33.4699C37.0689 32.9085 39.0592 33.0157 40.1718 33.4699C41.514 34.0364 42.0396 38.2671 42.0396 38.2671C42.0396 38.2671 42.4224 36.093 42.9123 35.2612C43.6166 34.0721 46.0254 34.9193 46.8777 35.5572C47.5105 36.0369 48.1127 41.0025 48.1127 41.0025C48.1127 41.0025 48.0514 39.4715 48.7914 38.8183C49.3936 38.3079 51.2206 38.6499 51.731 39.1908C52.8997 40.4361 53.4049 43.9064 53.4049 43.9064L53.6652 46.0651V50.5153C53.8702 53.0229 53.7288 56.1421 52.9787 57.9734C52.7392 58.5581 52.5196 59.0372 52.3161 59.481C51.8822 60.4271 51.5219 61.213 51.1996 62.5207C51.1738 62.9302 50.9608 66.154 50.593 67.0759C50.2 68.0608 48.8936 69.4949 46.8012 70.2808C43.8644 71.417 40.7175 71.9078 37.5742 71.72C35.699 71.5932 31.0356 70.6611 30.5211 68.3298C30.3147 67.3942 29.7628 66.5331 29.1906 65.7816C27.7084 64.3506 24.9074 61.4856 23.9003 59.3577L23.8817 59.3289Z" fill="#FFBC1F"/>
<path d="M36.8853 33.2097C36.8853 33.2097 35.5242 33.5579 36.2876 43.6064C36.5224 46.0713 34.9358 48.0759 35.0379 46.2387C35.2114 43.1766 34.6449 39.7369 34.8592 37.5629C35.3185 33.0311 36.8853 33.2097 36.8853 33.2097Z" fill="#CC8F00"/>
<path d="M44.1322 34.6795C44.1322 34.6795 42.1269 34.4246 43.303 44.535C43.548 46.9999 43.7495 47.8259 43.0299 46.7746C41.9224 45.1517 41.9071 43.1716 41.9531 38.8235C41.999 34.2917 44.1322 34.6795 44.1322 34.6795Z" fill="#CC8F00"/>
<path d="M49.701 38.5781C49.701 38.5781 48.3408 39.7446 49.2662 45.6491C49.8079 48.9711 50.2411 48.6577 50.088 48.8006C49.9349 48.9435 48.4294 47.9636 48.0772 42.5438C47.8185 40.4356 48.3702 39.2702 48.7002 38.9082C49.0302 38.5462 49.701 38.5781 49.701 38.5781Z" fill="#CC8F00"/>
<path d="M30.1274 18.4205C30.1274 18.4205 28.5466 18.6497 28.6538 23.0029C28.8171 29.7037 29.1641 30.3569 29.0263 34.3784C28.8885 38.3999 29.6643 43.9319 27.9495 47.1879C26.8574 49.265 26.1123 47.6983 26.1123 47.6983C26.1123 47.6983 28.419 45.7845 27.4443 23.0131C27.1609 18.0862 30.1274 18.4205 30.1274 18.4205Z" fill="#CC8F00"/>
<path d="M28.9839 23.4788C28.6222 22.5746 28.1701 19.3192 28.6222 18.9575C29.0743 18.5958 32.6435 18.7706 32.9626 19.2537C33.2344 19.6651 33.1435 22.4841 32.9626 23.4788C32.7818 24.4735 29.3938 24.5036 28.9839 23.4788Z" fill="#FFD470"/>
<path d="M16.6094 46.5194C16.6094 46.5194 19.2376 48.9895 21.3096 50.8624C22.6201 52.047 24.8223 56.9738 24.8116 56.9107C25.0225 57.6341 26.6741 61.3371 28.6637 62.4778C32.9637 64.9432 31.5271 70.3587 38.1811 70.2808C43.4121 70.2196 49.7069 69.1128 51.2022 62.4778C51.2022 62.4778 50.9859 66.091 50.593 67.0759C50.2 68.0609 48.8936 69.4949 46.8012 70.2808C43.8644 71.417 40.7175 71.9078 37.5742 71.72C35.699 71.5932 31.0356 70.6612 30.5211 68.3298C30.3147 67.3943 29.7628 66.5332 29.1905 65.7816C27.7017 64.3442 24.8822 61.4598 23.8868 59.329C22.5089 56.3792 21.2484 52.8119 20.3502 51.5361C19.333 50.2723 18.2316 49.0788 17.0534 47.9637L16.6094 46.5194Z" fill="#CC8F00"/>
<path d="M29.5988 21.7714C29.4541 20.6863 29.5988 18.7593 29.5988 18.7593C29.418 18.7593 28.7218 18.8235 28.5982 18.9717C28.4051 19.2034 28.4056 19.7928 28.4533 20.5999C28.5191 21.7135 28.7941 23.05 28.984 23.4788C29.2481 24.0751 30.4552 24.2327 30.9375 24.2327C30.4854 24.0518 29.7797 23.1278 29.5988 21.7714Z" fill="#F5AB00"/>
<path d="M17.0483 47.9688C17.1749 46.78 16.9528 45.5799 16.4091 44.5152C15.8654 43.4504 15.0235 42.5668 13.9863 41.9723C13.578 41.7222 12.9275 42.7334 13.2796 43.1774C13.6318 43.6214 16.4091 47.1827 17.0483 47.9688Z" fill="#FFD470"/>
<path d="M13.2812 42.4391L17.0774 46.9564C17.0774 46.9564 17.1131 47.2169 17.0986 47.5182C17.0841 47.8194 17.0485 47.9688 17.0485 47.9688C17.0485 47.9688 14.8593 45.3353 13.8282 43.886C13.2566 43.1256 13.0212 43.0897 13.2812 42.4391Z" fill="#F5AB00"/>
</g>
<defs>
<clipPath id="clip0_510_17728">
<rect width="40.9141" height="53.3511" fill="white" transform="translate(13.1597 18.4149)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

View file

@ -21,6 +21,8 @@ import type UserModel from '@typings/database/models/servers/user';
const OVERFLOW_DISPLAY_LIMIT = 99;
type Props = {
channelId: string;
location: string;
users: UserModel[];
breakAt?: number;
style?: StyleProp<ViewStyle>;
@ -93,7 +95,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const UserAvatarsStack = ({breakAt = 3, style: baseContainerStyle, users}: Props) => {
const UserAvatarsStack = ({breakAt = 3, channelId, location, style: baseContainerStyle, users}: Props) => {
const theme = useTheme();
const intl = useIntl();
const isTablet = useIsTablet();
@ -110,18 +112,22 @@ const UserAvatarsStack = ({breakAt = 3, style: baseContainerStyle, users}: Props
/>
</View>
)}
<UsersList users={users}/>
<UsersList
channelId={channelId}
location={location}
users={users}
/>
</>
);
bottomSheet({
closeButtonId: 'close-set-user-status',
renderContent,
snapPoints: [(Math.min(14, users.length) + 3) * 40, 10],
initialSnapIndex: 1,
snapPoints: ['90%', '50%', 10],
title: intl.formatMessage({id: 'mobile.participants.header', defaultMessage: 'Thread Participants'}),
theme,
});
}), [isTablet, theme, users]);
}), [isTablet, theme, users, channelId, location]);
const displayUsers = users.slice(0, breakAt);
const overflowUsersCount = Math.min(users.length - displayUsers.length, OVERFLOW_DISPLAY_LIMIT);

View file

@ -1,34 +1,107 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet} from 'react-native';
import React, {useCallback, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, NativeScrollEvent, NativeSyntheticEvent, PanResponder, StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native';
import {FlatList} from 'react-native-gesture-handler';
import UserItem from '@components/user_item';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {dismissBottomSheet, openAsBottomSheet} from '@screens/navigation';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
channelId: string;
location: string;
users: UserModel[];
};
type ItemProps = {
channelId: string;
containerStyle: StyleProp<ViewStyle>;
location: string;
user: UserModel;
}
const style = StyleSheet.create({
container: {
paddingLeft: 0,
},
});
const UsersList = ({users}: Props) => {
const Item = ({channelId, containerStyle, location, user}: ItemProps) => {
const intl = useIntl();
const theme = useTheme();
const openUserProfile = async () => {
if (user) {
await dismissBottomSheet(Screens.BOTTOM_SHEET);
const screen = Screens.USER_PROFILE;
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
const closeButtonId = 'close-user-profile';
const props = {closeButtonId, location, userId: user.id, channelId};
Keyboard.dismiss();
openAsBottomSheet({screen, title, theme, closeButtonId, props});
}
};
return (
<>
{users.map((user) => (
<UserItem
key={user.id}
user={user}
containerStyle={style.container}
/>
))}
</>
<TouchableOpacity onPress={openUserProfile}>
<UserItem
user={user}
containerStyle={containerStyle}
/>
</TouchableOpacity>
);
};
const UsersList = ({channelId, location, users}: Props) => {
const [enabled, setEnabled] = useState(false);
const [direction, setDirection] = useState<'down' | 'up'>('down');
const listRef = useRef<FlatList>(null);
const prevOffset = useRef(0);
const panResponder = useRef(PanResponder.create({
onMoveShouldSetPanResponderCapture: (evt, g) => {
const dir = prevOffset.current < g.dy ? 'down' : 'up';
prevOffset.current = g.dy;
if (!enabled && dir === 'up') {
setEnabled(true);
}
setDirection(dir);
return false;
},
})).current;
const onScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
if (e.nativeEvent.contentOffset.y <= 0 && enabled && direction === 'down') {
setEnabled(false);
listRef.current?.scrollToOffset({animated: true, offset: 0});
}
}, [enabled, direction]);
const renderItem = useCallback(({item}) => (
<Item
channelId={channelId}
location={location}
user={item}
containerStyle={style.container}
/>
), [channelId, location]);
return (
<FlatList
data={users}
ref={listRef}
renderItem={renderItem}
onScroll={onScroll}
overScrollMode={'always'}
scrollEnabled={enabled}
scrollEventThrottle={60}
{...panResponder.panHandlers}
/>
);
};

View file

@ -1,30 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {
Platform,
Text,
View,
} from 'react-native';
import {storeProfileLongPressTutorial} from '@actions/app/global';
import CompassIcon from '@components/compass_icon';
import ProfilePicture from '@components/profile_picture';
import {BotTag, GuestTag} from '@components/tag';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import TutorialHighlight from '@components/tutorial_highlight';
import TutorialLongPress from '@components/tutorial_highlight/long_press';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {displayUsername, isGuest} from '@utils/user';
type Props = {
id: string;
isMyUser: boolean;
highlight?: boolean;
user: UserProfile;
teammateNameDisplay: string;
testID: string;
onPress?: (user: UserProfile) => void;
onLongPress: (user: UserProfile) => void;
selectable: boolean;
selected: boolean;
tutorialWatched?: boolean;
enabled: boolean;
}
@ -90,33 +98,73 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
backgroundColor: theme.sidebarBg,
borderWidth: 0,
},
tutorial: {
top: Platform.select({ios: -74, default: -94}),
},
tutorialTablet: {
top: -84,
},
};
});
export default function UserListRow({
id,
isMyUser,
highlight,
user,
teammateNameDisplay,
testID,
onPress,
onLongPress,
tutorialWatched = false,
selectable,
selected,
enabled,
}: Props) {
const theme = useTheme();
const style = getStyleFromTheme(theme);
const intl = useIntl();
const isTablet = useIsTablet();
const [showTutorial, setShowTutorial] = useState(false);
const [itemBounds, setItemBounds] = useState<TutorialItemBounds>({startX: 0, startY: 0, endX: 0, endY: 0});
const viewRef = useRef<View>(null);
const style = getStyleFromTheme(theme);
const {formatMessage} = intl;
const {username} = user;
const handlePress = useCallback(() => {
if (onPress) {
onPress(user);
const startTutorial = () => {
viewRef.current?.measureInWindow((x, y, w, h) => {
const bounds: TutorialItemBounds = {
startX: x - 20,
startY: y,
endX: x + w + 20,
endY: y + h,
};
setShowTutorial(true);
setItemBounds(bounds);
});
};
const handleDismissTutorial = useCallback(() => {
setShowTutorial(false);
storeProfileLongPressTutorial();
}, []);
useEffect(() => {
let time: NodeJS.Timeout;
if (highlight && !tutorialWatched) {
time = setTimeout(startTutorial, 650);
}
return () => clearTimeout(time);
}, [highlight, tutorialWatched]);
const handlePress = useCallback(() => {
onPress?.(user);
}, [onPress, user]);
const handleLongPress = useCallback(() => {
onLongPress?.(user);
}, [onLongPress, user]);
const iconStyle = useMemo(() => {
return [style.selector, (selected && style.selectorFilled), (!enabled && style.selectorDisabled)];
}, [style, selected, enabled]);
@ -153,65 +201,80 @@ export default function UserListRow({
const profilePictureTestID = `${itemTestID}.profile_picture`;
return (
<TouchableWithFeedback
onPress={handlePress}
underlayColor={changeOpacity(theme.centerChannelColor, 0.16)}
>
<View
style={style.container}
testID={itemTestID}
<>
<TouchableWithFeedback
onLongPress={handleLongPress}
onPress={handlePress}
underlayColor={changeOpacity(theme.centerChannelColor, 0.16)}
>
<View style={style.profileContainer}>
<ProfilePicture
author={user}
size={32}
iconSize={24}
testID={profilePictureTestID}
/>
</View>
<View style={style.textContainer}>
<View style={style.indicatorContainer}>
<Text
style={style.username}
ellipsizeMode='tail'
numberOfLines={1}
testID={displayNameTestID}
>
{usernameDisplay}
</Text>
<BotTag
show={Boolean(user.is_bot)}
/>
<GuestTag
show={isGuest(user.roles)}
<View
ref={viewRef}
style={style.container}
testID={itemTestID}
>
<View style={style.profileContainer}>
<ProfilePicture
author={user}
size={32}
iconSize={24}
testID={profilePictureTestID}
/>
</View>
{showTeammateDisplay &&
<View>
<Text
style={style.displayName}
ellipsizeMode='tail'
numberOfLines={1}
>
{teammateDisplay}
</Text>
</View>
}
{user.delete_at > 0 &&
<View>
<Text
style={style.deactivated}
>
{formatMessage({id: 'mobile.user_list.deactivated', defaultMessage: 'Deactivated'})}
</Text>
<View style={style.textContainer}>
<View style={style.indicatorContainer}>
<Text
style={style.username}
ellipsizeMode='tail'
numberOfLines={1}
testID={displayNameTestID}
>
{usernameDisplay}
</Text>
<BotTag
show={Boolean(user.is_bot)}
/>
<GuestTag
show={isGuest(user.roles)}
/>
</View>
{showTeammateDisplay &&
<View>
<Text
style={style.displayName}
ellipsizeMode='tail'
numberOfLines={1}
>
{teammateDisplay}
</Text>
</View>
}
{user.delete_at > 0 &&
<View>
<Text
style={style.deactivated}
>
{formatMessage({id: 'mobile.user_list.deactivated', defaultMessage: 'Deactivated'})}
</Text>
</View>
}
</View>
{selectable &&
<Icon/>
}
</View>
{selectable &&
<Icon/>
}
</View>
</TouchableWithFeedback>
</TouchableWithFeedback>
{showTutorial &&
<TutorialHighlight
itemBounds={itemBounds}
onDismiss={handleDismissTutorial}
>
<TutorialLongPress
message={intl.formatMessage({id: 'user.tutorial.long_press', defaultMessage: "Long-press on an item to view a user's profile"})}
style={isTablet ? style.tutorialTablet : style.tutorial}
/>
</TutorialHighlight>
}
</>
);
}

View file

@ -70,6 +70,7 @@ export const SYSTEM_IDENTIFIERS = {
export const GLOBAL_IDENTIFIERS = {
DEVICE_TOKEN: 'deviceToken',
MULTI_SERVER_TUTORIAL: 'multiServerTutorial',
PROFILE_LONG_PRESS_TUTORIAL: 'profileLongPressTutorial',
};
export default {

View file

@ -26,4 +26,5 @@ export default keyMirror({
POST_LIST_SCROLL_TO_BOTTOM: null,
SWIPEABLE: null,
ITEM_IN_VIEWPORT: null,
SEND_TO_POST_DRAFT: null,
});

View file

@ -131,5 +131,4 @@ export const NOT_READY = [
CREATE_TEAM,
INTEGRATION_SELECTOR,
INTERACTIVE_DIALOG,
USER_PROFILE,
];

View file

@ -6,6 +6,7 @@ import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {GLOBAL_IDENTIFIERS, MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import type GlobalModel from '@typings/database/models/app/global';
@ -26,3 +27,14 @@ export const observeMultiServerTutorial = (appDatabase: Database) => {
switchMap((v) => of$(Boolean(v))),
);
};
export const observeProfileLongPresTutorial = () => {
const appDatabase = DatabaseManager.appDatabase?.database;
if (!appDatabase) {
return of$(false);
}
return appDatabase.get<GlobalModel>(GLOBAL).query(Q.where('id', GLOBAL_IDENTIFIERS.PROFILE_LONG_PRESS_TUTORIAL), Q.take(1)).observe().pipe(
switchMap((result) => (result.length ? result[0].observe() : of$(false))),
switchMap((v) => of$(Boolean(v))),
);
};

View file

@ -13,9 +13,11 @@ import {queryPreferencesByCategoryAndName} from './preference';
import {observeConfig, observeCurrentUserId, observeLicense, getCurrentUserId, getConfig, getLicense} from './system';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type ChannelMembershipModel from '@typings/database/models/servers/channel_membership';
import type TeamMembershipModel from '@typings/database/models/servers/team_membership';
import type UserModel from '@typings/database/models/servers/user';
const {SERVER: {USER}} = MM_TABLES;
const {SERVER: {CHANNEL_MEMBERSHIP, USER, TEAM_MEMBERSHIP}} = MM_TABLES;
export const getUserById = async (database: Database, userId: string) => {
try {
const userRecord = (await database.get<UserModel>(USER).find(userId));
@ -96,3 +98,21 @@ export const queryUsersByIdsOrUsernames = (database: Database, ids: string[], us
Q.where('username', Q.oneOf(usernames)),
));
};
export const observeUserIsTeamAdmin = (database: Database, userId: string, teamId: string) => {
const id = `${teamId}-${userId}`;
return database.get<TeamMembershipModel>(TEAM_MEMBERSHIP).query(
Q.where('id', Q.eq(id)),
).observe().pipe(
switchMap((tm) => of$(tm.length ? tm[0].schemeAdmin : false)),
);
};
export const observeUserIsChannelAdmin = (database: Database, userId: string, teamId: string) => {
const id = `${teamId}-${userId}`;
return database.get<ChannelMembershipModel>(CHANNEL_MEMBERSHIP).query(
Q.where('id', Q.eq(id)),
).observe().pipe(
switchMap((tm) => of$(tm.length ? tm[0].schemeAdmin : false)),
);
};

View file

@ -397,6 +397,8 @@ function AppsFormComponent({
baseTextStyle={style.errorLabel}
textStyles={getMarkdownTextStyles(theme)}
blockStyles={getMarkdownBlockStyles(theme)}
location=''
disableAtMentions={true}
value={error}
theme={theme}
/>

View file

@ -157,6 +157,8 @@ function AppsFormField({
<Markdown
value={field.description}
mentionKeys={[]}
disableAtMentions={true}
location=''
blockStyles={getMarkdownBlockStyles(theme)}
textStyles={getMarkdownTextStyles(theme)}
baseTextStyle={style.markdownFieldText}

View file

@ -106,6 +106,7 @@ const DirectChannel = ({channel, currentUserId, isBot, members, theme}: Props) =
if (channel.type === General.DM_CHANNEL) {
return (
<Member
channelId={channel.id}
containerStyle={{height: 96}}
member={channelMembers[0]}
size={96}

View file

@ -3,16 +3,17 @@
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {StyleProp, StyleSheet, ViewStyle} from 'react-native';
import {Keyboard, 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 {showModal} from '@screens/navigation';
import {Screens} from '@constants';
import {openAsBottomSheet} from '@screens/navigation';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
channelId: string;
containerStyle?: StyleProp<ViewStyle>;
size?: number;
showStatus?: boolean;
@ -28,30 +29,17 @@ const styles = StyleSheet.create({
},
});
const Member = ({containerStyle, size = 72, showStatus = true, theme, user}: Props) => {
const Member = ({channelId, containerStyle, size = 72, showStatus = true, theme, user}: Props) => {
const intl = useIntl();
const onPress = useCallback(() => {
// const screen = Screens.USER_PROFILE;
const screen = 'UserProfile';
const screen = Screens.USER_PROFILE;
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
const passProps = {
userId: user.id,
};
const closeButtonId = 'close-user-profile';
const props = {closeButtonId, userId: user.id, channelId, location: Screens.CHANNEL};
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]);
Keyboard.dismiss();
openAsBottomSheet({screen, title, theme, closeButtonId, props});
}, [theme, intl.locale]);
return (
<TouchableWithFeedback

View file

@ -10,12 +10,14 @@ import Emoji from '@components/emoji';
import FormattedDate from '@components/formatted_date';
import FormattedText from '@components/formatted_text';
import Markdown from '@components/markdown';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
channelId: string;
createdAt: number;
createdBy: string;
customStatus?: UserCustomStatus;
@ -63,7 +65,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
},
}));
const Extra = ({createdAt, createdBy, customStatus, header}: Props) => {
const Extra = ({channelId, createdAt, createdBy, customStatus, header}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const blockStyles = getMarkdownBlockStyles(theme);
@ -123,6 +125,7 @@ const Extra = ({createdAt, createdBy, customStatus, header}: Props) => {
style={styles.extraHeading}
/>
<Markdown
channelId={channelId}
baseTextStyle={styles.header}
blockStyles={blockStyles}
disableBlockQuote={true}
@ -130,6 +133,7 @@ const Extra = ({createdAt, createdBy, customStatus, header}: Props) => {
disableGallery={true}
disableHeading={true}
disableTables={true}
location={Screens.CHANNEL_INFO}
textStyles={textStyles}
layoutHeight={48}
layoutWidth={100}

View file

@ -38,6 +38,7 @@ type Props = {
currentUserId: string;
restrictDirectMessage: boolean;
teammateNameDisplay: string;
tutorialWatched: boolean;
}
const close = () => {
@ -90,6 +91,7 @@ export default function CreateDirectMessage({
currentUserId,
restrictDirectMessage,
teammateNameDisplay,
tutorialWatched,
}: Props) {
const serverUrl = useServerUrl();
const theme = useTheme();
@ -397,6 +399,7 @@ export default function CreateDirectMessage({
fetchMore={getProfiles}
term={term}
testID='create_direct_message.user_list'
tutorialWatched={tutorialWatched}
/>
</SafeAreaView>
);

View file

@ -7,6 +7,7 @@ import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {General} from '@constants';
import {observeProfileLongPresTutorial} from '@queries/app/global';
import {observeConfig, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import {observeTeammateNameDisplay} from '@queries/servers/user';
@ -23,6 +24,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
teammateNameDisplay: observeTeammateNameDisplay(database),
currentUserId: observeCurrentUserId(database),
currentTeamId: observeCurrentTeamId(database),
tutorialWatched: observeProfileLongPresTutorial(),
restrictDirectMessage,
};
});

View file

@ -2,14 +2,18 @@
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {FlatList, Keyboard, ListRenderItemInfo, Platform, SectionList, SectionListData, Text, View} from 'react-native';
import {storeProfile} from '@actions/local/user';
import Loading from '@components/loading';
import NoResultsWithTerm from '@components/no_results_with_term';
import UserListRow from '@components/user_list_row';
import {General} from '@constants';
import {General, Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useKeyboardHeight} from '@hooks/device';
import {openAsBottomSheet} from '@screens/navigation';
import {
changeOpacity,
makeStyleSheetFromTheme,
@ -112,6 +116,7 @@ type Props = {
selectedIds: {[id: string]: UserProfile};
testID?: string;
term?: string;
tutorialWatched: boolean;
}
export default function UserList({
@ -125,8 +130,11 @@ export default function UserList({
showNoResults,
term,
testID,
tutorialWatched,
}: Props) {
const intl = useIntl();
const theme = useTheme();
const serverUrl = useServerUrl();
const style = getStyleFromTheme(theme);
const keyboardHeight = useKeyboardHeight();
const noResutsStyle = useMemo(() => [
@ -134,7 +142,24 @@ export default function UserList({
{paddingBottom: keyboardHeight},
], [style, keyboardHeight]);
const renderItem = useCallback(({item}: ListRenderItemInfo<UserProfile>) => {
const openUserProfile = useCallback(async (profile: UserProfile) => {
const {user} = await storeProfile(serverUrl, profile);
if (user) {
const screen = Screens.USER_PROFILE;
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
const closeButtonId = 'close-user-profile';
const props = {
closeButtonId,
userId: user.id,
location: Screens.USER_PROFILE,
};
Keyboard.dismiss();
openAsBottomSheet({screen, title, theme, closeButtonId, props});
}
}, []);
const renderItem = useCallback(({item, index}: ListRenderItemInfo<UserProfile>) => {
// The list will re-render when the selection changes because it's passed into the list as extraData
const selected = Boolean(selectedIds[item.id]);
const canAdd = Object.keys(selectedIds).length < General.MAX_USERS_IN_GM;
@ -142,18 +167,21 @@ export default function UserList({
return (
<UserListRow
key={item.id}
highlight={index === 0}
id={item.id}
isMyUser={currentUserId === item.id}
onPress={handleSelectProfile}
onLongPress={openUserProfile}
selectable={canAdd}
selected={selected}
enabled={canAdd}
testID='create_direct_message.user_list.user_item'
teammateNameDisplay={teammateNameDisplay}
tutorialWatched={tutorialWatched}
user={item}
/>
);
}, [selectedIds, currentUserId, handleSelectProfile, teammateNameDisplay]);
}, [selectedIds, currentUserId, handleSelectProfile, teammateNameDisplay, tutorialWatched]);
const renderLoading = useCallback(() => {
if (!loading) {

View file

@ -29,6 +29,7 @@ import type UserModel from '@typings/database/models/servers/user';
type Props = {
author?: UserModel;
channel?: ChannelModel;
location: string;
post?: PostModel;
teammateNameDisplay: string;
testID: string;
@ -119,7 +120,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const Thread = ({author, channel, post, teammateNameDisplay, testID, thread}: Props) => {
const Thread = ({author, channel, location, post, teammateNameDisplay, testID, thread}: Props) => {
const intl = useIntl();
const isTablet = useIsTablet();
const theme = useTheme();
@ -236,11 +237,15 @@ const Thread = ({author, channel, post, teammateNameDisplay, testID, thread}: Pr
/>
</View>
{postBody}
{Boolean(post && thread) &&
<ThreadFooter
author={author}
channelId={post!.channelId}
location={location}
testID={`${threadItemTestId}.footer`}
thread={thread}
/>
}
</View>
</View>
</TouchableHighlight>

View file

@ -15,6 +15,8 @@ import type UserModel from '@typings/database/models/servers/user';
type Props = {
author?: UserModel;
channelId: string;
location: string;
participants: UserModel[];
testID: string;
thread: ThreadModel;
@ -46,7 +48,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const ThreadFooter = ({author, participants, testID, thread}: Props) => {
const ThreadFooter = ({author, channelId, location, participants, testID, thread}: Props) => {
const theme = useTheme();
const style = getStyleSheet(theme);
@ -91,6 +93,8 @@ const ThreadFooter = ({author, participants, testID, thread}: Props) => {
if (author && participantsList.length) {
userAvatarsStack = (
<UserAvatarsStack
channelId={channelId}
location={location}
style={style.avatarsContainer}
users={participantsList}
/>

View file

@ -6,7 +6,7 @@ import {FlatList, StyleSheet} from 'react-native';
import {fetchRefreshThreads, fetchThreads} from '@actions/remote/thread';
import Loading from '@components/loading';
import {General} from '@constants';
import {General, Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
@ -141,6 +141,7 @@ const ThreadsList = ({
const renderItem = useCallback(({item}) => (
<Thread
location={Screens.GLOBAL_THREADS}
testID={testID}
teammateNameDisplay={teammateNameDisplay}
thread={item}

View file

@ -164,30 +164,6 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.SAVED_POSTS:
screen = withServerDatabase((require('@screens/saved_posts').default));
break;
case Screens.SSO:
screen = withIntl(require('@screens/sso').default);
break;
case Screens.THREAD:
screen = withServerDatabase(require('@screens/thread').default);
break;
case Screens.THREAD_FOLLOW_BUTTON:
Navigation.registerComponent(Screens.THREAD_FOLLOW_BUTTON, () => withServerDatabase(
require('@screens/thread/thread_follow_button').default,
));
break;
case Screens.SNACK_BAR: {
const snackBarScreen = withServerDatabase(require('@screens/snack_bar').default);
Navigation.registerComponent(Screens.SNACK_BAR, () =>
Platform.select({
default: snackBarScreen,
ios: withSafeAreaInsets(snackBarScreen) as ComponentType,
}),
);
break;
}
case Screens.THREAD_OPTIONS:
screen = withServerDatabase(require('@screens/thread_options').default);
break;
case Screens.SETTINGS:
screen = withServerDatabase(require('@screens/settings').default);
break;
@ -206,6 +182,33 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.SETTINGS_NOTIFICATION_AUTO_RESPONDER:
screen = withServerDatabase(require('@screens/settings/notification_auto_responder').default);
break;
case Screens.SNACK_BAR: {
const snackBarScreen = withServerDatabase(require('@screens/snack_bar').default);
Navigation.registerComponent(Screens.SNACK_BAR, () =>
Platform.select({
default: snackBarScreen,
ios: withSafeAreaInsets(snackBarScreen) as ComponentType,
}),
);
break;
}
case Screens.SSO:
screen = withIntl(require('@screens/sso').default);
break;
case Screens.THREAD:
screen = withServerDatabase(require('@screens/thread').default);
break;
case Screens.THREAD_FOLLOW_BUTTON:
Navigation.registerComponent(Screens.THREAD_FOLLOW_BUTTON, () => withServerDatabase(
require('@screens/thread/thread_follow_button').default,
));
break;
case Screens.THREAD_OPTIONS:
screen = withServerDatabase(require('@screens/thread_options').default);
break;
case Screens.USER_PROFILE:
screen = withServerDatabase(require('@screens/user_profile').default);
break;
}
if (screen) {

View file

@ -41,6 +41,7 @@ function DialogIntroductionText({value}: Props) {
disableHashtags={true}
disableAtMentions={true}
disableChannelLink={true}
location=''
theme={theme}
/>
</View>

View file

@ -111,6 +111,10 @@ export const bottomSheetModalOptions = (theme: Theme, closeButtonId?: string) =>
enabled: false,
},
},
modalPresentationStyle: Platform.select({
ios: OptionsModalPresentationStyle.overFullScreen,
default: OptionsModalPresentationStyle.overCurrentContext,
}),
modal: {swipeToDismiss: true},
statusBar: {
backgroundColor: null,
@ -524,7 +528,7 @@ export function showModalOverCurrentContext(name: string, passProps = {}, option
break;
}
const defaultOptions = {
modalPresentationStyle: 'overCurrentContext',
modalPresentationStyle: OptionsModalPresentationStyle.overCurrentContext,
layout: {
backgroundColor: 'transparent',
componentBackgroundColor: 'transparent',
@ -684,6 +688,28 @@ export async function dismissBottomSheet(alternativeScreen = Screens.BOTTOM_SHEE
await EphemeralStore.waitUntilScreensIsRemoved(alternativeScreen);
}
type AsBottomSheetArgs = {
closeButtonId: string;
props?: Record<string, any>;
screen: typeof Screens[keyof typeof Screens];
theme: Theme;
title: string;
}
export async function openAsBottomSheet({closeButtonId, screen, theme, title, props}: AsBottomSheetArgs) {
const {isSplitView} = await isRunningInSplitView();
const isTablet = Device.IS_TABLET && !isSplitView;
if (isTablet) {
showModal(screen, title, {
closeButtonId,
...props,
}, bottomSheetModalOptions(theme, closeButtonId));
} else {
showModalOverCurrentContext(screen, props, bottomSheetModalOptions(theme));
}
}
export const showAppForm = async (form: AppForm, call: AppCallRequest) => {
const passProps = {form, call};
showModal(Screens.APPS_FORM, form.title || '', passProps);

View file

@ -15,10 +15,11 @@ import type ReactionModel from '@typings/database/models/servers/reaction';
type Props = {
initialEmoji: string;
location: string;
reactions?: ReactionModel[];
}
const Reactions = ({initialEmoji, reactions}: Props) => {
const Reactions = ({initialEmoji, location, reactions}: Props) => {
const [sortedReactions, setSortedReactions] = useState(Array.from(new Set(reactions?.map((r) => getEmojiFirstAlias(r.emojiName)))));
const [index, setIndex] = useState(sortedReactions.indexOf(initialEmoji));
const reactionsByName = useMemo(() => {
@ -56,11 +57,12 @@ const Reactions = ({initialEmoji, reactions}: Props) => {
<EmojiAliases emoji={emojiAlias}/>
<ReactorsList
key={emojiAlias}
location={location}
reactions={reactionsByName.get(emojiAlias)!}
/>
</>
);
}, [index, reactions, sortedReactions]);
}, [index, location, reactions, sortedReactions]);
useEffect(() => {
// This helps keep the reactions in the same position at all times until unmounted

View file

@ -13,10 +13,11 @@ import Reactor from './reactor';
import type ReactionModel from '@typings/database/models/servers/reaction';
type Props = {
location: string;
reactions: ReactionModel[];
}
const ReactorsList = ({reactions}: Props) => {
const ReactorsList = ({location, reactions}: Props) => {
const serverUrl = useServerUrl();
const [enabled, setEnabled] = useState(false);
const [direction, setDirection] = useState<'down' | 'up'>('down');
@ -35,7 +36,10 @@ const ReactorsList = ({reactions}: Props) => {
})).current;
const renderItem = useCallback(({item}) => (
<Reactor reaction={item}/>
<Reactor
location={location}
reaction={item}
/>
), [reactions]);
const onScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {

View file

@ -3,7 +3,10 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observePost} from '@queries/servers/post';
import {observeUser} from '@queries/servers/user';
import {WithDatabaseArgs} from '@typings/database/database';
@ -12,6 +15,9 @@ import Reactor from './reactor';
import type ReactionModel from '@typings/database/models/servers/reaction';
const enhance = withObservables(['reaction'], ({database, reaction}: {reaction: ReactionModel} & WithDatabaseArgs) => ({
channelId: observePost(database, reaction.postId).pipe(
switchMap((p) => of$(p?.channelId)),
),
user: observeUser(database, reaction.userId),
}));

View file

@ -2,13 +2,19 @@
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet} from 'react-native';
import {useIntl} from 'react-intl';
import {Keyboard, StyleSheet, TouchableOpacity} from 'react-native';
import UserItem from '@components/user_item';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {dismissBottomSheet, openAsBottomSheet} from '@screens/navigation';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
channelId: string;
location: string;
user?: UserModel;
}
@ -18,12 +24,29 @@ const style = StyleSheet.create({
},
});
const Reactor = ({user}: Props) => {
const Reactor = ({channelId, location, user}: Props) => {
const intl = useIntl();
const theme = useTheme();
const openUserProfile = async () => {
if (user) {
await dismissBottomSheet(Screens.REACTIONS);
const screen = Screens.USER_PROFILE;
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
const closeButtonId = 'close-user-profile';
const props = {closeButtonId, location, userId: user.id, channelId};
Keyboard.dismiss();
openAsBottomSheet({screen, title, theme, closeButtonId, props});
}
};
return (
<UserItem
containerStyle={style.container}
user={user}
/>
<TouchableOpacity onPress={openUserProfile}>
<UserItem
containerStyle={style.container}
user={user}
/>
</TouchableOpacity>
);
};

View file

@ -0,0 +1,60 @@
// 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 {map, switchMap} from 'rxjs/operators';
import {General, Preferences} from '@constants';
import {getPreferenceAsBool} from '@helpers/api/preference';
import {observeChannel} from '@queries/servers/channel';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {observeConfigBooleanValue, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import {observeTeammateNameDisplay, observeUser, observeUserIsChannelAdmin, observeUserIsTeamAdmin} from '@queries/servers/user';
import {isSystemAdmin} from '@utils/user';
import UserProfile from './user_profile';
import type {WithDatabaseArgs} from '@typings/database/database';
type EnhancedProps = WithDatabaseArgs & {
userId: string;
channelId?: string;
}
const enhanced = withObservables([], ({channelId, database, userId}: EnhancedProps) => {
const currentUserId = observeCurrentUserId(database);
const channel = channelId ? observeChannel(database, channelId) : of$(undefined);
const user = observeUser(database, userId);
const teammateDisplayName = observeTeammateNameDisplay(database);
const isChannelAdmin = channelId ? observeUserIsChannelAdmin(database, userId, channelId) : of$(false);
const isDirectMessage = channelId ? channel.pipe(
switchMap((c) => of$(c?.type === General.DM_CHANNEL)),
) : of$(false);
const teamId = channel.pipe(switchMap((c) => (c?.teamId ? of$(c.teamId) : observeCurrentTeamId(database))));
const isTeamAdmin = teamId.pipe(switchMap((id) => observeUserIsTeamAdmin(database, userId, id)));
const systemAdmin = user.pipe(switchMap((u) => of$(u?.roles ? isSystemAdmin(u.roles) : false)));
const enablePostIconOverride = observeConfigBooleanValue(database, 'EnablePostIconOverride');
const enablePostUsernameOverride = observeConfigBooleanValue(database, 'EnablePostUsernameOverride');
const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).
observeWithColumns(['value']);
const isMilitaryTime = preferences.pipe(map((prefs) => getPreferenceAsBool(prefs, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false)));
return {
currentUserId,
enablePostIconOverride,
enablePostUsernameOverride,
isChannelAdmin,
isDirectMessage,
isMilitaryTime,
isSystemAdmin: systemAdmin,
isTeamAdmin,
teamId,
teammateDisplayName,
user,
};
});
export default withDatabase(enhanced(UserProfile));

View file

@ -0,0 +1,43 @@
// 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 {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
title: string;
description: string;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
marginVertical: 8,
},
description: {
color: theme.centerChannelColor,
...typography('Body', 200),
},
title: {
color: changeOpacity(theme.centerChannelColor, 0.56),
marginBottom: 2,
...typography('Body', 50, 'SemiBold'),
},
}));
const UserProfileLabel = ({title, description}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
return (
<View style={styles.container}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.description}>{description}</Text>
</View>
);
};
export default UserProfileLabel;

View file

@ -0,0 +1,113 @@
// 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, StyleSheet, TouchableOpacity, View} from 'react-native';
import {createDirectChannel, switchToChannelById} from '@actions/remote/channel';
import {buttonBackgroundStyle, buttonTextStyle} from '@app/utils/buttonStyles';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import OptionBox, {OPTIONS_HEIGHT} from '@components/option_box';
import {Events, Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {dismissBottomSheet} from '@screens/navigation';
export type OptionsType = 'all' | 'message';
type Props = {
location: string;
type: OptionsType;
userId: string;
username: string;
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
height: OPTIONS_HEIGHT,
marginBottom: 20,
width: '100%',
},
divider: {
marginRight: 8,
},
icon: {
fontSize: 24,
lineHeight: 22,
},
singleButton: {
flexDirection: 'row',
width: '100%',
},
singleContainer: {
flexDirection: 'row',
marginBottom: 20,
width: '100%',
},
});
const UserProfileOptions = ({location, type, userId, username}: Props) => {
const intl = useIntl();
const theme = useTheme();
const serverUrl = useServerUrl();
const buttonStyle = useMemo(() => {
return buttonBackgroundStyle(theme, 'lg', 'tertiary', 'default');
}, [theme]);
const mentionUser = useCallback(async () => {
await dismissBottomSheet(Screens.USER_PROFILE);
DeviceEventEmitter.emit(Events.SEND_TO_POST_DRAFT, {location, text: `@${username}`});
}, [location, username]);
const openChannel = useCallback(async () => {
await dismissBottomSheet(Screens.USER_PROFILE);
const {data} = await createDirectChannel(serverUrl, userId);
if (data) {
switchToChannelById(serverUrl, data.id);
}
}, [userId, serverUrl]);
if (type === 'all') {
return (
<View style={styles.container}>
<OptionBox
iconName='send'
onPress={openChannel}
text={intl.formatMessage({id: 'channel_info.send_mesasge', defaultMessage: 'Send message'})}
/>
<View style={styles.divider}/>
<OptionBox
iconName='at'
onPress={mentionUser}
text={intl.formatMessage({id: 'channel_info.mention', defaultMessage: 'Mention'})}
/>
</View>
);
}
return (
<View style={styles.singleContainer}>
<TouchableOpacity
style={[buttonStyle, styles.singleButton]}
onPress={openChannel}
>
<CompassIcon
color={theme.buttonBg}
name='send'
style={styles.icon}
/>
<FormattedText
id='channel_info.send_a_mesasge'
defaultMessage='Send a message'
style={[buttonTextStyle(theme, 'lg', 'tertiary', 'default'), {marginLeft: 8}]}
/>
</TouchableOpacity>
</View>
);
};
export default UserProfileOptions;

View file

@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, View} from 'react-native';
import FastImage from 'react-native-fast-image';
import ProfilePicture from '@app/components/profile_picture';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
enablePostIconOverride: boolean;
user: UserModel;
userIconOverride?: string;
}
const styles = StyleSheet.create({
avatar: {
borderRadius: 48,
height: 96,
width: 96,
},
});
const UserProfileAvatar = ({enablePostIconOverride, user, userIconOverride}: Props) => {
if (enablePostIconOverride && userIconOverride) {
return (
<View style={styles.avatar}>
<FastImage
style={styles.avatar}
source={{uri: userIconOverride}}
/>
</View>
);
}
return (
<ProfilePicture
author={user}
showStatus={true}
size={96}
statusSize={24}
/>
);
};
export default UserProfileAvatar;

View file

@ -0,0 +1,108 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {displayUsername} from '@utils/user';
import UserProfileAvatar from './avatar';
import UserProfileTag from './tag';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
enablePostIconOverride: boolean;
enablePostUsernameOverride: boolean;
isChannelAdmin: boolean;
isSystemAdmin: boolean;
isTeamAdmin: boolean;
teammateDisplayName: string;
user: UserModel;
userIconOverride?: string;
usernameOverride?: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flexDirection: 'row',
marginBottom: 20,
},
details: {
marginLeft: 24,
justifyContent: 'center',
flex: 1,
},
displayName: {
color: theme.centerChannelColor,
...typography('Heading', 600, 'SemiBold'),
},
username: {
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 200),
},
tablet: {
marginTop: 20,
},
}));
const UserProfileTitle = ({
enablePostIconOverride, enablePostUsernameOverride,
isChannelAdmin, isSystemAdmin, isTeamAdmin,
teammateDisplayName, user, userIconOverride, usernameOverride,
}: Props) => {
const intl = useIntl();
const isTablet = useIsTablet();
const theme = useTheme();
const styles = getStyleSheet(theme);
const override = enablePostUsernameOverride && usernameOverride;
let displayName;
if (override) {
displayName = usernameOverride;
} else {
displayName = displayUsername(user, intl.locale, teammateDisplayName, false);
}
const hideUsername = override || (displayName && displayName === user.username);
const prefix = hideUsername ? '@' : '';
return (
<View style={[styles.container, isTablet && styles.tablet]}>
<UserProfileAvatar
enablePostIconOverride={enablePostIconOverride}
user={user}
userIconOverride={userIconOverride}
/>
<View style={styles.details}>
<UserProfileTag
isBot={user.isBot || Boolean(userIconOverride || usernameOverride)}
isChannelAdmin={isChannelAdmin}
isGuest={user.isGuest}
isSystemAdmin={isSystemAdmin}
isTeamAdmin={isTeamAdmin}
/>
<Text
numberOfLines={1}
style={styles.displayName}
>
{`${prefix}${displayName}`}
</Text>
{!hideUsername &&
<Text
numberOfLines={1}
style={styles.username}
>
{`@${user.username}`}
</Text>
}
</View>
</View>
);
};
export default UserProfileTitle;

View file

@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet} from 'react-native';
import Tag, {BotTag, GuestTag} from '@components/tag';
type Props = {
isBot: boolean;
isChannelAdmin: boolean;
isGuest: boolean;
isSystemAdmin: boolean;
isTeamAdmin: boolean;
}
const styles = StyleSheet.create({
tag: {
alignSelf: 'flex-start',
paddingHorizontal: 6,
marginBottom: 4,
},
});
const UserProfileTag = ({isBot, isChannelAdmin, isGuest, isSystemAdmin, isTeamAdmin}: Props) => {
if (isBot) {
return (<BotTag style={styles.tag}/>);
}
if (isGuest) {
return (<GuestTag style={styles.tag}/>);
}
if (isSystemAdmin) {
return (
<Tag
id='user_profile.system_admin'
defaultMessage='System Admin'
style={styles.tag}
/>
);
}
if (isTeamAdmin) {
return (
<Tag
id='user_profile.team_admin'
defaultMessage='Team Admin'
style={styles.tag}
/>
);
}
if (isChannelAdmin) {
return (
<Tag
id='user_profile.channel_admin'
defaultMessage='Channel Admin'
style={styles.tag}
/>
);
}
return null;
};
export default UserProfileTag;

View file

@ -0,0 +1,160 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import React, {useEffect, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {fetchTeamAndChannelMembership} from '@actions/remote/user';
import {useServerUrl} from '@app/context/server';
import {Screens} from '@constants';
import {getLocaleFromLanguage} from '@i18n';
import BottomSheet from '@screens/bottom_sheet';
import {getUserTimezone} from '@utils/user';
import UserProfileLabel from './label';
import UserProfileOptions, {OptionsType} from './options';
import UserProfileTitle from './title';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
channelId?: string;
currentUserId: string;
enablePostIconOverride: boolean;
enablePostUsernameOverride: boolean;
isChannelAdmin: boolean;
isDirectMessage: boolean;
isMilitaryTime: boolean;
isSystemAdmin: boolean;
isTeamAdmin: boolean;
location: string;
teamId: string;
teammateDisplayName: string;
user: UserModel;
userIconOverride?: string;
usernameOverride?: string;
}
const TITLE_HEIGHT = 118;
const OPTIONS_HEIGHT = 82;
const SINGLE_OPTION_HEIGHT = 68;
const LABEL_HEIGHT = 58;
const EXTRA_HEIGHT = 60;
const UserProfile = ({
channelId, currentUserId, enablePostIconOverride, enablePostUsernameOverride,
isChannelAdmin, isDirectMessage, isMilitaryTime, isSystemAdmin, isTeamAdmin,
location, teamId, teammateDisplayName,
user, userIconOverride, usernameOverride,
}: Props) => {
const {formatMessage, locale} = useIntl();
const serverUrl = useServerUrl();
const insets = useSafeAreaInsets();
const channelContext = [Screens.CHANNEL, Screens.THREAD].includes(location);
const showOptions: OptionsType = channelContext && !user.isBot ? 'all' : 'message';
const override = Boolean(userIconOverride || usernameOverride);
const timezone = getUserTimezone(user);
let localTime: string|undefined;
if (timezone) {
moment.locale(getLocaleFromLanguage(locale).toLowerCase());
let format = 'H:mm';
if (!isMilitaryTime) {
const localeFormat = moment.localeData().longDateFormat('LT');
format = localeFormat?.includes('A') ? localeFormat : 'h:mm A';
}
localTime = moment.tz(Date.now(), timezone).format(format);
}
const snapPoints = useMemo(() => {
let initial = TITLE_HEIGHT;
if ((!isDirectMessage || !channelContext) && !override) {
initial += showOptions === 'all' ? OPTIONS_HEIGHT : SINGLE_OPTION_HEIGHT;
}
let labels = 0;
if (!override && !user.isBot) {
if (user.nickname) {
labels += 1;
}
if (user.position) {
labels += 1;
}
if (localTime) {
labels += 1;
}
initial += (labels * LABEL_HEIGHT);
}
return [initial + insets.bottom + EXTRA_HEIGHT, 10];
}, [
isChannelAdmin, isDirectMessage, isSystemAdmin,
isTeamAdmin, user, localTime, insets.bottom, override,
]);
useEffect(() => {
if (currentUserId !== user.id) {
fetchTeamAndChannelMembership(serverUrl, user.id, teamId, channelId);
}
}, []);
const renderContent = () => {
return (
<>
<UserProfileTitle
enablePostIconOverride={enablePostIconOverride}
enablePostUsernameOverride={enablePostUsernameOverride}
isChannelAdmin={isChannelAdmin}
isSystemAdmin={isSystemAdmin}
isTeamAdmin={isTeamAdmin}
teammateDisplayName={teammateDisplayName}
user={user}
userIconOverride={userIconOverride}
usernameOverride={usernameOverride}
/>
{(!isDirectMessage || !channelContext) && !override &&
<UserProfileOptions
location={location}
type={showOptions}
username={user.username}
userId={user.id}
/>
}
{Boolean(user.nickname) && !override && !user.isBot &&
<UserProfileLabel
description={user.nickname}
title={formatMessage({id: 'channel_info.nickname', defaultMessage: 'Nickname'})}
/>
}
{Boolean(user.position) && !override && !user.isBot &&
<UserProfileLabel
description={user.position}
title={formatMessage({id: 'channel_info.position', defaultMessage: 'Position'})}
/>
}
{Boolean(localTime) && !override && !user.isBot &&
<UserProfileLabel
description={localTime!}
title={formatMessage({id: 'channel_info.local_time', defaultMessage: 'Local Time'})}
/>
}
</>
);
};
return (
<BottomSheet
renderContent={renderContent}
closeButtonId='close-post-options'
componentId={Screens.USER_PROFILE}
initialSnapIndex={0}
snapPoints={snapPoints}
testID='post_options'
/>
);
};
export default UserProfile;

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Keyboard} from 'react-native';
import {Keyboard, Platform} from 'react-native';
import {OptionsModalPresentationStyle} from 'react-native-navigation';
import {dismissAllModals, showModalOverCurrentContext} from '@screens/navigation';
@ -23,7 +23,10 @@ export const displayPermalink = async (teamName: string, postId: string, openAsP
};
const options = {
modalPresentationStyle: OptionsModalPresentationStyle.overFullScreen,
modalPresentationStyle: Platform.select({
ios: OptionsModalPresentationStyle.overFullScreen,
default: OptionsModalPresentationStyle.overCurrentContext,
}),
layout: {
componentBackgroundColor: changeOpacity('#000', 0.2),
},