refactor: post editor
This commit is contained in:
711
package-lock.json
generated
711
package-lock.json
generated
@@ -25,6 +25,13 @@
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@tiptap/extension-history": "^2.12.0",
|
||||
"@tiptap/extension-mention": "^2.12.0",
|
||||
"@tiptap/extension-placeholder": "^2.12.0",
|
||||
"@tiptap/pm": "^2.12.0",
|
||||
"@tiptap/react": "^2.12.0",
|
||||
"@tiptap/starter-kit": "^2.12.0",
|
||||
"@tiptap/suggestion": "^2.12.0",
|
||||
"@webbtc/webln-types": "^3.0.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -49,6 +56,7 @@
|
||||
"react-simple-pull-to-refresh": "^1.3.3",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tippy.js": "^6.3.7",
|
||||
"vaul": "^1.1.2",
|
||||
"yet-another-react-lightbox": "^3.21.7",
|
||||
"zod": "^3.24.1"
|
||||
@@ -2421,6 +2429,15 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
|
||||
@@ -3391,6 +3408,11 @@
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz",
|
||||
"integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="
|
||||
},
|
||||
"node_modules/@remirror/core-constants": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg=="
|
||||
},
|
||||
"node_modules/@rollup/plugin-node-resolve": {
|
||||
"version": "15.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
|
||||
@@ -3810,6 +3832,406 @@
|
||||
"string.prototype.matchall": "^4.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.12.0.tgz",
|
||||
"integrity": "sha512-3qX8oGVKFFZzQ0vit+ZolR6AJIATBzmEmjAA0llFhWk4vf3v64p1YcXcJsOBsr5scizJu5L6RYWEFatFwqckRg==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.12.0.tgz",
|
||||
"integrity": "sha512-XUC2A77YAPMJS2SqZ2S62IGcUH8gZ7cdhoWlYQb1pR4ZzXFByeKDJPxfYeAePSiuI01YGrlzgY2c6Ncx/DtO0A==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.12.0.tgz",
|
||||
"integrity": "sha512-lAUtoLDLRc5ofD2I9MFY6MQ7d1qBLLqS1rvpwaPjOaoQb/GPVnaHj9qXYG0SY9K3erMtto48bMFpAcscjZHzZQ==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bubble-menu": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.12.0.tgz",
|
||||
"integrity": "sha512-DYijoE0igV0Oi+ZppFsp2UrQsM/4HZtmmpD78BJM9zfCbd1YvAUIxmzmXr8uqU18OHd1uQy+/zvuNoUNYyw67g==",
|
||||
"dependencies": {
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.12.0.tgz",
|
||||
"integrity": "sha512-YTCjztB8MaIpwyxFYr81H4+LdKCq1VlaSXQyrPdB44mVdhhRqc46BYQb8/B//XE3UIu3X2QWFjwrqRlUq6vUiw==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.12.0.tgz",
|
||||
"integrity": "sha512-R7RaS+hJeHFim7alImQ9L9CSWSMjWXvz0Ote568x9ea5gdBGUYW8PcH+5a91lh8e1XGYWBM12a8oJZRyxg/tQA==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.12.0.tgz",
|
||||
"integrity": "sha512-1D7cYAjgxEFHdfC/35Ooi4GqWKB5sszbW8iI7N16XILNln26xb0d5KflXqYrwr9CN/ZnZoCl2o6YsP7xEObcZA==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.12.0.tgz",
|
||||
"integrity": "sha512-sA1Q+mxDIv0Y3qQTBkYGwknNbDcGFiJ/fyAFholXpqbrcRx3GavwR/o0chBdsJZlFht0x7AWGwUYWvIo7wYilA==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.12.0.tgz",
|
||||
"integrity": "sha512-zcZSOXFj+7LVnmdPWTfKr5AoxYIzFPFlLJe35AdTQC5IhkljLn1Exct8I30ZREojX/00hKYsO7JJmePS6TEVlQ==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-floating-menu": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.12.0.tgz",
|
||||
"integrity": "sha512-BYpyZx/56KCDksWuJJbhki/uNgt9sACuSSZFH5AN1yS1ISD+EzIxqf6Pzzv8QCoNJ+KcRNVaZsOlOFaJGoyzag==",
|
||||
"dependencies": {
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.12.0.tgz",
|
||||
"integrity": "sha512-k8ji5v9YKn7bNjo8UtI9hEfXfl4tKUp1hpJOEmUxGJQa3LIwrwSbReupUTnHszGQelzxikS/l1xO9P0TIGwRoA==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.12.0.tgz",
|
||||
"integrity": "sha512-08MNS2PK5DzdnAfqXn4krmJ/xebKmWpRpYqqN5EM8AvetYKlAJyTVSpo0ZUeGbZ3EZiPm9djgSnrLqpFUDjRCg==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.12.0.tgz",
|
||||
"integrity": "sha512-9DfES4Wd5TX1foI70N9sAL+35NN1UHrtzDYN2+dTHupnmKir9RaMXyZcbkUb4aDVzYrGxIqxJzHBVkquKIlTrw==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-history": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.12.0.tgz",
|
||||
"integrity": "sha512-+B9CAf2BFURC6mQiM1OQtahVTzdEOEgT/UUNlRZkeeBc0K5of3dr6UdBqaoaMAefja3jx5PqiQ7mhUBAjSt6AA==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.12.0.tgz",
|
||||
"integrity": "sha512-Vi2+6RIehDSpoJn/7PDuOieUj7W7WrEb4wBxK9TG8PDscihR0mehhhzm/K2xhH4TN48iPJGRsjDFrFjTbXmcnw==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.12.0.tgz",
|
||||
"integrity": "sha512-JKcXK3LmEsmxNzEq5e06rPUGMRLUxmJ2mYtBY4NlJ6yLM9XMDljtgeTnWT0ySLYmfINSFTkX4S7WIRbpl9l4pw==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.12.0.tgz",
|
||||
"integrity": "sha512-4YwZooC8HP+gPxs6YrkB1ayggyYbgVvJx/rWBT6lKSW2MVVg8QXi1zAcSI3MhIhHmqDysXXFPL8JURlbeGjaFA==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-mention": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-2.12.0.tgz",
|
||||
"integrity": "sha512-+b/fqOU+pRWWAo0ZfyInkhkvV0Ub5RpNrYZ45v2nn5PjbXbxyxNQ51zT6cGk2F6Jmc6UBmlR8iqqNTIQY9ieEg==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0",
|
||||
"@tiptap/suggestion": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.12.0.tgz",
|
||||
"integrity": "sha512-1ys0e/oqk09oXxrB1WzAx5EntK/QreObG/V1yhgihGm429fxHMsxzIYN6dKAYxx0YOPQG7qEZRrrPuWU70Ms7g==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.12.0.tgz",
|
||||
"integrity": "sha512-QNK5cgewCunWFxpLlbvvoO1rrLgEtNKxiY79fctP9toV+e59R+1i1Q9lXC1O5mOfDgVxCb6uFDMsqmKhFjpPog==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-placeholder": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.12.0.tgz",
|
||||
"integrity": "sha512-K7irDox4P+NLAMjVrJeG72f0sulsCRYpx1Cy4gxKCdi1LTivj5VkXa6MXmi42KTCwBu3pWajBctYIOAES1FTAA==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.12.0.tgz",
|
||||
"integrity": "sha512-nBaa5YtBsLJPZFfSs36sBz4Zgi/c8b3MsmS/Az8uXaHb0R9yPewOVUMDIQbxMct8SXUlIo9VtKlOL+mVJ3Nkpw==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.12.0.tgz",
|
||||
"integrity": "sha512-0ytN9V1tZYTXdiYDQg4FB2SQ56JAJC9r/65snefb9ztl+gZzDrIvih7CflHs1ic9PgyjexfMLeH+VzuMccNyZw==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-style": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.12.0.tgz",
|
||||
"integrity": "sha512-Pxwt23ZlvbQUahV0PvHy8Ej6IAuKR1FvHobUvwP3T8AiY7hob66fWRe7tQbESzSAzm5Vv2xkvyHeU8vekMTezA==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.12.0.tgz",
|
||||
"integrity": "sha512-TNzVwpeNzFfHAcYTOKqX9iU4fRxliyoZrCnERR+RRzeg7gWrXrCLubQt1WEx0sojMAfznshSL3M5HGsYjEbYwA==",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-collab": "^1.3.1",
|
||||
"prosemirror-commands": "^1.6.2",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-markdown": "^1.13.1",
|
||||
"prosemirror-menu": "^1.2.4",
|
||||
"prosemirror-model": "^1.23.0",
|
||||
"prosemirror-schema-basic": "^1.2.3",
|
||||
"prosemirror-schema-list": "^1.4.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.6.4",
|
||||
"prosemirror-trailing-node": "^3.0.0",
|
||||
"prosemirror-transform": "^1.10.2",
|
||||
"prosemirror-view": "^1.37.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/react": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.12.0.tgz",
|
||||
"integrity": "sha512-D+PR+4kJO9h8AB/7XyQ/Anw8tqeS2ecv5QemBOCHi9JlMAjytauUrj6IfFBO9RbsCowlBjW5GnSpFhzpk2Gghg==",
|
||||
"dependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^2.12.0",
|
||||
"@tiptap/extension-floating-menu": "^2.12.0",
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"fast-deep-equal": "^3",
|
||||
"use-sync-external-store": "^1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.12.0.tgz",
|
||||
"integrity": "sha512-wlcEEtexd6u0gbR311/OFZnbtRWU97DUsY6/GsSQzN4rqZ7Ra6YbfHEN5Lutu+I/anomK8vKy8k9NyvfY5Hllg==",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^2.12.0",
|
||||
"@tiptap/extension-blockquote": "^2.12.0",
|
||||
"@tiptap/extension-bold": "^2.12.0",
|
||||
"@tiptap/extension-bullet-list": "^2.12.0",
|
||||
"@tiptap/extension-code": "^2.12.0",
|
||||
"@tiptap/extension-code-block": "^2.12.0",
|
||||
"@tiptap/extension-document": "^2.12.0",
|
||||
"@tiptap/extension-dropcursor": "^2.12.0",
|
||||
"@tiptap/extension-gapcursor": "^2.12.0",
|
||||
"@tiptap/extension-hard-break": "^2.12.0",
|
||||
"@tiptap/extension-heading": "^2.12.0",
|
||||
"@tiptap/extension-history": "^2.12.0",
|
||||
"@tiptap/extension-horizontal-rule": "^2.12.0",
|
||||
"@tiptap/extension-italic": "^2.12.0",
|
||||
"@tiptap/extension-list-item": "^2.12.0",
|
||||
"@tiptap/extension-ordered-list": "^2.12.0",
|
||||
"@tiptap/extension-paragraph": "^2.12.0",
|
||||
"@tiptap/extension-strike": "^2.12.0",
|
||||
"@tiptap/extension-text": "^2.12.0",
|
||||
"@tiptap/extension-text-style": "^2.12.0",
|
||||
"@tiptap/pm": "^2.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/suggestion": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.12.0.tgz",
|
||||
"integrity": "sha512-bsXLoZbjUo1oOF1Z+XSfoGzbcnrTcYtJdfylM/FerMLU9T12dhsM/Ri2SKLX4IR5D0HJ07FcsEHCrGEy8Y5y0A==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^2.7.0",
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -3863,6 +4285,25 @@
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="
|
||||
},
|
||||
"node_modules/@types/markdown-it": {
|
||||
"version": "14.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "^5",
|
||||
"@types/mdurl": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
|
||||
@@ -3909,6 +4350,11 @@
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.1.tgz",
|
||||
@@ -4232,8 +4678,7 @@
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.4",
|
||||
@@ -5091,6 +5536,11 @@
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -5361,6 +5811,17 @@
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.23.7",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.7.tgz",
|
||||
@@ -5535,7 +5996,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -5719,8 +6179,7 @@
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.2",
|
||||
@@ -6933,6 +7392,14 @@
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -7008,6 +7475,22 @@
|
||||
"sourcemap-codec": "^1.4.8"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "^4.4.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"mdurl": "^2.0.0",
|
||||
"punycode.js": "^2.3.1",
|
||||
"uc.micro": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -7017,6 +7500,11 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -7248,6 +7736,11 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -7567,6 +8060,183 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.0.tgz",
|
||||
"integrity": "sha512-8wRKhlEwEJ4I13Ju54q2NZR1pVKGTgJ/8XsQ8L5A5uUsQ/YQScQJuEAuh8Bn8i6IwAMjjLRABd9lVli+DlIiVw==",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-collab": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
|
||||
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz",
|
||||
"integrity": "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz",
|
||||
"integrity": "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-inputrules": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.0.tgz",
|
||||
"integrity": "sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-markdown": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz",
|
||||
"integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==",
|
||||
"dependencies": {
|
||||
"@types/markdown-it": "^14.0.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"prosemirror-model": "^1.25.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-menu": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz",
|
||||
"integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==",
|
||||
"dependencies": {
|
||||
"crelt": "^1.0.0",
|
||||
"prosemirror-commands": "^1.0.0",
|
||||
"prosemirror-history": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.1.tgz",
|
||||
"integrity": "sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg==",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-basic": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
|
||||
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.25.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz",
|
||||
"integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.7.1.tgz",
|
||||
"integrity": "sha512-eRQ97Bf+i9Eby99QbyAiyov43iOKgWa7QCGly+lrDt7efZ1v8NWolhXiB43hSDGIXT1UXgbs4KJN3a06FGpr1Q==",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-model": "^1.25.0",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-transform": "^1.10.3",
|
||||
"prosemirror-view": "^1.39.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-trailing-node": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
|
||||
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
|
||||
"dependencies": {
|
||||
"@remirror/core-constants": "3.0.0",
|
||||
"escape-string-regexp": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prosemirror-model": "^1.22.1",
|
||||
"prosemirror-state": "^1.4.2",
|
||||
"prosemirror-view": "^1.33.8"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.10.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz",
|
||||
"integrity": "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.39.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.39.2.tgz",
|
||||
"integrity": "sha512-BmOkml0QWNob165gyUxXi5K5CVUgVPpqMEAAml/qzgKn9boLUWVPzQ6LtzXw8Cn1GtRQX4ELumPxqtLTDaAKtg==",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -7576,6 +8246,14 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode-generator": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz",
|
||||
@@ -7967,6 +8645,11 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -8669,6 +9352,14 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tippy.js": {
|
||||
"version": "6.3.7",
|
||||
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
|
||||
"integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -8844,6 +9535,11 @@
|
||||
"typescript": ">=4.8.4 <5.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
@@ -9153,6 +9849,11 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
||||
|
||||
@@ -35,6 +35,13 @@
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@tiptap/extension-history": "^2.12.0",
|
||||
"@tiptap/extension-mention": "^2.12.0",
|
||||
"@tiptap/extension-placeholder": "^2.12.0",
|
||||
"@tiptap/pm": "^2.12.0",
|
||||
"@tiptap/react": "^2.12.0",
|
||||
"@tiptap/starter-kit": "^2.12.0",
|
||||
"@tiptap/suggestion": "^2.12.0",
|
||||
"@webbtc/webln-types": "^3.0.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -59,6 +66,7 @@
|
||||
"react-simple-pull-to-refresh": "^1.3.3",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tippy.js": "^6.3.7",
|
||||
"vaul": "^1.1.2",
|
||||
"yet-another-react-lightbox": "^3.21.7",
|
||||
"zod": "^3.24.1"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { extractEmojiInfosFromTags, isNsfwEvent } from '@/lib/event'
|
||||
import { extractImageInfoFromTag } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
import mediaUpload from '@/services/media-upload.service'
|
||||
import { TImageInfo } from '@/types'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { memo } from 'react'
|
||||
@@ -46,7 +47,11 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
|
||||
.map((node) => {
|
||||
if (node.type === 'image') {
|
||||
const imageInfo = imageInfos.find((image) => image.url === node.data)
|
||||
return imageInfo ?? { url: node.data }
|
||||
if (imageInfo) {
|
||||
return imageInfo
|
||||
}
|
||||
const tag = mediaUpload.getImetaTagByUrl(node.data)
|
||||
return tag ? extractImageInfoFromTag(tag) : { url: node.data }
|
||||
}
|
||||
if (node.type === 'images') {
|
||||
const urls = Array.isArray(node.data) ? node.data : [node.data]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import Username, { SimpleUsername } from '../Username'
|
||||
|
||||
export function EmbeddedMention({ userId }: { userId: string }) {
|
||||
@@ -6,6 +7,8 @@ export function EmbeddedMention({ userId }: { userId: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function EmbeddedMentionText({ userId }: { userId: string }) {
|
||||
return <SimpleUsername userId={userId} showAt className="inline truncate" withoutSkeleton />
|
||||
export function EmbeddedMentionText({ userId, className }: { userId: string; className?: string }) {
|
||||
return (
|
||||
<SimpleUsername userId={userId} showAt className={cn('inline', className)} withoutSkeleton />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { createPictureNoteDraftEvent } from '@/lib/draft-event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import postContentCache from '@/services/post-content-cache.service'
|
||||
import { ChevronDown, Loader, LoaderCircle, Plus, X } from 'lucide-react'
|
||||
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Image from '../Image'
|
||||
import PostTextarea from '../PostTextarea'
|
||||
import Mentions from './Mentions'
|
||||
import PostOptions from './PostOptions'
|
||||
import SendOnlyToSwitch from './SendOnlyToSwitch'
|
||||
import Uploader from './Uploader'
|
||||
import { preprocessContent } from './utils'
|
||||
|
||||
export default function PicturePostContent({ close }: { close: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
const { publish, checkLogin } = useNostr()
|
||||
const [content, setContent] = useState('')
|
||||
const [processedContent, setProcessedContent] = useState('')
|
||||
const [pictureInfos, setPictureInfos] = useState<{ url: string; tags: string[][] }[]>([])
|
||||
const [posting, setPosting] = useState(false)
|
||||
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||
const [addClientTag, setAddClientTag] = useState(false)
|
||||
const [mentions, setMentions] = useState<string[]>([])
|
||||
const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState<string[] | undefined>(undefined)
|
||||
const initializedRef = useRef(false)
|
||||
const canPost = !!content && !posting && pictureInfos.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
const { content, pictureInfos } = postContentCache.getPicturePostCache()
|
||||
setContent(content)
|
||||
setPictureInfos(pictureInfos)
|
||||
setTimeout(() => {
|
||||
initializedRef.current = true
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setProcessedContent(preprocessContent(content))
|
||||
if (!initializedRef.current) return
|
||||
postContentCache.setPicturePostCache(content, pictureInfos)
|
||||
}, [content, pictureInfos])
|
||||
|
||||
const post = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
if (!canPost) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
setPosting(true)
|
||||
try {
|
||||
if (!pictureInfos.length) {
|
||||
throw new Error(t('Picture note requires images'))
|
||||
}
|
||||
const draftEvent = await createPictureNoteDraftEvent(
|
||||
processedContent,
|
||||
pictureInfos,
|
||||
mentions,
|
||||
{
|
||||
addClientTag,
|
||||
protectedEvent: !!specifiedRelayUrls
|
||||
}
|
||||
)
|
||||
await publish(draftEvent, { specifiedRelayUrls })
|
||||
setContent('')
|
||||
setPictureInfos([])
|
||||
close()
|
||||
} catch (error) {
|
||||
if (error instanceof AggregateError) {
|
||||
error.errors.forEach((e) =>
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('Failed to post'),
|
||||
description: e.message
|
||||
})
|
||||
)
|
||||
} else if (error instanceof Error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('Failed to post'),
|
||||
description: error.message
|
||||
})
|
||||
}
|
||||
console.error(error)
|
||||
return
|
||||
} finally {
|
||||
setPosting(false)
|
||||
}
|
||||
toast({
|
||||
title: t('Post successful'),
|
||||
description: t('Your post has been published')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('A special note for picture-first clients like Olas')}
|
||||
</div>
|
||||
<PictureUploader pictureInfos={pictureInfos} setPictureInfos={setPictureInfos} />
|
||||
<PostTextarea
|
||||
className="h-32"
|
||||
setTextValue={setContent}
|
||||
textValue={content}
|
||||
placeholder={t('Write something...')}
|
||||
/>
|
||||
<SendOnlyToSwitch
|
||||
specifiedRelayUrls={specifiedRelayUrls}
|
||||
setSpecifiedRelayUrls={setSpecifiedRelayUrls}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-foreground gap-0 px-0"
|
||||
onClick={() => setShowMoreOptions((pre) => !pre)}
|
||||
>
|
||||
{t('More options')}
|
||||
<ChevronDown className={`transition-transform ${showMoreOptions ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Mentions content={processedContent} mentions={mentions} setMentions={setMentions} />
|
||||
<div className="flex gap-2 items-center max-sm:hidden">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canPost} onClick={post}>
|
||||
{posting && <LoaderCircle className="animate-spin" />}
|
||||
{t('Post')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PostOptions
|
||||
show={showMoreOptions}
|
||||
addClientTag={addClientTag}
|
||||
setAddClientTag={setAddClientTag}
|
||||
/>
|
||||
<div className="flex gap-2 items-center justify-around sm:hidden">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button className="w-full" type="submit" disabled={!canPost} onClick={post}>
|
||||
{posting && <LoaderCircle className="animate-spin" />}
|
||||
{t('Post')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PictureUploader({
|
||||
pictureInfos,
|
||||
setPictureInfos
|
||||
}: {
|
||||
pictureInfos: { url: string; tags: string[][] }[]
|
||||
setPictureInfos: Dispatch<
|
||||
SetStateAction<
|
||||
{
|
||||
url: string
|
||||
tags: string[][]
|
||||
}[]
|
||||
>
|
||||
>
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{pictureInfos.map(({ url }, index) => (
|
||||
<div className="relative" key={`${index}-${url}`}>
|
||||
<Image image={{ url }} className="aspect-square w-full rounded-lg" />
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 rounded-full w-6 h-6 p-0"
|
||||
onClick={() => {
|
||||
setPictureInfos((prev) => prev.filter((_, i) => i !== index))
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Uploader
|
||||
onUploadSuccess={({ url, tags }) => {
|
||||
setPictureInfos((prev) => [...prev, { url, tags }])
|
||||
}}
|
||||
onUploadingChange={setUploading}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-2 items-center justify-center aspect-square w-full rounded-lg border border-dashed',
|
||||
uploading ? 'cursor-not-allowed text-muted-foreground' : 'clickable'
|
||||
)}
|
||||
>
|
||||
{uploading ? <Loader size={36} className="animate-spin" /> : <Plus size={36} />}
|
||||
</div>
|
||||
</Uploader>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,22 @@
|
||||
import Note from '@/components/Note'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/draft-event'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import postContentCache from '@/services/post-content-cache.service'
|
||||
import { ChevronDown, ImageUp, LoaderCircle } from 'lucide-react'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PostTextarea from '../PostTextarea'
|
||||
import Mentions from './Mentions'
|
||||
import { usePostEditor } from './PostEditorProvider'
|
||||
import PostOptions from './PostOptions'
|
||||
import Preview from './Preview'
|
||||
import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
|
||||
import SendOnlyToSwitch from './SendOnlyToSwitch'
|
||||
import Uploader from './Uploader'
|
||||
import { preprocessContent } from './utils'
|
||||
|
||||
export default function NormalPostContent({
|
||||
export default function PostContent({
|
||||
defaultContent = '',
|
||||
parentEvent,
|
||||
close
|
||||
@@ -30,38 +28,15 @@ export default function NormalPostContent({
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
const { publish, checkLogin } = useNostr()
|
||||
const [content, setContent] = useState('')
|
||||
const [processedContent, setProcessedContent] = useState('')
|
||||
const [pictureInfos, setPictureInfos] = useState<{ url: string; tags: string[][] }[]>([])
|
||||
const { uploadingFiles, setUploadingFiles } = usePostEditor()
|
||||
const [text, setText] = useState('')
|
||||
const textareaRef = useRef<TPostTextareaHandle>(null)
|
||||
const [posting, setPosting] = useState(false)
|
||||
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||
const [addClientTag, setAddClientTag] = useState(false)
|
||||
const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState<string[] | undefined>(undefined)
|
||||
const [uploadingPicture, setUploadingPicture] = useState(false)
|
||||
const [mentions, setMentions] = useState<string[]>([])
|
||||
const [cursorOffset, setCursorOffset] = useState(0)
|
||||
const initializedRef = useRef(false)
|
||||
const canPost = !!content && !posting
|
||||
|
||||
useEffect(() => {
|
||||
const cached = postContentCache.getNormalPostCache({ defaultContent, parentEvent })
|
||||
if (cached) {
|
||||
setContent(cached.content || '')
|
||||
setPictureInfos(cached.pictureInfos || [])
|
||||
}
|
||||
if (defaultContent) {
|
||||
setCursorOffset(defaultContent.length)
|
||||
}
|
||||
setTimeout(() => {
|
||||
initializedRef.current = true
|
||||
}, 100)
|
||||
}, [defaultContent, parentEvent])
|
||||
|
||||
useEffect(() => {
|
||||
setProcessedContent(preprocessContent(content))
|
||||
if (!initializedRef.current) return
|
||||
postContentCache.setNormalPostCache({ defaultContent, parentEvent }, content, pictureInfos)
|
||||
}, [content, pictureInfos])
|
||||
const canPost = !!text && !posting && !uploadingFiles
|
||||
|
||||
const post = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -75,17 +50,17 @@ export default function NormalPostContent({
|
||||
try {
|
||||
const draftEvent =
|
||||
parentEvent && parentEvent.kind !== kinds.ShortTextNote
|
||||
? await createCommentDraftEvent(processedContent, parentEvent, pictureInfos, mentions, {
|
||||
? await createCommentDraftEvent(text, parentEvent, mentions, {
|
||||
addClientTag,
|
||||
protectedEvent: !!specifiedRelayUrls
|
||||
})
|
||||
: await createShortTextNoteDraftEvent(processedContent, pictureInfos, mentions, {
|
||||
: await createShortTextNoteDraftEvent(text, mentions, {
|
||||
parentEvent,
|
||||
addClientTag,
|
||||
protectedEvent: !!specifiedRelayUrls
|
||||
})
|
||||
await publish(draftEvent, { specifiedRelayUrls })
|
||||
setContent('')
|
||||
postContentCache.clearPostCache({ defaultContent, parentEvent })
|
||||
close()
|
||||
} catch (error) {
|
||||
if (error instanceof AggregateError) {
|
||||
@@ -124,29 +99,13 @@ export default function NormalPostContent({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
<Tabs defaultValue="edit" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="edit">{t('Edit')}</TabsTrigger>
|
||||
<TabsTrigger value="preview">{t('Preview')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="edit">
|
||||
<PostTextarea
|
||||
className="h-52"
|
||||
setTextValue={setContent}
|
||||
textValue={content}
|
||||
placeholder={
|
||||
t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')'
|
||||
}
|
||||
cursorOffset={cursorOffset}
|
||||
onUploadImage={({ url, tags }) => {
|
||||
setPictureInfos((prev) => [...prev, { url, tags }])
|
||||
}}
|
||||
ref={textareaRef}
|
||||
text={text}
|
||||
setText={setText}
|
||||
defaultContent={defaultContent}
|
||||
parentEvent={parentEvent}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="preview">
|
||||
<Preview content={processedContent} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<SendOnlyToSwitch
|
||||
parentEvent={parentEvent}
|
||||
specifiedRelayUrls={specifiedRelayUrls}
|
||||
@@ -155,15 +114,16 @@ export default function NormalPostContent({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Uploader
|
||||
onUploadSuccess={({ url, tags }) => {
|
||||
setPictureInfos((prev) => [...prev, { url, tags }])
|
||||
setContent((prev) => (prev === '' ? url : `${prev}\n${url}`))
|
||||
onUploadSuccess={({ url }) => {
|
||||
textareaRef.current?.appendText(url)
|
||||
}}
|
||||
onUploadingChange={setUploadingPicture}
|
||||
onUploadingChange={(uploading) =>
|
||||
setUploadingFiles((prev) => (uploading ? prev + 1 : prev - 1))
|
||||
}
|
||||
accept="image/*,video/*,audio/*"
|
||||
>
|
||||
<Button variant="secondary" disabled={uploadingPicture}>
|
||||
{uploadingPicture ? <LoaderCircle className="animate-spin" /> : <ImageUp />}
|
||||
<Button variant="secondary" disabled={uploadingFiles > 0}>
|
||||
{uploadingFiles > 0 ? <LoaderCircle className="animate-spin" /> : <ImageUp />}
|
||||
</Button>
|
||||
</Uploader>
|
||||
<Button
|
||||
@@ -179,7 +139,7 @@ export default function NormalPostContent({
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Mentions
|
||||
content={processedContent}
|
||||
content={text}
|
||||
parentEvent={parentEvent}
|
||||
mentions={mentions}
|
||||
setMentions={setMentions}
|
||||
26
src/components/PostEditor/PostEditorProvider.tsx
Normal file
26
src/components/PostEditor/PostEditorProvider.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createContext, Dispatch, SetStateAction, useContext, useState } from 'react'
|
||||
|
||||
type TPostEditorContext = {
|
||||
uploadingFiles: number
|
||||
setUploadingFiles: Dispatch<SetStateAction<number>>
|
||||
}
|
||||
|
||||
const PostEditorContext = createContext<TPostEditorContext | undefined>(undefined)
|
||||
|
||||
export const usePostEditor = () => {
|
||||
const context = useContext(PostEditorContext)
|
||||
if (!context) {
|
||||
throw new Error('usePostEditor must be used within a PostEditorProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function PostEditorProvider({ children }: { children: React.ReactNode }) {
|
||||
const [uploadingFiles, setUploadingFiles] = useState(0)
|
||||
|
||||
return (
|
||||
<PostEditorContext.Provider value={{ uploadingFiles, setUploadingFiles }}>
|
||||
{children}
|
||||
</PostEditorContext.Provider>
|
||||
)
|
||||
}
|
||||
92
src/components/PostEditor/PostTextarea/CustomMention.ts
Normal file
92
src/components/PostEditor/PostTextarea/CustomMention.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { formatNpub } from '@/lib/pubkey'
|
||||
import { ExtendedRegExpMatchArray, InputRule, PasteRule, Range, SingleCommands } from '@tiptap/core'
|
||||
import Mention from '@tiptap/extension-mention'
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import MentionNode from './MentionNode'
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
mention: {
|
||||
createMention: (id: string) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MENTION_REGEX = /(nostr:)?(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)/g
|
||||
|
||||
const CustomMention = Mention.extend({
|
||||
selectable: true,
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MentionNode)
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
|
||||
createMention:
|
||||
(npub: string) =>
|
||||
({ chain }) => {
|
||||
chain()
|
||||
.focus()
|
||||
.insertContent([
|
||||
{
|
||||
type: 'mention',
|
||||
attrs: {
|
||||
id: npub,
|
||||
label: formatNpub(npub)
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ' '
|
||||
}
|
||||
])
|
||||
.run()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: MENTION_REGEX,
|
||||
handler: (props) => handler(props)
|
||||
})
|
||||
]
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
return [
|
||||
new PasteRule({
|
||||
find: MENTION_REGEX,
|
||||
handler: (props) => handler(props)
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
export default CustomMention
|
||||
|
||||
function handler({
|
||||
range,
|
||||
match,
|
||||
commands
|
||||
}: {
|
||||
commands: SingleCommands
|
||||
match: ExtendedRegExpMatchArray
|
||||
range: Range
|
||||
}) {
|
||||
const mention = match[0]
|
||||
if (!mention) return
|
||||
const npub = mention.replace('nostr:', '')
|
||||
|
||||
const matchLength = mention.length
|
||||
const end = range.to
|
||||
const start = Math.max(0, end - matchLength)
|
||||
|
||||
commands.deleteRange({ from: start, to: end })
|
||||
commands.createMention(npub)
|
||||
}
|
||||
155
src/components/PostEditor/PostTextarea/FileHandler.ts
Normal file
155
src/components/PostEditor/PostTextarea/FileHandler.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import mediaUpload from '@/services/media-upload.service'
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { EditorView } from '@tiptap/pm/view'
|
||||
import { Plugin, TextSelection } from 'prosemirror-state'
|
||||
|
||||
const DRAGOVER_CLASS_LIST = [
|
||||
'outline-2',
|
||||
'outline-offset-4',
|
||||
'outline-dashed',
|
||||
'outline-border',
|
||||
'rounded-md'
|
||||
]
|
||||
|
||||
export interface FileHandlerOptions {
|
||||
onUploadStart?: (file: File) => void
|
||||
onUploadSuccess?: (file: File, result: any) => void
|
||||
onUploadError?: (file: File, error: any) => void
|
||||
}
|
||||
|
||||
export const FileHandler = Extension.create<FileHandlerOptions>({
|
||||
name: 'fileHandler',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
onUploadStart: undefined,
|
||||
onUploadSuccess: undefined,
|
||||
onUploadError: undefined
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const options = this.options
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
dragover(view, event) {
|
||||
event.preventDefault()
|
||||
view.dom.classList.add(...DRAGOVER_CLASS_LIST)
|
||||
return false
|
||||
},
|
||||
dragleave(view) {
|
||||
view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
|
||||
return false
|
||||
},
|
||||
drop(view, event) {
|
||||
event.preventDefault()
|
||||
view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
|
||||
|
||||
const items = Array.from(event.dataTransfer?.files ?? [])
|
||||
const mediaFile = items.find(
|
||||
(item) => item.type.includes('image') || item.type.includes('video')
|
||||
)
|
||||
if (!mediaFile) return false
|
||||
|
||||
uploadFile(view, mediaFile, options)
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
handlePaste(view, event) {
|
||||
const items = Array.from(event.clipboardData?.items ?? [])
|
||||
const mediaItem = items.find(
|
||||
(item) => item.type.includes('image') || item.type.includes('video')
|
||||
)
|
||||
if (!mediaItem) return false
|
||||
|
||||
const file = mediaItem.getAsFile()
|
||||
if (!file) return false
|
||||
|
||||
uploadFile(view, file, options)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
async function uploadFile(view: EditorView, file: File, options: FileHandlerOptions) {
|
||||
const name = file.name
|
||||
|
||||
options.onUploadStart?.(file)
|
||||
|
||||
const placeholder = `[Uploading "${name}"...]`
|
||||
const uploadingNode = view.state.schema.text(placeholder)
|
||||
const tr = view.state.tr.replaceSelectionWith(uploadingNode)
|
||||
view.dispatch(tr)
|
||||
|
||||
mediaUpload
|
||||
.upload(file)
|
||||
.then((result) => {
|
||||
options.onUploadSuccess?.(file, result)
|
||||
const urlNode = view.state.schema.text(result.url)
|
||||
|
||||
const tr = view.state.tr
|
||||
let didReplace = false
|
||||
|
||||
view.state.doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text && node.text.includes(placeholder) && !didReplace) {
|
||||
const startPos = node.text.indexOf(placeholder)
|
||||
const from = pos + startPos
|
||||
const to = from + placeholder.length
|
||||
tr.replaceWith(from, to, urlNode)
|
||||
didReplace = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (didReplace) {
|
||||
view.dispatch(tr)
|
||||
} else {
|
||||
const endPos = view.state.doc.content.size
|
||||
|
||||
const paragraphNode = view.state.schema.nodes.paragraph.create(
|
||||
null,
|
||||
view.state.schema.text(result.url)
|
||||
)
|
||||
|
||||
const insertTr = view.state.tr.insert(endPos, paragraphNode)
|
||||
const newPos = endPos + 1 + result.url.length
|
||||
insertTr.setSelection(TextSelection.near(insertTr.doc.resolve(newPos)))
|
||||
view.dispatch(insertTr)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Upload failed:', error)
|
||||
options.onUploadError?.(file, error)
|
||||
|
||||
const tr = view.state.tr
|
||||
let didReplace = false
|
||||
|
||||
view.state.doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text && node.text.includes(placeholder) && !didReplace) {
|
||||
const startPos = node.text.indexOf(placeholder)
|
||||
const from = pos + startPos
|
||||
const to = from + placeholder.length
|
||||
const errorNode = view.state.schema.text(`[Error uploading "${name}"]`)
|
||||
tr.replaceWith(from, to, errorNode)
|
||||
didReplace = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (didReplace) {
|
||||
view.dispatch(tr)
|
||||
}
|
||||
|
||||
throw error
|
||||
})
|
||||
}
|
||||
100
src/components/PostEditor/PostTextarea/MentionList.tsx
Normal file
100
src/components/PostEditor/PostTextarea/MentionList.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { formatNpub, userIdToPubkey } from '@/lib/pubkey'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SuggestionKeyDownProps } from '@tiptap/suggestion'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import Nip05 from '../../Nip05'
|
||||
import { SimpleUserAvatar } from '../../UserAvatar'
|
||||
import { SimpleUsername } from '../../Username'
|
||||
|
||||
export interface MentionListProps {
|
||||
items: string[]
|
||||
command: (payload: { id: string; label?: string }) => void
|
||||
}
|
||||
|
||||
export interface MentionListHandle {
|
||||
onKeyDown: (args: SuggestionKeyDownProps) => boolean
|
||||
}
|
||||
|
||||
const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>(0)
|
||||
|
||||
const selectItem = (index: number) => {
|
||||
const item = props.items[index]
|
||||
|
||||
if (item) {
|
||||
props.command({ id: item, label: formatNpub(item) })
|
||||
}
|
||||
}
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
|
||||
}
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % props.items.length)
|
||||
}
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [props.items])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
upHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
downHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
enterHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}))
|
||||
|
||||
if (props.items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
className="border rounded-lg bg-background z-50 pointer-events-auto flex flex-col max-h-80 overflow-y-auto"
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
>
|
||||
{props.items.map((item, index) => (
|
||||
<button
|
||||
className={cn(
|
||||
'cursor-pointer text-start items-center m-1 p-2 outline-none transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 rounded-md',
|
||||
selectedIndex === index && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
key={item}
|
||||
onClick={() => selectItem(index)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<div className="flex gap-2 w-80 items-center truncate pointer-events-none">
|
||||
<SimpleUserAvatar userId={item} />
|
||||
<div className="flex-1 w-0">
|
||||
<SimpleUsername userId={item} className="font-semibold truncate" />
|
||||
<Nip05 pubkey={userIdToPubkey(item)} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</ScrollArea>
|
||||
)
|
||||
})
|
||||
MentionList.displayName = 'MentionList'
|
||||
export default MentionList
|
||||
20
src/components/PostEditor/PostTextarea/MentionNode.tsx
Normal file
20
src/components/PostEditor/PostTextarea/MentionNode.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useFetchProfile } from '@/hooks'
|
||||
import { formatUserId } from '@/lib/pubkey'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { NodeViewRendererProps, NodeViewWrapper } from '@tiptap/react'
|
||||
|
||||
export default function MentionNode(props: NodeViewRendererProps & { selected: boolean }) {
|
||||
const { profile } = useFetchProfile(props.node.attrs.id)
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={cn(
|
||||
'inline text-primary bg-primary/10 rounded-md px-1 transition-colors',
|
||||
props.selected ? 'bg-primary/20' : ''
|
||||
)}
|
||||
>
|
||||
{'@'}
|
||||
{profile ? profile.username : formatUserId(props.node.attrs.id)}
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { createFakeEvent } from '@/lib/event'
|
||||
import Content from '../Content'
|
||||
import Content from '../../Content'
|
||||
|
||||
export default function Preview({ content }: { content: string }) {
|
||||
return (
|
||||
110
src/components/PostEditor/PostTextarea/index.tsx
Normal file
110
src/components/PostEditor/PostTextarea/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { parseEditorJsonToText } from '@/lib/tiptap'
|
||||
import postContentCache from '@/services/post-content-cache.service'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import History from '@tiptap/extension-history'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { Dispatch, forwardRef, SetStateAction, useImperativeHandle } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CustomMention from './CustomMention'
|
||||
import { FileHandler } from './FileHandler'
|
||||
import Preview from './Preview'
|
||||
import suggestion from './suggestion'
|
||||
import { usePostEditor } from '../PostEditorProvider'
|
||||
|
||||
export type TPostTextareaHandle = {
|
||||
appendText: (text: string) => void
|
||||
}
|
||||
|
||||
const PostTextarea = forwardRef<
|
||||
TPostTextareaHandle,
|
||||
{
|
||||
text: string
|
||||
setText: Dispatch<SetStateAction<string>>
|
||||
defaultContent?: string
|
||||
parentEvent?: Event
|
||||
}
|
||||
>(({ text = '', setText, defaultContent, parentEvent }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { setUploadingFiles } = usePostEditor()
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
History,
|
||||
Placeholder.configure({
|
||||
placeholder: t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')'
|
||||
}),
|
||||
CustomMention.configure({
|
||||
suggestion
|
||||
}),
|
||||
FileHandler.configure({
|
||||
onUploadStart: () => setUploadingFiles((prev) => prev + 1),
|
||||
onUploadSuccess: () => setUploadingFiles((prev) => prev - 1),
|
||||
onUploadError: () => setUploadingFiles((prev) => prev - 1)
|
||||
})
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
'border rounded-lg p-3 min-h-52 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'
|
||||
}
|
||||
},
|
||||
content: postContentCache.getPostCache({ defaultContent, parentEvent }),
|
||||
onUpdate(props) {
|
||||
setText(parseEditorJsonToText(props.editor.getJSON()))
|
||||
postContentCache.setPostCache({ defaultContent, parentEvent }, props.editor.getJSON())
|
||||
},
|
||||
onCreate(props) {
|
||||
setText(parseEditorJsonToText(props.editor.getJSON()))
|
||||
}
|
||||
})
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
appendText: (text: string) => {
|
||||
if (editor) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
const endPos = tr.doc.content.size
|
||||
const selection = TextSelection.create(tr.doc, endPos)
|
||||
tr.setSelection(selection)
|
||||
dispatch(tr)
|
||||
}
|
||||
return true
|
||||
})
|
||||
.insertContent(text)
|
||||
.run()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="edit" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="edit">{t('Edit')}</TabsTrigger>
|
||||
<TabsTrigger value="preview">{t('Preview')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="edit">
|
||||
<EditorContent className="tiptap" editor={editor} />
|
||||
</TabsContent>
|
||||
<TabsContent value="preview">
|
||||
<Preview content={text} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
})
|
||||
PostTextarea.displayName = 'PostTextarea'
|
||||
export default PostTextarea
|
||||
101
src/components/PostEditor/PostTextarea/suggestion.ts
Normal file
101
src/components/PostEditor/PostTextarea/suggestion.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import client from '@/services/client.service'
|
||||
import postEditor from '@/services/post-editor.service'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { ReactRenderer } from '@tiptap/react'
|
||||
import { SuggestionKeyDownProps } from '@tiptap/suggestion'
|
||||
import tippy, { GetReferenceClientRect, Instance, Props } from 'tippy.js'
|
||||
import MentionList, { MentionListHandle, MentionListProps } from './MentionList'
|
||||
|
||||
const suggestion = {
|
||||
items: async ({ query }: { query: string }) => {
|
||||
return await client.searchNpubs(query, 20)
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: ReactRenderer<MentionListHandle, MentionListProps>
|
||||
let popup: Instance[]
|
||||
let touchListener: (e: TouchEvent) => void
|
||||
let closePopup: () => void
|
||||
|
||||
return {
|
||||
onBeforeStart: () => {
|
||||
touchListener = (e: TouchEvent) => {
|
||||
if (popup && popup[0] && postEditor.isSuggestionPopupOpen) {
|
||||
const popupElement = popup[0].popper
|
||||
if (popupElement && !popupElement.contains(e.target as Node)) {
|
||||
popup[0].hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('touchstart', touchListener)
|
||||
|
||||
closePopup = () => {
|
||||
console.log('closePopup')
|
||||
if (popup && popup[0]) {
|
||||
popup[0].hide()
|
||||
}
|
||||
}
|
||||
postEditor.addEventListener('closeSuggestionPopup', closePopup)
|
||||
},
|
||||
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor
|
||||
})
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect as GetReferenceClientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
hideOnClick: true,
|
||||
touch: true,
|
||||
onShow() {
|
||||
postEditor.isSuggestionPopupOpen = true
|
||||
},
|
||||
onHide() {
|
||||
postEditor.isSuggestionPopupOpen = false
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onUpdate(props: { clientRect?: (() => DOMRect | null) | null | undefined }) {
|
||||
component.updateProps(props)
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect
|
||||
} as Partial<Props>)
|
||||
},
|
||||
|
||||
onKeyDown(props: SuggestionKeyDownProps) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide()
|
||||
return true
|
||||
}
|
||||
return component.ref?.onKeyDown(props) ?? false
|
||||
},
|
||||
|
||||
onExit() {
|
||||
postEditor.isSuggestionPopupOpen = false
|
||||
popup[0].destroy()
|
||||
component.destroy()
|
||||
|
||||
document.removeEventListener('touchstart', touchListener)
|
||||
postEditor.removeEventListener('closeSuggestionPopup', closePopup)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default suggestion
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { useMediaUploadService } from '@/providers/MediaUploadServiceProvider'
|
||||
import mediaUpload from '@/services/media-upload.service'
|
||||
import { useRef } from 'react'
|
||||
|
||||
export default function Uploader({
|
||||
@@ -16,7 +16,6 @@ export default function Uploader({
|
||||
accept?: string
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const { upload } = useMediaUploadService()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -25,7 +24,7 @@ export default function Uploader({
|
||||
onUploadingChange?.(true)
|
||||
try {
|
||||
for (const file of event.target.files) {
|
||||
const result = await upload(file)
|
||||
const result = await mediaUpload.upload(file)
|
||||
console.log('File uploaded successfully', result)
|
||||
onUploadSuccess(result)
|
||||
}
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
SheetTitle
|
||||
} from '@/components/ui/sheet'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import postEditor from '@/services/post-editor.service'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { Dispatch, useMemo } from 'react'
|
||||
import NormalPostContent from './NormalPostContent'
|
||||
import PostContent from './PostContent'
|
||||
import { PostEditorProvider } from './PostEditorProvider'
|
||||
import Title from './Title'
|
||||
|
||||
export default function PostEditor({
|
||||
@@ -34,18 +36,30 @@ export default function PostEditor({
|
||||
|
||||
const content = useMemo(() => {
|
||||
return (
|
||||
<NormalPostContent
|
||||
<PostEditorProvider>
|
||||
<PostContent
|
||||
defaultContent={defaultContent}
|
||||
parentEvent={parentEvent}
|
||||
close={() => setOpen(false)}
|
||||
/>
|
||||
</PostEditorProvider>
|
||||
)
|
||||
}, [])
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent className="h-full w-full p-0 border-none" side="bottom" hideClose>
|
||||
<SheetContent
|
||||
className="h-full w-full p-0 border-none"
|
||||
side="bottom"
|
||||
hideClose
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (postEditor.isSuggestionPopupOpen) {
|
||||
e.preventDefault()
|
||||
postEditor.closeSuggestionPopup()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ScrollArea className="px-4 h-full max-h-screen">
|
||||
<div className="space-y-4 px-2 py-6">
|
||||
<SheetHeader>
|
||||
@@ -64,7 +78,16 @@ export default function PostEditor({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="p-0 max-w-2xl" withoutClose>
|
||||
<DialogContent
|
||||
className="p-0 max-w-2xl"
|
||||
withoutClose
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (postEditor.isSuggestionPopupOpen) {
|
||||
e.preventDefault()
|
||||
postEditor.closeSuggestionPopup()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ScrollArea className="px-4 h-full max-h-screen">
|
||||
<div className="space-y-4 px-2 py-6">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
export function preprocessContent(content: string) {
|
||||
const regex = /(?<=^|\s)(nevent|naddr|nprofile|npub)[a-zA-Z0-9]+/g
|
||||
return content.replace(regex, (match) => {
|
||||
try {
|
||||
nip19.decode(match)
|
||||
return `nostr:${match}`
|
||||
} catch {
|
||||
return match
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/hooks'
|
||||
import { pubkeyToNpub } from '@/lib/pubkey'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useMediaUploadService } from '@/providers/MediaUploadServiceProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { TProfile } from '@/types'
|
||||
import React, {
|
||||
ComponentProps,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import Nip05 from '../Nip05'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
import { SimpleUsername } from '../Username'
|
||||
import { getCurrentWord, replaceWord } from './utils'
|
||||
|
||||
export default function PostTextarea({
|
||||
textValue,
|
||||
setTextValue,
|
||||
cursorOffset = 0,
|
||||
onUploadImage,
|
||||
...props
|
||||
}: ComponentProps<'textarea'> & {
|
||||
textValue: string
|
||||
setTextValue: Dispatch<SetStateAction<string>>
|
||||
cursorOffset?: number
|
||||
onUploadImage?: ({ url, tags }: { url: string; tags: string[][] }) => void
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const { upload } = useMediaUploadService()
|
||||
const [commandValue, setCommandValue] = useState('')
|
||||
const [debouncedCommandValue, setDebouncedCommandValue] = useState(commandValue)
|
||||
const [profiles, setProfiles] = useState<TProfile[]>([])
|
||||
const [dragover, setDragover] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current && cursorOffset !== 0) {
|
||||
const textarea = textareaRef.current
|
||||
const newPos = Math.max(0, textarea.selectionStart - cursorOffset)
|
||||
textarea.setSelectionRange(newPos, newPos)
|
||||
textarea.focus()
|
||||
}
|
||||
}, [cursorOffset])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedCommandValue(commandValue)
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [commandValue])
|
||||
|
||||
useEffect(() => {
|
||||
setProfiles([])
|
||||
if (debouncedCommandValue) {
|
||||
const fetchProfiles = async () => {
|
||||
const newProfiles = await client.searchProfilesFromIndex(debouncedCommandValue, 100)
|
||||
setProfiles(newProfiles)
|
||||
}
|
||||
fetchProfiles()
|
||||
}
|
||||
}, [debouncedCommandValue])
|
||||
|
||||
useEffect(() => {
|
||||
const dropdown = dropdownRef.current
|
||||
if (!dropdown) return
|
||||
|
||||
if (profiles.length > 0) {
|
||||
dropdown.classList.remove('hidden')
|
||||
} else {
|
||||
dropdown.classList.add('hidden')
|
||||
}
|
||||
}, [profiles])
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
const dropdown = dropdownRef.current
|
||||
if (dropdown) {
|
||||
dropdown.classList.add('hidden')
|
||||
setCommandValue('')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
const textarea = textareaRef.current
|
||||
const input = inputRef.current
|
||||
const dropdown = dropdownRef.current
|
||||
if (textarea && input && dropdown) {
|
||||
const currentWord = getCurrentWord(textarea)
|
||||
const isDropdownHidden = dropdown.classList.contains('hidden')
|
||||
if (currentWord.startsWith('@') && !isDropdownHidden) {
|
||||
if (
|
||||
e.key === 'ArrowUp' ||
|
||||
e.key === 'ArrowDown' ||
|
||||
e.key === 'Enter' ||
|
||||
e.key === 'Escape'
|
||||
) {
|
||||
e.preventDefault()
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onTextValueChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = e.target.value
|
||||
const textarea = textareaRef.current
|
||||
const dropdown = dropdownRef.current
|
||||
|
||||
if (textarea && dropdown) {
|
||||
const currentWord = getCurrentWord(textarea)
|
||||
setTextValue(text)
|
||||
if (currentWord.startsWith('@') && currentWord.length > 1) {
|
||||
setCommandValue(currentWord.slice(1))
|
||||
} else {
|
||||
// REMINDER: apparently, we need it when deleting
|
||||
if (commandValue !== '') {
|
||||
setCommandValue('')
|
||||
dropdown.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[setTextValue, commandValue]
|
||||
)
|
||||
|
||||
const onCommandSelect = useCallback((value: string) => {
|
||||
const textarea = textareaRef.current
|
||||
const dropdown = dropdownRef.current
|
||||
if (textarea && dropdown) {
|
||||
replaceWord(textarea, `${value} `)
|
||||
setCommandValue('')
|
||||
dropdown.classList.add('hidden')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleMouseDown = useCallback((e: Event) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleSectionChange = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
const dropdown = dropdownRef.current
|
||||
if (textarea && dropdown) {
|
||||
const currentWord = getCurrentWord(textarea)
|
||||
if (!currentWord.startsWith('@') && commandValue !== '') {
|
||||
setCommandValue('')
|
||||
dropdown.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
}, [commandValue])
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current
|
||||
const dropdown = dropdownRef.current
|
||||
textarea?.addEventListener('keydown', handleKeyDown)
|
||||
textarea?.addEventListener('blur', handleBlur)
|
||||
document?.addEventListener('selectionchange', handleSectionChange)
|
||||
dropdown?.addEventListener('mousedown', handleMouseDown)
|
||||
return () => {
|
||||
textarea?.removeEventListener('keydown', handleKeyDown)
|
||||
textarea?.removeEventListener('blur', handleBlur)
|
||||
document?.removeEventListener('selectionchange', handleSectionChange)
|
||||
dropdown?.removeEventListener('mousedown', handleMouseDown)
|
||||
}
|
||||
}, [handleBlur, handleKeyDown, handleMouseDown, handleSectionChange])
|
||||
|
||||
const uploadImages = async (files: File[]) => {
|
||||
for (const file of files) {
|
||||
if (file.type.startsWith('image/') || file.type.startsWith('video/')) {
|
||||
const placeholder = `[Uploading "${file.name}"...]`
|
||||
if (textValue.includes(placeholder)) {
|
||||
continue
|
||||
}
|
||||
setTextValue((prev) => (prev === '' ? placeholder : `${prev}\n${placeholder}`))
|
||||
try {
|
||||
const result = await upload(file)
|
||||
setTextValue((prev) => {
|
||||
if (prev.includes(placeholder)) {
|
||||
return prev.replace(placeholder, result.url)
|
||||
} else {
|
||||
return prev + `\n${result.url}`
|
||||
}
|
||||
})
|
||||
onUploadImage?.(result)
|
||||
} catch (error) {
|
||||
console.error('Error uploading file', error)
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to upload file',
|
||||
description: (error as Error).message
|
||||
})
|
||||
setTextValue((prev) => prev.replace(placeholder, ''))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePast = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
await uploadImages(
|
||||
Array.from(e.clipboardData.items)
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean) as File[]
|
||||
)
|
||||
}
|
||||
|
||||
const handleDrop = async (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault()
|
||||
setDragover(false)
|
||||
await uploadImages(Array.from(e.dataTransfer.files))
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full',
|
||||
dragover && 'outline-2 outline-offset-4 outline-dashed outline-border rounded-md'
|
||||
)}
|
||||
>
|
||||
<Textarea
|
||||
{...props}
|
||||
ref={textareaRef}
|
||||
value={textValue}
|
||||
onChange={onTextValueChange}
|
||||
onPaste={handlePast}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setDragover(true)
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setDragover(false)
|
||||
}}
|
||||
/>
|
||||
<Command
|
||||
ref={dropdownRef}
|
||||
className={cn(
|
||||
'sm:fixed hidden translate-y-2 h-auto w-full sm:w-[462px] z-10 border border-popover shadow'
|
||||
)}
|
||||
shouldFilter={false}
|
||||
>
|
||||
<div className="hidden">
|
||||
<CommandInput ref={inputRef} value={commandValue} />
|
||||
</div>
|
||||
<CommandList scrollAreaClassName="h-44">
|
||||
{profiles.map((p) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={p.pubkey}
|
||||
value={`nostr:${pubkeyToNpub(p.pubkey)}`}
|
||||
onSelect={onCommandSelect}
|
||||
>
|
||||
<div className="flex gap-2 items-center pointer-events-none truncate">
|
||||
<SimpleUserAvatar userId={p.pubkey} />
|
||||
<div>
|
||||
<SimpleUsername userId={p.pubkey} className="font-semibold truncate" />
|
||||
<Nip05 pubkey={p.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
export function getCaretPosition(element: HTMLTextAreaElement) {
|
||||
return {
|
||||
caretStartIndex: element.selectionStart || 0,
|
||||
caretEndIndex: element.selectionEnd || 0
|
||||
}
|
||||
}
|
||||
|
||||
export function getCurrentWord(element: HTMLTextAreaElement) {
|
||||
const text = element.value
|
||||
const { caretStartIndex } = getCaretPosition(element)
|
||||
|
||||
// Find the start position of the word
|
||||
let start = caretStartIndex
|
||||
while (start > 0 && text[start - 1].match(/\S/)) {
|
||||
start--
|
||||
}
|
||||
|
||||
// Find the end position of the word
|
||||
let end = caretStartIndex
|
||||
while (end < text.length && text[end].match(/\S/)) {
|
||||
end++
|
||||
}
|
||||
|
||||
const w = text.substring(start, end)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
export function replaceWord(element: HTMLTextAreaElement, value: string) {
|
||||
const text = element.value
|
||||
const caretPos = element.selectionStart
|
||||
|
||||
// Find the word that needs to be replaced
|
||||
const wordRegex = /[\w@#]+/g
|
||||
let match
|
||||
let startIndex
|
||||
let endIndex
|
||||
|
||||
while ((match = wordRegex.exec(text)) !== null) {
|
||||
startIndex = match.index
|
||||
endIndex = startIndex + match[0].length
|
||||
|
||||
if (caretPos >= startIndex && caretPos <= endIndex) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the word with a new word using document.execCommand
|
||||
if (startIndex !== undefined && endIndex !== undefined) {
|
||||
// Preserve the current selection range
|
||||
const selectionStart = element.selectionStart
|
||||
const selectionEnd = element.selectionEnd
|
||||
|
||||
// Modify the selected range to encompass the word to be replaced
|
||||
element.setSelectionRange(startIndex, endIndex)
|
||||
|
||||
// REMINDER: Fastest way to include CMD + Z compatibility
|
||||
// Execute the command to replace the selected text with the new word
|
||||
document.execCommand('insertText', false, value)
|
||||
|
||||
// Restore the original selection range
|
||||
element.setSelectionRange(
|
||||
selectionStart - (endIndex - startIndex) + value.length,
|
||||
selectionEnd - (endIndex - startIndex) + value.length
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
color: hsl(var(--muted-foreground));
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.clickable:hover {
|
||||
background-color: hsl(var(--muted) / 0.5);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApplicationDataKey, ExtendedKind } from '@/constants'
|
||||
import client from '@/services/client.service'
|
||||
import mediaUpload from '@/services/media-upload.service'
|
||||
import { TDraftEvent, TEmoji, TMailboxRelay, TRelaySet } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
@@ -61,7 +62,6 @@ export function createRepostDraftEvent(event: Event): TDraftEvent {
|
||||
|
||||
export async function createShortTextNoteDraftEvent(
|
||||
content: string,
|
||||
pictureInfos: { url: string; tags: string[][] }[],
|
||||
mentions: string[],
|
||||
options: {
|
||||
parentEvent?: Event
|
||||
@@ -80,7 +80,7 @@ export async function createShortTextNoteDraftEvent(
|
||||
// imeta tags
|
||||
const { images } = extractImagesFromContent(content)
|
||||
if (images && images.length) {
|
||||
tags.push(...generateImetaTags(images, pictureInfos))
|
||||
tags.push(...generateImetaTags(images))
|
||||
}
|
||||
|
||||
// q tags
|
||||
@@ -168,7 +168,6 @@ export async function createPictureNoteDraftEvent(
|
||||
export async function createCommentDraftEvent(
|
||||
content: string,
|
||||
parentEvent: Event,
|
||||
pictureInfos: { url: string; tags: string[][] }[],
|
||||
mentions: string[],
|
||||
options: {
|
||||
addClientTag?: boolean
|
||||
@@ -192,7 +191,7 @@ export async function createCommentDraftEvent(
|
||||
|
||||
const { images } = extractImagesFromContent(content)
|
||||
if (images && images.length) {
|
||||
tags.push(...generateImetaTags(images, pictureInfos))
|
||||
tags.push(...generateImetaTags(images))
|
||||
}
|
||||
|
||||
tags.push(
|
||||
@@ -300,11 +299,11 @@ export function createBookmarkDraftEvent(tags: string[][], content = ''): TDraft
|
||||
}
|
||||
}
|
||||
|
||||
function generateImetaTags(imageUrls: string[], pictureInfos: { url: string; tags: string[][] }[]) {
|
||||
function generateImetaTags(imageUrls: string[]) {
|
||||
return imageUrls
|
||||
.map((imageUrl) => {
|
||||
const pictureInfo = pictureInfos.find((info) => info.url === imageUrl)
|
||||
return pictureInfo ? ['imeta', ...pictureInfo.tags.map(([n, v]) => `${n} ${v}`)] : null
|
||||
const tag = mediaUpload.getImetaTagByUrl(imageUrl)
|
||||
return tag ?? null
|
||||
})
|
||||
.filter(Boolean) as string[][]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TEmoji, TImageInfo, TRelayList, TRelaySet } from '@/types'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { Event, kinds, nip19 } from 'nostr-tools'
|
||||
import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning'
|
||||
import { formatPubkey } from './pubkey'
|
||||
import { formatPubkey, pubkeyToNpub } from './pubkey'
|
||||
import {
|
||||
extractImageInfoFromTag,
|
||||
generateEventIdFromETag,
|
||||
@@ -180,6 +180,7 @@ export function getProfileFromProfileEvent(event: Event) {
|
||||
profileObj.nip05?.split('@')[0]?.trim()
|
||||
return {
|
||||
pubkey: event.pubkey,
|
||||
npub: pubkeyToNpub(event.pubkey) ?? '',
|
||||
banner: profileObj.banner,
|
||||
avatar: profileObj.picture,
|
||||
username: username || formatPubkey(event.pubkey),
|
||||
@@ -196,6 +197,7 @@ export function getProfileFromProfileEvent(event: Event) {
|
||||
console.error(err)
|
||||
return {
|
||||
pubkey: event.pubkey,
|
||||
npub: pubkeyToNpub(event.pubkey) ?? '',
|
||||
username: formatPubkey(event.pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ export function formatNpub(npub: string, length = 12) {
|
||||
return npub.slice(0, prefixLength) + '...' + npub.slice(-suffixLength)
|
||||
}
|
||||
|
||||
export function formatUserId(userId: string) {
|
||||
if (userId.startsWith('npub1')) {
|
||||
return formatNpub(userId)
|
||||
}
|
||||
return formatPubkey(userId)
|
||||
}
|
||||
|
||||
export function pubkeyToNpub(pubkey: string) {
|
||||
try {
|
||||
return nip19.npubEncode(pubkey)
|
||||
|
||||
40
src/lib/tiptap.ts
Normal file
40
src/lib/tiptap.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { JSONContent } from '@tiptap/react'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
export function parseEditorJsonToText(node?: JSONContent) {
|
||||
const text = _parseEditorJsonToText(node).trim()
|
||||
const regex = /(?<=^|\s)(nevent|naddr|nprofile|npub)[a-zA-Z0-9]+/g
|
||||
return text.replace(regex, (match) => {
|
||||
try {
|
||||
nip19.decode(match)
|
||||
return `nostr:${match}`
|
||||
} catch {
|
||||
return match
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function _parseEditorJsonToText(node?: JSONContent): string {
|
||||
if (!node) return ''
|
||||
|
||||
if (typeof node === 'string') return node
|
||||
|
||||
if (node.type === 'text') {
|
||||
return node.text || ''
|
||||
}
|
||||
|
||||
if (Array.isArray(node.content)) {
|
||||
return (
|
||||
node.content.map(_parseEditorJsonToText).join('') + (node.type === 'paragraph' ? '\n' : '')
|
||||
)
|
||||
}
|
||||
|
||||
switch (node.type) {
|
||||
case 'paragraph':
|
||||
return '\n'
|
||||
case 'mention':
|
||||
return node.attrs ? `nostr:${node.attrs.id}` : ''
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
import { simplifyUrl } from '@/lib/url'
|
||||
import storage from '@/services/local-storage.service'
|
||||
import mediaUpload from '@/services/media-upload.service'
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useNostr } from './NostrProvider'
|
||||
|
||||
type TMediaUploadServiceContext = {
|
||||
service: string
|
||||
updateService: (service: string) => void
|
||||
upload: (file: File) => Promise<{ url: string; tags: string[][] }>
|
||||
}
|
||||
|
||||
const MediaUploadServiceContext = createContext<TMediaUploadServiceContext | undefined>(undefined)
|
||||
@@ -20,64 +16,16 @@ export const useMediaUploadService = () => {
|
||||
return context
|
||||
}
|
||||
|
||||
const ServiceUploadUrlMap = new Map<string, string | undefined>()
|
||||
|
||||
export function MediaUploadServiceProvider({ children }: { children: React.ReactNode }) {
|
||||
const { signHttpAuth } = useNostr()
|
||||
const [service, setService] = useState(storage.getMediaUploadService())
|
||||
const [service, setService] = useState(mediaUpload.getService())
|
||||
|
||||
const updateService = (newService: string) => {
|
||||
setService(newService)
|
||||
storage.setMediaUploadService(newService)
|
||||
}
|
||||
|
||||
const upload = async (file: File) => {
|
||||
let uploadUrl = ServiceUploadUrlMap.get(service)
|
||||
if (!uploadUrl) {
|
||||
const response = await fetch(`${service}/.well-known/nostr/nip96.json`)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`${simplifyUrl(service)} does not work, please try another service in your settings`
|
||||
)
|
||||
}
|
||||
const data = await response.json()
|
||||
uploadUrl = data?.api_url
|
||||
if (!uploadUrl) {
|
||||
throw new Error(
|
||||
`${simplifyUrl(service)} does not work, please try another service in your settings`
|
||||
)
|
||||
}
|
||||
ServiceUploadUrlMap.set(service, uploadUrl)
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const auth = await signHttpAuth(uploadUrl, 'POST')
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
Authorization: auth
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.status.toString() + ' ' + response.statusText)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const tags = z.array(z.array(z.string())).parse(data.nip94_event?.tags ?? [])
|
||||
const imageUrl = tags.find(([tagName]) => tagName === 'url')?.[1]
|
||||
if (imageUrl) {
|
||||
return { url: imageUrl, tags }
|
||||
} else {
|
||||
throw new Error('No image url found')
|
||||
}
|
||||
mediaUpload.setService(newService)
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaUploadServiceContext.Provider value={{ service, updateService, upload }}>
|
||||
<MediaUploadServiceContext.Provider value={{ service, updateService }}>
|
||||
{children}
|
||||
</MediaUploadServiceContext.Provider>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
getRelayListFromRelayListEvent,
|
||||
getReplaceableEventIdentifier
|
||||
} from '@/lib/event'
|
||||
import { formatPubkey, isValidPubkey } from '@/lib/pubkey'
|
||||
import { formatPubkey, isValidPubkey, pubkeyToNpub } from '@/lib/pubkey'
|
||||
import client from '@/services/client.service'
|
||||
import indexedDb from '@/services/indexed-db.service'
|
||||
import storage from '@/services/local-storage.service'
|
||||
@@ -238,6 +238,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
} else if (!storedProfileEvent) {
|
||||
setProfile({
|
||||
pubkey: account.pubkey,
|
||||
npub: pubkeyToNpub(account.pubkey) ?? '',
|
||||
username: formatPubkey(account.pubkey)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||
import { getProfileFromProfileEvent, getRelayListFromRelayListEvent } from '@/lib/event'
|
||||
import { formatPubkey, userIdToPubkey } from '@/lib/pubkey'
|
||||
import { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
|
||||
import { extractPubkeysFromEventTags } from '@/lib/tag'
|
||||
import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
||||
import { ISigner, TProfile, TRelayList } from '@/types'
|
||||
@@ -693,7 +693,7 @@ class ClientService extends EventTarget {
|
||||
|
||||
try {
|
||||
const pubkey = userIdToPubkey(id)
|
||||
return { pubkey, username: formatPubkey(pubkey) }
|
||||
return { pubkey, npub: pubkeyToNpub(pubkey) ?? '', username: formatPubkey(pubkey) }
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
@@ -833,11 +833,9 @@ class ClientService extends EventTarget {
|
||||
this.relayListEventDataLoader.prime(event.pubkey, Promise.resolve(event))
|
||||
}
|
||||
|
||||
async searchProfilesFromIndex(query: string, limit: number = 100) {
|
||||
async searchNpubs(query: string, limit: number = 100) {
|
||||
const result = await this.userIndex.searchAsync(query, { limit })
|
||||
return Promise.all(result.map((pubkey) => this.fetchProfile(pubkey as string))).then(
|
||||
(profiles) => profiles.filter(Boolean) as TProfile[]
|
||||
)
|
||||
return result.map((pubkey) => pubkeyToNpub(pubkey as string)).filter(Boolean) as string[]
|
||||
}
|
||||
|
||||
async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) {
|
||||
|
||||
99
src/services/media-upload.service.ts
Normal file
99
src/services/media-upload.service.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { simplifyUrl } from '@/lib/url'
|
||||
import dayjs from 'dayjs'
|
||||
import { kinds } from 'nostr-tools'
|
||||
import { z } from 'zod'
|
||||
import client from './client.service'
|
||||
import storage from './local-storage.service'
|
||||
|
||||
class MediaUploadService {
|
||||
static instance: MediaUploadService
|
||||
|
||||
private service: string = storage.getMediaUploadService()
|
||||
private serviceUploadUrlMap = new Map<string, string | undefined>()
|
||||
private imetaTagMap = new Map<string, string[]>()
|
||||
|
||||
constructor() {
|
||||
if (!MediaUploadService.instance) {
|
||||
MediaUploadService.instance = this
|
||||
}
|
||||
return MediaUploadService.instance
|
||||
}
|
||||
|
||||
getService() {
|
||||
return this.service
|
||||
}
|
||||
|
||||
setService(service: string) {
|
||||
this.service = service
|
||||
storage.setMediaUploadService(service)
|
||||
}
|
||||
|
||||
async upload(file: File) {
|
||||
let uploadUrl = this.serviceUploadUrlMap.get(this.service)
|
||||
if (!uploadUrl) {
|
||||
const response = await fetch(`${this.service}/.well-known/nostr/nip96.json`)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`${simplifyUrl(this.service)} does not work, please try another service in your settings`
|
||||
)
|
||||
}
|
||||
const data = await response.json()
|
||||
uploadUrl = data?.api_url
|
||||
if (!uploadUrl) {
|
||||
throw new Error(
|
||||
`${simplifyUrl(this.service)} does not work, please try another service in your settings`
|
||||
)
|
||||
}
|
||||
this.serviceUploadUrlMap.set(this.service, uploadUrl)
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const auth = await this.signHttpAuth(uploadUrl, 'POST')
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
Authorization: auth
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.status.toString() + ' ' + response.statusText)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const tags = z.array(z.array(z.string())).parse(data.nip94_event?.tags ?? [])
|
||||
const url = tags.find(([tagName]) => tagName === 'url')?.[1]
|
||||
if (url) {
|
||||
this.imetaTagMap.set(url, ['imeta', ...tags.map(([n, v]) => `${n} ${v}`)])
|
||||
return { url: url, tags }
|
||||
} else {
|
||||
throw new Error('No url found')
|
||||
}
|
||||
}
|
||||
|
||||
getImetaTagByUrl(url: string) {
|
||||
return this.imetaTagMap.get(url)
|
||||
}
|
||||
|
||||
async signHttpAuth(url: string, method: string) {
|
||||
if (!client.signer) {
|
||||
throw new Error('No signer found')
|
||||
}
|
||||
const event = await client.signer.signEvent({
|
||||
content: '',
|
||||
kind: kinds.HTTPAuth,
|
||||
created_at: dayjs().unix(),
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', method]
|
||||
]
|
||||
})
|
||||
return 'Nostr ' + btoa(JSON.stringify(event))
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new MediaUploadService()
|
||||
export default instance
|
||||
@@ -1,19 +1,10 @@
|
||||
import { Content } from '@tiptap/react'
|
||||
import { Event } from 'nostr-tools'
|
||||
|
||||
class PostContentCacheService {
|
||||
static instance: PostContentCacheService
|
||||
|
||||
private normalPostCache: Map<
|
||||
string,
|
||||
{
|
||||
content: string
|
||||
pictureInfos: { url: string; tags: string[][] }[]
|
||||
}
|
||||
> = new Map()
|
||||
private picturePostCache: {
|
||||
content: string
|
||||
pictureInfos: { url: string; tags: string[][] }[]
|
||||
} = { content: '', pictureInfos: [] }
|
||||
private normalPostCache: Map<string, Content> = new Map()
|
||||
|
||||
constructor() {
|
||||
if (!PostContentCacheService.instance) {
|
||||
@@ -22,35 +13,30 @@ class PostContentCacheService {
|
||||
return PostContentCacheService.instance
|
||||
}
|
||||
|
||||
getNormalPostCache({
|
||||
getPostCache({
|
||||
defaultContent,
|
||||
parentEvent
|
||||
}: { defaultContent?: string; parentEvent?: Event } = {}) {
|
||||
return (
|
||||
this.normalPostCache.get(this.generateCacheKey(defaultContent, parentEvent)) ?? {
|
||||
content: defaultContent,
|
||||
pictureInfos: [] as { url: string; tags: string[][] }[]
|
||||
}
|
||||
this.normalPostCache.get(this.generateCacheKey(defaultContent, parentEvent)) ?? defaultContent
|
||||
)
|
||||
}
|
||||
|
||||
setNormalPostCache(
|
||||
setPostCache(
|
||||
{ defaultContent, parentEvent }: { defaultContent?: string; parentEvent?: Event },
|
||||
content: string,
|
||||
pictureInfos: { url: string; tags: string[][] }[]
|
||||
content: Content
|
||||
) {
|
||||
this.normalPostCache.set(this.generateCacheKey(defaultContent, parentEvent), {
|
||||
content,
|
||||
pictureInfos
|
||||
})
|
||||
this.normalPostCache.set(this.generateCacheKey(defaultContent, parentEvent), content)
|
||||
}
|
||||
|
||||
getPicturePostCache() {
|
||||
return this.picturePostCache
|
||||
}
|
||||
|
||||
setPicturePostCache(content: string, pictureInfos: { url: string; tags: string[][] }[]) {
|
||||
this.picturePostCache = { content, pictureInfos }
|
||||
clearPostCache({
|
||||
defaultContent,
|
||||
parentEvent
|
||||
}: {
|
||||
defaultContent?: string
|
||||
parentEvent?: Event
|
||||
}) {
|
||||
this.normalPostCache.delete(this.generateCacheKey(defaultContent, parentEvent))
|
||||
}
|
||||
|
||||
generateCacheKey(defaultContent: string = '', parentEvent?: Event): string {
|
||||
|
||||
23
src/services/post-editor.service.ts
Normal file
23
src/services/post-editor.service.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
class PostEditorService extends EventTarget {
|
||||
static instance: PostEditorService
|
||||
|
||||
isSuggestionPopupOpen = false
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
if (!PostEditorService.instance) {
|
||||
PostEditorService.instance = this
|
||||
}
|
||||
return PostEditorService.instance
|
||||
}
|
||||
|
||||
closeSuggestionPopup() {
|
||||
if (this.isSuggestionPopupOpen) {
|
||||
this.isSuggestionPopupOpen = false
|
||||
this.dispatchEvent(new CustomEvent('closeSuggestionPopup'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new PostEditorService()
|
||||
export default instance
|
||||
@@ -3,6 +3,7 @@ import { Event, VerifiedEvent } from 'nostr-tools'
|
||||
export type TProfile = {
|
||||
username: string
|
||||
pubkey: string
|
||||
npub: string
|
||||
original_username?: string
|
||||
banner?: string
|
||||
avatar?: string
|
||||
|
||||
Reference in New Issue
Block a user