Gekidou fixes (#6274)

* Add error boundry around latex to avoid crash

* Do not update channel display name when displayName is empty

* Show Muted unread channel as muted unless it has mentions

* Video size to wrapperWidth if thumbnail is not available

* Hide Threads button in channel list when only unreads filter if it does not have any unreads or mentions

* Ensure channel_item muted state is bolded when unread

* Bump network default retry to 3 attempts

* Sort only unreads by last root post if CRT is enabled

* Default canPost to true if permission is not found

* rename to ErrorBoundary (typo fix)

* simplify filterAndSortMyChannels

* Fix channel name collision with mention badges
This commit is contained in:
Elias Nahum 2022-05-17 11:07:46 -04:00 committed by GitHub
parent 2323f4aa31
commit 65366e53a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 241 additions and 76 deletions

View file

@ -410,7 +410,7 @@ export async function updateChannelsDisplayName(serverUrl: string, channels: Cha
}
}
if (channel.displayName !== newDisplayName) {
if (newDisplayName && channel.displayName !== newDisplayName) {
channel.prepareUpdate((c) => {
c.displayName = extractChannelDisplayName({
type: c.type,

View file

@ -93,12 +93,14 @@ exports[`components/channel_list/categories/body/channel_item should match snaps
Object {
"color": "rgba(255,255,255,0.72)",
"marginTop": -1,
"paddingHorizontal": 12,
"paddingLeft": 12,
"paddingRight": 20,
},
false,
null,
null,
false,
false,
null,
null,
]
}
testID="channel_list_item.hello.display_name"
@ -204,12 +206,14 @@ exports[`components/channel_list/categories/body/channel_item should match snaps
Object {
"color": "rgba(255,255,255,0.72)",
"marginTop": -1,
"paddingHorizontal": 12,
"paddingLeft": 12,
"paddingRight": 20,
},
false,
null,
null,
false,
false,
null,
null,
]
}
testID="channel_list_item.hello.display_name"

View file

@ -56,7 +56,8 @@ export const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
text: {
marginTop: -1,
color: changeOpacity(theme.sidebarText, 0.72),
paddingHorizontal: 12,
paddingLeft: 12,
paddingRight: 20,
},
highlight: {
color: theme.sidebarText,
@ -68,6 +69,9 @@ export const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
muted: {
color: changeOpacity(theme.sidebarText, 0.32),
},
mutedInfo: {
color: changeOpacity(theme.centerChannelColor, 0.32),
},
badge: {
borderColor: theme.sidebarBg,
position: 'relative',
@ -99,6 +103,9 @@ export const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
marginTop: 4,
...typography('Body', 75),
},
teamNameMuted: {
color: changeOpacity(theme.centerChannelColor, 0.32),
},
teamNameTablet: {
marginLeft: -12,
paddingLeft: 0,
@ -109,7 +116,7 @@ export const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
}));
export const textStyle = StyleSheet.create({
bright: typography('Body', 200, 'SemiBold'),
bold: typography('Body', 200, 'SemiBold'),
regular: typography('Body', 200, 'Regular'),
});
@ -122,8 +129,8 @@ const ChannelListItem = ({
const isTablet = useIsTablet();
const styles = getStyleSheet(theme);
// Make it brighter if it's not muted, and highlighted or has unreads
const isBright = isUnread || mentionsCount > 0;
// Make it bolded if it has unreads or mentions
const isBolded = isUnread || mentionsCount > 0;
const height = useMemo(() => {
let h = 40;
@ -138,13 +145,14 @@ const ChannelListItem = ({
}, [channel.id]);
const textStyles = useMemo(() => [
isBright ? textStyle.bright : textStyle.regular,
isBolded ? textStyle.bold : textStyle.regular,
styles.text,
isBright && styles.highlight,
isMuted && styles.muted,
isBolded && styles.highlight,
isActive && isTablet && !isInfo ? styles.textActive : null,
isInfo ? styles.textInfo : null,
], [isBright, styles, isMuted, isActive, isInfo, isTablet]);
isMuted && styles.muted,
isMuted && isInfo && styles.infoMuted,
], [isBolded, styles, isMuted, isActive, isInfo, isTablet]);
const containerStyle = useMemo(() => [
styles.container,
@ -178,7 +186,7 @@ const ChannelListItem = ({
hasDraft={hasDraft}
isActive={isInfo ? false : isTablet && isActive}
isInfo={isInfo}
isUnread={isBright}
isUnread={isBolded}
isArchived={channel.deleteAt > 0}
membersCount={membersCount}
name={channel.name}
@ -201,7 +209,7 @@ const ChannelListItem = ({
ellipsizeMode='tail'
numberOfLines={1}
testID={`${testID}.${teamDisplayName}.display_name`}
style={styles.teamName}
style={[styles.teamName, isMuted && styles.teamNameMuted]}
>
{teamDisplayName}
</Text>
@ -218,7 +226,7 @@ const ChannelListItem = ({
ellipsizeMode='tail'
numberOfLines={1}
testID={`${testID}.${teamDisplayName}.display_name`}
style={[styles.teamName, styles.teamNameTablet]}
style={[styles.teamName, styles.teamNameTablet, isMuted && styles.teamNameMuted]}
>
{teamDisplayName}
</Text>

View file

@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Text} from 'react-native';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type State = {
hasError: boolean;
}
type Props = {
children: JSX.Element;
error: string;
theme: Theme;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
error: {
color: theme.errorTextColor,
flexDirection: 'row',
flexWrap: 'wrap',
fontStyle: 'italic',
...typography('Body', 100),
},
}));
class ErrorBoundary extends React.PureComponent<Props, State, any> {
constructor(props: Props) {
super(props);
this.state = {hasError: false};
}
componentDidCatch() {
this.setState({hasError: true});
}
render() {
// eslint-disable-next-line react/prop-types
const {children, error, theme} = this.props;
const {hasError} = this.state;
const style = getStyleSheet(theme);
if (hasError) {
return (
<Text style={style.error}>
{error}
</Text>
);
}
return children;
}
}
export default ErrorBoundary;

View file

@ -10,6 +10,7 @@ import MathView from 'react-native-math-view';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import FormattedText from '@components/formatted_text';
import ErrorBoundary from '@components/markdown/error_boundary';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Screens} from '@constants';
@ -194,21 +195,26 @@ const LatexCodeBlock = ({content, theme}: Props) => {
type={'opacity'}
>
<View style={styles.container}>
<View style={styles.rightColumn}>
{split.lines?.map((latexCode) => (
<View
style={styles.code}
key={latexCode}
>
<MathView
math={latexCode}
renderError={onRenderErrorMessage}
resizeMode={'cover'}
/>
</View>
))}
{plusMoreLines}
</View>
<ErrorBoundary
error={intl.formatMessage({id: 'markdown.latex.error', defaultMessage: 'Latex render error'})}
theme={theme}
>
<View style={styles.rightColumn}>
{split.lines?.map((latexCode) => (
<View
style={styles.code}
key={latexCode}
>
<MathView
math={latexCode}
renderError={onRenderErrorMessage}
resizeMode={'cover'}
/>
</View>
))}
{plusMoreLines}
</View>
</ErrorBoundary>
<View style={styles.language}>
<Text style={styles.languageText}>
{languageDisplayName}

View file

@ -2,10 +2,13 @@
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {Platform, Text, View} from 'react-native';
import MathView from 'react-native-math-view';
import ErrorBoundary from '@components/markdown/error_boundary';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
content: string;
@ -27,32 +30,41 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
flexWrap: 'wrap',
},
errorText: {
flexDirection: 'row',
color: theme.errorTextColor,
flexDirection: 'row',
flexWrap: 'wrap',
fontStyle: 'italic',
...typography('Body', 100),
},
};
});
const LatexInline = ({content, maxMathWidth, theme}: Props) => {
const {formatMessage} = useIntl();
const style = getStyleSheet(theme);
const onRenderErrorMessage = (errorMsg: MathViewErrorProps) => {
return <Text style={style.errorText}>{'Latex render error: ' + errorMsg.error.message}</Text>;
const error = formatMessage({id: 'markdown.latex.error', defaultMessage: 'Latex render error'});
return <Text style={style.errorText}>{`${error}: ${errorMsg.error.message}`}</Text>;
};
return (
<View
style={style.viewStyle}
key={content}
<ErrorBoundary
error={formatMessage({id: 'markdown.latex.error', defaultMessage: 'Latex render error'})}
theme={theme}
>
<MathView
style={[style.mathStyle, {maxWidth: maxMathWidth || '100%'}]}
math={content}
renderError={onRenderErrorMessage}
resizeMode='contain'
/>
</View>
<View
style={style.viewStyle}
key={content}
>
<MathView
style={[style.mathStyle, {maxWidth: maxMathWidth || '100%'}]}
math={content}
renderError={onRenderErrorMessage}
resizeMode='contain'
/>
</View>
</ErrorBoundary>
);
};

View file

@ -50,7 +50,7 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) =>
switchMap((id) => observeChannel(database, id!)),
);
const canPost = combineLatest([channel, currentUser]).pipe(switchMap(([c, u]) => (c && u ? observePermissionForChannel(c, u, Permissions.CREATE_POST, false) : of$(false))));
const canPost = combineLatest([channel, currentUser]).pipe(switchMap(([c, u]) => (c && u ? observePermissionForChannel(c, u, Permissions.CREATE_POST, true) : of$(true))));
const channelIsArchived = channel.pipe(switchMap((c) => (ownProps.channelIsArchived ? of$(true) : of$(c?.deleteAt !== 0))));
const experimentalTownSquareIsReadOnly = observeConfigBooleanValue(database, 'ExperimentalTownSquareIsReadOnly');

View file

@ -95,7 +95,7 @@ const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, l
filesForGallery.value[idx] = file;
};
const isSingleImage = () => (filesInfo.length === 1 && isImage(filesInfo[0]));
const isSingleImage = () => (filesInfo.length === 1 && (isImage(filesInfo[0]) || isVideo(filesInfo[0])));
const renderItems = (items: FileInfo[], moreImagesCount = 0, includeGutter = false) => {
const singleImage = isSingleImage();

View file

@ -26,7 +26,7 @@ type Props = {
inViewPort?: boolean;
isSingleImage?: boolean;
resizeMode?: ResizeMode;
wrapperWidth?: number;
wrapperWidth: number;
updateFileForGallery: (idx: number, file: FileInfo) => void;
}
@ -74,7 +74,7 @@ const VideoFile = ({
const imageDimensions = useMemo(() => {
if (isSingleImage) {
const viewPortHeight = Math.max(dimensions.height, dimensions.width) * 0.45;
return calculateDimensions(video.height, video.width, wrapperWidth, viewPortHeight);
return calculateDimensions(video.height || wrapperWidth, video.width || wrapperWidth, wrapperWidth, viewPortHeight);
}
return undefined;
@ -106,6 +106,10 @@ const VideoFile = ({
setVideo(data);
}
} finally {
if (!data.width) {
data.height = wrapperWidth;
data.width = wrapperWidth;
}
const {width: tw, height: th} = calculateDimensions(
data.height,
data.width,

View file

@ -41,7 +41,7 @@ class NetworkManager {
},
retryPolicyConfiguration: {
type: RetryTypes.EXPONENTIAL_RETRY,
retryLimit: 2,
retryLimit: 3,
exponentialBackoffBase: 2,
exponentialBackoffScale: 0.5,
},

View file

@ -14,7 +14,7 @@ import {queryPreferencesByCategoryAndName} from './preference';
import type PostInChannelModel from '@typings/database/models/servers/posts_in_channel';
import type PostsInThreadModel from '@typings/database/models/servers/posts_in_thread';
const {SERVER: {POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLES;
const {SERVER: {CHANNEL, POST, POSTS_IN_CHANNEL, POSTS_IN_THREAD}} = MM_TABLES;
export const prepareDeletePost = async (post: PostModel): Promise<Model[]> => {
const preparedModels: Model[] = [post.prepareDestroyPermanently()];
@ -178,3 +178,30 @@ export const queryPostsBetween = (database: Database, earliest: number, latest:
}
return database.get<PostModel>(POST).query(...clauses);
};
export const observeLastPostAtPerChannelByTeam = (database: Database, teamId: string) => {
return database.get<PostInChannelModel>(POSTS_IN_CHANNEL).query(
Q.on(CHANNEL,
Q.and(
Q.or(
Q.where('team_id', Q.eq(teamId)),
Q.where('team_id', Q.eq('')),
),
Q.where('delete_at', Q.eq(0)),
),
),
Q.sortBy('latest', Q.desc),
).observeWithColumns(['latest']).pipe(
switchMap((pics) => {
return of$(pics.reduce<Record<string, number>>((result, pic) => {
const id = pic.channelId;
if (result[id]) {
result[id] = Math.max(result[id], pic.latest);
} else {
result[id] = pic.latest;
}
return result;
}, {}));
}),
);
};

View file

@ -9,8 +9,10 @@ import {combineLatestWith, concatAll, map, switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {getPreferenceAsBool} from '@helpers/api/preference';
import {getChannelById, observeAllMyChannelNotifyProps, queryMyChannelUnreads} from '@queries/servers/channel';
import {observeLastPostAtPerChannelByTeam} from '@queries/servers/post';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {observeLastUnreadChannelId} from '@queries/servers/system';
import {observeIsCRTEnabled} from '@queries/servers/thread';
import {getChannelsFromRelation} from '../body';
@ -40,7 +42,10 @@ type NotifyProps = {
[key: string]: Partial<ChannelNotifyProps>;
}
const mostRecentFirst = (a: MyChannelModel, b: MyChannelModel) => {
const mostRecentFirst = (lastPostAtPerChannel: Record<string, number> | undefined, a: MyChannelModel, b: MyChannelModel) => {
if (lastPostAtPerChannel && lastPostAtPerChannel[a.id] && lastPostAtPerChannel[b.id]) {
return lastPostAtPerChannel[b.id] - lastPostAtPerChannel[a.id];
}
return b.lastPostAt - a.lastPostAt;
};
@ -50,10 +55,16 @@ const mostRecentFirst = (a: MyChannelModel, b: MyChannelModel) => {
* Unreads, Mentions, and Muted Mentions Only
*
* Mentions on top, then unreads, then muted channels with mentions.
* Secondary sorting within each of those is by recent posting.
* Secondary sorting within each of those is by recent posting or by recent root post if CRT is enabled.
*/
const filterAndSortMyChannels = ([myChannels, notifyProps]: [MyChannelModel[], NotifyProps]): MyChannelModel[] => {
type FilterAndSortMyChannelsArgs = [
MyChannelModel[],
NotifyProps,
Record<string, number> | undefined
]
const filterAndSortMyChannels = ([myChannels, notifyProps, lastPostAtPerChannel]: FilterAndSortMyChannelsArgs): MyChannelModel[] => {
const mentions: MyChannelModel[] = [];
const unreads: MyChannelModel[] = [];
const mutedMentions: MyChannelModel[] = [];
@ -63,8 +74,10 @@ const filterAndSortMyChannels = ([myChannels, notifyProps]: [MyChannelModel[], N
};
for (const myChannel of myChannels) {
const id = myChannel.id;
// is it a mention?
if (!isMuted(myChannel.id) && myChannel.mentionsCount > 0) {
if (!isMuted(id) && myChannel.mentionsCount > 0) {
mentions.push(myChannel);
continue;
}
@ -83,9 +96,9 @@ const filterAndSortMyChannels = ([myChannels, notifyProps]: [MyChannelModel[], N
}
// Sort
mentions.sort(mostRecentFirst);
unreads.sort(mostRecentFirst);
mutedMentions.sort(mostRecentFirst);
mentions.sort(mostRecentFirst.bind(null, lastPostAtPerChannel));
unreads.sort(mostRecentFirst.bind(null, lastPostAtPerChannel));
mutedMentions.sort(mostRecentFirst.bind(null, lastPostAtPerChannel));
return [...mentions, ...unreads, ...mutedMentions];
};
@ -105,9 +118,13 @@ const enhanced = withObservables(['currentTeamId', 'isTablet', 'onlyUnreads'], (
switchMap(getC),
) : of$('');
const notifyProps = observeAllMyChannelNotifyProps(database);
const crt = observeIsCRTEnabled(database);
const lastPostInChannel = crt.pipe(
switchMap((enabled) => (enabled && onlyUnreads ? observeLastPostAtPerChannelByTeam(database, currentTeamId) : of$(undefined))),
);
const unreads = queryMyChannelUnreads(database, currentTeamId).observe().pipe(
combineLatestWith(notifyProps),
const unreads = queryMyChannelUnreads(database, currentTeamId).observeWithColumns(['last_post_at']).pipe(
combineLatestWith(notifyProps, lastPostInChannel),
map(filterAndSortMyChannels),
map(getChannelsFromRelation),
concatAll(),

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Threads Component should match snapshot 1`] = `
exports[`Thread item in the channel list Threads Component should match snapshot 1`] = `
<View
accessible={true}
collapsable={false}
@ -67,7 +67,8 @@ exports[`Threads Component should match snapshot 1`] = `
Object {
"color": "rgba(255,255,255,0.72)",
"marginTop": -1,
"paddingHorizontal": 12,
"paddingLeft": 12,
"paddingRight": 20,
},
undefined,
undefined,
@ -80,3 +81,5 @@ exports[`Threads Component should match snapshot 1`] = `
</View>
</View>
`;
exports[`Thread item in the channel list Threads Component should match snapshot with only unreads filter 1`] = `null`;

View file

@ -5,7 +5,7 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {switchMap} from 'rxjs/operators';
import {observeCurrentChannelId, observeCurrentTeamId} from '@queries/servers/system';
import {observeCurrentChannelId, observeCurrentTeamId, observeOnlyUnreads} from '@queries/servers/system';
import {observeUnreadsAndMentionsInTeam} from '@queries/servers/thread';
import ThreadsButton from './threads_button';
@ -17,6 +17,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
return {
currentChannelId: observeCurrentChannelId(database),
onlyUnreads: observeOnlyUnreads(database),
unreadsAndMentions: currentTeamId.pipe(
switchMap(
(teamId) => observeUnreadsAndMentionsInTeam(database, teamId),

View file

@ -7,16 +7,34 @@ import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import Threads from './threads_button';
test('Threads Component should match snapshot', () => {
const {toJSON} = renderWithIntlAndTheme(
<Threads
currentChannelId='someChannelId'
unreadsAndMentions={{
unreads: 0,
mentions: 0,
}}
/>,
);
describe('Thread item in the channel list', () => {
test('Threads Component should match snapshot', () => {
const {toJSON} = renderWithIntlAndTheme(
<Threads
currentChannelId='someChannelId'
onlyUnreads={false}
unreadsAndMentions={{
unreads: 0,
mentions: 0,
}}
/>,
);
expect(toJSON()).toMatchSnapshot();
expect(toJSON()).toMatchSnapshot();
});
test('Threads Component should match snapshot with only unreads filter', () => {
const {toJSON} = renderWithIntlAndTheme(
<Threads
currentChannelId='someChannelId'
onlyUnreads={true}
unreadsAndMentions={{
unreads: 0,
mentions: 0,
}}
/>,
);
expect(toJSON()).toMatchSnapshot();
});
});

View file

@ -37,13 +37,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
type Props = {
currentChannelId: string;
onlyUnreads: boolean;
unreadsAndMentions: {
unreads: number;
mentions: number;
};
};
const ThreadsButton = ({currentChannelId, unreadsAndMentions}: Props) => {
const ThreadsButton = ({currentChannelId, onlyUnreads, unreadsAndMentions}: Props) => {
const isTablet = useIsTablet();
const serverUrl = useServerUrl();
@ -71,7 +72,7 @@ const ThreadsButton = ({currentChannelId, unreadsAndMentions}: Props) => {
const text = [
customStyles.text,
unreads ? channelItemTextStyle.bright : channelItemTextStyle.regular,
unreads ? channelItemTextStyle.bold : channelItemTextStyle.regular,
styles.text,
unreads ? styles.highlight : undefined,
isActive ? styles.textActive : undefined,
@ -80,6 +81,10 @@ const ThreadsButton = ({currentChannelId, unreadsAndMentions}: Props) => {
return [container, icon, text];
}, [customStyles, isActive, styles, unreads]);
if (onlyUnreads && !unreads && !mentions) {
return null;
}
return (
<TouchableOpacity
onPress={handlePress}

View file

@ -273,6 +273,7 @@
"login.signIn": "Log In",
"login.signingIn": "Logging In",
"login.username": "Username",
"markdown.latex.error": "Latex render error",
"mentions.empty.paragraph": "You'll see messages here when someone mentions you or uses terms you're monitoring.",
"mentions.empty.title": "No Mentions yet",
"mobile.about.appVersion": "App Version: {version} (Build {number})",
@ -286,6 +287,7 @@
"mobile.action_menu.select": "Select an option",
"mobile.add_team.create_team": "Create a new team",
"mobile.add_team.join_team": "Join Another Team",
"mobile.android.back_handler_exit": "Press back again to exit",
"mobile.android.photos_permission_denied_description": "Upload photos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo library.",
"mobile.android.photos_permission_denied_title": "{applicationName} would like to access your photos",
"mobile.camera_photo_permission_denied_description": "Take photos and upload them to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your camera.",