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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"@babel/preset-typescript": "^7.26.0",
"@babel/runtime": "^7.26.7",
"@ecency/render-helper": "^2.5.17",
"@ecency/sdk": "^2.3.36",
"@ecency/sdk": "^2.3.37",
"@esteemapp/react-native-autocomplete-input": "^4.2.1",
"@esteemapp/react-native-multi-slider": "^1.1.0",
"@native-html/iframe-plugin": "^2.6.1",
Expand Down
7 changes: 5 additions & 2 deletions src/config/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -343,9 +343,12 @@
"templates": {
"title": "Templates",
"untitled": "Untitled template",
"empty_list": "Save a post as template on Ecency web to reuse it here.",
"empty_list": "Save a post as template from the editor to reuse it here.",
"delete_confirm": "Are you sure you want to delete this template?",
"applied": "Template applied as new post"
"applied": "Template applied as new post",
"save_as_template": "Save as template",
"name_placeholder": "Template name",
"saved": "Template saved"
},
"selection_list": {
"available": "Available {postfix}",
Expand Down
16 changes: 16 additions & 0 deletions src/providers/translation/translateMarkdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,4 +304,20 @@ describe('translateMarkdown', () => {
expect(result).toContain('\u00ab');
expect(mockedPost).toHaveBeenCalled();
});

it('translates pipe prose directly above a separator when cell counts differ', async () => {
const block = 'El valor |x| es absoluto\n|---|';
const result = await translateMarkdown(block, 'es', 'en');

expect(result).toContain('\u00ab');
expect(mockedPost).toHaveBeenCalled();
});

it('still skips a borderless GFM table (header without outer pipes)', async () => {
const table = 'a | b\n--- | ---\n1 | 2';
const result = await translateMarkdown(table, 'es', 'en');

expect(result).toBe(table);
expect(mockedPost).not.toHaveBeenCalled();
});
});
24 changes: 19 additions & 5 deletions src/providers/translation/translateMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ const splitMarkdownBlocks = (markdown: string): string[] => {
return blocks;
};

// Cell count of a table row: strip one leading and one trailing border pipe
// (if present) and count the remaining "|"-separated segments.
const countTableCells = (line: string): number => {
let trimmed = line.trim();
if (trimmed.startsWith('|')) {
trimmed = trimmed.slice(1);
}
if (trimmed.endsWith('|')) {
trimmed = trimmed.slice(0, -1);
}
return trimmed.split('|').length;
};

const isSkippableBlock = (block: string): boolean => {
if (FENCE_LINE.test(block) || /^\s*</.test(block)) {
return true;
Expand All @@ -69,17 +82,18 @@ const isSkippableBlock = (block: string): boolean => {
) {
return true;
}
// Tables: a |---| separator row directly below a pipe row (the GFM shape).
// Cell-safe translation is out of scope, so the whole block is passed
// through; requiring adjacency keeps prose that merely contains pipe
// characters translatable.
// Tables: a |---| separator row directly below a pipe row with the same
// cell count (the GFM shape). Cell-safe translation is out of scope, so the
// whole block is passed through; requiring adjacency plus matching cell
// counts keeps prose that merely contains pipe characters translatable.
return lines.some(
(line, i) =>
i > 0 &&
line.includes('|') &&
line.includes('-') &&
TABLE_SEPARATOR_LINE.test(line) &&
lines[i - 1].includes('|'),
lines[i - 1].includes('|') &&
countTableCells(lines[i - 1]) === countTableCells(line),
);
};

Expand Down
25 changes: 25 additions & 0 deletions src/screens/editor/children/postOptionsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,14 @@ interface PostOptionsModalProps {
rewardType: string;
postDescription: string;
isUploading: boolean;
canSaveTemplate: boolean;
handleRewardChange: (rewardType: string) => void;
handlePostDescriptionChange: (value: string) => void;
handleThumbSelection: (url: string) => void;
handleScheduleChange: (datetime: string | null) => void;
handleShouldReblogChange: (shouldReblog: boolean) => void;
handleFormUpdate: () => void;
handleSaveTemplatePress: () => void;
}

const PostOptionsModal = forwardRef(
Expand All @@ -71,12 +73,14 @@ const PostOptionsModal = forwardRef(
rewardType,
postDescription,
isUploading,
canSaveTemplate,
handleRewardChange,
handleThumbSelection,
handleScheduleChange,
handleShouldReblogChange,
handleFormUpdate,
handlePostDescriptionChange,
handleSaveTemplatePress,
}: PostOptionsModalProps,
ref,
) => {
Expand Down Expand Up @@ -160,6 +164,14 @@ const PostOptionsModal = forwardRef(
handleThumbSelection(url);
};

// close the options modal first; the parent then presents the template
// name prompt once this formSheet has dismissed
const _onSaveTemplatePress = () => {
setShowModal(false);
handleFormUpdate();
handleSaveTemplatePress();
};

// handle save default reward checkbox here
const _onCheckPress = () => {
setIsSaveDefaultChecked(!isSaveDefaultChecked);
Expand Down Expand Up @@ -244,6 +256,19 @@ const PostOptionsModal = forwardRef(
handleOnChange={setShouldReblog}
/>
)}
{canSaveTemplate && (
<SettingsItem
title={intl.formatMessage({
id: 'templates.save_as_template',
})}
text={intl.formatMessage({
id: 'beneficiary_modal.save',
})}
type="button"
actionType="saveTemplate"
handleOnButtonPress={_onSaveTemplatePress}
/>
)}
</>
)}

Expand Down
83 changes: 83 additions & 0 deletions src/screens/editor/children/saveTemplateModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React, { forwardRef, useImperativeHandle, useState } from 'react';
import { useIntl } from 'react-intl';
import { Alert, View } from 'react-native';
import { Modal, TextInput } from '../../../components';
import { TextButton } from '../../../components/buttons';
import { useAppSelector } from '../../../hooks';
import { selectIsDarkTheme } from '../../../redux/selectors';
import styles from './saveTemplateModalStyles';

export interface SaveTemplateModalRef {
show: () => void;
}

interface SaveTemplateModalProps {
onSave: (templateName: string) => void;
}

// Small name prompt for saving the current post as a template; follows the
// snippetEditorModal pattern (Modal + TextInput + TextButton action panel).
const SaveTemplateModal = ({ onSave }: SaveTemplateModalProps, ref) => {
const intl = useIntl();
const isDarkTheme = useAppSelector(selectIsDarkTheme);

const [templateName, setTemplateName] = useState('');
const [showModal, setShowModal] = useState(false);

useImperativeHandle(ref, () => ({
show: () => {
setTemplateName('');
setShowModal(true);
},
}));

const _onSavePress = () => {
const name = templateName.trim();
if (!name) {
Alert.alert(intl.formatMessage({ id: 'alert.can_not_be_empty' }));
return;
}
setShowModal(false);
onSave(name);
};

return (
<Modal
isOpen={showModal}
handleOnModalClose={() => {
setShowModal(false);
}}
presentationStyle="formSheet"
title={intl.formatMessage({ id: 'templates.save_as_template' })}
animationType="slide"
style={styles.modalStyle}
>
<View style={styles.container}>
<TextInput
autoFocus={true}
style={styles.nameInput}
placeholder={intl.formatMessage({ id: 'templates.name_placeholder' })}
placeholderTextColor={isDarkTheme ? '#526d91' : '#c1c5c7'}
maxLength={255}
onChangeText={setTemplateName}
value={templateName}
/>
<View style={styles.actionPanel}>
<TextButton
text={intl.formatMessage({ id: 'snippets.btn_cancel' })}
onPress={() => setShowModal(false)}
style={styles.closeButton}
/>
<TextButton
text={intl.formatMessage({ id: 'snippets.btn_save' })}
onPress={_onSavePress}
textStyle={styles.btnText}
style={styles.saveButton}
/>
</View>
</View>
</Modal>
);
};

export default forwardRef(SaveTemplateModal);
51 changes: 51 additions & 0 deletions src/screens/editor/children/saveTemplateModalStyles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { TextStyle, StyleSheet, ViewStyle } from 'react-native';
import EStyleSheet from 'react-native-extended-stylesheet';

export default EStyleSheet.create({
modalStyle: {
flex: 1,
backgroundColor: '$primaryBackgroundColor',
margin: 0,
paddingTop: 32,
paddingBottom: 8,
},
container: {
flexGrow: 1,
marginTop: 24,
paddingHorizontal: 24,
} as ViewStyle,
nameInput: {
color: '$primaryBlack',
fontWeight: 'bold',
fontSize: 18,
textAlignVertical: 'top',
paddingVertical: 0,
backgroundColor: '$primaryBackgroundColor',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '$primaryDarkGray',
} as TextStyle,
btnText: {
color: '$pureWhite',
} as TextStyle,
saveButton: {
backgroundColor: '$primaryBlue',
width: 150,
paddingVertical: 16,
borderRadius: 32,
justifyContent: 'center',
alignItems: 'center',
} as ViewStyle,
closeButton: {
marginRight: 16,
paddingVertical: 8,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
} as ViewStyle,
actionPanel: {
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
marginTop: 24,
} as ViewStyle,
});
88 changes: 88 additions & 0 deletions src/screens/editor/container/editorContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,93 @@ class EditorContainer extends Component<EditorContainerProps, any> {
}
};

// Saves the current compose state as a NEW template draft (meta.postTemplate +
// meta.templateName, same convention as Ecency web). Always addDraft: it never
// updates the draft being composed, never touches state.draftId/isDraftSaved/
// isDraftSaving and never clears local draft caches, so the normal draft
// autosave flow keeps working on whatever the user is writing.
_saveAsTemplate = async (fields, templateName: string) => {
const { isReply, isEdit, thumbUrl, rewardType, postDescription } = this.state;
const { currentAccount, dispatch, intl, queryClient, pinCode } = this.props;

if (isReply || isEdit || !fields) {
return;
}

const beneficiaries = this._extractBeneficiaries();
const pollDraft = this._extractPollDraft();

try {
const draftField = {
...fields,
// a template can be a title-only scaffold; keep body a string throughout
body: fields.body || '',
tags: fields.tags && fields.tags.length > 0 ? fields.tags.join(' ') : '',
};

const _extractedMeta = await extractMetadata({
body: draftField.body,
thumbUrl,
fetchRatios: false,
});

const postBodySummaryContent = postBodySummary(
draftField.body || '',
200,
Platform.OS as any,
);

const meta = Object.assign({}, _extractedMeta, {
tags: draftField.tags,
beneficiaries,
poll: pollDraft,
rewardType,
description: postDescription || postBodySummaryContent,
postTemplate: true,
templateName,
});

const jsonMeta = makeJsonMetadata(meta, draftField.tags);

const accessToken = currentAccount?.local?.accessToken
? decryptKey(currentAccount.local.accessToken, getDigitPinCode(pinCode))
: '';

if (!accessToken) {
dispatch(toastNotification(intl.formatMessage({ id: 'editor.draft_save_fail' })));
return;
}

await addDraft(
accessToken,
draftField.title || '',
draftField.body,
draftField.tags,
jsonMeta,
);

dispatch(toastNotification(intl.formatMessage({ id: 'templates.saved' })));

// refresh drafts/templates lists so the new template shows up
if (queryClient) {
const { queryKey: draftsQueryKey } = getDraftsQueryOptions(
currentAccount.name,
accessToken,
);
const { queryKey: draftsInfiniteKey } = getDraftsInfiniteQueryOptions(
currentAccount.name,
accessToken,
20,
);
queryClient.invalidateQueries({ queryKey: draftsQueryKey });
queryClient.invalidateQueries({ queryKey: draftsInfiniteKey });
}
} catch (err) {
console.warn('Failed to save template', err);
dispatch(toastNotification(intl.formatMessage({ id: 'editor.draft_save_fail' })));
}
};

_updateDraftFields = (fields) => {
this._updatedDraftFields = fields;
};
Expand Down Expand Up @@ -1784,6 +1871,7 @@ class EditorContainer extends Component<EditorContainerProps, any> {
updateDraftFields={this._updateDraftFields}
saveCurrentDraft={this._saveCurrentDraft}
saveDraftToDB={this._saveDraftToDB}
saveAsTemplate={this._saveAsTemplate}
uploadedImage={uploadedImage}
tags={tags}
community={community}
Expand Down
Loading
Loading