diff --git a/CLAUDE-IOS-SIMULATOR.md b/CLAUDE-IOS-SIMULATOR.md new file mode 100644 index 000000000..705b1c427 --- /dev/null +++ b/CLAUDE-IOS-SIMULATOR.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index b44550798..56cf2a60a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,8 @@ When testing features that require mobile device interaction: ## 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 # Setup npm run pod-install # iOS CocoaPods (or pod-install-m1 for M1 Macs) diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 6e77e27fc..8238b21fe 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -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) => { try { const name = generateChannelNameFromDisplayName(displayName); diff --git a/app/actions/websocket/event.ts b/app/actions/websocket/event.ts index 53604db05..3bdc62ac7 100644 --- a/app/actions/websocket/event.ts +++ b/app/actions/websocket/event.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {checkIsAgentsPluginEnabled} from '@agents/actions/remote/agents_status'; import {handleAgentPostUpdate} from '@agents/actions/websocket'; 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_ENABLED: case WebsocketEvents.PLUGIN_DISABLED: - // Do nothing, this event doesn't need logic in the mobile app + checkIsAgentsPluginEnabled(serverUrl); break; // bookmarks diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 13b2a171e..ca8664927 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {checkIsAgentsPluginEnabled} from '@agents/actions/remote/agents_status'; + import {markChannelAsViewed} from '@actions/local/channel'; import {dataRetentionCleanup, expiredBoRPostCleanup} from '@actions/local/systems'; import {markChannelAsRead} from '@actions/remote/channel'; @@ -95,6 +97,8 @@ async function doReconnect(serverUrl: string, groupLabel?: BaseRequestGroupLabel loadConfigAndCalls(serverUrl, currentUserId, groupLabel); } + checkIsAgentsPluginEnabled(serverUrl); + await deferredAppEntryActions(serverUrl, lastFullSync, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, meData, initialTeamId, undefined, groupLabel); await setLastFullSync(operator, now); diff --git a/app/components/channel_actions/ask_agents_option/ask_agents_option.tsx b/app/components/channel_actions/ask_agents_option/ask_agents_option.tsx new file mode 100644 index 000000000..b5e387b3a --- /dev/null +++ b/app/components/channel_actions/ask_agents_option/ask_agents_option.tsx @@ -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 = () => ( + + ); + + 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 ( + + ); + } + + return ( + + ); +}; + +export default AskAgentsOption; diff --git a/app/components/channel_actions/ask_agents_option/index.ts b/app/components/channel_actions/ask_agents_option/index.ts new file mode 100644 index 000000000..582b0dccd --- /dev/null +++ b/app/components/channel_actions/ask_agents_option/index.ts @@ -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; + diff --git a/app/components/markdown/inline_entity_link/index.tsx b/app/components/markdown/inline_entity_link/index.tsx new file mode 100644 index 000000000..81d6bd762 --- /dev/null +++ b/app/components/markdown/inline_entity_link/index.tsx @@ -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 ( + + + + + + ); +}; + +export default InlineEntityLink; + diff --git a/app/components/markdown/markdown.tsx b/app/components/markdown/markdown.tsx index 8add4accc..bc87eb5f0 100644 --- a/app/components/markdown/markdown.tsx +++ b/app/components/markdown/markdown.tsx @@ -12,6 +12,7 @@ import CompassIcon from '@components/compass_icon'; import EditedIndicator from '@components/edited_indicator'; import Emoji from '@components/emoji'; import FormattedText from '@components/formatted_text'; +import {useServerUrl} from '@context/server'; import {logError} from '@utils/log'; import {computeTextStyle, getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -20,6 +21,7 @@ import {typography} from '@utils/typography'; import AtMention from './at_mention'; import ChannelMention from './channel_mention'; import Hashtag from './hashtag'; +import InlineEntityLink, {type InlineEntityType} from './inline_entity_link'; import MarkdownBlockQuote from './markdown_block_quote'; import MarkdownCodeBlock from './markdown_code_block'; import MarkdownImage from './markdown_image'; @@ -32,7 +34,7 @@ import MarkdownTable from './markdown_table'; import MarkdownTableCell, {type MarkdownTableCellProps} from './markdown_table_cell'; import MarkdownTableImage from './markdown_table_image'; 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 { @@ -116,6 +118,12 @@ const getExtraPropsForNode = (node: any) => { extraProps.isChecked = node.isChecked; } + if (node.type === 'inline_entity_link') { + extraProps.entityType = node.entityType; + extraProps.entityId = node.entityId; + extraProps.linkUrl = node.linkUrl; + } + return extraProps; }; @@ -178,6 +186,7 @@ const Markdown = ({ const blockStyles = useMemo(() => getMarkdownBlockStyles(theme), [theme]); const textStyles = useMemo(() => getMarkdownTextStyles(theme), [theme]); const managedConfig = useManagedConfig(); + const serverUrl = useServerUrl(); const renderText = useCallback(({context, literal}: MarkdownBaseRenderer) => { const selectable = (managedConfig.copyAndPasteProtection !== 'true') && context.includes('table_cell'); @@ -285,6 +294,19 @@ const Markdown = ({ ); }, [theme.centerChannelColor]); + const renderInlineEntityLink = useCallback(({entityType, entityId, linkUrl}: {entityType: InlineEntityType; entityId: string; linkUrl?: string}) => { + if (!entityType || !entityId) { + return null; + } + return ( + + ); + }, []); + const renderCodeBlock = useCallback((props: any) => { if (disableCodeBlock) { return null; @@ -617,6 +639,7 @@ const Markdown = ({ search_highlight: Renderer.forwardChildren, highlight_without_notification: Renderer.forwardChildren, checkbox: renderCheckbox, + inline_entity_link: renderInlineEntityLink, editedIndicator: renderEditedIndicator, maxNodesWarning: renderMaxNodesWarning, @@ -660,6 +683,7 @@ const Markdown = ({ renderTableRow, renderTableCell, renderCheckbox, + renderInlineEntityLink, renderEditedIndicator, renderMaxNodesWarning, maxNodes, @@ -676,6 +700,7 @@ const Markdown = ({ ast = addListItemIndices(ast); ast = pullOutImages(ast); ast = parseTaskLists(ast); + ast = processInlineEntities(ast, serverUrl); if (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; }; diff --git a/app/components/markdown/transform.test.ts b/app/components/markdown/transform.test.ts index bcecd4c97..c960fe051 100644 --- a/app/components/markdown/transform.test.ts +++ b/app/components/markdown/transform.test.ts @@ -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 diff --git a/app/components/markdown/transform.ts b/app/components/markdown/transform.ts index 20b4bfd8a..2fef81e48 100644 --- a/app/components/markdown/transform.ts +++ b/app/components/markdown/transform.ts @@ -2,10 +2,15 @@ // See LICENSE.txt for license information. 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 {safeDecodeURIComponent} from '@utils/url'; import type {HighlightWithoutNotificationKey, SearchPattern, UserMentionKey} from '@typings/global/markdown'; +import type {DeepLinkChannel, DeepLinkPermalink} from '@typings/launch'; /* eslint-disable no-underscore-dangle */ @@ -381,3 +386,115 @@ export function parseTaskLists(ast: Node) { 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; +} diff --git a/app/products/agents/actions/remote/agents.ts b/app/products/agents/actions/remote/agents.ts new file mode 100644 index 000000000..664eb467d --- /dev/null +++ b/app/products/agents/actions/remote/agents.ts @@ -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)}; + } +} diff --git a/app/products/agents/actions/remote/agents_status.test.ts b/app/products/agents/actions/remote/agents_status.test.ts new file mode 100644 index 000000000..24e1f5a55 --- /dev/null +++ b/app/products/agents/actions/remote/agents_status.test.ts @@ -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}); + }); +}); diff --git a/app/products/agents/actions/remote/agents_status.ts b/app/products/agents/actions/remote/agents_status.ts new file mode 100644 index 000000000..967983611 --- /dev/null +++ b/app/products/agents/actions/remote/agents_status.ts @@ -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}; + } +} diff --git a/app/products/agents/actions/remote/channel_summary.test.ts b/app/products/agents/actions/remote/channel_summary.test.ts new file mode 100644 index 000000000..b47755e3b --- /dev/null +++ b/app/products/agents/actions/remote/channel_summary.test.ts @@ -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'); + }); +}); + diff --git a/app/products/agents/actions/remote/channel_summary.ts b/app/products/agents/actions/remote/channel_summary.ts new file mode 100644 index 000000000..fbb97b7c4 --- /dev/null +++ b/app/products/agents/actions/remote/channel_summary.ts @@ -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)}; + } +} + diff --git a/app/products/agents/client/rest.ts b/app/products/agents/client/rest.ts index 1747ddb77..c40ad6543 100644 --- a/app/products/agents/client/rest.ts +++ b/app/products/agents/client/rest.ts @@ -1,11 +1,23 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {Agent, AgentsResponse, AgentsStatusResponse, ChannelAnalysisOptions, ChannelAnalysisResponse} from '@agents/types/api'; + +export type {Agent}; + export interface ClientAgentsMix { getAgentsRoute: () => string; + getAgents: () => Promise; stopGeneration: (postId: string) => Promise; regenerateResponse: (postId: string) => Promise; + doChannelAnalysis: ( + channelId: string, + analysisType: string, + botUsername: string, + options?: ChannelAnalysisOptions, + ) => Promise; submitToolApproval: (postId: string, acceptedToolIds: string[]) => Promise; + getAgentsStatus: () => Promise; } const ClientAgents = (superclass: any) => class extends superclass { @@ -13,6 +25,19 @@ const ClientAgents = (superclass: any) => class extends superclass { return '/plugins/mattermost-ai'; }; + getAgents = async (): Promise => { + 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) => { return this.doFetch( `${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 => { + 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[]) => { return this.doFetch( `${this.getAgentsRoute()}/post/${postId}/tool_call`, @@ -36,6 +84,13 @@ const ClientAgents = (superclass: any) => class extends superclass { }, ); }; + + getAgentsStatus = async (): Promise => { + return this.doFetch( + `${this.urlVersion}/agents/status`, + {method: 'get'}, + ); + }; }; export default ClientAgents; diff --git a/app/products/agents/components/channel_summary_sheet/agent_item.tsx b/app/products/agents/components/channel_summary_sheet/agent_item.tsx new file mode 100644 index 000000000..4f6f8a0cf --- /dev/null +++ b/app/products/agents/components/channel_summary_sheet/agent_item.tsx @@ -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 ( + + + {profileImageUrl ? ( + + ) : ( + + + + )} + + {agent.displayName || agent.username} + + + {currentAgentUsername === agent.username && ( + + )} + + ); +}); +AgentItem.displayName = 'AgentItem'; + +export default AgentItem; diff --git a/app/products/agents/components/channel_summary_sheet/agent_selector_panel.tsx b/app/products/agents/components/channel_summary_sheet/agent_selector_panel.tsx new file mode 100644 index 000000000..b9d7e2f6b --- /dev/null +++ b/app/products/agents/components/channel_summary_sheet/agent_selector_panel.tsx @@ -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 = {}; + 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) => { + const profileImageUrl = agent.id ? agentImageUrls[agent.id] : undefined; + return ( + + ); + }, [agentImageUrls, currentAgentUsername, onSelectAgent]); + + const renderSeparator = useCallback(() => ( + + ), [styles.divider]); + + const renderEmpty = useCallback(() => ( + + + + ), [styles.emptyContainer, styles.emptyText]); + + const renderHeader = useCallback(() => ( + + + + + + + ), [onBack, styles.backButton, styles.headerRow, styles.headerTitle, theme.centerChannelColor]); + + return ( + + + + ); +}; + +export default AgentSelectorPanel; diff --git a/app/products/agents/components/channel_summary_sheet/date_range_picker.tsx b/app/products/agents/components/channel_summary_sheet/date_range_picker.tsx new file mode 100644 index 000000000..51a01b1f1 --- /dev/null +++ b/app/products/agents/components/channel_summary_sheet/date_range_picker.tsx @@ -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; + isActive: boolean; +}; + +const DateInputField = ({label, date, placeholder, onPress, testID, styles, isActive}: DateInputFieldProps) => ( + <> + + + {date ? ( + + ) : ( + + )} + + +); + +const DateRangePicker = ({onSubmit, onCancel}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + + const [since, setSince] = useState(() => { + const d = new Date(); + d.setDate(d.getDate() - 7); + return d; + }); + const [until, setUntil] = useState(() => new Date()); + const [pickerTarget, setPickerTarget] = useState(); + 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 ( + + {/* Header with back button */} + + + + + + + + {/* Start Date */} + + + {/* End Date */} + + + {showError && ( + + )} + + {/* Submit Button */} + +