├── .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 |
--------------------------------------------------------------------------------