Skip to content

Commit 7b58118

Browse files
authored
Merge pull request #3532 from ecency/feat/quick-post-composer-toolbar
Composer: dictation in Waves and comment replies, AI image inside AI assist, expand last
2 parents e236f88 + bd85677 commit 7b58118

7 files changed

Lines changed: 246 additions & 23 deletions

File tree

src/components/aiAssistModal/aiAssistModal.styles.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ export default EStyleSheet.create({
9494
color: '$iconColor',
9595
} as TextStyle,
9696

97+
// Colour only: a fontSize here would override the Icon's `size` prop, since the vector-icon
98+
// sets merge the incoming style AFTER their own { fontSize: size }.
99+
actionCardChevron: {
100+
color: '$iconColor',
101+
} as TextStyle,
102+
97103
actionCostSelected: {
98104
color: '$white',
99105
opacity: 0.8,

src/components/aiAssistModal/aiAssistModal.tsx

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,16 @@ import {
1111
import ActionSheet, { SheetManager, SheetProps } from 'react-native-actions-sheet';
1212
import { useIntl } from 'react-intl';
1313
import { useQuery } from '@tanstack/react-query';
14-
import { getPointsQueryOptions, getAiAssistPriceQueryOptions, useAiAssist } from '@ecency/sdk';
14+
import {
15+
getPointsQueryOptions,
16+
getAiAssistPriceQueryOptions,
17+
getAiGeneratePriceQueryOptions,
18+
useAiAssist,
19+
} from '@ecency/sdk';
1520
import Clipboard from '@react-native-clipboard/clipboard';
1621
import { useAuth } from '../../hooks';
1722
import { SheetNames } from '../../navigation/sheets';
23+
import { Icon } from '../icon';
1824
import styles from './aiAssistModal.styles';
1925

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

2935
const MAX_INPUT = 10000;
3036

37+
// The generator screen opens on 4:3 at 1x power, so that row is the honest "from" figure.
38+
const IMAGE_PRICE_RATIO = '4:3';
39+
3140
const getMinInput = (action: AiAssistAction | null) => {
3241
if (!action) return 50;
3342
if (action === 'improve' || action === 'check_grammar') return 50;
@@ -75,6 +84,14 @@ export const AiAssistModal = ({ payload }: SheetProps<SheetNames.AI_ASSIST>) =>
7584
enabled: !!code,
7685
});
7786

87+
// Image generation is a different, metered endpoint: priced per aspect ratio with no free
88+
// tier, so it cannot come out of the assist price list. Only fetched when a caller actually
89+
// offers the entry point, so surfaces without it make no extra request.
90+
const imagePricesQuery = useQuery({
91+
...getAiGeneratePriceQueryOptions(code || ''),
92+
enabled: !!code && !!payload?.onGenerateImage,
93+
});
94+
7895
// AI assist mutation via SDK
7996
const assistMutation = useAiAssist(username, code);
8097

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

112+
// A starting price only: the real charge is base cost x power multiplier, and the multiplier is
113+
// picked on the generator screen. No fallback constant here, or the screen's own default would
114+
// gain a second, silently diverging source of truth.
115+
const imageFromCost = useMemo(() => {
116+
const prices = imagePricesQuery.data?.prices;
117+
if (!prices?.length) {
118+
return null;
119+
}
120+
return prices.find((p) => p.aspect_ratio === IMAGE_PRICE_RATIO)?.cost ?? prices[0].cost;
121+
}, [imagePricesQuery.data]);
122+
95123
const isInsufficientBalance = useMemo(() => {
96124
if (isFree) return false;
97125
if (selectedPrice) return balance < selectedPrice.cost;
@@ -235,6 +263,18 @@ export const AiAssistModal = ({ payload }: SheetProps<SheetNames.AI_ASSIST>) =>
235263
setSelectedAction(null);
236264
}, []);
237265

266+
const _handleGenerateImage = useCallback(async () => {
267+
const _onGenerateImage = payload?.onGenerateImage;
268+
if (!_onGenerateImage) {
269+
return;
270+
}
271+
// Sheets render inside a modal, so this one has to be gone before the caller navigates or
272+
// the pushed screen comes up behind it. The caller closes its OWN host surface, which is
273+
// what flushes a composer draft; doing it from here would skip that.
274+
await SheetManager.hide(SheetNames.AI_ASSIST);
275+
await _onGenerateImage();
276+
}, [payload]);
277+
238278
const _renderActionCard = (action: AiAssistAction) => {
239279
const isSelected = selectedAction === action;
240280
const price = pricesQuery.data?.find((p) => p.action === action);
@@ -269,6 +309,39 @@ export const AiAssistModal = ({ payload }: SheetProps<SheetNames.AI_ASSIST>) =>
269309
);
270310
};
271311

312+
// Deliberately NOT a member of ACTIONS: image generation is priced differently and navigates
313+
// away instead of returning text to apply, so folding it into the union would drag it through
314+
// selectedAction / minInput / canSubmit and post an action the assist endpoint does not know.
315+
const _renderGenerateImageCard = () => (
316+
<TouchableOpacity style={styles.actionCard} onPress={_handleGenerateImage} activeOpacity={0.7}>
317+
<View style={styles.actionCardContent}>
318+
<Text style={styles.actionName}>
319+
{intl.formatMessage({ id: 'ai_assist.action_generate_image' })}
320+
</Text>
321+
<Text style={styles.actionDesc}>
322+
{intl.formatMessage({ id: 'ai_assist.action_generate_image_desc' })}
323+
</Text>
324+
</View>
325+
<View style={styles.costRow}>
326+
{imageFromCost !== null && (
327+
<Text style={styles.actionCost}>
328+
{intl.formatMessage(
329+
{ id: 'ai_assist.cost_from' },
330+
{ cost: imageFromCost, unit: intl.formatMessage({ id: 'ai_assist.points_unit' }) },
331+
)}
332+
</Text>
333+
)}
334+
{/* The other cards select in place; this one leaves the sheet, so it says so. */}
335+
<Icon
336+
iconType="MaterialCommunityIcons"
337+
name="chevron-right"
338+
size={20}
339+
style={styles.actionCardChevron}
340+
/>
341+
</View>
342+
</TouchableOpacity>
343+
);
344+
272345
const _renderResultView = () => {
273346
if (!result) return null;
274347

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

403479
{/* Text input */}
404480
{selectedAction && (

src/components/quickPostModal/quickPostModalContent.tsx

Lines changed: 96 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
UserAvatar,
4343
} from '..';
4444
import { delay } from '../../utils/editor';
45+
import { appendDictatedText } from '../../utils/dictationInsert';
4546
import { hasClipboardImage as detectClipboardImage } from '../../utils/clipboard';
4647
import { deleteReplyCacheEntry, updateReplyCache } from '../../redux/actions/cacheActions';
4748
import { default as ROUTES } from '../../constants/routeNames';
@@ -513,7 +514,7 @@ export const QuickPostModalContent = forwardRef(
513514
RootNavigation.navigate({
514515
name: ROUTES.SCREENS.AI_IMAGE_GENERATOR,
515516
params: {
516-
suggestedPrompt: commentValue?.trim() || undefined,
517+
suggestedPrompt: commentValueRef.current?.trim() || undefined,
517518
onInsert: (url: string) => {
518519
_handleMediaInsert([
519520
{
@@ -533,17 +534,77 @@ export const QuickPostModalContent = forwardRef(
533534
[_addQuickCommentIntoCache],
534535
);
535536

537+
// Dictation appends: see appendDictatedText for why the end of the body is the only position
538+
// that is always right here. Reads commentValueRef rather than the commentValue state, since
539+
// the sheet stays open and fires once per recorded segment, so a captured state value would
540+
// be stale from the second segment on.
541+
const _handleDictationResult = useCallback(
542+
(text: string) => {
543+
const body = commentValueRef.current || '';
544+
const next = appendDictatedText(body, text);
545+
if (next === body) {
546+
return;
547+
}
548+
549+
// The collapsed caret has to travel with the text. Android's updateExtraData preserves
550+
// the caret's DISTANCE FROM THE END across a text-only update, so after an append it
551+
// lands inside the segment just added and the next keystroke splits it.
552+
const caret = next.length;
553+
554+
// Same order as the AI assist onApply: cancel first, or the pending 500ms callback fires
555+
// with the pre-dictation body and overwrites the draft cache.
556+
_deboucedCacheUpdate.cancel();
557+
commentValueRef.current = next;
558+
setCommentValue(next);
559+
inputRef.current?.setNativeProps({ text: next, selection: { start: caret, end: caret } });
560+
_addQuickCommentIntoCache(next);
561+
},
562+
[_deboucedCacheUpdate, _addQuickCommentIntoCache],
563+
);
564+
565+
// A sheet payload is frozen at show time, so everything handed to one has to be reached
566+
// through a ref or the sheet keeps calling the handler that existed when it opened. Both of
567+
// these write the draft cache through closures over mediaUrls / videoEmbedUrl / videoThumbUrl,
568+
// and that write replaces the whole entry, so a stale one drops media added in the meantime.
569+
// Dictation needs it most, since its sheet stays open and fires once per recorded segment.
570+
const _dictationResultRef = useRef(_handleDictationResult);
571+
const _aiImageBtnRef = useRef(_handleAiImageBtn);
572+
useEffect(() => {
573+
_dictationResultRef.current = _handleDictationResult;
574+
_aiImageBtnRef.current = _handleAiImageBtn;
575+
});
576+
577+
const _handleDictationBtn = () => {
578+
// Dismissed for room, not for focus: the recorder needs the vertical space, and nothing is
579+
// typed while speaking. The sheet library dismisses the keyboard on hide regardless.
580+
Keyboard.dismiss();
581+
SheetManager.show(SheetNames.DICTATION, {
582+
payload: {
583+
onInsert: (text: string) => _dictationResultRef.current(text),
584+
},
585+
});
586+
};
587+
536588
const _handleAiAssistBtn = () => {
537589
SheetManager.show(SheetNames.AI_ASSIST, {
538590
payload: {
539591
text: commentValueRef.current,
540592
supportedActions: ['improve', 'check_grammar', 'summarize'],
593+
// AI image generation lives in this sheet now rather than its own toolbar icon.
594+
onGenerateImage: () => _aiImageBtnRef.current(),
541595
onApply: (output: string, _action: string) => {
542596
// Cancel any pending debounced cache update to prevent stale overwrite
543597
_deboucedCacheUpdate.cancel();
544598
commentValueRef.current = output;
545599
setCommentValue(output);
546-
inputRef.current?.setNativeProps({ text: output });
600+
// Caret to the end, for the same reason as the dictation insert above: a text-only
601+
// update keeps the caret's distance from the end, which is meaningless once the
602+
// whole body has been replaced by different text.
603+
const caret = output.length;
604+
inputRef.current?.setNativeProps({
605+
text: output,
606+
selection: { start: caret, end: caret },
607+
});
547608
_addQuickCommentIntoCache(output);
548609
},
549610
},
@@ -712,7 +773,27 @@ export const QuickPostModalContent = forwardRef(
712773
);
713774
};
714775

776+
// Content buttons first, then the AI pair, then expand: expand leaves this composer for the
777+
// full editor, so it belongs at the end rather than interrupting the compose actions. The row
778+
// is a fixed-width non-wrapping flex row, which is why AI image generation moved into the AI
779+
// assist sheet to make room for dictation rather than taking a sixth slot.
715780
const _renderExpandBtn = () => {
781+
// Dictation sits with the other ways of getting content in, which puts it next to the
782+
// video button in a wave and next to the image button in a reply. Same element either
783+
// way, so the two placements cannot drift apart.
784+
const _dictationBtn = (
785+
<IconButton
786+
iconType="MaterialCommunityIcons"
787+
name="microphone-outline"
788+
onPress={_handleDictationBtn}
789+
size={22}
790+
color={EStyleSheet.value('$primaryBlack')}
791+
badgeCount="AI"
792+
badgeStyle={styles.aiBadge}
793+
badgeTextStyle={styles.aiBadgeText}
794+
/>
795+
);
796+
716797
return (
717798
<View style={styles.toolbarContainer}>
718799
<IconButton
@@ -723,16 +804,7 @@ export const QuickPostModalContent = forwardRef(
723804
size={24}
724805
color={EStyleSheet.value('$primaryBlack')}
725806
/>
726-
{mode !== 'wave' && canCommentToCommunity && (
727-
<IconButton
728-
iconType="MaterialCommunityIcons"
729-
name="arrow-expand"
730-
onPress={_handleExpandBtn}
731-
size={24}
732-
color={EStyleSheet.value('$primaryBlack')}
733-
/>
734-
)}
735-
{mode === 'wave' && (
807+
{mode === 'wave' ? (
736808
<>
737809
<IconButton
738810
iconType="MaterialCommunityIcons"
@@ -742,6 +814,7 @@ export const QuickPostModalContent = forwardRef(
742814
color={EStyleSheet.value(videoEmbedUrl ? '$primaryBlue' : '$primaryBlack')}
743815
disabled={!!videoEmbedUrl || isVideoUploading}
744816
/>
817+
{_dictationBtn}
745818
<IconButton
746819
iconType="SimpleLineIcons"
747820
style={!!pollDraft && styles.iconBottomBar}
@@ -751,17 +824,9 @@ export const QuickPostModalContent = forwardRef(
751824
color={EStyleSheet.value('$primaryBlack')}
752825
/>
753826
</>
827+
) : (
828+
_dictationBtn
754829
)}
755-
<IconButton
756-
iconType="MaterialsIcons"
757-
name="image-outline"
758-
onPress={_handleAiImageBtn}
759-
size={24}
760-
color={EStyleSheet.value('$primaryBlack')}
761-
badgeCount="AI"
762-
badgeStyle={styles.aiBadge}
763-
badgeTextStyle={styles.aiBadgeText}
764-
/>
765830
<IconButton
766831
iconType="MaterialCommunityIcons"
767832
name="creation"
@@ -772,6 +837,15 @@ export const QuickPostModalContent = forwardRef(
772837
badgeStyle={styles.aiBadge}
773838
badgeTextStyle={styles.aiBadgeText}
774839
/>
840+
{mode !== 'wave' && canCommentToCommunity && (
841+
<IconButton
842+
iconType="MaterialCommunityIcons"
843+
name="arrow-expand"
844+
onPress={_handleExpandBtn}
845+
size={24}
846+
color={EStyleSheet.value('$primaryBlack')}
847+
/>
848+
)}
775849
</View>
776850
);
777851
};

src/config/locales/en-US.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,9 @@
815815
"action_summarize_desc": "Create a concise summary",
816816
"action_check_grammar": "Check Grammar",
817817
"action_check_grammar_desc": "Fix grammar and spelling issues",
818+
"action_generate_image": "Generate Image",
819+
"action_generate_image_desc": "Create an AI image for your post",
820+
"cost_from": "from {cost} {unit}",
818821
"text_label": "Your text",
819822
"text_placeholder": "Paste or type your content here...",
820823
"min_chars": "Minimum {count} characters required",

src/navigation/sheets.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,10 @@ declare module 'react-native-actions-sheet' {
221221
text: string;
222222
onApply?: (output: string, action: string) => void;
223223
supportedActions?: string[];
224+
// Optional "leave the sheet and generate an image" entry. Only callers that can host
225+
// the generator screen pass it, and the sheet renders the card only when they do, so
226+
// surfaces without a place to put the result are unaffected.
227+
onGenerateImage?: () => void | Promise<void>;
224228
};
225229
}>;
226230
dictation: SheetDefinition<{

src/utils/dictationInsert.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { appendDictatedText } from './dictationInsert';
2+
3+
describe('appendDictatedText', () => {
4+
it('uses the transcript as-is when the body is empty', () => {
5+
expect(appendDictatedText('', 'hello there')).toBe('hello there');
6+
});
7+
8+
it('separates a new segment from an existing body', () => {
9+
expect(appendDictatedText('hello', 'there')).toBe('hello there');
10+
});
11+
12+
it('does not double a space the body already ends with', () => {
13+
expect(appendDictatedText('hello ', 'there')).toBe('hello there');
14+
});
15+
16+
it('does not add a space after a trailing newline', () => {
17+
expect(appendDictatedText('hello\n', 'there')).toBe('hello\nthere');
18+
});
19+
20+
it('trims the transcript before appending', () => {
21+
expect(appendDictatedText('hello', ' there ')).toBe('hello there');
22+
});
23+
24+
it('leaves the body untouched for a blank transcript', () => {
25+
expect(appendDictatedText('hello', ' ')).toBe('hello');
26+
expect(appendDictatedText('hello', '')).toBe('hello');
27+
});
28+
29+
it('keeps appending across several segments', () => {
30+
const first = appendDictatedText('', 'one');
31+
const second = appendDictatedText(first, 'two');
32+
expect(appendDictatedText(second, 'three')).toBe('one two three');
33+
});
34+
});

0 commit comments

Comments
 (0)