20 Commits
v0.0.2 ... main

Author SHA1 Message Date
91bcc6072f Fix image resize mode
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-08-06 15:13:07 +03:00
231c9b0d79 Add skeleton placeholders
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-08-04 14:15:03 +03:00
1b09b058e4 Improve performance
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-08-04 13:39:55 +03:00
5958cf57ee Fix crash when creating tag inside meme editor
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-08-04 12:21:41 +03:00
e550fcd881 Fix a couple of bugs
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-08-03 21:01:25 +03:00
665931f7b9 Add text recognition
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-08-03 14:20:23 +03:00
d2054b028a Reorganize files
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-08-01 14:53:10 +03:00
b83407f1f4 Add video support
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-08-01 14:35:10 +03:00
f1f969c8ea Refactor editor architecture
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-08-01 08:58:35 +03:00
880c20661e Fix crash when deleting memes
Again.

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-07-31 10:03:16 +03:00
f635c9d961 Improve mime type handling using magic byes
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-07-30 16:07:06 +03:00
5a35191d12 Fix crash when changing column numbers
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-07-30 10:13:41 +03:00
7b39d80c9b Add searchbar autofocus
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-07-29 23:23:22 +03:00
e794832f38 Add root snackbar
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-07-29 23:11:00 +03:00
5770a9b234 Add pasting from clipboard
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-07-29 22:43:58 +03:00
391e232bf7 Add custom AnimatedImage component
This also fixes the white flashing when loading images

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-07-29 20:56:37 +03:00
a5911ff617 Fix crash when deleting memes
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-07-29 20:55:18 +03:00
f33fe2c54b Add share intent
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-07-29 19:14:19 +03:00
a0b7a6310b Enable backups
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-07-29 13:23:04 +03:00
2a5165abf6 Add variable storage locations & batch adding
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2023-07-28 21:18:38 +03:00
68 changed files with 2746 additions and 1131 deletions

View File

@@ -79,7 +79,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
versionName "0.0.8"
}
signingConfigs {
debug {

View File

@@ -7,7 +7,7 @@
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:allowBackup="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
@@ -20,6 +20,54 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/bmp" />
<data android:mimeType="image/jpeg" />
<data android:mimeType="image/png" />
<data android:mimeType="image/webp" />
<data android:mimeType="image/gif" />
<data android:mimeType="video/av01" />
<data android:mimeType="video/3gpp" />
<data android:mimeType="video/avc" />
<data android:mimeType="video/hevc" />
<data android:mimeType="video/x-matroska" />
<data android:mimeType="video/mp2t" />
<data android:mimeType="video/mp4" />
<data android:mimeType="video/mp42" />
<data android:mimeType="video/mp43" />
<data android:mimeType="video/mp4v-es" />
<data android:mimeType="video/mpeg" />
<data android:mimeType="video/mpeg2" />
<data android:mimeType="video/x-vnd.on2.vp8" />
<data android:mimeType="video/x-vnd.on2.vp9" />
<data android:mimeType="video/webm" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/bmp" />
<data android:mimeType="image/jpeg" />
<data android:mimeType="image/png" />
<data android:mimeType="image/webp" />
<data android:mimeType="image/gif" />
<data android:mimeType="video/av01" />
<data android:mimeType="video/3gpp" />
<data android:mimeType="video/avc" />
<data android:mimeType="video/hevc" />
<data android:mimeType="video/x-matroska" />
<data android:mimeType="video/mp2t" />
<data android:mimeType="video/mp4" />
<data android:mimeType="video/mp42" />
<data android:mimeType="video/mp43" />
<data android:mimeType="video/mp4v-es" />
<data android:mimeType="video/mpeg" />
<data android:mimeType="video/mpeg2" />
<data android:mimeType="video/x-vnd.on2.vp8" />
<data android:mimeType="video/x-vnd.on2.vp9" />
<data android:mimeType="video/webm" />
</intent-filter>
</activity>
</application>
</manifest>

195
package-lock.json generated
View File

@@ -1,30 +1,34 @@
{
"name": "@karaolidis/terminally-online",
"version": "0.0.2",
"version": "0.0.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@karaolidis/terminally-online",
"version": "0.0.2",
"version": "0.0.4",
"hasInstallScript": true,
"dependencies": {
"@bankify/redux-persist-realm": "^0.1.3",
"@likashefqet/react-native-image-zoom": "^1.3.0",
"@react-native-clipboard/clipboard": "^1.11.2",
"@react-native-community/hooks": "^3.0.0",
"@react-native-masked-view/masked-view": "^0.2.9",
"@react-native-ml-kit/text-recognition": "^1.2.1",
"@react-navigation/bottom-tabs": "^6.5.8",
"@react-navigation/native": "^6.1.7",
"@react-navigation/native-stack": "^6.9.13",
"@realm/react": "^0.5.1",
"@reduxjs/toolkit": "^1.9.5",
"@shopify/flash-list": "^1.4.3",
"magic-bytes.js": "^1.0.15",
"react": "18.2.0",
"react-native": "0.72.2",
"react-native-document-picker": "^9.0.1",
"react-native-fast-image": "^8.6.3",
"react-native-file-access": "^3.0.4",
"react-native-gesture-handler": "^2.12.0",
"react-native-get-random-values": "^1.9.0",
"react-native-linear-gradient": "^2.8.1",
"react-native-mime-types": "^2.4.0",
"react-native-paper": "^5.9.1",
"react-native-reanimated": "^3.3.0",
@@ -32,6 +36,8 @@
"react-native-scoped-storage": "^1.9.3",
"react-native-screens": "^3.22.1",
"react-native-share": "^9.2.3",
"react-native-share-menu": "^6.0.0",
"react-native-skeleton-placeholder": "^5.2.4",
"react-native-vector-icons": "^9.2.0",
"react-native-video": "^6.0.0-alpha.6",
"react-redux": "^8.1.1",
@@ -49,6 +55,7 @@
"@types/jest": "^29.5.2",
"@types/metro-config": "^0.76.3",
"@types/react": "^18.2.14",
"@types/react-native-share-menu": "^5.0.2",
"@types/react-native-vector-icons": "^6.4.13",
"@types/react-native-video": "^5.0.15",
"@types/react-test-renderer": "^18.0.0",
@@ -2989,17 +2996,6 @@
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw=="
},
"node_modules/@likashefqet/react-native-image-zoom": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@likashefqet/react-native-image-zoom/-/react-native-image-zoom-1.3.0.tgz",
"integrity": "sha512-PLRd1hNMHe9LUn8b4rmLt86282geuaqP4Qd2rFWIloxMS2ePNTIaNlEUu3T3LaO8Pg9vhVV97TxfFeU8F+tcYQ==",
"peerDependencies": {
"react": ">=16.x.x",
"react-native": ">=0.62.x",
"react-native-gesture-handler": ">=2.x.x",
"react-native-reanimated": ">=2.x.x"
}
},
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -4122,6 +4118,24 @@
"react-native": ">=0.65"
}
},
"node_modules/@react-native-masked-view/masked-view": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.2.9.tgz",
"integrity": "sha512-Hs4vKBKj+15VxHZHFtMaFWSBxXoOE5Ea8saoigWhahp8Mepssm0ezU+2pTl7DK9z8Y9s5uOl/aPb4QmBZ3R3Zw==",
"peerDependencies": {
"react": ">=16",
"react-native": ">=0.57"
}
},
"node_modules/@react-native-ml-kit/text-recognition": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@react-native-ml-kit/text-recognition/-/text-recognition-1.2.1.tgz",
"integrity": "sha512-pJrnf8AvihzYdPAZoZZEeKbOUOMjdsetDjHlleXOoVcoPo6qjfh6Il/Q0ey3boIQuO3HglvNjcMPGEPThF3sPA==",
"peerDependencies": {
"react": ">=16.8.1",
"react-native": ">=0.60.0-rc.0 <1.0.x"
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.72.0",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.72.0.tgz",
@@ -4615,6 +4629,12 @@
"@types/react": "*"
}
},
"node_modules/@types/react-native-share-menu": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/react-native-share-menu/-/react-native-share-menu-5.0.2.tgz",
"integrity": "sha512-Qa9DGfL6Bvng2DXgCK0fFzdi9SJMGfs06MLSkCfSXBCGKlFLzSHCsXztvXlCCChn3dQArFHyz/uRUN3Sbt6LtQ==",
"dev": true
},
"node_modules/@types/react-native-vector-icons": {
"version": "6.4.13",
"resolved": "https://registry.npmjs.org/@types/react-native-vector-icons/-/react-native-vector-icons-6.4.13.tgz",
@@ -5621,6 +5641,29 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/bl/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -5691,7 +5734,7 @@
"node": ">=6.9.0"
}
},
"node_modules/buffer": {
"node_modules/bson/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
@@ -11099,6 +11142,11 @@
"yallist": "^3.0.2"
}
},
"node_modules/magic-bytes.js": {
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.0.15.tgz",
"integrity": "sha512-bpRmwbRHqongRhA+mXzbLWjVy7ylqmfMBYaQkSs6pac0z6hBTvsgrH0r4FBYd/UYVJBmS6Rp/O+oCCQVLzKV1g=="
},
"node_modules/magic-string": {
"version": "0.30.1",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.1.tgz",
@@ -13399,6 +13447,15 @@
}
}
},
"node_modules/react-native-fast-image": {
"version": "8.6.3",
"resolved": "https://registry.npmjs.org/react-native-fast-image/-/react-native-fast-image-8.6.3.tgz",
"integrity": "sha512-Sdw4ESidXCXOmQ9EcYguNY2swyoWmx53kym2zRsvi+VeFCHEdkO+WG1DK+6W81juot40bbfLNhkc63QnWtesNg==",
"peerDependencies": {
"react": "^17 || ^18",
"react-native": ">=0.60.0"
}
},
"node_modules/react-native-file-access": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-native-file-access/-/react-native-file-access-3.0.4.tgz",
@@ -13435,6 +13492,15 @@
"react-native": ">=0.56"
}
},
"node_modules/react-native-linear-gradient": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/react-native-linear-gradient/-/react-native-linear-gradient-2.8.1.tgz",
"integrity": "sha512-934R4Bnjo7mYT38W9ypS1Dq/YW6TgyGdkHg+w72HNxN0ZDKG1GqAnZ6XlicMUYJDh7ViiJAKN8eOF3Ho0N4J0Q==",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-mime-types": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/react-native-mime-types/-/react-native-mime-types-2.4.0.tgz",
@@ -13542,6 +13608,22 @@
"resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-9.2.3.tgz",
"integrity": "sha512-y6ju4HS6ydJoPVoacZ/Hp3i47AfI9W4e76Jv00r01dVbr6SCCcuqk37kIbn+kYivdTxOW77UGEbhtBHHtXnhzg=="
},
"node_modules/react-native-share-menu": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/react-native-share-menu/-/react-native-share-menu-6.0.0.tgz",
"integrity": "sha512-KdmRnqjI/B2MigSxGmhbYJ3WMJxKXj+0c47ANcVZ/PTzc2vtz6d1r4KQJgkBImXgNC+vowpuD2UGdPllxadr2A=="
},
"node_modules/react-native-skeleton-placeholder": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/react-native-skeleton-placeholder/-/react-native-skeleton-placeholder-5.2.4.tgz",
"integrity": "sha512-OZntVq1hU1UX33FltxK2ezT2v9vHIhV8YnEbnMWUCvxT0N9OsgD1qxiHm6qb9YRJVgq2o5z3S7dNPsPnDF/jNg==",
"peerDependencies": {
"@react-native-masked-view/masked-view": "^0.2.8",
"react": ">=0.14.8",
"react-native": ">=0.50.1",
"react-native-linear-gradient": "^2.5.6"
}
},
"node_modules/react-native-vector-icons": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-9.2.0.tgz",
@@ -17927,12 +18009,6 @@
}
}
},
"@likashefqet/react-native-image-zoom": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@likashefqet/react-native-image-zoom/-/react-native-image-zoom-1.3.0.tgz",
"integrity": "sha512-PLRd1hNMHe9LUn8b4rmLt86282geuaqP4Qd2rFWIloxMS2ePNTIaNlEUu3T3LaO8Pg9vhVV97TxfFeU8F+tcYQ==",
"requires": {}
},
"@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -18775,6 +18851,18 @@
"integrity": "sha512-g2OyxXHfwIytXUJitBR6Z/ISoOfp0WKx5FOv+NqJ/CrWjRDcTw6zXE5I1C9axfuh30kJqzWchVfCDrkzZYTxqg==",
"requires": {}
},
"@react-native-masked-view/masked-view": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.2.9.tgz",
"integrity": "sha512-Hs4vKBKj+15VxHZHFtMaFWSBxXoOE5Ea8saoigWhahp8Mepssm0ezU+2pTl7DK9z8Y9s5uOl/aPb4QmBZ3R3Zw==",
"requires": {}
},
"@react-native-ml-kit/text-recognition": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@react-native-ml-kit/text-recognition/-/text-recognition-1.2.1.tgz",
"integrity": "sha512-pJrnf8AvihzYdPAZoZZEeKbOUOMjdsetDjHlleXOoVcoPo6qjfh6Il/Q0ey3boIQuO3HglvNjcMPGEPThF3sPA==",
"requires": {}
},
"@react-native/assets-registry": {
"version": "0.72.0",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.72.0.tgz",
@@ -19197,6 +19285,12 @@
"@types/react": "*"
}
},
"@types/react-native-share-menu": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/react-native-share-menu/-/react-native-share-menu-5.0.2.tgz",
"integrity": "sha512-Qa9DGfL6Bvng2DXgCK0fFzdi9SJMGfs06MLSkCfSXBCGKlFLzSHCsXztvXlCCChn3dQArFHyz/uRUN3Sbt6LtQ==",
"dev": true
},
"@types/react-native-vector-icons": {
"version": "6.4.13",
"resolved": "https://registry.npmjs.org/@types/react-native-vector-icons/-/react-native-vector-icons-6.4.13.tgz",
@@ -19946,6 +20040,17 @@
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
},
"dependencies": {
"buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"requires": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
}
}
},
"brace-expansion": {
@@ -19990,15 +20095,17 @@
"integrity": "sha512-Uu4OCZa0jouQJCKOk1EmmyqtdWAP5HVLru4lQxTwzJzxT+sJ13lVpEZU/MATDxtHiekWMAL84oQY3Xn1LpJVSg==",
"requires": {
"buffer": "^5.6.0"
}
},
"buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"requires": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
},
"dependencies": {
"buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"requires": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
}
}
},
"buffer-from": {
@@ -23978,6 +24085,11 @@
"yallist": "^3.0.2"
}
},
"magic-bytes.js": {
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.0.15.tgz",
"integrity": "sha512-bpRmwbRHqongRhA+mXzbLWjVy7ylqmfMBYaQkSs6pac0z6hBTvsgrH0r4FBYd/UYVJBmS6Rp/O+oCCQVLzKV1g=="
},
"magic-string": {
"version": "0.30.1",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.1.tgz",
@@ -25797,6 +25909,12 @@
"invariant": "^2.2.4"
}
},
"react-native-fast-image": {
"version": "8.6.3",
"resolved": "https://registry.npmjs.org/react-native-fast-image/-/react-native-fast-image-8.6.3.tgz",
"integrity": "sha512-Sdw4ESidXCXOmQ9EcYguNY2swyoWmx53kym2zRsvi+VeFCHEdkO+WG1DK+6W81juot40bbfLNhkc63QnWtesNg==",
"requires": {}
},
"react-native-file-access": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-native-file-access/-/react-native-file-access-3.0.4.tgz",
@@ -25823,6 +25941,12 @@
"fast-base64-decode": "^1.0.0"
}
},
"react-native-linear-gradient": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/react-native-linear-gradient/-/react-native-linear-gradient-2.8.1.tgz",
"integrity": "sha512-934R4Bnjo7mYT38W9ypS1Dq/YW6TgyGdkHg+w72HNxN0ZDKG1GqAnZ6XlicMUYJDh7ViiJAKN8eOF3Ho0N4J0Q==",
"requires": {}
},
"react-native-mime-types": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/react-native-mime-types/-/react-native-mime-types-2.4.0.tgz",
@@ -25904,6 +26028,17 @@
"resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-9.2.3.tgz",
"integrity": "sha512-y6ju4HS6ydJoPVoacZ/Hp3i47AfI9W4e76Jv00r01dVbr6SCCcuqk37kIbn+kYivdTxOW77UGEbhtBHHtXnhzg=="
},
"react-native-share-menu": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/react-native-share-menu/-/react-native-share-menu-6.0.0.tgz",
"integrity": "sha512-KdmRnqjI/B2MigSxGmhbYJ3WMJxKXj+0c47ANcVZ/PTzc2vtz6d1r4KQJgkBImXgNC+vowpuD2UGdPllxadr2A=="
},
"react-native-skeleton-placeholder": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/react-native-skeleton-placeholder/-/react-native-skeleton-placeholder-5.2.4.tgz",
"integrity": "sha512-OZntVq1hU1UX33FltxK2ezT2v9vHIhV8YnEbnMWUCvxT0N9OsgD1qxiHm6qb9YRJVgq2o5z3S7dNPsPnDF/jNg==",
"requires": {}
},
"react-native-vector-icons": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-9.2.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@karaolidis/terminally-online",
"version": "0.0.2",
"version": "0.0.8",
"private": true,
"scripts": {
"postinstall": "patch-package",
@@ -15,21 +15,25 @@
},
"dependencies": {
"@bankify/redux-persist-realm": "^0.1.3",
"@likashefqet/react-native-image-zoom": "^1.3.0",
"@react-native-clipboard/clipboard": "^1.11.2",
"@react-native-community/hooks": "^3.0.0",
"@react-native-masked-view/masked-view": "^0.2.9",
"@react-native-ml-kit/text-recognition": "^1.2.1",
"@react-navigation/bottom-tabs": "^6.5.8",
"@react-navigation/native": "^6.1.7",
"@react-navigation/native-stack": "^6.9.13",
"@realm/react": "^0.5.1",
"@reduxjs/toolkit": "^1.9.5",
"@shopify/flash-list": "^1.4.3",
"magic-bytes.js": "^1.0.15",
"react": "18.2.0",
"react-native": "0.72.2",
"react-native-document-picker": "^9.0.1",
"react-native-fast-image": "^8.6.3",
"react-native-file-access": "^3.0.4",
"react-native-gesture-handler": "^2.12.0",
"react-native-get-random-values": "^1.9.0",
"react-native-linear-gradient": "^2.8.1",
"react-native-mime-types": "^2.4.0",
"react-native-paper": "^5.9.1",
"react-native-reanimated": "^3.3.0",
@@ -37,6 +41,8 @@
"react-native-scoped-storage": "^1.9.3",
"react-native-screens": "^3.22.1",
"react-native-share": "^9.2.3",
"react-native-share-menu": "^6.0.0",
"react-native-skeleton-placeholder": "^5.2.4",
"react-native-vector-icons": "^9.2.0",
"react-native-video": "^6.0.0-alpha.6",
"react-redux": "^8.1.1",
@@ -54,6 +60,7 @@
"@types/jest": "^29.5.2",
"@types/metro-config": "^0.76.3",
"@types/react": "^18.2.14",
"@types/react-native-share-menu": "^5.0.2",
"@types/react-native-vector-icons": "^6.4.13",
"@types/react-native-video": "^5.0.15",
"@types/react-test-renderer": "^18.0.0",

View File

@@ -1,11 +1,134 @@
diff --git a/node_modules/@react-native-clipboard/clipboard/android/src/main/java/com/reactnativecommunity/clipboard/ClipboardModule.java b/node_modules/@react-native-clipboard/clipboard/android/src/main/java/com/reactnativecommunity/clipboard/ClipboardModule.java
index 048ebe5..8afa5b2 100644
index 048ebe5..01fa3ad 100644
--- a/node_modules/@react-native-clipboard/clipboard/android/src/main/java/com/reactnativecommunity/clipboard/ClipboardModule.java
+++ b/node_modules/@react-native-clipboard/clipboard/android/src/main/java/com/reactnativecommunity/clipboard/ClipboardModule.java
@@ -156,6 +156,17 @@ public class ClipboardModule extends ReactContextBaseJavaModule {
@@ -24,6 +24,7 @@ import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;
+import com.facebook.react.bridge.WritableNativeMap;
import com.facebook.react.module.annotations.ReactModule;
import com.facebook.react.modules.core.DeviceEventManagerModule;
@@ -70,9 +71,9 @@ public class ClipboardModule extends ReactContextBaseJavaModule {
ClipData clipData = clipboard.getPrimaryClip();
if (clipData != null && clipData.getItemCount() >= 1) {
ClipData.Item firstItem = clipboard.getPrimaryClip().getItemAt(0);
- promise.resolve("" + firstItem.getText());
+ promise.resolve(firstItem.getText());
} else {
- promise.resolve("");
+ promise.resolve(null);
}
} catch (Exception e) {
promise.reject(e);
@@ -95,34 +96,37 @@ public class ClipboardModule extends ReactContextBaseJavaModule {
try {
ClipboardManager clipboard = getClipboardService();
ClipData clipData = clipboard.getPrimaryClip();
- promise.resolve(clipData != null && clipData.getItemCount() >= 1);
+ if (clipData != null && clipData.getItemCount() >= 1) {
+ ClipData.Item firstItem = clipboard.getPrimaryClip().getItemAt(0);
+ promise.resolve(firstItem.getText() != null);
+ } else {
+ promise.resolve(false);
+ }
} catch (Exception e) {
promise.reject(e);
}
}
@ReactMethod
- public void getImage(Promise promise){
+ public void getImage(Promise promise) {
ClipboardManager clipboardManager = getClipboardService();
- if (!(clipboardManager.hasPrimaryClip())){
- promise.resolve("");
- }
- else if (clipboardManager.getPrimaryClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)){
- promise.resolve("");
- }
- else {
+ if (!(clipboardManager.hasPrimaryClip())) {
+ promise.resolve(null);
+ } else if (clipboardManager.getPrimaryClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) {
+ promise.resolve(null);
+ } else {
ClipData clipData = clipboardManager.getPrimaryClip();
- if(clipData != null){
+ if (clipData != null) {
ClipData.Item item = clipData.getItemAt(0);
Uri pasteUri = item.getUri();
- if (pasteUri != null){
+ if (pasteUri != null) {
ContentResolver cr = reactContext.getContentResolver();
String mimeType = cr.getType(pasteUri);
- if (mimeType != null){
+ if (mimeType != null) {
try {
Bitmap bitmap = MediaStore.Images.Media.getBitmap(cr, pasteUri);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
- switch(mimeType){
+ switch (mimeType) {
case MIMETYPE_JPEG:
case MIMETYPE_JPG:
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream);
@@ -133,7 +137,7 @@ public class ClipboardModule extends ReactContextBaseJavaModule {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream);
break;
case MIMETYPE_WEBP:
- if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q){
+ if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSLESS, 100, outputStream);
break;
}
@@ -156,6 +160,77 @@ public class ClipboardModule extends ReactContextBaseJavaModule {
}
}
+ @ReactMethod
+ public void hasImage(Promise promise) {
+ try {
+ ClipboardManager clipboard = getClipboardService();
+ ClipData clipData = clipboard.getPrimaryClip();
+ if (clipData != null && clipData.getItemCount() >= 1) {
+ ClipData.Item firstItem = clipboard.getPrimaryClip().getItemAt(0);
+ Uri pasteUri = firstItem.getUri();
+ if (pasteUri != null) {
+ ContentResolver cr = reactContext.getContentResolver();
+ String mimeType = cr.getType(pasteUri);
+ if (mimeType != null) {
+ promise.resolve(mimeType.startsWith("image/"));
+ return;
+ }
+ }
+ }
+ promise.resolve(false);
+ } catch (Exception e) {
+ promise.reject(e);
+ }
+ }
+
+ @ReactMethod
+ public void getURI(Promise promise) {
+ try {
+ ClipboardManager clipboard = getClipboardService();
+ ClipData clipData = clipboard.getPrimaryClip();
+ if (clipData != null && clipData.getItemCount() >= 1) {
+ ClipData.Item firstItem = clipboard.getPrimaryClip().getItemAt(0);
+ Uri uri = firstItem.getUri();
+ if (uri != null) {
+ promise.resolve(uri.toString());
+ } else {
+ promise.resolve(null);
+ }
+ }
+ promise.resolve(null);
+ } catch (Exception e) {
+ promise.reject(e);
+ }
+ }
+
+ @ReactMethod
+ public void setURI(String uri) {
+ try {
@@ -16,20 +139,69 @@ index 048ebe5..8afa5b2 100644
+ e.printStackTrace();
+ }
+ }
+
+ @ReactMethod
+ public void hasURI(Promise promise) {
+ try {
+ ClipboardManager clipboard = getClipboardService();
+ ClipData clipData = clipboard.getPrimaryClip();
+ if (clipData != null && clipData.getItemCount() >= 1) {
+ ClipData.Item firstItem = clipboard.getPrimaryClip().getItemAt(0);
+ Uri pasteUri = firstItem.getUri();
+ promise.resolve(pasteUri != null);
+ } else {
+ promise.resolve(false);
+ }
+ } catch (Exception e) {
+ promise.reject(e);
+ }
+ }
+
@ReactMethod
public void setListener() {
try {
@@ -164,8 +239,8 @@ public class ClipboardModule extends ReactContextBaseJavaModule {
@Override
public void onPrimaryClipChanged() {
reactContext
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
- .emit(CLIPBOARD_TEXT_CHANGED, null);
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
+ .emit(CLIPBOARD_TEXT_CHANGED, null);
}
};
clipboard.addPrimaryClipChangedListener(listener);
@@ -176,8 +251,8 @@ public class ClipboardModule extends ReactContextBaseJavaModule {
@ReactMethod
public void removeListener() {
- if(listener != null){
- try{
+ if (listener != null) {
+ try {
ClipboardManager clipboard = getClipboardService();
clipboard.removePrimaryClipChangedListener(listener);
} catch (Exception e) {
diff --git a/node_modules/@react-native-clipboard/clipboard/dist/Clipboard.d.ts b/node_modules/@react-native-clipboard/clipboard/dist/Clipboard.d.ts
index a3e4abd..904a199 100644
index a3e4abd..9fc11e6 100644
--- a/node_modules/@react-native-clipboard/clipboard/dist/Clipboard.d.ts
+++ b/node_modules/@react-native-clipboard/clipboard/dist/Clipboard.d.ts
@@ -81,6 +81,17 @@ export declare const Clipboard: {
@@ -81,6 +81,38 @@ export declare const Clipboard: {
* @param the content to be stored in the clipboard.
*/
setStrings(content: string[]): void;
+ /**
+ * (Android Only)
+ * Get content of URI type. You can use following code to get clipboard content
+ * ```javascript
+ * async _getContent() {
+ * var content = await Clipboard.getURI();
+ * }
+ * ```
+ */
+ getURI(): Promise<string>;
+ /**
+ * (Android Only)
+ * Set content of URI type. You can use following code to set clipboard content
+ * ```javascript
+ * _setContent() {
@@ -39,19 +211,90 @@ index a3e4abd..904a199 100644
+ * @param the content to be stored in the clipboard.
+ */
+ setURI(content: string): void;
+ /**
+ * (Android Only)
+ * Returns whether the clipboard has a URI or is empty.
+ * This method returns a `Promise`, so you can use following code to check clipboard content
+ * ```javascript
+ * async _hasContent() {
+ * var hasContent = await Clipboard.hasURI();
+ * }
+ * ```
+ */
+ hasURI(): Promise<boolean>;
/**
* Returns whether the clipboard has content or is empty.
* This method returns a `Promise`, so you can use following code to get clipboard content
@@ -90,7 +122,7 @@ export declare const Clipboard: {
* }
* ```
*/
- hasString(): any;
+ hasString(): Promise<boolean>;
/**
* Returns whether the clipboard has an image or is empty.
* This method returns a `Promise`, so you can use following code to check clipboard content
@@ -100,7 +132,7 @@ export declare const Clipboard: {
* }
* ```
*/
- hasImage(): any;
+ hasImage(): Promise<boolean>;
/**
* (iOS Only)
* Returns whether the clipboard has a URL content. Can check
@@ -112,7 +144,7 @@ export declare const Clipboard: {
* }
* ```
*/
- hasURL(): any;
+ hasURL(): Promise<boolean>;
/**
* (iOS 14+ Only)
* Returns whether the clipboard has a Number(UIPasteboardDetectionPatternNumber) content. Can check
@@ -124,7 +156,7 @@ export declare const Clipboard: {
* }
* ```
*/
- hasNumber(): any;
+ hasNumber(): Promise<boolean>;
/**
* (iOS 14+ Only)
* Returns whether the clipboard has a WebURL(UIPasteboardDetectionPatternProbableWebURL) content. Can check
@@ -136,7 +168,7 @@ export declare const Clipboard: {
* }
* ```
*/
- hasWebURL(): any;
+ hasWebURL(): Promise<boolean>;
/**
* (iOS and Android Only)
* Adds a listener to get notifications when the clipboard has changed.
diff --git a/node_modules/@react-native-clipboard/clipboard/dist/Clipboard.js b/node_modules/@react-native-clipboard/clipboard/dist/Clipboard.js
index 67b7237..0a74329 100644
index 67b7237..df3bff6 100644
--- a/node_modules/@react-native-clipboard/clipboard/dist/Clipboard.js
+++ b/node_modules/@react-native-clipboard/clipboard/dist/Clipboard.js
@@ -123,6 +123,22 @@ exports.Clipboard = {
@@ -123,6 +123,53 @@ exports.Clipboard = {
setStrings: function (content) {
NativeClipboard_1.default.setStrings(content);
},
+ /**
+ * (Android Only)
+ * Get content of URI type. You can use following code to get clipboard content
+ * ```javascript
+ * async _getContent() {
+ * var content = await Clipboard.getURI();
+ * }
+ * ```
+ */
+ getURI: function () {
+ if (react_native_1.Platform.OS !== 'android') {
+ return;
+ }
+ return NativeClipboard_1.default.getURI();
+ },
+ /**
+ * (Android Only)
+ * Set content of URI type. You can use following code to set clipboard content
+ * ```javascript
+ * _setContent() {
@@ -65,6 +308,22 @@ index 67b7237..0a74329 100644
+ return;
+ }
+ return NativeClipboard_1.default.setURI(content);
+ },
+ /**
+ * (Android Only)
+ * Returns whether the clipboard has a URI or is empty.
+ * This method returns a `Promise`, so you can use following code to check clipboard content
+ * ```javascript
+ * async _hasContent() {
+ * var hasContent = await Clipboard.hasURI();
+ * }
+ * ```
+ */
+ hasURI: function () {
+ if (react_native_1.Platform.OS !== 'android') {
+ return;
+ }
+ return NativeClipboard_1.default.hasURI();
+ },
/**
* Returns whether the clipboard has content or is empty.

View File

@@ -0,0 +1,169 @@
diff --git a/node_modules/react-native-file-access/README.md b/node_modules/react-native-file-access/README.md
index e0540e0..9eb3295 100644
--- a/node_modules/react-native-file-access/README.md
+++ b/node_modules/react-native-file-access/README.md
@@ -155,6 +155,12 @@ type ManagedFetchResult = {
- Read the content of a file.
- Default encoding of returned string is utf8.
+`FileSystem.read(path: string, length?: number, position?: number): Promise<number[]>`
+
+- Read a file as a byte array.
+ - `length` - Optional number of bytes to read.
+ - `position` - Optional position to start reading from.
+
```
FileSystem.stat(path: string): Promise<FileStat>
diff --git a/node_modules/react-native-file-access/android/src/main/java/com/alpha0010/fs/FileAccessModule.kt b/node_modules/react-native-file-access/android/src/main/java/com/alpha0010/fs/FileAccessModule.kt
index 248a938..cbd4fe7 100644
--- a/node_modules/react-native-file-access/android/src/main/java/com/alpha0010/fs/FileAccessModule.kt
+++ b/node_modules/react-native-file-access/android/src/main/java/com/alpha0010/fs/FileAccessModule.kt
@@ -13,6 +13,8 @@ import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap
+import com.facebook.react.bridge.WritableArray
+import com.facebook.react.bridge.WritableNativeArray
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -400,6 +402,27 @@ class FileAccessModule internal constructor(context: ReactApplicationContext) :
}
}
+ @ReactMethod
+ override fun read(path: String, length: Double, position: Double, promise: Promise) {
+ ioScope.launch {
+ try {
+ val data = openForReading(path).use {
+ it.skip(position.toLong())
+ val byteArray = ByteArray(length.toInt())
+ it.read(byteArray)
+ byteArray
+ }
+ val writableArray: WritableArray = WritableNativeArray()
+ for (byte in data) {
+ writableArray.pushInt(byte.toInt() and 0xFF)
+ }
+ promise.resolve(writableArray)
+ } catch (e: Throwable) {
+ promise.reject(e)
+ }
+ }
+ }
+
@ReactMethod
override fun stat(path: String, promise: Promise) {
ioScope.launch {
diff --git a/node_modules/react-native-file-access/android/src/oldarch/FileAccessSpec.kt b/node_modules/react-native-file-access/android/src/oldarch/FileAccessSpec.kt
index 736324b..c223276 100644
--- a/node_modules/react-native-file-access/android/src/oldarch/FileAccessSpec.kt
+++ b/node_modules/react-native-file-access/android/src/oldarch/FileAccessSpec.kt
@@ -30,6 +30,7 @@ abstract class FileAccessSpec internal constructor(context: ReactApplicationCont
abstract fun mkdir(path: String, promise: Promise)
abstract fun mv(source: String, target: String, promise: Promise)
abstract fun readFile(path: String, encoding: String, promise: Promise)
+ abstract fun read(path: String, length: Double, position: Double, promise: Promise)
abstract fun stat(path: String, promise: Promise)
abstract fun statDir(path: String, promise: Promise)
abstract fun unlink(path: String, promise: Promise)
diff --git a/node_modules/react-native-file-access/lib/commonjs/index.js b/node_modules/react-native-file-access/lib/commonjs/index.js
index 88f1c2c..20eb70b 100644
--- a/node_modules/react-native-file-access/lib/commonjs/index.js
+++ b/node_modules/react-native-file-access/lib/commonjs/index.js
@@ -209,6 +209,12 @@ const FileSystem = {
let encoding = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'utf8';
return FileAccessNative.readFile(path, encoding);
},
+ /**
+ * Read the content of a file as a byte array.
+ */
+ read(path, length, position) {
+ return FileAccessNative.read(path, length, position);
+ },
/**
* Read file metadata.
*/
diff --git a/node_modules/react-native-file-access/lib/module/index.js b/node_modules/react-native-file-access/lib/module/index.js
index 0581920..22c37d3 100644
--- a/node_modules/react-native-file-access/lib/module/index.js
+++ b/node_modules/react-native-file-access/lib/module/index.js
@@ -198,6 +198,12 @@ export const FileSystem = {
let encoding = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'utf8';
return FileAccessNative.readFile(path, encoding);
},
+ /**
+ * Read the content of a file as a byte array.
+ */
+ read(path, length, position) {
+ return FileAccessNative.read(path, length, position);
+ },
/**
* Read file metadata.
*/
diff --git a/node_modules/react-native-file-access/lib/typescript/NativeFileAccess.d.ts b/node_modules/react-native-file-access/lib/typescript/NativeFileAccess.d.ts
index 0c58874..ed84081 100644
--- a/node_modules/react-native-file-access/lib/typescript/NativeFileAccess.d.ts
+++ b/node_modules/react-native-file-access/lib/typescript/NativeFileAccess.d.ts
@@ -63,6 +63,7 @@ export interface Spec extends TurboModule {
mkdir(path: string): Promise<string>;
mv(source: string, target: string): Promise<void>;
readFile(path: string, encoding: string): Promise<string>;
+ read(path: string, length: number, position: number): Promise<number[]>;
stat(path: string): Promise<FileStat>;
statDir(path: string): Promise<FileStat[]>;
unlink(path: string): Promise<void>;
diff --git a/node_modules/react-native-file-access/lib/typescript/index.d.ts b/node_modules/react-native-file-access/lib/typescript/index.d.ts
index 5433d53..12854ca 100644
--- a/node_modules/react-native-file-access/lib/typescript/index.d.ts
+++ b/node_modules/react-native-file-access/lib/typescript/index.d.ts
@@ -84,6 +84,10 @@ export declare const FileSystem: {
* Read the content of a file.
*/
readFile(path: string, encoding?: Encoding): Promise<string>;
+ /**
+ * Read the content of a file as a byte array.
+ */
+ read(path: string, length?: number, position?: number): Promise<number[]>;
/**
* Read file metadata.
*/
diff --git a/node_modules/react-native-file-access/src/NativeFileAccess.ts b/node_modules/react-native-file-access/src/NativeFileAccess.ts
index b3a7baa..affd76c 100644
--- a/node_modules/react-native-file-access/src/NativeFileAccess.ts
+++ b/node_modules/react-native-file-access/src/NativeFileAccess.ts
@@ -71,6 +71,7 @@ export interface Spec extends TurboModule {
mkdir(path: string): Promise<string>;
mv(source: string, target: string): Promise<void>;
readFile(path: string, encoding: string): Promise<string>;
+ read(path: string, length: number, position: number): Promise<number[]>;
stat(path: string): Promise<FileStat>;
statDir(path: string): Promise<FileStat[]>;
unlink(path: string): Promise<void>;
diff --git a/node_modules/react-native-file-access/src/index.ts b/node_modules/react-native-file-access/src/index.ts
index 1b38d45..5c9fd5e 100644
--- a/node_modules/react-native-file-access/src/index.ts
+++ b/node_modules/react-native-file-access/src/index.ts
@@ -31,7 +31,6 @@ const LINKING_ERROR =
'- You rebuilt the app after installing the package\n' +
'- You are not using Expo Go\n';
-// @ts-expect-error
const isTurboModuleEnabled = global.__turboModuleProxy != null;
const FileAccessModule = isTurboModuleEnabled
@@ -275,6 +274,13 @@ export const FileSystem = {
return FileAccessNative.readFile(path, encoding);
},
+ /**
+ * Read the content of a file as a byte array.
+ */
+ read(path: string, length: number = 1000, position: number = 0) {
+ return FileAccessNative.read(path, length, position);
+ },
+
/**
* Read file metadata.
*/

View File

@@ -0,0 +1,38 @@
diff --git a/node_modules/react-native-share-menu/android/build.gradle b/node_modules/react-native-share-menu/android/build.gradle
index 9557fdb..b0503cb 100644
--- a/node_modules/react-native-share-menu/android/build.gradle
+++ b/node_modules/react-native-share-menu/android/build.gradle
@@ -1,12 +1,12 @@
apply plugin: 'com.android.library'
android {
- compileSdkVersion 29
- buildToolsVersion "29.0.2"
+ compileSdkVersion 33
+ buildToolsVersion "33.0.0"
defaultConfig {
- minSdkVersion 16
- targetSdkVersion 29
+ minSdkVersion 21
+ targetSdkVersion 33
versionCode 1
versionName "1.0"
ndk {
diff --git a/node_modules/react-native-share-menu/android/src/main/java/com/meedan/ShareMenuModule.java b/node_modules/react-native-share-menu/android/src/main/java/com/meedan/ShareMenuModule.java
index 09abd7b..af552b1 100644
--- a/node_modules/react-native-share-menu/android/src/main/java/com/meedan/ShareMenuModule.java
+++ b/node_modules/react-native-share-menu/android/src/main/java/com/meedan/ShareMenuModule.java
@@ -163,4 +163,12 @@ public class ShareMenuModule extends ReactContextBaseJavaModule implements Activ
// Update intent in case the user calls `getSharedText` again
currentActivity.setIntent(intent);
}
+
+ @ReactMethod
+ public void addListener(String eventName) {
+ }
+
+ @ReactMethod
+ public void removeListeners(Integer count) {
+ }
}

View File

@@ -54,13 +54,13 @@ const App = () => {
}, []);
return (
<PaperProvider theme={theme}>
<ReduxProvider store={store}>
<PersistGate
loading={<LoadingView />}
persistor={persistor}
onBeforeLift={onBeforeLift}>
<RealmProvider schema={[Meme, Tag]}>
<ReduxProvider store={store}>
<RealmProvider schema={[Meme, Tag]}>
<PaperProvider theme={theme}>
<PersistGate
loading={<LoadingView />}
persistor={persistor}
onBeforeLift={onBeforeLift}>
<GestureHandlerRootView style={appStyles.gestureHandler}>
<SafeAreaProvider>
<StatusBar
@@ -74,10 +74,10 @@ const App = () => {
)}
</SafeAreaProvider>
</GestureHandlerRootView>
</RealmProvider>
</PersistGate>
</ReduxProvider>
</PaperProvider>
</PersistGate>
</PaperProvider>
</RealmProvider>
</ReduxProvider>
);
};

View File

@@ -0,0 +1,56 @@
import React, { ComponentProps, useMemo } from 'react';
import { Image } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
const AnimatedImage = ({ ...props }: ComponentProps<typeof Image>) => {
const scale = useSharedValue(1);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const animatedStyles = useAnimatedStyle(() => {
return {
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
{ scale: scale.value },
],
};
});
const gesture = useMemo(
() =>
Gesture.Simultaneous(
Gesture.Pinch()
.onUpdate(event => {
scale.value = event.scale;
})
.onFinalize(() => {
scale.value = withSpring(1);
}),
Gesture.Pan()
.minPointers(2)
.onUpdate(event => {
translateX.value = event.translationX;
translateY.value = event.translationY;
})
.onFinalize(() => {
translateX.value = withSpring(0);
translateY.value = withSpring(0);
}),
),
[scale, translateX, translateY],
);
return (
<GestureDetector gesture={gesture}>
<Animated.Image {...props} style={[props.style, animatedStyles]} />
</GestureDetector>
);
};
export default AnimatedImage;

View File

@@ -1,12 +1,18 @@
import React, { useEffect, useState } from 'react';
import { Keyboard, StyleSheet } from 'react-native';
import React, { useCallback, useState } from 'react';
import { StyleSheet } from 'react-native';
import { FAB } from 'react-native-paper';
import { ParamListBase, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { pickSingle } from 'react-native-document-picker';
import { ROUTE } from '../types';
import { allowedMimeTypes, noOp } from '../utilities';
import { useDeviceOrientation } from '@react-native-community/hooks';
import { pick } from 'react-native-document-picker';
import {
useDeviceOrientation,
useKeyboard,
} from '@react-native-community/hooks';
import Clipboard from '@react-native-clipboard/clipboard';
import { useDispatch } from 'react-redux';
import { documentPickerResponseToAddMemeFile, ROUTE } from '../types';
import { allowedMimeTypes, getFilenameFromUri, noOp } from '../utilities';
import { setSnackbarMessage } from '../state';
const floatingActionButtonStyles = StyleSheet.create({
fab: {
@@ -23,58 +29,72 @@ const FloatingActionButton = ({ visible = true }: { visible?: boolean }) => {
const { navigate } =
useNavigation<NativeStackNavigationProp<ParamListBase>>();
const orientation = useDeviceOrientation();
const keyboardOpen = useKeyboard().keyboardShown;
const dispatch = useDispatch();
const [state, setState] = useState(false);
const [keyboardOpen, setKeyboardOpen] = useState(false);
useEffect(() => {
const keyboardDidShowListener = Keyboard.addListener(
'keyboardDidShow',
() => setKeyboardOpen(true),
);
const keyboardDidHideListener = Keyboard.addListener(
'keyboardDidHide',
() => setKeyboardOpen(false),
);
const handleAddMeme = useCallback(async () => {
const response = await pick({
type: allowedMimeTypes,
allowMultiSelection: true,
}).catch(noOp);
if (!response) return;
const files = documentPickerResponseToAddMemeFile(response);
navigate(ROUTE.ADD_MEME, { files });
}, [navigate]);
return () => {
keyboardDidShowListener.remove();
keyboardDidHideListener.remove();
};
}, []);
const handleAddTag = useCallback(() => {
navigate(ROUTE.ADD_TAG);
}, [navigate]);
const handlePaste = useCallback(async () => {
const uri = await Clipboard.getURI();
if (!uri) {
dispatch(setSnackbarMessage('Clipboard does not contain a URI.'));
return;
}
navigate(ROUTE.ADD_MEME, {
files: [
{
uri: uri,
filename: getFilenameFromUri(uri),
},
],
});
}, [dispatch, navigate]);
return (
<FAB.Group
open={state}
visible={visible && !keyboardOpen}
icon={state ? 'image' : 'plus'}
actions={[
{
icon: 'tag',
label: 'Tag',
onPress: () => navigate(ROUTE.ADD_TAG),
},
{
icon: 'note-text',
label: 'Text',
onPress: () => {
throw new Error('Not yet implemented');
<>
<FAB.Group
open={state}
visible={visible && !keyboardOpen}
icon={state ? 'close' : 'plus'}
actions={[
{
icon: 'content-paste',
label: 'Paste',
onPress: handlePaste,
},
},
]}
onStateChange={({ open }) => setState(open)}
onPress={async () => {
if (!state) return;
const file = await pickSingle({ type: allowedMimeTypes }).catch(noOp);
if (!file) return;
navigate(ROUTE.ADD_MEME, { file });
}}
style={
orientation === 'portrait'
? floatingActionButtonStyles.fab
: floatingActionButtonStyles.fabLandscape
}
/>
{
icon: 'tag',
label: 'Tag',
onPress: handleAddTag,
},
{
icon: 'image',
label: 'Meme',
onPress: handleAddMeme,
},
]}
onStateChange={({ open }) => setState(open)}
style={
orientation === 'portrait'
? floatingActionButtonStyles.fab
: floatingActionButtonStyles.fabLandscape
}
/>
</>
);
};

View File

@@ -1,12 +1,9 @@
export {
MemesList,
MemeEditor,
MemesHeader,
MemeTagSelector,
MemeViewItem,
} from './memes';
export { TagChip, TagEditor, TagPreview, TagRow, TagsHeader } from './tags';
export { default as AnimatedImage } from './animatedImage';
export { default as FloatingActionButton } from './floatingActionButton';
export { default as HideableBottomNavigationBar } from './hideableBottomNavigationBar';
export { default as HideableHeader } from './hideableHeader';
export { default as LoadingView } from './loadingView';
export { default as MemeFail } from './memeFail';
export { default as TagChip } from './tagChip';
export { default as TextOverlay } from './textOverlay';
export { default as ThemedSkeletonPlaceholder } from './themedSkeletonPlaceholder';

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { ComponentProps } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { useTheme } from 'react-native-paper';
@@ -10,12 +10,17 @@ const loadingViewStyles = StyleSheet.create({
},
});
const LoadingView = () => {
const LoadingView = ({ ...props }: ComponentProps<typeof View>) => {
const { colors } = useTheme();
return (
<View
style={[loadingViewStyles.view, { backgroundColor: colors.background }]}>
{...props}
style={[
props.style,
loadingViewStyles.view,
{ backgroundColor: colors.background },
]}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);

View File

@@ -2,7 +2,7 @@ import React, { ComponentProps } from 'react';
import { StyleSheet, View } from 'react-native';
import { useTheme } from 'react-native-paper';
import FontAwesome5 from 'react-native-vector-icons/FontAwesome5';
import { rgbToRgba } from '../../utilities';
import { rgbToRgba } from '../utilities';
const memeFailStyles = StyleSheet.create({
view: {

View File

@@ -1,6 +0,0 @@
export { default as MemesList } from './memesList/memesList';
export { default as MemeEditor } from './memeEditor';
export { default as MemeFail } from './memeFail';
export { default as MemesHeader } from './memesHeader';
export { default as MemeTagSelector } from './memeTagSelector/memeTagSelector';
export { default as MemeViewItem } from './memeViewItem';

View File

@@ -1,100 +0,0 @@
import React, { useEffect } from 'react';
import { HelperText, TextInput } from 'react-native-paper';
import { Image } from 'react-native';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { useImageDimensions } from '@react-native-community/hooks/lib/useImageDimensions';
import LoadingView from '../loadingView';
import { MemeFail, MemeTagSelector } from '.';
import { Tag } from '../../database';
import { StringValidationResult, validateMemeTitle } from '../../utilities';
const memeEditorStyles = {
image: {
marginBottom: 15,
borderRadius: 5,
},
memeTagSelector: {
marginBottom: 10,
},
description: {
marginBottom: 10,
},
};
const MemeEditor = ({
memeUri,
memeUriError,
setMemeUriError,
memeTitle,
setMemeTitle,
memeTags,
setMemeTags,
}: {
memeUri: string;
memeUriError: Error | undefined;
setMemeUriError: (error: Error | undefined) => void;
memeTitle: StringValidationResult;
setMemeTitle: (name: StringValidationResult) => void;
memeTags: Map<string, Tag>;
setMemeTags: (tags: Map<string, Tag>) => void;
}) => {
const { width } = useSafeAreaFrame();
const { dimensions, loading, error } = useImageDimensions({ uri: memeUri });
useEffect(() => setMemeUriError(error), [error, setMemeUriError]);
if (!memeUriError && (loading || !dimensions)) return <LoadingView />;
return (
<>
<TextInput
mode="outlined"
label="Title"
value={memeTitle.raw}
onChangeText={title => setMemeTitle(validateMemeTitle(title))}
error={!memeTitle.valid}
selectTextOnFocus
/>
<HelperText type="error" visible={!memeTitle.valid}>
{memeTitle.error}
</HelperText>
{memeUriError || !dimensions ? (
<MemeFail
style={[
{
width: width * 0.92,
height: width * 0.92,
},
memeEditorStyles.image,
]}
iconSize={50}
/>
) : (
<Image
source={{ uri: memeUri }}
style={[
{
width: width * 0.92,
height: Math.max(
Math.min(
((width * 0.92) / dimensions.width) * dimensions.height,
500,
),
100,
),
},
memeEditorStyles.image,
]}
resizeMode="contain"
/>
)}
<MemeTagSelector
memeTags={memeTags}
setMemeTags={setMemeTags}
style={memeEditorStyles.memeTagSelector}
/>
</>
);
};
export default MemeEditor;

View File

@@ -1,62 +0,0 @@
import React from 'react';
import { ImageZoom } from '@likashefqet/react-native-image-zoom';
import { StyleSheet, View } from 'react-native';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { useImageDimensions } from '@react-native-community/hooks';
import LoadingView from '../loadingView';
import { Meme } from '../../database';
import MemeFail from './memeFail';
const memeViewItemStyles = StyleSheet.create({
view: {
justifyContent: 'center',
alignItems: 'center',
},
});
const MemeViewItem = ({ meme }: { meme: Meme }) => {
const { height, width } = useSafeAreaFrame();
const { dimensions, loading, error } = useImageDimensions({ uri: meme.uri });
if (!error && (loading || !dimensions)) {
return (
<View style={{ width, height }}>
<LoadingView />
</View>
);
}
return (
<View style={[{ width, height }, memeViewItemStyles.view]}>
{error || !dimensions ? (
<MemeFail
style={{
width: Math.min(width, height - 128),
height: Math.min(width, height - 128),
}}
iconSize={50}
/>
) : (
<ImageZoom
source={{ uri: meme.uri }}
style={
dimensions.aspectRatio > width / (height - 128)
? {
width,
height: width / (dimensions.width / dimensions.height),
}
: {
width:
(height - 128) * (dimensions.width / dimensions.height),
height: height - 128,
}
}
minScale={0.5}
/>
)}
</View>
);
};
export default MemeViewItem;

View File

@@ -1,54 +0,0 @@
import React from 'react';
import { Image, TouchableHighlight } from 'react-native';
import { useSelector } from 'react-redux';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { useImageDimensions } from '@react-native-community/hooks';
import { Meme } from '../../../database';
import { RootState } from '../../../state';
import { MemeFail } from '..';
import { getFontAwesome5IconSize } from '../../../utilities';
const MemesGridItem = ({
meme,
index,
focusMeme,
}: {
meme: Meme;
index: number;
focusMeme: (index: number) => void;
}) => {
const { width } = useSafeAreaFrame();
const gridColumns = useSelector(
(state: RootState) => state.settings.gridColumns,
);
const { dimensions, loading, error } = useImageDimensions({ uri: meme.uri });
if (!error && (loading || !dimensions)) return <></>;
return (
<TouchableHighlight onPress={() => focusMeme(index)}>
{error ? (
<MemeFail
style={{
width: (width * 0.92 - 5) / gridColumns,
height: (width * 0.92 - 5) / gridColumns,
}}
iconSize={getFontAwesome5IconSize(gridColumns)}
/>
) : (
<Image
source={{ uri: meme.uri }}
style={[
{
width: (width * 0.92 - 5) / gridColumns,
height: (width * 0.92 - 5) / gridColumns,
},
]}
/>
)}
</TouchableHighlight>
);
};
export default MemesGridItem;

View File

@@ -1,71 +0,0 @@
import React from 'react';
import { Image, StyleSheet, TouchableHighlight } from 'react-native';
import { useSelector } from 'react-redux';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { Meme } from '../../../database';
import { RootState } from '../../../state';
import { useImageDimensions } from '@react-native-community/hooks';
import { MemeFail } from '..';
import { getFontAwesome5IconSize } from '../../../utilities';
const memeMasonryItemStyles = StyleSheet.create({
view: {
margin: 2.5,
borderRadius: 5,
},
image: {
borderRadius: 5,
},
});
const MemesMasonryItem = ({
meme,
index,
focusMeme,
}: {
meme: Meme;
index: number;
focusMeme: (index: number) => void;
}) => {
const { width } = useSafeAreaFrame();
const masonryColumns = useSelector(
(state: RootState) => state.settings.masonryColumns,
);
const { dimensions, loading, error } = useImageDimensions({ uri: meme.uri });
if (!error && (loading || !dimensions)) return <></>;
return (
<TouchableHighlight
onPress={() => focusMeme(index)}
style={memeMasonryItemStyles.view}>
{error || !dimensions ? (
<MemeFail
style={[
memeMasonryItemStyles.image,
{
width: (width * 0.92) / masonryColumns - 5,
height: (width * 0.92) / masonryColumns - 5,
},
]}
iconSize={getFontAwesome5IconSize(masonryColumns)}
/>
) : (
<Image
source={{ uri: meme.uri }}
style={[
memeMasonryItemStyles.image,
{
width: (width * 0.92) / masonryColumns - 5,
height:
((width * 0.92) / masonryColumns - 5) / dimensions.aspectRatio,
},
]}
/>
)}
</TouchableHighlight>
);
};
export default MemesMasonryItem;

View File

@@ -1,9 +1,9 @@
import React, { ComponentProps, useMemo } from 'react';
import { getContrastColor } from '../../utilities';
import { Chip, useTheme } from 'react-native-paper';
import { Tag } from '../../database';
import FontAwesome5 from 'react-native-vector-icons/FontAwesome5';
import { StyleSheet } from 'react-native';
import { getContrastColor } from '../utilities';
import { Tag } from '../database';
const tagChipStyles = StyleSheet.create({
chip: {

View File

@@ -1,5 +0,0 @@
export { default as TagChip } from './tagChip';
export { default as TagEditor } from './tagEditor';
export { default as TagPreview } from './tagPreview';
export { default as TagRow } from './tagRow';
export { default as TagsHeader } from './tagsHeader';

View File

@@ -1,66 +0,0 @@
import React, { useEffect, useRef } from 'react';
import { HelperText, TextInput } from 'react-native-paper';
import TagPreview from './tagPreview';
import {
StringValidationResult,
generateRandomColor,
validateColor,
validateTagName,
} from '../../utilities';
const TagEditor = ({
tagName,
setTagName,
tagColor,
setTagColor,
}: {
tagName: StringValidationResult;
setTagName: (name: StringValidationResult) => void;
tagColor: StringValidationResult;
setTagColor: (color: StringValidationResult) => void;
}) => {
const lastValidTagColor = useRef(tagColor.parsed);
useEffect(() => {
if (tagColor.valid) lastValidTagColor.current = tagColor.parsed;
}, [tagColor]);
return (
<>
<TagPreview
name={tagName.parsed}
color={tagColor.valid ? tagColor.parsed : lastValidTagColor.current}
/>
<TextInput
mode="outlined"
label="Name"
value={tagName.raw}
onChangeText={name => setTagName(validateTagName(name))}
error={!tagName.valid}
selectTextOnFocus
/>
<HelperText type="error" visible={!tagName.valid}>
{tagName.error}
</HelperText>
<TextInput
mode="outlined"
label="Color"
value={tagColor.raw}
onChangeText={color => setTagColor(validateColor(color))}
error={!tagColor.valid}
autoCorrect={false}
right={
<TextInput.Icon
icon="palette"
onPress={() => setTagColor(validateColor(generateRandomColor()))}
/>
}
/>
<HelperText type="error" visible={!tagColor.valid}>
{tagColor.error}
</HelperText>
</>
);
};
export default TagEditor;

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { TextBlock } from '@react-native-ml-kit/text-recognition';
import { TouchableRipple, useTheme } from 'react-native-paper';
import { StyleSheet } from 'react-native';
import { Dimensions } from '../types';
const textOverlayStyles = StyleSheet.create({
touchable: {
position: 'absolute',
borderWidth: 1,
zIndex: 1,
},
});
const TextOverlay = ({
blocks,
onTextPress,
onTextLongPress,
imageDimensions,
frameDimensions,
}: {
blocks: TextBlock[];
onTextPress: (text: string) => void;
onTextLongPress: (text: string) => void;
imageDimensions: Dimensions;
frameDimensions: Dimensions;
}) => {
const { colors } = useTheme();
const widthScale = frameDimensions.width / imageDimensions.width;
const heightScale = frameDimensions.height / imageDimensions.height;
return (
<>
{blocks.map(
(block, index) =>
block.frame && (
<TouchableRipple
key={index}
style={[
textOverlayStyles.touchable,
{
top: block.frame.top * heightScale - 5,
left: block.frame.left * widthScale - 5,
width: block.frame.width * widthScale + 10,
height: block.frame.height * heightScale + 10,
borderColor: colors.error,
},
]}
onPress={() =>
onTextPress(block.text.replaceAll('\n', ' ').trim())
}
onLongPress={() =>
onTextLongPress(block.text.replaceAll('\n', ' ').trim())
}>
<></>
</TouchableRipple>
),
)}
</>
);
};
export default TextOverlay;

View File

@@ -0,0 +1,20 @@
import React, { ComponentProps } from 'react';
import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
import { useTheme } from 'react-native-paper';
import { rgbToRgba } from '../utilities';
const ThemedSkeletonPlaceholder = ({
...props
}: ComponentProps<typeof SkeletonPlaceholder>) => {
const { colors } = useTheme();
return (
<SkeletonPlaceholder
backgroundColor={rgbToRgba(colors.surfaceVariant, 0.2)}
highlightColor={rgbToRgba(colors.surfaceVariant, 0.7)}
{...props}
/>
);
};
export default ThemedSkeletonPlaceholder;

View File

@@ -1,5 +1,5 @@
import { BSON, Object, ObjectSchema } from 'realm';
import { Tag } from './tag';
import { Tag } from '.';
enum MEME_TYPE {
IMAGE = 'Image',
@@ -17,11 +17,10 @@ const memeTypePlural = {
[MEME_TYPE.TEXT]: 'Text',
};
// eslint-disable-next-line @typescript-eslint/naming-convention
class Meme extends Object<Meme> {
id!: BSON.UUID;
type!: MEME_TYPE;
uri!: string;
filename!: string;
memeType!: MEME_TYPE;
mimeType!: string;
size!: number;
title!: string;
@@ -38,8 +37,8 @@ class Meme extends Object<Meme> {
primaryKey: 'id',
properties: {
id: { type: 'uuid', default: () => new BSON.UUID() },
type: { type: 'string', indexed: true },
uri: 'string',
filename: 'string',
memeType: { type: 'string', indexed: true },
mimeType: 'string',
size: 'int',
title: 'string',

View File

@@ -1,8 +1,7 @@
import { BSON, Object, ObjectSchema } from 'realm';
import { Meme } from './meme';
import { Meme } from '.';
import { generateRandomColor } from '../utilities';
// eslint-disable-next-line @typescript-eslint/naming-convention
class Tag extends Object<Tag> {
id!: BSON.UUID;
name!: string;

1
src/hooks/index.ts Normal file
View File

@@ -0,0 +1 @@
export { default as useMemeDimensions } from './useMemeDimensions';

View File

@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react';
import { Image } from 'react-native';
import { Dimensions } from '../types';
const useMediaDimensions = (
uri?: string,
mimeType?: string,
onLoad?: (width: number, height: number, aspectRatio: number) => void,
onError?: (error: Error) => void,
) => {
const [dimensions, setDimensions] = useState<Dimensions | undefined>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | undefined>();
useEffect(() => {
const getDimensions = () => {
if (!uri || !mimeType) return;
const mimeStart = mimeType.split('/')[0];
switch (mimeStart) {
case 'image':
case 'video': {
Image.getSize(
uri,
(width, height) => {
const aspectRatio = width / height;
setDimensions({ width, height, aspectRatio });
setLoading(false);
onLoad?.(width, height, aspectRatio);
},
(errorIn: string) => {
const errorOut = new Error(errorIn);
setError(errorOut);
setLoading(false);
onError?.(errorOut);
},
);
break;
}
default: {
const errorOut = new Error(`Unknown mime type: ${mimeType}`);
setError(errorOut);
setLoading(false);
onError?.(errorOut);
}
}
};
getDimensions();
}, [mimeType, onError, onLoad, uri]);
return { dimensions, loading, error };
};
export default useMediaDimensions;

View File

@@ -1,10 +1,14 @@
import React from 'react';
import { NavigationContainer as NavigationContainerBase } from '@react-navigation/native';
import React, { useCallback, useEffect } from 'react';
import {
NavigationContainer as NavigationContainerBase,
ParamListBase,
createNavigationContainerRef,
} from '@react-navigation/native';
import FontAwesome5 from 'react-native-vector-icons/FontAwesome5';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useTheme } from 'react-native-paper';
import { useSelector } from 'react-redux';
import { Snackbar, useTheme } from 'react-native-paper';
import { useDispatch, useSelector } from 'react-redux';
import {
Memes,
Tags,
@@ -20,13 +24,36 @@ import {
FloatingActionButton,
HideableBottomNavigationBar,
} from './components';
import { ROUTE, RootStackParamList } from './types';
import {
ROUTE,
RootStackParamList,
SharedItem,
sharedItemToAddMemeFile,
} from './types';
import { RootState } from './state';
import ShareMenu from 'react-native-share-menu';
import { setSnackbarMessage } from './state/navigation';
import { StyleSheet } from 'react-native';
import { useKeyboard } from '@react-native-community/hooks';
const tabNavigatorStyles = StyleSheet.create({
snackbar: {
marginBottom: 90,
},
snackbarKeyboard: {
marginBottom: 10,
},
});
const TabNavigator = () => {
const navVisible = useSelector(
(state: RootState) => state.navigation.navVisible,
);
const snackbarMessage = useSelector(
(state: RootState) => state.navigation.snackbarMessage,
);
const dispatch = useDispatch();
const keyboardOpen = useKeyboard().keyboardShown;
const [route, setRoute] = React.useState(ROUTE.MEMES);
const TabNavigatorBase = createBottomTabNavigator();
@@ -77,6 +104,22 @@ const TabNavigator = () => {
/>
</TabNavigatorBase.Navigator>
<FloatingActionButton visible={navVisible && route !== ROUTE.SETTINGS} />
<Snackbar
visible={!!snackbarMessage}
// eslint-disable-next-line unicorn/no-useless-undefined
onDismiss={() => dispatch(setSnackbarMessage(undefined))}
style={
keyboardOpen
? tabNavigatorStyles.snackbarKeyboard
: tabNavigatorStyles.snackbar
}
action={{
label: 'Dismiss',
// eslint-disable-next-line unicorn/no-useless-undefined
onPress: () => dispatch(setSnackbarMessage(undefined)),
}}>
{snackbarMessage}
</Snackbar>
</>
);
};
@@ -84,10 +127,28 @@ const TabNavigator = () => {
const NavigationContainer = () => {
const theme = useTheme();
const navigationRef = createNavigationContainerRef<ParamListBase>();
const handleShare = useCallback(
(item: SharedItem | undefined) => {
if (!item) return;
const files = sharedItemToAddMemeFile(item);
navigationRef.current?.navigate(ROUTE.ADD_MEME, { files });
},
[navigationRef],
);
useEffect(() => {
ShareMenu.getInitialShare(handleShare);
const listener = ShareMenu.addNewShareListener(handleShare);
return () => listener.remove();
}, [handleShare]);
const StackNavigatorBase = createNativeStackNavigator<RootStackParamList>();
return (
<NavigationContainerBase
ref={navigationRef}
theme={theme.dark ? darkNavigationTheme : lightNavigationTheme}>
<StackNavigatorBase.Navigator
screenOptions={{

View File

@@ -1,181 +0,0 @@
import React, { useCallback, useRef, useState } from 'react';
import { Appbar, Banner, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { ScrollView, View } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useRealm } from '@realm/react';
import { BSON } from 'realm';
import { AndroidScoped, FileSystem } from 'react-native-file-access';
import { useSelector } from 'react-redux';
import { extension } from 'react-native-mime-types';
import { useDeviceOrientation } from '@react-native-community/hooks';
import {
DocumentPickerResponse,
pickSingle,
} from 'react-native-document-picker';
import { ROUTE, RootStackParamList } from '../../types';
import { Meme, Tag } from '../../database';
import { RootState } from '../../state';
import {
allowedMimeTypes,
getMemeType,
validateMemeTitle,
} from '../../utilities';
import { MemeEditor } from '../../components';
import editorStyles from './editorStyles';
const AddMeme = ({
route,
}: NativeStackScreenProps<RootStackParamList, ROUTE.ADD_MEME>) => {
const { goBack } = useNavigation();
const { colors } = useTheme();
const orientation = useDeviceOrientation();
const realm = useRealm();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const file = useRef(route.params.file);
const [memeUri, setMemeUri] = useState(file.current.uri);
const [memeUriError, setMemeUriError] = useState<Error>();
const [memeTitle, setMemeTitle] = useState(validateMemeTitle('New Meme'));
const [memeIsFavorite, setMemeIsFavorite] = useState(false);
const [memeTags, setMemeTags] = useState(new Map<string, Tag>());
const [isSaving, setIsSaving] = useState(false);
const [isSavingAndAddingAnother, setIsSavingAndAddingAnother] =
useState(false);
const handleSave = useCallback(async () => {
const uuid = new BSON.UUID();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const mimeType = file.current.type!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const memeType = getMemeType(mimeType)!;
const fileExtension = extension(mimeType) as string;
if (!fileExtension) goBack();
const uri = AndroidScoped.appendPath(
storageUri,
`${uuid.toHexString()}-${Math.round(Date.now() / 1000)}.${fileExtension}`,
);
await FileSystem.cp(file.current.uri, uri);
const { size } = await FileSystem.stat(uri);
realm.write(() => {
const meme: Meme | undefined = realm.create<Meme>(Meme.schema.name, {
id: uuid,
type: memeType,
uri,
mimeType,
size,
title: memeTitle.parsed,
isFavorite: memeIsFavorite,
tags: [...memeTags.values()],
tagsLength: memeTags.size,
});
memeTags.forEach(tag => {
tag.dateModified = new Date();
tag.memes.push(meme);
tag.memesLength = tag.memes.length;
});
});
}, [goBack, memeIsFavorite, memeTags, memeTitle.parsed, realm, storageUri]);
return (
<>
<Appbar.Header>
<Appbar.BackAction onPress={() => goBack()} />
<Appbar.Content title={'Add Meme'} />
<Appbar.Action
icon={memeIsFavorite ? 'heart' : 'heart-outline'}
onPress={() => setMemeIsFavorite(!memeIsFavorite)}
/>
</Appbar.Header>
<Banner
visible={!!memeUriError}
actions={[
{
label: 'Cancel',
onPress: goBack,
},
]}>
The selected URI appears to be broken. This may have been caused by the
file being corrupted or unsupported.
</Banner>
<ScrollView
contentContainerStyle={[
editorStyles.scrollView,
orientation === 'portrait'
? editorStyles.scrollViewPortrait
: editorStyles.scrollViewLandscape,
{ backgroundColor: colors.background },
]}>
<View style={editorStyles.editorView}>
<MemeEditor
memeUri={memeUri}
memeUriError={memeUriError}
setMemeUriError={setMemeUriError}
memeTitle={memeTitle}
setMemeTitle={setMemeTitle}
memeTags={memeTags}
setMemeTags={setMemeTags}
/>
</View>
<View style={editorStyles.saveButtonView}>
<Button
mode="contained-tonal"
icon="plus"
onPress={async () => {
setIsSavingAndAddingAnother(true);
await handleSave();
setIsSavingAndAddingAnother(false);
file.current = (await pickSingle({
type: allowedMimeTypes,
}).catch(goBack)) as DocumentPickerResponse;
setMemeUri(file.current.uri);
setMemeTitle(validateMemeTitle('New Meme'));
setMemeIsFavorite(false);
setMemeTags(new Map<string, Tag>());
}}
disabled={
!memeTitle.valid ||
isSaving ||
isSavingAndAddingAnother ||
!!memeUriError
}
loading={isSavingAndAddingAnother}
style={editorStyles.saveAndAddButton}>
Save & Add
</Button>
<Button
mode="contained"
icon="floppy"
onPress={async () => {
setIsSaving(true);
await handleSave();
setIsSaving(false);
goBack();
}}
disabled={
!memeTitle.valid ||
isSaving ||
isSavingAndAddingAnother ||
!!memeUriError
}
loading={isSaving}
style={editorStyles.saveButton}>
Save
</Button>
</View>
</ScrollView>
</>
);
};
export default AddMeme;

View File

@@ -1,201 +0,0 @@
import React, { useCallback, useState } from 'react';
import { ScrollView, View } from 'react-native';
import { Appbar, Banner, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useObject, useRealm } from '@realm/react';
import { useDeviceOrientation } from '@react-native-community/hooks';
import { BSON } from 'realm';
import { RootStackParamList, ROUTE } from '../../types';
import { pickSingle } from 'react-native-document-picker';
import { AndroidScoped, FileSystem } from 'react-native-file-access';
import { Tag, Meme } from '../../database';
import {
StringValidationResult,
allowedMimeTypes,
deleteMeme,
favoriteMeme,
getMemeType,
noOp,
validateMemeTitle,
} from '../../utilities';
import { MemeEditor } from '../../components';
import editorStyles from './editorStyles';
import { extension } from 'react-native-mime-types';
import { useSelector } from 'react-redux';
import { RootState } from '../../state';
const EditMeme = ({
route,
}: NativeStackScreenProps<RootStackParamList, ROUTE.EDIT_MEME>) => {
const { goBack } = useNavigation();
const { colors } = useTheme();
const orientation = useDeviceOrientation();
const realm = useRealm();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const meme = useObject<Meme>(
Meme.schema.name,
BSON.UUID.createFromHexString(route.params.id),
)!;
const [hasChanges, setHasChanges] = useState(false);
const [memeUriError, setMemeUriError] = useState<Error>();
const [memeTitle, setMemeTitle] = useState(validateMemeTitle(meme.title));
const [memeTags, setMemeTags] = useState(
new Map<string, Tag>(meme.tags.map(tag => [tag.id.toHexString(), tag])),
);
const handleMemeTitleChange = useCallback((title: StringValidationResult) => {
setMemeTitle(title);
setHasChanges(true);
}, []);
const handleMemeTagsChange = useCallback((tags: Map<string, Tag>) => {
setMemeTags(tags);
setHasChanges(true);
}, []);
const [isSaving, setIsSaving] = useState(false);
const handleSave = useCallback(() => {
realm.write(() => {
meme.tags.forEach(tag => {
if (!memeTags.has(tag.id.toHexString())) {
tag.memes.slice(tag.memes.indexOf(meme), 1);
tag.memesLength -= 1;
tag.dateModified = new Date();
}
});
memeTags.forEach(tag => {
if (!meme.tags.some(memeTag => memeTag.id.equals(tag.id))) {
tag.memes.push(meme);
tag.memesLength = tag.memes.length;
tag.dateModified = new Date();
}
});
meme.title = memeTitle.parsed;
// @ts-expect-error - Realm is a fuck
meme.tags = [...memeTags.values()];
meme.tagsLength = memeTags.size;
meme.dateModified = new Date();
});
}, [meme, memeTags, memeTitle.parsed, realm]);
const handleFixUri = useCallback(async () => {
const file = await pickSingle({ type: allowedMimeTypes }).catch(noOp);
if (!file) return;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const mimeType = file.type!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const memeType = getMemeType(mimeType)!;
const fileExtension = extension(mimeType) as string;
if (!fileExtension) return;
const uri = AndroidScoped.appendPath(
storageUri,
`${meme.id.toHexString()}-${Date.now() / 1000}.${fileExtension}`,
);
await FileSystem.cp(file.uri, uri);
const { size } = await FileSystem.stat(uri);
realm.write(() => {
meme.uri = uri;
meme.type = memeType;
meme.mimeType = mimeType;
meme.size = size;
});
}, [meme, realm, storageUri]);
return (
<>
<Appbar.Header>
<Appbar.BackAction onPress={() => goBack()} />
<Appbar.Content title={'Edit Meme'} />
<Appbar.Action
icon={meme.isFavorite ? 'heart' : 'heart-outline'}
onPress={() => favoriteMeme(realm, meme)}
/>
<Appbar.Action
icon="delete"
onPress={async () => {
setIsSaving(true);
await deleteMeme(realm, meme);
setIsSaving(false);
goBack();
}}
/>
</Appbar.Header>
<Banner
visible={!!memeUriError}
actions={[
{
label: 'Fix URI',
onPress: handleFixUri,
},
{
label: 'Delete Meme',
onPress: async () => {
setIsSaving(true);
await deleteMeme(realm, meme);
setIsSaving(false);
goBack();
},
},
]}>
The URI for this meme appears to be broken. This may have been caused by
the file being moved or deleted.
</Banner>
<ScrollView
contentContainerStyle={[
editorStyles.scrollView,
orientation === 'portrait'
? editorStyles.scrollViewPortrait
: editorStyles.scrollViewLandscape,
{ backgroundColor: colors.background },
]}>
<View style={editorStyles.editorView}>
<MemeEditor
memeUri={meme.uri}
memeUriError={memeUriError}
setMemeUriError={setMemeUriError}
memeTitle={memeTitle}
setMemeTitle={handleMemeTitleChange}
memeTags={memeTags}
setMemeTags={handleMemeTagsChange}
/>
</View>
<View style={editorStyles.saveButtonView}>
<Button
mode="contained"
icon="floppy"
onPress={() => {
setIsSaving(true);
handleSave();
setIsSaving(false);
goBack();
}}
disabled={
!memeTitle.valid || !hasChanges || isSaving || !!memeUriError
}
loading={isSaving}
style={editorStyles.soloSaveButton}>
Save
</Button>
</View>
</ScrollView>
</>
);
};
export default EditMeme;

View File

@@ -0,0 +1,233 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Appbar, Banner, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { ScrollView, View } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useRealm } from '@realm/react';
import { BSON } from 'realm';
import { AndroidScoped, FileSystem } from 'react-native-file-access';
import { useSelector } from 'react-redux';
import { extension } from 'react-native-mime-types';
import { useDeviceOrientation } from '@react-native-community/hooks';
import { pick } from 'react-native-document-picker';
import {
documentPickerResponseToAddMemeFile,
ROUTE,
RootStackParamList,
StagingMeme,
} from '../../../types';
import { Meme, Tag } from '../../../database';
import { RootState } from '../../../state';
import {
allowedMimeTypes,
getMemeTypeFromMimeType,
guessMimeType,
validateMemeTitle,
} from '../../../utilities';
import MemeEditor from './memeEditor';
import editorStyles from '../editorStyles';
const AddMeme = ({
route,
}: NativeStackScreenProps<RootStackParamList, ROUTE.ADD_MEME>) => {
const { goBack } = useNavigation();
const { colors } = useTheme();
const orientation = useDeviceOrientation();
const realm = useRealm();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const [index, setIndex] = useState(0);
const files = useRef(route.params.files);
const file = useRef(files.current[index]);
const isLastFile = index === files.current.length - 1;
const [isSaving, setIsSaving] = useState(false);
const [isSavingAndAddingMore, setIsSavingAndAddingMore] = useState(false);
const [uri, setUri] = useState<string>();
const [mimeType, setMimeType] = useState<string>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error>();
const [staging, setStaging] = useState<StagingMeme>();
const resetState = useCallback(async (newIndex: number) => {
setLoading(true);
// eslint-disable-next-line unicorn/no-useless-undefined
setError(undefined);
setIndex(newIndex);
file.current = files.current[newIndex];
setUri(file.current.uri);
const guessedMimeType = await guessMimeType(file.current.uri);
if (!guessedMimeType) {
setError(
new Error('Could not determine MIME type or file is not supported.'),
);
return;
}
setMimeType(guessedMimeType);
setStaging({
title: validateMemeTitle('New Meme'),
isFavorite: false,
tags: new Map<string, Tag>(),
});
setLoading(false);
}, []);
useEffect(() => void resetState(0), [resetState]);
const saveMeme = useCallback(async () => {
if (!mimeType || !staging) return;
const uuid = new BSON.UUID();
const memeType = getMemeTypeFromMimeType(mimeType);
if (!memeType) return;
const fileExtension = extension(mimeType);
if (!fileExtension) return;
const filename = `${uuid.toHexString()}-${Math.round(
Date.now() / 1000,
)}.${fileExtension}`;
const finalUri = AndroidScoped.appendPath(storageUri, filename);
await FileSystem.cp(file.current.uri, finalUri);
const { size } = await FileSystem.stat(finalUri);
realm.write(() => {
const meme: Meme | undefined = realm.create<Meme>(Meme.schema.name, {
id: uuid,
memeType,
filename,
mimeType: mimeType,
size,
title: staging.title.parsed,
isFavorite: staging.isFavorite,
tags: [...staging.tags.values()],
tagsLength: staging.tags.size,
});
staging.tags.forEach(tag => {
tag.dateModified = new Date();
tag.memes.push(meme);
tag.memesLength = tag.memes.length;
});
});
}, [mimeType, realm, staging, storageUri]);
const handleSave = useCallback(async () => {
setIsSaving(true);
await saveMeme();
goBack();
}, [goBack, saveMeme]);
const handleSaveAndNext = useCallback(async () => {
setIsSaving(true);
await saveMeme();
setIsSaving(false);
await resetState(index + 1);
}, [index, resetState, saveMeme]);
const handleSaveAndAddMore = useCallback(async () => {
setIsSavingAndAddingMore(true);
await saveMeme();
setIsSavingAndAddingMore(false);
const response = await pick({
type: allowedMimeTypes,
allowMultiSelection: true,
}).catch(goBack);
if (!response) return;
files.current = documentPickerResponseToAddMemeFile(response);
await resetState(0);
}, [goBack, resetState, saveMeme]);
return (
<>
<Appbar.Header>
<Appbar.BackAction onPress={() => goBack()} />
<Appbar.Content title={'Add Meme'} />
<Appbar.Action
icon={staging?.isFavorite ? 'heart' : 'heart-outline'}
disabled={!staging}
onPress={() =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
setStaging({ ...staging!, isFavorite: !staging!.isFavorite })
}
/>
</Appbar.Header>
<Banner
visible={!!error}
actions={[
{
label: 'Cancel',
onPress: goBack,
},
]}>
{error?.message}
</Banner>
<ScrollView
contentContainerStyle={[
editorStyles.scrollView,
orientation === 'portrait'
? editorStyles.scrollViewPortrait
: editorStyles.scrollViewLandscape,
{ backgroundColor: colors.background },
]}>
<View style={editorStyles.editorView}>
<MemeEditor
uri={uri}
mimeType={mimeType}
loading={loading}
setLoading={setLoading}
error={error}
setError={setError}
staging={staging}
setStaging={setStaging}
/>
</View>
<View style={editorStyles.saveButtonView}>
<Button
mode="contained-tonal"
icon="plus"
onPress={handleSaveAndAddMore}
disabled={
loading ||
!!error ||
isSaving ||
isSavingAndAddingMore ||
!staging?.title.valid ||
!isLastFile
}
loading={isSavingAndAddingMore}
style={editorStyles.saveAndAddButton}>
Save & Add More
</Button>
<Button
mode="contained"
icon="floppy"
onPress={isLastFile ? handleSave : handleSaveAndNext}
disabled={
loading ||
!!error ||
isSaving ||
isSavingAndAddingMore ||
!staging?.title.valid
}
loading={isSaving}
style={editorStyles.saveButton}>
{isLastFile ? 'Save' : 'Save & Next'}
</Button>
</View>
</ScrollView>
</>
);
};
export default AddMeme;

View File

@@ -0,0 +1,232 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ScrollView, View } from 'react-native';
import { Appbar, Banner, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useObject, useRealm } from '@realm/react';
import { useDeviceOrientation } from '@react-native-community/hooks';
import { BSON } from 'realm';
import { pickSingle } from 'react-native-document-picker';
import { AndroidScoped, FileSystem } from 'react-native-file-access';
import { extension } from 'react-native-mime-types';
import { useSelector } from 'react-redux';
import { RootStackParamList, ROUTE, StagingMeme } from '../../../types';
import { Meme } from '../../../database';
import {
allowedMimeTypes,
deleteMeme,
favoriteMeme,
getMemeTypeFromMimeType,
guessMimeType,
noOp,
validateMemeTitle,
} from '../../../utilities';
import { RootState } from '../../../state';
import MemeEditor from './memeEditor';
import editorStyles from '../editorStyles';
const EditMeme = ({
route,
}: NativeStackScreenProps<RootStackParamList, ROUTE.EDIT_MEME>) => {
const { goBack } = useNavigation();
const { colors } = useTheme();
const orientation = useDeviceOrientation();
const realm = useRealm();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const meme = useObject<Meme>(
Meme.schema.name,
BSON.UUID.createFromHexString(route.params.id),
)!;
const [isSaving, setIsSaving] = useState(false);
const [uri, setUri] = useState<string>();
const [mimeType, setMimeType] = useState<string>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error>();
const [staging, setStaging] = useState<StagingMeme>();
const originalStaging = useRef<StagingMeme>();
const resetState = useCallback(
async (newUri: string) => {
setLoading(true);
// eslint-disable-next-line unicorn/no-useless-undefined
setError(undefined);
setUri(newUri);
const guessedMimeType = await guessMimeType(newUri);
if (!guessedMimeType) {
setError(
new Error('Could not determine MIME type or file is not supported.'),
);
return;
}
setMimeType(guessedMimeType);
const stagingMeme = {
title: validateMemeTitle(meme.title),
isFavorite: meme.isFavorite,
tags: new Map(meme.tags.map(tag => [tag.id.toHexString(), tag])),
};
setStaging(stagingMeme);
originalStaging.current = stagingMeme;
setLoading(false);
},
[meme.isFavorite, meme.tags, meme.title],
);
useEffect(
() => void resetState(AndroidScoped.appendPath(storageUri, meme.filename)),
[meme.filename, resetState, storageUri],
);
const handleSave = useCallback(() => {
if (!mimeType || !staging) return;
setIsSaving(true);
realm.write(() => {
meme.tags.forEach(tag => {
if (!staging.tags.has(tag.id.toHexString())) {
tag.memes.slice(tag.memes.indexOf(meme), 1);
tag.memesLength -= 1;
tag.dateModified = new Date();
}
});
staging.tags.forEach(tag => {
if (!meme.tags.some(memeTag => memeTag.id.equals(tag.id))) {
tag.memes.push(meme);
tag.memesLength = tag.memes.length;
tag.dateModified = new Date();
}
});
meme.title = staging.title.parsed;
// @ts-expect-error - Realm is a fuck
meme.tags = [...staging.tags.values()];
meme.tagsLength = staging.tags.size;
meme.dateModified = new Date();
});
goBack();
}, [goBack, meme, mimeType, realm, staging]);
const handleDelete = useCallback(async () => {
setIsSaving(true);
await deleteMeme(realm, storageUri, meme);
goBack();
}, [goBack, meme, realm, storageUri]);
const handleFixUri = useCallback(async () => {
const file = await pickSingle({ type: allowedMimeTypes }).catch(noOp);
if (!file) return;
const guessedMimeType = await guessMimeType(file.uri, file.type);
if (!guessedMimeType) {
setError(
new Error('Could not determine MIME type or file is not supported.'),
);
return;
}
const memeType = getMemeTypeFromMimeType(guessedMimeType);
if (!memeType) return;
const fileExtension = extension(guessedMimeType);
if (!fileExtension) return;
const filename = `${meme.id.toHexString()}-${
Date.now() / 1000
}.${fileExtension}`;
const newUri = AndroidScoped.appendPath(storageUri, filename);
await FileSystem.cp(file.uri, newUri);
const { size } = await FileSystem.stat(newUri);
realm.write(() => {
meme.filename = filename;
meme.memeType = memeType;
meme.mimeType = guessedMimeType;
meme.size = size;
});
void resetState(newUri);
}, [meme, realm, resetState, storageUri]);
return (
<>
<Appbar.Header>
<Appbar.BackAction onPress={() => goBack()} />
<Appbar.Content title={'Edit Meme'} />
<Appbar.Action
icon={meme.isFavorite ? 'heart' : 'heart-outline'}
onPress={() => favoriteMeme(realm, meme)}
/>
<Appbar.Action icon="delete" onPress={handleDelete} />
</Appbar.Header>
<Banner
visible={!!error}
actions={[
{
label: 'Fix URI',
onPress: handleFixUri,
},
{
label: 'Delete Meme',
onPress: handleDelete,
},
]}>
{error?.message}
</Banner>
<ScrollView
contentContainerStyle={[
editorStyles.scrollView,
orientation === 'portrait'
? editorStyles.scrollViewPortrait
: editorStyles.scrollViewLandscape,
{ backgroundColor: colors.background },
]}>
<View style={editorStyles.editorView}>
<MemeEditor
uri={uri}
mimeType={mimeType}
loading={loading}
setLoading={setLoading}
error={error}
setError={setError}
staging={staging}
setStaging={setStaging}
/>
</View>
<View style={editorStyles.saveButtonView}>
<Button
mode="contained"
icon="floppy"
onPress={handleSave}
disabled={
loading ||
!!error ||
isSaving ||
!staging?.title.valid ||
originalStaging.current === staging
}
loading={isSaving}
style={editorStyles.soloSaveButton}>
Save
</Button>
</View>
</ScrollView>
</>
);
};
export default EditMeme;

View File

@@ -0,0 +1,194 @@
import React, { useEffect, useMemo, useState } from 'react';
import { HelperText, Text, TextInput, useTheme } from 'react-native-paper';
import { LayoutAnimation, View } from 'react-native';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import Video from 'react-native-video';
import TextRecognition, {
TextRecognitionResult,
} from '@react-native-ml-kit/text-recognition';
import FastImage from 'react-native-fast-image';
import { LoadingView, MemeFail, TextOverlay } from '../../../components';
import {
getFilenameFromUri,
getMemeTypeFromMimeType,
validateMemeTitle,
} from '../../../utilities';
import { StagingMeme } from '../../../types';
import { useMemeDimensions } from '../../../hooks';
import { MEME_TYPE } from '../../../database';
import MemeTagSelector from './memeTagSelector/memeTagSelector';
const memeEditorStyles = {
media: {
marginBottom: 15,
borderRadius: 5,
},
uri: {
marginBottom: 15,
marginHorizontal: 5,
},
memeTagSelector: {
marginBottom: 10,
},
description: {
marginBottom: 10,
},
};
const MemeEditor = ({
uri,
mimeType,
loading,
setLoading,
error,
setError,
staging,
setStaging,
}: {
uri?: string;
mimeType?: string;
loading: boolean;
setLoading: (loading: boolean) => void;
error: Error | undefined;
setError: (error: Error | undefined) => void;
staging?: StagingMeme;
setStaging: (staging: StagingMeme) => void;
}) => {
const { width } = useSafeAreaFrame();
const { colors } = useTheme();
const { dimensions } = useMemeDimensions(
uri,
mimeType,
useMemo(
() => () => {
setLoading(false);
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
},
[setLoading],
),
useMemo(() => (errorIn: Error) => setError(errorIn), [setError]),
);
const [recognizedText, setRecognizedText] = useState<TextRecognitionResult>();
useEffect(() => {
if (!uri || !mimeType || !mimeType.startsWith('image')) return;
void TextRecognition.recognize(uri).then(setRecognizedText);
}, [mimeType, uri]);
const mediaComponent = useMemo(() => {
if (!mimeType || !dimensions || !staging) return <></>;
const dimensionStyles = {
width: width * 0.92,
height: Math.max(
Math.min((width * 0.92) / dimensions.aspectRatio, 500),
100,
),
};
const memeType = getMemeTypeFromMimeType(mimeType);
if (!memeType) return <></>;
switch (memeType) {
case MEME_TYPE.IMAGE:
case MEME_TYPE.GIF: {
return (
<View>
<FastImage
source={{ uri }}
style={[memeEditorStyles.media, dimensionStyles]}
resizeMode="contain"
/>
{recognizedText && (
<TextOverlay
blocks={recognizedText.blocks}
onTextPress={text =>
setStaging({
...staging,
title: validateMemeTitle(text),
})
}
onTextLongPress={text =>
setStaging({
...staging,
title: validateMemeTitle(`${staging.title.parsed} ${text}`),
})
}
imageDimensions={dimensions}
frameDimensions={{
...dimensionStyles,
aspectRatio: dimensionStyles.width / dimensionStyles.height,
}}
/>
)}
</View>
);
}
case MEME_TYPE.VIDEO: {
return (
<Video
source={{ uri }}
style={[memeEditorStyles.media, dimensionStyles]}
resizeMode="contain"
controls
/>
);
}
default: {
return <></>;
}
}
}, [dimensions, mimeType, recognizedText, setStaging, staging, uri, width]);
if (!uri || !mimeType || !staging) return <LoadingView />;
return (
<>
<TextInput
mode="outlined"
label="Title"
value={staging.title.raw}
onChangeText={title =>
setStaging({ ...staging, title: validateMemeTitle(title) })
}
error={!staging.title.valid}
selectTextOnFocus
/>
<HelperText type="error" visible={!staging.title.valid}>
{staging.title.error}
</HelperText>
{error ? (
<MemeFail
style={[
{
width: width * 0.92,
height: width * 0.92,
},
memeEditorStyles.media,
]}
iconSize={50}
/>
) : // eslint-disable-next-line unicorn/no-nested-ternary
loading || !dimensions ? (
<></>
) : (
mediaComponent
)}
<Text
variant="bodySmall"
style={[memeEditorStyles.uri, { color: colors.onSurfaceDisabled }]}
numberOfLines={1}>
{getFilenameFromUri(uri)}
</Text>
<MemeTagSelector
memeTags={staging.tags}
setMemeTags={tags => setStaging({ ...staging, tags })}
style={memeEditorStyles.memeTagSelector}
/>
</>
);
};
export default MemeEditor;

View File

@@ -4,10 +4,10 @@ import { Chip, Modal, Portal, Searchbar, useTheme } from 'react-native-paper';
import { LayoutAnimation, StyleSheet } from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { TAG_SORT, tagSortQuery } from '../../../types';
import { TagChip } from '../../tags';
import { Tag } from '../../../database';
import { validateTagName } from '../../../utilities';
import { TAG_SORT, tagSortQuery } from '../../../../types';
import { TagChip } from '../../../../components';
import { Tag } from '../../../../database';
import { validateTagName } from '../../../../utilities';
const memeTagSearchModalStyles = StyleSheet.create({
modal: {
@@ -87,6 +87,8 @@ const MemeTagSearchModal = ({
[search],
);
const [refreshKey, setRefreshKey] = useState(0);
const handleTagPress = (tag: Tag) => {
const id = tag.id.toHexString();
memeTags.delete(id) || memeTags.set(id, tag);
@@ -105,6 +107,7 @@ const MemeTagSearchModal = ({
if (!tag) return;
memeTags.set(tag.id.toHexString(), tag);
setMemeTags(new Map(memeTags));
setRefreshKey(refreshKey + 1);
flashListRef.current?.prepareForLayoutAnimationRender();
LayoutAnimation.configureNext(tagLayoutAnimation);
};
@@ -129,6 +132,7 @@ const MemeTagSearchModal = ({
/>
<FlashList
ref={flashListRef}
key={refreshKey}
data={tags}
extraData={memeTags}
keyExtractor={tag => tag.id.toHexString()}
@@ -148,7 +152,7 @@ const MemeTagSearchModal = ({
active={memeTags.has(tag.id.toHexString())}
/>
)}
ListEmptyComponent={() => (
ListFooterComponent={() => (
<Chip
icon="plus"
mode="outlined"

View File

@@ -3,8 +3,8 @@ import { LayoutAnimation, StyleSheet, View } from 'react-native';
import { Chip } from 'react-native-paper';
import { FlashList } from '@shopify/flash-list';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { TagChip } from '../../tags';
import { Tag } from '../../../database';
import { TagChip } from '../../../../components';
import { Tag } from '../../../../database';
import MemeTagSearchModal from './memeTagSearchModal';
const memeTagSelectorStyles = StyleSheet.create({

View File

@@ -8,10 +8,11 @@ import {
generateRandomColor,
validateColor,
validateTagName,
} from '../../utilities';
import { Tag } from '../../database';
import { TagEditor } from '../../components';
import editorStyles from './editorStyles';
} from '../../../utilities';
import { Tag } from '../../../database';
import { StagingTag } from '../../../types';
import TagEditor from './tagEditor';
import editorStyles from '../editorStyles';
const AddTag = () => {
const { goBack } = useNavigation();
@@ -19,24 +20,38 @@ const AddTag = () => {
const orientation = useDeviceOrientation();
const realm = useRealm();
const [tagName, setTagName] = useState(validateTagName('newTag'));
const [tagColor, setTagColor] = useState(
validateColor(generateRandomColor()),
);
const [staging, setStaging] = useState<StagingTag>({
name: validateTagName('newTag'),
color: validateColor(generateRandomColor()),
});
// Although saving tags is instantaneous, we still want to show a loading
// indicator to prevent the user from spamming the save button.
const [isSavingAndAddingAnother, setIsSavingAndAddingAnother] =
useState(false);
const [isSavingAndAddingMore, setIsSavingAndAddingMore] = useState(false);
const handleSave = useCallback(() => {
const saveTag = useCallback(() => {
realm.write(() => {
realm.create(Tag.schema.name, {
name: tagName.parsed,
color: tagColor.parsed,
name: staging.name.parsed,
color: staging.color.parsed,
});
});
}, [realm, tagColor.parsed, tagName.parsed]);
}, [realm, staging.color.parsed, staging.name.parsed]);
const handleSave = useCallback(() => {
saveTag();
goBack();
}, [goBack, saveTag]);
const handleSaveAndAddMore = useCallback(() => {
setIsSavingAndAddingMore(true);
saveTag();
setTimeout(() => setIsSavingAndAddingMore(false), 250);
setStaging({
name: validateTagName('newTag'),
color: validateColor(generateRandomColor()),
});
}, [saveTag]);
return (
<>
@@ -53,37 +68,23 @@ const AddTag = () => {
{ backgroundColor: colors.background },
]}>
<View style={editorStyles.editorView}>
<TagEditor
tagName={tagName}
setTagName={setTagName}
tagColor={tagColor}
setTagColor={setTagColor}
/>
<TagEditor staging={staging} setStaging={setStaging} />
</View>
<View style={editorStyles.saveButtonView}>
<Button
mode="contained-tonal"
icon="plus"
onPress={() => {
setIsSavingAndAddingAnother(true);
handleSave();
setTimeout(() => setIsSavingAndAddingAnother(false), 250);
setTagName(validateTagName('newTag'));
setTagColor(validateColor(generateRandomColor()));
}}
disabled={!tagName.valid || isSavingAndAddingAnother}
loading={isSavingAndAddingAnother}
onPress={handleSaveAndAddMore}
disabled={!staging.name.valid || isSavingAndAddingMore}
loading={isSavingAndAddingMore}
style={editorStyles.saveAndAddButton}>
Save & Add
</Button>
<Button
mode="contained"
icon="floppy"
onPress={() => {
handleSave();
goBack();
}}
disabled={!tagName.valid || isSavingAndAddingAnother}
onPress={handleSave}
disabled={!staging.name.valid || isSavingAndAddingMore}
style={editorStyles.saveButton}>
Save
</Button>

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import { ScrollView, View } from 'react-native';
import { Appbar, Button, useTheme } from 'react-native-paper';
import { useNavigation } from '@react-navigation/native';
@@ -6,16 +6,11 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { BSON } from 'realm';
import { useObject, useRealm } from '@realm/react';
import { useDeviceOrientation } from '@react-native-community/hooks';
import { TagEditor } from '../../components';
import { ROUTE, RootStackParamList } from '../../types';
import { Tag } from '../../database';
import {
StringValidationResult,
deleteTag,
validateColor,
validateTagName,
} from '../../utilities';
import editorStyles from './editorStyles';
import { ROUTE, RootStackParamList, StagingTag } from '../../../types';
import { Tag } from '../../../database';
import { deleteTag, validateColor, validateTagName } from '../../../utilities';
import TagEditor from './tagEditor';
import editorStyles from '../editorStyles';
const EditTag = ({
route,
@@ -31,27 +26,21 @@ const EditTag = ({
BSON.UUID.createFromHexString(route.params.id),
)!;
const [hasChanges, setHasChanges] = useState(false);
const [tagName, setTagName] = useState(validateTagName(tag.name));
const [tagColor, setTagColor] = useState(validateColor(tag.color));
const handleTagNameChange = useCallback((name: StringValidationResult) => {
setTagName(name);
setHasChanges(true);
}, []);
const handleTagColorChange = useCallback((color: StringValidationResult) => {
setTagColor(color);
setHasChanges(true);
}, []);
const [staging, setStaging] = useState<StagingTag>({
name: validateTagName(tag.name),
color: validateColor(tag.color),
});
const originalStaging = useRef<StagingTag>(staging);
const handleSave = useCallback(() => {
realm.write(() => {
tag.name = tagName.parsed;
tag.color = tagColor.parsed;
tag.name = staging.name.parsed;
tag.color = staging.color.parsed;
tag.dateModified = new Date();
});
}, [realm, tag, tagColor.parsed, tagName.parsed]);
goBack();
}, [goBack, realm, staging.color.parsed, staging.name.parsed, tag]);
return (
<>
@@ -76,22 +65,18 @@ const EditTag = ({
]}
nestedScrollEnabled>
<View style={editorStyles.editorView}>
<TagEditor
tagName={tagName}
setTagName={handleTagNameChange}
tagColor={tagColor}
setTagColor={handleTagColorChange}
/>
<TagEditor staging={staging} setStaging={setStaging} />
</View>
<View style={editorStyles.saveButtonView}>
<Button
mode="contained"
icon="floppy"
onPress={() => {
handleSave();
goBack();
}}
disabled={!tagName.valid || !tagColor.valid || !hasChanges}
onPress={handleSave}
disabled={
!staging.name.valid ||
!staging.color.valid ||
originalStaging.current === staging
}
style={editorStyles.soloSaveButton}>
Save
</Button>

View File

@@ -0,0 +1,73 @@
import React, { useEffect, useRef } from 'react';
import { HelperText, TextInput } from 'react-native-paper';
import {
generateRandomColor,
validateColor,
validateTagName,
} from '../../../utilities';
import { StagingTag } from '../../../types';
import TagPreview from './tagPreview';
const TagEditor = ({
staging,
setStaging,
}: {
staging: StagingTag;
setStaging: (staging: StagingTag) => void;
}) => {
const lastValidColor = useRef(staging.color.parsed);
useEffect(() => {
if (staging.color.valid) lastValidColor.current = staging.color.parsed;
}, [staging.color.parsed, staging.color.valid]);
return (
<>
<TagPreview
name={staging.name.parsed}
color={
staging.color.valid ? staging.color.parsed : lastValidColor.current
}
/>
<TextInput
mode="outlined"
label="Name"
value={staging.name.raw}
onChangeText={name =>
setStaging({ ...staging, name: validateTagName(name) })
}
error={!staging.name.valid}
selectTextOnFocus
/>
<HelperText type="error" visible={!staging.name.valid}>
{staging.name.error}
</HelperText>
<TextInput
mode="outlined"
label="Color"
value={staging.color.raw}
onChangeText={color =>
setStaging({ ...staging, color: validateColor(color) })
}
error={!staging.color.valid}
autoCorrect={false}
right={
<TextInput.Icon
icon="palette"
onPress={() =>
setStaging({
...staging,
color: validateColor(generateRandomColor()),
})
}
/>
}
/>
<HelperText type="error" visible={!staging.color.valid}>
{staging.color.error}
</HelperText>
</>
);
};
export default TagEditor;

View File

@@ -2,7 +2,7 @@ import React, { useMemo } from 'react';
import { StyleSheet, View } from 'react-native';
import FontAwesome5 from 'react-native-vector-icons/FontAwesome5';
import { Chip, useTheme } from 'react-native-paper';
import { getContrastColor } from '../../utilities';
import { getContrastColor } from '../../../utilities';
const tagPreviewStyles = StyleSheet.create({
view: {

View File

@@ -1,9 +1,9 @@
export { default as AddMeme } from './editors/addMeme';
export { default as AddTag } from './editors/addTag';
export { default as EditMeme } from './editors/editMeme';
export { default as EditTag } from './editors/editTag';
export { default as Memes } from './memes';
export { default as MemeView } from './memeView';
export { default as Settings } from './settings';
export { default as Tags } from './tags';
export { default as AddMeme } from './editors/meme/addMeme';
export { default as AddTag } from './editors/tag/addTag';
export { default as EditMeme } from './editors/meme/editMeme';
export { default as EditTag } from './editors/tag/editTag';
export { default as Memes } from './memes/memes';
export { default as MemeView } from './memeView/memeView';
export { default as Settings } from './settings/settings';
export { default as Tags } from './tags/tags';
export { default as Welcome } from './welcome';

View File

@@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { StyleSheet } from 'react-native';
import { useQuery, useRealm } from '@realm/react';
@@ -6,9 +6,15 @@ import { FlashList } from '@shopify/flash-list';
import { Appbar, Portal, Snackbar } from 'react-native-paper';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import { RootStackParamList, ROUTE } from '../types';
import { Meme } from '../database';
import { MemeViewItem } from '../components';
import { useSelector } from 'react-redux';
import {
memesSortQuery,
RootStackParamList,
ROUTE,
SORT_DIRECTION,
} from '../../types';
import { Meme } from '../../database';
import { LoadingView } from '../../components';
import {
copyMeme,
deleteMeme,
@@ -16,7 +22,9 @@ import {
favoriteMeme,
multipleIdQuery,
shareMeme,
} from '../utilities';
} from '../../utilities';
import { RootState } from '../../state';
import MemeViewItem from './memeViewItem';
const memeViewStyles = StyleSheet.create({
// eslint-disable-next-line react-native/no-color-literals
@@ -48,27 +56,45 @@ const MemeView = ({
}: NativeStackScreenProps<RootStackParamList, ROUTE.MEME_VIEW>) => {
const { height, width } = useSafeAreaFrame();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const sort = useSelector((state: RootState) => state.memes.sort);
const sortDirection = useSelector(
(state: RootState) => state.memes.sortDirection,
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const realm = useRealm();
const { ids } = route.params;
const [index, setIndex] = useState(route.params.index);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [isBlocked, setIsBlocked] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState<string>();
const flashListRef = useRef<FlashList<Meme>>(null);
const [index, setIndex] = useState(route.params.index);
const memes = useQuery<Meme>(Meme.schema.name, collectionIn => {
return collectionIn.filtered(multipleIdQuery(ids));
return collectionIn
.filtered(multipleIdQuery(route.params.ids))
.sorted(
memesSortQuery(sort),
sortDirection === SORT_DIRECTION.DESCENDING,
);
});
if (memes.length === 0) return <></>;
useEffect(() => {
if (memes.length === 0) navigation.goBack();
if (index >= memes.length) {
setIndex(memes.length - 1);
flashListRef.current?.scrollToIndex({ index: memes.length - 1 });
}
}, [index, memes.length, navigation]);
return (
<>
<Appbar.Header style={memeViewStyles.header}>
<Appbar.BackAction onPress={() => navigation.goBack()} />
<Appbar.Content title={memes[index].title} />
{/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */}
<Appbar.Content title={memes[index]?.title} />
</Appbar.Header>
<FlashList
ref={flashListRef}
@@ -87,64 +113,68 @@ const MemeView = ({
pagingEnabled
horizontal
showsHorizontalScrollIndicator={false}
renderItem={({ item: meme }) => <MemeViewItem meme={meme} />}
renderItem={({ item }) =>
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
item ? (
<MemeViewItem meme={item} />
) : (
<LoadingView style={{ width, height }} />
)
}
scrollEnabled={!isBlocked}
/>
<Appbar style={memeViewStyles.footer}>
<Appbar.Action
icon={memes[index].isFavorite ? 'heart' : 'heart-outline'}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
icon={memes[index]?.isFavorite ? 'heart' : 'heart-outline'}
onPress={() => favoriteMeme(realm, memes[index])}
disabled={isBlocked}
/>
<Appbar.Action
icon="share"
onPress={() => {
shareMeme(memes[index]).catch(() => {
setSnackbarMessage('Failed to share meme!');
setSnackbarVisible(true);
});
shareMeme(realm, storageUri, memes[index]).catch(() =>
setSnackbarMessage('Failed to share meme!'),
);
}}
disabled={isBlocked}
/>
<Appbar.Action
icon="content-copy"
onPress={async () => {
await copyMeme(memes[index])
.then(() => {
setSnackbarMessage('Meme copied!');
setSnackbarVisible(true);
})
.catch(() => {
setSnackbarMessage('Failed to copy meme!');
setSnackbarVisible(true);
});
await copyMeme(realm, storageUri, memes[index])
.then(() => setSnackbarMessage('Meme copied!'))
.catch(() => setSnackbarMessage('Failed to copy meme!'));
}}
disabled={isBlocked}
/>
<Appbar.Action
icon="pencil"
onPress={() => {
editMeme(navigation, memes[index]);
}}
disabled={isBlocked}
/>
<Appbar.Action
icon="delete"
onPress={() => {
if (index === memes.length - 1) {
setIndex(index - 1);
flashListRef.current?.scrollToIndex({
index: index - 1,
});
}
void deleteMeme(realm, memes[index]);
if (memes.length === 1) navigation.goBack();
onPress={async () => {
setIsBlocked(true);
await deleteMeme(realm, storageUri, memes[index]);
setIsBlocked(false);
}}
disabled={isBlocked}
/>
</Appbar>
<Portal>
<Snackbar
visible={snackbarVisible}
onDismiss={() => setSnackbarVisible(false)}
visible={!!snackbarMessage}
// eslint-disable-next-line unicorn/no-useless-undefined
onDismiss={() => setSnackbarMessage(undefined)}
style={memeViewStyles.snackbar}
action={{
label: 'Dismiss',
onPress: () => setSnackbarVisible(false),
// eslint-disable-next-line unicorn/no-useless-undefined
onPress: () => setSnackbarMessage(undefined),
}}>
{snackbarMessage}
</Snackbar>

View File

@@ -0,0 +1,90 @@
import React, { useMemo } from 'react';
import { StyleSheet, View } from 'react-native';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { AndroidScoped } from 'react-native-file-access';
import { useSelector } from 'react-redux';
import Video from 'react-native-video';
import { MEME_TYPE, Meme } from '../../database';
import { RootState } from '../../state';
import { AnimatedImage, LoadingView, MemeFail } from '../../components';
import { useMemeDimensions } from '../../hooks';
const memeViewItemStyles = StyleSheet.create({
view: {
justifyContent: 'center',
alignItems: 'center',
},
});
const MemeViewItem = ({ meme }: { meme: Meme }) => {
const { height, width } = useSafeAreaFrame();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
const { dimensions, loading, error } = useMemeDimensions(uri, meme.mimeType);
const mediaComponent = useMemo(() => {
if (!dimensions) return <></>;
const dimensionStyles =
dimensions.aspectRatio > width / (height - 128)
? {
width,
height: width / (dimensions.width / dimensions.height),
}
: {
width: (height - 128) * (dimensions.width / dimensions.height),
height: height - 128,
};
switch (meme.memeType) {
case MEME_TYPE.IMAGE:
case MEME_TYPE.GIF: {
return (
<AnimatedImage
source={{ uri }}
style={dimensionStyles}
resizeMode="contain"
/>
);
}
default: {
return (
<Video
source={{ uri }}
style={dimensionStyles}
resizeMode="contain"
paused
controls
/>
);
}
}
}, [dimensions, height, meme.memeType, uri, width]);
if (!error && (loading || !dimensions)) {
return <LoadingView style={{ width, height }} />;
}
return (
<View style={[{ width, height }, memeViewItemStyles.view]}>
{error || !dimensions ? (
<MemeFail
style={{
width: Math.min(width, height - 128),
height: Math.min(width, height - 128),
}}
iconSize={50}
/>
) : (
mediaComponent
)}
</View>
);
};
export default MemeViewItem;

View File

@@ -17,11 +17,13 @@ import {
useNavigation,
} from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ROUTE, SORT_DIRECTION, memesSortQuery } from '../types';
import { RootState, setNavVisible } from '../state';
import { Meme } from '../database';
import { HideableHeader, MemesHeader, MemesList } from '../components';
import { useDeviceOrientation } from '@react-native-community/hooks';
import { ROUTE, SORT_DIRECTION, memesSortQuery } from '../../types';
import { RootState, setNavVisible } from '../../state';
import { Meme } from '../../database';
import { HideableHeader } from '../../components';
import MemesHeader from './memesHeader';
import MemesList from './memesList/memesList';
const memesStyles = StyleSheet.create({
listView: {
@@ -48,6 +50,9 @@ const Memes = () => {
const navVisisble = useSelector(
(state: RootState) => state.navigation.navVisible,
);
const autoFocus = useSelector(
(state: RootState) => state.settings.autoFocusMemesSearch,
);
const dispatch = useDispatch();
const [flashListPadding, setFlashListPadding] = useState(0);
@@ -77,7 +82,7 @@ const Memes = () => {
.join(' OR ');
if (favoritesOnly) collection = collection.filtered('isFavorite == true');
if (filter) collection = collection.filtered('type == $0', filter);
if (filter) collection = collection.filtered('memeType == $0', filter);
if (tags && tagsQuery) {
collection = collection.filtered(tagsQuery, ...tags);
}
@@ -95,7 +100,7 @@ const Memes = () => {
[sort, sortDirection, favoritesOnly, filter, search],
);
const [scrollOffset, setScrollOffset] = useState(0);
const previousOffset = useRef(0);
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const currentOffset = event.nativeEvent.contentOffset.y;
@@ -103,13 +108,13 @@ const Memes = () => {
if (currentOffset <= 150) {
dispatch(setNavVisible(true));
} else {
const diff = currentOffset - scrollOffset;
if (Math.abs(diff) > 50) {
const diff = currentOffset - previousOffset.current;
if (Math.abs(diff) > 35) {
dispatch(setNavVisible(diff < 0));
}
}
setScrollOffset(currentOffset);
previousOffset.current = currentOffset;
};
const flashListRef = useRef<FlashList<Meme>>(null);
@@ -117,7 +122,7 @@ const Memes = () => {
useFocusEffect(
useCallback(() => {
const handleBackPress = () => {
if (scrollOffset <= 0) return false;
if (previousOffset.current <= 0) return false;
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
return true;
};
@@ -126,7 +131,7 @@ const Memes = () => {
return () =>
BackHandler.removeEventListener('hardwareBackPress', handleBackPress);
}, [scrollOffset]),
}, []),
);
useFocusEffect(
@@ -141,6 +146,7 @@ const Memes = () => {
<MemesHeader
search={search}
setSearch={setSearch}
autoFocus={autoFocus}
onLayout={event => {
setFlashListPadding(
event.nativeEvent.layout.height +

View File

@@ -41,10 +41,12 @@ const memesHeaderStyles = StyleSheet.create({
const MemesHeader = ({
search,
setSearch,
autoFocus,
...props
}: {
search: string;
setSearch: (search: string) => void;
autoFocus: boolean;
} & ComponentProps<typeof View>) => {
const { colors } = useTheme();
const sort = useSelector((state: RootState) => state.memes.sort);
@@ -86,6 +88,7 @@ const MemesHeader = ({
placeholder="Search Memes"
value={search}
onChangeText={setSearch}
autoFocus={autoFocus}
/>
<View style={memesHeaderStyles.buttonView}>
<View style={memesHeaderStyles.buttonSection}>

View File

@@ -0,0 +1,74 @@
import React, { useMemo } from 'react';
import { TouchableHighlight } from 'react-native';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import FastImage from 'react-native-fast-image';
import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
import { MEME_TYPE, Meme } from '../../../database';
import { MemeFail, ThemedSkeletonPlaceholder } from '../../../components';
import { getFontAwesome5IconSize } from '../../../utilities';
import { useMemeDimensions } from '../../../hooks';
const MemesGridItem = ({
meme,
focusMeme,
uri,
columns,
}: {
meme: Meme;
focusMeme: () => void;
uri: string;
columns: number;
}) => {
const { width } = useSafeAreaFrame();
const itemWidth = useMemo(
() => (width * 0.92 - 5) / columns,
[columns, width],
);
const { dimensions, loading, error } = useMemeDimensions(uri, meme.mimeType);
const mediaComponent = useMemo(() => {
switch (meme.memeType) {
case MEME_TYPE.IMAGE:
case MEME_TYPE.GIF:
case MEME_TYPE.VIDEO: {
return (
<FastImage
source={{ uri }}
style={{ width: itemWidth, height: itemWidth }}
/>
);
}
default: {
return <></>;
}
}
}, [itemWidth, meme.memeType, uri]);
const skeletonComponent = useMemo(
() => (
<ThemedSkeletonPlaceholder>
<SkeletonPlaceholder.Item width={itemWidth} height={itemWidth} />
</ThemedSkeletonPlaceholder>
),
[itemWidth],
);
if (!error && (loading || !dimensions)) return skeletonComponent;
return (
<TouchableHighlight onPress={focusMeme}>
{error ? (
<MemeFail
style={{ width: itemWidth, height: itemWidth }}
iconSize={getFontAwesome5IconSize(columns)}
/>
) : (
mediaComponent
)}
</TouchableHighlight>
);
};
export default MemesGridItem;

View File

@@ -15,6 +15,7 @@ import { getFlashListItemHeight } from '../../../utilities';
import MemesMasonryItem from './memesMasonryItem';
import MemesGridItem from './memesGridItem';
import MemesListItem from './memesListItem';
import { AndroidScoped } from 'react-native-file-access';
const sharedMemesListStyles = StyleSheet.create({
flashList: {
@@ -66,6 +67,10 @@ const MemesList = ({
const gridColumns = useSelector(
(state: RootState) => state.settings.gridColumns,
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
return (
<>
@@ -73,6 +78,7 @@ const MemesList = ({
<MasonryFlashList
ref={flashListRef}
data={memes}
key={masonryColumns}
estimatedItemSize={getFlashListItemHeight(masonryColumns)}
estimatedListSize={{
height,
@@ -81,7 +87,12 @@ const MemesList = ({
numColumns={masonryColumns}
showsVerticalScrollIndicator={false}
renderItem={({ item: meme, index }) => (
<MemesMasonryItem meme={meme} index={index} focusMeme={focusMeme} />
<MemesMasonryItem
meme={meme}
focusMeme={() => focusMeme(index)}
uri={AndroidScoped.appendPath(storageUri, meme.filename)}
columns={masonryColumns}
/>
)}
contentContainerStyle={{
paddingTop: flashListPadding,
@@ -95,12 +106,14 @@ const MemesList = ({
)}
onScroll={handleScroll}
fadingEdgeLength={100}
overScrollMode="never"
/>
)}
{view === VIEW.GRID && (
<FlashList
ref={flashListRef}
data={memes}
key={gridColumns}
estimatedItemSize={getFlashListItemHeight(gridColumns)}
estimatedListSize={{
height: height,
@@ -109,7 +122,12 @@ const MemesList = ({
numColumns={gridColumns}
showsVerticalScrollIndicator={false}
renderItem={({ item: meme, index }) => (
<MemesGridItem meme={meme} index={index} focusMeme={focusMeme} />
<MemesGridItem
meme={meme}
focusMeme={() => focusMeme(index)}
uri={AndroidScoped.appendPath(storageUri, meme.filename)}
columns={gridColumns}
/>
)}
contentContainerStyle={{
paddingTop: flashListPadding,
@@ -123,20 +141,25 @@ const MemesList = ({
)}
onScroll={handleScroll}
fadingEdgeLength={100}
overScrollMode="never"
/>
)}
{view === VIEW.LIST && (
<FlashList
ref={flashListRef}
data={memes}
estimatedItemSize={50}
estimatedItemSize={90}
estimatedListSize={{
height: height,
width: width * 0.92,
}}
showsVerticalScrollIndicator={false}
renderItem={({ item: meme, index }) => (
<MemesListItem meme={meme} index={index} focusMeme={focusMeme} />
<MemesListItem
meme={meme}
focusMeme={() => focusMeme(index)}
uri={AndroidScoped.appendPath(storageUri, meme.filename)}
/>
)}
ItemSeparatorComponent={() => <Divider />}
contentContainerStyle={{
@@ -151,6 +174,7 @@ const MemesList = ({
)}
onScroll={handleScroll}
fadingEdgeLength={100}
overScrollMode="never"
/>
)}
</>

View File

@@ -1,10 +1,12 @@
import React from 'react';
import { Image, StyleSheet, View } from 'react-native';
import React, { useMemo } from 'react';
import { StyleSheet, View } from 'react-native';
import { Text, TouchableRipple } from 'react-native-paper';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { useImageDimensions } from '@react-native-community/hooks';
import { Meme } from '../../../database';
import { MemeFail } from '..';
import FastImage from 'react-native-fast-image';
import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
import { MEME_TYPE, Meme } from '../../../database';
import { MemeFail, ThemedSkeletonPlaceholder } from '../../../components';
import { useMemeDimensions } from '../../../hooks';
const memesListItemStyles = StyleSheet.create({
view: {
@@ -32,36 +34,58 @@ const memesListItemStyles = StyleSheet.create({
const MemesListItem = ({
meme,
index,
focusMeme,
uri,
}: {
meme: Meme;
index: number;
focusMeme: (index: number) => void;
focusMeme: () => void;
uri: string;
}) => {
const { width } = useSafeAreaFrame();
const { dimensions, loading, error } = useImageDimensions({ uri: meme.uri });
const listItemWidth = useMemo(
() => ({ width: width * 0.92 - 75 - 10 }),
[width],
);
if (!error && (loading || !dimensions)) return <></>;
const { dimensions, loading, error } = useMemeDimensions(uri, meme.mimeType);
const mediaComponent = useMemo(() => {
switch (meme.memeType) {
case MEME_TYPE.IMAGE:
case MEME_TYPE.GIF:
case MEME_TYPE.VIDEO: {
return <FastImage source={{ uri }} style={memesListItemStyles.image} />;
}
default: {
return <></>;
}
}
}, [meme.memeType, uri]);
const skeletonComponent = useMemo(
() => (
<ThemedSkeletonPlaceholder>
<SkeletonPlaceholder.Item
width={listItemWidth.width + 75}
height={90}
/>
</ThemedSkeletonPlaceholder>
),
[listItemWidth.width],
);
if (!error && (loading || !dimensions)) return skeletonComponent;
return (
<TouchableRipple
onPress={() => focusMeme(index)}
style={memesListItemStyles.view}>
<TouchableRipple onPress={focusMeme} style={memesListItemStyles.view}>
<>
{error ? (
<MemeFail style={memesListItemStyles.image} />
) : (
<Image source={{ uri: meme.uri }} style={memesListItemStyles.image} />
mediaComponent
)}
<View
style={[
memesListItemStyles.detailsView,
{
width: width * 0.92 - 75 - 10,
},
]}>
<View style={[memesListItemStyles.detailsView, listItemWidth]}>
<Text variant="titleMedium" style={memesListItemStyles.text}>
{meme.title}
</Text>

View File

@@ -0,0 +1,99 @@
import React, { useMemo } from 'react';
import { StyleSheet, TouchableHighlight } from 'react-native';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import FastImage from 'react-native-fast-image';
import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
import { MEME_TYPE, Meme } from '../../../database';
import { MemeFail, ThemedSkeletonPlaceholder } from '../../../components';
import { getFontAwesome5IconSize } from '../../../utilities';
import { useMemeDimensions } from '../../../hooks';
const memeMasonryItemStyles = StyleSheet.create({
view: {
margin: 2.5,
borderRadius: 5,
},
image: {
borderRadius: 5,
},
});
const MemesMasonryItem = ({
meme,
focusMeme,
uri,
columns,
}: {
meme: Meme;
focusMeme: () => void;
uri: string;
columns: number;
}) => {
const { width } = useSafeAreaFrame();
const { dimensions, loading, error } = useMemeDimensions(uri, meme.mimeType);
const itemWidth = useMemo(
() => (width * 0.92 - 5) / columns - 5,
[columns, width],
);
const itemHeight = useMemo(
() => itemWidth / (dimensions?.aspectRatio ?? 1),
[dimensions?.aspectRatio, itemWidth],
);
const mediaComponent = useMemo(() => {
switch (meme.memeType) {
case MEME_TYPE.IMAGE:
case MEME_TYPE.GIF:
case MEME_TYPE.VIDEO: {
return (
<FastImage
source={{ uri }}
style={[
memeMasonryItemStyles.image,
{ width: itemWidth, height: itemHeight },
]}
/>
);
}
default: {
return <></>;
}
}
}, [itemHeight, itemWidth, meme.memeType, uri]);
const skeletonComponent = useMemo(
() => (
<ThemedSkeletonPlaceholder borderRadius={5}>
<SkeletonPlaceholder.Item
width={itemWidth}
height={itemWidth}
style={memeMasonryItemStyles.view}
/>
</ThemedSkeletonPlaceholder>
),
[itemWidth],
);
if (!error && (loading || !dimensions)) return skeletonComponent;
return (
<TouchableHighlight onPress={focusMeme} style={memeMasonryItemStyles.view}>
{error || !dimensions ? (
<MemeFail
style={[
memeMasonryItemStyles.image,
{ width: itemWidth, height: itemHeight },
]}
iconSize={getFontAwesome5IconSize(columns)}
/>
) : (
mediaComponent
)}
</TouchableHighlight>
);
};
export default MemesMasonryItem;

View File

@@ -5,29 +5,30 @@ import {
List,
Portal,
SegmentedButtons,
Snackbar,
Switch,
Text,
useTheme,
} from 'react-native-paper';
import { openDocumentTree } from 'react-native-scoped-storage';
import { useDispatch, useSelector } from 'react-redux';
import type {} from 'redux-thunk/extend-redux';
import { useRealm } from '@realm/react';
import { FileSystem, FileStat } from 'react-native-file-access';
import {
RootState,
setAutofocusMemesSearch,
setAutofocusTagsSearch,
setGridColumns,
setMasonryColumns,
setNoMedia,
setStorageUri,
} from '../state';
setSnackbarMessage,
} from '../../state';
import { Meme } from '../../database';
import StorageLocationChangeDialog from './storageLocationChangeDialog';
const settingsStyles = StyleSheet.create({
scrollView: {
paddingHorizontal: '4%',
},
snackbar: {
marginBottom: 90,
},
marginBottom: {
marginBottom: 15,
},
@@ -35,6 +36,12 @@ const settingsStyles = StyleSheet.create({
marginBottom: 15,
paddingHorizontal: '2%',
},
autoFocusSwitch: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: '2%',
marginBottom: 15,
},
hideMediaSwitch: {
flexDirection: 'row',
justifyContent: 'space-between',
@@ -45,24 +52,46 @@ const settingsStyles = StyleSheet.create({
const Settings = () => {
const { colors } = useTheme();
const noMedia = useSelector((state: RootState) => state.settings.noMedia);
const autoFocusMemesSearch = useSelector(
(state: RootState) => state.settings.autoFocusMemesSearch,
);
const autoFocusTagsSearch = useSelector(
(state: RootState) => state.settings.autoFocusTagsSearch,
);
const masonryColumns = useSelector(
(state: RootState) => state.settings.masonryColumns,
);
const gridColumns = useSelector(
(state: RootState) => state.settings.gridColumns,
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const dispatch = useDispatch();
const realm = useRealm();
const [isOptimizingDatabase, setIsOptimizingDatabase] = useState(false);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [
storageLocationChangeDialogVisible,
setStorageLocationChangeDialogVisible,
] = useState(false);
const optimizeDatabase = () => {
setIsOptimizingDatabase(true);
// TODO: clean up missing / extra files
setSnackbarMessage('Database optimized!');
setSnackbarVisible(true);
setIsOptimizingDatabase(false);
const refreshMemeMetadata = async () => {
const stat = await FileSystem.statDir(storageUri);
const statMap = new Map<string, FileStat>();
stat.forEach(s => statMap.set(s.filename, s));
const memes = realm.objects<Meme>(Meme.schema.name);
realm.write(() => {
memes.forEach(meme => {
const fileStat = statMap.get(meme.filename);
meme.size = fileStat?.size ?? 0;
});
});
dispatch(setSnackbarMessage('Meme metadata refreshed.'));
};
return (
@@ -74,6 +103,24 @@ const Settings = () => {
]}>
<List.Section>
<List.Subheader>Views</List.Subheader>
<View style={settingsStyles.autoFocusSwitch}>
<Text>Autofocus Memes Searchbar</Text>
<Switch
value={autoFocusMemesSearch}
onValueChange={value => {
void dispatch(setAutofocusMemesSearch(value));
}}
/>
</View>
<View style={settingsStyles.autoFocusSwitch}>
<Text>Autofocus Tags Searchbar</Text>
<Switch
value={autoFocusTagsSearch}
onValueChange={value => {
void dispatch(setAutofocusTagsSearch(value));
}}
/>
</View>
<Text style={settingsStyles.columnSegmentedButtons}>
Masonry Columns
</Text>
@@ -111,15 +158,18 @@ const Settings = () => {
/>
</List.Section>
<List.Section>
<List.Subheader>Media Storage</List.Subheader>
<List.Subheader>Storage</List.Subheader>
<Button
mode="elevated"
style={settingsStyles.marginBottom}
onPress={async () => {
const { uri } = await openDocumentTree(true);
void dispatch(setStorageUri(uri));
}}>
Change External Storage Path
onPress={() => setStorageLocationChangeDialogVisible(true)}>
Change Storage Location
</Button>
<Button
mode="elevated"
style={settingsStyles.marginBottom}
onPress={refreshMemeMetadata}>
Refresh Meme Metadata
</Button>
<View style={settingsStyles.hideMediaSwitch}>
<Text>Hide media from gallery</Text>
@@ -131,27 +181,15 @@ const Settings = () => {
/>
</View>
</List.Section>
<List.Section>
<List.Subheader>Database</List.Subheader>
<Button
mode="elevated"
loading={isOptimizingDatabase}
onPress={optimizeDatabase}>
Optimize Database Now
</Button>
</List.Section>
</ScrollView>
<Portal>
<Snackbar
visible={snackbarVisible}
onDismiss={() => setSnackbarVisible(false)}
style={settingsStyles.snackbar}
action={{
label: 'Dismiss',
onPress: () => setSnackbarVisible(false),
}}>
{snackbarMessage}
</Snackbar>
<StorageLocationChangeDialog
visible={storageLocationChangeDialogVisible}
setVisible={setStorageLocationChangeDialogVisible}
setSnackbarMessage={message => {
dispatch(setSnackbarMessage(message));
}}
/>
</Portal>
</>
);

View File

@@ -0,0 +1,97 @@
import React, { useEffect, useState } from 'react';
import { StyleSheet } from 'react-native';
import { Dialog, ProgressBar, Text } from 'react-native-paper';
import { openDocumentTree } from 'react-native-scoped-storage';
import { useDispatch, useSelector } from 'react-redux';
import { AndroidScoped, FileSystem } from 'react-native-file-access';
import { RootState, setStorageUri } from '../../state';
import { clearPermissions, isPermissionForPath, noOp } from '../../utilities';
const storageLocationChangeDialogStyles = StyleSheet.create({
progressBar: {
marginVertical: 15,
},
});
const StorageLocationChangeDialog = ({
visible,
setVisible,
setSnackbarMessage,
}: {
visible: boolean;
setVisible: (visible: boolean) => void;
setSnackbarMessage: (message: string) => void;
}) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const storageUri = useSelector(
(state: RootState) => state.settings.storageUri,
)!;
const dispatch = useDispatch();
const [progress, setProgress] = useState(0);
useEffect(() => {
const selectNewStorageUri = async () => {
const uri = await openDocumentTree(true).catch(noOp);
if (!uri) {
setVisible(false);
return;
}
const newStorageUri = uri.uri;
if (isPermissionForPath(storageUri, newStorageUri)) {
setSnackbarMessage('Folder already selected.');
setVisible(false);
return;
}
const files = await FileSystem.ls(storageUri);
let filesCopied = 0;
await Promise.all(
files.map(async file => {
const oldUri = AndroidScoped.appendPath(storageUri, file);
const newUri = AndroidScoped.appendPath(newStorageUri, file);
// You may be wondering, why cp and unlink instead of mv?
// That's because Android is a fuck and does not allow moving across different scoped storage paths.
await FileSystem.cp(oldUri, newUri).catch(noOp);
await FileSystem.unlink(oldUri).catch(noOp);
filesCopied++;
setProgress(filesCopied / files.length);
}),
);
await dispatch(setStorageUri(newStorageUri));
await clearPermissions([newStorageUri]);
setVisible(false);
setProgress(0);
};
if (visible) void selectNewStorageUri();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible]);
return (
<Dialog
visible={visible}
onDismiss={() => setVisible(false)}
dismissable={false}
dismissableBackButton={false}>
<Dialog.Title>Change Storage Location</Dialog.Title>
<Dialog.Content>
<Text>Copying files. Do not close the app.</Text>
<ProgressBar
animatedValue={progress}
style={storageLocationChangeDialogStyles.progressBar}
/>
</Dialog.Content>
</Dialog>
);
};
export default StorageLocationChangeDialog;

View File

@@ -13,10 +13,12 @@ import { FlashList } from '@shopify/flash-list';
import { useFocusEffect } from '@react-navigation/native';
import { useDeviceOrientation } from '@react-native-community/hooks';
import { useSafeAreaFrame } from 'react-native-safe-area-context';
import { HideableHeader, TagRow, TagsHeader } from '../components';
import { Tag } from '../database';
import { RootState, setNavVisible } from '../state';
import { SORT_DIRECTION, tagSortQuery } from '../types';
import { HideableHeader } from '../../components';
import { Tag } from '../../database';
import { RootState, setNavVisible } from '../../state';
import { SORT_DIRECTION, tagSortQuery } from '../../types';
import TagsHeader from './tagsHeader';
import TagRow from './tagsList/tagRow';
const tagsStyles = StyleSheet.create({
listView: {
@@ -44,6 +46,9 @@ const Tags = () => {
const navVisisble = useSelector(
(state: RootState) => state.navigation.navVisible,
);
const autoFocus = useSelector(
(state: RootState) => state.settings.autoFocusTagsSearch,
);
const dispatch = useDispatch();
const [flashListPadding, setFlashListPadding] = useState(0);
@@ -68,7 +73,7 @@ const Tags = () => {
[search, sort, sortDirection],
);
const [scrollOffset, setScrollOffset] = useState(0);
const previousOffset = useRef(0);
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
const currentOffset = event.nativeEvent.contentOffset.y;
@@ -76,11 +81,11 @@ const Tags = () => {
if (currentOffset <= 150) {
dispatch(setNavVisible(true));
} else {
const diff = currentOffset - scrollOffset;
const diff = currentOffset - previousOffset.current;
if (Math.abs(diff) > 50) dispatch(setNavVisible(diff < 0));
}
setScrollOffset(currentOffset);
previousOffset.current = currentOffset;
};
const flashListRef = useRef<FlashList<Tag>>(null);
@@ -88,7 +93,7 @@ const Tags = () => {
useFocusEffect(
useCallback(() => {
const handleBackPress = () => {
if (scrollOffset > 0) {
if (previousOffset.current > 0) {
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
return true;
}
@@ -99,7 +104,7 @@ const Tags = () => {
return () =>
BackHandler.removeEventListener('hardwareBackPress', handleBackPress);
}, [scrollOffset]),
}, []),
);
useFocusEffect(
@@ -114,6 +119,7 @@ const Tags = () => {
<TagsHeader
search={search}
setSearch={setSearch}
autoFocus={autoFocus}
onLayout={event => {
setFlashListPadding(event.nativeEvent.layout.height);
}}

View File

@@ -25,10 +25,12 @@ const tagsHeaderStyles = StyleSheet.create({
const TagsHeader = ({
search,
setSearch,
autoFocus,
...props
}: {
search: string;
setSearch: (search: string) => void;
autoFocus: boolean;
} & ComponentProps<typeof View>) => {
const sort = useSelector((state: RootState) => state.tags.sort);
const sortDirection = useSelector(
@@ -60,6 +62,7 @@ const TagsHeader = ({
onChangeText={(value: string) => {
setSearch(value);
}}
autoFocus={autoFocus}
/>
<View style={tagsHeaderStyles.buttonView}>
<Menu

View File

@@ -2,9 +2,9 @@ import React from 'react';
import { StyleSheet, View } from 'react-native';
import { TouchableRipple, Text } from 'react-native-paper';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { Tag } from '../../database';
import { ROUTE, RootStackParamList } from '../../types';
import { TagChip } from '.';
import { Tag } from '../../../database';
import { ROUTE, RootStackParamList } from '../../../types';
import { TagChip } from '../../../components';
const tagRowStyles = StyleSheet.create({
view: {

View File

@@ -54,6 +54,8 @@ export {
type SettingsState,
setStorageUri,
setNoMedia,
setAutofocusMemesSearch,
setAutofocusTagsSearch,
setMasonryColumns,
setGridColumns,
validateSettings,
@@ -79,4 +81,5 @@ export {
type NavigationState,
setNavVisible,
toggleNavVisible,
setSnackbarMessage,
} from './navigation';

View File

@@ -2,10 +2,12 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface NavigationState {
navVisible: boolean;
snackbarMessage: string | undefined;
}
const initialState: NavigationState = {
navVisible: true,
snackbarMessage: undefined,
};
const navigationSlice = createSlice({
@@ -18,14 +20,19 @@ const navigationSlice = createSlice({
toggleNavVisible: state => {
state.navVisible = !state.navVisible;
},
setSnackbarMessage: (state, action: PayloadAction<string | undefined>) => {
state.snackbarMessage = action.payload;
},
},
});
const { setNavVisible, toggleNavVisible } = navigationSlice.actions;
const { setNavVisible, toggleNavVisible, setSnackbarMessage } =
navigationSlice.actions;
export {
type NavigationState,
setNavVisible,
toggleNavVisible,
setSnackbarMessage,
};
export default navigationSlice.reducer;

View File

@@ -11,6 +11,8 @@ import { RootState } from '.';
interface SettingsState {
storageUri: string | undefined;
noMedia: boolean;
autoFocusMemesSearch: boolean;
autoFocusTagsSearch: boolean;
masonryColumns: 1 | 2 | 3 | 4;
gridColumns: 1 | 2 | 3 | 4;
}
@@ -18,6 +20,8 @@ interface SettingsState {
const initialState: SettingsState = {
storageUri: undefined,
noMedia: false,
autoFocusMemesSearch: false,
autoFocusTagsSearch: false,
masonryColumns: 2,
gridColumns: 3,
};
@@ -32,6 +36,12 @@ const settingsSlice = createSlice({
setNoMedia: (state, action: PayloadAction<boolean>) => {
state.noMedia = action.payload;
},
setAutofocusMemesSearch: (state, action: PayloadAction<boolean>) => {
state.autoFocusMemesSearch = action.payload;
},
setAutofocusTagsSearch: (state, action: PayloadAction<boolean>) => {
state.autoFocusTagsSearch = action.payload;
},
setMasonryColumns: (state, action: PayloadAction<1 | 2 | 3 | 4>) => {
state.masonryColumns = action.payload;
},
@@ -41,8 +51,14 @@ const settingsSlice = createSlice({
},
});
const { setStorageUri, setNoMedia, setMasonryColumns, setGridColumns } =
settingsSlice.actions;
const {
setStorageUri,
setNoMedia,
setAutofocusMemesSearch,
setAutofocusTagsSearch,
setMasonryColumns,
setGridColumns,
} = settingsSlice.actions;
const updateStorageUri = createAsyncThunk(
'settings/updateStorageUri',
@@ -75,8 +91,7 @@ const updateNoMedia = createAsyncThunk(
const validateSettings = createAsyncThunk(
'settings/validateSettings',
// eslint-disable-next-line @typescript-eslint/naming-convention
async (_, { dispatch, getState }) => {
async (ignored, { dispatch, getState }) => {
const state = getState() as RootState;
const { storageUri, noMedia } = state.settings;
@@ -122,6 +137,8 @@ export {
type SettingsState,
updateStorageUri as setStorageUri,
updateNoMedia as setNoMedia,
setAutofocusMemesSearch,
setAutofocusTagsSearch,
setMasonryColumns,
setGridColumns,
validateSettings,

View File

@@ -1,6 +1,7 @@
interface Dimensions {
width: number;
height: number;
aspectRatio: number;
}
export { type Dimensions };

View File

@@ -1,5 +1,11 @@
export { type Dimensions } from './dimensions';
export { ROUTE, type RootStackParamList } from './route';
export {
ROUTE,
type RootStackParamList,
documentPickerResponseToAddMemeFile,
sharedItemToAddMemeFile,
} from './route';
export { type SharedItem } from './share';
export {
MEME_SORT,
memesSortQuery,
@@ -7,4 +13,5 @@ export {
tagSortQuery,
SORT_DIRECTION,
} from './sort';
export { type StagingMeme, type StagingTag } from './staging';
export { VIEW } from './view';

View File

@@ -1,4 +1,6 @@
import { DocumentPickerResponse } from 'react-native-document-picker';
import { getFilenameFromUri } from '../utilities';
import { SharedItem } from '.';
enum ROUTE {
MAIN = 'Main',
@@ -17,8 +19,48 @@ interface MemeViewRouteParams {
index: number;
}
interface AddMemeFile {
uri: string;
filename: string;
type?: string;
}
const documentPickerResponseToAddMemeFile = (
response: DocumentPickerResponse[],
): AddMemeFile[] => {
return response.map(item => {
const { uri, name, type } = item;
return {
uri,
filename: name ?? getFilenameFromUri(uri),
type: type ?? undefined,
};
});
};
const sharedItemToAddMemeFile = (item: SharedItem): AddMemeFile[] => {
const { data, mimeType } = item;
if (typeof data === 'string') {
return [
{
uri: data,
filename: getFilenameFromUri(data),
type: mimeType,
},
];
}
return data.map(uri => ({
uri,
filename: getFilenameFromUri(uri),
type: mimeType,
}));
};
interface AddMemeRouteParams {
file: DocumentPickerResponse;
files: AddMemeFile[];
}
interface EditMemeRouteParams {
@@ -44,4 +86,9 @@ interface RootStackParamList {
[ROUTE.EDIT_TAG]: EditTagRouteParams;
}
export { ROUTE, type RootStackParamList };
export {
ROUTE,
type RootStackParamList,
documentPickerResponseToAddMemeFile,
sharedItemToAddMemeFile,
};

7
src/types/share.ts Normal file
View File

@@ -0,0 +1,7 @@
interface SharedItem {
data: string | string[];
mimeType: string;
extraData?: object;
}
export { type SharedItem };

15
src/types/staging.ts Normal file
View File

@@ -0,0 +1,15 @@
import { Tag } from '../database';
import { StringValidationResult } from '../utilities';
interface StagingMeme {
title: StringValidationResult;
isFavorite: boolean;
tags: Map<string, Tag>;
}
interface StagingTag {
name: StringValidationResult;
color: StringValidationResult;
}
export { type StagingMeme, type StagingTag };

View File

@@ -0,0 +1,11 @@
import Clipboard from '@react-native-clipboard/clipboard';
const clipboardHasContent = () => {
return Promise.all([Clipboard.hasString(), Clipboard.hasURI()]).then(
([hasString, hasURI]) => {
return hasString || hasURI;
},
);
};
export { clipboardHasContent };

View File

@@ -1,8 +1,7 @@
const packageName = 'com.karaolidis.terminallyonline';
const appName = 'Terminally Online';
const fileProvider = 'com.karaolidis.terminallyonline.rnshare.fileprovider';
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noOp = () => {};
export { packageName, appName, fileProvider, noOp };
export { packageName, appName, noOp };

View File

@@ -1,7 +1,11 @@
import { FileSystem } from 'react-native-file-access';
import filetypemime from 'magic-bytes.js';
import { lookup } from 'react-native-mime-types';
import { MEME_TYPE } from '../database';
const allowedImageMimeTypes = [
'image/bmp',
'image/jpg',
'image/jpeg',
'image/png',
'image/webp',
@@ -9,25 +13,84 @@ const allowedImageMimeTypes = [
const allowedGifMimeTypes = ['image/gif'];
const allowedMimeTypes = [...allowedImageMimeTypes, ...allowedGifMimeTypes];
const allowedVideoMimeTypes = [
'video/av01',
'video/3gpp',
'video/avc',
'video/hevc',
'video/x-matroska',
'video/mp2t',
'video/mp4',
'video/mp42',
'video/mp43',
'video/mp4v-es',
'video/mpeg',
'video/mpeg2',
'video/x-vnd.on2.vp8',
'video/x-vnd.on2.vp9',
'video/webm',
];
const getMemeType = (mimeType: string): MEME_TYPE | undefined => {
switch (mimeType) {
case 'image/bmp':
case 'image/jpeg':
case 'image/png':
case 'image/webp': {
const allowedMimeTypes = [
...allowedImageMimeTypes,
...allowedGifMimeTypes,
...allowedVideoMimeTypes,
];
const getMemeTypeFromMimeType = (mimeType: string): MEME_TYPE | undefined => {
if (!allowedMimeTypes.includes(mimeType)) return undefined;
const mimeStart = mimeType.split('/')[0];
switch (mimeStart) {
case 'image': {
if (mimeType === 'image/gif') return MEME_TYPE.GIF;
return MEME_TYPE.IMAGE;
}
case 'image/gif': {
return MEME_TYPE.GIF;
case 'video': {
return MEME_TYPE.VIDEO;
}
}
};
const guessMimeTypeFromExtension = (filename: string): string | undefined => {
const extension = filename.split('.').pop()?.toLowerCase();
if (!extension) return undefined;
const guessedMimeType = lookup(extension);
if (!guessedMimeType) return undefined;
return guessedMimeType;
};
const guessMimeTypeFromMagicBytes = async (
uri: string,
): Promise<string | undefined> => {
const fileContent = await FileSystem.read(uri, 100);
const possibleMimeTypes = filetypemime(fileContent);
if (possibleMimeTypes.length === 0) return undefined;
return possibleMimeTypes[0].mime;
};
const guessMimeType = async (
uri: string,
hint?: string | null,
): Promise<string | undefined> => {
if (hint && allowedMimeTypes.includes(hint)) return hint;
let guessedMimeType = guessMimeTypeFromExtension(uri);
if (guessedMimeType && allowedMimeTypes.includes(guessedMimeType)) {
return guessedMimeType;
}
guessedMimeType = await guessMimeTypeFromMagicBytes(uri);
if (guessedMimeType && allowedMimeTypes.includes(guessedMimeType)) {
return guessedMimeType;
}
};
export {
allowedImageMimeTypes,
allowedGifMimeTypes,
allowedMimeTypes,
getMemeType,
getMemeTypeFromMimeType,
guessMimeType,
};

View File

@@ -1,3 +1,4 @@
export { clipboardHasContent } from './clipboard';
export {
getContrastColor,
isHexColor,
@@ -6,14 +7,15 @@ export {
rgbToRgba,
generateRandomColor,
} from './color';
export { packageName, appName, fileProvider, noOp } from './constants';
export { packageName, appName, noOp } from './constants';
export { multipleIdQuery } from './database';
export { getFlashListItemHeight, getFontAwesome5IconSize } from './dimensions';
export {
allowedImageMimeTypes,
allowedGifMimeTypes,
allowedMimeTypes,
getMemeType,
getMemeTypeFromMimeType,
guessMimeType,
} from './filesystem';
export { getSortIcon, getViewIcon } from './icon';
export {
@@ -23,7 +25,11 @@ export {
editMeme,
deleteMeme,
} from './meme';
export { isPermissionForPath, clearPermissions } from './permissions';
export {
isPermissionForPath,
clearPermissions,
getFilenameFromUri,
} from './permissions';
export { deleteTag } from './tag';
export {
type StringValidationResult,

View File

@@ -1,11 +1,10 @@
import { NavigationProp } from '@react-navigation/native';
import { Dirs, FileSystem } from 'react-native-file-access';
import { extension } from 'react-native-mime-types';
import { AndroidScoped, Dirs, FileSystem } from 'react-native-file-access';
import Share from 'react-native-share';
import Clipboard from '@react-native-clipboard/clipboard';
import { Meme } from '../database';
import { ROUTE, RootStackParamList } from '../types';
import { noOp } from './constants';
import { noOp } from '.';
const favoriteMeme = (realm: Realm, meme: Meme) => {
realm.write(() => {
@@ -13,10 +12,20 @@ const favoriteMeme = (realm: Realm, meme: Meme) => {
});
};
const shareMeme = async (meme: Meme) => {
const fileExtension = extension(meme.mimeType) as string;
const cacheUri = `${Dirs.CacheDir}/${meme.id.toHexString()}.${fileExtension}`;
await FileSystem.cp(meme.uri, cacheUri);
const shareMeme = async (realm: Realm, storageUri: string, meme: Meme) => {
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
const cacheUri = `${Dirs.CacheDir}/${meme.filename}`;
realm.write(() => {
meme.dateUsed = new Date();
meme.timesUsed += 1;
meme.tags.forEach(tag => {
tag.dateUsed = new Date();
tag.timesUsed += 1;
});
});
await FileSystem.cp(uri, cacheUri);
await Share.open({
url: `file://${cacheUri}`,
type: meme.mimeType,
@@ -24,10 +33,22 @@ const shareMeme = async (meme: Meme) => {
});
};
const copyMeme = async (meme: Meme) => {
const exists = await FileSystem.exists(meme.uri);
const copyMeme = async (realm: Realm, storageUri: string, meme: Meme) => {
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
const exists = await FileSystem.exists(uri);
if (!exists) throw new Error('File does not exist');
Clipboard.setURI(meme.uri);
realm.write(() => {
meme.dateUsed = new Date();
meme.timesUsed += 1;
meme.tags.forEach(tag => {
tag.dateUsed = new Date();
tag.timesUsed += 1;
});
});
Clipboard.setURI(uri);
};
const editMeme = (
@@ -37,8 +58,10 @@ const editMeme = (
navigation.navigate(ROUTE.EDIT_MEME, { id: meme.id.toHexString() });
};
const deleteMeme = async (realm: Realm, meme: Meme) => {
await FileSystem.unlink(meme.uri).catch(noOp);
const deleteMeme = async (realm: Realm, storageUri: string, meme: Meme) => {
const uri = AndroidScoped.appendPath(storageUri, meme.filename);
await FileSystem.unlink(uri).catch(noOp);
realm.write(() => {
for (const tag of meme.tags) {

View File

@@ -1,10 +1,11 @@
import { Util } from 'react-native-file-access';
import {
getPersistedUriPermissions,
releasePersistableUriPermission,
} from 'react-native-scoped-storage';
const isPermissionForPath = (permission: string, path: string) => {
return path.startsWith(permission + '/');
return path.startsWith(permission + '/') || path === permission;
};
const clearPermissions = async (excepts: string[] = []) => {
@@ -16,4 +17,8 @@ const clearPermissions = async (excepts: string[] = []) => {
});
};
export { isPermissionForPath, clearPermissions };
const getFilenameFromUri = (uri: string) => {
return Util.basename(uri.replaceAll('%2F', '/'));
};
export { isPermissionForPath, clearPermissions, getFilenameFromUri };

View File

@@ -1,4 +1,4 @@
import { isHexColor, isRgbColor } from './color';
import { isHexColor, isRgbColor } from '.';
interface StringValidationResult {
valid: boolean;