文件
mynode/packages/app-mobile/components/screens/ResourceScreen.tsx
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

454 行
14 KiB
TypeScript

import * as React from 'react';
import { View, Text, FlatList, StyleSheet, Button, TouchableOpacity, ActivityIndicator } from 'react-native';
import { connect } from 'react-redux';
import ScreenHeader from '../ScreenHeader';
import SearchInput from '../SearchInput';
import IconButton from '../IconButton';
import { _ } from '@joplin/lib/locale';
import { AppState } from '../../utils/types';
import { themeStyle } from '../global-style';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import Resource, { NoteResourceSortDirection, NoteResourceSortField } from '@joplin/lib/models/Resource';
import { ResourceEntity } from '@joplin/lib/services/database/types';
import { substrWithEllipsis } from '@joplin/lib/string-utils';
import shim from '@joplin/lib/shim';
import showResource from '../../commands/util/showResource';
import { bytesToHuman } from '@joplin/utils/bytes';
import Clipboard from '@react-native-clipboard/clipboard';
import { buildResourceMarkdownLink, nextSortState } from './resourceScreenUtils';
interface Props {
themeId: number;
}
interface ResourceListItem {
item: ResourceEntity;
}
const PAGE_SIZE = 50;
const sortTypeLabel = (sortField: NoteResourceSortField, sortDirection: NoteResourceSortDirection) => {
if (sortField === 'title') return sortDirection === 'asc' ? _('Title (A-Z)') : _('Title (Z-A)');
return sortDirection === 'asc' ? _('Size (smallest first)') : _('Size (largest first)');
};
const displayTitle = (resource: ResourceEntity) => {
return resource.title ? resource.title : `(${_('Untitled')})`;
};
const displaySize = (resource: ResourceEntity) => {
if (typeof resource.size !== 'number' || resource.size < 0) return _('Unknown size');
return bytesToHuman(resource.size);
};
const errorToMessage = (error: unknown) => {
return error instanceof Error ? error.message : String(error);
};
const ResourceScreenComponent: React.FC<Props> = props => {
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
const [resources, setResources] = useState<ResourceEntity[]>([]);
const [sortField, setSortField] = useState<NoteResourceSortField>('title');
const [sortDirection, setSortDirection] = useState<NoteResourceSortDirection>('asc');
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [deletingResourceIds, setDeletingResourceIds] = useState<string[]>([]);
const [expandedResourceIds, setExpandedResourceIds] = useState<Record<string, boolean>>({});
const theme = themeStyle(props.themeId);
const isMountedRef = useRef(true);
const activeLoadIdRef = useRef(0);
const getNextLoadId = useCallback(() => {
activeLoadIdRef.current++;
return activeLoadIdRef.current;
}, []);
const canApplyLoadStateUpdate = useCallback((loadId: number, isCancelled?: ()=> boolean) => {
if (!isMountedRef.current) return false;
if (isCancelled?.()) return false;
return loadId === activeLoadIdRef.current;
}, []);
const styles = useMemo(() => {
return StyleSheet.create({
root: {
...theme.rootStyle,
flex: 1,
},
warning: {
marginTop: theme.margin,
marginBottom: theme.margin,
marginLeft: theme.marginLeft,
marginRight: theme.marginRight,
backgroundColor: theme.warningBackgroundColor,
paddingTop: 5,
paddingBottom: 5,
paddingLeft: 10,
paddingRight: 10,
},
warningText: {
color: theme.color,
fontSize: theme.fontSize,
},
searchInputContainer: {
marginBottom: theme.itemMarginBottom,
marginLeft: theme.marginLeft,
marginRight: theme.marginRight,
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
paddingTop: 4,
paddingBottom: 4,
borderWidth: 1,
borderColor: theme.dividerColor,
borderRadius: 4,
backgroundColor: theme.backgroundColor,
},
listContent: {
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
paddingBottom: theme.marginBottom,
},
row: {
paddingTop: theme.itemMarginTop,
paddingBottom: theme.itemMarginBottom,
borderBottomWidth: 1,
borderBottomColor: theme.dividerColor,
position: 'relative',
},
rowTop: {
flexDirection: 'row',
alignItems: 'center',
},
actionIconsRow: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: 8,
},
rowPressable: {
flex: 1,
minHeight: 44,
paddingTop: 6,
paddingBottom: 8,
position: 'relative',
},
sortBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
paddingBottom: theme.itemMarginBottom,
},
errorText: {
color: theme.colorError,
fontSize: theme.fontSize,
marginBottom: 8,
},
rowTitle: {
color: theme.color,
fontSize: theme.fontSize,
fontWeight: '700',
lineHeight: theme.fontSize * 1.4,
},
rowMeta: {
color: theme.colorFaded,
fontSize: theme.fontSizeSmaller,
marginTop: 2,
},
copyButtonContainer: {
minWidth: 44,
minHeight: 44,
paddingLeft: 10,
paddingRight: 10,
justifyContent: 'center',
alignItems: 'center',
},
copyIcon: {
color: theme.colorFaded,
fontSize: theme.fontSizeLarger,
},
deleteButtonContainer: {
minWidth: 44,
minHeight: 44,
paddingLeft: 10,
paddingRight: 10,
justifyContent: 'center',
alignItems: 'center',
},
deleteIcon: {
color: theme.colorFaded,
fontSize: theme.fontSizeLarger,
},
emptyText: {
color: theme.colorFaded,
fontSize: theme.fontSize,
marginTop: theme.marginTop,
},
loadingFooter: {
paddingTop: theme.marginTop,
paddingBottom: theme.marginBottom,
},
});
}, [theme]);
useEffect(() => {
const timeout = shim.setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 250);
return () => {
shim.clearTimeout(timeout);
};
}, [searchQuery]);
useEffect(() => {
setExpandedResourceIds({});
}, [debouncedSearchQuery]);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
const loadPage = useCallback(async (offset: number, isCancelled?: ()=> boolean) => {
const currentLoad = getNextLoadId();
const loadingInitialPage = offset === 0;
if (loadingInitialPage) {
setIsLoading(true);
setErrorMessage('');
} else {
setIsLoadingMore(true);
}
try {
const result = await Resource.noteResources({
searchQuery: debouncedSearchQuery,
sortField,
sortDirection,
limit: PAGE_SIZE,
offset,
});
if (!canApplyLoadStateUpdate(currentLoad, isCancelled)) return;
if (loadingInitialPage) {
setResources(result.items);
} else {
setResources(previous => previous.concat(result.items));
}
setHasMore(result.hasMore);
setErrorMessage('');
} catch (error: unknown) {
if (!canApplyLoadStateUpdate(currentLoad, isCancelled)) return;
setErrorMessage(errorToMessage(error));
} finally {
if (canApplyLoadStateUpdate(currentLoad, isCancelled)) {
setIsLoading(false);
setIsLoadingMore(false);
}
}
}, [canApplyLoadStateUpdate, debouncedSearchQuery, getNextLoadId, sortDirection, sortField]);
useAsyncEffect(async (event) => {
await loadPage(0, () => event.cancelled);
}, [loadPage]);
const onDeleteResource = useCallback(async (resource: ResourceEntity) => {
if (!resource.id) return;
const confirmed = await shim.showConfirmationDialog(_('Delete attachment "%s"?', substrWithEllipsis(displayTitle(resource), 0, 50)));
if (!confirmed) return;
if (!isMountedRef.current) return;
setDeletingResourceIds(previous => previous.concat(resource.id));
try {
await Resource.delete(resource.id, { sourceDescription: 'ResourceScreen' });
await loadPage(0);
} catch (error: unknown) {
if (!isMountedRef.current) return;
await shim.showErrorDialog(errorToMessage(error));
} finally {
if (isMountedRef.current) {
setDeletingResourceIds(previous => previous.filter(id => id !== resource.id));
}
}
}, [loadPage]);
const onOpenResource = useCallback(async (resource: ResourceEntity) => {
try {
await showResource(resource);
} catch (error: unknown) {
if (!isMountedRef.current) return;
const fullPath = Resource.fullPath(resource);
const errorMessage = errorToMessage(error);
await shim.showErrorDialog(`${_('This file could not be opened: %s', fullPath)}\n\n${errorMessage}`);
}
}, []);
const onCopyMarkdownLink = useCallback((resource: ResourceEntity) => {
const markdownLink = buildResourceMarkdownLink(resource);
if (!markdownLink) return;
Clipboard.setString(markdownLink);
}, []);
const onToggleExpandedRow = useCallback((resourceId: string) => {
setExpandedResourceIds(previous => ({
...previous,
[resourceId]: !previous[resourceId],
}));
}, []);
useEffect(() => {
const resourceIds = new Set(resources.map(resource => resource.id));
setExpandedResourceIds(previous => {
const nextState: Record<string, boolean> = {};
for (const id of Object.keys(previous)) {
if (resourceIds.has(id) && previous[id]) {
nextState[id] = true;
}
}
return nextState;
});
}, [resources]);
const onToggleSorting = useCallback((nextSortField: NoteResourceSortField) => {
const nextState = nextSortState(sortField, sortDirection, nextSortField);
setSortField(nextState.sortField);
setSortDirection(nextState.sortDirection);
}, [sortDirection, sortField]);
const onLoadMore = useCallback(() => {
if (isLoading || isLoadingMore || !hasMore || errorMessage) return;
void loadPage(resources.length);
}, [errorMessage, hasMore, isLoading, isLoadingMore, loadPage, resources.length]);
const listEmptyText = useMemo(() => {
if (debouncedSearchQuery.trim()) return _('No attachments match your search.');
return _('No attachments!');
}, [debouncedSearchQuery]);
const loadingFooter = useMemo(() => {
if (!isLoadingMore) return null;
return <View style={styles.loadingFooter}><ActivityIndicator /></View>;
}, [isLoadingMore, styles.loadingFooter]);
const renderItem = useCallback(({ item }: ResourceListItem) => {
if (!item.id) return null;
const deleting = deletingResourceIds.includes(item.id);
const title = displayTitle(item);
const size = displaySize(item);
const isExpanded = !!expandedResourceIds[item.id];
const titleLineProps = isExpanded ? {} : {
numberOfLines: 1,
ellipsizeMode: 'tail' as const,
};
return (
<View style={styles.row} accessible={false}>
<View style={styles.rowTop}>
<TouchableOpacity
accessible={true}
focusable={true}
activeOpacity={1}
style={styles.rowPressable}
onPress={() => {
void onOpenResource(item);
}}
onLongPress={() => {
onToggleExpandedRow(item.id);
}}
accessibilityRole='button'
accessibilityLabel={_('Attachment: %s. Size: %s', title, size)}
accessibilityHint={_('Double tap to open this attachment. Long press to expand or collapse details.')}
accessibilityState={{ expanded: isExpanded }}
>
<Text accessible={false} style={styles.rowTitle} {...titleLineProps}>{title}</Text>
<Text accessible={false} style={styles.rowMeta}>{size}</Text>
</TouchableOpacity>
<View style={styles.actionIconsRow}>
<IconButton
onPress={() => onCopyMarkdownLink(item)}
description={_('Copy Markdown link')}
accessibilityHint={_('Copies a Markdown link to this attachment')}
iconName='material content-copy'
iconStyle={styles.copyIcon}
containerStyle={styles.copyButtonContainer}
themeId={props.themeId}
accessibilityRole='button'
/>
<IconButton
onPress={() => {
void onDeleteResource(item);
}}
description={_('Delete attachment')}
accessibilityHint={_('Deletes this attachment')}
iconName='material delete'
iconStyle={styles.deleteIcon}
containerStyle={styles.deleteButtonContainer}
themeId={props.themeId}
accessibilityRole='button'
disabled={deleting}
/>
</View>
</View>
</View>
);
}, [deletingResourceIds, expandedResourceIds, onCopyMarkdownLink, onDeleteResource, onOpenResource, onToggleExpandedRow, props.themeId, styles]);
return (
<View style={styles.root}>
<ScreenHeader title={_('Note attachments')} />
<View style={styles.warning}>
<Text style={styles.warningText}>
{_('This is an advanced tool to show the attachments that are linked to your notes. Please be careful when deleting one of them as they cannot be restored afterwards.')}
</Text>
</View>
<SearchInput
themeId={props.themeId}
value={searchQuery}
onChangeText={setSearchQuery}
placeholder={_('Search...')}
autoCorrect={false}
autoComplete='off'
autoCapitalize='none'
containerStyle={styles.searchInputContainer}
/>
<View style={styles.sortBar}>
<Button title={sortTypeLabel(sortField, sortDirection)} onPress={() => onToggleSorting(sortField)} />
<Button title={sortField === 'title' ? _('Sort by size') : _('Sort by title')} onPress={() => onToggleSorting(sortField === 'title' ? 'size' : 'title')} />
</View>
{errorMessage ? <View style={styles.listContent}>
<Text style={styles.errorText}>{errorMessage}</Text>
<Button title={_('Retry')} onPress={() => {
void loadPage(0);
}} />
</View> : null}
{isLoading ? <View style={styles.listContent}><ActivityIndicator /><Text style={styles.emptyText}>{_('Please wait...')}</Text></View> : null}
<FlatList
data={resources}
keyExtractor={(item, index) => item.id ? item.id : `resource-${index}`}
contentContainerStyle={styles.listContent}
ListEmptyComponent={!isLoading && !errorMessage ? <Text style={styles.emptyText}>{listEmptyText}</Text> : null}
renderItem={renderItem}
onEndReached={onLoadMore}
onEndReachedThreshold={0.8}
ListFooterComponent={loadingFooter}
/>
</View>
);
};
const ResourceScreen = connect((state: AppState) => {
return {
themeId: state.settings.theme,
};
})(ResourceScreenComponent);
export default ResourceScreen;