[MM-66591] Channel Summaries with Agents (#9405)
* Add support for Channel Summarization, inline citations * Add support for Channel Summarization, inline citations * Revert package-lock to master * i18n fixes * e2e fixes * Remove a few tests as they're already covered by unit * Ensure channel summary is offline compatible - Add channel persistence check before switching to summary channel - Fetch and persist channel/membership if not in database - Fix TypeScript error handling for unknown error types - Update tests to properly mock DatabaseManager * Fixes * Fix expo-image patch file - remove accidental build artifacts The patch file was accidentally corrupted with build artifacts from node_modules/expo-image/android/build/ including .dex files, results.bin, and Oops.rej. This was causing the 16KB page size compatibility patch to fail during CI. * Fix iOS date picker spacing and placeholder text - Increase snap points on iOS to accommodate inline date picker spinner - Update AI prompt placeholder to "Ask AI about this channel" * Fix padding * Address PR review feedback from larkox - Fix i18n: use defineMessages pattern instead of separate labelId/defaultLabel - Replace custom SummaryOptionItem with existing OptionItem component - Add ScrollView for scrollability on smaller phones - Extract AgentItem to separate file, use FlatList for virtualization - Extract DateInputField component, unify DateTimePicker, add useCallback - Consolidate types into app/products/agents/types/api.ts - Remove unnecessary fetchPostById (WebSocket handles post delivery) - Use jest.mocked() instead of 'as jest.Mock' for type safety - Improve transform.ts: use urlParse, add post ID length constraint, validate internal links with serverUrl parameter, add subpath support - Add transform.test.ts cases for external links, subpaths, URL encoding - Remove unnecessary Platform.select in inline_entity_link - Revert video_file.tsx and prefetch.ts to use cachePath (patches include types) * Update en.json with new channel summary option translations Added missing translation keys for the defineMessages pattern used in channel summary sheet options. * Fix regex patterns and add negative assertions per PR review - Use exact 26-character constraint in post ID regex instead of permissive pattern followed by validation - Use specific identifier pattern in channel regex instead of [^/]+ - Remove unused ID_PATH_PATTERN and IDENTIFIER_PATH_PATTERN imports - Add negative assertions to processInlineEntities tests to verify mutual exclusivity of link vs inline_entity_link nodes * Add test cases for non-internal URLs and escaped slashes Per PR review feedback, added tests for: - Non-internal URLs with similar shape to internal links - URLs with escaped slashes (%2F) in various positions * Improve UI consistency for channel summary date picker and AI input - Add focus state (border highlight) to AI prompt input on Ask Agents sheet - Add active state highlight to Start/End date fields in date range picker - Increase back button tap area on date range picker using hitSlop - Fix date picker theme mismatch by setting themeVariant based on app theme - Apply initial date when opening picker so value matches displayed date Co-authored-by: Cursor <cursoragent@cursor.com> * Gate AskAgentsOption behind agents plugin availability check Add a plugin availability check for the "Ask Agents" feature that mirrors the webapp's useGetAgentsBridgeEnabled hook. Calls GET /api/v4/agents/status on WebSocket connect and plugin status change events, stores the result in an RxJS BehaviorSubject ephemeral store, and conditionally renders the AskAgentsOption component based on the plugin being available. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix quick actions tests and default date range to last 7 days Mock useAgentsConfig in quick actions tests so the Ask Agents button renders when agents plugin availability is gated. Default the date range picker to the last 7 days instead of empty. Co-authored-by: Cursor <cursoragent@cursor.com> * Address enahum's review feedback on channel summaries PR - Remove pluginEnabled=false on network error in agents_status - Use enableDynamicSizing for channel summary bottom sheet - Extract channel navigation logic to switchToChannelByName action - Move icon color to stylesheet, null guard to parent in InlineEntityLink - Replace custom URL parsing with parseDeepLink in transform.ts - Reorder channel membership check before API request in channel_summary - Use FormattedText, FormattedDate, FloatingTextInput consistently - Use getErrorMessage for proper intl error handling in alerts - Simplify agent_item to always use expo-image, memoize styles - Gate Ask Agents item count on agentsEnabled in header.tsx - Clean up CLAUDE-IOS-SIMULATOR.md per review suggestions - Update tests to match new behavior Co-authored-by: Cursor <cursoragent@cursor.com> * Fix i18n extraction for date_range_picker translated strings Add defineMessages declarations so the i18n extraction tool can find the translated string IDs passed through custom props to DateInputField. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix agent profile image not rendering on agent selector * Reduce channel summary bottom sheet max height from 80% to 50% The 80% snap point created too much whitespace below the options. 50% fits the main content well while still leaving room for the iOS date picker spinner when the custom date range view is shown. Co-authored-by: Cursor <cursoragent@cursor.com> * Adjust channel summary bottom sheet max height to 65% 50% was too short, cutting off the iOS date picker spinner. 65% provides enough room for the date range view with spinner while keeping the main options view compact. Co-authored-by: Cursor <cursoragent@cursor.com> * Use platform-specific max height for channel summary bottom sheet iOS uses 65% to accommodate the inline date picker spinner. Android uses 50% since its modal date picker doesn't need extra space. Co-authored-by: Cursor <cursoragent@cursor.com> * Reduce height on android slightly * Final tweaks for spacing * Address PR review feedback from larkox and pvev - Use MessageDescriptor props instead of separate id/defaultMessage strings in DateInputField - Extract hitSlop constant outside component for render stability - Normalize date range to UTC start/end of day to avoid timezone data loss - Replace hardcoded '#FFFFFF' with theme.buttonColor Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Mattermost Build <build@mattermost.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9699318043
commit
12c8d80f4a
31 changed files with 2520 additions and 5 deletions
172
CLAUDE-IOS-SIMULATOR.md
Normal file
172
CLAUDE-IOS-SIMULATOR.md
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
# iOS Simulator Setup Guide for AI Agents
|
||||||
|
|
||||||
|
This guide documents the process for setting up and running the Mattermost Mobile app on an iOS simulator.
|
||||||
|
|
||||||
|
## Prerequisites Check
|
||||||
|
|
||||||
|
Before starting, verify these are installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Xcode
|
||||||
|
xcodebuild -version
|
||||||
|
|
||||||
|
# Check CocoaPods
|
||||||
|
pod --version
|
||||||
|
|
||||||
|
# Check Node.js
|
||||||
|
node --version
|
||||||
|
|
||||||
|
# Check npm dependencies
|
||||||
|
test -d node_modules && echo "node_modules exists" || echo "Run: npm install"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 1: iOS Simulator Runtime
|
||||||
|
|
||||||
|
Check if iOS simulators are available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcrun simctl list devices available
|
||||||
|
```
|
||||||
|
|
||||||
|
If no devices are listed or you see "Install Started", you need to download the iOS platform:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild -downloadPlatform iOS
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** This downloads ~8GB and takes several minutes. The command runs in the foreground and shows progress.
|
||||||
|
|
||||||
|
## Step 2: CocoaPods Installation
|
||||||
|
|
||||||
|
### Critical: Set UTF-8 Encoding
|
||||||
|
|
||||||
|
CocoaPods requires UTF-8 encoding. Always set this before running pod commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LANG=en_US.UTF-8
|
||||||
|
export LC_ALL=en_US.UTF-8
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install Pods (Apple Silicon Mac)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ios
|
||||||
|
RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common CocoaPods Issues
|
||||||
|
|
||||||
|
#### Issue: "Unicode Normalization not appropriate for ASCII-8BIT"
|
||||||
|
**Solution:** Set UTF-8 encoding as shown above.
|
||||||
|
|
||||||
|
#### Issue: Podfile.lock version conflicts
|
||||||
|
**Solution:** Clean install:
|
||||||
|
```bash
|
||||||
|
cd ios
|
||||||
|
rm -rf Pods Podfile.lock build
|
||||||
|
export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
|
||||||
|
RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Issue: GitHub SSH timeouts during pod install
|
||||||
|
**Symptom:** Errors like "Connection to github.com port 22: Operation timed out"
|
||||||
|
|
||||||
|
**Solution:** Temporarily configure git to use HTTPS instead of SSH:
|
||||||
|
```bash
|
||||||
|
git config --global url."https://github.com/".insteadOf git@github.com:
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT:** Revert this after pod install completes, or the user won't be able to push:
|
||||||
|
```bash
|
||||||
|
git config --global --unset url.https://github.com/.insteadof
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warning:** Never leave git URL rewrites in place without informing the user!
|
||||||
|
|
||||||
|
## Step 3: Start Metro Bundler (if not already running)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait for "Dev server ready" message before proceeding.
|
||||||
|
|
||||||
|
## Step 4: Run iOS App (if not already built)
|
||||||
|
|
||||||
|
### Using npm script
|
||||||
|
```bash
|
||||||
|
npm run ios -- --simulator="iPhone 17 Pro"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Or specify a different simulator
|
||||||
|
List available simulators first:
|
||||||
|
```bash
|
||||||
|
xcrun simctl list devices available | grep -E "iPhone|iPad"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run with your chosen device:
|
||||||
|
```bash
|
||||||
|
npm run ios -- --simulator="iPhone 16e"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build Times
|
||||||
|
|
||||||
|
- **First build:** 10-30 minutes (compiles all native code)
|
||||||
|
- **Subsequent builds:** Much faster (incremental)
|
||||||
|
- **JS/TS changes:** ~3 seconds hot reload (no native rebuild needed)
|
||||||
|
|
||||||
|
## Troubleshooting Build Failures
|
||||||
|
|
||||||
|
### Getting Better Error Messages
|
||||||
|
|
||||||
|
If `npm run ios` fails, run xcodebuild directly for clearer errors:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild -workspace ios/Mattermost.xcworkspace \
|
||||||
|
-configuration Debug \
|
||||||
|
-scheme Mattermost \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
|
||||||
|
2>&1 | grep -E "error:|fatal error"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clean Build
|
||||||
|
|
||||||
|
If builds fail mysteriously, try a clean build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clean Xcode derived data
|
||||||
|
rm -rf ~/Library/Developer/Xcode/DerivedData/Mattermost-*
|
||||||
|
|
||||||
|
# Clean and reinstall pods
|
||||||
|
cd ios
|
||||||
|
rm -rf Pods build
|
||||||
|
export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
|
||||||
|
RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opening in Xcode
|
||||||
|
|
||||||
|
For complex build issues, open in Xcode for better debugging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open ios/Mattermost.xcworkspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Then select your simulator target and press Cmd+B to build.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full setup sequence (Apple Silicon)
|
||||||
|
export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
|
||||||
|
cd ios && RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install && cd ..
|
||||||
|
npm start &
|
||||||
|
sleep 10
|
||||||
|
npm run ios -- --simulator="iPhone 17 Pro"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Do NOT Modify
|
||||||
|
|
||||||
|
- **Git configuration:** Never permanently change git URL rewrites without reverting
|
||||||
|
- **Podfile:** Don't add modular_headers to pods unless you understand the implications
|
||||||
|
- **New Architecture:** Keep `RCT_NEW_ARCH_ENABLED=0` as the project uses the old architecture
|
||||||
|
|
@ -27,6 +27,8 @@ When testing features that require mobile device interaction:
|
||||||
|
|
||||||
## Key Commands
|
## Key Commands
|
||||||
|
|
||||||
|
> **iOS Simulator Setup:** For detailed iOS simulator setup instructions, troubleshooting CocoaPods issues, and build debugging, see [CLAUDE-IOS-SIMULATOR.md](./CLAUDE-IOS-SIMULATOR.md).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Setup
|
# Setup
|
||||||
npm run pod-install # iOS CocoaPods (or pod-install-m1 for M1 Macs)
|
npm run pod-install # iOS CocoaPods (or pod-install-m1 for M1 Macs)
|
||||||
|
|
|
||||||
|
|
@ -1468,6 +1468,42 @@ export const fetchGroupMessageMembersCommonTeams = async (serverUrl: string, cha
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function switchToChannelByName(serverUrl: string, channelName: string, teamName?: string) {
|
||||||
|
try {
|
||||||
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
|
||||||
|
let teamId = '';
|
||||||
|
if (teamName) {
|
||||||
|
const team = await getTeamByName(database, teamName);
|
||||||
|
teamId = team?.id || '';
|
||||||
|
}
|
||||||
|
if (!teamId) {
|
||||||
|
teamId = await getCurrentTeamId(database) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let channelId = '';
|
||||||
|
const channel = await getChannelByName(database, teamId, channelName);
|
||||||
|
if (channel) {
|
||||||
|
channelId = channel.id;
|
||||||
|
} else {
|
||||||
|
const fetchResult = await fetchChannelByName(serverUrl, teamId, channelName, true);
|
||||||
|
if (fetchResult.channel) {
|
||||||
|
channelId = fetchResult.channel.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelId) {
|
||||||
|
await switchToChannelById(serverUrl, channelId, teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
} catch (error) {
|
||||||
|
logDebug('error on switchToChannelByName', getFullErrorMessage(error));
|
||||||
|
forceLogoutIfNecessary(serverUrl, error);
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const convertGroupMessageToPrivateChannel = async (serverUrl: string, channelId: string, targetTeamId: string, displayName: string) => {
|
export const convertGroupMessageToPrivateChannel = async (serverUrl: string, channelId: string, targetTeamId: string, displayName: string) => {
|
||||||
try {
|
try {
|
||||||
const name = generateChannelNameFromDisplayName(displayName);
|
const name = generateChannelNameFromDisplayName(displayName);
|
||||||
|
|
|
||||||
|
|
@ -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 {checkIsAgentsPluginEnabled} from '@agents/actions/remote/agents_status';
|
||||||
import {handleAgentPostUpdate} from '@agents/actions/websocket';
|
import {handleAgentPostUpdate} from '@agents/actions/websocket';
|
||||||
|
|
||||||
import * as bookmark from '@actions/local/channel_bookmark';
|
import * as bookmark from '@actions/local/channel_bookmark';
|
||||||
|
|
@ -274,7 +275,7 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess
|
||||||
case WebsocketEvents.PLUGIN_STATUSES_CHANGED:
|
case WebsocketEvents.PLUGIN_STATUSES_CHANGED:
|
||||||
case WebsocketEvents.PLUGIN_ENABLED:
|
case WebsocketEvents.PLUGIN_ENABLED:
|
||||||
case WebsocketEvents.PLUGIN_DISABLED:
|
case WebsocketEvents.PLUGIN_DISABLED:
|
||||||
// Do nothing, this event doesn't need logic in the mobile app
|
checkIsAgentsPluginEnabled(serverUrl);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// bookmarks
|
// bookmarks
|
||||||
|
|
|
||||||
|
|
@ -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 {checkIsAgentsPluginEnabled} from '@agents/actions/remote/agents_status';
|
||||||
|
|
||||||
import {markChannelAsViewed} from '@actions/local/channel';
|
import {markChannelAsViewed} from '@actions/local/channel';
|
||||||
import {dataRetentionCleanup, expiredBoRPostCleanup} from '@actions/local/systems';
|
import {dataRetentionCleanup, expiredBoRPostCleanup} from '@actions/local/systems';
|
||||||
import {markChannelAsRead} from '@actions/remote/channel';
|
import {markChannelAsRead} from '@actions/remote/channel';
|
||||||
|
|
@ -95,6 +97,8 @@ async function doReconnect(serverUrl: string, groupLabel?: BaseRequestGroupLabel
|
||||||
loadConfigAndCalls(serverUrl, currentUserId, groupLabel);
|
loadConfigAndCalls(serverUrl, currentUserId, groupLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkIsAgentsPluginEnabled(serverUrl);
|
||||||
|
|
||||||
await deferredAppEntryActions(serverUrl, lastFullSync, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, meData, initialTeamId, undefined, groupLabel);
|
await deferredAppEntryActions(serverUrl, lastFullSync, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, meData, initialTeamId, undefined, groupLabel);
|
||||||
|
|
||||||
await setLastFullSync(operator, now);
|
await setLastFullSync(operator, now);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import ChannelSummarySheet from '@agents/components/channel_summary_sheet';
|
||||||
|
import React, {useCallback} from 'react';
|
||||||
|
import {useIntl} from 'react-intl';
|
||||||
|
import {Platform} from 'react-native';
|
||||||
|
|
||||||
|
import OptionItem from '@components/option_item';
|
||||||
|
import SlideUpPanelItem from '@components/slide_up_panel_item';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {bottomSheet, dismissBottomSheet} from '@screens/navigation';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
channelId: string;
|
||||||
|
showAsLabel?: boolean;
|
||||||
|
testID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AskAgentsOption = ({
|
||||||
|
channelId,
|
||||||
|
showAsLabel,
|
||||||
|
testID,
|
||||||
|
}: Props) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const label = intl.formatMessage({id: 'agents.channel_summary.ask_agents', defaultMessage: 'Ask Agents'});
|
||||||
|
|
||||||
|
const openSheet = useCallback(async () => {
|
||||||
|
// First dismiss the current quick actions bottom sheet
|
||||||
|
await dismissBottomSheet();
|
||||||
|
|
||||||
|
const renderContent = () => (
|
||||||
|
<ChannelSummarySheet
|
||||||
|
channelId={channelId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
bottomSheet({
|
||||||
|
title: label,
|
||||||
|
renderContent,
|
||||||
|
closeButtonId: 'close-channel-summary',
|
||||||
|
enableDynamicSizing: true,
|
||||||
|
|
||||||
|
// iOS needs more space to accommodate the inline date picker spinner
|
||||||
|
// Android uses a modal date picker, so it needs less space
|
||||||
|
snapPoints: [1, Platform.select({ios: '60%', default: '40%'})!],
|
||||||
|
theme,
|
||||||
|
initialSnapIndex: 1,
|
||||||
|
});
|
||||||
|
}, [channelId, label, theme]);
|
||||||
|
|
||||||
|
if (showAsLabel) {
|
||||||
|
return (
|
||||||
|
<SlideUpPanelItem
|
||||||
|
onPress={openSheet}
|
||||||
|
text={label}
|
||||||
|
leftIcon='creation-outline'
|
||||||
|
testID={testID}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OptionItem
|
||||||
|
action={openSheet}
|
||||||
|
label={label}
|
||||||
|
icon='creation-outline'
|
||||||
|
type='default'
|
||||||
|
testID={testID}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AskAgentsOption;
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import AskAgentsOption from './ask_agents_option';
|
||||||
|
|
||||||
|
export default AskAgentsOption;
|
||||||
|
|
||||||
127
app/components/markdown/inline_entity_link/index.tsx
Normal file
127
app/components/markdown/inline_entity_link/index.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {useCallback} from 'react';
|
||||||
|
import {Text, View} from 'react-native';
|
||||||
|
|
||||||
|
import {switchToChannelByName} from '@actions/remote/channel';
|
||||||
|
import {showPermalink} from '@actions/remote/permalink';
|
||||||
|
import {handleTeamChange} from '@actions/remote/team';
|
||||||
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {usePreventDoubleTap} from '@hooks/utils';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
|
export const INLINE_ENTITY_TYPE = {
|
||||||
|
POST: 'POST',
|
||||||
|
CHANNEL: 'CHANNEL',
|
||||||
|
TEAM: 'TEAM',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type InlineEntityType = typeof INLINE_ENTITY_TYPE[keyof typeof INLINE_ENTITY_TYPE];
|
||||||
|
|
||||||
|
type InlineEntityLinkProps = {
|
||||||
|
entityType: InlineEntityType;
|
||||||
|
entityId: string;
|
||||||
|
linkUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Size matches web implementation (18px for mobile, 22px on web)
|
||||||
|
const CONTAINER_SIZE = 18;
|
||||||
|
const ICON_SIZE = 10;
|
||||||
|
|
||||||
|
// Vertical offset to center the icon with the middle of the text line
|
||||||
|
// This shifts the icon down to align with the visual center of text (accounting for descenders)
|
||||||
|
// Using transform translateY for more reliable positioning of inline Views
|
||||||
|
// TODO: This value is hardcoded for a particular font size and may need adjustment for different text sizes
|
||||||
|
const VERTICAL_OFFSET = 3;
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
|
||||||
|
|
||||||
|
// Circular container matching web styling
|
||||||
|
container: {
|
||||||
|
width: CONTAINER_SIZE,
|
||||||
|
height: CONTAINER_SIZE,
|
||||||
|
borderRadius: CONTAINER_SIZE / 2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: changeOpacity(theme.centerChannelColor, 0.12),
|
||||||
|
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
|
||||||
|
// Use transform for more reliable vertical positioning
|
||||||
|
transform: [{translateY: VERTICAL_OFFSET}],
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract team name from a citation URL.
|
||||||
|
* URL format: http://server/teamSlug/pl/postId or http://server/teamSlug/channels/channelName
|
||||||
|
*/
|
||||||
|
function extractTeamNameFromUrl(url: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
// First path segment is typically the team name
|
||||||
|
if (pathParts.length > 0) {
|
||||||
|
return pathParts[0];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// URL parsing failed
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InlineEntityLink = ({entityType, entityId, linkUrl}: InlineEntityLinkProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
|
||||||
|
const handlePress = useCallback(async () => {
|
||||||
|
if (!entityId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (entityType) {
|
||||||
|
case INLINE_ENTITY_TYPE.POST: {
|
||||||
|
const teamName = linkUrl ? extractTeamNameFromUrl(linkUrl) : undefined;
|
||||||
|
await showPermalink(serverUrl, teamName || '', entityId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case INLINE_ENTITY_TYPE.CHANNEL: {
|
||||||
|
const teamName = linkUrl ? extractTeamNameFromUrl(linkUrl) : undefined;
|
||||||
|
await switchToChannelByName(serverUrl, entityId, teamName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case INLINE_ENTITY_TYPE.TEAM: {
|
||||||
|
await handleTeamChange(serverUrl, entityId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [entityType, entityId, linkUrl, serverUrl]);
|
||||||
|
|
||||||
|
const preventDoubleTap = usePreventDoubleTap(handlePress);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
onPress={preventDoubleTap}
|
||||||
|
testID={`inline_entity_link.${entityType.toLowerCase()}.${entityId}`}
|
||||||
|
>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<CompassIcon
|
||||||
|
name='link-variant'
|
||||||
|
size={ICON_SIZE}
|
||||||
|
color={styles.icon.color}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InlineEntityLink;
|
||||||
|
|
||||||
|
|
@ -12,6 +12,7 @@ import CompassIcon from '@components/compass_icon';
|
||||||
import EditedIndicator from '@components/edited_indicator';
|
import EditedIndicator from '@components/edited_indicator';
|
||||||
import Emoji from '@components/emoji';
|
import Emoji from '@components/emoji';
|
||||||
import FormattedText from '@components/formatted_text';
|
import FormattedText from '@components/formatted_text';
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
import {logError} from '@utils/log';
|
import {logError} from '@utils/log';
|
||||||
import {computeTextStyle, getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
|
import {computeTextStyle, getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
@ -20,6 +21,7 @@ import {typography} from '@utils/typography';
|
||||||
import AtMention from './at_mention';
|
import AtMention from './at_mention';
|
||||||
import ChannelMention from './channel_mention';
|
import ChannelMention from './channel_mention';
|
||||||
import Hashtag from './hashtag';
|
import Hashtag from './hashtag';
|
||||||
|
import InlineEntityLink, {type InlineEntityType} from './inline_entity_link';
|
||||||
import MarkdownBlockQuote from './markdown_block_quote';
|
import MarkdownBlockQuote from './markdown_block_quote';
|
||||||
import MarkdownCodeBlock from './markdown_code_block';
|
import MarkdownCodeBlock from './markdown_code_block';
|
||||||
import MarkdownImage from './markdown_image';
|
import MarkdownImage from './markdown_image';
|
||||||
|
|
@ -32,7 +34,7 @@ import MarkdownTable from './markdown_table';
|
||||||
import MarkdownTableCell, {type MarkdownTableCellProps} from './markdown_table_cell';
|
import MarkdownTableCell, {type MarkdownTableCellProps} from './markdown_table_cell';
|
||||||
import MarkdownTableImage from './markdown_table_image';
|
import MarkdownTableImage from './markdown_table_image';
|
||||||
import MarkdownTableRow, {type MarkdownTableRowProps} from './markdown_table_row';
|
import MarkdownTableRow, {type MarkdownTableRowProps} from './markdown_table_row';
|
||||||
import {addListItemIndices, combineTextNodes, highlightMentions, highlightWithoutNotification, highlightSearchPatterns, parseTaskLists, pullOutImages} from './transform';
|
import {addListItemIndices, combineTextNodes, highlightMentions, highlightWithoutNotification, highlightSearchPatterns, parseTaskLists, processInlineEntities, pullOutImages} from './transform';
|
||||||
|
|
||||||
import type {ChannelMentions} from './channel_mention/channel_mention';
|
import type {ChannelMentions} from './channel_mention/channel_mention';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -116,6 +118,12 @@ const getExtraPropsForNode = (node: any) => {
|
||||||
extraProps.isChecked = node.isChecked;
|
extraProps.isChecked = node.isChecked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.type === 'inline_entity_link') {
|
||||||
|
extraProps.entityType = node.entityType;
|
||||||
|
extraProps.entityId = node.entityId;
|
||||||
|
extraProps.linkUrl = node.linkUrl;
|
||||||
|
}
|
||||||
|
|
||||||
return extraProps;
|
return extraProps;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -178,6 +186,7 @@ const Markdown = ({
|
||||||
const blockStyles = useMemo<MarkdownBlockStyles>(() => getMarkdownBlockStyles(theme), [theme]);
|
const blockStyles = useMemo<MarkdownBlockStyles>(() => getMarkdownBlockStyles(theme), [theme]);
|
||||||
const textStyles = useMemo<MarkdownTextStyles>(() => getMarkdownTextStyles(theme), [theme]);
|
const textStyles = useMemo<MarkdownTextStyles>(() => getMarkdownTextStyles(theme), [theme]);
|
||||||
const managedConfig = useManagedConfig<ManagedConfig>();
|
const managedConfig = useManagedConfig<ManagedConfig>();
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
|
|
||||||
const renderText = useCallback(({context, literal}: MarkdownBaseRenderer) => {
|
const renderText = useCallback(({context, literal}: MarkdownBaseRenderer) => {
|
||||||
const selectable = (managedConfig.copyAndPasteProtection !== 'true') && context.includes('table_cell');
|
const selectable = (managedConfig.copyAndPasteProtection !== 'true') && context.includes('table_cell');
|
||||||
|
|
@ -285,6 +294,19 @@ const Markdown = ({
|
||||||
);
|
);
|
||||||
}, [theme.centerChannelColor]);
|
}, [theme.centerChannelColor]);
|
||||||
|
|
||||||
|
const renderInlineEntityLink = useCallback(({entityType, entityId, linkUrl}: {entityType: InlineEntityType; entityId: string; linkUrl?: string}) => {
|
||||||
|
if (!entityType || !entityId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<InlineEntityLink
|
||||||
|
entityType={entityType}
|
||||||
|
entityId={entityId}
|
||||||
|
linkUrl={linkUrl}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const renderCodeBlock = useCallback((props: any) => {
|
const renderCodeBlock = useCallback((props: any) => {
|
||||||
if (disableCodeBlock) {
|
if (disableCodeBlock) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -617,6 +639,7 @@ const Markdown = ({
|
||||||
search_highlight: Renderer.forwardChildren,
|
search_highlight: Renderer.forwardChildren,
|
||||||
highlight_without_notification: Renderer.forwardChildren,
|
highlight_without_notification: Renderer.forwardChildren,
|
||||||
checkbox: renderCheckbox,
|
checkbox: renderCheckbox,
|
||||||
|
inline_entity_link: renderInlineEntityLink,
|
||||||
|
|
||||||
editedIndicator: renderEditedIndicator,
|
editedIndicator: renderEditedIndicator,
|
||||||
maxNodesWarning: renderMaxNodesWarning,
|
maxNodesWarning: renderMaxNodesWarning,
|
||||||
|
|
@ -660,6 +683,7 @@ const Markdown = ({
|
||||||
renderTableRow,
|
renderTableRow,
|
||||||
renderTableCell,
|
renderTableCell,
|
||||||
renderCheckbox,
|
renderCheckbox,
|
||||||
|
renderInlineEntityLink,
|
||||||
renderEditedIndicator,
|
renderEditedIndicator,
|
||||||
renderMaxNodesWarning,
|
renderMaxNodesWarning,
|
||||||
maxNodes,
|
maxNodes,
|
||||||
|
|
@ -676,6 +700,7 @@ const Markdown = ({
|
||||||
ast = addListItemIndices(ast);
|
ast = addListItemIndices(ast);
|
||||||
ast = pullOutImages(ast);
|
ast = pullOutImages(ast);
|
||||||
ast = parseTaskLists(ast);
|
ast = parseTaskLists(ast);
|
||||||
|
ast = processInlineEntities(ast, serverUrl);
|
||||||
if (mentionKeys) {
|
if (mentionKeys) {
|
||||||
ast = highlightMentions(ast, mentionKeys);
|
ast = highlightMentions(ast, mentionKeys);
|
||||||
}
|
}
|
||||||
|
|
@ -731,7 +756,7 @@ const Markdown = ({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [highlightKeys, isEdited, mentionKeys, parser, renderer, searchPatterns, style.errorMessage, value]);
|
}, [highlightKeys, isEdited, mentionKeys, parser, renderer, searchPatterns, serverUrl, style.errorMessage, value]);
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3258,6 +3258,355 @@ describe('Components.Markdown.transform', () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseCitationUrl', () => {
|
||||||
|
const {parseCitationUrl} = require('@components/markdown/transform');
|
||||||
|
|
||||||
|
// Valid 26-character post ID for testing
|
||||||
|
const validPostId1 = 'hod5do9pc38q7e7ofcc7e3hzae';
|
||||||
|
|
||||||
|
it('should return null for URLs without view=citation query param', () => {
|
||||||
|
expect(parseCitationUrl(`http://localhost:8066/team/pl/${validPostId1}`)).toBeNull();
|
||||||
|
expect(parseCitationUrl('http://localhost:8066/team/channels/general')).toBeNull();
|
||||||
|
expect(parseCitationUrl('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when view=citation appears in path but not as query param', () => {
|
||||||
|
// view=citation must be a query parameter, not just appear anywhere in URL
|
||||||
|
expect(parseCitationUrl('http://localhost:8066/view=citation/team/pl/abc123')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse post citation URLs with valid 26-char post ID', () => {
|
||||||
|
const result = parseCitationUrl(`http://localhost:8066/missionops/pl/${validPostId1}?view=citation`);
|
||||||
|
expect(result).toEqual({
|
||||||
|
entityType: 'POST',
|
||||||
|
entityId: validPostId1,
|
||||||
|
linkUrl: `http://localhost:8066/missionops/pl/${validPostId1}?view=citation`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for post IDs that are not exactly 26 characters', () => {
|
||||||
|
// Too short
|
||||||
|
expect(parseCitationUrl('http://localhost:8066/team/pl/abc123?view=citation')).toBeNull();
|
||||||
|
|
||||||
|
// Too long
|
||||||
|
expect(parseCitationUrl('http://localhost:8066/team/pl/abc123def456ghi789jkl012mnopq?view=citation')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse channel citation URLs with valid channel names', () => {
|
||||||
|
const result = parseCitationUrl('http://localhost:8066/missionops/channels/mission-planning?view=citation');
|
||||||
|
expect(result).toEqual({
|
||||||
|
entityType: 'CHANNEL',
|
||||||
|
entityId: 'mission-planning',
|
||||||
|
linkUrl: 'http://localhost:8066/missionops/channels/mission-planning?view=citation',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse channel citation URLs with underscores and dots', () => {
|
||||||
|
const result = parseCitationUrl('http://localhost:8066/team/channels/my_channel.name?view=citation');
|
||||||
|
expect(result).toEqual({
|
||||||
|
entityType: 'CHANNEL',
|
||||||
|
entityId: 'my_channel.name',
|
||||||
|
linkUrl: 'http://localhost:8066/team/channels/my_channel.name?view=citation',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for team-only URLs (not a supported citation entity)', () => {
|
||||||
|
const result = parseCitationUrl('http://localhost:8066/missionops?view=citation');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle view=citation with additional query params', () => {
|
||||||
|
const result = parseCitationUrl(`http://localhost:8066/team/pl/${validPostId1}?foo=bar&view=citation`);
|
||||||
|
expect(result).toEqual({
|
||||||
|
entityType: 'POST',
|
||||||
|
entityId: validPostId1,
|
||||||
|
linkUrl: `http://localhost:8066/team/pl/${validPostId1}?foo=bar&view=citation`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with serverUrl for internal link validation', () => {
|
||||||
|
const serverUrl = 'http://localhost:8066';
|
||||||
|
|
||||||
|
it('should parse internal links when serverUrl matches', () => {
|
||||||
|
const result = parseCitationUrl(
|
||||||
|
`http://localhost:8066/team/pl/${validPostId1}?view=citation`,
|
||||||
|
serverUrl,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
entityType: 'POST',
|
||||||
|
entityId: validPostId1,
|
||||||
|
linkUrl: `http://localhost:8066/team/pl/${validPostId1}?view=citation`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for external links when serverUrl is provided', () => {
|
||||||
|
const result = parseCitationUrl(
|
||||||
|
`http://external-server.com/team/pl/${validPostId1}?view=citation`,
|
||||||
|
serverUrl,
|
||||||
|
);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when ports do not match', () => {
|
||||||
|
const result = parseCitationUrl(
|
||||||
|
`http://localhost:9000/team/pl/${validPostId1}?view=citation`,
|
||||||
|
serverUrl,
|
||||||
|
);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with server subpaths', () => {
|
||||||
|
const serverUrlWithSubpath = 'http://localhost:8066/mattermost';
|
||||||
|
|
||||||
|
it('should handle server URLs with subpaths', () => {
|
||||||
|
const result = parseCitationUrl(
|
||||||
|
`http://localhost:8066/mattermost/team/pl/${validPostId1}?view=citation`,
|
||||||
|
serverUrlWithSubpath,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
entityType: 'POST',
|
||||||
|
entityId: validPostId1,
|
||||||
|
linkUrl: `http://localhost:8066/mattermost/team/pl/${validPostId1}?view=citation`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle channel URLs with subpaths', () => {
|
||||||
|
const result = parseCitationUrl(
|
||||||
|
'http://localhost:8066/mattermost/team/channels/general?view=citation',
|
||||||
|
serverUrlWithSubpath,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
entityType: 'CHANNEL',
|
||||||
|
entityId: 'general',
|
||||||
|
linkUrl: 'http://localhost:8066/mattermost/team/channels/general?view=citation',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for team-only URLs with subpaths (not a supported citation entity)', () => {
|
||||||
|
const result = parseCitationUrl(
|
||||||
|
'http://localhost:8066/mattermost/myteam?view=citation',
|
||||||
|
serverUrlWithSubpath,
|
||||||
|
);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle encoded URLs', () => {
|
||||||
|
const encodedUrl = `http://localhost:8066/team/channels/${encodeURIComponent('channel-name')}?view=citation`;
|
||||||
|
const result = parseCitationUrl(encodedUrl);
|
||||||
|
expect(result).toEqual({
|
||||||
|
entityType: 'CHANNEL',
|
||||||
|
entityId: 'channel-name',
|
||||||
|
linkUrl: encodedUrl,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('non-internal URLs with similar shape', () => {
|
||||||
|
const serverUrl = 'http://localhost:8066';
|
||||||
|
|
||||||
|
it('should reject external URLs that mimic internal link structure', () => {
|
||||||
|
// External server with same path structure as a Mattermost permalink
|
||||||
|
const result = parseCitationUrl(
|
||||||
|
`http://evil-site.com/team/pl/${validPostId1}?view=citation`,
|
||||||
|
serverUrl,
|
||||||
|
);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject URLs from similar-looking domains', () => {
|
||||||
|
// Subdomain that might try to look like the real server
|
||||||
|
const result = parseCitationUrl(
|
||||||
|
`http://localhost.evil.com:8066/team/pl/${validPostId1}?view=citation`,
|
||||||
|
serverUrl,
|
||||||
|
);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject URLs with matching path but different protocol handling', () => {
|
||||||
|
// Same structure but on a different server entirely
|
||||||
|
const result = parseCitationUrl(
|
||||||
|
'http://mattermost-lookalike.com/team/channels/general?view=citation',
|
||||||
|
serverUrl,
|
||||||
|
);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('URLs with escaped slashes', () => {
|
||||||
|
it('should handle URLs with encoded slashes in channel names', () => {
|
||||||
|
// Channel name with encoded slash - should not match since %2F is not a valid channel character
|
||||||
|
const result = parseCitationUrl('http://localhost:8066/team/channels/channel%2Fname?view=citation');
|
||||||
|
|
||||||
|
// The URL decodes to channel/name which has a slash, making it invalid
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle URLs with encoded characters that decode to valid patterns', () => {
|
||||||
|
// %2D is encoded hyphen, which should decode and work
|
||||||
|
const result = parseCitationUrl('http://localhost:8066/team/channels/my%2Dchannel?view=citation');
|
||||||
|
expect(result).toEqual({
|
||||||
|
entityType: 'CHANNEL',
|
||||||
|
entityId: 'my-channel',
|
||||||
|
linkUrl: 'http://localhost:8066/team/channels/my%2Dchannel?view=citation',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle URLs with escaped slashes that decode to valid paths', () => {
|
||||||
|
// URL with %2F in team name decodes to a valid path structure
|
||||||
|
// After decoding: /team/evil/channels/general - the channel part is still valid
|
||||||
|
const result = parseCitationUrl('http://localhost:8066/team%2Fevil/channels/general?view=citation');
|
||||||
|
|
||||||
|
// The channel regex matches at the end of the decoded path
|
||||||
|
expect(result).toEqual({
|
||||||
|
entityType: 'CHANNEL',
|
||||||
|
entityId: 'general',
|
||||||
|
linkUrl: 'http://localhost:8066/team%2Fevil/channels/general?view=citation',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject URLs where escaped slashes break the expected pattern', () => {
|
||||||
|
// Escaped slash in the post ID itself - after decoding this creates an invalid path
|
||||||
|
const result = parseCitationUrl('http://localhost:8066/team/pl/abc%2F123?view=citation');
|
||||||
|
|
||||||
|
// The post ID pattern requires exactly 26 alphanumeric chars, abc/123 doesn't match
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processInlineEntities', () => {
|
||||||
|
const {processInlineEntities} = require('@components/markdown/transform');
|
||||||
|
|
||||||
|
// Valid 26-character post IDs for testing
|
||||||
|
const validPostId1 = 'hod5do9pc38q7e7ofcc7e3hzae';
|
||||||
|
const validPostId2 = 'abc123def456ghi789jkl012mn';
|
||||||
|
|
||||||
|
it('should not modify links without view=citation', () => {
|
||||||
|
const input = parser.parse(`[link text](http://example.com/team/pl/${validPostId1})`);
|
||||||
|
const result = processInlineEntities(input);
|
||||||
|
|
||||||
|
const walker = result.walker();
|
||||||
|
let foundLink = false;
|
||||||
|
let foundInlineEntity = false;
|
||||||
|
let e;
|
||||||
|
while ((e = walker.next())) {
|
||||||
|
if (e.node.type === 'link') {
|
||||||
|
foundLink = true;
|
||||||
|
expect(e.node.destination).toBe(`http://example.com/team/pl/${validPostId1}`);
|
||||||
|
}
|
||||||
|
if (e.node.type === 'inline_entity_link') {
|
||||||
|
foundInlineEntity = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(foundLink).toBe(true);
|
||||||
|
expect(foundInlineEntity).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transform post citation links to inline_entity_link nodes', () => {
|
||||||
|
const input = parser.parse(`[permalink](http://localhost:8066/team/pl/${validPostId1}?view=citation)`);
|
||||||
|
const result = processInlineEntities(input);
|
||||||
|
|
||||||
|
const walker = result.walker();
|
||||||
|
let foundInlineEntity = false;
|
||||||
|
let foundLink = false;
|
||||||
|
let e;
|
||||||
|
while ((e = walker.next())) {
|
||||||
|
if (e.node.type === 'inline_entity_link') {
|
||||||
|
foundInlineEntity = true;
|
||||||
|
expect(e.node.entityType).toBe('POST');
|
||||||
|
expect(e.node.entityId).toBe(validPostId1);
|
||||||
|
expect(e.node.linkUrl).toBe(`http://localhost:8066/team/pl/${validPostId1}?view=citation`);
|
||||||
|
}
|
||||||
|
if (e.node.type === 'link') {
|
||||||
|
foundLink = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(foundInlineEntity).toBe(true);
|
||||||
|
expect(foundLink).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transform channel citation links to inline_entity_link nodes', () => {
|
||||||
|
const input = parser.parse('[permalink](http://localhost:8066/team/channels/general?view=citation)');
|
||||||
|
const result = processInlineEntities(input);
|
||||||
|
|
||||||
|
const walker = result.walker();
|
||||||
|
let foundInlineEntity = false;
|
||||||
|
let foundLink = false;
|
||||||
|
let e;
|
||||||
|
while ((e = walker.next())) {
|
||||||
|
if (e.node.type === 'inline_entity_link') {
|
||||||
|
foundInlineEntity = true;
|
||||||
|
expect(e.node.entityType).toBe('CHANNEL');
|
||||||
|
expect(e.node.entityId).toBe('general');
|
||||||
|
}
|
||||||
|
if (e.node.type === 'link') {
|
||||||
|
foundLink = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(foundInlineEntity).toBe(true);
|
||||||
|
expect(foundLink).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple citation links in one document', () => {
|
||||||
|
const input = parser.parse(`First [link](http://localhost:8066/team/pl/${validPostId1}?view=citation) and second [link](http://localhost:8066/team/pl/${validPostId2}?view=citation)`);
|
||||||
|
const result = processInlineEntities(input);
|
||||||
|
|
||||||
|
const walker = result.walker();
|
||||||
|
const entityLinks: any[] = [];
|
||||||
|
const regularLinks: any[] = [];
|
||||||
|
let e;
|
||||||
|
while ((e = walker.next())) {
|
||||||
|
if (e.node.type === 'inline_entity_link') {
|
||||||
|
entityLinks.push(e.node);
|
||||||
|
}
|
||||||
|
if (e.node.type === 'link') {
|
||||||
|
regularLinks.push(e.node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(entityLinks.length).toBe(2);
|
||||||
|
expect(entityLinks[0].entityId).toBe(validPostId1);
|
||||||
|
expect(entityLinks[1].entityId).toBe(validPostId2);
|
||||||
|
expect(regularLinks.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only process internal links when serverUrl is provided', () => {
|
||||||
|
const serverUrl = 'http://localhost:8066';
|
||||||
|
const input = parser.parse(`[external](http://external.com/team/pl/${validPostId1}?view=citation) and [internal](http://localhost:8066/team/pl/${validPostId2}?view=citation)`);
|
||||||
|
const result = processInlineEntities(input, serverUrl);
|
||||||
|
|
||||||
|
const walker = result.walker();
|
||||||
|
const entityLinks: any[] = [];
|
||||||
|
const regularLinks: any[] = [];
|
||||||
|
let e;
|
||||||
|
while ((e = walker.next())) {
|
||||||
|
// Only count on entering to avoid duplicates
|
||||||
|
if (!e.entering) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (e.node.type === 'inline_entity_link') {
|
||||||
|
entityLinks.push(e.node);
|
||||||
|
} else if (e.node.type === 'link') {
|
||||||
|
regularLinks.push(e.node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the internal link should be transformed
|
||||||
|
expect(entityLinks.length).toBe(1);
|
||||||
|
expect(entityLinks[0].entityId).toBe(validPostId2);
|
||||||
|
|
||||||
|
// Verify the internal link is NOT a regular link
|
||||||
|
expect(regularLinks.some((link) => link.destination.includes(validPostId2))).toBe(false);
|
||||||
|
|
||||||
|
// The external link should remain as a regular link
|
||||||
|
expect(regularLinks.length).toBe(1);
|
||||||
|
expect(regularLinks[0].destination).toBe(`http://external.com/team/pl/${validPostId1}?view=citation`);
|
||||||
|
|
||||||
|
// Verify the external link is NOT an inline_entity_link
|
||||||
|
expect(entityLinks.some((link) => link.entityId === validPostId1)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Testing and debugging functions
|
// Testing and debugging functions
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,15 @@
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Node, type NodeType} from 'commonmark';
|
import {Node, type NodeType} from 'commonmark';
|
||||||
|
import urlParse from 'url-parse';
|
||||||
|
|
||||||
|
import {DeepLink} from '@constants';
|
||||||
|
import {parseDeepLink} from '@utils/deep_link';
|
||||||
import {escapeRegex} from '@utils/markdown';
|
import {escapeRegex} from '@utils/markdown';
|
||||||
|
import {safeDecodeURIComponent} from '@utils/url';
|
||||||
|
|
||||||
import type {HighlightWithoutNotificationKey, SearchPattern, UserMentionKey} from '@typings/global/markdown';
|
import type {HighlightWithoutNotificationKey, SearchPattern, UserMentionKey} from '@typings/global/markdown';
|
||||||
|
import type {DeepLinkChannel, DeepLinkPermalink} from '@typings/launch';
|
||||||
|
|
||||||
/* eslint-disable no-underscore-dangle */
|
/* eslint-disable no-underscore-dangle */
|
||||||
|
|
||||||
|
|
@ -381,3 +386,115 @@ export function parseTaskLists(ast: Node) {
|
||||||
|
|
||||||
return ast;
|
return ast;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a citation URL to extract entity type and identifier.
|
||||||
|
* Citation URLs must have view=citation as a query parameter.
|
||||||
|
* Uses parseDeepLink for URL structural parsing.
|
||||||
|
*
|
||||||
|
* URL patterns:
|
||||||
|
* - Post: /team/pl/postId?view=citation
|
||||||
|
* - Channel: /team/channels/channelName?view=citation
|
||||||
|
*
|
||||||
|
* @param url - The URL to parse
|
||||||
|
* @param serverUrl - Optional server URL to validate this is an internal link
|
||||||
|
* @returns Parsed citation info or null if not a valid citation URL
|
||||||
|
*/
|
||||||
|
export function parseCitationUrl(url: string, serverUrl?: string): {entityType: string; entityId: string; linkUrl: string} | null {
|
||||||
|
const decodedUrl = safeDecodeURIComponent(url);
|
||||||
|
const parsedUrl = urlParse(decodedUrl, true);
|
||||||
|
|
||||||
|
// Check if view=citation is a proper query parameter
|
||||||
|
if (parsedUrl.query.view !== 'citation') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If serverUrl is provided, validate this is an internal link
|
||||||
|
if (serverUrl) {
|
||||||
|
const parsedServerUrl = urlParse(serverUrl);
|
||||||
|
const normalizedServerHost = parsedServerUrl.hostname.toLowerCase();
|
||||||
|
const normalizedUrlHost = parsedUrl.hostname.toLowerCase();
|
||||||
|
|
||||||
|
if (normalizedUrlHost !== normalizedServerHost) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedServerUrl.port && parsedUrl.port !== parsedServerUrl.port) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use parseDeepLink to determine the entity type from the URL structure
|
||||||
|
const deepLink = parseDeepLink(decodedUrl);
|
||||||
|
|
||||||
|
switch (deepLink.type) {
|
||||||
|
case DeepLink.Permalink: {
|
||||||
|
const data = deepLink.data as DeepLinkPermalink;
|
||||||
|
return {
|
||||||
|
entityType: 'POST',
|
||||||
|
entityId: data.postId,
|
||||||
|
linkUrl: url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case DeepLink.Channel: {
|
||||||
|
const data = deepLink.data as DeepLinkChannel;
|
||||||
|
return {
|
||||||
|
entityType: 'CHANNEL',
|
||||||
|
entityId: data.channelName,
|
||||||
|
linkUrl: url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process markdown links with ?view=citation query parameter and
|
||||||
|
* replace them with inline_entity_link nodes that will be rendered as clickable link icons.
|
||||||
|
*
|
||||||
|
* This handles the new citation format:
|
||||||
|
* - [text](http://server/team/pl/postId?view=citation) -> post citation
|
||||||
|
* - [text](http://server/team/channels/channelName?view=citation) -> channel citation
|
||||||
|
* - [text](http://server/team?view=citation) -> team citation
|
||||||
|
*
|
||||||
|
* @param ast - The AST to process
|
||||||
|
* @param serverUrl - Optional server URL to validate internal links only
|
||||||
|
*/
|
||||||
|
export function processInlineEntities(ast: Node, serverUrl?: string) {
|
||||||
|
const walker = ast.walker();
|
||||||
|
|
||||||
|
let e;
|
||||||
|
while ((e = walker.next())) {
|
||||||
|
if (!e.entering) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node: any = e.node;
|
||||||
|
|
||||||
|
// Look for link nodes with citation query parameter
|
||||||
|
if (node.type !== 'link' || !node.destination) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseCitationUrl(node.destination, serverUrl);
|
||||||
|
if (!parsed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the inline entity link node
|
||||||
|
const entityNode: any = new Node('inline_entity_link' as any);
|
||||||
|
entityNode.entityType = parsed.entityType;
|
||||||
|
entityNode.entityId = parsed.entityId;
|
||||||
|
entityNode.linkUrl = parsed.linkUrl;
|
||||||
|
|
||||||
|
// Replace the link node with the entity node
|
||||||
|
node.insertBefore(entityNode);
|
||||||
|
node.unlink();
|
||||||
|
|
||||||
|
// Resume at the entity node
|
||||||
|
walker.resumeAt(entityNode, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ast;
|
||||||
|
}
|
||||||
|
|
|
||||||
21
app/products/agents/actions/remote/agents.ts
Normal file
21
app/products/agents/actions/remote/agents.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {type Agent} from '@agents/client/rest';
|
||||||
|
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import {getFullErrorMessage} from '@utils/errors';
|
||||||
|
import {logError} from '@utils/log';
|
||||||
|
|
||||||
|
export async function fetchAgents(
|
||||||
|
serverUrl: string,
|
||||||
|
): Promise<{data?: Agent[]; error?: string}> {
|
||||||
|
try {
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
const agents = await client.getAgents();
|
||||||
|
return {data: agents};
|
||||||
|
} catch (error) {
|
||||||
|
logError('[fetchAgents]', error);
|
||||||
|
return {error: getFullErrorMessage(error)};
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/products/agents/actions/remote/agents_status.test.ts
Normal file
52
app/products/agents/actions/remote/agents_status.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {setAgentsConfig} from '@agents/store/agents_config';
|
||||||
|
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
|
||||||
|
import {checkIsAgentsPluginEnabled} from './agents_status';
|
||||||
|
|
||||||
|
jest.mock('@managers/network_manager');
|
||||||
|
jest.mock('@agents/store/agents_config');
|
||||||
|
jest.mock('@utils/log');
|
||||||
|
|
||||||
|
describe('checkIsAgentsPluginEnabled', () => {
|
||||||
|
const serverUrl = 'https://server.example.com';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set pluginEnabled to true when API returns available: true', async () => {
|
||||||
|
const getAgentsStatus = jest.fn().mockResolvedValue({available: true});
|
||||||
|
jest.mocked(NetworkManager.getClient).mockReturnValue({getAgentsStatus} as any);
|
||||||
|
|
||||||
|
const result = await checkIsAgentsPluginEnabled(serverUrl);
|
||||||
|
|
||||||
|
expect(getAgentsStatus).toHaveBeenCalled();
|
||||||
|
expect(setAgentsConfig).toHaveBeenCalledWith(serverUrl, {pluginEnabled: true});
|
||||||
|
expect(result).toEqual({data: true});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set pluginEnabled to false when API returns available: false', async () => {
|
||||||
|
const getAgentsStatus = jest.fn().mockResolvedValue({available: false});
|
||||||
|
jest.mocked(NetworkManager.getClient).mockReturnValue({getAgentsStatus} as any);
|
||||||
|
|
||||||
|
const result = await checkIsAgentsPluginEnabled(serverUrl);
|
||||||
|
|
||||||
|
expect(setAgentsConfig).toHaveBeenCalledWith(serverUrl, {pluginEnabled: false});
|
||||||
|
expect(result).toEqual({data: false});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not update config and return error when API call fails', async () => {
|
||||||
|
const error = new Error('network error');
|
||||||
|
const getAgentsStatus = jest.fn().mockRejectedValue(error);
|
||||||
|
jest.mocked(NetworkManager.getClient).mockReturnValue({getAgentsStatus} as any);
|
||||||
|
|
||||||
|
const result = await checkIsAgentsPluginEnabled(serverUrl);
|
||||||
|
|
||||||
|
expect(setAgentsConfig).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({error});
|
||||||
|
});
|
||||||
|
});
|
||||||
19
app/products/agents/actions/remote/agents_status.ts
Normal file
19
app/products/agents/actions/remote/agents_status.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {setAgentsConfig} from '@agents/store/agents_config';
|
||||||
|
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import {logDebug} from '@utils/log';
|
||||||
|
|
||||||
|
export async function checkIsAgentsPluginEnabled(serverUrl: string): Promise<{data?: boolean; error?: unknown}> {
|
||||||
|
try {
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
const response = await client.getAgentsStatus();
|
||||||
|
setAgentsConfig(serverUrl, {pluginEnabled: response.available});
|
||||||
|
return {data: response.available};
|
||||||
|
} catch (error) {
|
||||||
|
logDebug('checkIsAgentsPluginEnabled', 'Failed to check agents status', error);
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/products/agents/actions/remote/channel_summary.test.ts
Normal file
98
app/products/agents/actions/remote/channel_summary.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {fetchMyChannel, switchToChannelById} from '@actions/remote/channel';
|
||||||
|
import DatabaseManager from '@database/manager';
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import {getMyChannel} from '@queries/servers/channel';
|
||||||
|
|
||||||
|
import {requestChannelSummary} from './channel_summary';
|
||||||
|
|
||||||
|
jest.mock('@actions/remote/channel');
|
||||||
|
jest.mock('@managers/network_manager');
|
||||||
|
jest.mock('@database/manager', () => ({
|
||||||
|
getServerDatabaseAndOperator: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('@queries/servers/channel');
|
||||||
|
|
||||||
|
describe('requestChannelSummary', () => {
|
||||||
|
const serverUrl = 'https://server.example.com';
|
||||||
|
const channelId = 'channel-id';
|
||||||
|
const botUsername = 'ai-bot';
|
||||||
|
const analysisType = 'summarize_channel';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls client, ensures channel membership, and switches channel on success', async () => {
|
||||||
|
const doChannelAnalysis = jest.fn().mockResolvedValue({postid: 'post-id', channelid: 'dm-id'});
|
||||||
|
jest.mocked(NetworkManager.getClient).mockReturnValue({doChannelAnalysis} as any);
|
||||||
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({database: {}} as any);
|
||||||
|
jest.mocked(getMyChannel).mockResolvedValue({id: channelId} as any);
|
||||||
|
|
||||||
|
const result = await requestChannelSummary(serverUrl, channelId, analysisType, botUsername, {days: 7});
|
||||||
|
|
||||||
|
expect(getMyChannel).toHaveBeenCalledWith({}, channelId);
|
||||||
|
expect(fetchMyChannel).not.toHaveBeenCalled();
|
||||||
|
expect(doChannelAnalysis).toHaveBeenCalledWith(channelId, analysisType, botUsername, {days: 7});
|
||||||
|
expect(switchToChannelById).toHaveBeenCalledWith(serverUrl, 'dm-id');
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.data).toEqual({postid: 'post-id', channelid: 'dm-id'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches channel membership if it does not exist in database before requesting', async () => {
|
||||||
|
const doChannelAnalysis = jest.fn().mockResolvedValue({postid: 'post-id', channelid: 'dm-id'});
|
||||||
|
jest.mocked(NetworkManager.getClient).mockReturnValue({doChannelAnalysis} as any);
|
||||||
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({database: {}} as any);
|
||||||
|
jest.mocked(getMyChannel).mockResolvedValue(undefined);
|
||||||
|
jest.mocked(fetchMyChannel).mockResolvedValue({channels: [], memberships: []});
|
||||||
|
|
||||||
|
const result = await requestChannelSummary(serverUrl, channelId, analysisType, botUsername);
|
||||||
|
|
||||||
|
expect(getMyChannel).toHaveBeenCalledWith({}, channelId);
|
||||||
|
expect(fetchMyChannel).toHaveBeenCalledWith(serverUrl, '', channelId);
|
||||||
|
expect(doChannelAnalysis).toHaveBeenCalled();
|
||||||
|
expect(switchToChannelById).toHaveBeenCalledWith(serverUrl, 'dm-id');
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.data).toEqual({postid: 'post-id', channelid: 'dm-id'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error if fetching channel membership fails before requesting', async () => {
|
||||||
|
const doChannelAnalysis = jest.fn();
|
||||||
|
jest.mocked(NetworkManager.getClient).mockReturnValue({doChannelAnalysis} as any);
|
||||||
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({database: {}} as any);
|
||||||
|
jest.mocked(getMyChannel).mockResolvedValue(undefined);
|
||||||
|
jest.mocked(fetchMyChannel).mockResolvedValue({error: 'Failed to fetch channel'});
|
||||||
|
|
||||||
|
const result = await requestChannelSummary(serverUrl, channelId, analysisType, botUsername);
|
||||||
|
|
||||||
|
expect(fetchMyChannel).toHaveBeenCalledWith(serverUrl, '', channelId);
|
||||||
|
expect(doChannelAnalysis).not.toHaveBeenCalled();
|
||||||
|
expect(switchToChannelById).not.toHaveBeenCalled();
|
||||||
|
expect(result.error).toBe('Failed to fetch channel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when response is invalid', async () => {
|
||||||
|
const doChannelAnalysis = jest.fn().mockResolvedValue({});
|
||||||
|
jest.mocked(NetworkManager.getClient).mockReturnValue({doChannelAnalysis} as any);
|
||||||
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({database: {}} as any);
|
||||||
|
jest.mocked(getMyChannel).mockResolvedValue({id: channelId} as any);
|
||||||
|
|
||||||
|
const result = await requestChannelSummary(serverUrl, channelId, analysisType, botUsername);
|
||||||
|
|
||||||
|
expect(result.error).toBe('Invalid response from server');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces errors from client', async () => {
|
||||||
|
const doChannelAnalysis = jest.fn().mockRejectedValue(new Error('boom'));
|
||||||
|
jest.mocked(NetworkManager.getClient).mockReturnValue({doChannelAnalysis} as any);
|
||||||
|
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({database: {}} as any);
|
||||||
|
jest.mocked(getMyChannel).mockResolvedValue({id: channelId} as any);
|
||||||
|
|
||||||
|
const result = await requestChannelSummary(serverUrl, channelId, analysisType, botUsername);
|
||||||
|
|
||||||
|
expect(result.error).toBe('boom');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
50
app/products/agents/actions/remote/channel_summary.ts
Normal file
50
app/products/agents/actions/remote/channel_summary.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {fetchMyChannel, switchToChannelById} from '@actions/remote/channel';
|
||||||
|
import DatabaseManager from '@database/manager';
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import {getMyChannel} from '@queries/servers/channel';
|
||||||
|
import {getFullErrorMessage} from '@utils/errors';
|
||||||
|
import {logError} from '@utils/log';
|
||||||
|
|
||||||
|
import type {ChannelAnalysisOptions, ChannelAnalysisResponse} from '@agents/types/api';
|
||||||
|
|
||||||
|
export async function requestChannelSummary(
|
||||||
|
serverUrl: string,
|
||||||
|
channelId: string,
|
||||||
|
analysisType: string,
|
||||||
|
botUsername: string,
|
||||||
|
options?: ChannelAnalysisOptions,
|
||||||
|
): Promise<{data?: ChannelAnalysisResponse; error?: string}> {
|
||||||
|
try {
|
||||||
|
// Ensure channel exists in database and user has membership before requesting
|
||||||
|
// This is critical for offline compatibility - switchToChannelById expects
|
||||||
|
// the channel to already exist in the database
|
||||||
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
const myChannel = await getMyChannel(database, channelId);
|
||||||
|
if (!myChannel) {
|
||||||
|
// Channel doesn't exist or user doesn't have membership - fetch and persist it
|
||||||
|
// Use empty string for teamId - fetchMyChannel will use channel.team_id if available
|
||||||
|
const channelResult = await fetchMyChannel(serverUrl, '', channelId);
|
||||||
|
if (channelResult.error) {
|
||||||
|
return {error: getFullErrorMessage(channelResult.error)};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
const result = await client.doChannelAnalysis(channelId, analysisType, botUsername, options);
|
||||||
|
|
||||||
|
if (!result?.postid || !result?.channelid) {
|
||||||
|
return {error: 'Invalid response from server'};
|
||||||
|
}
|
||||||
|
|
||||||
|
await switchToChannelById(serverUrl, result.channelid);
|
||||||
|
|
||||||
|
return {data: result};
|
||||||
|
} catch (error) {
|
||||||
|
logError('[requestChannelSummary]', error);
|
||||||
|
return {error: getFullErrorMessage(error)};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,11 +1,23 @@
|
||||||
// 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';
|
||||||
|
|
||||||
|
export type {Agent};
|
||||||
|
|
||||||
export interface ClientAgentsMix {
|
export interface ClientAgentsMix {
|
||||||
getAgentsRoute: () => string;
|
getAgentsRoute: () => string;
|
||||||
|
getAgents: () => Promise<Agent[]>;
|
||||||
stopGeneration: (postId: string) => Promise<void>;
|
stopGeneration: (postId: string) => Promise<void>;
|
||||||
regenerateResponse: (postId: string) => Promise<void>;
|
regenerateResponse: (postId: string) => Promise<void>;
|
||||||
|
doChannelAnalysis: (
|
||||||
|
channelId: string,
|
||||||
|
analysisType: string,
|
||||||
|
botUsername: string,
|
||||||
|
options?: ChannelAnalysisOptions,
|
||||||
|
) => Promise<ChannelAnalysisResponse>;
|
||||||
submitToolApproval: (postId: string, acceptedToolIds: string[]) => Promise<void>;
|
submitToolApproval: (postId: string, acceptedToolIds: string[]) => Promise<void>;
|
||||||
|
getAgentsStatus: () => Promise<AgentsStatusResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClientAgents = (superclass: any) => class extends superclass {
|
const ClientAgents = (superclass: any) => class extends superclass {
|
||||||
|
|
@ -13,6 +25,19 @@ const ClientAgents = (superclass: any) => class extends superclass {
|
||||||
return '/plugins/mattermost-ai';
|
return '/plugins/mattermost-ai';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getAgents = async (): Promise<Agent[]> => {
|
||||||
|
const response = await this.doFetch(
|
||||||
|
`${this.urlVersion}/agents`,
|
||||||
|
{method: 'get'},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle both array response and wrapped response
|
||||||
|
if (Array.isArray(response)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
return (response as AgentsResponse).agents || [];
|
||||||
|
};
|
||||||
|
|
||||||
stopGeneration = async (postId: string) => {
|
stopGeneration = async (postId: string) => {
|
||||||
return this.doFetch(
|
return this.doFetch(
|
||||||
`${this.getAgentsRoute()}/post/${postId}/stop`,
|
`${this.getAgentsRoute()}/post/${postId}/stop`,
|
||||||
|
|
@ -27,6 +52,29 @@ const ClientAgents = (superclass: any) => class extends superclass {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
doChannelAnalysis = async (
|
||||||
|
channelId: string,
|
||||||
|
analysisType: string,
|
||||||
|
botUsername: string,
|
||||||
|
options?: ChannelAnalysisOptions,
|
||||||
|
): Promise<ChannelAnalysisResponse> => {
|
||||||
|
const {since, until, days, prompt, unreads_only} = options || {};
|
||||||
|
return this.doFetch(
|
||||||
|
`${this.getAgentsRoute()}/channel/${channelId}/analyze?botUsername=${encodeURIComponent(botUsername)}`,
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
body: {
|
||||||
|
analysis_type: analysisType,
|
||||||
|
since,
|
||||||
|
until,
|
||||||
|
days,
|
||||||
|
prompt,
|
||||||
|
unreads_only,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
submitToolApproval = async (postId: string, acceptedToolIds: string[]) => {
|
submitToolApproval = async (postId: string, acceptedToolIds: string[]) => {
|
||||||
return this.doFetch(
|
return this.doFetch(
|
||||||
`${this.getAgentsRoute()}/post/${postId}/tool_call`,
|
`${this.getAgentsRoute()}/post/${postId}/tool_call`,
|
||||||
|
|
@ -36,6 +84,13 @@ const ClientAgents = (superclass: any) => class extends superclass {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getAgentsStatus = async (): Promise<AgentsStatusResponse> => {
|
||||||
|
return this.doFetch(
|
||||||
|
`${this.urlVersion}/agents/status`,
|
||||||
|
{method: 'get'},
|
||||||
|
);
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ClientAgents;
|
export default ClientAgents;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {type Agent} from '@agents/client/rest';
|
||||||
|
import React, {useCallback, useMemo} from 'react';
|
||||||
|
import {Text, TouchableOpacity, View} from 'react-native';
|
||||||
|
|
||||||
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import {ExpoImageAnimated} from '@components/expo_image';
|
||||||
|
import {ACCOUNT_OUTLINE_IMAGE} from '@constants/profile';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
import {typography} from '@utils/typography';
|
||||||
|
|
||||||
|
const AVATAR_SIZE = 32;
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||||
|
agentRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 16,
|
||||||
|
},
|
||||||
|
agentInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
agentAvatar: {
|
||||||
|
width: AVATAR_SIZE,
|
||||||
|
height: AVATAR_SIZE,
|
||||||
|
borderRadius: AVATAR_SIZE / 2,
|
||||||
|
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||||
|
},
|
||||||
|
agentName: {
|
||||||
|
color: theme.centerChannelColor,
|
||||||
|
...typography('Body', 200, 'Regular'),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
type AgentItemProps = {
|
||||||
|
agent: Agent;
|
||||||
|
profileImageUrl?: string;
|
||||||
|
currentAgentUsername: string;
|
||||||
|
onSelect: (agent: Agent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AgentItem = React.memo(({agent, profileImageUrl, currentAgentUsername, onSelect}: AgentItemProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const styles = useMemo(() => getStyleSheet(theme), [theme]);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onSelect(agent);
|
||||||
|
}, [onSelect, agent]);
|
||||||
|
|
||||||
|
const imageStyle = useMemo(() => ({
|
||||||
|
borderRadius: AVATAR_SIZE / 2,
|
||||||
|
backgroundColor: theme.centerChannelBg,
|
||||||
|
height: AVATAR_SIZE,
|
||||||
|
width: AVATAR_SIZE,
|
||||||
|
}), [theme.centerChannelBg]);
|
||||||
|
|
||||||
|
const iconStyle = useMemo(() => ({
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.48),
|
||||||
|
}), [theme.centerChannelColor]);
|
||||||
|
|
||||||
|
const cacheId = agent.id ? `user-${agent.id}` : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
style={styles.agentRow}
|
||||||
|
testID={`agents.selector.agent.${agent.username}`}
|
||||||
|
>
|
||||||
|
<View style={styles.agentInfo}>
|
||||||
|
{profileImageUrl ? (
|
||||||
|
<ExpoImageAnimated
|
||||||
|
id={cacheId!}
|
||||||
|
source={{uri: profileImageUrl}}
|
||||||
|
style={imageStyle}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.agentAvatar}>
|
||||||
|
<CompassIcon
|
||||||
|
name={ACCOUNT_OUTLINE_IMAGE}
|
||||||
|
size={AVATAR_SIZE}
|
||||||
|
style={iconStyle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={styles.agentName}>
|
||||||
|
{agent.displayName || agent.username}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{currentAgentUsername === agent.username && (
|
||||||
|
<CompassIcon
|
||||||
|
name='check'
|
||||||
|
size={24}
|
||||||
|
color={theme.linkColor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
AgentItem.displayName = 'AgentItem';
|
||||||
|
|
||||||
|
export default AgentItem;
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {type Agent} from '@agents/client/rest';
|
||||||
|
import React, {useCallback, useMemo} from 'react';
|
||||||
|
import {FlatList, type ListRenderItemInfo, TouchableOpacity, View} from 'react-native';
|
||||||
|
|
||||||
|
import {buildAbsoluteUrl} from '@actions/remote/file';
|
||||||
|
import {buildProfileImageUrl} from '@actions/remote/user';
|
||||||
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import FormattedText from '@components/formatted_text';
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
import {typography} from '@utils/typography';
|
||||||
|
|
||||||
|
import AgentItem from './agent_item';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
agents: Agent[];
|
||||||
|
currentAgentUsername: string;
|
||||||
|
onSelectAgent: (agent: Agent) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
color: theme.centerChannelColor,
|
||||||
|
...typography('Heading', 300, 'SemiBold'),
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
paddingVertical: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.56),
|
||||||
|
...typography('Body', 200, 'Regular'),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const AgentSelectorPanel = ({
|
||||||
|
agents,
|
||||||
|
currentAgentUsername,
|
||||||
|
onSelectAgent,
|
||||||
|
onBack,
|
||||||
|
}: Props) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
|
||||||
|
// Build profile image URLs for all agents
|
||||||
|
const agentImageUrls = useMemo(() => {
|
||||||
|
const urls: Record<string, string | undefined> = {};
|
||||||
|
for (const agent of agents) {
|
||||||
|
if (agent.id) {
|
||||||
|
const relativePath = buildProfileImageUrl(serverUrl, agent.id);
|
||||||
|
urls[agent.id] = relativePath ? buildAbsoluteUrl(serverUrl, relativePath) : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}, [agents, serverUrl]);
|
||||||
|
|
||||||
|
const keyExtractor = useCallback((agent: Agent) => agent.id || agent.username, []);
|
||||||
|
|
||||||
|
const renderItem = useCallback(({item: agent}: ListRenderItemInfo<Agent>) => {
|
||||||
|
const profileImageUrl = agent.id ? agentImageUrls[agent.id] : undefined;
|
||||||
|
return (
|
||||||
|
<AgentItem
|
||||||
|
agent={agent}
|
||||||
|
profileImageUrl={profileImageUrl}
|
||||||
|
currentAgentUsername={currentAgentUsername}
|
||||||
|
onSelect={onSelectAgent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [agentImageUrls, currentAgentUsername, onSelectAgent]);
|
||||||
|
|
||||||
|
const renderSeparator = useCallback(() => (
|
||||||
|
<View style={styles.divider}/>
|
||||||
|
), [styles.divider]);
|
||||||
|
|
||||||
|
const renderEmpty = useCallback(() => (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<FormattedText
|
||||||
|
id='agents.selector.no_agents'
|
||||||
|
defaultMessage='No agents available'
|
||||||
|
style={styles.emptyText}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
), [styles.emptyContainer, styles.emptyText]);
|
||||||
|
|
||||||
|
const renderHeader = useCallback(() => (
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onBack}
|
||||||
|
style={styles.backButton}
|
||||||
|
testID='agents.selector.back'
|
||||||
|
>
|
||||||
|
<CompassIcon
|
||||||
|
name='arrow-left'
|
||||||
|
size={24}
|
||||||
|
color={theme.centerChannelColor}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<FormattedText
|
||||||
|
id='agents.selector.title'
|
||||||
|
defaultMessage='Select Agent'
|
||||||
|
style={styles.headerTitle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
), [onBack, styles.backButton, styles.headerRow, styles.headerTitle, theme.centerChannelColor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<FlatList
|
||||||
|
data={agents}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
renderItem={renderItem}
|
||||||
|
ItemSeparatorComponent={renderSeparator}
|
||||||
|
ListEmptyComponent={renderEmpty}
|
||||||
|
ListHeaderComponent={renderHeader}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentSelectorPanel;
|
||||||
|
|
@ -0,0 +1,289 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import DateTimePicker, {type DateTimePickerEvent} from '@react-native-community/datetimepicker';
|
||||||
|
import React, {useCallback, useMemo, useState} from 'react';
|
||||||
|
import {defineMessages, useIntl, type MessageDescriptor} from 'react-intl';
|
||||||
|
import {Platform, TouchableOpacity, View} from 'react-native';
|
||||||
|
import tinyColor from 'tinycolor2';
|
||||||
|
|
||||||
|
import Button from '@components/button';
|
||||||
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import FormattedDate, {type FormattedDateFormat} from '@components/formatted_date';
|
||||||
|
import FormattedText from '@components/formatted_text';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
import {typography} from '@utils/typography';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onSubmit: (since: Date, until: Date) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||||
|
container: {
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
color: theme.centerChannelColor,
|
||||||
|
...typography('Heading', 300, 'SemiBold'),
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.72),
|
||||||
|
marginBottom: 4,
|
||||||
|
marginTop: 8,
|
||||||
|
...typography('Body', 75, 'SemiBold'),
|
||||||
|
},
|
||||||
|
dateBox: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
dateBoxActive: {
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: theme.buttonBg,
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
color: theme.centerChannelColor,
|
||||||
|
...typography('Body', 200, 'Regular'),
|
||||||
|
},
|
||||||
|
datePlaceholder: {
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.56),
|
||||||
|
...typography('Body', 200, 'Regular'),
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
marginTop: 8,
|
||||||
|
color: theme.errorTextColor,
|
||||||
|
...typography('Body', 75, 'SemiBold'),
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const DATE_FORMAT: FormattedDateFormat = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateMessages = defineMessages({
|
||||||
|
sinceLabel: {id: 'agents.channel_summary.since', defaultMessage: 'Start'},
|
||||||
|
sincePlaceholder: {id: 'agents.channel_summary.since_placeholder', defaultMessage: 'Select date'},
|
||||||
|
untilLabel: {id: 'agents.channel_summary.until', defaultMessage: 'End'},
|
||||||
|
untilPlaceholder: {id: 'agents.channel_summary.until_placeholder', defaultMessage: 'Select date'},
|
||||||
|
});
|
||||||
|
|
||||||
|
type PickerTarget = 'since' | 'until' | undefined;
|
||||||
|
|
||||||
|
const BACK_BUTTON_HIT_SLOP = {top: 16, bottom: 16, left: 16, right: 16};
|
||||||
|
|
||||||
|
type DateInputFieldProps = {
|
||||||
|
label: MessageDescriptor;
|
||||||
|
date: Date | undefined;
|
||||||
|
placeholder: MessageDescriptor;
|
||||||
|
onPress: () => void;
|
||||||
|
testID: string;
|
||||||
|
styles: ReturnType<typeof getStyleSheet>;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DateInputField = ({label, date, placeholder, onPress, testID, styles, isActive}: DateInputFieldProps) => (
|
||||||
|
<>
|
||||||
|
<FormattedText
|
||||||
|
id={label.id}
|
||||||
|
defaultMessage={label.defaultMessage}
|
||||||
|
style={styles.label}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
style={[styles.dateBox, isActive && styles.dateBoxActive]}
|
||||||
|
testID={testID}
|
||||||
|
>
|
||||||
|
{date ? (
|
||||||
|
<FormattedDate
|
||||||
|
value={date}
|
||||||
|
format={DATE_FORMAT}
|
||||||
|
style={styles.dateText}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormattedText
|
||||||
|
id={placeholder.id}
|
||||||
|
defaultMessage={placeholder.defaultMessage}
|
||||||
|
style={styles.datePlaceholder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DateRangePicker = ({onSubmit, onCancel}: Props) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const theme = useTheme();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
|
||||||
|
const [since, setSince] = useState<Date | undefined>(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - 7);
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
const [until, setUntil] = useState<Date | undefined>(() => new Date());
|
||||||
|
const [pickerTarget, setPickerTarget] = useState<PickerTarget>();
|
||||||
|
const [showError, setShowError] = useState(false);
|
||||||
|
|
||||||
|
const dateIsValid = useMemo(() => {
|
||||||
|
if (!since || !until) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return since.getTime() <= until.getTime();
|
||||||
|
}, [since, until]);
|
||||||
|
|
||||||
|
const openSincePicker = useCallback(() => {
|
||||||
|
if (!since) {
|
||||||
|
const today = new Date();
|
||||||
|
setSince(until && until < today ? until : today);
|
||||||
|
}
|
||||||
|
setPickerTarget('since');
|
||||||
|
}, [since, until]);
|
||||||
|
|
||||||
|
const openUntilPicker = useCallback(() => {
|
||||||
|
if (!until) {
|
||||||
|
const today = new Date();
|
||||||
|
setUntil(since && since > today ? since : today);
|
||||||
|
}
|
||||||
|
setPickerTarget('until');
|
||||||
|
}, [until, since]);
|
||||||
|
|
||||||
|
const onChangeDate = useCallback((_: DateTimePickerEvent, date?: Date) => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
// iOS uses an inline picker that can stay visible
|
||||||
|
const currentTarget = pickerTarget;
|
||||||
|
|
||||||
|
if (!currentTarget || !date) {
|
||||||
|
setPickerTarget(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTarget === 'since') {
|
||||||
|
setSince(date);
|
||||||
|
} else {
|
||||||
|
setUntil(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't close picker on iOS - user can continue adjusting
|
||||||
|
} else {
|
||||||
|
// Android: Set date inside functional updater to batch both state updates,
|
||||||
|
// preventing React re-renders from causing the picker to reopen.
|
||||||
|
setPickerTarget((currentTarget) => {
|
||||||
|
if (currentTarget && date) {
|
||||||
|
if (currentTarget === 'since') {
|
||||||
|
setSince(date);
|
||||||
|
} else {
|
||||||
|
setUntil(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined; // Always close on Android (modal auto-dismisses)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [pickerTarget]);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
setShowError(false);
|
||||||
|
if (!since || !until || !dateIsValid) {
|
||||||
|
setShowError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit(since, until);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header with back button */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onCancel}
|
||||||
|
style={styles.backButton}
|
||||||
|
hitSlop={BACK_BUTTON_HIT_SLOP}
|
||||||
|
testID='agents.channel_summary.date_picker.back'
|
||||||
|
>
|
||||||
|
<CompassIcon
|
||||||
|
name='arrow-left'
|
||||||
|
size={24}
|
||||||
|
color={theme.centerChannelColor}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<FormattedText
|
||||||
|
id='agents.channel_summary.date_picker.title'
|
||||||
|
defaultMessage='Select date range'
|
||||||
|
style={styles.headerTitle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Start Date */}
|
||||||
|
<DateInputField
|
||||||
|
label={dateMessages.sinceLabel}
|
||||||
|
date={since}
|
||||||
|
placeholder={dateMessages.sincePlaceholder}
|
||||||
|
onPress={openSincePicker}
|
||||||
|
testID='agents.channel_summary.date_from'
|
||||||
|
styles={styles}
|
||||||
|
isActive={pickerTarget === 'since'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* End Date */}
|
||||||
|
<DateInputField
|
||||||
|
label={dateMessages.untilLabel}
|
||||||
|
date={until}
|
||||||
|
placeholder={dateMessages.untilPlaceholder}
|
||||||
|
onPress={openUntilPicker}
|
||||||
|
testID='agents.channel_summary.date_to'
|
||||||
|
styles={styles}
|
||||||
|
isActive={pickerTarget === 'until'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showError && (
|
||||||
|
<FormattedText
|
||||||
|
id='agents.channel_summary.range_error'
|
||||||
|
defaultMessage='Select a valid date range'
|
||||||
|
style={styles.error}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Button
|
||||||
|
onPress={handleSubmit}
|
||||||
|
size='lg'
|
||||||
|
text={intl.formatMessage({id: 'agents.channel_summary.date_picker.submit', defaultMessage: 'Summarize'})}
|
||||||
|
theme={theme}
|
||||||
|
disabled={!since || !until}
|
||||||
|
testID='agents.channel_summary.date_picker.submit'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Date Picker */}
|
||||||
|
{pickerTarget && (
|
||||||
|
<DateTimePicker
|
||||||
|
value={(pickerTarget === 'since' ? since : until) || new Date()}
|
||||||
|
mode='date'
|
||||||
|
display={Platform.select({ios: 'spinner', default: 'calendar'})}
|
||||||
|
onChange={onChangeDate}
|
||||||
|
maximumDate={pickerTarget === 'since' ? until : new Date()}
|
||||||
|
minimumDate={pickerTarget === 'until' ? since : undefined}
|
||||||
|
themeVariant={tinyColor(theme.centerChannelBg).isDark() ? 'dark' : 'light'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DateRangePicker;
|
||||||
384
app/products/agents/components/channel_summary_sheet/index.tsx
Normal file
384
app/products/agents/components/channel_summary_sheet/index.tsx
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {fetchAgents} from '@agents/actions/remote/agents';
|
||||||
|
import {requestChannelSummary} from '@agents/actions/remote/channel_summary';
|
||||||
|
import {type Agent} from '@agents/client/rest';
|
||||||
|
import {AGENT_ANALYSIS_SUMMARY} from '@agents/constants';
|
||||||
|
import React, {useCallback, useEffect, useState} from 'react';
|
||||||
|
import {defineMessages, useIntl, type MessageDescriptor} from 'react-intl';
|
||||||
|
import {Alert, ScrollView, Text, TouchableOpacity, View} from 'react-native';
|
||||||
|
|
||||||
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
|
||||||
|
import FormattedText from '@components/formatted_text';
|
||||||
|
import Loading from '@components/loading';
|
||||||
|
import OptionItem from '@components/option_item';
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {usePreventDoubleTap} from '@hooks/utils';
|
||||||
|
import {dismissBottomSheet} from '@screens/navigation';
|
||||||
|
import {getErrorMessage} from '@utils/errors';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
import {typography} from '@utils/typography';
|
||||||
|
|
||||||
|
import AgentSelectorPanel from './agent_selector_panel';
|
||||||
|
import DateRangePicker from './date_range_picker';
|
||||||
|
|
||||||
|
type SummaryOptionId = 'unreads' | '7d' | '14d' | 'custom';
|
||||||
|
|
||||||
|
type SummaryOption = {
|
||||||
|
id: SummaryOptionId;
|
||||||
|
message: MessageDescriptor;
|
||||||
|
days?: number;
|
||||||
|
showChevron?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
unreads: {id: 'agents.channel_summary.option.unreads', defaultMessage: 'Summarize unreads'},
|
||||||
|
sevenDays: {id: 'agents.channel_summary.option.7d', defaultMessage: 'Summarize last 7 days'},
|
||||||
|
fourteenDays: {id: 'agents.channel_summary.option.14d', defaultMessage: 'Summarize last 14 days'},
|
||||||
|
custom: {id: 'agents.channel_summary.option.custom', defaultMessage: 'Select date range to summarize'},
|
||||||
|
});
|
||||||
|
|
||||||
|
const SUMMARY_OPTIONS: SummaryOption[] = [
|
||||||
|
{id: 'unreads', message: messages.unreads},
|
||||||
|
{id: '7d', days: 7, message: messages.sevenDays},
|
||||||
|
{id: '14d', days: 14, message: messages.fourteenDays},
|
||||||
|
{id: 'custom', message: messages.custom, showChevron: true},
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
channelId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||||
|
headerSection: {
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
agentRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
agentLabel: {
|
||||||
|
color: theme.centerChannelColor,
|
||||||
|
...typography('Body', 200, 'Regular'),
|
||||||
|
},
|
||||||
|
agentSelector: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
agentName: {
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.56),
|
||||||
|
...typography('Body', 100, 'Regular'),
|
||||||
|
},
|
||||||
|
promptWrapper: {
|
||||||
|
paddingTop: 4,
|
||||||
|
},
|
||||||
|
sendButton: {
|
||||||
|
width: 44,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: theme.buttonBg,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
sendButtonDisabled: {
|
||||||
|
backgroundColor: changeOpacity(theme.buttonBg, 0.5),
|
||||||
|
},
|
||||||
|
optionsContainer: {
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
loadingOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: changeOpacity(theme.centerChannelBg, 0.7),
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ChannelSummarySheet = ({channelId}: Props) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const theme = useTheme();
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
|
||||||
|
const [customPrompt, setCustomPrompt] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||||
|
const [showAgentSelector, setShowAgentSelector] = useState(false);
|
||||||
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||||
|
const [loadingAgents, setLoadingAgents] = useState(true);
|
||||||
|
|
||||||
|
// Fetch agents on mount and set default to first agent
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAgents = async () => {
|
||||||
|
setLoadingAgents(true);
|
||||||
|
const result = await fetchAgents(serverUrl);
|
||||||
|
if (result.data && result.data.length > 0) {
|
||||||
|
setAgents(result.data);
|
||||||
|
setSelectedAgent(result.data[0]);
|
||||||
|
}
|
||||||
|
setLoadingAgents(false);
|
||||||
|
};
|
||||||
|
loadAgents();
|
||||||
|
}, [serverUrl]);
|
||||||
|
|
||||||
|
const handleOptionPress = useCallback(async (optionId: string | boolean) => {
|
||||||
|
if (submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = SUMMARY_OPTIONS.find((o) => o.id === optionId);
|
||||||
|
if (!option) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.id === 'custom') {
|
||||||
|
setShowDatePicker(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate selectedAgent before setting submitting state to avoid stuck loading UI
|
||||||
|
if (!selectedAgent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
const options: Record<string, string | number | boolean | undefined> = {};
|
||||||
|
|
||||||
|
if (option.days) {
|
||||||
|
options.days = option.days;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.id === 'unreads') {
|
||||||
|
options.unreads_only = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customPrompt.trim()) {
|
||||||
|
options.prompt = customPrompt.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const {error} = await requestChannelSummary(
|
||||||
|
serverUrl,
|
||||||
|
channelId,
|
||||||
|
AGENT_ANALYSIS_SUMMARY,
|
||||||
|
selectedAgent.username,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setSubmitting(false);
|
||||||
|
Alert.alert(
|
||||||
|
intl.formatMessage({id: 'agents.channel_summary.error_title', defaultMessage: 'Unable to start summary'}),
|
||||||
|
getErrorMessage(error, intl),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissBottomSheet();
|
||||||
|
}, [serverUrl, channelId, selectedAgent, customPrompt, intl, submitting]);
|
||||||
|
|
||||||
|
const handleAgentSelectorOpen = useCallback(() => {
|
||||||
|
setShowAgentSelector(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAgentSelectorBack = useCallback(() => {
|
||||||
|
setShowAgentSelector(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAgentSelect = useCallback((agent: Agent) => {
|
||||||
|
setSelectedAgent(agent);
|
||||||
|
setShowAgentSelector(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCustomPromptSubmit = useCallback(async () => {
|
||||||
|
if (!customPrompt.trim() || !selectedAgent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
const options: Record<string, string | number | undefined> = {
|
||||||
|
prompt: customPrompt.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const {error} = await requestChannelSummary(
|
||||||
|
serverUrl,
|
||||||
|
channelId,
|
||||||
|
AGENT_ANALYSIS_SUMMARY,
|
||||||
|
selectedAgent.username,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setSubmitting(false);
|
||||||
|
Alert.alert(
|
||||||
|
intl.formatMessage({id: 'agents.channel_summary.error_title', defaultMessage: 'Unable to start summary'}),
|
||||||
|
getErrorMessage(error, intl),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissBottomSheet();
|
||||||
|
}, [serverUrl, channelId, selectedAgent, customPrompt, intl]);
|
||||||
|
|
||||||
|
const handleDateRangeSubmit = useCallback(async (since: Date, until: Date) => {
|
||||||
|
if (!selectedAgent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowDatePicker(false);
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
// Normalize to UTC start/end of day to avoid missing data due to timezone conversion
|
||||||
|
const sinceUtc = new Date(Date.UTC(since.getFullYear(), since.getMonth(), since.getDate(), 0, 0, 0));
|
||||||
|
const untilUtc = new Date(Date.UTC(until.getFullYear(), until.getMonth(), until.getDate(), 23, 59, 59));
|
||||||
|
|
||||||
|
const options: Record<string, string | number | undefined> = {
|
||||||
|
since: sinceUtc.toISOString(),
|
||||||
|
until: untilUtc.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (customPrompt.trim()) {
|
||||||
|
options.prompt = customPrompt.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const {error} = await requestChannelSummary(
|
||||||
|
serverUrl,
|
||||||
|
channelId,
|
||||||
|
AGENT_ANALYSIS_SUMMARY,
|
||||||
|
selectedAgent.username,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setSubmitting(false);
|
||||||
|
Alert.alert(
|
||||||
|
intl.formatMessage({id: 'agents.channel_summary.error_title', defaultMessage: 'Unable to start summary'}),
|
||||||
|
getErrorMessage(error, intl),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissBottomSheet();
|
||||||
|
}, [serverUrl, channelId, selectedAgent, customPrompt, intl]);
|
||||||
|
|
||||||
|
const handleCustomPromptSubmitDebounced = usePreventDoubleTap(handleCustomPromptSubmit);
|
||||||
|
const handleDateRangeSubmitDebounced = usePreventDoubleTap(handleDateRangeSubmit);
|
||||||
|
|
||||||
|
if (showAgentSelector) {
|
||||||
|
return (
|
||||||
|
<AgentSelectorPanel
|
||||||
|
agents={agents}
|
||||||
|
currentAgentUsername={selectedAgent?.username ?? ''}
|
||||||
|
onSelectAgent={handleAgentSelect}
|
||||||
|
onBack={handleAgentSelectorBack}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDatePicker) {
|
||||||
|
return (
|
||||||
|
<DateRangePicker
|
||||||
|
onSubmit={handleDateRangeSubmitDebounced}
|
||||||
|
onCancel={() => setShowDatePicker(false)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedAgentDisplayName = selectedAgent?.displayName || selectedAgent?.username || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView>
|
||||||
|
{/* Header Section - Agent selector + Prompt input */}
|
||||||
|
<View style={styles.headerSection}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleAgentSelectorOpen}
|
||||||
|
style={styles.agentRow}
|
||||||
|
testID='agents.channel_summary.agent_selector'
|
||||||
|
disabled={submitting || loadingAgents}
|
||||||
|
>
|
||||||
|
<FormattedText
|
||||||
|
id='agents.channel_summary.selected_agent'
|
||||||
|
defaultMessage='Selected Agent'
|
||||||
|
style={styles.agentLabel}
|
||||||
|
/>
|
||||||
|
<View style={styles.agentSelector}>
|
||||||
|
{loadingAgents ? (
|
||||||
|
<Loading size='small'/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.agentName}>{selectedAgentDisplayName}</Text>
|
||||||
|
<CompassIcon
|
||||||
|
name='chevron-right'
|
||||||
|
size={20}
|
||||||
|
color={changeOpacity(theme.centerChannelColor, 0.32)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.promptWrapper}>
|
||||||
|
<FloatingTextInput
|
||||||
|
label={intl.formatMessage({id: 'agents.channel_summary.ai_prompt_placeholder', defaultMessage: 'Ask AI about this channel'})}
|
||||||
|
theme={theme}
|
||||||
|
value={customPrompt}
|
||||||
|
onChangeText={setCustomPrompt}
|
||||||
|
testID='agents.channel_summary.prompt_input'
|
||||||
|
editable={!submitting}
|
||||||
|
onSubmitEditing={handleCustomPromptSubmitDebounced}
|
||||||
|
returnKeyType='send'
|
||||||
|
endAdornment={
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleCustomPromptSubmitDebounced}
|
||||||
|
style={[
|
||||||
|
styles.sendButton,
|
||||||
|
(!customPrompt.trim() || submitting) && styles.sendButtonDisabled,
|
||||||
|
]}
|
||||||
|
disabled={!customPrompt.trim() || submitting}
|
||||||
|
testID='agents.channel_summary.prompt_submit'
|
||||||
|
>
|
||||||
|
<CompassIcon
|
||||||
|
name='send'
|
||||||
|
size={20}
|
||||||
|
color={theme.buttonColor}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Summary Options */}
|
||||||
|
<View style={styles.optionsContainer}>
|
||||||
|
{SUMMARY_OPTIONS.map((option) => (
|
||||||
|
<OptionItem
|
||||||
|
key={option.id}
|
||||||
|
action={handleOptionPress}
|
||||||
|
label={intl.formatMessage(option.message)}
|
||||||
|
testID={`agents.channel_summary.option.${option.id}`}
|
||||||
|
type={option.showChevron ? 'arrow' : 'default'}
|
||||||
|
value={option.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{submitting && (
|
||||||
|
<View style={styles.loadingOverlay}>
|
||||||
|
<Loading color={theme.buttonBg}/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChannelSummarySheet;
|
||||||
|
|
@ -31,3 +31,6 @@ export const CONTROL_SIGNALS = {
|
||||||
TOOL_CALL: 'tool_call',
|
TOOL_CALL: 'tool_call',
|
||||||
ANNOTATIONS: 'annotations',
|
ANNOTATIONS: 'annotations',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const DEFAULT_AGENT_BOT_USERNAME = 'ai-bot';
|
||||||
|
export const AGENT_ANALYSIS_SUMMARY = 'summarize_channel';
|
||||||
|
|
|
||||||
108
app/products/agents/store/agents_config.test.ts
Normal file
108
app/products/agents/store/agents_config.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {act, renderHook} from '@testing-library/react-hooks';
|
||||||
|
|
||||||
|
import type {AgentsConfigState} from './agents_config';
|
||||||
|
|
||||||
|
describe('AgentsConfigStore', () => {
|
||||||
|
let getAgentsConfig: typeof import('./agents_config').getAgentsConfig;
|
||||||
|
let setAgentsConfig: typeof import('./agents_config').setAgentsConfig;
|
||||||
|
let observeAgentsConfig: typeof import('./agents_config').observeAgentsConfig;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
const store = require('./agents_config');
|
||||||
|
getAgentsConfig = store.getAgentsConfig;
|
||||||
|
setAgentsConfig = store.setAgentsConfig;
|
||||||
|
observeAgentsConfig = store.observeAgentsConfig;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAgentsConfig / setAgentsConfig', () => {
|
||||||
|
it('should return default config for a new server URL', () => {
|
||||||
|
const config = getAgentsConfig('server1');
|
||||||
|
expect(config).toEqual({pluginEnabled: false});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update config when setAgentsConfig is called', () => {
|
||||||
|
setAgentsConfig('server1', {pluginEnabled: true});
|
||||||
|
|
||||||
|
const config = getAgentsConfig('server1');
|
||||||
|
expect(config).toEqual({pluginEnabled: true});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge partial config updates', () => {
|
||||||
|
setAgentsConfig('server1', {pluginEnabled: true});
|
||||||
|
const configAfterFirst = getAgentsConfig('server1');
|
||||||
|
expect(configAfterFirst.pluginEnabled).toBe(true);
|
||||||
|
|
||||||
|
// Setting again with same partial should merge over existing values
|
||||||
|
setAgentsConfig('server1', {pluginEnabled: false});
|
||||||
|
const configAfterSecond = getAgentsConfig('server1');
|
||||||
|
expect(configAfterSecond.pluginEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain separate configs per server URL', () => {
|
||||||
|
setAgentsConfig('server1', {pluginEnabled: true});
|
||||||
|
setAgentsConfig('server2', {pluginEnabled: false});
|
||||||
|
|
||||||
|
expect(getAgentsConfig('server1')).toEqual({pluginEnabled: true});
|
||||||
|
expect(getAgentsConfig('server2')).toEqual({pluginEnabled: false});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('observeAgentsConfig', () => {
|
||||||
|
it('should emit default config on initial subscribe', () => {
|
||||||
|
const receivedConfigs: AgentsConfigState[] = [];
|
||||||
|
|
||||||
|
const subscription = observeAgentsConfig('server1').subscribe((config) => {
|
||||||
|
receivedConfigs.push(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(receivedConfigs).toHaveLength(1);
|
||||||
|
expect(receivedConfigs[0]).toEqual({pluginEnabled: false});
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit updated config after setAgentsConfig', () => {
|
||||||
|
const receivedConfigs: AgentsConfigState[] = [];
|
||||||
|
|
||||||
|
const subscription = observeAgentsConfig('server1').subscribe((config) => {
|
||||||
|
receivedConfigs.push(config);
|
||||||
|
});
|
||||||
|
|
||||||
|
setAgentsConfig('server1', {pluginEnabled: true});
|
||||||
|
|
||||||
|
expect(receivedConfigs).toHaveLength(2);
|
||||||
|
expect(receivedConfigs[1]).toEqual({pluginEnabled: true});
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hook tests use static imports to avoid React instance mismatch from jest.resetModules().
|
||||||
|
// Each test uses a unique server URL for isolation (each creates a fresh BehaviorSubject).
|
||||||
|
describe('useAgentsConfig', () => {
|
||||||
|
const {useAgentsConfig, setAgentsConfig} = require('./agents_config') as typeof import('./agents_config');
|
||||||
|
|
||||||
|
it('should return default config initially', () => {
|
||||||
|
const {result} = renderHook(() => useAgentsConfig('hook-test-default'));
|
||||||
|
expect(result.current).toEqual({pluginEnabled: false});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update when config changes', () => {
|
||||||
|
const {result} = renderHook(() => useAgentsConfig('hook-test-update'));
|
||||||
|
expect(result.current).toEqual({pluginEnabled: false});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setAgentsConfig('hook-test-update', {pluginEnabled: true});
|
||||||
|
});
|
||||||
|
expect(result.current).toEqual({pluginEnabled: true});
|
||||||
|
});
|
||||||
|
});
|
||||||
54
app/products/agents/store/agents_config.ts
Normal file
54
app/products/agents/store/agents_config.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {BehaviorSubject} from 'rxjs';
|
||||||
|
|
||||||
|
export type AgentsConfigState = {
|
||||||
|
pluginEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DefaultAgentsConfig: AgentsConfigState = {
|
||||||
|
pluginEnabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const agentsConfigSubjects: Dictionary<BehaviorSubject<AgentsConfigState>> = {};
|
||||||
|
|
||||||
|
const getAgentsConfigSubject = (serverUrl: string) => {
|
||||||
|
if (!agentsConfigSubjects[serverUrl]) {
|
||||||
|
agentsConfigSubjects[serverUrl] = new BehaviorSubject(DefaultAgentsConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
return agentsConfigSubjects[serverUrl];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAgentsConfig = (serverUrl: string) => {
|
||||||
|
return getAgentsConfigSubject(serverUrl).value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setAgentsConfig = (serverUrl: string, config: Partial<AgentsConfigState>) => {
|
||||||
|
const subject = getAgentsConfigSubject(serverUrl);
|
||||||
|
subject.next({...subject.value, ...config});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const observeAgentsConfig = (serverUrl: string) => {
|
||||||
|
return getAgentsConfigSubject(serverUrl).asObservable();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAgentsConfig = (serverUrl: string) => {
|
||||||
|
const [state, setState] = useState(DefaultAgentsConfig);
|
||||||
|
|
||||||
|
const agentsConfigSubject = getAgentsConfigSubject(serverUrl);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = agentsConfigSubject.subscribe((agentsConfig) => {
|
||||||
|
setState(agentsConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription?.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [agentsConfigSubject]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
47
app/products/agents/types/api.ts
Normal file
47
app/products/agents/types/api.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for channel analysis requests
|
||||||
|
*/
|
||||||
|
export type ChannelAnalysisOptions = {
|
||||||
|
since?: string;
|
||||||
|
until?: string;
|
||||||
|
days?: number;
|
||||||
|
prompt?: string;
|
||||||
|
unreads_only?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from channel analysis API
|
||||||
|
*/
|
||||||
|
export type ChannelAnalysisResponse = {
|
||||||
|
postid: string;
|
||||||
|
channelid: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent data structure
|
||||||
|
*/
|
||||||
|
export type Agent = {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
username: string;
|
||||||
|
service_type?: string;
|
||||||
|
service_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from agents list API
|
||||||
|
*/
|
||||||
|
export type AgentsResponse = {
|
||||||
|
agents: Agent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from agents status API
|
||||||
|
*/
|
||||||
|
export type AgentsStatusResponse = {
|
||||||
|
available: boolean;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
@ -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 {useAgentsConfig} from '@agents/store/agents_config';
|
||||||
import React, {useCallback, useEffect, useMemo} from 'react';
|
import React, {useCallback, useEffect, useMemo} from 'react';
|
||||||
import {useIntl} from 'react-intl';
|
import {useIntl} from 'react-intl';
|
||||||
import {Keyboard, Platform, Text, View} from 'react-native';
|
import {Keyboard, Platform, Text, View} from 'react-native';
|
||||||
|
|
@ -124,6 +125,7 @@ const ChannelHeader = ({
|
||||||
const serverUrl = useServerUrl();
|
const serverUrl = useServerUrl();
|
||||||
|
|
||||||
const callsConfig = getCallsConfig(serverUrl);
|
const callsConfig = getCallsConfig(serverUrl);
|
||||||
|
const {pluginEnabled: agentsEnabled} = useAgentsConfig(serverUrl);
|
||||||
|
|
||||||
// NOTE: callsEnabledInChannel will be true/false (not undefined) based on explicit state + the DefaultEnabled system setting
|
// NOTE: callsEnabledInChannel will be true/false (not undefined) based on explicit state + the DefaultEnabled system setting
|
||||||
// which ultimately comes from channel/index.tsx, and observeIsCallsEnabledInChannel
|
// which ultimately comes from channel/index.tsx, and observeIsCallsEnabledInChannel
|
||||||
|
|
@ -193,6 +195,9 @@ const ChannelHeader = ({
|
||||||
if (hasPlaybookRuns && !isDMorGM) {
|
if (hasPlaybookRuns && !isDMorGM) {
|
||||||
items += 1;
|
items += 1;
|
||||||
}
|
}
|
||||||
|
if (agentsEnabled) {
|
||||||
|
items += 1; // Ask Agents action (shown in all channel types)
|
||||||
|
}
|
||||||
let height = CHANNEL_ACTIONS_OPTIONS_HEIGHT + SEPARATOR_HEIGHT + MARGIN + (items * ITEM_HEIGHT);
|
let height = CHANNEL_ACTIONS_OPTIONS_HEIGHT + SEPARATOR_HEIGHT + MARGIN + (items * ITEM_HEIGHT);
|
||||||
if (Platform.OS === 'android') {
|
if (Platform.OS === 'android') {
|
||||||
height += BOTTOM_SHEET_ANDROID_OFFSET;
|
height += BOTTOM_SHEET_ANDROID_OFFSET;
|
||||||
|
|
@ -216,7 +221,7 @@ const ChannelHeader = ({
|
||||||
theme,
|
theme,
|
||||||
closeButtonId: 'close-channel-quick-actions',
|
closeButtonId: 'close-channel-quick-actions',
|
||||||
});
|
});
|
||||||
}, [isTablet, callsAvailable, isDMorGM, hasPlaybookRuns, theme, onTitlePress, channelId]);
|
}, [isTablet, callsAvailable, isDMorGM, hasPlaybookRuns, agentsEnabled, theme, onTitlePress, channelId]);
|
||||||
|
|
||||||
const openPlaybooksRuns = useCallback(() => {
|
const openPlaybooksRuns = useCallback(() => {
|
||||||
// If no active runs, create a new one instead
|
// If no active runs, create a new one instead
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ import ChannelQuickAction from './index';
|
||||||
|
|
||||||
import type {Database} from '@nozbe/watermelondb';
|
import type {Database} from '@nozbe/watermelondb';
|
||||||
|
|
||||||
|
jest.mock('@agents/store/agents_config', () => ({
|
||||||
|
useAgentsConfig: jest.fn(() => ({pluginEnabled: true})),
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock('@playbooks/components/channel_actions/playbook_runs_option', () => ({
|
jest.mock('@playbooks/components/channel_actions/playbook_runs_option', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: jest.fn(),
|
default: jest.fn(),
|
||||||
|
|
@ -62,4 +66,19 @@ describe('ChannelQuickAction', () => {
|
||||||
const {queryByTestId} = renderWithEverything(<ChannelQuickAction {...props}/>, {database});
|
const {queryByTestId} = renderWithEverything(<ChannelQuickAction {...props}/>, {database});
|
||||||
expect(queryByTestId('playbook-runs-option')).toBeNull();
|
expect(queryByTestId('playbook-runs-option')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows Ask Agents option in all channel types', () => {
|
||||||
|
const props = getBaseProps();
|
||||||
|
const {getByTestId} = renderWithEverything(<ChannelQuickAction {...props}/>, {database});
|
||||||
|
|
||||||
|
expect(getByTestId('channel.quick_actions.ask_agents')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Ask Agents option in DM/GM channels', () => {
|
||||||
|
const props = getBaseProps();
|
||||||
|
props.isDMorGM = true;
|
||||||
|
const {getByTestId} = renderWithEverything(<ChannelQuickAction {...props}/>, {database});
|
||||||
|
|
||||||
|
expect(getByTestId('channel.quick_actions.ask_agents')).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
// 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 {useAgentsConfig} from '@agents/store/agents_config';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {View} from 'react-native';
|
import {View} from 'react-native';
|
||||||
|
|
||||||
import ChannelActions from '@components/channel_actions';
|
import ChannelActions from '@components/channel_actions';
|
||||||
|
import AskAgentsOption from '@components/channel_actions/ask_agents_option';
|
||||||
import CopyChannelLinkOption from '@components/channel_actions/copy_channel_link_option';
|
import CopyChannelLinkOption from '@components/channel_actions/copy_channel_link_option';
|
||||||
import InfoBox from '@components/channel_actions/info_box';
|
import InfoBox from '@components/channel_actions/info_box';
|
||||||
import LeaveChannelLabel from '@components/channel_actions/leave_channel_label';
|
import LeaveChannelLabel from '@components/channel_actions/leave_channel_label';
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
import PlaybookRunsOption from '@playbooks/components/channel_actions/playbook_runs_option';
|
import PlaybookRunsOption from '@playbooks/components/channel_actions/playbook_runs_option';
|
||||||
import {dismissBottomSheet} from '@screens/navigation';
|
import {dismissBottomSheet} from '@screens/navigation';
|
||||||
|
|
@ -46,6 +49,8 @@ const ChannelQuickAction = ({
|
||||||
isDMorGM,
|
isDMorGM,
|
||||||
hasPlaybookRuns,
|
hasPlaybookRuns,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
|
const {pluginEnabled: agentsEnabled} = useAgentsConfig(serverUrl);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const styles = getStyleSheet(theme);
|
const styles = getStyleSheet(theme);
|
||||||
|
|
||||||
|
|
@ -76,6 +81,13 @@ const ChannelQuickAction = ({
|
||||||
showAsLabel={true}
|
showAsLabel={true}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
{agentsEnabled && (
|
||||||
|
<AskAgentsOption
|
||||||
|
channelId={channelId}
|
||||||
|
showAsLabel={true}
|
||||||
|
testID='channel.quick_actions.ask_agents'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<View style={styles.line}/>
|
<View style={styles.line}/>
|
||||||
<LeaveChannelLabel
|
<LeaveChannelLabel
|
||||||
channelId={channelId}
|
channelId={channelId}
|
||||||
|
|
|
||||||
|
|
@ -860,6 +860,7 @@ export async function dismissAllOverlays() {
|
||||||
|
|
||||||
type BottomSheetArgs = {
|
type BottomSheetArgs = {
|
||||||
closeButtonId: string;
|
closeButtonId: string;
|
||||||
|
enableDynamicSizing?: boolean;
|
||||||
initialSnapIndex?: number;
|
initialSnapIndex?: number;
|
||||||
footerComponent?: React.FC<BottomSheetFooterProps>;
|
footerComponent?: React.FC<BottomSheetFooterProps>;
|
||||||
renderContent: () => React.ReactNode;
|
renderContent: () => React.ReactNode;
|
||||||
|
|
@ -869,10 +870,11 @@ type BottomSheetArgs = {
|
||||||
scrollable?: boolean;
|
scrollable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bottomSheet({title, renderContent, footerComponent, snapPoints, initialSnapIndex = 1, theme, closeButtonId, scrollable = false}: BottomSheetArgs) {
|
export function bottomSheet({title, renderContent, footerComponent, snapPoints, initialSnapIndex = 1, theme, closeButtonId, scrollable = false, enableDynamicSizing}: BottomSheetArgs) {
|
||||||
if (isTablet()) {
|
if (isTablet()) {
|
||||||
showModal(Screens.BOTTOM_SHEET, title, {
|
showModal(Screens.BOTTOM_SHEET, title, {
|
||||||
closeButtonId,
|
closeButtonId,
|
||||||
|
enableDynamicSizing,
|
||||||
initialSnapIndex,
|
initialSnapIndex,
|
||||||
renderContent,
|
renderContent,
|
||||||
footerComponent,
|
footerComponent,
|
||||||
|
|
@ -881,6 +883,7 @@ export function bottomSheet({title, renderContent, footerComponent, snapPoints,
|
||||||
}, bottomSheetModalOptions(theme, closeButtonId));
|
}, bottomSheetModalOptions(theme, closeButtonId));
|
||||||
} else {
|
} else {
|
||||||
showModalOverCurrentContext(Screens.BOTTOM_SHEET, {
|
showModalOverCurrentContext(Screens.BOTTOM_SHEET, {
|
||||||
|
enableDynamicSizing,
|
||||||
initialSnapIndex,
|
initialSnapIndex,
|
||||||
renderContent,
|
renderContent,
|
||||||
footerComponent,
|
footerComponent,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,21 @@
|
||||||
"account.logout_from": "Log out of {serverName}",
|
"account.logout_from": "Log out of {serverName}",
|
||||||
"account.settings": "Settings",
|
"account.settings": "Settings",
|
||||||
"account.your_profile": "Your Profile",
|
"account.your_profile": "Your Profile",
|
||||||
|
"agents.channel_summary.ai_prompt_placeholder": "Ask AI about this channel",
|
||||||
|
"agents.channel_summary.ask_agents": "Ask Agents",
|
||||||
|
"agents.channel_summary.date_picker.submit": "Summarize",
|
||||||
|
"agents.channel_summary.date_picker.title": "Select date range",
|
||||||
|
"agents.channel_summary.error_title": "Unable to start summary",
|
||||||
|
"agents.channel_summary.option.14d": "Summarize last 14 days",
|
||||||
|
"agents.channel_summary.option.7d": "Summarize last 7 days",
|
||||||
|
"agents.channel_summary.option.custom": "Select date range to summarize",
|
||||||
|
"agents.channel_summary.option.unreads": "Summarize unreads",
|
||||||
|
"agents.channel_summary.range_error": "Select a valid date range",
|
||||||
|
"agents.channel_summary.selected_agent": "Selected Agent",
|
||||||
|
"agents.channel_summary.since": "Start",
|
||||||
|
"agents.channel_summary.since_placeholder": "Select date",
|
||||||
|
"agents.channel_summary.until": "End",
|
||||||
|
"agents.channel_summary.until_placeholder": "Select date",
|
||||||
"agents.citations.title": "Sources ({count})",
|
"agents.citations.title": "Sources ({count})",
|
||||||
"agents.controls.regenerate": "Regenerate",
|
"agents.controls.regenerate": "Regenerate",
|
||||||
"agents.controls.stop": "Stop Generating",
|
"agents.controls.stop": "Stop Generating",
|
||||||
|
|
@ -22,6 +37,8 @@
|
||||||
"agents.regenerate.confirm": "Regenerate",
|
"agents.regenerate.confirm": "Regenerate",
|
||||||
"agents.regenerate.confirm_message": "This will clear the current response and generate a new one. Continue?",
|
"agents.regenerate.confirm_message": "This will clear the current response and generate a new one. Continue?",
|
||||||
"agents.regenerate.confirm_title": "Regenerate Response",
|
"agents.regenerate.confirm_title": "Regenerate Response",
|
||||||
|
"agents.selector.no_agents": "No agents available",
|
||||||
|
"agents.selector.title": "Select Agent",
|
||||||
"agents.tool_call.approve": "Accept",
|
"agents.tool_call.approve": "Accept",
|
||||||
"agents.tool_call.pending_decisions": "{count, plural, =0 {All tools decided} one {# tool needs a decision} other {# tools need decisions}}",
|
"agents.tool_call.pending_decisions": "{count, plural, =0 {All tools decided} one {# tool needs a decision} other {# tools need decisions}}",
|
||||||
"agents.tool_call.processing": "Processing...",
|
"agents.tool_call.processing": "Processing...",
|
||||||
|
|
|
||||||
112
detox/e2e/test/products/agents/channel_summary.e2e.ts
Normal file
112
detox/e2e/test/products/agents/channel_summary.e2e.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
// *******************************************************************
|
||||||
|
// - [#] indicates a test step (e.g. # Go to a screen)
|
||||||
|
// - [*] indicates an assertion (e.g. * Check the title)
|
||||||
|
// - Use element testID when selecting an element. Create one if none.
|
||||||
|
// *******************************************************************
|
||||||
|
|
||||||
|
import {
|
||||||
|
Setup,
|
||||||
|
} from '@support/server_api';
|
||||||
|
import {
|
||||||
|
serverOneUrl,
|
||||||
|
siteOneUrl,
|
||||||
|
} from '@support/test_config';
|
||||||
|
import {
|
||||||
|
ChannelListScreen,
|
||||||
|
ChannelScreen,
|
||||||
|
HomeScreen,
|
||||||
|
LoginScreen,
|
||||||
|
ServerScreen,
|
||||||
|
} from '@support/ui/screen';
|
||||||
|
import {timeouts, wait} from '@support/utils';
|
||||||
|
import {expect} from 'detox';
|
||||||
|
|
||||||
|
describe('Agents - Channel Summary', () => {
|
||||||
|
const serverOneDisplayName = 'Server 1';
|
||||||
|
const channelsCategory = 'channels';
|
||||||
|
let testChannel: any;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const {channel, user} = await Setup.apiInit(siteOneUrl);
|
||||||
|
testChannel = channel;
|
||||||
|
|
||||||
|
// # Log in to server
|
||||||
|
await ServerScreen.connectToServer(serverOneUrl, serverOneDisplayName);
|
||||||
|
await LoginScreen.login(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// * Verify on channel list screen
|
||||||
|
await ChannelListScreen.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// # Log out
|
||||||
|
await HomeScreen.logout();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show Ask Agents option in public channel', async () => {
|
||||||
|
// # Open a channel screen
|
||||||
|
await ChannelScreen.open(channelsCategory, testChannel.name);
|
||||||
|
|
||||||
|
// # Open quick actions by tapping the quick actions button
|
||||||
|
await wait(timeouts.ONE_SEC);
|
||||||
|
await ChannelScreen.channelQuickActionsButton.tap();
|
||||||
|
|
||||||
|
// * Verify Ask Agents option is visible
|
||||||
|
await waitFor(element(by.id('channel.quick_actions.ask_agents'))).toBeVisible().withTimeout(timeouts.FOUR_SEC);
|
||||||
|
|
||||||
|
// # Close the bottom sheet by pressing back
|
||||||
|
await device.pressBack();
|
||||||
|
await ChannelScreen.back();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open summary sheet and show options', async () => {
|
||||||
|
// # Open a channel screen
|
||||||
|
await ChannelScreen.open(channelsCategory, testChannel.name);
|
||||||
|
|
||||||
|
// # Open quick actions by tapping the quick actions button
|
||||||
|
await wait(timeouts.ONE_SEC);
|
||||||
|
await ChannelScreen.channelQuickActionsButton.tap();
|
||||||
|
|
||||||
|
// # Wait for and tap Ask Agents option to open the summary sheet
|
||||||
|
await waitFor(element(by.id('channel.quick_actions.ask_agents'))).toBeVisible().withTimeout(timeouts.FOUR_SEC);
|
||||||
|
await element(by.id('channel.quick_actions.ask_agents')).tap();
|
||||||
|
|
||||||
|
// * Verify summary options are visible
|
||||||
|
await waitFor(element(by.id('agents.channel_summary.option.unreads'))).toBeVisible().withTimeout(timeouts.FOUR_SEC);
|
||||||
|
await expect(element(by.id('agents.channel_summary.option.7d'))).toBeVisible();
|
||||||
|
await expect(element(by.id('agents.channel_summary.option.custom'))).toBeVisible();
|
||||||
|
await expect(element(by.id('agents.channel_summary.agent_selector'))).toBeVisible();
|
||||||
|
|
||||||
|
// # Open Agent Selector panel
|
||||||
|
await element(by.id('agents.channel_summary.agent_selector')).tap();
|
||||||
|
|
||||||
|
// * Verify Agent Selector back button is visible
|
||||||
|
await waitFor(element(by.id('agents.selector.back'))).toBeVisible().withTimeout(timeouts.FOUR_SEC);
|
||||||
|
|
||||||
|
// # Go back from Agent Selector
|
||||||
|
await element(by.id('agents.selector.back')).tap();
|
||||||
|
|
||||||
|
// # Wait for main options to reappear and open Date Range Picker
|
||||||
|
await waitFor(element(by.id('agents.channel_summary.option.custom'))).toBeVisible().withTimeout(timeouts.FOUR_SEC);
|
||||||
|
await element(by.id('agents.channel_summary.option.custom')).tap();
|
||||||
|
|
||||||
|
// * Verify Date Picker UI elements
|
||||||
|
await waitFor(element(by.id('agents.channel_summary.date_picker.back'))).toBeVisible().withTimeout(timeouts.FOUR_SEC);
|
||||||
|
await expect(element(by.id('agents.channel_summary.date_from'))).toBeVisible();
|
||||||
|
|
||||||
|
// # Go back from Date Picker
|
||||||
|
await element(by.id('agents.channel_summary.date_picker.back')).tap();
|
||||||
|
|
||||||
|
// # Close the bottom sheet by pressing back
|
||||||
|
await wait(timeouts.ONE_SEC);
|
||||||
|
await device.pressBack();
|
||||||
|
|
||||||
|
// # Navigate back to channel list
|
||||||
|
await ChannelScreen.back();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue