mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-08 20:20:13 +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>
396 lines
13 KiB
TypeScript
396 lines
13 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
|
|
import {Pressable, View, type LayoutChangeEvent, type PressableStateCallbackType, type ViewStyle} from 'react-native';
|
|
import Animated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
|
|
|
import CompassIcon from '@components/compass_icon';
|
|
import {Screens} from '@constants';
|
|
import {usePreventDoubleTap} from '@hooks/utils';
|
|
import {navigateToScreen} from '@screens/navigation';
|
|
import CallbackStore from '@store/callback_store';
|
|
|
|
import {ButtonElement} from './button_element';
|
|
import ClippedScrollContent from './clipped_scroll_content';
|
|
import {MmBlocksLayoutWidthContext, MmBlocksRenderContext} from './context';
|
|
import {DividerBlock} from './divider_block';
|
|
import {ImageBlock} from './image_block';
|
|
import {type MmBlocksExpandedContentPayload} from './mm_blocks_context_provider';
|
|
import {StaticSelectElement} from './static_select_element';
|
|
import {
|
|
containerGapStyle,
|
|
containerMaxHeightPx,
|
|
getStyleSheet,
|
|
isMmContainerSemanticAccent,
|
|
} from './styles';
|
|
import {TextBlock} from './text_block';
|
|
|
|
import type {ActionHandler} from './types';
|
|
|
|
const COLLAPSIBLE_ANIMATION_MS = 250;
|
|
|
|
export type BlockSwitchProps = {
|
|
block: MmBlock;
|
|
currentLayout?: 'column' | 'row';
|
|
onAction: ActionHandler;
|
|
theme: Theme;
|
|
};
|
|
|
|
export const BlockSwitch = ({block, currentLayout, onAction, theme}: BlockSwitchProps) => {
|
|
switch (block.type) {
|
|
case 'text':
|
|
return (
|
|
<TextBlock
|
|
block={block}
|
|
theme={theme}
|
|
/>
|
|
);
|
|
case 'image':
|
|
return (
|
|
<ImageBlock
|
|
block={block}
|
|
theme={theme}
|
|
/>
|
|
);
|
|
case 'divider':
|
|
return (
|
|
<DividerBlock
|
|
currentLayout={currentLayout}
|
|
theme={theme}
|
|
/>
|
|
);
|
|
case 'column_set':
|
|
return (
|
|
<ColumnSetBlock
|
|
block={block}
|
|
onAction={onAction}
|
|
theme={theme}
|
|
/>
|
|
);
|
|
case 'container':
|
|
return (
|
|
<ContainerBlock
|
|
block={block}
|
|
onAction={onAction}
|
|
theme={theme}
|
|
/>
|
|
);
|
|
case 'collapsible':
|
|
return (
|
|
<CollapsibleBlock
|
|
block={block}
|
|
onAction={onAction}
|
|
theme={theme}
|
|
/>
|
|
);
|
|
case 'button':
|
|
return (
|
|
<ButtonElement
|
|
element={block}
|
|
onAction={onAction}
|
|
theme={theme}
|
|
/>
|
|
);
|
|
case 'static_select':
|
|
return (
|
|
<StaticSelectElement
|
|
element={block}
|
|
onAction={onAction}
|
|
/>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
type ColumnSetBlockProps = BlockSwitchProps & {block: MmColumnSetBlock};
|
|
|
|
const ColumnSetBlock = ({block, ...switchProps}: ColumnSetBlockProps) => {
|
|
const {theme} = switchProps;
|
|
const style = getStyleSheet(theme);
|
|
|
|
const columnSetStyle = useMemo(
|
|
() => [style.columnSet, containerGapStyle(block.gap)],
|
|
[block.gap, style],
|
|
);
|
|
|
|
if (!block.columns || block.columns.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// We use the index on the key since we don't have a unique id for the columns,
|
|
// and we do not expect the number or order of columns to change.
|
|
return (
|
|
<View style={columnSetStyle}>
|
|
{block.columns.map((column, i) => (
|
|
<ColumnBlock
|
|
key={i}
|
|
block={column}
|
|
{...switchProps}
|
|
/>
|
|
))}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
type ColumnBlockProps = BlockSwitchProps & {block: MmColumnBlock};
|
|
|
|
const ColumnBlock = ({block, ...switchProps}: ColumnBlockProps) => {
|
|
const {theme} = switchProps;
|
|
const style = getStyleSheet(theme);
|
|
|
|
const innerBlock = useMemo(() => ({
|
|
type: 'container' as const,
|
|
content: block.items,
|
|
...(block.gap ? {gap: block.gap} : {}),
|
|
}), [block.items, block.gap]);
|
|
|
|
if (!block.items || block.items.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const columnStyle = block.width === 'stretch' ? style.columnStretch : style.columnAuto;
|
|
return (
|
|
<View style={columnStyle}>
|
|
<ContainerBlock
|
|
block={innerBlock}
|
|
{...switchProps}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
type ContainerBlockProps = BlockSwitchProps & {block: MmContainerBlock};
|
|
|
|
export const ContainerBlock = ({block, ...switchProps}: ContainerBlockProps) => {
|
|
const {theme} = switchProps;
|
|
const style = getStyleSheet(theme);
|
|
const parentLayoutWidth = useContext(MmBlocksLayoutWidthContext);
|
|
const renderContext = useContext(MmBlocksRenderContext);
|
|
const [measuredLayoutWidth, setMeasuredLayoutWidth] = useState<number | undefined>(undefined);
|
|
|
|
const contextLayoutWidth = measuredLayoutWidth ?? parentLayoutWidth;
|
|
|
|
const currentLayout: 'column' | 'row' = block.flow === 'horizontal' ? 'row' : 'column';
|
|
|
|
const maxHeightPx = containerMaxHeightPx(block.max_height);
|
|
|
|
const containerStyle = useMemo(() => {
|
|
const containerBaseStyle = [block.border && style.containerBorder, block.background === 'gray' && style.containerBgGray];
|
|
const accent = block.accent_color;
|
|
|
|
let accentStyles: ViewStyle[] = [];
|
|
if (accent) {
|
|
let accentStyle: ViewStyle = {};
|
|
if (isMmContainerSemanticAccent(accent)) {
|
|
switch (accent) {
|
|
case 'primary':
|
|
accentStyle = style.accentPrimary;
|
|
break;
|
|
case 'good':
|
|
accentStyle = style.accentGood;
|
|
break;
|
|
case 'warning':
|
|
accentStyle = style.accentWarning;
|
|
break;
|
|
case 'danger':
|
|
accentStyle = style.accentDanger;
|
|
break;
|
|
default:
|
|
accentStyle = style.accentDefault;
|
|
break;
|
|
}
|
|
} else {
|
|
accentStyle = {borderLeftColor: accent};
|
|
}
|
|
accentStyles = [style.containerAccent, accentStyle];
|
|
}
|
|
return [containerBaseStyle, accentStyles];
|
|
}, [block.border, block.background, block.accent_color, style]);
|
|
|
|
const innerContentStyle = useMemo(() => {
|
|
const flowStyle = block.flow === 'horizontal' ? style.containerHorizontal : style.containerVertical;
|
|
const gapStyle = containerGapStyle(block.gap);
|
|
return [style.container, flowStyle, gapStyle];
|
|
}, [block.flow, block.gap, style]);
|
|
|
|
const handleLayout = useCallback((event: LayoutChangeEvent) => {
|
|
const width = Math.round(event.nativeEvent.layout.width);
|
|
if (width > 0) {
|
|
setMeasuredLayoutWidth(width);
|
|
}
|
|
}, []);
|
|
|
|
const handleExpandBoundedContent = useCallback(() => {
|
|
if (!block.content?.length || !renderContext) {
|
|
return;
|
|
}
|
|
|
|
const expandedBlock: MmContainerBlock = {
|
|
...block,
|
|
max_height: undefined,
|
|
};
|
|
|
|
const payload: MmBlocksExpandedContentPayload = {
|
|
...renderContext,
|
|
renderContent: () => (
|
|
<ContainerBlock
|
|
block={expandedBlock}
|
|
{...switchProps}
|
|
/>
|
|
),
|
|
};
|
|
|
|
CallbackStore.setCallback(payload);
|
|
navigateToScreen(Screens.MM_BLOCKS_CONTENT);
|
|
}, [block, renderContext, switchProps]);
|
|
|
|
if (!block.content || block.content.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const blockItems = block.content.map((item, i) => (
|
|
|
|
// Index keys are safe: block content is static and not reordered at runtime.
|
|
<BlockSwitch
|
|
key={i}
|
|
block={item}
|
|
currentLayout={currentLayout}
|
|
{...switchProps}
|
|
/>
|
|
));
|
|
|
|
let boundedContent: React.ReactNode = blockItems;
|
|
if (maxHeightPx) {
|
|
boundedContent = (
|
|
<ClippedScrollContent
|
|
maxHeight={maxHeightPx}
|
|
onExpand={handleExpandBoundedContent}
|
|
containerPadding={block.border ? style.containerBorder.padding : 0}
|
|
testID='mm_blocks.container.bounded'
|
|
>
|
|
{blockItems}
|
|
</ClippedScrollContent>
|
|
);
|
|
}
|
|
|
|
const inner = (
|
|
<MmBlocksLayoutWidthContext.Provider value={contextLayoutWidth}>
|
|
<View style={innerContentStyle}>
|
|
{boundedContent}
|
|
</View>
|
|
</MmBlocksLayoutWidthContext.Provider>
|
|
);
|
|
|
|
return (
|
|
<View
|
|
style={containerStyle}
|
|
onLayout={handleLayout}
|
|
>
|
|
{inner}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
type CollapsibleBlockProps = BlockSwitchProps & {block: MmCollapsibleBlock};
|
|
|
|
const CollapsibleBlock = ({block, ...switchProps}: CollapsibleBlockProps) => {
|
|
const {theme} = switchProps;
|
|
const style = getStyleSheet(theme);
|
|
const [collapsed, setCollapsed] = useState(block.collapsed !== false);
|
|
const contentHeight = useSharedValue(0);
|
|
const isExpanded = useSharedValue(!(block.collapsed !== false));
|
|
const toggleCollapsed = usePreventDoubleTap(useCallback(() => {
|
|
setCollapsed((prev) => !prev);
|
|
}, []));
|
|
|
|
useEffect(() => {
|
|
isExpanded.value = !collapsed;
|
|
}, [collapsed, isExpanded]);
|
|
|
|
const onContentLayout = useCallback((event: LayoutChangeEvent) => {
|
|
contentHeight.value = event.nativeEvent.layout.height;
|
|
}, [contentHeight]);
|
|
|
|
const chevronAnimatedStyle = useAnimatedStyle(() => ({
|
|
transform: [{
|
|
rotate: withTiming(isExpanded.value ? '90deg' : '0deg', {
|
|
duration: COLLAPSIBLE_ANIMATION_MS,
|
|
easing: Easing.out(Easing.cubic),
|
|
}),
|
|
}],
|
|
}));
|
|
|
|
const contentAnimatedStyle = useAnimatedStyle(() => ({
|
|
height: withTiming(isExpanded.value ? contentHeight.value : 0, {
|
|
duration: COLLAPSIBLE_ANIMATION_MS,
|
|
easing: Easing.out(Easing.cubic),
|
|
}),
|
|
opacity: withTiming(isExpanded.value ? 1 : 0, {
|
|
duration: COLLAPSIBLE_ANIMATION_MS,
|
|
easing: Easing.out(Easing.cubic),
|
|
}),
|
|
overflow: 'hidden',
|
|
}));
|
|
|
|
const innerHeaderBlock = useMemo(() => ({
|
|
type: 'container' as const,
|
|
content: block.header,
|
|
}), [block.header]);
|
|
const innerContentBlock = useMemo(() => ({
|
|
type: 'container' as const,
|
|
content: block.content,
|
|
}), [block.content]);
|
|
|
|
const pressableStyle = useCallback(({pressed}: PressableStateCallbackType) => [
|
|
style.collapsibleHeader,
|
|
pressed && style.collapsibleHeaderPressed,
|
|
], [style.collapsibleHeader, style.collapsibleHeaderPressed]);
|
|
|
|
const accessibilityState = useMemo(() => ({expanded: !collapsed}), [collapsed]);
|
|
|
|
if (!block.header?.length || !block.content?.length) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<View style={style.collapsible}>
|
|
<Pressable
|
|
onPress={toggleCollapsed}
|
|
style={pressableStyle}
|
|
accessibilityRole='button'
|
|
accessibilityState={accessibilityState}
|
|
>
|
|
<Animated.View style={chevronAnimatedStyle}>
|
|
<CompassIcon
|
|
name='chevron-right'
|
|
size={18}
|
|
style={style.collapsibleChevron}
|
|
/>
|
|
</Animated.View>
|
|
<View style={style.collapsibleHeaderBody}>
|
|
<ContainerBlock
|
|
block={innerHeaderBlock}
|
|
{...switchProps}
|
|
/>
|
|
</View>
|
|
</Pressable>
|
|
<Animated.View
|
|
pointerEvents={collapsed ? 'none' : 'auto'}
|
|
style={[style.collapsibleContentClip, contentAnimatedStyle]}
|
|
>
|
|
<View
|
|
style={style.collapsibleContent}
|
|
onLayout={onContentLayout}
|
|
>
|
|
<ContainerBlock
|
|
block={innerContentBlock}
|
|
{...switchProps}
|
|
/>
|
|
</View>
|
|
</Animated.View>
|
|
</View>
|
|
);
|
|
};
|