feat: AI rewrite (#9280)
* feat: ai rewrite * feat: allow crating content apart from editing it * feat: feature parity with webapp * feat: feature parity with webapp * chore: fixed padding * map ux to webapp * refactored ai rewrite logic to separate package * chore: tests and lint * Update app/products/ai/rewrite/screens/options/options.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * rewrite post editor animation * i18n * ui feedback, centered icon and less top padding * chore: lint * Consolidate @ai product into @agents Move all rewrite functionality from app/products/ai/ into app/products/agents/ to unify under a single namespace. - Add rewrite types, store, hooks, components, and screens to agents - Merge AI client methods into agents client - Update screen constants (AI_* -> AGENTS_*) - Update all consumer imports - Remove AI state from EphemeralStore - Remove @ai path alias from config - Delete app/products/ai/ directory * refactor: load screens from product package * refactor: move detection logic to the agents pacakge * refactor: remove "backwards compatibility" * refactor: move hooks to proper package * refactor: styles * refactor: remove unneedd position attribute * refactor: remove unneeded cancel animation calls * refactor: "backwards compat" * refactor: optimize renderContent with useCallback for performance * refactor: rename variable to avoid confusion * refactor: update handleRewrite to use async/await for better error handling * refactor: simplify handleRewrite by always dismissing keyboard * refactor: use hook, always all keyboard.dismiss * chore: enhance AgentSelector component with FlatList support * feat: add rewriteMessage function for AI message rewriting and integrate it into useRewrite hook * refactor: simplify message length calculation in useHandleSendMessage hook * refactor: consolidate agent screen constants and integrate with existing screens * fix: update dependency array in useMemo for isUnrevealedPost to include post expiration metadata * refactor: remove unused variable to clean up Typing component * refactor: simplify logic for atDisabled and slashDisabled flags in QuickActions component * feat: add AIRewriteAction component for AI message rewriting functionality * revert: thread.ts changes manually * refactor: integrate useSafeAreaInsets * refactor: cleanup unused methods * chore: add comment to clarify casting * refactor: use_agents * chore: lint and test * fix: trim --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
1db5cd456c
commit
714c3dc769
40 changed files with 1411 additions and 125 deletions
|
|
@ -1,6 +1,8 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {fetchAgents} from '@agents/actions/remote/agents';
|
||||||
|
|
||||||
import {setLastServerVersionCheck} from '@actions/local/systems';
|
import {setLastServerVersionCheck} from '@actions/local/systems';
|
||||||
import {fetchConfigAndLicense} from '@actions/remote/systems';
|
import {fetchConfigAndLicense} from '@actions/remote/systems';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
|
|
@ -35,6 +37,9 @@ export async function appEntry(serverUrl: string, since = 0) {
|
||||||
|
|
||||||
verifyPushProxy(serverUrl);
|
verifyPushProxy(serverUrl);
|
||||||
|
|
||||||
|
// Fetch agents to determine if AI features are available
|
||||||
|
fetchAgents(serverUrl);
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ jest.mock('@screens/navigation', () => ({
|
||||||
|
|
||||||
jest.mock('@context/server', () => ({
|
jest.mock('@context/server', () => ({
|
||||||
useServerUrl: jest.fn().mockReturnValue('https://server.com'),
|
useServerUrl: jest.fn().mockReturnValue('https://server.com'),
|
||||||
|
withServerUrl: (Component: React.ComponentType<any>) => (props: any) => (
|
||||||
|
<Component
|
||||||
|
{...props}
|
||||||
|
serverUrl={'https://server.com'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@components/draft_scheduled_post_header', () => () => null);
|
jest.mock('@components/draft_scheduled_post_header', () => () => null);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ import FormattedDate, {type FormattedDateFormat} from './index';
|
||||||
|
|
||||||
jest.mock('@utils/log', () => ({
|
jest.mock('@utils/log', () => ({
|
||||||
logDebug: jest.fn(),
|
logDebug: jest.fn(),
|
||||||
|
logError: jest.fn(),
|
||||||
|
logInfo: jest.fn(),
|
||||||
|
logWarning: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const DATE = new Date('2024-10-26T10:01:04.653Z');
|
const DATE = new Date('2024-10-26T10:01:04.653Z');
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,12 @@ const SERVER_URL = 'https://appv1.mattermost.com';
|
||||||
// this is needed to when using the useServerUrl hook
|
// this is needed to when using the useServerUrl hook
|
||||||
jest.mock('@context/server', () => ({
|
jest.mock('@context/server', () => ({
|
||||||
useServerUrl: jest.fn(() => SERVER_URL),
|
useServerUrl: jest.fn(() => SERVER_URL),
|
||||||
|
withServerUrl: (Component: React.ComponentType<any>) => (props: any) => (
|
||||||
|
<Component
|
||||||
|
{...props}
|
||||||
|
serverUrl={SERVER_URL}
|
||||||
|
/>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@screens/navigation', () => ({
|
jest.mock('@screens/navigation', () => ({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import RewritingIndicator from '@agents/components/rewriting_indicator';
|
||||||
import React, {useCallback} from 'react';
|
import React, {useCallback} from 'react';
|
||||||
import {useIntl} from 'react-intl';
|
import {useIntl} from 'react-intl';
|
||||||
import {Keyboard, type LayoutChangeEvent, Platform, ScrollView, View} from 'react-native';
|
import {Keyboard, type LayoutChangeEvent, Platform, ScrollView, View} from 'react-native';
|
||||||
|
|
@ -199,6 +200,7 @@ function DraftInput({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<RewritingIndicator/>
|
||||||
<Typing
|
<Typing
|
||||||
channelId={channelId}
|
channelId={channelId}
|
||||||
rootId={rootId}
|
rootId={rootId}
|
||||||
|
|
@ -209,7 +211,6 @@ function DraftInput({
|
||||||
style={style.inputWrapper}
|
style={style.inputWrapper}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
>
|
>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={style.inputContainer}
|
style={style.inputContainer}
|
||||||
contentContainerStyle={style.inputContentContainer}
|
contentContainerStyle={style.inputContentContainer}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {useRewrite} from '@agents/hooks';
|
||||||
import {useHardwareKeyboardEvents} from '@mattermost/hardware-keyboard';
|
import {useHardwareKeyboardEvents} from '@mattermost/hardware-keyboard';
|
||||||
import {useManagedConfig} from '@mattermost/react-native-emm';
|
import {useManagedConfig} from '@mattermost/react-native-emm';
|
||||||
import PasteableTextInput, {type PastedFile, type PasteInputRef} from '@mattermost/react-native-paste-input';
|
import PasteableTextInput, {type PastedFile, type PasteInputRef} from '@mattermost/react-native-paste-input';
|
||||||
|
|
@ -10,7 +11,7 @@ import {
|
||||||
Alert, AppState, type AppStateStatus, DeviceEventEmitter, type EmitterSubscription, Keyboard,
|
Alert, AppState, type AppStateStatus, DeviceEventEmitter, type EmitterSubscription, Keyboard,
|
||||||
type NativeSyntheticEvent, Platform, type TextInputSelectionChangeEventData,
|
type NativeSyntheticEvent, Platform, type TextInputSelectionChangeEventData,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import {runOnUI} from 'react-native-reanimated';
|
import Animated, {cancelAnimation, Easing, runOnUI, useAnimatedStyle, useSharedValue, withRepeat, withTiming} from 'react-native-reanimated';
|
||||||
|
|
||||||
import {updateDraftMessage} from '@actions/local/draft';
|
import {updateDraftMessage} from '@actions/local/draft';
|
||||||
import {userTyping} from '@actions/websocket/users';
|
import {userTyping} from '@actions/websocket/users';
|
||||||
|
|
@ -167,6 +168,7 @@ export default function PostInput({
|
||||||
}, [registerPostInputCallbacks]);
|
}, [registerPostInputCallbacks]);
|
||||||
|
|
||||||
const [propagateValue, shouldProcessEvent] = useInputPropagation();
|
const [propagateValue, shouldProcessEvent] = useInputPropagation();
|
||||||
|
const {isProcessing} = useRewrite();
|
||||||
|
|
||||||
const lastTypingEventSent = useRef(0);
|
const lastTypingEventSent = useRef(0);
|
||||||
|
|
||||||
|
|
@ -193,6 +195,28 @@ export default function PostInput({
|
||||||
return {...style.input, maxHeight};
|
return {...style.input, maxHeight};
|
||||||
}, [maxHeight, style.input]);
|
}, [maxHeight, style.input]);
|
||||||
|
|
||||||
|
// Pulsing animation for when AI rewrite is processing
|
||||||
|
const pulseOpacity = useSharedValue(1);
|
||||||
|
const pulsingAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: pulseOpacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isProcessing) {
|
||||||
|
pulseOpacity.value = withRepeat(
|
||||||
|
withTiming(0.5, {duration: 400, easing: Easing.inOut(Easing.ease)}),
|
||||||
|
-1,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
pulseOpacity.value = withTiming(1, {duration: 200});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimation(pulseOpacity);
|
||||||
|
};
|
||||||
|
}, [isProcessing, pulseOpacity]);
|
||||||
|
|
||||||
const onBlur = useCallback(() => {
|
const onBlur = useCallback(() => {
|
||||||
handleDraftUpdate({
|
handleDraftUpdate({
|
||||||
serverUrl,
|
serverUrl,
|
||||||
|
|
@ -516,31 +540,34 @@ export default function PostInput({
|
||||||
useHardwareKeyboardEvents(events);
|
useHardwareKeyboardEvents(events);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PasteableTextInput
|
<Animated.View style={pulsingAnimatedStyle}>
|
||||||
allowFontScaling={true}
|
<PasteableTextInput
|
||||||
disableCopyPaste={disableCopyAndPaste}
|
allowFontScaling={true}
|
||||||
disableFullscreenUI={true}
|
disableCopyPaste={disableCopyAndPaste}
|
||||||
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
|
disableFullscreenUI={true}
|
||||||
multiline={true}
|
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
|
||||||
onBlur={onBlur}
|
multiline={true}
|
||||||
onChangeText={handleTextChange}
|
onBlur={onBlur}
|
||||||
onFocus={onFocus}
|
onChangeText={handleTextChange}
|
||||||
onPress={Platform.OS === 'android' ? handlePress : undefined}
|
onFocus={onFocus}
|
||||||
onPaste={onPaste}
|
onPress={Platform.OS === 'android' ? handlePress : undefined}
|
||||||
onSelectionChange={handlePostDraftSelectionChanged}
|
onPaste={onPaste}
|
||||||
placeholder={intl.formatMessage(getPlaceHolder(rootId), {channelDisplayName})}
|
onSelectionChange={handlePostDraftSelectionChanged}
|
||||||
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
|
placeholder={intl.formatMessage(getPlaceHolder(rootId), {channelDisplayName})}
|
||||||
ref={inputRef}
|
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||||
showSoftInputOnFocus={Platform.OS === 'android' ? (!showInputAccessoryView || isManuallyFocusingAfterEmojiDismiss) : true}
|
ref={inputRef}
|
||||||
smartPunctuation='disable'
|
showSoftInputOnFocus={Platform.OS === 'android' ? (!showInputAccessoryView || isManuallyFocusingAfterEmojiDismiss) : true}
|
||||||
submitBehavior='newline'
|
smartPunctuation='disable'
|
||||||
style={pasteInputStyle}
|
submitBehavior='newline'
|
||||||
testID={testID}
|
style={pasteInputStyle}
|
||||||
underlineColorAndroid='transparent'
|
testID={testID}
|
||||||
textContentType='none'
|
underlineColorAndroid='transparent'
|
||||||
value={value}
|
textContentType='none'
|
||||||
autoCapitalize='sentences'
|
value={value}
|
||||||
nativeID={testID}
|
autoCapitalize='sentences'
|
||||||
/>
|
editable={!isProcessing}
|
||||||
|
nativeID={testID}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {observeIsAgentsEnabled} from '@agents/queries/agents';
|
||||||
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import {withServerUrl} from '@context/server';
|
||||||
import {observeIsBoREnabled, observeIsPostPriorityEnabled} from '@queries/servers/post';
|
import {observeIsBoREnabled, observeIsPostPriorityEnabled} from '@queries/servers/post';
|
||||||
import {observeCanUploadFiles} from '@queries/servers/security';
|
import {observeCanUploadFiles} from '@queries/servers/security';
|
||||||
import {observeMaxFileCount} from '@queries/servers/system';
|
import {observeMaxFileCount} from '@queries/servers/system';
|
||||||
|
|
@ -12,16 +14,21 @@ import QuickActions from './quick_actions';
|
||||||
|
|
||||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||||
|
|
||||||
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
type EnhancedProps = WithDatabaseArgs & {
|
||||||
|
serverUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enhanced = withObservables([], ({database, serverUrl}: EnhancedProps) => {
|
||||||
const canUploadFiles = observeCanUploadFiles(database);
|
const canUploadFiles = observeCanUploadFiles(database);
|
||||||
const maxFileCount = observeMaxFileCount(database);
|
const maxFileCount = observeMaxFileCount(database);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canUploadFiles,
|
canUploadFiles,
|
||||||
|
isAgentsEnabled: observeIsAgentsEnabled(serverUrl),
|
||||||
isPostPriorityEnabled: observeIsPostPriorityEnabled(database),
|
isPostPriorityEnabled: observeIsPostPriorityEnabled(database),
|
||||||
isBoREnabled: observeIsBoREnabled(database),
|
isBoREnabled: observeIsBoREnabled(database),
|
||||||
maxFileCount,
|
maxFileCount,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export default React.memo(withDatabase(enhanced(QuickActions)));
|
export default React.memo(withDatabase(withServerUrl(enhanced(QuickActions))));
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ describe('Quick Actions', () => {
|
||||||
testID: 'test-quick-actions',
|
testID: 'test-quick-actions',
|
||||||
canUploadFiles: true,
|
canUploadFiles: true,
|
||||||
fileCount: 0,
|
fileCount: 0,
|
||||||
|
isAgentsEnabled: true,
|
||||||
isPostPriorityEnabled: true,
|
isPostPriorityEnabled: true,
|
||||||
canShowPostPriority: true,
|
canShowPostPriority: true,
|
||||||
canShowSlashCommands: true,
|
canShowSlashCommands: true,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import AIRewriteAction from '@agents/components/ai_rewrite_action';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {StyleSheet, View} from 'react-native';
|
import {StyleSheet, View} from 'react-native';
|
||||||
|
|
||||||
|
|
@ -18,6 +19,7 @@ type Props = {
|
||||||
testID?: string;
|
testID?: string;
|
||||||
canUploadFiles: boolean;
|
canUploadFiles: boolean;
|
||||||
fileCount: number;
|
fileCount: number;
|
||||||
|
isAgentsEnabled: boolean;
|
||||||
isPostPriorityEnabled: boolean;
|
isPostPriorityEnabled: boolean;
|
||||||
isBoREnabled: boolean;
|
isBoREnabled: boolean;
|
||||||
canShowPostPriority?: boolean;
|
canShowPostPriority?: boolean;
|
||||||
|
|
@ -53,6 +55,7 @@ export default function QuickActions({
|
||||||
canUploadFiles,
|
canUploadFiles,
|
||||||
value,
|
value,
|
||||||
fileCount,
|
fileCount,
|
||||||
|
isAgentsEnabled,
|
||||||
isPostPriorityEnabled,
|
isPostPriorityEnabled,
|
||||||
isBoREnabled,
|
isBoREnabled,
|
||||||
canShowSlashCommands = true,
|
canShowSlashCommands = true,
|
||||||
|
|
@ -68,7 +71,7 @@ export default function QuickActions({
|
||||||
postBoRConfig,
|
postBoRConfig,
|
||||||
location,
|
location,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const atDisabled = value[value.length - 1] === '@';
|
const atDisabled = value.endsWith('@');
|
||||||
const slashDisabled = value.length > 0;
|
const slashDisabled = value.length > 0;
|
||||||
const showBoRAction = isBoREnabled && updatePostBoRStatus && location === Screens.CHANNEL;
|
const showBoRAction = isBoREnabled && updatePostBoRStatus && location === Screens.CHANNEL;
|
||||||
|
|
||||||
|
|
@ -76,6 +79,7 @@ export default function QuickActions({
|
||||||
const slashInputActionTestID = `${testID}.slash_input_action`;
|
const slashInputActionTestID = `${testID}.slash_input_action`;
|
||||||
const emojiActionTestID = `${testID}.emoji_action`;
|
const emojiActionTestID = `${testID}.emoji_action`;
|
||||||
const attachmentActionTestID = `${testID}.attachment_action`;
|
const attachmentActionTestID = `${testID}.attachment_action`;
|
||||||
|
const aiRewriteActionTestID = `${testID}.ai_rewrite_action`;
|
||||||
const postPriorityActionTestID = `${testID}.post_priority_action`;
|
const postPriorityActionTestID = `${testID}.post_priority_action`;
|
||||||
const borPriorityActionTestID = `${testID}.bor_action`;
|
const borPriorityActionTestID = `${testID}.bor_action`;
|
||||||
|
|
||||||
|
|
@ -117,6 +121,13 @@ export default function QuickActions({
|
||||||
testID={emojiActionTestID}
|
testID={emojiActionTestID}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isAgentsEnabled && (
|
||||||
|
<AIRewriteAction
|
||||||
|
testID={aiRewriteActionTestID}
|
||||||
|
value={value}
|
||||||
|
updateValue={updateValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isPostPriorityEnabled && canShowPostPriority && (
|
{isPostPriorityEnabled && canShowPostPriority && (
|
||||||
<PostPriorityAction
|
<PostPriorityAction
|
||||||
testID={postPriorityActionTestID}
|
testID={postPriorityActionTestID}
|
||||||
|
|
|
||||||
45
app/components/post_draft/status_indicator/index.tsx
Normal file
45
app/components/post_draft/status_indicator/index.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {useEffect} from 'react';
|
||||||
|
import {type StyleProp, type ViewStyle} from 'react-native';
|
||||||
|
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import {TYPING_HEIGHT} from '@constants/post_draft';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
visible: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_INDICATOR_HEIGHT = TYPING_HEIGHT;
|
||||||
|
|
||||||
|
function StatusIndicator({
|
||||||
|
visible,
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
}: Props) {
|
||||||
|
const height = useSharedValue(0);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
height: withTiming(height.value),
|
||||||
|
marginBottom: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
height.value = visible ? STATUS_INDICATOR_HEIGHT : 0;
|
||||||
|
}, [visible, height]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[animatedStyle, style]}>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(StatusIndicator);
|
||||||
|
|
||||||
|
|
@ -3,11 +3,10 @@
|
||||||
|
|
||||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
import {DeviceEventEmitter} from 'react-native';
|
import {DeviceEventEmitter} from 'react-native';
|
||||||
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
|
||||||
|
|
||||||
import FormattedText from '@components/formatted_text';
|
import FormattedText from '@components/formatted_text';
|
||||||
|
import StatusIndicator from '@components/post_draft/status_indicator';
|
||||||
import {Events} from '@constants';
|
import {Events} from '@constants';
|
||||||
import {TYPING_HEIGHT} from '@constants/post_draft';
|
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
import {typography} from '@utils/typography';
|
import {typography} from '@utils/typography';
|
||||||
|
|
@ -31,23 +30,15 @@ function Typing({
|
||||||
channelId,
|
channelId,
|
||||||
rootId,
|
rootId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const typingHeight = useSharedValue(0);
|
|
||||||
const typing = useRef<Array<{id: string; now: number; username: string}>>([]);
|
const typing = useRef<Array<{id: string; now: number; username: string}>>([]);
|
||||||
const timeoutToDisappear = useRef<NodeJS.Timeout>();
|
const timeoutToDisappear = useRef<NodeJS.Timeout>();
|
||||||
const mounted = useRef(false);
|
const mounted = useRef(false);
|
||||||
const [refresh, setRefresh] = useState(0);
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const [refresh, setRefresh] = useState(0); // Used to trigger re-renders when typing state changes
|
||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const style = getStyleSheet(theme);
|
const style = getStyleSheet(theme);
|
||||||
|
|
||||||
// This moves the list of post up. This may be rethought by UX in https://mattermost.atlassian.net/browse/MM-39681
|
|
||||||
const typingAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
height: withTiming(typingHeight.value),
|
|
||||||
marginBottom: 4,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const onUserStartTyping = useCallback((msg: any) => {
|
const onUserStartTyping = useCallback((msg: any) => {
|
||||||
if (channelId !== msg.channelId) {
|
if (channelId !== msg.channelId) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -119,18 +110,13 @@ function Typing({
|
||||||
};
|
};
|
||||||
}, [onUserStopTyping]);
|
}, [onUserStopTyping]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
typingHeight.value = typing.current.length ? TYPING_HEIGHT : 0;
|
|
||||||
}, [refresh, typingHeight]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
typing.current = [];
|
typing.current = [];
|
||||||
typingHeight.value = 0;
|
|
||||||
if (timeoutToDisappear.current) {
|
if (timeoutToDisappear.current) {
|
||||||
clearTimeout(timeoutToDisappear.current);
|
clearTimeout(timeoutToDisappear.current);
|
||||||
timeoutToDisappear.current = undefined;
|
timeoutToDisappear.current = undefined;
|
||||||
}
|
}
|
||||||
}, [channelId, rootId, typingHeight]);
|
}, [channelId, rootId]);
|
||||||
|
|
||||||
const renderTyping = () => {
|
const renderTyping = () => {
|
||||||
const nextTyping = typing.current.map(({username}) => username);
|
const nextTyping = typing.current.map(({username}) => username);
|
||||||
|
|
@ -175,12 +161,13 @@ function Typing({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isVisible = typing.current.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={typingAnimatedStyle}>
|
<StatusIndicator visible={isVisible}>
|
||||||
{renderTyping()}
|
{renderTyping()}
|
||||||
</Animated.View>
|
</StatusIndicator>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.memo(Typing);
|
export default React.memo(Typing);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,12 @@ import {getPostTranslation, postUserDisplayName} from '@utils/post';
|
||||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
import {ensureString} from '@utils/types';
|
import {ensureString} from '@utils/types';
|
||||||
import {typography} from '@utils/typography';
|
import {typography} from '@utils/typography';
|
||||||
import {displayUsername, getUserCustomStatus, getUserTimezone, isCustomStatusExpired} from '@utils/user';
|
import {
|
||||||
|
displayUsername,
|
||||||
|
getUserCustomStatus,
|
||||||
|
getUserTimezone,
|
||||||
|
isCustomStatusExpired,
|
||||||
|
} from '@utils/user';
|
||||||
|
|
||||||
import HeaderCommentedOn from './commented_on';
|
import HeaderCommentedOn from './commented_on';
|
||||||
import HeaderDisplayName from './display_name';
|
import HeaderDisplayName from './display_name';
|
||||||
|
|
@ -33,27 +38,27 @@ import type UserModel from '@typings/database/models/servers/user';
|
||||||
import type {AvailableScreens} from '@typings/screens/navigation';
|
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||||
|
|
||||||
type HeaderProps = {
|
type HeaderProps = {
|
||||||
author?: UserModel;
|
author?: UserModel;
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
currentUser?: UserModel;
|
currentUser?: UserModel;
|
||||||
enablePostUsernameOverride: boolean;
|
enablePostUsernameOverride: boolean;
|
||||||
isAutoResponse: boolean;
|
isAutoResponse: boolean;
|
||||||
isCRTEnabled?: boolean;
|
isCRTEnabled?: boolean;
|
||||||
isCustomStatusEnabled: boolean;
|
isCustomStatusEnabled: boolean;
|
||||||
isEphemeral: boolean;
|
isEphemeral: boolean;
|
||||||
isMilitaryTime: boolean;
|
isMilitaryTime: boolean;
|
||||||
isPendingOrFailed: boolean;
|
isPendingOrFailed: boolean;
|
||||||
isSystemPost: boolean;
|
isSystemPost: boolean;
|
||||||
isWebHook: boolean;
|
isWebHook: boolean;
|
||||||
location: AvailableScreens;
|
location: AvailableScreens;
|
||||||
post: PostModel;
|
post: PostModel;
|
||||||
rootPostAuthor?: UserModel;
|
rootPostAuthor?: UserModel;
|
||||||
showPostPriority: boolean;
|
showPostPriority: boolean;
|
||||||
shouldRenderReplyButton?: boolean;
|
shouldRenderReplyButton?: boolean;
|
||||||
teammateNameDisplay: string;
|
teammateNameDisplay: string;
|
||||||
hideGuestTags: boolean;
|
hideGuestTags: boolean;
|
||||||
isChannelAutotranslated: boolean;
|
isChannelAutotranslated: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -109,19 +114,46 @@ const Header = ({
|
||||||
const style = getStyleSheet(theme);
|
const style = getStyleSheet(theme);
|
||||||
const pendingPostStyle = isPendingOrFailed ? style.pendingPost : undefined;
|
const pendingPostStyle = isPendingOrFailed ? style.pendingPost : undefined;
|
||||||
const isReplyPost = Boolean(post.rootId && !isEphemeral);
|
const isReplyPost = Boolean(post.rootId && !isEphemeral);
|
||||||
const showReply = !isReplyPost && (location !== THREAD) && (shouldRenderReplyButton && (!rootPostAuthor && commentCount > 0));
|
const showReply =
|
||||||
const displayName = postUserDisplayName(post, author, teammateNameDisplay, enablePostUsernameOverride);
|
!isReplyPost &&
|
||||||
const rootAuthorDisplayName = rootPostAuthor ? displayUsername(rootPostAuthor, currentUser?.locale, teammateNameDisplay, true) : undefined;
|
location !== THREAD &&
|
||||||
|
shouldRenderReplyButton &&
|
||||||
|
!rootPostAuthor &&
|
||||||
|
commentCount > 0;
|
||||||
|
const displayName = postUserDisplayName(
|
||||||
|
post,
|
||||||
|
author,
|
||||||
|
teammateNameDisplay,
|
||||||
|
enablePostUsernameOverride,
|
||||||
|
);
|
||||||
|
const rootAuthorDisplayName = rootPostAuthor
|
||||||
|
? displayUsername(
|
||||||
|
rootPostAuthor,
|
||||||
|
currentUser?.locale,
|
||||||
|
teammateNameDisplay,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
const customStatus = getUserCustomStatus(author);
|
const customStatus = getUserCustomStatus(author);
|
||||||
const showCustomStatusEmoji = Boolean(
|
const showCustomStatusEmoji =
|
||||||
isCustomStatusEnabled && displayName && customStatus &&
|
Boolean(
|
||||||
|
isCustomStatusEnabled &&
|
||||||
|
displayName &&
|
||||||
|
customStatus &&
|
||||||
!(isSystemPost || author?.isBot || isAutoResponse || isWebHook),
|
!(isSystemPost || author?.isBot || isAutoResponse || isWebHook),
|
||||||
) && !isCustomStatusExpired(author) && Boolean(customStatus?.emoji);
|
) &&
|
||||||
|
!isCustomStatusExpired(author) &&
|
||||||
|
Boolean(customStatus?.emoji);
|
||||||
const userIconOverride = ensureString(post.props?.override_icon_url);
|
const userIconOverride = ensureString(post.props?.override_icon_url);
|
||||||
const usernameOverride = ensureString(post.props?.override_username);
|
const usernameOverride = ensureString(post.props?.override_username);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const showBoRIcon = useMemo(() => isUnrevealedBoRPost(post), [post, post.metadata?.expire_at]);
|
/* eslint-disable react-hooks/exhaustive-deps -- expire_at triggers recomputation when post metadata changes */
|
||||||
|
const showBoRIcon = useMemo(
|
||||||
|
() => isUnrevealedBoRPost(post),
|
||||||
|
[post, post.metadata?.expire_at],
|
||||||
|
);
|
||||||
|
/* eslint-enable react-hooks/exhaustive-deps */
|
||||||
const borExpireAt = post.metadata?.expire_at;
|
const borExpireAt = post.metadata?.expire_at;
|
||||||
const serverUrl = useServerUrl();
|
const serverUrl = useServerUrl();
|
||||||
|
|
||||||
|
|
@ -149,13 +181,13 @@ const Header = ({
|
||||||
showCustomStatusEmoji={showCustomStatusEmoji}
|
showCustomStatusEmoji={showCustomStatusEmoji}
|
||||||
customStatus={customStatus!}
|
customStatus={customStatus!}
|
||||||
/>
|
/>
|
||||||
{(!isSystemPost || isAutoResponse) &&
|
{(!isSystemPost || isAutoResponse) && (
|
||||||
<HeaderTag
|
<HeaderTag
|
||||||
isAutoResponder={isAutoResponse}
|
isAutoResponder={isAutoResponse}
|
||||||
isAutomation={isWebHook || author?.isBot}
|
isAutomation={isWebHook || author?.isBot}
|
||||||
showGuestTag={author?.isGuest && !hideGuestTags}
|
showGuestTag={author?.isGuest && !hideGuestTags}
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
<FormattedTime
|
<FormattedTime
|
||||||
timezone={getUserTimezone(currentUser)}
|
timezone={getUserTimezone(currentUser)}
|
||||||
isMilitaryTime={isMilitaryTime}
|
isMilitaryTime={isMilitaryTime}
|
||||||
|
|
@ -163,9 +195,9 @@ const Header = ({
|
||||||
style={style.time}
|
style={style.time}
|
||||||
testID='post_header.date_time'
|
testID='post_header.date_time'
|
||||||
/>
|
/>
|
||||||
{isChannelAutotranslated && post.type === '' &&
|
{isChannelAutotranslated && post.type === '' && (
|
||||||
<TranslateIcon translationState={translation?.state}/>
|
<TranslateIcon translationState={translation?.state}/>
|
||||||
}
|
)}
|
||||||
{isEphemeral && (
|
{isEphemeral && (
|
||||||
<FormattedText
|
<FormattedText
|
||||||
id='post_header.visible_message'
|
id='post_header.visible_message'
|
||||||
|
|
@ -175,41 +207,38 @@ const Header = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showPostPriority && post.metadata?.priority?.priority && (
|
{showPostPriority && post.metadata?.priority?.priority && (
|
||||||
<PostPriorityLabel
|
<PostPriorityLabel label={post.metadata.priority.priority}/>
|
||||||
label={post.metadata.priority.priority}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{showBoRIcon &&
|
{showBoRIcon && (
|
||||||
<CompassIcon
|
<CompassIcon
|
||||||
name='fire'
|
name='fire'
|
||||||
size={16}
|
size={16}
|
||||||
color={theme.dndIndicator}
|
color={theme.dndIndicator}
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
{
|
{Boolean(borExpireAt) && (
|
||||||
Boolean(borExpireAt) &&
|
|
||||||
<ExpiryTimer
|
<ExpiryTimer
|
||||||
expiryTime={borExpireAt as number}
|
expiryTime={borExpireAt as number}
|
||||||
onExpiry={onBoRPostExpiry}
|
onExpiry={onBoRPostExpiry}
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
{!isCRTEnabled && showReply && commentCount > 0 &&
|
{!isCRTEnabled && showReply && commentCount > 0 && (
|
||||||
<HeaderReply
|
<HeaderReply
|
||||||
commentCount={commentCount}
|
commentCount={commentCount}
|
||||||
location={location}
|
location={location}
|
||||||
post={post}
|
post={post}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{Boolean(rootAuthorDisplayName) && location === CHANNEL &&
|
{Boolean(rootAuthorDisplayName) && location === CHANNEL && (
|
||||||
<HeaderCommentedOn
|
<HeaderCommentedOn
|
||||||
locale={currentUser?.locale || DEFAULT_LOCALE}
|
locale={currentUser?.locale || DEFAULT_LOCALE}
|
||||||
name={rootAuthorDisplayName!}
|
name={rootAuthorDisplayName!}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import AGENTS_SCREENS from '@agents/constants/screens';
|
||||||
|
|
||||||
import PLAYBOOKS_SCREENS from '@playbooks/constants/screens';
|
import PLAYBOOKS_SCREENS from '@playbooks/constants/screens';
|
||||||
|
|
||||||
export const ABOUT = 'About';
|
export const ABOUT = 'About';
|
||||||
|
|
@ -185,6 +187,7 @@ export default {
|
||||||
USER_PROFILE,
|
USER_PROFILE,
|
||||||
SHOW_TRANSLATION,
|
SHOW_TRANSLATION,
|
||||||
...PLAYBOOKS_SCREENS,
|
...PLAYBOOKS_SCREENS,
|
||||||
|
...AGENTS_SCREENS,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
|
export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
|
||||||
|
|
@ -214,6 +217,8 @@ export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set<string>([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
|
export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
|
||||||
|
AGENTS_SCREENS.AGENTS_SELECTOR,
|
||||||
|
AGENTS_SCREENS.AGENTS_REWRITE_OPTIONS,
|
||||||
ATTACHMENT_OPTIONS,
|
ATTACHMENT_OPTIONS,
|
||||||
BOTTOM_SHEET,
|
BOTTOM_SHEET,
|
||||||
DRAFT_SCHEDULED_POST_OPTIONS,
|
DRAFT_SCHEDULED_POST_OPTIONS,
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,9 @@ export function useGalleryItem(
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
gallery.registerItem(index, ref);
|
gallery.registerItem(index, ref);
|
||||||
|
|
||||||
|
// Only register item once on mount - gallery, index, and ref are stable references
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onGestureEvent = () => {
|
const onGestureEvent = () => {
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ export const useHandleSendMessage = ({
|
||||||
}, [intl, channelTimezoneCount, doSubmitMessage]);
|
}, [intl, channelTimezoneCount, doSubmitMessage]);
|
||||||
|
|
||||||
const sendCommand = useCallback(async () => {
|
const sendCommand = useCallback(async () => {
|
||||||
if (value.trim().startsWith('/call')) {
|
if (value && value.trim().startsWith('/call')) {
|
||||||
const {handled, error} = await handleCallsSlashCommand(value.trim(), serverUrl, channelId, channelType ?? '', rootId, currentUserId, intl);
|
const {handled, error} = await handleCallsSlashCommand(value.trim(), serverUrl, channelId, channelType ?? '', rootId, currentUserId, intl);
|
||||||
if (handled) {
|
if (handled) {
|
||||||
setSendingMessage(false);
|
setSendingMessage(false);
|
||||||
|
|
@ -227,15 +227,15 @@ export const useHandleSendMessage = ({
|
||||||
|
|
||||||
clearDraft();
|
clearDraft();
|
||||||
|
|
||||||
if (data?.goto_location && !value.startsWith('/leave')) {
|
if (data?.goto_location && value && !value.startsWith('/leave')) {
|
||||||
handleGotoLocation(serverUrl, intl, data.goto_location);
|
handleGotoLocation(serverUrl, intl, data.goto_location);
|
||||||
}
|
}
|
||||||
}, [value, userIsOutOfOffice, serverUrl, intl, channelId, rootId, clearDraft, channelType, currentUserId]);
|
}, [value, userIsOutOfOffice, serverUrl, intl, channelId, rootId, clearDraft, channelType, currentUserId]);
|
||||||
|
|
||||||
const sendMessage = useCallback(async (schedulingInfo?: SchedulingInfo) => {
|
const sendMessage = useCallback(async (schedulingInfo?: SchedulingInfo) => {
|
||||||
const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions;
|
const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions;
|
||||||
const toAllOrChannel = DraftUtils.textContainsAtAllAtChannel(value);
|
const toAllOrChannel = value ? DraftUtils.textContainsAtAllAtChannel(value) : false;
|
||||||
const toHere = DraftUtils.textContainsAtHere(value);
|
const toHere = value ? DraftUtils.textContainsAtHere(value) : false;
|
||||||
|
|
||||||
if (value.indexOf('/') === 0 && !schedulingInfo) {
|
if (value.indexOf('/') === 0 && !schedulingInfo) {
|
||||||
// Don't execute slash command when scheduling message
|
// Don't execute slash command when scheduling message
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||||
|
|
||||||
jest.mock('@utils/log', () => ({
|
jest.mock('@utils/log', () => ({
|
||||||
logDebug: () => '',
|
logDebug: () => '',
|
||||||
|
logError: jest.fn(),
|
||||||
|
logInfo: jest.fn(),
|
||||||
|
logWarning: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,54 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {type Agent} from '@agents/client/rest';
|
import {rewriteStore} from '@agents/store';
|
||||||
|
|
||||||
import NetworkManager from '@managers/network_manager';
|
import NetworkManager from '@managers/network_manager';
|
||||||
import {getFullErrorMessage} from '@utils/errors';
|
import {getFullErrorMessage} from '@utils/errors';
|
||||||
import {logError} from '@utils/log';
|
import {logDebug, logError} from '@utils/log';
|
||||||
|
|
||||||
export async function fetchAgents(
|
import type {RewriteAction} from '@agents/types';
|
||||||
serverUrl: string,
|
|
||||||
): Promise<{data?: Agent[]; error?: string}> {
|
/**
|
||||||
|
* Fetch available agents from the server and store them in the rewrite store
|
||||||
|
*/
|
||||||
|
export const fetchAgents = async (serverUrl: string) => {
|
||||||
try {
|
try {
|
||||||
const client = NetworkManager.getClient(serverUrl);
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
const agents = await client.getAgents();
|
const agents = await client.getAgents();
|
||||||
|
|
||||||
|
// Store agents in rewriteStore
|
||||||
|
rewriteStore.setAgents(serverUrl, agents);
|
||||||
|
|
||||||
return {data: agents};
|
return {data: agents};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('[fetchAgents]', error);
|
logDebug('[fetchAgents] Error:', getFullErrorMessage(error));
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite a message using the AI service
|
||||||
|
* @param serverUrl The server URL
|
||||||
|
* @param message The message to rewrite
|
||||||
|
* @param action The rewrite action to perform
|
||||||
|
* @param customPrompt Optional custom prompt for the rewrite
|
||||||
|
* @param agentId Optional agent ID to use for the rewrite
|
||||||
|
* @returns {rewrittenText} on success, {error} on failure
|
||||||
|
*/
|
||||||
|
export async function rewriteMessage(
|
||||||
|
serverUrl: string,
|
||||||
|
message: string,
|
||||||
|
action: RewriteAction,
|
||||||
|
customPrompt: string | undefined,
|
||||||
|
agentId: string | undefined,
|
||||||
|
): Promise<{rewrittenText?: string; error?: unknown}> {
|
||||||
|
try {
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
const rewrittenText = await client.getRewrittenMessage(message, action, customPrompt, agentId);
|
||||||
|
return {rewrittenText};
|
||||||
|
} catch (error) {
|
||||||
|
logError('[rewriteMessage]', error);
|
||||||
return {error: getFullErrorMessage(error)};
|
return {error: getFullErrorMessage(error)};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import type {Agent, AgentsResponse, AgentsStatusResponse, ChannelAnalysisOptions, ChannelAnalysisResponse} from '@agents/types/api';
|
import type {Agent, AgentsResponse, AgentsStatusResponse, ChannelAnalysisOptions, ChannelAnalysisResponse, RewriteRequest, RewriteResponse} from '@agents/types/api';
|
||||||
|
|
||||||
export type {Agent};
|
export type {Agent};
|
||||||
|
|
||||||
|
|
@ -17,6 +17,9 @@ export interface ClientAgentsMix {
|
||||||
options?: ChannelAnalysisOptions,
|
options?: ChannelAnalysisOptions,
|
||||||
) => Promise<ChannelAnalysisResponse>;
|
) => Promise<ChannelAnalysisResponse>;
|
||||||
submitToolApproval: (postId: string, acceptedToolIds: string[]) => Promise<void>;
|
submitToolApproval: (postId: string, acceptedToolIds: string[]) => Promise<void>;
|
||||||
|
|
||||||
|
// Rewrite methods
|
||||||
|
getRewrittenMessage: (message: string, action?: string, customPrompt?: string, agentId?: string) => Promise<string>;
|
||||||
getAgentsStatus: () => Promise<AgentsStatusResponse>;
|
getAgentsStatus: () => Promise<AgentsStatusResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,6 +88,33 @@ const ClientAgents = (superclass: any) => class extends superclass {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Rewrite Methods
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
getRewrittenMessage = async (message: string, action?: string, customPrompt?: string, agentId?: string): Promise<string> => {
|
||||||
|
const body: RewriteRequest = {
|
||||||
|
agent_id: agentId,
|
||||||
|
message,
|
||||||
|
action,
|
||||||
|
custom_prompt: customPrompt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await this.doFetch(
|
||||||
|
`${this.urlVersion}/posts/rewrite`,
|
||||||
|
{method: 'post', body},
|
||||||
|
true,
|
||||||
|
) as RewriteResponse;
|
||||||
|
|
||||||
|
// Handle cases where the AI returns plain text instead of the expected JSON format.
|
||||||
|
// If rewritten_text is undefined, treat the entire response as the rewritten message.
|
||||||
|
if (response.rewritten_text === undefined) {
|
||||||
|
return response as unknown as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.rewritten_text;
|
||||||
|
};
|
||||||
|
|
||||||
getAgentsStatus = async (): Promise<AgentsStatusResponse> => {
|
getAgentsStatus = async (): Promise<AgentsStatusResponse> => {
|
||||||
return this.doFetch(
|
return this.doFetch(
|
||||||
`${this.urlVersion}/agents/status`,
|
`${this.urlVersion}/agents/status`,
|
||||||
|
|
|
||||||
83
app/products/agents/components/ai_rewrite_action/index.tsx
Normal file
83
app/products/agents/components/ai_rewrite_action/index.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {useRewrite} from '@agents/hooks';
|
||||||
|
import React, {useCallback} from 'react';
|
||||||
|
import {useIntl} from 'react-intl';
|
||||||
|
import {Keyboard} from 'react-native';
|
||||||
|
|
||||||
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||||
|
import {Screens} from '@constants';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {useIsTablet} from '@hooks/device';
|
||||||
|
import {openAsBottomSheet} from '@screens/navigation';
|
||||||
|
import {changeOpacity} from '@utils/theme';
|
||||||
|
|
||||||
|
const ICON_SIZE = 24;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
testID?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
value: string;
|
||||||
|
updateValue: (value: string | ((prevValue: string) => string)) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
icon: {
|
||||||
|
alignItems: 'center' as const,
|
||||||
|
justifyContent: 'center' as const,
|
||||||
|
padding: 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AIRewriteAction({
|
||||||
|
testID,
|
||||||
|
disabled = false,
|
||||||
|
value,
|
||||||
|
updateValue,
|
||||||
|
}: Props) {
|
||||||
|
const intl = useIntl();
|
||||||
|
const theme = useTheme();
|
||||||
|
const isTablet = useIsTablet();
|
||||||
|
const {isProcessing} = useRewrite();
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
const title = isTablet ? intl.formatMessage({id: 'ai_rewrite.title', defaultMessage: 'AI Rewrite'}) : '';
|
||||||
|
|
||||||
|
openAsBottomSheet({
|
||||||
|
closeButtonId: 'close-ai-rewrite',
|
||||||
|
screen: Screens.AGENTS_REWRITE_OPTIONS,
|
||||||
|
theme,
|
||||||
|
title,
|
||||||
|
props: {
|
||||||
|
closeButtonId: 'close-ai-rewrite',
|
||||||
|
originalMessage: value,
|
||||||
|
updateValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [intl, isTablet, theme, value, updateValue]);
|
||||||
|
|
||||||
|
const isDisabled = disabled || isProcessing;
|
||||||
|
const actionTestID = isDisabled ? `${testID}.disabled` : testID;
|
||||||
|
const iconColor = isDisabled ?
|
||||||
|
changeOpacity(theme.centerChannelColor, 0.16) :
|
||||||
|
changeOpacity(theme.centerChannelColor, 0.64);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableWithFeedback
|
||||||
|
testID={actionTestID}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onPress={handlePress}
|
||||||
|
style={styles.icon}
|
||||||
|
type={'opacity'}
|
||||||
|
>
|
||||||
|
<CompassIcon
|
||||||
|
name='creation-outline'
|
||||||
|
color={iconColor}
|
||||||
|
size={ICON_SIZE}
|
||||||
|
/>
|
||||||
|
</TouchableWithFeedback>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
app/products/agents/components/rewriting_indicator/index.tsx
Normal file
79
app/products/agents/components/rewriting_indicator/index.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {useRewrite} from '@agents/hooks';
|
||||||
|
import React, {useEffect} from 'react';
|
||||||
|
import {useIntl} from 'react-intl';
|
||||||
|
import {StyleSheet, Text, View} from 'react-native';
|
||||||
|
import Animated, {cancelAnimation, Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import StatusIndicator from '@components/post_draft/status_indicator';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {changeOpacity} from '@utils/theme';
|
||||||
|
import {typography} from '@utils/typography';
|
||||||
|
|
||||||
|
const SPINNER_SIZE = 10;
|
||||||
|
|
||||||
|
const getStyleSheet = (theme: Theme) => StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
},
|
||||||
|
spinner: {
|
||||||
|
height: SPINNER_SIZE,
|
||||||
|
width: SPINNER_SIZE,
|
||||||
|
borderRadius: SPINNER_SIZE / 2,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderLeftColor: changeOpacity(theme.centerChannelColor, 0.7),
|
||||||
|
borderTopColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||||
|
borderRightColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||||
|
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||||
|
marginRight: 6,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.7),
|
||||||
|
...typography('Body', 75),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function RewritingIndicator() {
|
||||||
|
const {isProcessing} = useRewrite();
|
||||||
|
const theme = useTheme();
|
||||||
|
const intl = useIntl();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
const rotation = useSharedValue(0);
|
||||||
|
|
||||||
|
const spinnerAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{rotateZ: `${rotation.value}deg`}],
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isProcessing) {
|
||||||
|
rotation.value = withRepeat(
|
||||||
|
withTiming(360, {duration: 750, easing: Easing.linear}),
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimation(rotation);
|
||||||
|
};
|
||||||
|
}, [isProcessing, rotation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatusIndicator visible={isProcessing}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Animated.View style={[styles.spinner, spinnerAnimatedStyle]}/>
|
||||||
|
<Text style={styles.text}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'ai_rewrite.rewriting',
|
||||||
|
defaultMessage: 'Rewriting...',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</StatusIndicator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(RewritingIndicator);
|
||||||
10
app/products/agents/constants/screens.ts
Normal file
10
app/products/agents/constants/screens.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
export const AGENTS_SELECTOR = 'AgentsSelector';
|
||||||
|
export const AGENTS_REWRITE_OPTIONS = 'AgentsRewriteOptions';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
AGENTS_SELECTOR,
|
||||||
|
AGENTS_REWRITE_OPTIONS,
|
||||||
|
} as const;
|
||||||
5
app/products/agents/hooks/index.ts
Normal file
5
app/products/agents/hooks/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
export {useRewrite, useRewriteState} from './use_rewrite';
|
||||||
|
export {useAgents} from './use_agents';
|
||||||
23
app/products/agents/hooks/use_agents.ts
Normal file
23
app/products/agents/hooks/use_agents.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {rewriteStore} from '@agents/store';
|
||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
|
||||||
|
import type {Agent} from '@agents/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook to subscribe to agents for a server
|
||||||
|
*/
|
||||||
|
export function useAgents(serverUrl: string): Agent[] {
|
||||||
|
const [agents, setAgents] = useState<Agent[]>(
|
||||||
|
() => rewriteStore.getAgents(serverUrl),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = rewriteStore.observeAgents(serverUrl).subscribe(setAgents);
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, [serverUrl]);
|
||||||
|
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
185
app/products/agents/hooks/use_rewrite.ts
Normal file
185
app/products/agents/hooks/use_rewrite.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {rewriteMessage} from '@agents/actions/remote/agents';
|
||||||
|
import {rewriteStore, type RewriteState} from '@agents/store';
|
||||||
|
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
|
|
||||||
|
import {logWarning} from '@utils/log';
|
||||||
|
|
||||||
|
import type {RewriteAction} from '@agents/types';
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 30000; // 30 seconds
|
||||||
|
|
||||||
|
type StartRewriteCallback = (
|
||||||
|
serverUrl: string,
|
||||||
|
message: string,
|
||||||
|
action: RewriteAction,
|
||||||
|
customPrompt: string | undefined,
|
||||||
|
agentId: string | undefined,
|
||||||
|
onSuccess: (rewrittenText: string) => void,
|
||||||
|
onError: (error: string) => void,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
type UseRewriteReturn = {
|
||||||
|
isProcessing: boolean;
|
||||||
|
startRewrite: StartRewriteCallback;
|
||||||
|
cancelRewrite: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that manages AI rewrite state across screens
|
||||||
|
*/
|
||||||
|
export const useRewrite = (): UseRewriteReturn => {
|
||||||
|
const [isProcessing, setIsProcessing] = useState(rewriteStore.isRewriteProcessing());
|
||||||
|
const currentPromiseRef = useRef<Promise<{rewrittenText?: string; error?: unknown}> | null>(null);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const isCancelledRef = useRef(false);
|
||||||
|
|
||||||
|
// Subscribe to rewrite store changes
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = rewriteStore.observeRewriteState().subscribe((state) => {
|
||||||
|
setIsProcessing(state.isProcessing);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelRewrite = useCallback(() => {
|
||||||
|
isCancelledRef.current = true;
|
||||||
|
currentPromiseRef.current = null;
|
||||||
|
rewriteStore.setRewriteProcessing(false, '');
|
||||||
|
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startRewrite = useCallback((
|
||||||
|
serverUrl: string,
|
||||||
|
message: string,
|
||||||
|
action: RewriteAction,
|
||||||
|
customPrompt: string | undefined,
|
||||||
|
agentId: string | undefined,
|
||||||
|
onSuccess: (rewrittenText: string) => void,
|
||||||
|
onError: (error: string) => void,
|
||||||
|
) => {
|
||||||
|
if (rewriteStore.isRewriteProcessing()) {
|
||||||
|
logWarning('[useRewrite] Rewrite already in progress');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rewriteStore.setRewriteProcessing(true, serverUrl);
|
||||||
|
isCancelledRef.current = false;
|
||||||
|
|
||||||
|
// Create timeout promise
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
reject(new Error('timeout'));
|
||||||
|
}, TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
const runRewrite = async () => {
|
||||||
|
try {
|
||||||
|
const rewritePromise = rewriteMessage(serverUrl, message, action, customPrompt, agentId);
|
||||||
|
currentPromiseRef.current = rewritePromise;
|
||||||
|
|
||||||
|
const result = await Promise.race([rewritePromise, timeoutPromise]);
|
||||||
|
|
||||||
|
// Clear timeout if successful
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is still the current promise (not cancelled)
|
||||||
|
if (currentPromiseRef.current === rewritePromise && !isCancelledRef.current) {
|
||||||
|
// Handle error from remote action
|
||||||
|
if (result.error) {
|
||||||
|
logWarning('[useRewrite] Error from remote action:', result.error);
|
||||||
|
rewriteStore.setRewriteProcessing(false, '');
|
||||||
|
currentPromiseRef.current = null;
|
||||||
|
onError('An error occurred while rewriting your message. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure response is a valid non-empty string
|
||||||
|
const response = result.rewrittenText;
|
||||||
|
if (response && typeof response === 'string' && response.trim().length > 0) {
|
||||||
|
// If response is a JSON-encoded string, parse it to get actual newlines/escapes
|
||||||
|
let formattedResponse = response;
|
||||||
|
if (response.startsWith('"') && response.endsWith('"')) {
|
||||||
|
try {
|
||||||
|
formattedResponse = JSON.parse(response);
|
||||||
|
} catch (e) {
|
||||||
|
logWarning('[useRewrite] Failed to parse JSON-encoded response, using raw:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rewriteStore.setRewriteProcessing(false, '');
|
||||||
|
currentPromiseRef.current = null;
|
||||||
|
onSuccess(formattedResponse);
|
||||||
|
} else {
|
||||||
|
logWarning('[useRewrite] Invalid or empty response received:', response);
|
||||||
|
rewriteStore.setRewriteProcessing(false, '');
|
||||||
|
currentPromiseRef.current = null;
|
||||||
|
onError('Received an invalid response from AI. Please try again.');
|
||||||
|
}
|
||||||
|
} else if (isCancelledRef.current) {
|
||||||
|
// Operation was cancelled, just cleanup
|
||||||
|
rewriteStore.setRewriteProcessing(false, '');
|
||||||
|
currentPromiseRef.current = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Clear timeout on error
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only handle error if this is still the current promise and not cancelled
|
||||||
|
if (currentPromiseRef.current && !isCancelledRef.current) {
|
||||||
|
logWarning('[useRewrite] Error:', error);
|
||||||
|
|
||||||
|
let errorMsg = 'An error occurred while rewriting your message. Please try again.';
|
||||||
|
if (error instanceof Error && error.message === 'timeout') {
|
||||||
|
errorMsg = 'The AI request timed out. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
rewriteStore.setRewriteProcessing(false, '');
|
||||||
|
currentPromiseRef.current = null;
|
||||||
|
onError(errorMsg);
|
||||||
|
} else if (isCancelledRef.current) {
|
||||||
|
// Operation was cancelled, just cleanup
|
||||||
|
rewriteStore.setRewriteProcessing(false, '');
|
||||||
|
currentPromiseRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
runRewrite();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isProcessing,
|
||||||
|
startRewrite,
|
||||||
|
cancelRewrite,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook to subscribe to rewrite state
|
||||||
|
*/
|
||||||
|
export function useRewriteState(): RewriteState {
|
||||||
|
const [state, setState] = useState<RewriteState>(
|
||||||
|
() => rewriteStore.getRewriteState(),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = rewriteStore.observeRewriteState().subscribe(setState);
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
11
app/products/agents/queries/agents.ts
Normal file
11
app/products/agents/queries/agents.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {rewriteStore} from '@agents/store';
|
||||||
|
import {map} from 'rxjs/operators';
|
||||||
|
|
||||||
|
export const observeIsAgentsEnabled = (serverUrl: string) => {
|
||||||
|
return rewriteStore.observeAgents(serverUrl).pipe(
|
||||||
|
map((agents) => agents.length > 0),
|
||||||
|
);
|
||||||
|
};
|
||||||
34
app/products/agents/screens/agent_selector/agent_item.tsx
Normal file
34
app/products/agents/screens/agent_selector/agent_item.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {useCallback} from 'react';
|
||||||
|
|
||||||
|
import OptionItem from '@components/option_item';
|
||||||
|
|
||||||
|
import type {Agent} from '@agents/types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agent: Agent;
|
||||||
|
selectedAgentId: string;
|
||||||
|
onSelect: (agent: Agent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AgentItem = ({agent, selectedAgentId, onSelect}: Props) => {
|
||||||
|
const handleSelect = useCallback(() => {
|
||||||
|
onSelect(agent);
|
||||||
|
}, [agent, onSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OptionItem
|
||||||
|
label={agent.displayName}
|
||||||
|
description={`@${agent.username} • ${agent.service_type}`}
|
||||||
|
action={handleSelect}
|
||||||
|
type='radio'
|
||||||
|
selected={agent.id === selectedAgentId}
|
||||||
|
testID={`ai_agent_selector.agent.${agent.id}`}
|
||||||
|
descriptionNumberOfLines={1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentItem;
|
||||||
127
app/products/agents/screens/agent_selector/index.tsx
Normal file
127
app/products/agents/screens/agent_selector/index.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {BottomSheetFlatList} from '@gorhom/bottom-sheet';
|
||||||
|
import React, {useCallback, useMemo} from 'react';
|
||||||
|
import {type ListRenderItemInfo, View} from 'react-native';
|
||||||
|
import {FlatList} from 'react-native-gesture-handler';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import {ITEM_HEIGHT} from '@components/option_item';
|
||||||
|
import {Screens} from '@constants';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||||
|
import {useBottomSheetListsFix} from '@hooks/bottom_sheet_lists_fix';
|
||||||
|
import {useIsTablet} from '@hooks/device';
|
||||||
|
import useNavButtonPressed from '@hooks/navigation_button_pressed';
|
||||||
|
import BottomSheet from '@screens/bottom_sheet';
|
||||||
|
import {dismissModal} from '@screens/navigation';
|
||||||
|
import {bottomSheetSnapPoint} from '@utils/helpers';
|
||||||
|
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
|
import AgentItem from './agent_item';
|
||||||
|
|
||||||
|
import type {Agent} from '@agents/types';
|
||||||
|
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
componentId: AvailableScreens;
|
||||||
|
closeButtonId: string;
|
||||||
|
agents: Agent[];
|
||||||
|
selectedAgentId: string;
|
||||||
|
onSelectAgent: (agent: Agent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPTIONS_PADDING = 12;
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||||
|
container: {
|
||||||
|
flexGrow: 1,
|
||||||
|
backgroundColor: theme.centerChannelBg,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
paddingTop: OPTIONS_PADDING,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const keyExtractor = (item: Agent) => item.id;
|
||||||
|
|
||||||
|
const AgentSelector = ({
|
||||||
|
componentId,
|
||||||
|
closeButtonId,
|
||||||
|
agents,
|
||||||
|
selectedAgentId,
|
||||||
|
onSelectAgent,
|
||||||
|
}: Props) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
const isTablet = useIsTablet();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const {enabled, panResponder} = useBottomSheetListsFix();
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
dismissModal({componentId});
|
||||||
|
}, [componentId]);
|
||||||
|
|
||||||
|
useNavButtonPressed(closeButtonId, componentId, close, []);
|
||||||
|
useAndroidHardwareBackHandler(componentId, close);
|
||||||
|
|
||||||
|
const handleSelectAgent = useCallback((agent: Agent) => {
|
||||||
|
onSelectAgent(agent);
|
||||||
|
close();
|
||||||
|
}, [onSelectAgent, close]);
|
||||||
|
|
||||||
|
const List = useMemo(() => (isTablet ? FlatList : BottomSheetFlatList), [isTablet]);
|
||||||
|
|
||||||
|
const renderItem = useCallback(({item}: ListRenderItemInfo<Agent>) => (
|
||||||
|
<AgentItem
|
||||||
|
agent={item}
|
||||||
|
selectedAgentId={selectedAgentId}
|
||||||
|
onSelect={handleSelectAgent}
|
||||||
|
/>
|
||||||
|
), [selectedAgentId, handleSelectAgent]);
|
||||||
|
|
||||||
|
const snapPoints = useMemo(() => {
|
||||||
|
const paddingBottom = 10;
|
||||||
|
|
||||||
|
// Calculate height based on number of agents
|
||||||
|
const optionsHeight = OPTIONS_PADDING + bottomSheetSnapPoint(agents.length, ITEM_HEIGHT);
|
||||||
|
const componentHeight = optionsHeight + paddingBottom + insets.bottom;
|
||||||
|
|
||||||
|
const points: Array<string | number> = [1, componentHeight];
|
||||||
|
|
||||||
|
// Add scrollable snap point if there are many agents
|
||||||
|
if (agents.length > 5) {
|
||||||
|
points.push('80%');
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}, [agents.length, insets.bottom]);
|
||||||
|
|
||||||
|
const renderContent = () => (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<List
|
||||||
|
data={agents}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
contentContainerStyle={styles.contentContainer}
|
||||||
|
testID='ai_agent_selector.flat_list'
|
||||||
|
scrollEnabled={enabled}
|
||||||
|
{...panResponder.panHandlers}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheet
|
||||||
|
renderContent={renderContent}
|
||||||
|
closeButtonId={closeButtonId}
|
||||||
|
componentId={Screens.AGENTS_SELECTOR}
|
||||||
|
initialSnapIndex={1}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
testID='ai_agent_selector'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentSelector;
|
||||||
17
app/products/agents/screens/index.ts
Normal file
17
app/products/agents/screens/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import Screens from '@agents/constants/screens';
|
||||||
|
|
||||||
|
import {withServerDatabase} from '@database/components';
|
||||||
|
|
||||||
|
export function loadAgentsScreen(screenName: string | number) {
|
||||||
|
switch (screenName) {
|
||||||
|
case Screens.AGENTS_SELECTOR:
|
||||||
|
return withServerDatabase(require('@agents/screens/agent_selector').default);
|
||||||
|
case Screens.AGENTS_REWRITE_OPTIONS:
|
||||||
|
return withServerDatabase(require('@agents/screens/rewrite_options').default);
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
356
app/products/agents/screens/rewrite_options/index.tsx
Normal file
356
app/products/agents/screens/rewrite_options/index.tsx
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {useAgents, useRewrite} from '@agents/hooks';
|
||||||
|
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
|
import {defineMessages, useIntl} from 'react-intl';
|
||||||
|
import {Alert, Keyboard, TextInput, View} from 'react-native';
|
||||||
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import OptionItem, {ITEM_HEIGHT} from '@components/option_item';
|
||||||
|
import {Screens} from '@constants';
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||||
|
import {useIsTablet} from '@hooks/device';
|
||||||
|
import useNavButtonPressed from '@hooks/navigation_button_pressed';
|
||||||
|
import BottomSheet from '@screens/bottom_sheet';
|
||||||
|
import {dismissBottomSheet, openAsBottomSheet} from '@screens/navigation';
|
||||||
|
import {bottomSheetSnapPoint} from '@utils/helpers';
|
||||||
|
import {logWarning} from '@utils/log';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
|
import type {Agent, RewriteAction} from '@agents/types';
|
||||||
|
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
errorTitle: {
|
||||||
|
id: 'ai_rewrite.error.title',
|
||||||
|
defaultMessage: 'AI Rewrite Error',
|
||||||
|
},
|
||||||
|
errorOk: {
|
||||||
|
id: 'ai_rewrite.error.ok',
|
||||||
|
defaultMessage: 'OK',
|
||||||
|
},
|
||||||
|
agentSelectorTitle: {
|
||||||
|
id: 'ai_rewrite.agent_selector_title',
|
||||||
|
defaultMessage: 'Select AI Agent',
|
||||||
|
},
|
||||||
|
selectedAgent: {
|
||||||
|
id: 'ai_rewrite.selected_agent',
|
||||||
|
defaultMessage: 'Selected Agent',
|
||||||
|
},
|
||||||
|
noAgentSelected: {
|
||||||
|
id: 'ai_rewrite.no_agent_selected',
|
||||||
|
defaultMessage: 'None',
|
||||||
|
},
|
||||||
|
generatePrompt: {
|
||||||
|
id: 'ai_rewrite.generate_prompt',
|
||||||
|
defaultMessage: 'Ask agents to create a message',
|
||||||
|
},
|
||||||
|
customPrompt: {
|
||||||
|
id: 'ai_rewrite.custom_prompt',
|
||||||
|
defaultMessage: 'Ask AI to edit message...',
|
||||||
|
},
|
||||||
|
shorten: {
|
||||||
|
id: 'ai_rewrite.shorten',
|
||||||
|
defaultMessage: 'Shorten',
|
||||||
|
},
|
||||||
|
elaborate: {
|
||||||
|
id: 'ai_rewrite.elaborate',
|
||||||
|
defaultMessage: 'Elaborate',
|
||||||
|
},
|
||||||
|
improveWriting: {
|
||||||
|
id: 'ai_rewrite.improve_writing',
|
||||||
|
defaultMessage: 'Improve writing',
|
||||||
|
},
|
||||||
|
fixSpelling: {
|
||||||
|
id: 'ai_rewrite.fix_spelling',
|
||||||
|
defaultMessage: 'Fix spelling and grammar',
|
||||||
|
},
|
||||||
|
simplify: {
|
||||||
|
id: 'ai_rewrite.simplify',
|
||||||
|
defaultMessage: 'Simplify',
|
||||||
|
},
|
||||||
|
summarize: {
|
||||||
|
id: 'ai_rewrite.summarize',
|
||||||
|
defaultMessage: 'Summarize',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
closeButtonId: string;
|
||||||
|
componentId: AvailableScreens;
|
||||||
|
originalMessage: string;
|
||||||
|
updateValue: (value: string | ((prevValue: string) => string)) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CUSTOM_PROMPT_INPUT_HEIGHT = 64;
|
||||||
|
const OPTIONS_PADDING = 8;
|
||||||
|
|
||||||
|
const options: Array<{action: RewriteAction; message: typeof messages.shorten; icon: string}> = [
|
||||||
|
{action: 'shorten', message: messages.shorten, icon: 'text-short'},
|
||||||
|
{action: 'elaborate', message: messages.elaborate, icon: 'text-long'},
|
||||||
|
{action: 'improve_writing', message: messages.improveWriting, icon: 'auto-fix'},
|
||||||
|
{action: 'fix_spelling', message: messages.fixSpelling, icon: 'spellcheck'},
|
||||||
|
{action: 'simplify', message: messages.simplify, icon: 'creation-outline'},
|
||||||
|
{action: 'summarize', message: messages.summarize, icon: 'ai-summarize'},
|
||||||
|
];
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||||
|
container: {
|
||||||
|
backgroundColor: theme.centerChannelBg,
|
||||||
|
},
|
||||||
|
headerContainer: {
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
customPromptContainer: {},
|
||||||
|
customPromptInputWrapper: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: theme.centerChannelBg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||||
|
borderRadius: 4,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
customPromptIcon: {
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
customPromptInput: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
color: theme.centerChannelColor,
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
includeFontPadding: false,
|
||||||
|
textAlignVertical: 'center',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
optionsContainer: {
|
||||||
|
paddingTop: OPTIONS_PADDING,
|
||||||
|
},
|
||||||
|
bottomSheetContent: {
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const RewriteOptions = ({
|
||||||
|
closeButtonId,
|
||||||
|
componentId,
|
||||||
|
originalMessage,
|
||||||
|
updateValue,
|
||||||
|
}: Props) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const theme = useTheme();
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const {startRewrite} = useRewrite();
|
||||||
|
|
||||||
|
const [customPrompt, setCustomPrompt] = useState('');
|
||||||
|
const agents = useAgents(serverUrl);
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||||
|
const textInputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
// Set default agent when agents list changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (agents.length > 0) {
|
||||||
|
setSelectedAgent((current) => current || agents[0]);
|
||||||
|
}
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
|
const closeBottomSheet = useCallback(async () => {
|
||||||
|
await dismissBottomSheet(Screens.AGENTS_REWRITE_OPTIONS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useNavButtonPressed(closeButtonId, componentId, closeBottomSheet, []);
|
||||||
|
useAndroidHardwareBackHandler(componentId, closeBottomSheet);
|
||||||
|
|
||||||
|
const handleRewriteSuccess = useCallback((rewrittenText: string) => {
|
||||||
|
updateValue(rewrittenText);
|
||||||
|
}, [updateValue]);
|
||||||
|
|
||||||
|
const handleRewriteError = useCallback((errorMsg: string) => {
|
||||||
|
Alert.alert(
|
||||||
|
intl.formatMessage(messages.errorTitle),
|
||||||
|
errorMsg,
|
||||||
|
[{
|
||||||
|
text: intl.formatMessage(messages.errorOk),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
}, [intl]);
|
||||||
|
|
||||||
|
const handleRewrite = useCallback(async (action: RewriteAction, prompt?: string) => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
|
||||||
|
// Determine if we're generating new content or editing existing content
|
||||||
|
const isGenerating = !originalMessage || !originalMessage.trim();
|
||||||
|
|
||||||
|
// For content generation, we need a custom prompt
|
||||||
|
if (isGenerating && (!prompt || !prompt.trim())) {
|
||||||
|
logWarning('[RewriteOptions] Content generation called without prompt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For generation, pass empty string as message and use prompt; for editing, use original message
|
||||||
|
const messageToProcess = isGenerating ? '' : originalMessage;
|
||||||
|
const agentId = selectedAgent?.id;
|
||||||
|
|
||||||
|
const triggerRewrite = () => {
|
||||||
|
startRewrite(
|
||||||
|
serverUrl,
|
||||||
|
messageToProcess,
|
||||||
|
action,
|
||||||
|
prompt,
|
||||||
|
agentId,
|
||||||
|
handleRewriteSuccess,
|
||||||
|
handleRewriteError,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close the bottom sheet first, then start rewrite
|
||||||
|
try {
|
||||||
|
await closeBottomSheet();
|
||||||
|
triggerRewrite();
|
||||||
|
} catch (e) {
|
||||||
|
logWarning('[RewriteOptions] Error closing bottom sheet:', e);
|
||||||
|
|
||||||
|
// Still try to start the rewrite even if sheet close failed
|
||||||
|
triggerRewrite();
|
||||||
|
}
|
||||||
|
}, [originalMessage, serverUrl, closeBottomSheet, selectedAgent, startRewrite, handleRewriteSuccess, handleRewriteError]);
|
||||||
|
|
||||||
|
// Determine if we're in generation mode (empty original message means user wants to generate new content)
|
||||||
|
const isInGenerationMode = !originalMessage || !originalMessage.trim();
|
||||||
|
|
||||||
|
// Auto-focus the text input when in generation mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInGenerationMode) {
|
||||||
|
// Small delay to ensure the bottom sheet has finished animating
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
textInputRef.current?.focus();
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [isInGenerationMode]);
|
||||||
|
|
||||||
|
const handleCustomPromptSubmit = useCallback(() => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
|
||||||
|
if (customPrompt && customPrompt.trim()) {
|
||||||
|
handleRewrite('custom', customPrompt);
|
||||||
|
}
|
||||||
|
}, [customPrompt, handleRewrite]);
|
||||||
|
|
||||||
|
const isTablet = useIsTablet();
|
||||||
|
|
||||||
|
const handleOpenAgentSelector = useCallback(() => {
|
||||||
|
const title = isTablet ? intl.formatMessage(messages.agentSelectorTitle) : '';
|
||||||
|
|
||||||
|
openAsBottomSheet({
|
||||||
|
closeButtonId: 'close-agent-selector',
|
||||||
|
screen: Screens.AGENTS_SELECTOR,
|
||||||
|
theme,
|
||||||
|
title,
|
||||||
|
props: {
|
||||||
|
closeButtonId: 'close-agent-selector',
|
||||||
|
agents,
|
||||||
|
selectedAgentId: selectedAgent?.id || '',
|
||||||
|
onSelectAgent: (agent: Agent) => {
|
||||||
|
setSelectedAgent(agent);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [theme, intl, agents, selectedAgent, isTablet]);
|
||||||
|
|
||||||
|
const snapPoints = useMemo(() => {
|
||||||
|
const paddingBottom = 10;
|
||||||
|
|
||||||
|
// Add agent selector height if multiple agents available
|
||||||
|
const agentSelectorHeight = agents.length > 1 ? ITEM_HEIGHT : 0;
|
||||||
|
|
||||||
|
// Use the same height for both generation and editing modes
|
||||||
|
const optionsHeight = OPTIONS_PADDING + bottomSheetSnapPoint(6, ITEM_HEIGHT);
|
||||||
|
const COMPONENT_HEIGHT = agentSelectorHeight + CUSTOM_PROMPT_INPUT_HEIGHT + optionsHeight + paddingBottom + insets.bottom;
|
||||||
|
|
||||||
|
return [1, COMPONENT_HEIGHT];
|
||||||
|
}, [agents.length, insets.bottom]);
|
||||||
|
|
||||||
|
const renderContent = useCallback(() => (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.headerContainer}>
|
||||||
|
{agents.length > 1 && (
|
||||||
|
<OptionItem
|
||||||
|
label={intl.formatMessage(messages.selectedAgent)}
|
||||||
|
info={selectedAgent?.displayName || intl.formatMessage(messages.noAgentSelected)}
|
||||||
|
action={handleOpenAgentSelector}
|
||||||
|
type='arrow'
|
||||||
|
testID='ai_rewrite.select_agent'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.customPromptContainer}>
|
||||||
|
<View style={styles.customPromptInputWrapper}>
|
||||||
|
<CompassIcon
|
||||||
|
name='creation-outline'
|
||||||
|
size={20}
|
||||||
|
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||||
|
style={styles.customPromptIcon}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
ref={textInputRef}
|
||||||
|
style={styles.customPromptInput}
|
||||||
|
placeholder={intl.formatMessage(isInGenerationMode ? messages.generatePrompt : messages.customPrompt)}
|
||||||
|
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||||
|
value={customPrompt}
|
||||||
|
onChangeText={setCustomPrompt}
|
||||||
|
onSubmitEditing={handleCustomPromptSubmit}
|
||||||
|
returnKeyType='send'
|
||||||
|
multiline={false}
|
||||||
|
autoCapitalize='none'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{!isInGenerationMode && (
|
||||||
|
<View style={styles.optionsContainer}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<OptionItem
|
||||||
|
key={option.action}
|
||||||
|
label={intl.formatMessage(option.message)}
|
||||||
|
icon={option.icon}
|
||||||
|
action={() => handleRewrite(option.action)}
|
||||||
|
type='default'
|
||||||
|
testID={`ai_rewrite.option.${option.action}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
), [styles, agents, intl, selectedAgent, handleOpenAgentSelector, theme, isInGenerationMode, customPrompt, handleCustomPromptSubmit, handleRewrite]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheet
|
||||||
|
renderContent={renderContent}
|
||||||
|
closeButtonId={closeButtonId}
|
||||||
|
componentId={Screens.AGENTS_REWRITE_OPTIONS}
|
||||||
|
initialSnapIndex={1}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
scrollable={true}
|
||||||
|
keyboardBehavior='fillParent'
|
||||||
|
keyboardBlurBehavior='none'
|
||||||
|
testID='ai_rewrite_options'
|
||||||
|
contentStyle={styles.bottomSheetContent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RewriteOptions;
|
||||||
|
|
@ -2,3 +2,4 @@
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
export {default as streamingStore, useStreamingState} from './streaming_store';
|
export {default as streamingStore, useStreamingState} from './streaming_store';
|
||||||
|
export {default as rewriteStore, type RewriteState} from './rewrite_store';
|
||||||
|
|
|
||||||
96
app/products/agents/store/rewrite_store.ts
Normal file
96
app/products/agents/store/rewrite_store.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {BehaviorSubject} from 'rxjs';
|
||||||
|
|
||||||
|
import type {Agent} from '@agents/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite processing state
|
||||||
|
*/
|
||||||
|
export interface RewriteState {
|
||||||
|
isProcessing: boolean;
|
||||||
|
serverUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store for managing rewrite-related ephemeral state
|
||||||
|
*/
|
||||||
|
class RewriteStore {
|
||||||
|
private agentsSubjects: Map<string, BehaviorSubject<Agent[]>> = new Map();
|
||||||
|
private rewriteState = new BehaviorSubject<RewriteState>({isProcessing: false, serverUrl: ''});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a BehaviorSubject for agents per server
|
||||||
|
*/
|
||||||
|
private getAgentsSubject(serverUrl: string): BehaviorSubject<Agent[]> {
|
||||||
|
let subject = this.agentsSubjects.get(serverUrl);
|
||||||
|
if (!subject) {
|
||||||
|
subject = new BehaviorSubject<Agent[]>([]);
|
||||||
|
this.agentsSubjects.set(serverUrl, subject);
|
||||||
|
}
|
||||||
|
return subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Agents Management
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe agents for a server (reactive)
|
||||||
|
*/
|
||||||
|
observeAgents(serverUrl: string) {
|
||||||
|
return this.getAgentsSubject(serverUrl).asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set agents for a server
|
||||||
|
*/
|
||||||
|
setAgents(serverUrl: string, agents: Agent[]) {
|
||||||
|
this.getAgentsSubject(serverUrl).next(agents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current agents for a server (synchronous)
|
||||||
|
*/
|
||||||
|
getAgents(serverUrl: string): Agent[] {
|
||||||
|
return this.getAgentsSubject(serverUrl).getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Rewrite Processing State
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe rewrite processing state (reactive)
|
||||||
|
*/
|
||||||
|
observeRewriteState() {
|
||||||
|
return this.rewriteState.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set rewrite processing state
|
||||||
|
*/
|
||||||
|
setRewriteProcessing(isProcessing: boolean, serverUrl: string) {
|
||||||
|
this.rewriteState.next({isProcessing, serverUrl});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current rewrite state (synchronous)
|
||||||
|
*/
|
||||||
|
getRewriteState(): RewriteState {
|
||||||
|
return this.rewriteState.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if currently processing a rewrite
|
||||||
|
*/
|
||||||
|
isRewriteProcessing(): boolean {
|
||||||
|
return this.rewriteState.getValue().isProcessing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
const rewriteStore = new RewriteStore();
|
||||||
|
|
||||||
|
export default rewriteStore;
|
||||||
|
|
@ -45,3 +45,20 @@ export type AgentsStatusResponse = {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request payload for rewriting a message
|
||||||
|
*/
|
||||||
|
export type RewriteRequest = {
|
||||||
|
agent_id?: string;
|
||||||
|
message: string;
|
||||||
|
action?: string;
|
||||||
|
custom_prompt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from a rewrite request
|
||||||
|
*/
|
||||||
|
export type RewriteResponse = {
|
||||||
|
rewritten_text: string;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -67,3 +67,13 @@ export interface StreamingState {
|
||||||
annotations: Annotation[]; // Citations/annotations for the post
|
annotations: Annotation[]; // Citations/annotations for the post
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Rewrite Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type {Agent} from './api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available rewrite action types
|
||||||
|
*/
|
||||||
|
export type RewriteAction = 'shorten' | 'elaborate' | 'improve_writing' | 'fix_spelling' | 'simplify' | 'summarize' | 'custom';
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ type Props = {
|
||||||
enableDynamicSizing?: boolean;
|
enableDynamicSizing?: boolean;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
scrollable?: boolean;
|
scrollable?: boolean;
|
||||||
|
keyboardBehavior?: 'extend' | 'fillParent' | 'interactive';
|
||||||
|
keyboardBlurBehavior?: 'none' | 'restore';
|
||||||
}
|
}
|
||||||
|
|
||||||
const PADDING_TOP_MOBILE = 20;
|
const PADDING_TOP_MOBILE = 20;
|
||||||
|
|
@ -101,6 +103,8 @@ const BottomSheet = ({
|
||||||
testID,
|
testID,
|
||||||
enableDynamicSizing = false,
|
enableDynamicSizing = false,
|
||||||
scrollable = false,
|
scrollable = false,
|
||||||
|
keyboardBehavior = 'extend',
|
||||||
|
keyboardBlurBehavior = 'restore',
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const reducedMotion = useReducedMotion();
|
const reducedMotion = useReducedMotion();
|
||||||
const sheetRef = useRef<BottomSheetM>(null);
|
const sheetRef = useRef<BottomSheetM>(null);
|
||||||
|
|
@ -266,8 +270,8 @@ const BottomSheet = ({
|
||||||
style={styles.bottomSheet}
|
style={styles.bottomSheet}
|
||||||
backgroundStyle={bottomSheetBackgroundStyle}
|
backgroundStyle={bottomSheetBackgroundStyle}
|
||||||
footerComponent={footerComponent}
|
footerComponent={footerComponent}
|
||||||
keyboardBehavior='extend'
|
keyboardBehavior={keyboardBehavior}
|
||||||
keyboardBlurBehavior='restore'
|
keyboardBlurBehavior={keyboardBlurBehavior}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
bottomInset={insets.bottom}
|
bottomInset={insets.bottom}
|
||||||
enableDynamicSizing={enableDynamicSizing}
|
enableDynamicSizing={enableDynamicSizing}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import React from 'react';
|
||||||
import AvailableScreens from '@constants/screens';
|
import AvailableScreens from '@constants/screens';
|
||||||
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
|
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
|
||||||
import TestHelper from '@test/test_helper';
|
import TestHelper from '@test/test_helper';
|
||||||
|
import {logError} from '@utils/log';
|
||||||
|
|
||||||
import EditProfile from './edit_profile';
|
import EditProfile from './edit_profile';
|
||||||
|
|
||||||
|
|
@ -78,8 +79,7 @@ const mockFetchCustomProfileAttributes = jest.fn();
|
||||||
// Add mock for updateCustomProfileAttributes to track calls
|
// Add mock for updateCustomProfileAttributes to track calls
|
||||||
const mockUpdateCustomProfileAttributes = jest.fn();
|
const mockUpdateCustomProfileAttributes = jest.fn();
|
||||||
|
|
||||||
// Mock logError function
|
const mockLogError = logError as jest.Mock;
|
||||||
const mockLogError = jest.fn();
|
|
||||||
|
|
||||||
jest.mock('@actions/remote/custom_profile', () => ({
|
jest.mock('@actions/remote/custom_profile', () => ({
|
||||||
fetchCustomProfileAttributes: (...args: any[]) => mockFetchCustomProfileAttributes(...args),
|
fetchCustomProfileAttributes: (...args: any[]) => mockFetchCustomProfileAttributes(...args),
|
||||||
|
|
@ -87,7 +87,10 @@ jest.mock('@actions/remote/custom_profile', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@utils/log', () => ({
|
jest.mock('@utils/log', () => ({
|
||||||
logError: (...args: any[]) => mockLogError(...args),
|
logError: jest.fn(),
|
||||||
|
logWarning: jest.fn(),
|
||||||
|
logDebug: jest.fn(),
|
||||||
|
logInfo: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@actions/remote/user', () => ({
|
jest.mock('@actions/remote/user', () => ({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {loadAgentsScreen} from '@agents/screens';
|
||||||
import {Provider as EMMProvider} from '@mattermost/react-native-emm';
|
import {Provider as EMMProvider} from '@mattermost/react-native-emm';
|
||||||
import React, {type ComponentType} from 'react';
|
import React, {type ComponentType} from 'react';
|
||||||
import {IntlProvider} from 'react-intl';
|
import {IntlProvider} from 'react-intl';
|
||||||
|
|
@ -316,6 +317,10 @@ Navigation.setLazyComponentRegistrator((screenName) => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!screen) {
|
||||||
|
screen = loadAgentsScreen(screenName);
|
||||||
|
}
|
||||||
|
|
||||||
if (!screen) {
|
if (!screen) {
|
||||||
screen = loadPlaybooksScreen(screenName);
|
screen = loadPlaybooksScreen(screenName);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,10 @@ jest.mock('react-native-exception-handler', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@utils/log', () => ({
|
jest.mock('@utils/log', () => ({
|
||||||
|
logError: jest.fn(),
|
||||||
logWarning: jest.fn(() => ''),
|
logWarning: jest.fn(() => ''),
|
||||||
|
logDebug: jest.fn(),
|
||||||
|
logInfo: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('JavascriptAndNativeErrorHandler', () => {
|
describe('JavascriptAndNativeErrorHandler', () => {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,21 @@
|
||||||
"agents.tool_call.response": "Response",
|
"agents.tool_call.response": "Response",
|
||||||
"agents.tool_call.status.rejected": "Rejected",
|
"agents.tool_call.status.rejected": "Rejected",
|
||||||
"agents.tool_call.submitting": "Submitting...",
|
"agents.tool_call.submitting": "Submitting...",
|
||||||
|
"ai_rewrite.agent_selector_title": "Select AI Agent",
|
||||||
|
"ai_rewrite.custom_prompt": "Ask AI to edit message...",
|
||||||
|
"ai_rewrite.elaborate": "Elaborate",
|
||||||
|
"ai_rewrite.error.ok": "OK",
|
||||||
|
"ai_rewrite.error.title": "AI Rewrite Error",
|
||||||
|
"ai_rewrite.fix_spelling": "Fix spelling and grammar",
|
||||||
|
"ai_rewrite.generate_prompt": "Ask agents to create a message",
|
||||||
|
"ai_rewrite.improve_writing": "Improve writing",
|
||||||
|
"ai_rewrite.no_agent_selected": "None",
|
||||||
|
"ai_rewrite.rewriting": "Rewriting...",
|
||||||
|
"ai_rewrite.selected_agent": "Selected Agent",
|
||||||
|
"ai_rewrite.shorten": "Shorten",
|
||||||
|
"ai_rewrite.simplify": "Simplify",
|
||||||
|
"ai_rewrite.summarize": "Summarize",
|
||||||
|
"ai_rewrite.title": "AI Rewrite",
|
||||||
"alert.channel_deleted.description": "The channel {displayName} has been archived.",
|
"alert.channel_deleted.description": "The channel {displayName} has been archived.",
|
||||||
"alert.channel_deleted.title": "Archived channel",
|
"alert.channel_deleted.title": "Archived channel",
|
||||||
"alert.push_proxy_error.description": "Due to the configuration of this server, notifications cannot be received in the mobile app. Contact your system admin for more information.",
|
"alert.push_proxy_error.description": "Due to the configuration of this server, notifications cannot be received in the mobile app. Contact your system admin for more information.",
|
||||||
|
|
|
||||||
|
|
@ -2746,6 +2746,6 @@ SPEC CHECKSUMS:
|
||||||
WatermelonDB: 4c846c8cb94eef3cba90fa034d15310163226703
|
WatermelonDB: 4c846c8cb94eef3cba90fa034d15310163226703
|
||||||
Yoga: 2b02f3f767761bb4ffd25b1bb56fd264be57bd6b
|
Yoga: 2b02f3f767761bb4ffd25b1bb56fd264be57bd6b
|
||||||
|
|
||||||
PODFILE CHECKSUM: f3442fc727bf29ef7e4eda969e4e598128801d4a
|
PODFILE CHECKSUM: 606164aa1d6839d4676945c6571456a11452e595
|
||||||
|
|
||||||
COCOAPODS: 1.16.1
|
COCOAPODS: 1.16.1
|
||||||
|
|
|
||||||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -6,7 +6,7 @@
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mattermost-mobile",
|
"name": "mattermost-mobile",
|
||||||
"version": "2.36.0",
|
"version": "2.38.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache 2.0",
|
"license": "Apache 2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue