mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-06 02:40:15 +00:00
* app fixes surfaced by detox RF migration Carve the app-side bug fixes from the migrate_RF_to_detox branch into their own PR. Includes: - NavigationStore.waitUntilScreenIsTop helper + matching switchToChannel / notification.test wiring (Screens.CHANNEL_LIST -> Screens.HOME) - Detox source build + null-guard patch for Android FabricUIManagerIdlingResources (android/settings.gradle, android/app/build.gradle, gradle.properties) - iOS Podfile: Alamofire 5.10 duplicate-allHeaders patch + stdlib embed for extensions - iOS Server screen: defer push-proxy verification alert past Navigation.push to fix iOS 26 race - Android NetworkClient: null-guard host extraction to prevent NPE on bad cert reports - ephemeral_store: track recently-unsaved saved posts with 2-min TTL - post_draft: filter undefined channel observable to prevent archived-banner flicker - queries/servers/thread: is_following nullish-coalesce to false - datetime: NaN guard for invalid scheduled-message timestamp (MM-T5720) - navigation: dismissBottomSheet handles GENERIC_BOTTOM_SHEET fallback - bottom_sheet: accessibilityViewIsModal for VoiceOver - calls websocket_client: extend interface for optional sendBinary - edit_profile: wrap Save Pressable in View to constrain Detox tap bounds (MM-T4989_2/MM-T3250) - invite/summary: fix retry testID hardcoded to use the dynamic type - channel_bookmark/bookmark_link: URL resolution timeout + setBookmarkLinkUrl - categories_list/load_channel_error: missing return statement for error UI - iOS MattermostShare: TestID-friendly testIDs on Swift share-extension views - patches/react-native-network-client: Android host null-guard patch - scripts/build.sh: copy compass-icons.ttf manually when --ignore-scripts is used - types/modules: WebSocketClientInterface sendBinary extension Matched detox specs that exercise these bugs land in the rf-split/detox-tests PR. * address CodeRabbit review on PR #9789 - post_list/index.ts, thread_options/index.ts: track serverUrl in withObservables dependency keys - post/body/index.tsx: restore React.memo(Body) to avoid per-item re-render regression - thread.tsx, edit_profile.tsx: add pressed feedback via style callback; move inline typography to stylesheet - bookmark_link.tsx: include queueFetch in useDidUpdate deps - ephemeral_store.ts: complete and delete per-post recently-unsaved subjects on expiry/clear to bound growth Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test: fix stale mocks and add coverage for recently-unsaved saved posts Update thread/preference tests for the new NavigationStore and prepareDestroyPermanently call paths, and add unit tests for the EphemeralStore recently-unsaved saved-post logic plus the websocket filterStaleSavedPostPreferences path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test: tighten recently-unsaved saved post assertions Address CodeRabbit nitpicks on #9789: - preference.test.ts: assert addRecentlyUnsavedSavedPost is called once with (serverUrl, postId) so the echoed websocket re-save is filtered. - ephemeral_store.test.ts: assert the observed Set size (2 after two adds, 1 after clearing one) so we catch stray/missing entries. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: explain pinned posts descending sort order The asc->desc change to queryPinnedPostsInChannel surfaced during the detox RF migration was correct but undocumented. selectOrderedPosts (reached via observePinnedPostsInChannel) iterates the posts array from the end backwards expecting oldest->newest, so it requires descending (newest-first) input, matching queryPostsInChannel. Add a comment so the sort order isn't questioned in review. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test: cover thread/config/bottom-sheet edge cases flagged in review Add unit tests for coverage gaps identified by CodeRabbit on #9789: - createThreadFromNewPost: reply where the thread is missing but the root post exists (back-fills + auto-follows), and where neither exists (only records the participant, no crash) - processReceivedThreads: thread entry with no post/participants is skipped without crashing - markThreadAsUnread: unread_replies/unread_mentions from the API response are persisted to the local thread - getConfigBooleanValue: 'true' -> true, non-'true' -> false, missing key honors the default - dismissBottomSheet: falls back to GENERIC_BOTTOM_SHEET when only the generic variant is in the stack Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(thread_options): use withServerUrl HOC Replace the hand-rolled `ThreadOptionsWithServerUrl` wrapper (which called `useServerUrl()` inside a function component and re-created the element via `React.createElement`) with the existing `withServerUrl` HOC from `@context/server`. Composition chain becomes: withDatabase(withServerUrl(enhanced(ThreadOptions))) Addresses larkox's review on app/screens/thread_options/index.ts:41: "This is not needed. You should be able to use `withServerUrl` function." Also tightens the Props type — `serverUrl` is now required (the HOC guarantees it), not optional. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(deps): bump react-native-network-client to 1.10.2; drop workarounds 1.10.2 upstreams both fixes that were being carried locally: - iOS: apply_patches.rb is now idempotent (mattermost/react-native-network-client#167), so its bundled Alamofire diff can no longer double-apply. Removes the need for the Podfile post_install workaround that stripped the duplicate `let allHeaders` (Swift 6 / Xcode 26 "invalid redeclaration"). Workaround deleted. - Android: the cert-error host null-fallback in NetworkClient.kt is now upstream (and improved — prefers HttpUrl host, then URI, then baseUrlString; references #9789). The patch-package patch (@mattermost+react-native-network-client+1.10.0.patch) is therefore obsolete and deleted. It was also a silent no-op here anyway — named 1.10.0 while 1.10.1 was installed, so patch-package skipped it. Addresses @enahum's review on #9789: the fix belongs in network-client, not patched downstream — and it now is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(ios): update Podfile.lock for rnnc 1.10.2 Run `pod install` to sync Podfile.lock with the dependency bump in the previous commit: - react-native-network-client 1.10.1 → 1.10.2 - its pod checksum recomputed - PODFILE CHECKSUM recomputed (the Podfile changed — Alamofire duplicate-allHeaders workaround removed) The previous commit bumped package.json + ran npm install but did not regenerate Podfile.lock, so the iOS lockfile-consistency check would have failed (caught in review by @enahum). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(thread): use shared NavigationButton for header back Addresses @larkox's review on #9789: the thread route hand-rolled its header back button with a raw Pressable + CompassIcon + local StyleSheet, which is inconsistent with the rest of the app. Switch to the shared NavigationButton component — the same pattern used by app/screens/code/index.tsx, custom_status_clear_after, channel_bookmark, and the getModalHeaderOptions/getLoginModalHeaderOptions helpers (navigation.setOptions → NavigationButton with iconName/iconSize/color/ onPress/testID). Behavior is unchanged (still navigation.goBack() via handleBack) and the testID 'thread.navigation.back.button' is preserved for Detox. Net effect in the app: Android now gets the standard header ripple, a larger hitSlop touch target, and the arrow aligns like other NavigationButton headers. Removes the local styles, Pressable, StyleSheet, and CompassIcon imports. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
282 lines
8.6 KiB
TypeScript
282 lines
8.6 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||
// See LICENSE.txt for license information.
|
||
|
||
import React, {useCallback, useMemo} from 'react';
|
||
import {defineMessages, useIntl} from 'react-intl';
|
||
import {View, Text, ScrollView} from 'react-native';
|
||
|
||
import Button from '@components/button';
|
||
import AlertSvg from '@components/illustrations/alert';
|
||
import ErrorSvg from '@components/illustrations/error';
|
||
import SuccessSvg from '@components/illustrations/success';
|
||
import {useTheme} from '@context/theme';
|
||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||
import {typography} from '@utils/typography';
|
||
|
||
import SummaryReport, {SummaryReportType} from './summary_report';
|
||
|
||
import type {SearchResult, Result} from './types';
|
||
import type {CompassIconName} from '@components/compass_icon';
|
||
|
||
const MAX_WIDTH_CONTENT = 480;
|
||
|
||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||
return {
|
||
container: {
|
||
display: 'flex',
|
||
flex: 1,
|
||
},
|
||
summary: {
|
||
display: 'flex',
|
||
flex: 1,
|
||
},
|
||
summaryContainer: {
|
||
display: 'flex',
|
||
flexGrow: 1,
|
||
flexDirection: 'row',
|
||
justifyContent: 'center',
|
||
alignItems: 'stretch',
|
||
marginTop: 20,
|
||
marginHorizontal: 20,
|
||
paddingBottom: 20,
|
||
},
|
||
summaryContent: {
|
||
flexGrow: 1,
|
||
flexDirection: 'column',
|
||
justifyContent: 'center',
|
||
maxWidth: MAX_WIDTH_CONTENT,
|
||
},
|
||
summarySvg: {
|
||
marginBottom: 20,
|
||
alignSelf: 'center',
|
||
},
|
||
summaryMessageText: {
|
||
color: theme.centerChannelColor,
|
||
...typography('Heading', 700, 'SemiBold'),
|
||
textAlign: 'center',
|
||
marginHorizontal: 32,
|
||
marginBottom: 24,
|
||
},
|
||
summaryErrorText: {
|
||
color: changeOpacity(theme.centerChannelColor, 0.72),
|
||
...typography('Body', 200, 'Regular'),
|
||
textAlign: 'center',
|
||
},
|
||
footer: {
|
||
display: 'flex',
|
||
flexDirection: 'row',
|
||
justifyContent: 'center',
|
||
borderTopWidth: 1,
|
||
borderTopColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||
padding: 20,
|
||
},
|
||
summaryButtonContainer: {
|
||
flexGrow: 1,
|
||
flexDirection: 'row',
|
||
justifyContent: 'center',
|
||
gap: 8,
|
||
},
|
||
summaryButtonContainerStyle: {flexGrow: 1},
|
||
};
|
||
});
|
||
|
||
enum SummaryButtonType {
|
||
DONE = 'done',
|
||
RETRY = 'retry',
|
||
BACK = 'back',
|
||
}
|
||
|
||
type SummaryProps = {
|
||
result: Result;
|
||
selectedIds: {[id: string]: SearchResult};
|
||
error?: string;
|
||
testID: string;
|
||
onClose: () => void;
|
||
onRetry: () => void;
|
||
onBack: () => void;
|
||
}
|
||
|
||
const messages = defineMessages({
|
||
done: {
|
||
id: 'invite.summary.done',
|
||
defaultMessage: 'Done',
|
||
},
|
||
tryAgain: {
|
||
id: 'invite.summary.try_again',
|
||
defaultMessage: 'Try again',
|
||
},
|
||
back: {
|
||
id: 'invite.summary.back',
|
||
defaultMessage: 'Go back',
|
||
},
|
||
});
|
||
|
||
export default function Summary({
|
||
result,
|
||
selectedIds,
|
||
error,
|
||
testID,
|
||
onClose,
|
||
onRetry,
|
||
onBack,
|
||
}: SummaryProps) {
|
||
const {formatMessage} = useIntl();
|
||
const theme = useTheme();
|
||
const styles = getStyleSheet(theme);
|
||
|
||
const {sent, notSent} = result;
|
||
const sentCount = sent.length;
|
||
const notSentCount = notSent.length;
|
||
|
||
const styleSummaryMessageText = useMemo(() => {
|
||
const style = [];
|
||
|
||
style.push(styles.summaryMessageText);
|
||
|
||
if (error) {
|
||
style.push({marginBottom: 8});
|
||
}
|
||
|
||
return style;
|
||
}, [error, styles]);
|
||
|
||
let svg = <></>;
|
||
let message = '';
|
||
|
||
if (error) {
|
||
svg = <ErrorSvg/>;
|
||
message = formatMessage(
|
||
{
|
||
id: 'invite.summary.error',
|
||
defaultMessage: '{invitationsCount, plural, one {Invitation} other {Invitations}} could not be sent successfully',
|
||
},
|
||
{invitationsCount: sentCount + notSentCount},
|
||
);
|
||
} else if (!sentCount && notSentCount) {
|
||
svg = <ErrorSvg/>;
|
||
message = formatMessage(
|
||
{
|
||
id: 'invite.summary.not_sent',
|
||
defaultMessage: '{notSentCount, plural, one {Invitation wasn’t} other {Invitations weren’t}} sent',
|
||
},
|
||
{notSentCount},
|
||
);
|
||
} else if (sentCount && !notSentCount) {
|
||
svg = <SuccessSvg/>;
|
||
message = formatMessage(
|
||
{
|
||
id: 'invite.summary.sent',
|
||
defaultMessage: 'Your {sentCount, plural, one {invitation has} other {invitations have}} been sent',
|
||
},
|
||
{sentCount},
|
||
);
|
||
} else if (sentCount && notSentCount) {
|
||
svg = <AlertSvg/>;
|
||
message = formatMessage(
|
||
{
|
||
id: 'invite.summary.some_not_sent',
|
||
defaultMessage: '{notSentCount, plural, one {An invitation was} other {Some invitations were}} not sent',
|
||
},
|
||
{notSentCount},
|
||
);
|
||
}
|
||
|
||
const renderButton = useCallback((type: SummaryButtonType) => {
|
||
let onPress;
|
||
let iconName: CompassIconName | undefined;
|
||
let text;
|
||
let emphasis: ButtonEmphasis = 'primary';
|
||
|
||
switch (type) {
|
||
case SummaryButtonType.BACK:
|
||
onPress = onBack;
|
||
iconName = 'chevron-left';
|
||
text = messages.back;
|
||
emphasis = 'tertiary';
|
||
break;
|
||
case SummaryButtonType.RETRY:
|
||
onPress = onRetry;
|
||
iconName = 'refresh';
|
||
text = messages.tryAgain;
|
||
break;
|
||
case SummaryButtonType.DONE:
|
||
default:
|
||
onPress = onClose;
|
||
text = messages.done;
|
||
break;
|
||
}
|
||
|
||
return (
|
||
<View style={styles.summaryButtonContainerStyle}>
|
||
<Button
|
||
onPress={onPress}
|
||
testID={`invite.summary_button.${type}`}
|
||
text={formatMessage(text)}
|
||
iconName={iconName}
|
||
emphasis={emphasis}
|
||
size='lg'
|
||
theme={theme}
|
||
/>
|
||
</View>
|
||
);
|
||
}, [styles.summaryButtonContainerStyle, formatMessage, theme, onBack, onRetry, onClose]);
|
||
|
||
return (
|
||
<View
|
||
style={styles.container}
|
||
testID={testID}
|
||
>
|
||
<ScrollView
|
||
style={styles.summary}
|
||
contentContainerStyle={styles.summaryContainer}
|
||
testID='invite.summary'
|
||
>
|
||
<View style={styles.summaryContent}>
|
||
<View style={styles.summarySvg}>
|
||
{svg}
|
||
</View>
|
||
<Text style={styleSummaryMessageText}>
|
||
{message}
|
||
</Text>
|
||
{error ? (
|
||
<Text style={styles.summaryErrorText}>
|
||
{error}
|
||
</Text>
|
||
) : (
|
||
<>
|
||
{notSent.length > 0 && (
|
||
<SummaryReport
|
||
type={SummaryReportType.NOT_SENT}
|
||
invites={notSent}
|
||
selectedIds={selectedIds}
|
||
testID='invite.summary_report'
|
||
/>
|
||
)}
|
||
{sent.length > 0 && (
|
||
<SummaryReport
|
||
type={SummaryReportType.SENT}
|
||
invites={sent}
|
||
selectedIds={selectedIds}
|
||
testID='invite.summary_report'
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
</View>
|
||
</ScrollView>
|
||
<View style={styles.footer}>
|
||
<View style={styles.summaryButtonContainer}>
|
||
{error ? (
|
||
<>
|
||
{renderButton(SummaryButtonType.BACK)}
|
||
{renderButton(SummaryButtonType.RETRY)}
|
||
</>
|
||
) : (
|
||
renderButton(SummaryButtonType.DONE)
|
||
)}
|
||
</View>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|