import * as React from 'react'; import { memo, useCallback, useContext, useMemo, useRef, useState } from 'react'; import { connect } from 'react-redux'; import { Text, StyleSheet, TextStyle, View, ViewStyle, AccessibilityInfo } from 'react-native'; import Checkbox from './Checkbox'; import Note from '@joplin/lib/models/Note'; import time from '@joplin/lib/time'; import { themeStyle } from './global-style'; import { _ } from '@joplin/lib/locale'; import { AppState } from '../utils/types'; import { Dispatch } from 'redux'; import { NoteEntity } from '@joplin/lib/services/database/types'; import useOnLongPressProps from '../utils/hooks/useOnLongPressProps'; import MultiTouchableOpacity from './buttons/MultiTouchableOpacity'; import { escapeRegExp } from '@joplin/lib/string-utils'; import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled'; import NoteLockNote from '@joplin/lib/services/noteLock/NoteLockNote'; import NoteLockSession from '@joplin/lib/services/noteLock/NoteLockSession'; import { DialogContext } from './DialogManager'; import Icon from './Icon'; import isSyncDisabledConflict from '@joplin/lib/services/noteList/isSyncDisabledConflict'; import { noteIsLockedInShare } from '@joplin/lib/models/utils/readOnly'; interface Props { dispatch: Dispatch; themeId: number; note: NoteEntity; noteSelectionEnabled: boolean; selectedNoteIds: string[]; highlightedWord?: string; index?: number; } const useStyles = (themeId: number, showTopBorder: boolean) => { return useMemo(() => { const theme = themeStyle(themeId); const listItemDivider: ViewStyle = { borderTopWidth: showTopBorder ? 1 : 0, borderTopColor: theme.dividerColor, marginLeft: theme.marginLeft, marginRight: theme.marginRight, }; const selectionWrapper: ViewStyle = { flexDirection: 'row', // backgroundColor: theme.backgroundColor, }; const listItemPressable: ViewStyle = { flexGrow: 1, flexShrink: 1, alignSelf: 'stretch', paddingTop: theme.marginTop, paddingBottom: theme.marginBottom, }; const listItemPressableWithCheckbox: ViewStyle = { ...listItemPressable, paddingRight: theme.marginRight, }; const listItemPressableWithoutCheckbox: ViewStyle = { ...listItemPressable, paddingLeft: theme.marginLeft, paddingRight: theme.marginRight, }; const listItemText: TextStyle = { flexShrink: 1, color: theme.color, fontSize: theme.fontSize, }; const listItemTextWithCheckbox = { ...listItemText }; const selectionWrapperSelected = { ...selectionWrapper }; selectionWrapperSelected.backgroundColor = theme.selectedColor; selectionWrapperSelected.borderColor = theme.selectedColor; selectionWrapperSelected.borderTopWidth = 1; selectionWrapperSelected.borderBottomWidth = 1; selectionWrapperSelected.marginVertical = -1; return StyleSheet.create({ listItemDivider, listItemText, titleRow: { flexDirection: 'row', alignItems: 'center', }, lockIcon: { color: theme.color, fontSize: theme.fontSize, marginRight: 8, }, syncDisabledIcon: { color: theme.colorFaded, fontSize: theme.fontSize, marginRight: 8, }, selectionWrapper, listItemPressableWithoutCheckbox, listItemPressableWithCheckbox, listItemTextWithCheckbox, highlightedText: { backgroundColor: theme.searchMarkerBackgroundColor, color: theme.searchMarkerColor, }, selectionWrapperSelected, checkboxStyle: { color: theme.color, paddingLeft: theme.marginLeft, paddingRight: 10, }, checkedOpacityStyle: { opacity: 0.4, }, uncheckedOpacityStyle: { }, }); }, [themeId, showTopBorder]); }; const NoteItemComponent: React.FC = memo(props => { const styles = useStyles(props.themeId, props.index !== 0); const dialogs = useContext(DialogContext); const [checkboxKey, setCheckboxKey] = useState(0); const suppressPressUntilRef = useRef(0); const todoCheckbox_change = useCallback(async (checked: boolean) => { if (!props.note) return; // Ignore the row press emitted by the checkbox gesture without blocking a deliberate // follow-up tap on the row. if (isNoteLockEnabled() || noteIsLockedInShare(props.note)) suppressPressUntilRef.current = Date.now() + 100; // Duplicates the locked-note guard in app-desktop/gui/NoteListItem/NoteListItem.tsx. const lockState = await Note.load(props.note.id, { fields: ['is_locked', 'share_id'] }); if (noteIsLockedInShare(lockState)) { setCheckboxKey(key => key + 1); await dialogs.error(_('This note is read-only because it is locked and contained within a share. To enable editing, it must be moved outside of the share.')); return; } if (isNoteLockEnabled()) { if (NoteLockNote.isLocked(lockState) && !NoteLockSession.instance().isUnlocked()) { // The checkbox keeps its own checked state, so a remount reverts the tick. setCheckboxKey(key => key + 1); await dialogs.error(_('Cannot change a locked note while the session is locked')); return; } } const newNote = { id: props.note.id, todo_completed: checked ? time.unixMs() : 0, }; await Note.save(newNote); props.dispatch({ type: 'NOTE_SORT' }); }, [props.note, props.dispatch, dialogs]); const onPress = useCallback(() => { // Suppress touch release triggers during interval, to avoid conflicting with right click event handling on web if (Date.now() < suppressPressUntilRef.current) return; if (!props.note) return; if (props.note.encryption_applied) return; if (props.noteSelectionEnabled) { props.dispatch({ type: 'NOTE_SELECTION_TOGGLE', id: props.note.id, }); } else { props.dispatch({ type: 'NAV_GO', routeName: 'Note', noteId: props.note.id, }); } }, [props.note, props.noteSelectionEnabled, props.dispatch]); const onLongPress = useCallback(() => { const now = Date.now(); // Suppress duplicate long press triggers during interval, to avoid conflicting with right click event handling on web if (now < suppressPressUntilRef.current) return; suppressPressUntilRef.current = now + 500; if (!props.note) return; if (!props.noteSelectionEnabled) { AccessibilityInfo.announceForAccessibility(_('Entering selection mode')); } props.dispatch({ type: props.noteSelectionEnabled ? 'NOTE_SELECTION_TOGGLE' : 'NOTE_SELECTION_START', id: props.note.id, }); }, [props.dispatch, props.note, props.noteSelectionEnabled]); const note = props.note ?? {}; const isTodo = !!Number(note.is_todo); const checkboxChecked = !!Number(note.todo_completed); const checkboxStyle = styles.checkboxStyle; const listItemTextStyle = isTodo ? styles.listItemTextWithCheckbox : styles.listItemText; const opacityStyle = isTodo && checkboxChecked ? styles.checkedOpacityStyle : styles.uncheckedOpacityStyle; const isSelected = props.noteSelectionEnabled && props.selectedNoteIds.includes(note.id); const showSyncDisabledIcon = isSyncDisabledConflict(note); const showLockIcon = isNoteLockEnabled() && !!note.is_locked; const selectionWrapperStyle = isSelected ? styles.selectionWrapperSelected : styles.selectionWrapper; const noteTitle = Note.displayTitle(note); const highlightedWord = props.highlightedWord; const displayedNoteTitle = highlightedWord ? noteTitle.split(new RegExp(`(${escapeRegExp(highlightedWord)})`, 'i')).map((part, index) => { return part.toLowerCase() === highlightedWord.toLowerCase() ? {part} : part; }) : noteTitle; const selectDeselectLabel = isSelected ? _('Deselect') : _('Select'); const onLongPressProps = useOnLongPressProps({ onLongPress, actionDescription: selectDeselectLabel }); const todoCheckbox = isTodo ? : null; const titleElement = {displayedNoteTitle}; const pressableProps = { style: isTodo ? styles.listItemPressableWithCheckbox : styles.listItemPressableWithoutCheckbox, accessibilityHint: props.noteSelectionEnabled ? '' : _('Opens note'), 'aria-pressed': props.noteSelectionEnabled ? isSelected : undefined, accessibilityState: { selected: isSelected }, ...onLongPressProps, }; return ( {showSyncDisabledIcon || showLockIcon ? ( {showSyncDisabledIcon && } {showLockIcon && } {titleElement} ) : titleElement} ); }); export default connect((state: AppState) => { return { themeId: state.settings.theme, noteSelectionEnabled: state.noteSelectionEnabled, selectedNoteIds: state.selectedNoteIds, }; })(NoteItemComponent);