mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-06 02:40:15 +00:00
* Mattermost Blocks * Move block renderer to its own components folder * Sync translation layer * UX fixes * Refactor button to use the button component * Code review * Fix CI * Add animations to collapse blocks * Add unit tests * Add e2e tests and related fixes * Address coderabbit comments * Address UX comments * Address feedback * Address coderabbit comments * Fix snapshots * Sync with webapp translation * Add node limit to button markdown node * Address feedback * i18n-extract * Fix test * Move image utils to the utils folder * Remove unneeded style * Revert build script change --------- Co-authored-by: Mattermost Build <build@mattermost.com>
212 lines
7.2 KiB
TypeScript
212 lines
7.2 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
|
import {useIntl} from 'react-intl';
|
|
import {type StyleProp, Text, type TextStyle} from 'react-native';
|
|
import urlParse from 'url-parse';
|
|
|
|
import {handleGotoLocation} from '@actions/remote/command';
|
|
import {postActionWithCookie} from '@actions/remote/integrations';
|
|
import {useServerUrl} from '@context/server';
|
|
import {useTheme} from '@context/theme';
|
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
|
import {typography} from '@utils/typography';
|
|
|
|
// Server's mm_blocks action ID regex; mirrors webapp's MMACTION_ID_REGEX.
|
|
const MMACTION_ID_REGEX = /^[A-Za-z0-9]+$/;
|
|
|
|
// Outer envelope cap on the raw query string. Per-key/per-value caps are
|
|
// enforced server-side; this protects against crafted markdown that bloats
|
|
// the click request.
|
|
const MAX_PARAMS_LENGTH = 2048;
|
|
|
|
const MMACTION_SCHEME_PREFIX = 'mmaction://';
|
|
|
|
// 15s client-side timeout. Server outgoing-integration timeout is up to 30s,
|
|
// but the user shouldn't stare at a spinner that long.
|
|
const INLINE_ACTION_TIMEOUT_MS = 15_000;
|
|
|
|
type Props = {
|
|
href: string;
|
|
postId: string;
|
|
baseTextStyle: StyleProp<TextStyle>;
|
|
children: ReactNode;
|
|
mmBlocksActionCookie?: string;
|
|
integrationFormat?: PostActionIntegrationFormat;
|
|
};
|
|
|
|
type ParsedHref = {actionId: string; query: Record<string, string>};
|
|
|
|
const parseMmactionHref = (href: string): ParsedHref | null => {
|
|
if (!href.startsWith(MMACTION_SCHEME_PREFIX)) {
|
|
return null;
|
|
}
|
|
const withoutScheme = href.slice(MMACTION_SCHEME_PREFIX.length);
|
|
const actionId = withoutScheme.split(/[/?#]/, 1)[0];
|
|
if (!MMACTION_ID_REGEX.test(actionId)) {
|
|
return null;
|
|
}
|
|
try {
|
|
const rawString = href.includes('?') ? href.slice(href.indexOf('?') + 1).split('#')[0] : '';
|
|
if (rawString.length > MAX_PARAMS_LENGTH) {
|
|
return null;
|
|
}
|
|
const parsed = urlParse(href, true);
|
|
const query: Record<string, string> = {};
|
|
const queryObj = typeof parsed.query === 'object' && parsed.query !== null ? parsed.query as Record<string, string | undefined> : null;
|
|
if (queryObj) {
|
|
for (const key of Object.keys(queryObj)) {
|
|
const value = queryObj[key];
|
|
if (typeof value === 'string') {
|
|
query[key] = value;
|
|
}
|
|
}
|
|
}
|
|
return {actionId, query};
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
|
button: {
|
|
color: theme.buttonBg,
|
|
backgroundColor: changeOpacity(theme.buttonBg, 0.12),
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 2,
|
|
borderRadius: 4,
|
|
...typography('Body', 200, 'SemiBold'),
|
|
},
|
|
buttonPressed: {
|
|
backgroundColor: changeOpacity(theme.buttonBg, 0.24),
|
|
},
|
|
buttonExecuting: {
|
|
opacity: 0.6,
|
|
},
|
|
error: {
|
|
color: theme.errorTextColor,
|
|
...typography('Body', 100),
|
|
},
|
|
}));
|
|
|
|
const InlineActionButton = ({href, postId, baseTextStyle, children, mmBlocksActionCookie, integrationFormat = 'mm_block'}: Props) => {
|
|
const theme = useTheme();
|
|
const style = getStyleSheet(theme);
|
|
const intl = useIntl();
|
|
const serverUrl = useServerUrl();
|
|
|
|
const parsed = useMemo(() => parseMmactionHref(href), [href]);
|
|
|
|
const [executing, setExecuting] = useState(false);
|
|
const [pressed, setPressed] = useState(false);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
|
|
const executingRef = useRef(false);
|
|
const mountedRef = useRef(true);
|
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => () => {
|
|
mountedRef.current = false;
|
|
if (timeoutRef.current !== null) {
|
|
clearTimeout(timeoutRef.current);
|
|
timeoutRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const handlePress = useCallback(async () => {
|
|
if (executingRef.current || !parsed || !postId) {
|
|
return;
|
|
}
|
|
executingRef.current = true;
|
|
setExecuting(true);
|
|
setActionError(null);
|
|
|
|
let timedOut = false;
|
|
const timeoutPromise = new Promise<{error: {message?: string}}>((resolve) => {
|
|
timeoutRef.current = setTimeout(() => {
|
|
timedOut = true;
|
|
resolve({error: {message: 'timeout'}});
|
|
}, INLINE_ACTION_TIMEOUT_MS);
|
|
});
|
|
|
|
try {
|
|
const result = await Promise.race([
|
|
postActionWithCookie(
|
|
serverUrl,
|
|
postId,
|
|
parsed.actionId,
|
|
mmBlocksActionCookie ?? '',
|
|
'',
|
|
parsed.query,
|
|
integrationFormat ?? '',
|
|
),
|
|
timeoutPromise,
|
|
]) as {error?: {message?: string}; data?: {goto_location?: string}};
|
|
if (mountedRef.current && result?.error) {
|
|
if (timedOut) {
|
|
setActionError(intl.formatMessage({
|
|
id: 'inline_action_button.timeout',
|
|
defaultMessage: 'Action timed out. Try again.',
|
|
}));
|
|
} else {
|
|
setActionError(intl.formatMessage({
|
|
id: 'inline_action_button.failed',
|
|
defaultMessage: 'Action failed to execute.',
|
|
}));
|
|
}
|
|
} else if (mountedRef.current && result?.data?.goto_location) {
|
|
handleGotoLocation(serverUrl, intl, result.data.goto_location);
|
|
}
|
|
} finally {
|
|
if (timeoutRef.current !== null) {
|
|
clearTimeout(timeoutRef.current);
|
|
timeoutRef.current = null;
|
|
}
|
|
executingRef.current = false;
|
|
if (mountedRef.current) {
|
|
setExecuting(false);
|
|
}
|
|
}
|
|
}, [integrationFormat, intl, mmBlocksActionCookie, parsed, postId, serverUrl]);
|
|
|
|
const handlePressIn = useCallback(() => setPressed(true), []);
|
|
const handlePressOut = useCallback(() => setPressed(false), []);
|
|
|
|
if (!parsed || !postId) {
|
|
return <Text style={baseTextStyle}>{children}</Text>;
|
|
}
|
|
|
|
const executingLabel = intl.formatMessage({
|
|
id: 'inline_action_button.executing',
|
|
defaultMessage: 'Executing...',
|
|
});
|
|
|
|
return (
|
|
<Text
|
|
onPress={handlePress}
|
|
onPressIn={handlePressIn}
|
|
onPressOut={handlePressOut}
|
|
accessibilityRole='button'
|
|
accessibilityState={{busy: executing, disabled: executing}}
|
|
testID='inline_action_button'
|
|
style={baseTextStyle}
|
|
>
|
|
<Text
|
|
style={[
|
|
style.button,
|
|
pressed && style.buttonPressed,
|
|
executing && style.buttonExecuting,
|
|
]}
|
|
>
|
|
{executing ? executingLabel : children}
|
|
</Text>
|
|
{actionError ? (
|
|
<Text style={style.error}>{'\n'}{actionError}</Text>
|
|
) : null}
|
|
</Text>
|
|
);
|
|
};
|
|
|
|
export default InlineActionButton;
|