In-Channel experience (#6141)
* User avatar stack * In-Channel experience * Misc Fixes * Fixed fetchPostThread & added observer * Reusing the user component * Refactor fix * Moved some post options to common post options * Combined follow/unfollow functions * Feedback fixes * Feedback fixes * teamId fix * Fixed teamId again Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
This commit is contained in:
parent
1996224a4c
commit
313fe9c469
30 changed files with 406 additions and 62 deletions
|
|
@ -8,7 +8,7 @@ import DatabaseManager from '@database/manager';
|
|||
import NetworkManager from '@managers/network_manager';
|
||||
import {getChannelById} from '@queries/servers/channel';
|
||||
import {getPostById} from '@queries/servers/post';
|
||||
import {getCommonSystemValues} from '@queries/servers/system';
|
||||
import {getCommonSystemValues, getCurrentTeamId} from '@queries/servers/system';
|
||||
import {getIsCRTEnabled, getNewestThreadInTeam, getThreadById} from '@queries/servers/thread';
|
||||
import {getCurrentUser} from '@queries/servers/user';
|
||||
|
||||
|
|
@ -180,6 +180,12 @@ export const updateThreadRead = async (serverUrl: string, teamId: string, thread
|
|||
};
|
||||
|
||||
export const updateThreadFollowing = async (serverUrl: string, teamId: string, threadId: string, state: boolean) => {
|
||||
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
||||
|
||||
if (!database) {
|
||||
return {error: `${serverUrl} database not found`};
|
||||
}
|
||||
|
||||
let client;
|
||||
try {
|
||||
client = NetworkManager.getClient(serverUrl);
|
||||
|
|
@ -187,8 +193,14 @@ export const updateThreadFollowing = async (serverUrl: string, teamId: string, t
|
|||
return {error};
|
||||
}
|
||||
|
||||
// DM/GM doesn't have a teamId, so we pass the current team id
|
||||
let threadTeamId = teamId;
|
||||
if (!threadTeamId) {
|
||||
threadTeamId = await getCurrentTeamId(database);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await client.updateThreadFollow('me', teamId, threadId, state);
|
||||
const data = await client.updateThreadFollow('me', threadTeamId, threadId, state);
|
||||
|
||||
// Update locally
|
||||
await updateThread(serverUrl, threadId, {is_following: state});
|
||||
|
|
|
|||
|
|
@ -278,7 +278,8 @@ const ClientChannels = (superclass: any) => class extends superclass {
|
|||
};
|
||||
|
||||
viewMyChannel = async (channelId: string, prevChannelId?: string) => {
|
||||
const data = {channel_id: channelId, prev_channel_id: prevChannelId};
|
||||
// collapsed_threads_supported is not based on user preferences but to know if "CLIENT" supports CRT
|
||||
const data = {channel_id: channelId, prev_channel_id: prevChannelId, collapsed_threads_supported: true};
|
||||
return this.doFetch(
|
||||
`${this.getChannelsRoute()}/members/me/view`,
|
||||
{method: 'post', body: data},
|
||||
|
|
|
|||
|
|
@ -4,13 +4,12 @@
|
|||
import Clipboard from '@react-native-community/clipboard';
|
||||
import React, {useCallback} from 'react';
|
||||
|
||||
import {BaseOption} from '@components/common_post_options';
|
||||
import {Screens} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {t} from '@i18n';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
|
||||
import BaseOption from '../base_option';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
||||
type Props = {
|
||||
|
|
@ -3,26 +3,28 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import {updateThreadFollowing} from '@actions/remote/thread';
|
||||
import {BaseOption} from '@components/common_post_options';
|
||||
import {Screens} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {t} from '@i18n';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
|
||||
import BaseOption from './base_option';
|
||||
import type ThreadModel from '@typings/database/models/servers/thread';
|
||||
|
||||
type FollowThreadOptionProps = {
|
||||
thread?: any;
|
||||
location?: typeof Screens[keyof typeof Screens];
|
||||
thread: ThreadModel;
|
||||
teamId?: string;
|
||||
};
|
||||
|
||||
//todo: to implement CRT follow thread
|
||||
|
||||
const FollowThreadOption = ({thread}: FollowThreadOptionProps) => {
|
||||
const FollowThreadOption = ({thread, teamId}: FollowThreadOptionProps) => {
|
||||
let id: string;
|
||||
let defaultMessage: string;
|
||||
let icon: string;
|
||||
|
||||
if (thread.is_following) {
|
||||
if (thread.isFollowing) {
|
||||
icon = 'message-minus-outline';
|
||||
if (thread?.participants?.length) {
|
||||
if (thread.replyCount) {
|
||||
id = t('threads.unfollowThread');
|
||||
defaultMessage = 'Unfollow Thread';
|
||||
} else {
|
||||
|
|
@ -31,7 +33,7 @@ const FollowThreadOption = ({thread}: FollowThreadOptionProps) => {
|
|||
}
|
||||
} else {
|
||||
icon = 'message-plus-outline';
|
||||
if (thread?.participants?.length) {
|
||||
if (thread.replyCount) {
|
||||
id = t('threads.followThread');
|
||||
defaultMessage = 'Follow Thread';
|
||||
} else {
|
||||
|
|
@ -40,8 +42,14 @@ const FollowThreadOption = ({thread}: FollowThreadOptionProps) => {
|
|||
}
|
||||
}
|
||||
|
||||
const serverUrl = useServerUrl();
|
||||
|
||||
const handleToggleFollow = () => {
|
||||
//todo:
|
||||
if (teamId == null) {
|
||||
return;
|
||||
}
|
||||
updateThreadFollowing(serverUrl, teamId, thread.id, !thread.isFollowing);
|
||||
dismissBottomSheet(Screens.POST_OPTIONS);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
|
||||
import {observeTeamIdByThread} from '@queries/servers/thread';
|
||||
|
||||
import FollowThreadOption from './follow_thread_option';
|
||||
|
||||
import type ThreadModel from '@typings/database/models/servers/thread';
|
||||
|
||||
const enhanced = withObservables(['thread'], ({thread}: { thread: ThreadModel }) => {
|
||||
return {
|
||||
teamId: observeTeamIdByThread(thread),
|
||||
};
|
||||
});
|
||||
|
||||
export default enhanced(FollowThreadOption);
|
||||
8
app/components/common_post_options/index.ts
Normal file
8
app/components/common_post_options/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {default as BaseOption} from './base_option';
|
||||
export {default as CopyPermalinkOption} from './copy_permalink_option';
|
||||
export {default as FollowThreadOption} from './follow_thread_option';
|
||||
export {default as ReplyOption} from './reply_option';
|
||||
export {default as SaveOption} from './save_option';
|
||||
|
|
@ -4,24 +4,24 @@
|
|||
import React, {useCallback} from 'react';
|
||||
|
||||
import {fetchAndSwitchToThread} from '@actions/remote/thread';
|
||||
import {BaseOption} from '@components/common_post_options';
|
||||
import {Screens} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {t} from '@i18n';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
|
||||
import BaseOption from './base_option';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
||||
type Props = {
|
||||
post: PostModel;
|
||||
location?: typeof Screens[keyof typeof Screens];
|
||||
}
|
||||
const ReplyOption = ({post}: Props) => {
|
||||
const ReplyOption = ({post, location}: Props) => {
|
||||
const serverUrl = useServerUrl();
|
||||
|
||||
const handleReply = useCallback(async () => {
|
||||
const rootId = post.rootId || post.id;
|
||||
await dismissBottomSheet(Screens.POST_OPTIONS);
|
||||
await dismissBottomSheet(location || Screens.POST_OPTIONS);
|
||||
fetchAndSwitchToThread(serverUrl, rootId);
|
||||
}, [post, serverUrl]);
|
||||
|
||||
|
|
@ -4,13 +4,12 @@
|
|||
import React, {useCallback} from 'react';
|
||||
|
||||
import {deleteSavedPost, savePostPreference} from '@actions/remote/preference';
|
||||
import {BaseOption} from '@components/common_post_options';
|
||||
import {Screens} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {t} from '@i18n';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
|
||||
import BaseOption from './base_option';
|
||||
|
||||
type CopyTextProps = {
|
||||
isSaved: boolean;
|
||||
postId: string;
|
||||
|
|
@ -31,6 +31,7 @@ type Props = {
|
|||
currentUsername: string;
|
||||
highlightedId?: PostModel['id'];
|
||||
highlightPinnedOrSaved?: boolean;
|
||||
isCRTEnabled?: boolean;
|
||||
isTimezoneEnabled: boolean;
|
||||
lastViewedAt: number;
|
||||
location: string;
|
||||
|
|
@ -82,6 +83,7 @@ const PostList = ({
|
|||
footer,
|
||||
highlightedId,
|
||||
highlightPinnedOrSaved = true,
|
||||
isCRTEnabled,
|
||||
isTimezoneEnabled,
|
||||
lastViewedAt,
|
||||
location,
|
||||
|
|
@ -300,6 +302,7 @@ const PostList = ({
|
|||
|
||||
return (
|
||||
<Post
|
||||
isCRTEnabled={isCRTEnabled}
|
||||
key={item.id}
|
||||
post={item}
|
||||
style={styles.scale}
|
||||
|
|
@ -307,7 +310,7 @@ const PostList = ({
|
|||
{...postProps}
|
||||
/>
|
||||
);
|
||||
}, [currentTimezone, highlightPinnedOrSaved, isTimezoneEnabled, orderedPosts, shouldRenderReplyButton, theme]);
|
||||
}, [currentTimezone, highlightPinnedOrSaved, isCRTEnabled, isTimezoneEnabled, orderedPosts, shouldRenderReplyButton, theme]);
|
||||
|
||||
const scrollToIndex = useCallback((index: number, animated = true, applyOffset = true) => {
|
||||
listRef.current?.scrollToIndex({
|
||||
|
|
|
|||
175
app/components/post_list/post/footer/footer.tsx
Normal file
175
app/components/post_list/post/footer/footer.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {TouchableOpacity, View} from 'react-native';
|
||||
|
||||
import {updateThreadFollowing} from '@actions/remote/thread';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import UserAvatarsStack from '@components/user_avatars_stack';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import type ThreadModel from '@typings/database/models/servers/thread';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
type Props = {
|
||||
participants: UserModel[];
|
||||
teamId?: string;
|
||||
testID: string;
|
||||
thread: ThreadModel;
|
||||
};
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
const followingButtonContainerBase = {
|
||||
justifyContent: 'center',
|
||||
height: 32,
|
||||
paddingHorizontal: 12,
|
||||
};
|
||||
|
||||
return {
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minHeight: 40,
|
||||
},
|
||||
avatarsContainer: {
|
||||
marginRight: 12,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
replyIconContainer: {
|
||||
top: -1,
|
||||
marginRight: 5,
|
||||
},
|
||||
replies: {
|
||||
alignSelf: 'center',
|
||||
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||
marginRight: 12,
|
||||
...typography('Heading', 75),
|
||||
},
|
||||
notFollowingButtonContainer: {
|
||||
...followingButtonContainerBase,
|
||||
paddingLeft: 0,
|
||||
},
|
||||
notFollowing: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||
...typography('Heading', 75),
|
||||
},
|
||||
followingButtonContainer: {
|
||||
...followingButtonContainerBase,
|
||||
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
|
||||
borderRadius: 4,
|
||||
},
|
||||
following: {
|
||||
color: theme.buttonBg,
|
||||
...typography('Heading', 75),
|
||||
},
|
||||
followSeparator: {
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||
height: 16,
|
||||
marginRight: 12,
|
||||
width: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const Footer = ({participants, teamId, testID, thread}: Props) => {
|
||||
const serverUrl = useServerUrl();
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const toggleFollow = useCallback(preventDoubleTap(() => {
|
||||
if (teamId == null) {
|
||||
return;
|
||||
}
|
||||
updateThreadFollowing(serverUrl, teamId, thread.id, !thread.isFollowing);
|
||||
}), [thread.isFollowing]);
|
||||
|
||||
let repliesComponent;
|
||||
let followButton;
|
||||
if (thread.replyCount) {
|
||||
repliesComponent = (
|
||||
<>
|
||||
<View style={styles.replyIconContainer}>
|
||||
<CompassIcon
|
||||
name='reply-outline'
|
||||
size={18}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||
/>
|
||||
</View>
|
||||
<FormattedText
|
||||
style={styles.replies}
|
||||
testID={`${testID}.reply_count`}
|
||||
id='threads.replies'
|
||||
defaultMessage='{count} {count, plural, one {reply} other {replies}}'
|
||||
values={{
|
||||
count: thread.replyCount,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (thread.isFollowing) {
|
||||
followButton = (
|
||||
<TouchableOpacity
|
||||
onPress={toggleFollow}
|
||||
style={styles.followingButtonContainer}
|
||||
testID={`${testID}.following`}
|
||||
>
|
||||
<FormattedText
|
||||
id='threads.following'
|
||||
defaultMessage='Following'
|
||||
style={styles.following}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
} else {
|
||||
followButton = (
|
||||
<>
|
||||
<View style={styles.followSeparator}/>
|
||||
<TouchableOpacity
|
||||
onPress={toggleFollow}
|
||||
style={styles.notFollowingButtonContainer}
|
||||
testID={`${testID}.follow`}
|
||||
>
|
||||
<FormattedText
|
||||
id='threads.follow'
|
||||
defaultMessage='Follow'
|
||||
style={styles.notFollowing}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const participantsList = useMemo(() => {
|
||||
if (participants?.length) {
|
||||
const orderedParticipantsList = [...participants].reverse();
|
||||
return orderedParticipantsList;
|
||||
}
|
||||
return [];
|
||||
}, [participants.length]);
|
||||
|
||||
let userAvatarsStack;
|
||||
if (participantsList.length) {
|
||||
userAvatarsStack = (
|
||||
<UserAvatarsStack
|
||||
style={styles.avatarsContainer}
|
||||
users={participantsList}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{userAvatarsStack}
|
||||
{repliesComponent}
|
||||
{followButton}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
24
app/components/post_list/post/footer/index.ts
Normal file
24
app/components/post_list/post/footer/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// 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 {observeTeamIdByThread, queryThreadParticipants} from '@queries/servers/thread';
|
||||
|
||||
import Footer from './footer';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type ThreadModel from '@typings/database/models/servers/thread';
|
||||
|
||||
const enhanced = withObservables(
|
||||
['thread'],
|
||||
({database, thread}: WithDatabaseArgs & {thread: ThreadModel}) => {
|
||||
return {
|
||||
participants: queryThreadParticipants(database, thread.id).observe(),
|
||||
teamId: observeTeamIdByThread(thread),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export default withDatabase(enhanced(Footer));
|
||||
|
|
@ -27,6 +27,7 @@ type HeaderProps = {
|
|||
currentUser: UserModel;
|
||||
enablePostUsernameOverride: boolean;
|
||||
isAutoResponse: boolean;
|
||||
isCRTEnabled?: boolean;
|
||||
isCustomStatusEnabled: boolean;
|
||||
isEphemeral: boolean;
|
||||
isMilitaryTime: boolean;
|
||||
|
|
@ -71,7 +72,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
|||
|
||||
const Header = (props: HeaderProps) => {
|
||||
const {
|
||||
author, commentCount = 0, currentUser, enablePostUsernameOverride, isAutoResponse, isCustomStatusEnabled,
|
||||
author, commentCount = 0, currentUser, enablePostUsernameOverride, isAutoResponse, isCRTEnabled, isCustomStatusEnabled,
|
||||
isEphemeral, isMilitaryTime, isPendingOrFailed, isSystemPost, isTimezoneEnabled, isWebHook,
|
||||
location, post, rootPostAuthor, shouldRenderReplyButton, teammateNameDisplay,
|
||||
} = props;
|
||||
|
|
@ -124,13 +125,13 @@ const Header = (props: HeaderProps) => {
|
|||
style={style.time}
|
||||
testID='post_header.date_time'
|
||||
/>
|
||||
{showReply && commentCount > 0 &&
|
||||
<HeaderReply
|
||||
commentCount={commentCount}
|
||||
location={location}
|
||||
post={post}
|
||||
theme={theme}
|
||||
/>
|
||||
{!isCRTEnabled && showReply && commentCount > 0 &&
|
||||
<HeaderReply
|
||||
commentCount={commentCount}
|
||||
location={location}
|
||||
post={post}
|
||||
theme={theme}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@ const withHeaderProps = withObservables(
|
|||
isCustomStatusEnabled,
|
||||
isMilitaryTime,
|
||||
isTimezoneEnabled,
|
||||
teammateNameDisplay,
|
||||
rootPostAuthor,
|
||||
teammateNameDisplay,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {queryPostsBetween} from '@queries/servers/post';
|
|||
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
|
||||
import {observeCanManageChannelMembers, observePermissionForPost} from '@queries/servers/role';
|
||||
import {observeConfigBooleanValue} from '@queries/servers/system';
|
||||
import {observeThreadById} from '@queries/servers/thread';
|
||||
import {observeCurrentUser} from '@queries/servers/user';
|
||||
import {hasJumboEmojiOnly} from '@utils/emoji/helpers';
|
||||
import {areConsecutivePosts, isPostEphemeral} from '@utils/post';
|
||||
|
|
@ -28,6 +29,7 @@ import type UserModel from '@typings/database/models/servers/user';
|
|||
type PropsInput = WithDatabaseArgs & {
|
||||
appsEnabled: boolean;
|
||||
currentUser: UserModel;
|
||||
isCRTEnabled?: boolean;
|
||||
nextPost: PostModel | undefined;
|
||||
post: PostModel;
|
||||
previousPost: PostModel | undefined;
|
||||
|
|
@ -92,8 +94,8 @@ const withSystem = withObservables([], ({database}: WithDatabaseArgs) => ({
|
|||
}));
|
||||
|
||||
const withPost = withObservables(
|
||||
['currentUser', 'post', 'previousPost', 'nextPost'],
|
||||
({appsEnabled, currentUser, database, post, previousPost, nextPost}: PropsInput) => {
|
||||
['currentUser', 'isCRTEnabled', 'post', 'previousPost', 'nextPost'],
|
||||
({appsEnabled, currentUser, database, isCRTEnabled, post, previousPost, nextPost}: PropsInput) => {
|
||||
let isJumboEmoji = of$(false);
|
||||
let isLastReply = of$(true);
|
||||
let isPostAddChannelMember = of$(false);
|
||||
|
|
@ -150,6 +152,7 @@ const withPost = withObservables(
|
|||
isLastReply,
|
||||
isPostAddChannelMember,
|
||||
post: post.observe(),
|
||||
thread: isCRTEnabled ? observeThreadById(database, post.id) : of$(undefined),
|
||||
reactionsCount: post.reactions.observeCount(),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,11 +21,14 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
|||
|
||||
import Avatar from './avatar';
|
||||
import Body from './body';
|
||||
import Footer from './footer';
|
||||
import Header from './header';
|
||||
import PreHeader from './pre_header';
|
||||
import SystemMessage from './system_message';
|
||||
import UnreadDot from './unread_dot';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type ThreadModel from '@typings/database/models/servers/thread';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
type PostProps = {
|
||||
|
|
@ -39,6 +42,7 @@ type PostProps = {
|
|||
highlightPinnedOrSaved?: boolean;
|
||||
highlightReplyBar: boolean;
|
||||
isConsecutivePost?: boolean;
|
||||
isCRTEnabled?: boolean;
|
||||
isEphemeral: boolean;
|
||||
isFirstReply?: boolean;
|
||||
isSaved?: boolean;
|
||||
|
|
@ -55,6 +59,7 @@ type PostProps = {
|
|||
skipPinnedHeader?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
thread?: ThreadModel;
|
||||
};
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
|
|
@ -97,9 +102,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
|||
|
||||
const Post = ({
|
||||
appsEnabled, canDelete, currentUser, differentThreadSequence, filesCount, hasReplies, highlight, highlightPinnedOrSaved = true, highlightReplyBar,
|
||||
isConsecutivePost, isEphemeral, isFirstReply, isSaved, isJumboEmoji, isLastReply, isPostAddChannelMember,
|
||||
isCRTEnabled, isConsecutivePost, isEphemeral, isFirstReply, isSaved, isJumboEmoji, isLastReply, isPostAddChannelMember,
|
||||
location, post, reactionsCount, shouldRenderReplyButton, skipSavedHeader, skipPinnedHeader, showAddReaction = true, style,
|
||||
testID, previousPost,
|
||||
testID, thread, previousPost,
|
||||
}: PostProps) => {
|
||||
const pressDetected = useRef(false);
|
||||
const intl = useIntl();
|
||||
|
|
@ -223,6 +228,7 @@ const Post = ({
|
|||
currentUser={currentUser}
|
||||
differentThreadSequence={differentThreadSequence}
|
||||
isAutoResponse={isAutoResponder}
|
||||
isCRTEnabled={isCRTEnabled}
|
||||
isEphemeral={isEphemeral}
|
||||
isPendingOrFailed={isPendingOrFailed}
|
||||
isSystemPost={isSystemPost}
|
||||
|
|
@ -264,6 +270,24 @@ const Post = ({
|
|||
);
|
||||
}
|
||||
|
||||
let unreadDot;
|
||||
let footer;
|
||||
if (isCRTEnabled && thread) {
|
||||
if (thread.replyCount > 0 || thread.isFollowing) {
|
||||
footer = (
|
||||
<Footer
|
||||
testID={`${itemTestID}.footer`}
|
||||
thread={thread}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (thread.unreadMentions || thread.unreadReplies) {
|
||||
unreadDot = (
|
||||
<UnreadDot testID={`${itemTestID}.badge`}/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
testID={testID}
|
||||
|
|
@ -289,7 +313,9 @@ const Post = ({
|
|||
<View style={rightColumnStyle}>
|
||||
{header}
|
||||
{body}
|
||||
{footer}
|
||||
</View>
|
||||
{unreadDot}
|
||||
</View>
|
||||
</>
|
||||
</TouchableHighlight>
|
||||
|
|
|
|||
46
app/components/post_list/post/unread_dot.tsx
Normal file
46
app/components/post_list/post/unread_dot.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {View} from 'react-native';
|
||||
|
||||
import {useTheme} from '@context/theme';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
type Props = {
|
||||
testID: string;
|
||||
};
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
badgeContainer: {
|
||||
position: 'absolute',
|
||||
left: 21,
|
||||
bottom: 9,
|
||||
},
|
||||
unreadDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.sidebarTextActiveBorder,
|
||||
alignSelf: 'center',
|
||||
top: -6,
|
||||
left: 4,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const UnreadDot = ({testID}: Props) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
return (
|
||||
<View
|
||||
style={styles.badgeContainer}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.unreadDot}/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnreadDot;
|
||||
|
|
@ -5,8 +5,11 @@ import {Database, Model, Q, Query} from '@nozbe/watermelondb';
|
|||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {Preferences} from '@constants';
|
||||
import {MM_TABLES} from '@constants/database';
|
||||
|
||||
import {queryPreferencesByCategoryAndName} from './preference';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type PostInChannelModel from '@typings/database/models/servers/posts_in_channel';
|
||||
import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread';
|
||||
|
|
@ -51,6 +54,14 @@ export const observePost = (database: Database, postId: string) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const observePostSaved = (database: Database, postId: string) => {
|
||||
return queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SAVED_POST, postId).observe().pipe(
|
||||
switchMap(
|
||||
(pref) => of$(Boolean(pref[0]?.value === 'true')),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const queryPostsInChannel = (database: Database, channelId: string) => {
|
||||
return database.get<PostInChannelModel>(POSTS_IN_CHANNEL).query(
|
||||
Q.where('channel_id', channelId),
|
||||
|
|
|
|||
|
|
@ -52,6 +52,19 @@ export const observeThreadById = (database: Database, threadId: string) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const observeTeamIdByThread = (thread: ThreadModel) => {
|
||||
return thread.post.observe().pipe(
|
||||
switchMap((post) => {
|
||||
if (!post) {
|
||||
return of$(undefined);
|
||||
}
|
||||
return post.channel.observe().pipe(
|
||||
switchMap((channel) => of$(channel?.teamId)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const observeUnreadsAndMentionsInTeam = (database: Database, teamId?: string, includeDmGm?: boolean): Observable<{unreads: number; mentions: number}> => {
|
||||
const observeThreads = () => queryThreads(database, teamId, true, includeDmGm).
|
||||
observeWithColumns(['unread_replies', 'unread_mentions']).
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ type Props = {
|
|||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
currentTimezone: string | null;
|
||||
currentUsername: string;
|
||||
isCRTEnabled: boolean;
|
||||
isTimezoneEnabled: boolean;
|
||||
lastViewedAt: number;
|
||||
nativeID: string;
|
||||
|
|
@ -36,7 +37,7 @@ const styles = StyleSheet.create({
|
|||
|
||||
const ChannelPostList = ({
|
||||
channelId, contentContainerStyle, currentTimezone, currentUsername,
|
||||
isTimezoneEnabled, lastViewedAt, nativeID, posts, shouldShowJoinLeaveMessages,
|
||||
isCRTEnabled, isTimezoneEnabled, lastViewedAt, nativeID, posts, shouldShowJoinLeaveMessages,
|
||||
}: Props) => {
|
||||
const isTablet = useIsTablet();
|
||||
const serverUrl = useServerUrl();
|
||||
|
|
@ -61,6 +62,7 @@ const ChannelPostList = ({
|
|||
contentContainerStyle={contentContainerStyle}
|
||||
currentTimezone={currentTimezone}
|
||||
currentUsername={currentUsername}
|
||||
isCRTEnabled={isCRTEnabled}
|
||||
isTimezoneEnabled={isTimezoneEnabled}
|
||||
footer={intro}
|
||||
lastViewedAt={lastViewedAt}
|
||||
|
|
@ -89,4 +91,3 @@ const ChannelPostList = ({
|
|||
};
|
||||
|
||||
export default ChannelPostList;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ import withObservables from '@nozbe/with-observables';
|
|||
import {combineLatest, of as of$, Observable} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {General, Permissions, Post, Preferences, Screens} from '@constants';
|
||||
import {General, Permissions, Post, Screens} from '@constants';
|
||||
import {MAX_ALLOWED_REACTIONS} from '@constants/emoji';
|
||||
import {observePost} from '@queries/servers/post';
|
||||
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
|
||||
import {observePost, observePostSaved} from '@queries/servers/post';
|
||||
import {observePermissionForChannel, observePermissionForPost} from '@queries/servers/role';
|
||||
import {observeConfig, observeLicense} from '@queries/servers/system';
|
||||
import {observeIsCRTEnabled, observeThreadById} from '@queries/servers/thread';
|
||||
import {observeCurrentUser} from '@queries/servers/user';
|
||||
import {isMinimumServerVersion} from '@utils/helpers';
|
||||
import {isSystemMessage} from '@utils/post';
|
||||
|
|
@ -112,7 +112,7 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, loca
|
|||
return of$(!isSystemMessage(post) && !isArchived && !isReadOnly);
|
||||
}));
|
||||
|
||||
const isSaved = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SAVED_POST, post.id).observe().pipe(switchMap((pref) => of$(Boolean(pref[0]?.value === 'true'))));
|
||||
const isSaved = observePostSaved(database, post.id);
|
||||
|
||||
const canEdit = combineLatest([postEditTimeLimit, isLicensed, channel, currentUser, channelIsArchived, channelIsReadOnly, canEditUntil, canPostPermission]).pipe(
|
||||
switchMap(([lt, ls, c, u, isArchived, isReadOnly, until, canPost]) => {
|
||||
|
|
@ -140,6 +140,10 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, loca
|
|||
return of$(permission && !isArchived && !isReadOnly && canPost);
|
||||
}));
|
||||
|
||||
const thread = observeIsCRTEnabled(database).pipe(
|
||||
switchMap((enabled) => (enabled ? observeThreadById(database, post.id) : of$(undefined))),
|
||||
);
|
||||
|
||||
return {
|
||||
canMarkAsUnread,
|
||||
canAddReaction,
|
||||
|
|
@ -150,6 +154,7 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, loca
|
|||
isSaved,
|
||||
canEdit,
|
||||
post,
|
||||
thread,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,11 @@
|
|||
import Clipboard from '@react-native-community/clipboard';
|
||||
import React, {useCallback} from 'react';
|
||||
|
||||
import {BaseOption} from '@components/common_post_options';
|
||||
import {Screens} from '@constants';
|
||||
import {t} from '@i18n';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
|
||||
import BaseOption from './base_option';
|
||||
|
||||
type Props = {
|
||||
postMessage: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,12 @@ import {useIntl} from 'react-intl';
|
|||
import {Alert} from 'react-native';
|
||||
|
||||
import {deletePost} from '@actions/remote/post';
|
||||
import {BaseOption} from '@components/common_post_options';
|
||||
import {Screens} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {t} from '@i18n';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
|
||||
import BaseOption from './base_option';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
||||
type Props = {
|
||||
|
|
|
|||
|
|
@ -4,14 +4,13 @@
|
|||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import {BaseOption} from '@components/common_post_options';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {Screens} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {t} from '@i18n';
|
||||
import {dismissBottomSheet, showModal} from '@screens/navigation';
|
||||
|
||||
import BaseOption from './base_option';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
|
||||
type Props = {
|
||||
|
|
|
|||
|
|
@ -4,13 +4,12 @@
|
|||
import React, {useCallback} from 'react';
|
||||
|
||||
import {markPostAsUnread} from '@actions/remote/post';
|
||||
import {BaseOption} from '@components/common_post_options';
|
||||
import Screens from '@constants/screens';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {t} from '@i18n';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
|
||||
import BaseOption from './base_option';
|
||||
|
||||
type Props = {
|
||||
postId: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,12 @@
|
|||
import React, {useCallback} from 'react';
|
||||
|
||||
import {togglePinPost} from '@actions/remote/post';
|
||||
import {BaseOption} from '@components/common_post_options';
|
||||
import {Screens} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {t} from '@i18n';
|
||||
import {dismissBottomSheet} from '@screens/navigation';
|
||||
|
||||
import BaseOption from './base_option';
|
||||
|
||||
type PinChannelProps = {
|
||||
isPostPinned: boolean;
|
||||
postId: string;
|
||||
|
|
|
|||
|
|
@ -5,24 +5,22 @@ import {useManagedConfig} from '@mattermost/react-native-emm';
|
|||
import React, {useEffect} from 'react';
|
||||
import {Navigation} from 'react-native-navigation';
|
||||
|
||||
import {CopyPermalinkOption, FollowThreadOption, ReplyOption, SaveOption} from '@components/common_post_options';
|
||||
import {ITEM_HEIGHT} from '@components/menu_item';
|
||||
import {Screens} from '@constants';
|
||||
import BottomSheet from '@screens/bottom_sheet';
|
||||
import {dismissModal} from '@screens/navigation';
|
||||
import {isSystemMessage} from '@utils/post';
|
||||
|
||||
import CopyLinkOption from './options/copy_permalink_option';
|
||||
import CopyTextOption from './options/copy_text_option';
|
||||
import DeletePostOption from './options/delete_post_option';
|
||||
import EditOption from './options/edit_option';
|
||||
import FollowThreadOption from './options/follow_option';
|
||||
import MarkAsUnreadOption from './options/mark_unread_option';
|
||||
import PinChannelOption from './options/pin_channel_option';
|
||||
import ReplyOption from './options/reply_option';
|
||||
import SaveOption from './options/save_option';
|
||||
import ReactionBar from './reaction_bar';
|
||||
|
||||
import type PostModel from '@typings/database/models/servers/post';
|
||||
import type ThreadModel from '@typings/database/models/servers/thread';
|
||||
|
||||
type PostOptionsProps = {
|
||||
canAddReaction: boolean;
|
||||
|
|
@ -35,7 +33,7 @@ type PostOptionsProps = {
|
|||
isSaved: boolean;
|
||||
location: typeof Screens[keyof typeof Screens];
|
||||
post: PostModel;
|
||||
thread: Partial<PostModel>;
|
||||
thread?: ThreadModel;
|
||||
componentId: string;
|
||||
};
|
||||
|
||||
|
|
@ -93,15 +91,12 @@ const PostOptions = ({
|
|||
{canAddReaction && <ReactionBar postId={post.id}/>}
|
||||
{canReply && <ReplyOption post={post}/>}
|
||||
{shouldRenderFollow &&
|
||||
<FollowThreadOption
|
||||
location={location}
|
||||
thread={thread}
|
||||
/>
|
||||
<FollowThreadOption thread={thread}/>
|
||||
}
|
||||
{canMarkAsUnread && !isSystemPost &&
|
||||
<MarkAsUnreadOption postId={post.id}/>
|
||||
}
|
||||
{canCopyPermalink && <CopyLinkOption post={post}/>}
|
||||
{canCopyPermalink && <CopyPermalinkOption post={post}/>}
|
||||
{!isSystemPost &&
|
||||
<SaveOption
|
||||
isSaved={isSaved}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
|||
},
|
||||
text: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
...typography('Heading', 75, 'SemiBold'),
|
||||
...typography('Heading', 75),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -520,6 +520,7 @@
|
|||
"threads.following": "Following",
|
||||
"threads.followMessage": "Follow Message",
|
||||
"threads.followThread": "Follow Thread",
|
||||
"threads.newReplies": "{count} new {count, plural, one {reply} other {replies}}",
|
||||
"threads.unfollowMessage": "Unfollow Message",
|
||||
"threads.unfollowThread": "Unfollow Thread",
|
||||
"user.edit_profile.email.auth_service": "Login occurs through {service}. Email cannot be updated. Email address used for notifications is {email}.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue