Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/components/aiAssistModal/aiAssistModal.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ export default EStyleSheet.create({
color: '$iconColor',
} as TextStyle,

// Colour only: a fontSize here would override the Icon's `size` prop, since the vector-icon
// sets merge the incoming style AFTER their own { fontSize: size }.
actionCardChevron: {
color: '$iconColor',
} as TextStyle,

actionCostSelected: {
color: '$white',
opacity: 0.8,
Expand Down
78 changes: 77 additions & 1 deletion src/components/aiAssistModal/aiAssistModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ import {
import ActionSheet, { SheetManager, SheetProps } from 'react-native-actions-sheet';
import { useIntl } from 'react-intl';
import { useQuery } from '@tanstack/react-query';
import { getPointsQueryOptions, getAiAssistPriceQueryOptions, useAiAssist } from '@ecency/sdk';
import {
getPointsQueryOptions,
getAiAssistPriceQueryOptions,
getAiGeneratePriceQueryOptions,
useAiAssist,
} from '@ecency/sdk';
import Clipboard from '@react-native-clipboard/clipboard';
import { useAuth } from '../../hooks';
import { SheetNames } from '../../navigation/sheets';
import { Icon } from '../icon';
import styles from './aiAssistModal.styles';

const ACTIONS = [
Expand All @@ -28,6 +34,9 @@ type AiAssistAction = (typeof ACTIONS)[number];

const MAX_INPUT = 10000;

// The generator screen opens on 4:3 at 1x power, so that row is the honest "from" figure.
const IMAGE_PRICE_RATIO = '4:3';

const getMinInput = (action: AiAssistAction | null) => {
if (!action) return 50;
if (action === 'improve' || action === 'check_grammar') return 50;
Expand Down Expand Up @@ -75,6 +84,14 @@ export const AiAssistModal = ({ payload }: SheetProps<SheetNames.AI_ASSIST>) =>
enabled: !!code,
});

// Image generation is a different, metered endpoint: priced per aspect ratio with no free
// tier, so it cannot come out of the assist price list. Only fetched when a caller actually
// offers the entry point, so surfaces without it make no extra request.
const imagePricesQuery = useQuery({
...getAiGeneratePriceQueryOptions(code || ''),
enabled: !!code && !!payload?.onGenerateImage,
});

// AI assist mutation via SDK
const assistMutation = useAiAssist(username, code);

Expand All @@ -92,6 +109,17 @@ export const AiAssistModal = ({ payload }: SheetProps<SheetNames.AI_ASSIST>) =>
return parseFloat(String(pointsQuery.data.points).replace(/,/g, ''));
}, [pointsQuery.data]);

// A starting price only: the real charge is base cost x power multiplier, and the multiplier is
// picked on the generator screen. No fallback constant here, or the screen's own default would
// gain a second, silently diverging source of truth.
const imageFromCost = useMemo(() => {
const prices = imagePricesQuery.data?.prices;
if (!prices?.length) {
return null;
}
return prices.find((p) => p.aspect_ratio === IMAGE_PRICE_RATIO)?.cost ?? prices[0].cost;
}, [imagePricesQuery.data]);

const isInsufficientBalance = useMemo(() => {
if (isFree) return false;
if (selectedPrice) return balance < selectedPrice.cost;
Expand Down Expand Up @@ -235,6 +263,18 @@ export const AiAssistModal = ({ payload }: SheetProps<SheetNames.AI_ASSIST>) =>
setSelectedAction(null);
}, []);

const _handleGenerateImage = useCallback(async () => {
const _onGenerateImage = payload?.onGenerateImage;
if (!_onGenerateImage) {
return;
}
// Sheets render inside a modal, so this one has to be gone before the caller navigates or
// the pushed screen comes up behind it. The caller closes its OWN host surface, which is
// what flushes a composer draft; doing it from here would skip that.
await SheetManager.hide(SheetNames.AI_ASSIST);
await _onGenerateImage();
}, [payload]);

const _renderActionCard = (action: AiAssistAction) => {
const isSelected = selectedAction === action;
const price = pricesQuery.data?.find((p) => p.action === action);
Expand Down Expand Up @@ -269,6 +309,39 @@ export const AiAssistModal = ({ payload }: SheetProps<SheetNames.AI_ASSIST>) =>
);
};

// Deliberately NOT a member of ACTIONS: image generation is priced differently and navigates
// away instead of returning text to apply, so folding it into the union would drag it through
// selectedAction / minInput / canSubmit and post an action the assist endpoint does not know.
const _renderGenerateImageCard = () => (
<TouchableOpacity style={styles.actionCard} onPress={_handleGenerateImage} activeOpacity={0.7}>
<View style={styles.actionCardContent}>
<Text style={styles.actionName}>
{intl.formatMessage({ id: 'ai_assist.action_generate_image' })}
</Text>
<Text style={styles.actionDesc}>
{intl.formatMessage({ id: 'ai_assist.action_generate_image_desc' })}
</Text>
</View>
<View style={styles.costRow}>
{imageFromCost !== null && (
<Text style={styles.actionCost}>
{intl.formatMessage(
{ id: 'ai_assist.cost_from' },
{ cost: imageFromCost, unit: intl.formatMessage({ id: 'ai_assist.points_unit' }) },
)}
</Text>
)}
{/* The other cards select in place; this one leaves the sheet, so it says so. */}
<Icon
iconType="MaterialCommunityIcons"
name="chevron-right"
size={20}
style={styles.actionCardChevron}
/>
</View>
</TouchableOpacity>
);

const _renderResultView = () => {
if (!result) return null;

Expand Down Expand Up @@ -399,6 +472,9 @@ export const AiAssistModal = ({ payload }: SheetProps<SheetNames.AI_ASSIST>) =>
) : (
availableActions.map((action) => _renderActionCard(action))
)}
{/* Outside the assist-price gate on purpose: this card prices itself, so it stays
reachable while the assist prices are slow or failing. */}
{!!payload?.onGenerateImage && _renderGenerateImageCard()}

{/* Text input */}
{selectedAction && (
Expand Down
118 changes: 96 additions & 22 deletions src/components/quickPostModal/quickPostModalContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
UserAvatar,
} from '..';
import { delay } from '../../utils/editor';
import { appendDictatedText } from '../../utils/dictationInsert';
import { hasClipboardImage as detectClipboardImage } from '../../utils/clipboard';
import { deleteReplyCacheEntry, updateReplyCache } from '../../redux/actions/cacheActions';
import { default as ROUTES } from '../../constants/routeNames';
Expand Down Expand Up @@ -513,7 +514,7 @@ export const QuickPostModalContent = forwardRef(
RootNavigation.navigate({
name: ROUTES.SCREENS.AI_IMAGE_GENERATOR,
params: {
suggestedPrompt: commentValue?.trim() || undefined,
suggestedPrompt: commentValueRef.current?.trim() || undefined,
onInsert: (url: string) => {
_handleMediaInsert([
{
Expand All @@ -533,17 +534,77 @@ export const QuickPostModalContent = forwardRef(
[_addQuickCommentIntoCache],
);

// Dictation appends: see appendDictatedText for why the end of the body is the only position
// that is always right here. Reads commentValueRef rather than the commentValue state, since
// the sheet stays open and fires once per recorded segment, so a captured state value would
// be stale from the second segment on.
const _handleDictationResult = useCallback(
(text: string) => {
const body = commentValueRef.current || '';
const next = appendDictatedText(body, text);
if (next === body) {
return;
}

// The collapsed caret has to travel with the text. Android's updateExtraData preserves
// the caret's DISTANCE FROM THE END across a text-only update, so after an append it
// lands inside the segment just added and the next keystroke splits it.
const caret = next.length;

// Same order as the AI assist onApply: cancel first, or the pending 500ms callback fires
// with the pre-dictation body and overwrites the draft cache.
_deboucedCacheUpdate.cancel();
commentValueRef.current = next;
setCommentValue(next);
inputRef.current?.setNativeProps({ text: next, selection: { start: caret, end: caret } });
_addQuickCommentIntoCache(next);
},
[_deboucedCacheUpdate, _addQuickCommentIntoCache],
);

// A sheet payload is frozen at show time, so everything handed to one has to be reached
// through a ref or the sheet keeps calling the handler that existed when it opened. Both of
// these write the draft cache through closures over mediaUrls / videoEmbedUrl / videoThumbUrl,
// and that write replaces the whole entry, so a stale one drops media added in the meantime.
// Dictation needs it most, since its sheet stays open and fires once per recorded segment.
const _dictationResultRef = useRef(_handleDictationResult);
const _aiImageBtnRef = useRef(_handleAiImageBtn);
useEffect(() => {
_dictationResultRef.current = _handleDictationResult;
_aiImageBtnRef.current = _handleAiImageBtn;
});

const _handleDictationBtn = () => {
// Dismissed for room, not for focus: the recorder needs the vertical space, and nothing is
// typed while speaking. The sheet library dismisses the keyboard on hide regardless.
Keyboard.dismiss();
SheetManager.show(SheetNames.DICTATION, {
payload: {
onInsert: (text: string) => _dictationResultRef.current(text),
},
});
};

const _handleAiAssistBtn = () => {
SheetManager.show(SheetNames.AI_ASSIST, {
payload: {
text: commentValueRef.current,
supportedActions: ['improve', 'check_grammar', 'summarize'],
// AI image generation lives in this sheet now rather than its own toolbar icon.
onGenerateImage: () => _aiImageBtnRef.current(),
onApply: (output: string, _action: string) => {
// Cancel any pending debounced cache update to prevent stale overwrite
_deboucedCacheUpdate.cancel();
commentValueRef.current = output;
setCommentValue(output);
inputRef.current?.setNativeProps({ text: output });
// Caret to the end, for the same reason as the dictation insert above: a text-only
// update keeps the caret's distance from the end, which is meaningless once the
// whole body has been replaced by different text.
const caret = output.length;
inputRef.current?.setNativeProps({
text: output,
selection: { start: caret, end: caret },
});
_addQuickCommentIntoCache(output);
},
},
Expand Down Expand Up @@ -712,7 +773,27 @@ export const QuickPostModalContent = forwardRef(
);
};

// Content buttons first, then the AI pair, then expand: expand leaves this composer for the
// full editor, so it belongs at the end rather than interrupting the compose actions. The row
// is a fixed-width non-wrapping flex row, which is why AI image generation moved into the AI
// assist sheet to make room for dictation rather than taking a sixth slot.
const _renderExpandBtn = () => {
// Dictation sits with the other ways of getting content in, which puts it next to the
// video button in a wave and next to the image button in a reply. Same element either
// way, so the two placements cannot drift apart.
const _dictationBtn = (
<IconButton
iconType="MaterialCommunityIcons"
name="microphone-outline"
onPress={_handleDictationBtn}
size={22}
color={EStyleSheet.value('$primaryBlack')}
badgeCount="AI"
badgeStyle={styles.aiBadge}
badgeTextStyle={styles.aiBadgeText}
/>
);

return (
<View style={styles.toolbarContainer}>
<IconButton
Expand All @@ -723,16 +804,7 @@ export const QuickPostModalContent = forwardRef(
size={24}
color={EStyleSheet.value('$primaryBlack')}
/>
{mode !== 'wave' && canCommentToCommunity && (
<IconButton
iconType="MaterialCommunityIcons"
name="arrow-expand"
onPress={_handleExpandBtn}
size={24}
color={EStyleSheet.value('$primaryBlack')}
/>
)}
{mode === 'wave' && (
{mode === 'wave' ? (
<>
<IconButton
iconType="MaterialCommunityIcons"
Expand All @@ -742,6 +814,7 @@ export const QuickPostModalContent = forwardRef(
color={EStyleSheet.value(videoEmbedUrl ? '$primaryBlue' : '$primaryBlack')}
disabled={!!videoEmbedUrl || isVideoUploading}
/>
{_dictationBtn}
<IconButton
iconType="SimpleLineIcons"
style={!!pollDraft && styles.iconBottomBar}
Expand All @@ -751,17 +824,9 @@ export const QuickPostModalContent = forwardRef(
color={EStyleSheet.value('$primaryBlack')}
/>
</>
) : (
_dictationBtn
)}
<IconButton
iconType="MaterialsIcons"
name="image-outline"
onPress={_handleAiImageBtn}
size={24}
color={EStyleSheet.value('$primaryBlack')}
badgeCount="AI"
badgeStyle={styles.aiBadge}
badgeTextStyle={styles.aiBadgeText}
/>
<IconButton
iconType="MaterialCommunityIcons"
name="creation"
Expand All @@ -772,6 +837,15 @@ export const QuickPostModalContent = forwardRef(
badgeStyle={styles.aiBadge}
badgeTextStyle={styles.aiBadgeText}
/>
{mode !== 'wave' && canCommentToCommunity && (
<IconButton
iconType="MaterialCommunityIcons"
name="arrow-expand"
onPress={_handleExpandBtn}
size={24}
color={EStyleSheet.value('$primaryBlack')}
/>
)}
</View>
);
};
Expand Down
3 changes: 3 additions & 0 deletions src/config/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,9 @@
"action_summarize_desc": "Create a concise summary",
"action_check_grammar": "Check Grammar",
"action_check_grammar_desc": "Fix grammar and spelling issues",
"action_generate_image": "Generate Image",
"action_generate_image_desc": "Create an AI image for your post",
"cost_from": "from {cost} {unit}",
"text_label": "Your text",
"text_placeholder": "Paste or type your content here...",
"min_chars": "Minimum {count} characters required",
Expand Down
4 changes: 4 additions & 0 deletions src/navigation/sheets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,10 @@ declare module 'react-native-actions-sheet' {
text: string;
onApply?: (output: string, action: string) => void;
supportedActions?: string[];
// Optional "leave the sheet and generate an image" entry. Only callers that can host
// the generator screen pass it, and the sheet renders the card only when they do, so
// surfaces without a place to put the result are unaffected.
onGenerateImage?: () => void | Promise<void>;
};
}>;
dictation: SheetDefinition<{
Expand Down
34 changes: 34 additions & 0 deletions src/utils/dictationInsert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { appendDictatedText } from './dictationInsert';

describe('appendDictatedText', () => {
it('uses the transcript as-is when the body is empty', () => {
expect(appendDictatedText('', 'hello there')).toBe('hello there');
});

it('separates a new segment from an existing body', () => {
expect(appendDictatedText('hello', 'there')).toBe('hello there');
});

it('does not double a space the body already ends with', () => {
expect(appendDictatedText('hello ', 'there')).toBe('hello there');
});

it('does not add a space after a trailing newline', () => {
expect(appendDictatedText('hello\n', 'there')).toBe('hello\nthere');
});

it('trims the transcript before appending', () => {
expect(appendDictatedText('hello', ' there ')).toBe('hello there');
});

it('leaves the body untouched for a blank transcript', () => {
expect(appendDictatedText('hello', ' ')).toBe('hello');
expect(appendDictatedText('hello', '')).toBe('hello');
});

it('keeps appending across several segments', () => {
const first = appendDictatedText('', 'one');
const second = appendDictatedText(first, 'two');
expect(appendDictatedText(second, 'three')).toBe('one two three');
});
});
Loading
Loading