├── .eslintrc ├── assets ├── icon.png └── splash.png ├── .prettierrc.js ├── electron ├── IconTemplate.png ├── IconTemplate@2x.png ├── main │ ├── IconTemplate.png │ ├── IconTemplate@2x.png │ └── index.js └── webpack.config.js ├── babel.config.js ├── .expo-shared └── assets.json ├── README.md ├── electron-webpack.js ├── tsconfig.json ├── .gitignore ├── App.tsx ├── .vscode └── launch.json ├── app.json ├── src ├── use-search.ts ├── index.tsx ├── Item.tsx ├── use-clipboard.ts └── Swipeable.tsx └── package.json /.eslintrc: -------------------------------------------------------------------------------- 1 | 2 | // generated by @nandorojo/lint-expo 3 | {"extends": ["nando"]} -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandorojo/expo-electron-copy-magic/HEAD/assets/icon.png -------------------------------------------------------------------------------- /assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandorojo/expo-electron-copy-magic/HEAD/assets/splash.png -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | 2 | // generated by @nandorojo/lint-expo 3 | module.exports = require('eslint-config-nando/prettier') -------------------------------------------------------------------------------- /electron/IconTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandorojo/expo-electron-copy-magic/HEAD/electron/IconTemplate.png -------------------------------------------------------------------------------- /electron/IconTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandorojo/expo-electron-copy-magic/HEAD/electron/IconTemplate@2x.png -------------------------------------------------------------------------------- /electron/main/IconTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandorojo/expo-electron-copy-magic/HEAD/electron/main/IconTemplate.png -------------------------------------------------------------------------------- /electron/main/IconTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nandorojo/expo-electron-copy-magic/HEAD/electron/main/IconTemplate@2x.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /electron/webpack.config.js: -------------------------------------------------------------------------------- 1 | const { withExpoWebpack } = require('@expo/electron-adapter'); 2 | 3 | module.exports = config => { 4 | return withExpoWebpack(config); 5 | }; 6 | -------------------------------------------------------------------------------- /.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # copy-magic 2 | Copy clip sucks, so this is a better one made with Expo + Electron. 3 | 4 | ## Tweet 5 | 6 | Here is the context from a tweet: https://twitter.com/FernandoTheRojo/status/1264020579734126592 7 | -------------------------------------------------------------------------------- /electron-webpack.js: -------------------------------------------------------------------------------- 1 | const { withExpoAdapter } = require('@expo/electron-adapter'); 2 | 3 | module.exports = withExpoAdapter({ 4 | projectRoot: __dirname, 5 | // Provide any overrides for electron-webpack: https://github.com/electron-userland/electron-webpack/blob/master/docs/en/configuration.md 6 | }); 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "jsx": "react-native", 5 | "lib": ["dom", "esnext"], 6 | "moduleResolution": "node", 7 | "noEmit": true, 8 | "skipLibCheck": true, 9 | "resolveJsonModule": true, 10 | "strict": true, 11 | "strictNullChecks": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/**/* 2 | .expo/* 3 | npm-debug.* 4 | *.jks 5 | *.p8 6 | *.p12 7 | *.key 8 | *.mobileprovision 9 | *.orig.* 10 | web-build/ 11 | web-report/ 12 | 13 | # macOS 14 | .DS_Store 15 | 16 | # @generated: @expo/electron-adapter@0.0.0-alpha.57 17 | /.expo/* 18 | # Expo Web 19 | /web-build/* 20 | # electron-webpack 21 | /dist 22 | # @end @expo/electron-adapter 23 | -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ActionSheetProvider } from '@expo/react-native-action-sheet' 3 | import { NotifierWrapper } from 'react-native-notifier' 4 | import App from './src' 5 | 6 | export default function Providers() { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceFolder}", 9 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" 12 | }, 13 | "args": ["."], 14 | "outputCapture": "std" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "copy-magic", 4 | "slug": "copy-magic", 5 | "platforms": [ 6 | "ios", 7 | "android", 8 | "web" 9 | ], 10 | "version": "1.0.0", 11 | "orientation": "portrait", 12 | "icon": "./assets/icon.png", 13 | "splash": { 14 | "image": "./assets/splash.png", 15 | "resizeMode": "contain", 16 | "backgroundColor": "#ffffff" 17 | }, 18 | "updates": { 19 | "fallbackToCacheTimeout": 0 20 | }, 21 | "assetBundlePatterns": [ 22 | "**/*" 23 | ], 24 | "ios": { 25 | "supportsTablet": true 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/use-search.ts: -------------------------------------------------------------------------------- 1 | import { useState, useReducer, useMemo } from 'react' 2 | import { HistoryItem, isHistoryImage } from './use-clipboard' 3 | import moment from 'moment' 4 | 5 | /** 6 | * Hook used to filter results. 7 | * 8 | * Enables search, 9 | */ 10 | export const useSearch = ({ history }: { history: HistoryItem[] }) => { 11 | const [query, setQuery] = useState('') 12 | 13 | const [showImages, toggleShowImages] = useReducer(show => !show, true) 14 | 15 | const [showText, toggleShowText] = useReducer(show => !show, true) 16 | 17 | const filteredHistory = useMemo(() => { 18 | return history.filter(copied => { 19 | // check if they typed text that's in this date 20 | const isInDate = 21 | copied.copiedAt && 22 | (moment(copied.copiedAt) 23 | .calendar() 24 | .toLowerCase() 25 | .includes(query.trim().toLowerCase()) || 26 | copied.copiedAt.includes(query.trim().toLowerCase())) 27 | 28 | // if this is an image... 29 | if (isHistoryImage(copied)) { 30 | if (!showImages) return false 31 | 32 | // also query the image URL, just in case 33 | const isInUrl = copied.value.url 34 | .toLowerCase() 35 | .includes(query.trim().toLowerCase()) 36 | return isInDate || isInUrl 37 | } 38 | 39 | if (!showText) return false 40 | 41 | // check if the copied text includes the typed text 42 | const isInText = copied.value 43 | .toLowerCase() 44 | .trim() 45 | .includes(query.trim().toLowerCase()) 46 | 47 | return isInDate || isInText 48 | }) 49 | }, [history, query, showImages, showText]) 50 | 51 | return { 52 | query, 53 | setQuery, 54 | showText, 55 | showImages, 56 | toggleShowText, 57 | toggleShowImages, 58 | filteredHistory, 59 | isEmptyQuery: query.trim() && !filteredHistory.length, 60 | isEmptyList: !history.length, 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "node_modules/expo/AppEntry.js", 3 | "scripts": { 4 | "start": "expo-electron start --enable-logging ELECTRON_ENABLE_LOGGING=true", 5 | "android": "expo start --android", 6 | "ios": "expo start --ios", 7 | "web": "expo start --web", 8 | "eject": "expo eject", 9 | "build:dev": "electron-webpack && electron-builder --dir -c.compression=store -c.mac.identity=null", 10 | "build:prod": "electron-webpack && electron-builder --dir -c.mac.identity=null", 11 | "pack": "electron-builder --dir", 12 | "dist": "electron-builder", 13 | "postinstall": "electron-builder install-app-deps" 14 | }, 15 | "dependencies": { 16 | "@expo/react-native-action-sheet": "^3.8.0", 17 | "@nandorojo/electron-clipboard": "^0.0.2", 18 | "@react-native-community/hooks": "^2.5.1", 19 | "electron-clipboard-extended": "^1.1.1", 20 | "electron-debug": "^3.1.0", 21 | "electron-store": "^5.1.1", 22 | "expo": "~37.0.3", 23 | "moment": "^2.26.0", 24 | "path": "^0.12.7", 25 | "react": "~16.9.0", 26 | "react-dom": "~16.9.0", 27 | "react-native": "https://github.com/expo/react-native/archive/sdk-37.0.1.tar.gz", 28 | "react-native-elements": "^2.0.0", 29 | "react-native-gesture-handler": "~1.6.0", 30 | "react-native-notifier": "^1.1.0", 31 | "react-native-reanimated": "~1.7.0", 32 | "react-native-redash": "^14.1.1", 33 | "react-native-screens": "~2.2.0", 34 | "react-native-web": "~0.11.7", 35 | "react-native-web-hooks": "^3.0.1", 36 | "url": "^0.11.0" 37 | }, 38 | "devDependencies": { 39 | "@babel/core": "^7.8.6", 40 | "@expo/electron-adapter": "^0.0.0-alpha.57", 41 | "@expo/webpack-config": "^0.12.9", 42 | "@types/react": "~16.9.23", 43 | "@types/react-native": "~0.61.17", 44 | "babel-preset-expo": "~8.1.0", 45 | "electron": "^9.4.0", 46 | "electron-builder": "^22.6.1", 47 | "eslint-config-nando": "^1.0.9", 48 | "typescript": "~3.8.3" 49 | }, 50 | "private": true, 51 | "version": "0.0.4", 52 | "name": "copy-magic", 53 | "author": "Fernando Rojo", 54 | "build": { 55 | "appId": "nandorojo.copymagic", 56 | "mac": { 57 | "category": "public.app-category.utilities" 58 | }, 59 | "extraMetadata": { 60 | "main": "main.js" 61 | }, 62 | "files": [ 63 | { 64 | "from": "dist/main/", 65 | "to": "./", 66 | "filter": [ 67 | "**/*" 68 | ] 69 | }, 70 | { 71 | "from": "dist/renderer", 72 | "to": "./", 73 | "filter": [ 74 | "**/*" 75 | ] 76 | }, 77 | "package.json", 78 | "**/node_modules/**/*" 79 | ] 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /electron/main/index.js: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, Tray, app } from 'electron' 2 | import * as path from 'path' 3 | import { format as formatUrl } from 'url' 4 | import debug from 'electron-debug' 5 | 6 | const isDevelopment = process.env.NODE_ENV !== 'production' 7 | 8 | debug() 9 | 10 | // global reference to mainWindow (necessary to prevent window from being garbage collected) 11 | let mainWindow 12 | let tray 13 | 14 | function createMainWindow() { 15 | const mainWindow = new BrowserWindow({ 16 | width: 400, 17 | height: 500, 18 | frame: false, 19 | resizable: false, 20 | show: false, 21 | webPreferences: { nodeIntegration: true }, 22 | }) 23 | console.log({ isDevelopment }) 24 | 25 | // if (isDevelopment) { 26 | // mainWindow.webContents.openDevTools() 27 | // } 28 | 29 | if (isDevelopment) { 30 | console.log(`http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}`) 31 | mainWindow.loadURL( 32 | `http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}` 33 | ) 34 | } else { 35 | mainWindow.loadURL( 36 | formatUrl({ 37 | pathname: path.join(__dirname, 'index.html'), 38 | protocol: 'file', 39 | slashes: true, 40 | }) 41 | ) 42 | } 43 | 44 | mainWindow.on('closed', () => (mainWindow = null)) 45 | mainWindow.on('blur', () => mainWindow.hide()) 46 | 47 | const iconPath = path.join(__dirname, './IconTemplate.png') 48 | tray = new Tray(iconPath) 49 | tray.on('click', (event, bounds) => { 50 | const { x, y } = bounds 51 | const { height, width } = mainWindow.getBounds() 52 | if (mainWindow.isVisible()) { 53 | mainWindow.hide() 54 | } else { 55 | mainWindow.setBounds({ 56 | x: x - width / 2, 57 | y: process.platform === 'win32' ? y - height : y, 58 | height, 59 | width, 60 | }) 61 | mainWindow.show() 62 | } 63 | }) 64 | 65 | mainWindow.webContents.on('devtools-opened', () => { 66 | mainWindow.focus() 67 | setImmediate(() => { 68 | mainWindow.focus() 69 | }) 70 | }) 71 | 72 | return mainWindow 73 | } 74 | 75 | // quit application when all windows are closed 76 | app.on('window-all-closed', () => { 77 | // on macOS it is common for applications to stay open until the user explicitly quits 78 | if (process.platform !== 'darwin') { 79 | app.quit() 80 | } 81 | }) 82 | 83 | app.on('activate', () => { 84 | // on macOS it is common to re-create a window even after all windows have been closed 85 | if (!mainWindow) { 86 | mainWindow = createMainWindow() 87 | } 88 | }) 89 | 90 | // create main mainWindow when electron is ready 91 | app.on('ready', () => { 92 | mainWindow = createMainWindow() 93 | }) 94 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | // dependencies 2 | import React from 'react' 3 | import { View, FlatList, Text } from 'react-native' 4 | import { SearchBar, Divider, CheckBox } from 'react-native-elements' 5 | import { Ionicons } from '@expo/vector-icons' 6 | import { useActionSheet } from '@expo/react-native-action-sheet' 7 | 8 | // my code 9 | import { useClipboard } from './use-clipboard' 10 | import { useSearch } from './use-search' 11 | import { CopiedItem } from './Item' 12 | 13 | export default function App() { 14 | const { 15 | history, 16 | handleClickItem, 17 | handleDeleteItem, 18 | flatlist, 19 | clearHistory, 20 | } = useClipboard() 21 | const { 22 | query, 23 | setQuery, 24 | showImages, 25 | showText, 26 | toggleShowImages, 27 | toggleShowText, 28 | filteredHistory, 29 | isEmptyList, 30 | } = useSearch({ history }) 31 | const { showActionSheetWithOptions } = useActionSheet() 32 | 33 | const renderFilters = () => { 34 | if (isEmptyList) return null 35 | 36 | return ( 37 | 44 | 56 | 68 | 69 | { 75 | const options = ['Delete History Forever', 'Cancel'] 76 | showActionSheetWithOptions( 77 | { 78 | options, 79 | cancelButtonIndex: 1, 80 | destructiveButtonIndex: 0, 81 | title: 'Delete copy history permanently?', 82 | message: 83 | 'This cannot be undone. If you want to delete individual items, swipe them to the left.', 84 | titleTextStyle: { 85 | color: 'black', 86 | fontWeight: 'bold', 87 | }, 88 | }, 89 | index => { 90 | if (index === 0) clearHistory() 91 | } 92 | ) 93 | }} 94 | /> 95 | 96 | ) 97 | } 98 | 99 | const renderEmptyList = () => 100 | isEmptyList ? ( 101 | 109 | 116 | ⚡️ Welcome! {'\n\n'}Try copying something on your computer, and watch 117 | it show up here. 118 | 119 | 120 | ) : null 121 | 122 | return ( 123 | 124 | 125 | {renderFilters()} 126 | 127 | `${value}${copiedAt}`} 135 | renderItem={({ item }) => ( 136 | 141 | )} 142 | initialNumToRender={8} 143 | removeClippedSubviews 144 | ItemSeparatorComponent={Divider} 145 | ListEmptyComponent={renderEmptyList} 146 | /> 147 | 148 | ) 149 | } 150 | -------------------------------------------------------------------------------- /src/Item.tsx: -------------------------------------------------------------------------------- 1 | // dependencies 2 | import React, { useRef } from 'react' 3 | import { Image, Text, View } from 'react-native' 4 | import { ListItem } from 'react-native-elements' 5 | import moment from 'moment' 6 | import { useDimensions } from '@react-native-community/hooks' 7 | import { Notifier, NotifierComponents } from 'react-native-notifier' 8 | import { RectButton } from 'react-native-gesture-handler' 9 | import { useHover, useFocus, useActive } from 'react-native-web-hooks' 10 | import { useTimingTransition } from 'react-native-redash' 11 | import Reanimated from 'react-native-reanimated' 12 | 13 | // our code 14 | import { HistoryItem, isHistoryImage } from './use-clipboard' 15 | import { SwipeableItem } from './Swipeable' 16 | 17 | type ItemProps = HistoryItem & { 18 | onPress: (value: HistoryItem) => void 19 | onDelete: (value: HistoryItem) => void 20 | } 21 | 22 | export const CopiedItem = React.memo(function CopiedItem({ 23 | onPress, 24 | onDelete, 25 | ...props 26 | }: ItemProps) { 27 | const { width: windowWidth } = useDimensions().window 28 | const ref = useRef(null) 29 | 30 | const isHovered = useHover(ref) 31 | const isFocused = useFocus(ref) 32 | const isActive = useActive(ref) 33 | const animatedHoverState = useTimingTransition( 34 | // this is 1 if this item is not hovered 35 | // it is 0 if it is hovered 36 | !isHovered && !isFocused && !isActive 37 | ) 38 | 39 | // if the copied item was an image 40 | if (isHistoryImage(props)) { 41 | const { url, height, width } = props.value 42 | const aspectRatio = width / height 43 | 44 | // create dimensions that stretch the full width 45 | const padding = 28 46 | const paddedWidth = windowWidth - padding * 2 47 | const paddedHeight = paddedWidth / aspectRatio 48 | 49 | // if the original image is smaller than the full width, use that instead 50 | const finalWidth = width < paddedWidth ? width : paddedWidth 51 | const finalHeight = width < paddedWidth ? height : paddedHeight 52 | 53 | const press = () => { 54 | onPress(props) // this, from use-clipboard, will re-copy this item. 55 | Notifier.showNotification({ 56 | title: 'Copied Image!', 57 | componentProps: { 58 | imageSource: { 59 | uri: url, 60 | }, 61 | }, 62 | Component: NotifierComponents.Notification, 63 | }) 64 | } 65 | 66 | return ( 67 | onDelete(props)}> 68 | 78 | 79 | 90 | 95 | 96 | } 97 | subtitle={`${props.type} copied ${moment( 98 | props.copiedAt 99 | ).calendar()}`} 100 | subtitleStyle={{ marginTop: 16 }} 101 | > 102 | 103 | 104 | 105 | ) 106 | } 107 | 108 | const { type, value, copiedAt } = props 109 | 110 | const press = () => { 111 | onPress(props) 112 | Notifier.showNotification({ 113 | title: 'Copied!', 114 | // @ts-ignore (we pass a text node here instead of a string, it's fine) 115 | description: {value}, 116 | Component: NotifierComponents.Notification, 117 | }) 118 | } 119 | 120 | return ( 121 | onDelete(props)}> 122 | 131 | 132 | 140 | 141 | 142 | 143 | ) 144 | }) 145 | -------------------------------------------------------------------------------- /src/use-clipboard.ts: -------------------------------------------------------------------------------- 1 | import clipboardSubscription from '@nandorojo/electron-clipboard' 2 | import { clipboard, nativeImage } from 'electron' 3 | import { useEffect, useState, useCallback, useRef } from 'react' 4 | import Store from 'electron-store' 5 | import { FlatList } from 'react-native' 6 | 7 | const store = new Store({ accessPropertiesByDotNotation: true, watch: true }) 8 | 9 | const STORAGE_KEY = 'clipboard-history' 10 | 11 | export type HistoryItemImage = { 12 | type: 'image' 13 | value: { 14 | url: string 15 | height: number 16 | width: number 17 | } 18 | copiedAt?: string 19 | } 20 | 21 | type HistoryImageText = { 22 | type: 'text' 23 | value: string 24 | copiedAt?: string 25 | } 26 | 27 | // this looks weird, but it helps Typescript deal with the two differing "value" types for HistoryItem 28 | // it's just a necessary convenience function used later 29 | export function isHistoryImage( 30 | copied: HistoryItem 31 | ): copied is HistoryItemImage { 32 | return ( 33 | !!((copied as HistoryItemImage).type === 'image') && 34 | !!(copied as HistoryItemImage)?.value?.url 35 | ) 36 | } 37 | // schema for an item in the history 38 | export type HistoryItem = HistoryImageText | HistoryItemImage 39 | 40 | export const useClipboard = () => { 41 | const [history, setHistory] = useState( 42 | store.get(STORAGE_KEY) ?? [] 43 | ) 44 | const flatlist = useRef>(null) 45 | 46 | useEffect(() => { 47 | // listen to changes in the system clipboard 48 | // when it changes, we update the history in the local storage cache 49 | // this triggers a subscription in the next useEffect() function 50 | const subscription = clipboardSubscription 51 | .on('text-changed', () => { 52 | const justCopied = clipboardSubscription.readText() 53 | if (!justCopied || !justCopied.trim()) return 54 | const previouslyCopied = store.get(STORAGE_KEY) ?? [] 55 | 56 | // add the recently-copied value 57 | store.set(STORAGE_KEY, [ 58 | { 59 | type: 'text', 60 | value: justCopied, 61 | copiedAt: new Date().toString(), 62 | }, 63 | ...previouslyCopied, 64 | ]) 65 | }) 66 | .on('image-changed', () => { 67 | const justCopied = clipboard.readImage() 68 | if (!justCopied) return 69 | const previouslyCopied = store.get(STORAGE_KEY) ?? [] 70 | console.log('[use-clipboard][image-changed]', { justCopied }) 71 | 72 | // add the recently-copied value 73 | store.set(STORAGE_KEY, [ 74 | { 75 | type: 'image', 76 | value: { url: justCopied.toDataURL(), ...justCopied.getSize() }, 77 | copiedAt: new Date().toString(), 78 | }, 79 | ...previouslyCopied, 80 | ]) 81 | }) 82 | .startWatching() 83 | return () => { 84 | clipboardSubscription.off('text-changed', subscription) 85 | clipboardSubscription.off('image-changed', subscription) 86 | } 87 | }, []) 88 | 89 | useEffect(() => { 90 | // listen to changes in the local storage state, and update the app state when they fire 91 | const unsubscribe = store.onDidChange(STORAGE_KEY, (newValue, oldValue) => { 92 | console.log('[use-clipboard] updated', STORAGE_KEY, { 93 | newValue, 94 | oldValue, 95 | }) 96 | if (newValue) { 97 | setHistory(newValue) 98 | } 99 | }) 100 | return () => unsubscribe() 101 | }, []) 102 | 103 | const clearHistory = useCallback(() => { 104 | store.set(STORAGE_KEY, []) 105 | }, []) 106 | 107 | const handleClickItem = useCallback((item: HistoryItem) => { 108 | if (isHistoryImage(item)) { 109 | // if (item.value.url !== history[0].value) { 110 | clipboard.write({ 111 | image: nativeImage.createFromDataURL(item.value.url), 112 | }) 113 | // } 114 | return 115 | } 116 | // if (item.value !== history[0].value) { 117 | clipboard.write({ 118 | text: item.value, 119 | }) 120 | // } 121 | }, []) 122 | 123 | const handleDeleteItem = useCallback( 124 | ( 125 | item: HistoryItem, 126 | { 127 | removeAllInstances, 128 | }: { 129 | /** 130 | * TODO add this 131 | * If `true`, it will delete any items with the given value. 132 | * If `false` (default), it will **only** delete the item you clicked, and not any others with the same value. 133 | * - It will use the date to determine this. 134 | */ 135 | removeAllInstances?: boolean 136 | } = {} 137 | ) => { 138 | const currentState: HistoryItem[] = store.get(STORAGE_KEY) ?? [] 139 | store.set( 140 | STORAGE_KEY, 141 | currentState.filter(i => { 142 | // if we just removed an image, and this one is an image 143 | if (isHistoryImage(i) && isHistoryImage(item)) { 144 | // remove the item if it has the same url and timestamp 145 | const isItemToRemove = 146 | i.value.url === item.value.url && 147 | // if remove all instances is false, then we only remove this exact item 148 | // ...which we itentify by the timestamp 149 | (removeAllInstances || i.copiedAt === item.copiedAt) 150 | return !isItemToRemove 151 | } 152 | const isItemToRemove = 153 | i.value === item.value && 154 | // if remove all instances is false, then we only remove this exact item 155 | // ...which we itentify by the timestamp 156 | (removeAllInstances || i.copiedAt === item.copiedAt) 157 | return !isItemToRemove 158 | }) 159 | ) 160 | }, 161 | [] 162 | ) 163 | 164 | return { 165 | history: history, 166 | clearHistory, 167 | handleClickItem, 168 | handleDeleteItem, 169 | flatlist, 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/Swipeable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import Swipes, { 3 | SwipeableProperties, 4 | } from 'react-native-gesture-handler/Swipeable' 5 | import { Animated, Text, View, StyleSheet, TextStyle } from 'react-native' 6 | import { RectButton } from 'react-native-gesture-handler' 7 | import { Ionicons } from '@expo/vector-icons' 8 | 9 | type SwipeableProps = { 10 | rightActions?: null | SwipeableAction[] 11 | leftActions?: null | SwipeableAction[] 12 | children: React.ReactNode 13 | // id: string 14 | actionWidth?: number 15 | gestureHandlerProps?: SwipeableProperties 16 | textStyle?: TextStyle 17 | renderLeftActions?: 18 | | null 19 | | (( 20 | actions: SwipeableAction[], 21 | progress: Animated.Value | Animated.AnimatedInterpolation, 22 | drag: Animated.AnimatedInterpolation 23 | ) => React.ReactNode) 24 | renderRightActions?: 25 | | null 26 | | (( 27 | action: SwipeableAction[], 28 | progress: Animated.Value | Animated.AnimatedInterpolation, 29 | drag: Animated.AnimatedInterpolation 30 | ) => React.ReactNode) 31 | renderAction?: 32 | | null 33 | | (( 34 | action: SwipeableAction, 35 | index: number, 36 | progress: Animated.Value | Animated.AnimatedInterpolation, 37 | drag: Animated.AnimatedInterpolation, 38 | side: 'left' | 'right' 39 | ) => React.ReactNode) 40 | } 41 | 42 | type SwipeableAction = { 43 | text: string 44 | renderIcon?: ( 45 | progress?: Animated.Value | Animated.AnimatedInterpolation, 46 | drag?: Animated.AnimatedInterpolation 47 | ) => React.ReactNode 48 | color: string 49 | backgroundColor: string 50 | onPress?: () => void 51 | } 52 | 53 | const Swipeable = (props: SwipeableProps) => { 54 | const { actionWidth, gestureHandlerProps = {} } = props 55 | const actionItemWidth = actionWidth || 64 56 | const swipeableRowRef = useRef(null) 57 | 58 | function renderAction( 59 | action: SwipeableAction, 60 | index: number, 61 | progress: Progress, 62 | drag: Drag, 63 | side: 'left' | 'right', 64 | numberOfActions: number 65 | ) { 66 | if (props.renderAction) 67 | return props.renderAction(action, index, progress, drag, side) 68 | if (props.renderAction === null) return null 69 | 70 | const outputRange = 71 | side === 'right' 72 | ? [(numberOfActions - index) * actionItemWidth, 0] 73 | : [-index * actionItemWidth, 0] 74 | 75 | const translateX = progress.interpolate({ 76 | inputRange: [0, 1], 77 | outputRange, 78 | }) 79 | 80 | function onPress() { 81 | if (swipeableRowRef.current) swipeableRowRef.current.close() 82 | if (action.onPress) action.onPress() 83 | } 84 | 85 | return ( 86 | 90 | 94 | {action.renderIcon && action.renderIcon(progress, drag)} 95 | 102 | {action.text} 103 | 104 | 105 | 106 | ) 107 | } 108 | function renderRightActions(progress: Progress, drag: Drag) { 109 | const actions = 110 | props.rightActions || 111 | [ 112 | // { 113 | // color: 'white', 114 | // backgroundColor: 'purple', 115 | // IconNode: null, 116 | // text: 'Test' 117 | // } 118 | ] 119 | if (props.renderRightActions) 120 | return props.renderRightActions(actions, progress, drag) 121 | if (props.renderRightActions === null) return null 122 | 123 | return renderActionList(actions, progress, drag, 'right') 124 | } 125 | function renderLeftActions(progress: Progress, drag: Drag) { 126 | const actions = 127 | props.leftActions || 128 | [ 129 | // { 130 | // color: 'white', 131 | // backgroundColor: 'blue', 132 | // IconNode: null, 133 | // text: 'Scripture monkey' 134 | // } 135 | ] 136 | if (props.renderLeftActions) 137 | return props.renderLeftActions(actions, progress, drag) 138 | if (props.renderLeftActions === null) return null 139 | 140 | return renderActionList(actions, progress, drag, 'left') 141 | } 142 | function renderActionList( 143 | actions: SwipeableAction[], 144 | progress: Progress, 145 | drag: Drag, 146 | side: 'left' | 'right' 147 | ) { 148 | return ( 149 | 152 | {actions.map((action, index) => 153 | renderAction(action, index, progress, drag, side, actions.length) 154 | )} 155 | 156 | ) 157 | } 158 | return ( 159 | 168 | {props.children} 169 | 170 | ) 171 | } 172 | 173 | export default React.memo(Swipeable) 174 | 175 | type Progress = Animated.Value | Animated.AnimatedInterpolation 176 | type Drag = Animated.AnimatedInterpolation 177 | 178 | const styles = StyleSheet.create({ 179 | actionList: { 180 | flexDirection: 'row', 181 | }, 182 | action: { 183 | alignItems: 'center', 184 | justifyContent: 'center', 185 | flex: 1, 186 | }, 187 | actionText: { 188 | padding: 10, 189 | }, 190 | }) 191 | 192 | export function SwipeableItem({ 193 | onDelete, 194 | children, 195 | }: { 196 | onDelete: () => void 197 | children: React.ReactNode 198 | }) { 199 | return ( 200 | 226 | 232 | 233 | ) 234 | }, 235 | }, 236 | ]} 237 | > 238 | {children} 239 | 240 | ) 241 | } 242 | --------------------------------------------------------------------------------